@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.
- package/package.json +1 -1
- package/src/agent-groups-perf.test.ts +48 -15
- package/src/db.ts +19 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/requirement-due-reminders-http.test.ts +128 -0
- package/src/requirement-due-reminders.ts +68 -9
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-tasks.ts +11 -2
- package/src/server.ts +22 -18
- package/src/task-queue-ahead-scale.test.ts +44 -19
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/tools.ts +22 -1
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
// read
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
209
|
-
expect(
|
|
210
|
-
|
|
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
|