@sleep2agi/commhub-server 0.9.0-preview.60 → 0.9.0-preview.61
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/inbox-count-sse.test.ts +1 -1
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/server.ts +64 -5
- package/src/stop-delete-node.test.ts +212 -0
- package/src/tools.ts +53 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.61",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -302,7 +302,7 @@ describe("#1440 broadcast delivery set", () => {
|
|
|
302
302
|
expect(res.message_ids).toHaveLength(3);
|
|
303
303
|
// #1440 ③ — `recipients` counted candidates, not deliveries, so it
|
|
304
304
|
// disagreed with the documented invariant
|
|
305
|
-
// ("message_ids.length === recipients", docs-site/docs/api/rest.md)
|
|
305
|
+
// ("message_ids.length === recipients", docs-site/docs/api/rest-data.md)
|
|
306
306
|
// exactly when a node was skipped. 4 sessions, 3 delivered.
|
|
307
307
|
expect(res.recipients).toBe(res.message_ids.length);
|
|
308
308
|
expect(res.recipients).toBe(3);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Handler-level regression for the #212 dedup window swallowing human chat
|
|
2
|
+
// messages (see send_dedup.ts header, "Key selection").
|
|
3
|
+
//
|
|
4
|
+
// Before the fix, POST /api/task keyed the dedup window on
|
|
5
|
+
// (from, to, sha256(content)) only and ignored meta.client_request_id, so a
|
|
6
|
+
// user sending the same short text twice within 5 minutes got 200/429/429 and
|
|
7
|
+
// ONE stored row — and the app maps that 429 to "delivered". The table below
|
|
8
|
+
// is the repro, run against both transports:
|
|
9
|
+
//
|
|
10
|
+
// case | before (REST) | after
|
|
11
|
+
// ----------------------------------+---------------+-------------------------
|
|
12
|
+
// same text, 3 different ids | 1 row | 3 rows, 3 x ok
|
|
13
|
+
// same text, same id (retry) | 1 row | 1 row, 2 x replay (same id)
|
|
14
|
+
// different text, same id | 3 rows | 1 row, 2 x idempotency_conflict
|
|
15
|
+
// same text, no id (#212 agents) | 1 row | 1 row, 2 x duplicate_send
|
|
16
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { register } from "./auth.js";
|
|
22
|
+
import { db } from "./db.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { sharedSendDedup } from "./send_dedup.js";
|
|
25
|
+
|
|
26
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-send-dedup-rid-"));
|
|
27
|
+
const TARGET = `dedup-rid-target-${process.pid}`;
|
|
28
|
+
const TARGET_NODE_ID = `node_dedup_rid_${process.pid}`;
|
|
29
|
+
|
|
30
|
+
let server: any;
|
|
31
|
+
let base = "";
|
|
32
|
+
let userToken = "";
|
|
33
|
+
let restNet = "";
|
|
34
|
+
let restUser = "";
|
|
35
|
+
|
|
36
|
+
const MCP_NET = `net_dedup_rid_${process.pid}`;
|
|
37
|
+
const MCP_USER = `user_dedup_rid_${process.pid}`;
|
|
38
|
+
|
|
39
|
+
function rid(n: number): string {
|
|
40
|
+
// App shape: dreq_ + 32 lowercase hex.
|
|
41
|
+
return `dreq_${n.toString(16).padStart(32, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seedTarget(networkId: string, suffix: string) {
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
47
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
48
|
+
[`resume_${TARGET_NODE_ID}_${suffix}`, TARGET, `${TARGET_NODE_ID}_${suffix}`, networkId],
|
|
49
|
+
);
|
|
50
|
+
db.run(
|
|
51
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
52
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
53
|
+
[`${TARGET_NODE_ID}_${suffix}`, TARGET, networkId],
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
59
|
+
restUser = `dedup_rid_${Date.now()}`;
|
|
60
|
+
const registered = register(restUser, "DedupRid123!", undefined, "seed");
|
|
61
|
+
expect(registered.ok).toBe(true);
|
|
62
|
+
userToken = registered.token!;
|
|
63
|
+
restNet = registered.network_id!;
|
|
64
|
+
seedTarget(restNet, "rest");
|
|
65
|
+
|
|
66
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [MCP_USER]);
|
|
67
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [MCP_NET, MCP_USER]);
|
|
68
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [MCP_USER, MCP_NET]);
|
|
69
|
+
seedTarget(MCP_NET, "mcp");
|
|
70
|
+
|
|
71
|
+
const mod: any = await import("./server.js");
|
|
72
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
73
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
74
|
+
}, 30_000);
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
try { server?.stop?.(true); } catch {}
|
|
78
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
for (const net of [restNet, MCP_NET]) {
|
|
83
|
+
if (!net) continue;
|
|
84
|
+
for (const table of ["tasks", "inbox", "task_events"]) {
|
|
85
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
sharedSendDedup.clear();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function rowCount(networkId: string): number {
|
|
92
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", [networkId])!.n;
|
|
93
|
+
}
|
|
94
|
+
function inboxCount(networkId: string): number {
|
|
95
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", [networkId])!.n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── REST ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function rest(task: string, requestId?: string): Promise<{ status: number; body: any }> {
|
|
101
|
+
const res = await fetch(`${base}/api/task`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
alias: TARGET,
|
|
106
|
+
task,
|
|
107
|
+
priority: "normal",
|
|
108
|
+
network_id: restNet,
|
|
109
|
+
from: restUser,
|
|
110
|
+
meta: requestId
|
|
111
|
+
? { source: "dashboard-chat", client_request_id: requestId }
|
|
112
|
+
: { source: "dashboard-chat" },
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
return { status: res.status, body: await res.json() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("POST /api/task dedup key selection", () => {
|
|
119
|
+
test("same text with 3 different request ids -> 3 rows (the reported data loss)", async () => {
|
|
120
|
+
const results = [await rest("好", rid(1)), await rest("好", rid(2)), await rest("好", rid(3))];
|
|
121
|
+
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
|
|
122
|
+
const ids = results.map((r) => r.body.task_id);
|
|
123
|
+
expect(new Set(ids).size).toBe(3);
|
|
124
|
+
expect(rowCount(restNet)).toBe(3);
|
|
125
|
+
expect(inboxCount(restNet)).toBe(3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("same text with the same request id -> 1 row, retries replay the same task id", async () => {
|
|
129
|
+
const first = await rest("好", rid(7));
|
|
130
|
+
const second = await rest("好", rid(7));
|
|
131
|
+
const third = await rest("好", rid(7));
|
|
132
|
+
expect(first.status).toBe(200);
|
|
133
|
+
expect(second.status).toBe(200);
|
|
134
|
+
expect(third.status).toBe(200);
|
|
135
|
+
expect(second.body).toMatchObject({ ok: true, task_id: first.body.task_id, message_id: first.body.task_id, idempotent_replay: true });
|
|
136
|
+
expect(third.body.task_id).toBe(first.body.task_id);
|
|
137
|
+
expect(rowCount(restNet)).toBe(1);
|
|
138
|
+
expect(inboxCount(restNet)).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("different text with the same request id -> 1 row, reuse fails closed (409)", async () => {
|
|
142
|
+
const first = await rest("第一条", rid(9));
|
|
143
|
+
const second = await rest("第二条", rid(9));
|
|
144
|
+
const third = await rest("第三条", rid(9));
|
|
145
|
+
expect(first.status).toBe(200);
|
|
146
|
+
expect(second.status).toBe(409);
|
|
147
|
+
expect(second.body.error).toBe("idempotency_conflict");
|
|
148
|
+
expect(third.status).toBe(409);
|
|
149
|
+
expect(rowCount(restNet)).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#212 guard: same text WITHOUT a request id is still deduped (429 duplicate_send)", async () => {
|
|
153
|
+
const results = [await rest("继续"), await rest("继续"), await rest("继续")];
|
|
154
|
+
expect(results.map((r) => r.status)).toEqual([200, 429, 429]);
|
|
155
|
+
expect(results[1].body.error).toBe("duplicate_send");
|
|
156
|
+
expect(rowCount(restNet)).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("no request id, different text -> each goes through", async () => {
|
|
160
|
+
const results = [await rest("a"), await rest("b"), await rest("c")];
|
|
161
|
+
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
|
|
162
|
+
expect(rowCount(restNet)).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── MCP send_task ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
169
|
+
|
|
170
|
+
function mcpSendTask(): ToolHandler {
|
|
171
|
+
const mcp = new McpServer({ name: "dedup-rid-test", version: "0" }) as any;
|
|
172
|
+
let handler: ToolHandler | undefined;
|
|
173
|
+
const original = mcp.tool.bind(mcp);
|
|
174
|
+
mcp.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
|
|
175
|
+
if (name === "send_task") handler = candidate;
|
|
176
|
+
return original(name, description, schema, candidate);
|
|
177
|
+
};
|
|
178
|
+
registerTools(mcp, undefined, MCP_NET, MCP_USER, MCP_USER, false, null);
|
|
179
|
+
if (!handler) throw new Error("send_task handler missing");
|
|
180
|
+
return handler;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function mcp(handler: ToolHandler, task: string, requestId?: string): Promise<any> {
|
|
184
|
+
const result = await handler({
|
|
185
|
+
alias: TARGET,
|
|
186
|
+
task,
|
|
187
|
+
priority: "normal",
|
|
188
|
+
...(requestId ? { meta: { source: "dashboard-chat", client_request_id: requestId } } : {}),
|
|
189
|
+
});
|
|
190
|
+
return JSON.parse(result.content[0].text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("MCP send_task dedup key selection", () => {
|
|
194
|
+
test("same text with 3 different request ids -> 3 rows", async () => {
|
|
195
|
+
const h = mcpSendTask();
|
|
196
|
+
const results = [await mcp(h, "好", rid(11)), await mcp(h, "好", rid(12)), await mcp(h, "好", rid(13))];
|
|
197
|
+
expect(results.map((r) => r.ok)).toEqual([true, true, true]);
|
|
198
|
+
expect(new Set(results.map((r) => r.message_id)).size).toBe(3);
|
|
199
|
+
expect(rowCount(MCP_NET)).toBe(3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("same text with the same request id -> 1 row, replay returns the same task id", async () => {
|
|
203
|
+
const h = mcpSendTask();
|
|
204
|
+
const first = await mcp(h, "好", rid(17));
|
|
205
|
+
const second = await mcp(h, "好", rid(17));
|
|
206
|
+
const third = await mcp(h, "好", rid(17));
|
|
207
|
+
expect(first.ok).toBe(true);
|
|
208
|
+
expect(second).toMatchObject({ ok: true, task_id: first.message_id, idempotent_replay: true });
|
|
209
|
+
expect(third.task_id).toBe(first.message_id);
|
|
210
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("different text with the same request id -> 1 row, idempotency_conflict", async () => {
|
|
214
|
+
const h = mcpSendTask();
|
|
215
|
+
expect((await mcp(h, "第一条", rid(19))).ok).toBe(true);
|
|
216
|
+
expect((await mcp(h, "第二条", rid(19))).error).toBe("idempotency_conflict");
|
|
217
|
+
expect((await mcp(h, "第三条", rid(19))).error).toBe("idempotency_conflict");
|
|
218
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("#212 guard: same text WITHOUT a request id is still deduped", async () => {
|
|
222
|
+
const h = mcpSendTask();
|
|
223
|
+
const results = [await mcp(h, "继续"), await mcp(h, "继续"), await mcp(h, "继续")];
|
|
224
|
+
expect(results[0].ok).toBe(true);
|
|
225
|
+
expect(results[1].error).toBe("duplicate_send");
|
|
226
|
+
expect(results[2].error).toBe("duplicate_send");
|
|
227
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/send_dedup.test.ts
CHANGED
|
@@ -146,6 +146,60 @@ describe("SendDedup", () => {
|
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
describe("SendDedup key selection (client_request_id vs content)", () => {
|
|
150
|
+
const t0 = 1_700_000_000_000;
|
|
151
|
+
const A = "dreq_00000000000000000000000000000001";
|
|
152
|
+
const B = "dreq_00000000000000000000000000000002";
|
|
153
|
+
|
|
154
|
+
it("keyFor picks the request id when present, content hash otherwise", () => {
|
|
155
|
+
const withId = SendDedup.keyFor("alice", "bob", "好", { requestId: A, networkId: "net1" });
|
|
156
|
+
expect(withId.kind).toBe("request_id");
|
|
157
|
+
expect(withId.key).toBe(`rid|net1|alice|bob|${A}`);
|
|
158
|
+
const noId = SendDedup.keyFor("alice", "bob", "好", {});
|
|
159
|
+
expect(noId.kind).toBe("content");
|
|
160
|
+
expect(noId.key).toBe(SendDedup.key("alice", "bob", "好"));
|
|
161
|
+
// Empty / null ids fall back to the content key.
|
|
162
|
+
expect(SendDedup.keyFor("alice", "bob", "好", { requestId: "" }).kind).toBe("content");
|
|
163
|
+
expect(SendDedup.keyFor("alice", "bob", "好", { requestId: null }).kind).toBe("content");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("same text with different request ids is never a duplicate", () => {
|
|
167
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
168
|
+
d.record("alice", "bob", "好", t0, { requestId: A });
|
|
169
|
+
expect(d.check("alice", "bob", "好", t0 + 1000, { requestId: B })).toEqual({ duplicate: false });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("same request id is a duplicate even with different content (one id = one message)", () => {
|
|
173
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
174
|
+
d.record("alice", "bob", "first", t0, { requestId: A });
|
|
175
|
+
const again = d.check("alice", "bob", "second", t0 + 1000, { requestId: A });
|
|
176
|
+
expect(again.duplicate).toBe(true);
|
|
177
|
+
if (again.duplicate) expect(again.kind).toBe("request_id");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("request-id keys are scoped by network, sender and target", () => {
|
|
181
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
182
|
+
d.record("alice", "bob", "x", t0, { requestId: A, networkId: "net1" });
|
|
183
|
+
expect(d.check("alice", "bob", "x", t0 + 1, { requestId: A, networkId: "net2" })).toEqual({ duplicate: false });
|
|
184
|
+
expect(d.check("carol", "bob", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
|
|
185
|
+
expect(d.check("alice", "dave", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("#212 regression guard: without an id, same content is still a duplicate and reports kind=content", () => {
|
|
189
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
190
|
+
d.record("agent", "peer", "same task", t0);
|
|
191
|
+
const again = d.check("agent", "peer", "same task", t0 + 1000);
|
|
192
|
+
expect(again.duplicate).toBe(true);
|
|
193
|
+
if (again.duplicate) expect(again.kind).toBe("content");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an id-bearing send does not shadow a later id-less send of the same text", () => {
|
|
197
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
198
|
+
d.record("alice", "bob", "好", t0, { requestId: A });
|
|
199
|
+
expect(d.check("alice", "bob", "好", t0 + 1000)).toEqual({ duplicate: false });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
149
203
|
describe("buildDuplicateSendPayload", () => {
|
|
150
204
|
it("contains the Chinese LLM-facing hint with target alias and window minutes", () => {
|
|
151
205
|
const payload = buildDuplicateSendPayload({
|
package/src/send_dedup.ts
CHANGED
|
@@ -33,6 +33,26 @@
|
|
|
33
33
|
// - The structured response shape is identical between MCP and REST
|
|
34
34
|
// callers so the LLM gets the same parseable hint regardless of
|
|
35
35
|
// transport.
|
|
36
|
+
//
|
|
37
|
+
// Key selection (#2043, follow-up to #212): the content-hash key above is
|
|
38
|
+
// only right for callers that have no other notion of "one message".
|
|
39
|
+
// Human chat clients (the desktop/mobile app, the web Dashboard) mint a
|
|
40
|
+
// fresh `meta.client_request_id` per message bubble and reuse it only
|
|
41
|
+
// when retrying that same bubble. For those sends the content hash is the
|
|
42
|
+
// WRONG identity: a user who types 「好」 twice within five minutes sent
|
|
43
|
+
// two messages, and dropping the second one (while the app maps the 429
|
|
44
|
+
// to "delivered") silently loses it. So:
|
|
45
|
+
// - a send WITH a valid client_request_id is keyed on
|
|
46
|
+
// (network, from, to, client_request_id) — a new id always proceeds,
|
|
47
|
+
// identical text or not; a same-id retry is answered from the durable
|
|
48
|
+
// tasks row by the handler (task-idempotency.ts) before this index is
|
|
49
|
+
// even consulted, so this key is defence in depth, not the primary
|
|
50
|
+
// replay mechanism;
|
|
51
|
+
// - a send WITHOUT one (agent / MCP / scripted traffic) keeps the #212
|
|
52
|
+
// content-hash window unchanged.
|
|
53
|
+
// One id is one message: re-using an id with different content is a
|
|
54
|
+
// client bug and the handlers fail it closed (`idempotency_conflict`)
|
|
55
|
+
// rather than storing a second row or silently replaying the first.
|
|
36
56
|
|
|
37
57
|
import { createHash } from "crypto";
|
|
38
58
|
|
|
@@ -61,9 +81,23 @@ export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConf
|
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
/** Which identity a dedup key was built from. Logged on every drop. */
|
|
85
|
+
export type DedupKeyKind = "request_id" | "content";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional per-send scope. When `requestId` is a non-empty string the key
|
|
89
|
+
* is the request id (scoped by network/from/to) instead of the content hash.
|
|
90
|
+
* Callers must pass the already-validated id (clientRequestIdFromMeta), not
|
|
91
|
+
* raw client input.
|
|
92
|
+
*/
|
|
93
|
+
export type DedupScope = {
|
|
94
|
+
requestId?: string | null;
|
|
95
|
+
networkId?: string | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
64
98
|
export type DedupCheck =
|
|
65
99
|
| { duplicate: false }
|
|
66
|
-
| { duplicate: true; lastSentMs: number; ageMs: number };
|
|
100
|
+
| { duplicate: true; lastSentMs: number; ageMs: number; kind: DedupKeyKind };
|
|
67
101
|
|
|
68
102
|
export class SendDedup {
|
|
69
103
|
private last = new Map<string, number>();
|
|
@@ -93,6 +127,18 @@ export class SendDedup {
|
|
|
93
127
|
return `${from}|${to}|${hash}`;
|
|
94
128
|
}
|
|
95
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Pick the dedup identity for one send. A validated client request id
|
|
132
|
+
* wins over the content hash; see the header comment for why.
|
|
133
|
+
*/
|
|
134
|
+
static keyFor(from: string, to: string, content: string, scope: DedupScope = {}): { key: string; kind: DedupKeyKind } {
|
|
135
|
+
const requestId = typeof scope.requestId === "string" && scope.requestId.length > 0 ? scope.requestId : null;
|
|
136
|
+
if (requestId) {
|
|
137
|
+
return { key: `rid|${scope.networkId ?? ""}|${from}|${to}|${requestId}`, kind: "request_id" };
|
|
138
|
+
}
|
|
139
|
+
return { key: SendDedup.key(from, to, content), kind: "content" };
|
|
140
|
+
}
|
|
141
|
+
|
|
96
142
|
/**
|
|
97
143
|
* Check whether (from, to, content) was sent recently. Returns
|
|
98
144
|
* `{ duplicate: false }` when the call should proceed, or
|
|
@@ -103,10 +149,10 @@ export class SendDedup {
|
|
|
103
149
|
* AFTER the underlying side effect (inbox insert + pushEvent) succeeds,
|
|
104
150
|
* so failed sends don't accidentally block legitimate retries.
|
|
105
151
|
*/
|
|
106
|
-
check(from: string, to: string, content: string, nowMs: number = Date.now()): DedupCheck {
|
|
152
|
+
check(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): DedupCheck {
|
|
107
153
|
if (!this.enabled) return { duplicate: false };
|
|
108
154
|
this.evictExpired(nowMs);
|
|
109
|
-
const k = SendDedup.
|
|
155
|
+
const { key: k, kind } = SendDedup.keyFor(from, to, content, scope);
|
|
110
156
|
const lastSentMs = this.last.get(k);
|
|
111
157
|
if (lastSentMs === undefined) return { duplicate: false };
|
|
112
158
|
const ageMs = nowMs - lastSentMs;
|
|
@@ -114,13 +160,13 @@ export class SendDedup {
|
|
|
114
160
|
this.last.delete(k);
|
|
115
161
|
return { duplicate: false };
|
|
116
162
|
}
|
|
117
|
-
return { duplicate: true, lastSentMs, ageMs };
|
|
163
|
+
return { duplicate: true, lastSentMs, ageMs, kind };
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
/** Record a successful send for future dedup checks. */
|
|
121
|
-
record(from: string, to: string, content: string, nowMs: number = Date.now()): void {
|
|
167
|
+
record(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): void {
|
|
122
168
|
if (!this.enabled) return;
|
|
123
|
-
const k = SendDedup.
|
|
169
|
+
const k = SendDedup.keyFor(from, to, content, scope).key;
|
|
124
170
|
this.last.set(k, nowMs);
|
|
125
171
|
if (this.last.size > this.cfg.maxKeys) this.evictOldest();
|
|
126
172
|
}
|
package/src/server.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-va
|
|
|
19
19
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
20
20
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
21
21
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
22
|
+
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
22
23
|
import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
|
|
23
24
|
import {
|
|
24
25
|
FILE_ID_REGEX,
|
|
@@ -738,6 +739,16 @@ export function patrolExpiredTasks(): void {
|
|
|
738
739
|
// in this count. The WHERE clause still targets one exact guarded
|
|
739
740
|
// task, so any positive count proves the task transition happened.
|
|
740
741
|
if (result.changes < 1) continue;
|
|
742
|
+
// #2022 — retire the inbox rows in the same transaction. Nothing
|
|
743
|
+
// else acks them: an expired task can never be acked by its target,
|
|
744
|
+
// and retention only sweeps acked=1 rows, so leaving them unacked
|
|
745
|
+
// kept them in every "unacked inbox" consumer (the stop/delete gate,
|
|
746
|
+
// the unread badge) forever. COALESCE(task_id, id) mirrors
|
|
747
|
+
// cancel_task — legacy rows carry the logical task id in `id`.
|
|
748
|
+
db.run(
|
|
749
|
+
`UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0`,
|
|
750
|
+
[task.task_id],
|
|
751
|
+
);
|
|
741
752
|
syncScheduledRunForTask(task.task_id, task.network_id);
|
|
742
753
|
changed.push(task);
|
|
743
754
|
}
|
|
@@ -2391,7 +2402,6 @@ return Bun.serve({
|
|
|
2391
2402
|
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
2392
2403
|
}, { status: 404 }));
|
|
2393
2404
|
}
|
|
2394
|
-
const id = crypto.randomUUID();
|
|
2395
2405
|
// Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
|
|
2396
2406
|
// fromIdentityMismatchReply). Both transports resolve the same
|
|
2397
2407
|
// api_tokens row through resolveToken, and auth.ts already documents the
|
|
@@ -2450,12 +2460,61 @@ return Bun.serve({
|
|
|
2450
2460
|
: "legacy";
|
|
2451
2461
|
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
|
|
2452
2462
|
|
|
2463
|
+
// Client-request-id idempotency, mirroring the MCP `send_task` tool
|
|
2464
|
+
// (tools.ts). The app/Dashboard mint one `meta.client_request_id` per
|
|
2465
|
+
// message bubble and reuse it only to retry that bubble, so:
|
|
2466
|
+
// - the task id is derived from (network, from, request id): a retry
|
|
2467
|
+
// after a lost response finds the row it already wrote and gets the
|
|
2468
|
+
// same task id back (200, idempotent_replay) — across restarts too;
|
|
2469
|
+
// - the same id with a different payload fails closed (409);
|
|
2470
|
+
// - a NEW id is a new message even when the text is identical, so it
|
|
2471
|
+
// must not hit the #212 content-hash window below (that silently
|
|
2472
|
+
// dropped a user's second 「好」 while the app showed it delivered).
|
|
2473
|
+
const clientRequestId = clientRequestIdFromMeta(mergedMeta);
|
|
2474
|
+
const id = clientRequestId
|
|
2475
|
+
? idempotentTaskId(taskNetId ?? null, fromSession, clientRequestId)
|
|
2476
|
+
: crypto.randomUUID();
|
|
2477
|
+
if (clientRequestId) {
|
|
2478
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
2479
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
2480
|
+
[id],
|
|
2481
|
+
);
|
|
2482
|
+
if (existing) {
|
|
2483
|
+
if (!idempotentTaskMatches(existing, {
|
|
2484
|
+
fromName: fromSession, toName: targetAlias, priority: body.priority, content: body.task,
|
|
2485
|
+
networkId: taskNetId ?? null, metaJson,
|
|
2486
|
+
})) {
|
|
2487
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: REJECTED idempotency_conflict (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
2488
|
+
return withCors(req, Response.json({
|
|
2489
|
+
ok: false,
|
|
2490
|
+
error: "idempotency_conflict",
|
|
2491
|
+
message: "client_request_id was already used with a different task payload",
|
|
2492
|
+
}, { status: 409 }));
|
|
2493
|
+
}
|
|
2494
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
2495
|
+
return withCors(req, Response.json({
|
|
2496
|
+
ok: true,
|
|
2497
|
+
task_id: existing.task_id,
|
|
2498
|
+
message_id: existing.task_id,
|
|
2499
|
+
task_status: existing.status,
|
|
2500
|
+
idempotent_replay: true,
|
|
2501
|
+
actual_to: {
|
|
2502
|
+
alias: existing.to_name,
|
|
2503
|
+
to_node_id: existing.to_node_id ?? null,
|
|
2504
|
+
network_id: existing.network_id ?? null,
|
|
2505
|
+
},
|
|
2506
|
+
}));
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2453
2510
|
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
2454
2511
|
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
2455
2512
|
// is rejected with a structured `duplicate_send` error so dashboard
|
|
2456
2513
|
// dispatch buttons and scripted REST callers get the same guarantee
|
|
2457
|
-
// as agent-driven MCP traffic.
|
|
2458
|
-
|
|
2514
|
+
// as agent-driven MCP traffic. Sends carrying a client_request_id are
|
|
2515
|
+
// keyed on that id instead of the content (send_dedup.ts header).
|
|
2516
|
+
const dedupScope = { requestId: clientRequestId, networkId: taskNetId ?? null };
|
|
2517
|
+
const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task, undefined, dedupScope);
|
|
2459
2518
|
if (dedup.duplicate) {
|
|
2460
2519
|
const payload = buildDuplicateSendPayload({
|
|
2461
2520
|
from: fromSession,
|
|
@@ -2463,7 +2522,7 @@ return Bun.serve({
|
|
|
2463
2522
|
ageMs: dedup.ageMs,
|
|
2464
2523
|
windowMs: sharedSendDedup.windowMs,
|
|
2465
2524
|
});
|
|
2466
|
-
console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
2525
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
2467
2526
|
return withCors(req, Response.json(payload, { status: 429 }));
|
|
2468
2527
|
}
|
|
2469
2528
|
|
|
@@ -2518,7 +2577,7 @@ return Bun.serve({
|
|
|
2518
2577
|
// #212 — stamp the dedup index only after the inbox/tasks insert
|
|
2519
2578
|
// succeeds. Mirrors the MCP `send_task` path so a failed write
|
|
2520
2579
|
// never shadows a legitimate retry.
|
|
2521
|
-
sharedSendDedup.record(fromSession, targetAlias, body.task);
|
|
2580
|
+
sharedSendDedup.record(fromSession, targetAlias, body.task, undefined, dedupScope);
|
|
2522
2581
|
if (target.state === "offline") {
|
|
2523
2582
|
return withCors(req, Response.json({
|
|
2524
2583
|
ok: false,
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { db } from "./db.js";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
+
import { patrolExpiredTasks } from "./server.js";
|
|
5
6
|
|
|
6
7
|
// RFC-027 PR1 — handler-driven tests for stop_node, delete_node,
|
|
7
8
|
// get_stop_request, ack_stop_request. Drives the MCP handlers via
|
|
@@ -42,6 +43,9 @@ function cleanup() {
|
|
|
42
43
|
// PR1.2a restart_node tests leave node_config_updates rows; clear
|
|
43
44
|
// so the next test's restart_node doesn't see them as in_flight.
|
|
44
45
|
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
|
|
46
|
+
// #2022 seeds tasks rows whose status drives the in-flight join; clear
|
|
47
|
+
// them by network or a later test inherits a stale terminal row.
|
|
48
|
+
try { db.run("DELETE FROM tasks WHERE network_id = ?1", [n]); } catch {}
|
|
45
49
|
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
|
|
46
50
|
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
|
|
47
51
|
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
|
|
@@ -950,3 +954,211 @@ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
|
|
|
950
954
|
expect(r.ok).toBe(true);
|
|
951
955
|
});
|
|
952
956
|
});
|
|
957
|
+
|
|
958
|
+
// ── #2022 — in-flight gate: terminal / aged rows must not pin busy ──
|
|
959
|
+
//
|
|
960
|
+
// The old gate was COUNT(*) FROM inbox WHERE session_name=? AND acked=0:
|
|
961
|
+
// no join to tasks, no age limit, and nothing acks an inbox row whose
|
|
962
|
+
// task already died (retention sweeps only acked=1 rows), so a single
|
|
963
|
+
// dead row pinned the node to node_busy_in_flight forever.
|
|
964
|
+
//
|
|
965
|
+
// Rule under test (#2029 review):
|
|
966
|
+
// 1. type='task' rows that join to a tasks row are judged by status
|
|
967
|
+
// ALONE — terminal (replied / failed / cancelled / expired) is not
|
|
968
|
+
// counted, anything else is counted with NO age ceiling. agent-node
|
|
969
|
+
// acks the inbox row only when the task is done, so a 90-minute
|
|
970
|
+
// (or ttl_seconds>3600) run stays acked=0 the whole time and must
|
|
971
|
+
// keep the gate closed; expiry is patrolExpiredTasks' job.
|
|
972
|
+
// Join key is COALESCE(task_id, id), the logical id cancel_task
|
|
973
|
+
// uses.
|
|
974
|
+
// 2. rows with no status of their own — task rows whose tasks row is
|
|
975
|
+
// gone, and non-task rows (reply / broadcast / message) — get the
|
|
976
|
+
// 60-minute age ceiling; nothing else will ever retire them.
|
|
977
|
+
// 3. patrolExpiredTasks acks the matching inbox rows in the same
|
|
978
|
+
// transaction that expires the task.
|
|
979
|
+
|
|
980
|
+
function seedTaskWithInbox(
|
|
981
|
+
net: string,
|
|
982
|
+
alias: string,
|
|
983
|
+
opts: {
|
|
984
|
+
task_id: string;
|
|
985
|
+
status: string;
|
|
986
|
+
/** signed datetime offset for the tasks row, e.g. '-3 days' */
|
|
987
|
+
task_age?: string;
|
|
988
|
+
/** signed datetime offset for the inbox row, e.g. '-3 days' */
|
|
989
|
+
inbox_age?: string;
|
|
990
|
+
/** signed datetime offset for tasks.expires_at, e.g. '+1 hour' */
|
|
991
|
+
expires_in?: string;
|
|
992
|
+
},
|
|
993
|
+
) {
|
|
994
|
+
db.run(
|
|
995
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
|
|
996
|
+
created_at, delivered_at, expires_at, network_id)
|
|
997
|
+
VALUES (?1, 'caller', ?2, 'normal', ?3, 'do the thing', 'reply',
|
|
998
|
+
datetime('now', ?4), datetime('now', ?4), datetime('now', ?5), ?6)`,
|
|
999
|
+
[opts.task_id, alias, opts.status, opts.task_age ?? "0 seconds", opts.expires_in ?? "+1 hour", net],
|
|
1000
|
+
);
|
|
1001
|
+
db.run(
|
|
1002
|
+
`INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id, acked, created_at)
|
|
1003
|
+
VALUES (?1, ?1, ?2, 'task', 'normal', 'do the thing', 'caller', ?3, 0, datetime('now', ?4))`,
|
|
1004
|
+
[opts.task_id, alias, net, opts.inbox_age ?? "0 seconds"],
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function stopArgs() {
|
|
1009
|
+
return { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
describe("#2022 stop gate — terminal task rows are not in flight", () => {
|
|
1013
|
+
test("28-day-old expired + 3-day-old replied rows → stop dispatches, in_flight=0", async () => {
|
|
1014
|
+
setupAlphaNetwork();
|
|
1015
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1016
|
+
task_id: "task_2022_expired", status: "expired",
|
|
1017
|
+
task_age: "-28 days", inbox_age: "-28 days", expires_in: "-27 days",
|
|
1018
|
+
});
|
|
1019
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1020
|
+
task_id: "task_2022_replied", status: "replied",
|
|
1021
|
+
task_age: "-3 days", inbox_age: "-3 days", expires_in: "-2 days",
|
|
1022
|
+
});
|
|
1023
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1024
|
+
expect(r.ok).toBe(true);
|
|
1025
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1026
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("FRESH terminal row (replied, 0s old) also not counted — terminal filter alone is enough", async () => {
|
|
1030
|
+
setupAlphaNetwork();
|
|
1031
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_fresh_replied", status: "replied" });
|
|
1032
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1033
|
+
expect(r.ok).toBe(true);
|
|
1034
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
describe("#2022 stop gate — genuinely in-flight rows still block", () => {
|
|
1039
|
+
test("fresh non-terminal task row → node_busy_in_flight, count surfaced, state untouched", async () => {
|
|
1040
|
+
setupAlphaNetwork();
|
|
1041
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_live", status: "delivered" });
|
|
1042
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1043
|
+
expect(r.ok).toBe(false);
|
|
1044
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1045
|
+
expect(r.in_flight_count).toBe(1);
|
|
1046
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1047
|
+
expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test("fresh non-task inbox row (type='reply') still counts — judged by age only", async () => {
|
|
1051
|
+
setupAlphaNetwork();
|
|
1052
|
+
db.run(
|
|
1053
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1054
|
+
VALUES (?1, ?2, 'reply', 'fresh reply', 'caller', ?3, 0, datetime('now'))`,
|
|
1055
|
+
[`inbox_2022_fresh_reply`, CHILD_A_ALIAS, NET_A],
|
|
1056
|
+
);
|
|
1057
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1058
|
+
expect(r.ok).toBe(false);
|
|
1059
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1060
|
+
expect(r.in_flight_count).toBe(1);
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
describe("#2022 stop gate — a live task is never aged out (status wins over age)", () => {
|
|
1065
|
+
test("🔴 created 2 hours ago + task.status='running' → still node_busy_in_flight", async () => {
|
|
1066
|
+
setupAlphaNetwork();
|
|
1067
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1068
|
+
task_id: "task_2022_running_90m", status: "running",
|
|
1069
|
+
task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
|
|
1070
|
+
});
|
|
1071
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1072
|
+
expect(r.ok).toBe(false);
|
|
1073
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1074
|
+
expect(r.in_flight_count).toBe(1);
|
|
1075
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("task.status='acked' 2 hours old (patrol never expires it) → still blocked", async () => {
|
|
1079
|
+
setupAlphaNetwork();
|
|
1080
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1081
|
+
task_id: "task_2022_acked_120m", status: "acked",
|
|
1082
|
+
inbox_age: "-120 minutes", expires_in: "+12 hours",
|
|
1083
|
+
});
|
|
1084
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1085
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1086
|
+
expect(r.in_flight_count).toBe(1);
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
describe("#2022 stop gate — age ceiling (60 min) for rows with no status", () => {
|
|
1091
|
+
test("orphan task row (tasks row reaped) 8 days old → not counted", async () => {
|
|
1092
|
+
setupAlphaNetwork();
|
|
1093
|
+
db.run(
|
|
1094
|
+
`INSERT INTO inbox (id, task_id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1095
|
+
VALUES (?1, ?1, ?2, 'task', 'orphan task', 'caller', ?3, 0, datetime('now', '-8 days'))`,
|
|
1096
|
+
[`task_2022_reaped`, CHILD_A_ALIAS, NET_A],
|
|
1097
|
+
);
|
|
1098
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1099
|
+
expect(r.ok).toBe(true);
|
|
1100
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("non-task row older than the ceiling → not counted (the production backlog shape)", async () => {
|
|
1104
|
+
setupAlphaNetwork();
|
|
1105
|
+
db.run(
|
|
1106
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1107
|
+
VALUES (?1, ?2, 'broadcast', 'week-old broadcast', 'caller', ?3, 0, datetime('now', '-7 days'))`,
|
|
1108
|
+
[`inbox_2022_old_broadcast`, CHILD_A_ALIAS, NET_A],
|
|
1109
|
+
);
|
|
1110
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1111
|
+
expect(r.ok).toBe(true);
|
|
1112
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe("#2022 patrolExpiredTasks — expires the task AND acks its inbox rows", () => {
|
|
1117
|
+
test("same transaction: tasks.status='expired' + inbox.acked=1 → gate unblocks", async () => {
|
|
1118
|
+
setupAlphaNetwork();
|
|
1119
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1120
|
+
task_id: "task_2022_due", status: "delivered",
|
|
1121
|
+
inbox_age: "-10 minutes", expires_in: "-1 minute",
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// Before the patrol: fresh, non-terminal → the node is correctly busy.
|
|
1125
|
+
const before = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1126
|
+
expect(before.error).toBe("node_busy_in_flight");
|
|
1127
|
+
expect(before.in_flight_count).toBe(1);
|
|
1128
|
+
|
|
1129
|
+
patrolExpiredTasks();
|
|
1130
|
+
|
|
1131
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", "task_2022_due")?.status).toBe("expired");
|
|
1132
|
+
const inboxRow = db.get<{ acked: number }>(
|
|
1133
|
+
"SELECT acked FROM inbox WHERE task_id = ?1 AND session_name = ?2", "task_2022_due", CHILD_A_ALIAS,
|
|
1134
|
+
);
|
|
1135
|
+
expect(inboxRow?.acked).toBe(1);
|
|
1136
|
+
|
|
1137
|
+
// And the gate that was blocked a moment ago now dispatches.
|
|
1138
|
+
const after = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1139
|
+
expect(after.ok).toBe(true);
|
|
1140
|
+
expect(after.in_flight_at_dispatch).toBe(0);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test("legacy inbox row keyed by id (task_id IS NULL) is acked too", async () => {
|
|
1144
|
+
setupAlphaNetwork();
|
|
1145
|
+
db.run(
|
|
1146
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
|
|
1147
|
+
created_at, expires_at, network_id)
|
|
1148
|
+
VALUES ('task_2022_legacy', 'caller', ?1, 'normal', 'delivered', 'do the thing', 'reply',
|
|
1149
|
+
datetime('now', '-10 minutes'), datetime('now', '-1 minute'), ?2)`,
|
|
1150
|
+
[CHILD_A_ALIAS, NET_A],
|
|
1151
|
+
);
|
|
1152
|
+
// Pre-task_id shape: the transport row IS the logical id.
|
|
1153
|
+
db.run(
|
|
1154
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1155
|
+
VALUES ('task_2022_legacy', ?1, 'task', 'do the thing', 'caller', ?2, 0, datetime('now', '-10 minutes'))`,
|
|
1156
|
+
[CHILD_A_ALIAS, NET_A],
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
patrolExpiredTasks();
|
|
1160
|
+
|
|
1161
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = 'task_2022_legacy'")?.status).toBe("expired");
|
|
1162
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'task_2022_legacy'")?.acked).toBe(1);
|
|
1163
|
+
});
|
|
1164
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -1621,6 +1621,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1621
1621
|
message: "client_request_id was already used with a different task payload",
|
|
1622
1622
|
}) }] };
|
|
1623
1623
|
}
|
|
1624
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
1624
1625
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1625
1626
|
ok: true, message_id: existing.task_id, task_id: existing.task_id,
|
|
1626
1627
|
task_status: existing.status, idempotent_replay: true,
|
|
@@ -1644,7 +1645,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1644
1645
|
// incident: 50+ identical dispatches across 5 LLM turns ignored
|
|
1645
1646
|
// three STOP replies — the LLM cannot be trusted to debounce
|
|
1646
1647
|
// itself, so the runtime layer must.
|
|
1647
|
-
|
|
1648
|
+
//
|
|
1649
|
+
// A send carrying a validated client_request_id is keyed on that id
|
|
1650
|
+
// instead of the content hash (send_dedup.ts header): a human typing
|
|
1651
|
+
// the same short text twice sent two messages. Same-id retries were
|
|
1652
|
+
// already answered from the durable row above.
|
|
1653
|
+
const dedupScope = { requestId: clientRequestId, networkId: effectiveNetId ?? null };
|
|
1654
|
+
const dedup = sharedSendDedup.check(from_session, targetAlias, task, undefined, dedupScope);
|
|
1648
1655
|
if (dedup.duplicate) {
|
|
1649
1656
|
const payload = buildDuplicateSendPayload({
|
|
1650
1657
|
from: from_session,
|
|
@@ -1652,7 +1659,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1652
1659
|
ageMs: dedup.ageMs,
|
|
1653
1660
|
windowMs: sharedSendDedup.windowMs,
|
|
1654
1661
|
});
|
|
1655
|
-
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
1662
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
1656
1663
|
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
1657
1664
|
}
|
|
1658
1665
|
|
|
@@ -1692,7 +1699,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1692
1699
|
// Only stamp the dedup index after the inbox/tasks transaction
|
|
1693
1700
|
// succeeds, so a failed insert never silently shadows a legitimate
|
|
1694
1701
|
// retry.
|
|
1695
|
-
sharedSendDedup.record(from_session, targetAlias, task);
|
|
1702
|
+
sharedSendDedup.record(from_session, targetAlias, task, undefined, dedupScope);
|
|
1696
1703
|
|
|
1697
1704
|
// SSE push by alias.
|
|
1698
1705
|
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
@@ -4528,10 +4535,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4528
4535
|
// Use COALESCE so a NULL inbox row is counted against the same
|
|
4529
4536
|
// network as the target node (which is the only safe default
|
|
4530
4537
|
// — pre-multi-network rows existed in single-network mode).
|
|
4538
|
+
//
|
|
4539
|
+
// #2022 — "unacked" alone is not "in flight". Nothing acks a row
|
|
4540
|
+
// whose task already reached a terminal state (report_completion,
|
|
4541
|
+
// send_reply, send_ack, the TTL patrol all leave inbox alone) and
|
|
4542
|
+
// retention sweeps only acked=1 rows, so those rows were counted
|
|
4543
|
+
// forever: one dead row pinned this gate to node_busy_in_flight
|
|
4544
|
+
// and force=true was the only way past it.
|
|
4545
|
+
//
|
|
4546
|
+
// Counting rule (#2029 review): a row counts while it can still be
|
|
4547
|
+
// work this node is doing — and the age ceiling must NOT be applied
|
|
4548
|
+
// to a task row whose tasks row we can see. agent-node acks the
|
|
4549
|
+
// inbox row only once the task is done, so a 90-minute (or 3-hour,
|
|
4550
|
+
// or ttl_seconds>3600) task keeps acked=0 for its whole run;
|
|
4551
|
+
// capping it by age would open this gate mid-run, which is exactly
|
|
4552
|
+
// what the gate exists to prevent. So:
|
|
4553
|
+
// 1. type='task' row with a matching tasks row — LEFT JOIN on
|
|
4554
|
+
// COALESCE(task_id, id), the key cancel_task / ack_inbox use
|
|
4555
|
+
// (first deliveries set id == task_id; retry/reassign write a
|
|
4556
|
+
// fresh transport id). Status alone decides: terminal
|
|
4557
|
+
// (replied / failed / cancelled / expired) → not counted;
|
|
4558
|
+
// anything else → counted, no age ceiling. Expiry belongs to
|
|
4559
|
+
// patrolExpiredTasks, which now acks the inbox row in the same
|
|
4560
|
+
// transaction, so an expired task leaves this count together
|
|
4561
|
+
// with its rows.
|
|
4562
|
+
// 2. every other row — a task row whose tasks row is gone
|
|
4563
|
+
// (reaped by retention, or a legacy row whose id resolves to
|
|
4564
|
+
// nothing) and non-task rows (reply / broadcast / message):
|
|
4565
|
+
// these carry no status of their own and nothing else will
|
|
4566
|
+
// ever retire them, so they get the age ceiling
|
|
4567
|
+
// IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
|
|
4568
|
+
// The count stays a per-alias inbox count: one PK join per row.
|
|
4569
|
+
const IN_FLIGHT_MAX_AGE_MINUTES = 60;
|
|
4531
4570
|
const inFlightRow = db.get<{ n: number }>(
|
|
4532
|
-
`SELECT COUNT(*) AS n FROM inbox
|
|
4533
|
-
|
|
4534
|
-
|
|
4571
|
+
`SELECT COUNT(*) AS n FROM inbox i
|
|
4572
|
+
LEFT JOIN tasks t
|
|
4573
|
+
ON i.type = 'task'
|
|
4574
|
+
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
4575
|
+
WHERE i.session_name = ?1 AND i.acked = 0
|
|
4576
|
+
AND COALESCE(i.network_id, ?2) = ?2
|
|
4577
|
+
AND ((t.task_id IS NOT NULL
|
|
4578
|
+
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
4579
|
+
OR (t.task_id IS NULL
|
|
4580
|
+
AND i.created_at >= datetime('now', ?3)))`,
|
|
4581
|
+
node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
4535
4582
|
);
|
|
4536
4583
|
const inFlight = inFlightRow?.n ?? 0;
|
|
4537
4584
|
if (inFlight > 0 && !args.force) {
|