@sleep2agi/commhub-server 0.9.0-preview.100 → 0.9.0-preview.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,9 @@
9
9
  // the whole-table one is driven by the covering idx_tasks_created_queue (no task-row lookups), the alias list
10
10
  // and the per-send count by idx_tasks_to_created;
11
11
  // 2. the cost, relative only (no absolute ms, so a slow runner can't fail it): the queue_depth statement is at most
12
- // half the #2325 statement measured on the same DB in the same loop, and a full /api/status read (cache
13
- // bypassed) with queue_depth is at most 50 % slower than the same read with it switched off (= the pre-#2325
14
- // read), alternated request by request on the same server;
12
+ // half the #2325 statement measured on the same DB in the same loop; and, on the low percentile of alternated
13
+ // samples (#517), the statement costs at most half a full /api/status read (cache bypassed) without it, and that
14
+ // read with queue_depth is at most 50 % slower than with it switched off (= the pre-#2325 read);
15
15
  // 3. the counts are still right at that scale (stale `acked` rows are outside the 24 h window).
16
16
  //
17
17
  // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-queue-ahead-scale.test.ts
@@ -35,6 +35,8 @@ const RECENT = 1_900; // created in the last 24 h
35
35
  const OPEN_EVERY = 30; // 3 in every OPEN_EVERY recent tasks are still open (~190 of 1.9k; see the cost test)
36
36
  const HOT = "scale-node-7"; // most stuck rows land on this one (the per-send plan must not care)
37
37
  const N = 25;
38
+ /** #517: A/B pairs per pass, the percentile compared, and how many extra passes a failing pass gets. */
39
+ const AB_PAIRS = 60, LOW_P = 0.2, RETRIES = 2;
38
40
  /** The #2325 statement (bare columns): the in-test baseline the fixed query is measured against. */
39
41
  const SQL_2325 = `SELECT network_id, to_name, COUNT(*) AS n FROM tasks
40
42
  WHERE status IN ('created', 'delivered', 'acked', 'running') AND created_at >= datetime('now', '-86400 seconds')
@@ -170,34 +172,56 @@ describe.skipIf(PG)("#500 queue_depth at production scale (57k tasks, 31k stuck
170
172
  s0 = performance.now(); db.all(SQL_2325); const b = performance.now() - s0;
171
173
  if (i >= 3) { fixed.push(a); old.push(b); }
172
174
  }
173
- // Full /api/status, cache bypassed, A/B request by request: queue_depth on vs off (off = the pre-#2325 read).
174
- // Alternating (and flipping the order every iteration) keeps both arms in the same time window — the first
175
- // version of this budget timed the statement and the read in separate loops and compared them, which a busy
176
- // runner (test638 runs two aggregates at once) skews: CI saw 2.10 ms vs a 5.25 ms read and failed.
175
+ // Measured locally (this fixture, covering index): ≈ 0.15× (1.0 vs 6.5 ms); on the production copy 0.3 vs 55 ms.
176
+ const qP50 = pct(fixed, 0.5), oldP50 = pct(old, 0.5);
177
+ console.log(`[perf] queue_depth p50=${qP50.toFixed(2)}ms vs #2325 statement p50=${oldP50.toFixed(2)}ms`);
178
+ expect(qP50).toBeLessThanOrEqual(0.5 * oldP50);
179
+
180
+ // Second budget: queue_depth adds at most +50 % to a full /api/status read (cache bypassed) — the same read
181
+ // with it switched off is the pre-#2325 read. #517: the first version compared the p50 of `with` and `without`
182
+ // on a ~4 ms read and asserted the *difference*; under test638's concurrent aggregates a difference of two
183
+ // noisy p50s is mostly noise (CI #2355: statement 0.64 ms, yet with−without = 2.4 ms > 2.03 ms budget). Now:
184
+ // - each pass alternates A/B request by request (order flipped every pair) and also times the bare
185
+ // queue_depth statement between the reads, so all three series share one time window;
186
+ // - it compares a low percentile (LOW_P) rather than the p50: scheduler noise only ever adds time, so the
187
+ // lower tail is the closest a busy runner gets to the real cost;
188
+ // - it asserts both what the comment claims — the statement's own cost ≤ 50 % of the read without it — and
189
+ // the end-to-end with/without delta on the same low percentile;
190
+ // - a failing pass is re-measured up to RETRIES more times; the real regression (the #2325 statement through
191
+ // idx_tasks_status, 5–8× the fixed cost) fails every pass, noise does not.
177
192
  const cache = await import("./status-read-cache.js");
178
193
  cache.__setStatusCacheBypassForTest(true);
179
- const on: number[] = [], off: number[] = [];
180
194
  const readOnce = async (depthOn: boolean) => {
181
195
  mod.__setQueueDepthOffForTest(!depthOn);
182
196
  const s0 = performance.now();
183
197
  await (await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { Authorization: `Bearer ${token}` } })).text();
184
198
  return performance.now() - s0;
185
199
  };
186
- try {
187
- for (let i = 0; i < 2 * N + 3; i++) {
200
+ const abPass = async () => {
201
+ const on: number[] = [], off: number[] = [], q: number[] = [];
202
+ for (let i = 0; i < AB_PAIRS + 3; i++) {
188
203
  const first = i % 2 === 0;
189
204
  const a = await readOnce(first), b = await readOnce(!first);
190
- if (i >= 3) { (first ? on : off).push(a); (first ? off : on).push(b); }
205
+ mod.__setQueueDepthOffForTest(false);
206
+ const s0 = performance.now(); mod.queueDepthByNode(); const c = performance.now() - s0;
207
+ if (i >= 3) { (first ? on : off).push(a); (first ? off : on).push(b); q.push(c); }
208
+ }
209
+ const onLow = pct(on, LOW_P), offLow = pct(off, LOW_P), qLow = pct(q, LOW_P);
210
+ return { onLow, offLow, qLow, onP50: pct(on, 0.5), offP50: pct(off, 0.5),
211
+ okDirect: qLow <= 0.5 * offLow, okRead: onLow - offLow <= 0.5 * offLow };
212
+ };
213
+ let r: Awaited<ReturnType<typeof abPass>> | null = null;
214
+ try {
215
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
216
+ r = await abPass();
217
+ console.log(`[perf] pass ${attempt + 1}: p${LOW_P * 100} queue_depth=${r.qLow.toFixed(2)}ms; full /api/status (bypass) p${LOW_P * 100} with=${r.onLow.toFixed(2)}ms without=${r.offLow.toFixed(2)}ms (+${(100 * (r.onLow - r.offLow) / r.offLow).toFixed(0)}%); p50 with=${r.onP50.toFixed(2)}ms without=${r.offP50.toFixed(2)}ms; direct ${r.okDirect ? "ok" : "OVER"} read ${r.okRead ? "ok" : "OVER"}`);
218
+ if (r.okDirect && r.okRead) break;
191
219
  }
192
220
  } finally {
193
221
  mod.__setQueueDepthOffForTest(false);
194
222
  cache.__setStatusCacheBypassForTest(false);
195
223
  }
196
- const qP50 = pct(fixed, 0.5), oldP50 = pct(old, 0.5), onP50 = pct(on, 0.5), offP50 = pct(off, 0.5);
197
- console.log(`[perf] queue_depth p50=${qP50.toFixed(2)}ms vs #2325 statement p50=${oldP50.toFixed(2)}ms; full /api/status (bypass) p50 with=${onP50.toFixed(2)}ms without=${offP50.toFixed(2)}ms (+${(100 * (onP50 - offP50) / offP50).toFixed(0)}%) p90 with=${pct(on, 0.9).toFixed(2)}ms`);
198
- // Measured locally (this fixture, covering index): ≈ 0.15× (1.0 vs 6.5 ms); on the production copy 0.3 vs 55 ms.
199
- expect(qP50).toBeLessThanOrEqual(0.5 * oldP50);
200
- // Why the budget is "vs the same read without queue_depth", and what it was measured at (2026-10-04):
224
+ // What the read budget was measured at (2026-10-04, p50s, before #517):
201
225
  // production copy (VACUUM INTO of the live DB: 306 sessions, 57k tasks, 64 open in the last 24 h on 4 nodes),
202
226
  // full /api/status p50, cache bypassed, 3 runs each —
203
227
  // pre-#2325 10.5–11.3 ms · #2325 68–71 ms · first fix (idx_tasks_created + row lookups) 16.1–17.1 ms (+50 %,
@@ -205,7 +229,8 @@ describe.skipIf(PG)("#500 queue_depth at production scale (57k tasks, 31k stuck
205
229
  // this fixture: +17–31 %. It is harsher than production: ~190 open tasks in the window spread over ~150 nodes
206
230
  // (3× production's open count, ~38× its groups) and one-word session rows that make the rest of the read cheap.
207
231
  // With the original 1-in-7 open share (813 open) it measured +55–62 % even with the covering index — the
208
- // GROUP BY over 300 groups, not the index. CI's 67 % (before the covering index) is consistent with that plus the separate-loop skew above.
209
- expect(onP50 - offP50).toBeLessThanOrEqual(0.5 * offP50);
210
- }, 60_000);
232
+ // GROUP BY over 300 groups, not the index.
233
+ expect(r!.qLow).toBeLessThanOrEqual(0.5 * r!.offLow);
234
+ expect(r!.onLow - r!.offLow).toBeLessThanOrEqual(0.5 * r!.offLow);
235
+ }, 120_000);
211
236
  });
@@ -0,0 +1,160 @@
1
+ // #552 — every rejected REST POST /api/task leaves exactly one hub log line.
2
+ //
3
+ // Incident: a restricted member's app sends were refused and the hub log
4
+ // showed nothing, so "never arrived" and "rejected" were indistinguishable.
5
+ // Real Bun.serve on a private port + temp DB; console.log is captured around
6
+ // each request. The line must carry status + error + user + requested
7
+ // alias/network/client_request_id, and must NOT carry the bearer token or the
8
+ // task text.
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register } from "./auth.js";
15
+ import { db } from "./db.js";
16
+ import { formatTaskRejection } from "./task-rejection-log.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-task-reject-log-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "RejectLogPassw0rd!x";
22
+ let adminToken = "";
23
+ let adminName = "";
24
+ let NET = "";
25
+ let aliceToken = "";
26
+ const AGENT = { alias: "rejlog-agent", node: "node_rejlog" };
27
+ const SECRET_TEXT = "TOP-SECRET-TASK-BODY-552";
28
+
29
+ async function postTask(token: string | null, body: string): Promise<{ status: number; json: any; lines: string[] }> {
30
+ const lines: string[] = [];
31
+ const orig = console.log;
32
+ console.log = (...args: any[]) => { lines.push(args.map(String).join(" ")); };
33
+ try {
34
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
35
+ if (token) headers.Authorization = `Bearer ${token}`;
36
+ const res = await fetch(`${BASE}/api/task`, { method: "POST", headers, body });
37
+ const text = await res.text();
38
+ let json: any = text;
39
+ try { json = JSON.parse(text); } catch {}
40
+ return { status: res.status, json, lines };
41
+ } finally {
42
+ console.log = orig;
43
+ }
44
+ }
45
+ const rejectedLines = (lines: string[]) => lines.filter(l => l.includes("→ /api/task →") && l.includes("REJECTED"));
46
+
47
+ beforeAll(async () => {
48
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
49
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
50
+ process.env.HOST = "127.0.0.1";
51
+
52
+ const admin = register(`rejlog_admin_${Date.now()}`, PW, undefined, "Admin");
53
+ expect(admin.ok).toBe(true);
54
+ adminToken = admin.token!;
55
+ adminName = admin.user!.username;
56
+ NET = admin.network_id!;
57
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.user!.user_id]);
58
+ db.run(
59
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
60
+ [AGENT.node, AGENT.alias, NET, admin.user!.user_id],
61
+ );
62
+ db.run(
63
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
64
+ VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
65
+ [`resume_${AGENT.node}`, AGENT.alias, AGENT.node, NET],
66
+ );
67
+
68
+ const mod: any = await import("./server.js");
69
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
70
+ BASE = `http://127.0.0.1:${hub.port}`;
71
+
72
+ // A restricted member (default agent_access='granted', zero grants) who also
73
+ // owns a personal network → two memberships, like the #552 app user.
74
+ const created = await fetch(`${BASE}/api/admin/users`, {
75
+ method: "POST",
76
+ headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
77
+ body: JSON.stringify({ username: "rejlog_alice", password: PW, network_id: NET, role: "member" }),
78
+ });
79
+ expect(created.status).toBe(200);
80
+ const login = await fetch(`${BASE}/api/auth/login`, {
81
+ method: "POST", headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ username: "rejlog_alice", password: PW }),
83
+ });
84
+ expect(login.status).toBe(200);
85
+ aliceToken = (await login.json() as any).token;
86
+ }, 30_000);
87
+
88
+ afterAll(() => {
89
+ try { hub?.stop?.(true); } catch {}
90
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ function assertNoSecrets(line: string, token: string) {
94
+ expect(line).not.toContain(token);
95
+ expect(line).not.toContain(SECRET_TEXT);
96
+ expect(line).not.toContain("file_");
97
+ }
98
+
99
+ describe("#552 REST POST /api/task rejections are logged", () => {
100
+ test("alias_not_found (404): one line with status, error, user, alias, network, client_request_id", async () => {
101
+ const r = await postTask(adminToken, JSON.stringify({ alias: "no-such-agent", task: SECRET_TEXT, network_id: NET, meta: { client_request_id: "crid-552-a" } }));
102
+ expect(r.status).toBe(404);
103
+ expect(r.json.error).toBe("alias_not_found");
104
+ const lines = rejectedLines(r.lines);
105
+ expect(lines.length).toBe(1);
106
+ expect(lines[0]).toMatch(/^\[\d\d:\d\d:\d\d\] /);
107
+ expect(lines[0]).toContain(`${adminName} → /api/task → no-such-agent: REJECTED 404 alias_not_found`);
108
+ expect(lines[0]).toContain(`net=${NET}`);
109
+ expect(lines[0]).toContain("crid=crid-552-a");
110
+ assertNoSecrets(lines[0], adminToken);
111
+ });
112
+
113
+ test("network_id_required (400) for a member of two networks who omits network_id", async () => {
114
+ const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT }));
115
+ expect(r.status).toBe(400);
116
+ expect(r.json.error).toBe("network_id_required");
117
+ const lines = rejectedLines(r.lines);
118
+ expect(lines.length).toBe(1);
119
+ expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 400 network_id_required (net=-)`);
120
+ assertNoSecrets(lines[0], aliceToken);
121
+ });
122
+
123
+ test("agent_not_granted (403) — the restricted-member case from #552", async () => {
124
+ const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT, network_id: NET, attachments: [{ file_id: "file_deadbeef" }] }));
125
+ expect(r.status).toBe(403);
126
+ expect(r.json.error).toBe("agent_not_granted");
127
+ const lines = rejectedLines(r.lines);
128
+ expect(lines.length).toBe(1);
129
+ expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 403 agent_not_granted (net=${NET})`);
130
+ assertNoSecrets(lines[0], aliceToken);
131
+ });
132
+
133
+ test("invalid JSON (400) is logged with the user and '-' placeholders", async () => {
134
+ const r = await postTask(aliceToken, "{not json");
135
+ expect(r.status).toBe(400);
136
+ const lines = rejectedLines(r.lines);
137
+ expect(lines.length).toBe(1);
138
+ expect(lines[0]).toContain("rejlog_alice → /api/task → -: REJECTED 400 invalid JSON (net=-)");
139
+ });
140
+
141
+ test("a successful send logs no REJECTED line", async () => {
142
+ const r = await postTask(adminToken, JSON.stringify({ alias: AGENT.alias, task: "hello", network_id: NET }));
143
+ expect(r.status).toBe(200);
144
+ expect(rejectedLines(r.lines).length).toBe(0);
145
+ });
146
+ });
147
+
148
+ describe("formatTaskRejection", () => {
149
+ test("request-supplied values cannot inject extra log lines and are clipped", () => {
150
+ const line = formatTaskRejection({
151
+ status: 404, error: "alias_not_found", username: null,
152
+ body: { alias: "evil\n[00:00:00] admin → forged", network_id: "n".repeat(200), meta: { client_request_id: "x\r\ny" } },
153
+ });
154
+ expect(line).not.toContain("\n");
155
+ expect(line).not.toContain("\r");
156
+ expect(line).toContain("anon → /api/task → evil [00:00:00] admin → forged: REJECTED 404 alias_not_found");
157
+ expect(line).toContain(`net=${"n".repeat(64)}…`);
158
+ expect(line).toContain("crid=x y");
159
+ });
160
+ });
@@ -0,0 +1,49 @@
1
+ // #552 — one log line for every non-2xx answer from REST POST /api/task.
2
+ //
3
+ // A restricted member's app sends were rejected and the hub log showed
4
+ // nothing, so "the request never arrived" and "the hub refused it" looked
5
+ // identical from the server side. Every rejection branch now goes through
6
+ // logTaskRejection().
7
+ //
8
+ // What is logged is deliberately narrow: status, error code, the
9
+ // authenticated username, and the *requested* alias / network_id /
10
+ // client_request_id. Never the bearer token, the task text, or attachment
11
+ // ids. Request-supplied values are clipped and stripped of control
12
+ // characters so a client cannot forge extra log lines.
13
+
14
+ function ts(): string {
15
+ return new Date().toTimeString().slice(0, 8);
16
+ }
17
+
18
+ function clip(value: unknown, max = 64): string {
19
+ if (typeof value !== "string" || !value) return "-";
20
+ const clean = value.replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim();
21
+ if (!clean) return "-";
22
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
23
+ }
24
+
25
+ export interface TaskRejectionContext {
26
+ status: number;
27
+ error: unknown;
28
+ username: string | null | undefined;
29
+ /** The raw request body (or null when it did not parse). Only alias, network_id and meta.client_request_id are read. */
30
+ body: unknown;
31
+ }
32
+
33
+ export function formatTaskRejection(ctx: TaskRejectionContext): string {
34
+ const b = ctx.body && typeof ctx.body === "object" ? (ctx.body as Record<string, any>) : {};
35
+ const meta = b.meta && typeof b.meta === "object" ? b.meta : {};
36
+ const crid = typeof meta.client_request_id === "string" ? ` crid=${clip(meta.client_request_id)}` : "";
37
+ return `[${ts()}] ${clip(ctx.username ?? "anon")} → /api/task → ${clip(b.alias)}: REJECTED ${ctx.status} ${clip(ctx.error)} (net=${clip(b.network_id)}${crid})`;
38
+ }
39
+
40
+ /** Logs one line for a non-2xx response and returns the response unchanged. */
41
+ export async function logTaskRejection(res: Response, username: string | null | undefined, body: unknown): Promise<Response> {
42
+ if (res.status >= 200 && res.status < 300) return res;
43
+ let error: unknown = null;
44
+ try {
45
+ error = ((await res.clone().json()) as any)?.error;
46
+ } catch {}
47
+ console.log(formatTaskRejection({ status: res.status, error: error ?? "unknown", username: username || "anon", body }));
48
+ return res;
49
+ }
package/src/tools.ts CHANGED
@@ -1034,7 +1034,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1034
1034
  `SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
1035
1035
  channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
1036
1036
  disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
1037
- process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
1037
+ process_uptime_seconds, process_in_flight_count, external_schedules, registered_at,
1038
+ rules_file_capable, skills_capable, files_capable, logs_capable
1038
1039
  FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
1039
1040
  ORDER BY updated_at DESC LIMIT 1`,
1040
1041
  effectiveAlias, resume_id, sessionNetId,
@@ -1104,6 +1105,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1104
1105
  handover.registered_at ?? null],
1105
1106
  );
1106
1107
  }
1108
+ // #550 — the sticky capability flags survive a session handover. The
1109
+ // DELETE+INSERT above starts the new resume_id row at the column default
1110
+ // (0), so a node whose MCP channel reconnected under a new resume_id lost
1111
+ // its rules-file / skills / files / logs doorbell until it next reported
1112
+ // the flag (board #548). Carry each flag that was 1 on the replaced row —
1113
+ // only-ever-set-to-1, and under the same binding as setting it: a node
1114
+ // token bound to this alias. Fixed statement, every parameter used (PG).
1115
+ if (handover && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
1116
+ db.run(
1117
+ `UPDATE sessions SET
1118
+ rules_file_capable = CASE WHEN ?2 = 1 THEN 1 ELSE rules_file_capable END,
1119
+ skills_capable = CASE WHEN ?3 = 1 THEN 1 ELSE skills_capable END,
1120
+ files_capable = CASE WHEN ?4 = 1 THEN 1 ELSE files_capable END,
1121
+ logs_capable = CASE WHEN ?5 = 1 THEN 1 ELSE logs_capable END
1122
+ WHERE resume_id = ?1`,
1123
+ [resume_id,
1124
+ Number(handover.rules_file_capable) === 1 ? 1 : 0, Number(handover.skills_capable) === 1 ? 1 : 0,
1125
+ Number(handover.files_capable) === 1 ? 1 : 0, Number(handover.logs_capable) === 1 ? 1 : 0],
1126
+ );
1127
+ }
1107
1128
  // app#225 follow-up — separate statement on purpose: the INSERT above is
1108
1129
  // byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
1109
1130
  // and only by a node token bound to this alias (a user token or another