@sleep2agi/commhub-server 0.8.4 → 0.8.5-preview.1

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/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # @sleep2agi/commhub-server
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@sleep2agi/commhub-server.svg)](https://www.npmjs.com/package/@sleep2agi/commhub-server)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@sleep2agi/commhub-server.svg)](https://www.npmjs.com/package/@sleep2agi/commhub-server)
5
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/sleep2agi/agent-network/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-anet.sh-009e7e.svg)](https://anet.sh)
7
+
3
8
  CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config when launched through `anet`.
4
9
 
5
- The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`, currently v2.2.9 at v0.10.10) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
10
+ The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`, currently v2.2.10 at v0.10.11) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
6
11
 
7
12
  ## Quick start (verified)
8
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.4",
3
+ "version": "0.8.5-preview.1",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
7
  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";
8
8
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
9
+ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
9
10
 
10
11
  const PORT = Number(process.env.PORT) || 9200;
11
12
  const HOST = process.env.HOST || "127.0.0.1";
@@ -340,6 +341,36 @@ function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null
340
341
  return !!role && role !== "viewer";
341
342
  }
342
343
 
344
+ type RestDeliveryTarget =
345
+ | { state: "online"; alias: string; session: any }
346
+ | { state: "offline"; alias: string; session: any; message: string }
347
+ | { state: "not_found"; alias: string; message: string };
348
+
349
+ function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
350
+ const params: any[] = [alias];
351
+ let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
352
+ if (networkId) {
353
+ sql += " AND network_id = ?2";
354
+ params.push(networkId);
355
+ }
356
+ const session = db.get<any>(sql, ...params);
357
+ if (!session) {
358
+ return { state: "not_found", alias, message: `alias not found: ${alias}` };
359
+ }
360
+ const lastSeen = session.last_seen_at || session.updated_at;
361
+ const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
362
+ const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
363
+ if (String(session.status || "").toLowerCase() === "offline" || stale) {
364
+ return {
365
+ state: "offline",
366
+ alias,
367
+ session,
368
+ message: `alias is offline; task queued in inbox: ${alias}`,
369
+ };
370
+ }
371
+ return { state: "online", alias, session };
372
+ }
373
+
343
374
  // ── REST input schema ───────────────────────────────
344
375
  const TaskSchema = z.object({
345
376
  alias: z.string().min(1).max(200),
@@ -1198,10 +1229,39 @@ Bun.serve({
1198
1229
  }
1199
1230
  const canonical = resolveCanonicalAlias(taskNetId, body.alias);
1200
1231
  const targetAlias = canonical.alias;
1232
+ const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
1233
+ if (target.state === "not_found") {
1234
+ return withCors(req, Response.json({
1235
+ ok: false,
1236
+ error: "alias_not_found",
1237
+ message: target.message,
1238
+ alias: targetAlias,
1239
+ queued: false,
1240
+ ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
1241
+ }, { status: 404 }));
1242
+ }
1201
1243
  const id = crypto.randomUUID();
1202
1244
  const fromSession = body.from || "api";
1203
1245
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1204
1246
  const metaJson = normalizeMetaJson((body as any).meta);
1247
+
1248
+ // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
1249
+ // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
1250
+ // is rejected with a structured `duplicate_send` error so dashboard
1251
+ // dispatch buttons and scripted REST callers get the same guarantee
1252
+ // as agent-driven MCP traffic.
1253
+ const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
1254
+ if (dedup.duplicate) {
1255
+ const payload = buildDuplicateSendPayload({
1256
+ from: fromSession,
1257
+ to: targetAlias,
1258
+ ageMs: dedup.ageMs,
1259
+ windowMs: sharedSendDedup.windowMs,
1260
+ });
1261
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
1262
+ return withCors(req, Response.json(payload, { status: 429 }));
1263
+ }
1264
+
1205
1265
  // Mirror send_task MCP: write inbox + tasks rows in a single
1206
1266
  // transaction so the dispatch is visible to dashboard's Tasks page
1207
1267
  // and the parent_task_id lineage chain. Previously this endpoint
@@ -1232,11 +1292,26 @@ Bun.serve({
1232
1292
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
1233
1293
  if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
1234
1294
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
1235
- const sessionParams: any[] = [targetAlias];
1236
- let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
1237
- if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
1238
- const targetSession = db.get<any>(sessionSql, ...sessionParams);
1239
- if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
1295
+ if (target.state === "online") {
1296
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
1297
+ }
1298
+ // #212 stamp the dedup index only after the inbox/tasks insert
1299
+ // succeeds. Mirrors the MCP `send_task` path so a failed write
1300
+ // never shadows a legitimate retry.
1301
+ sharedSendDedup.record(fromSession, targetAlias, body.task);
1302
+ if (target.state === "offline") {
1303
+ return withCors(req, Response.json({
1304
+ ok: false,
1305
+ error: "alias_offline",
1306
+ message: target.message,
1307
+ alias: targetAlias,
1308
+ queued: true,
1309
+ task_id: id,
1310
+ message_id: id,
1311
+ session_status: target.session.status ?? "offline",
1312
+ ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
1313
+ }, { status: 202 }));
1314
+ }
1240
1315
  return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
1241
1316
  }
1242
1317
 
@@ -0,0 +1,160 @@
1
+ // Unit test for the #212 send_task dedup guardrail.
2
+ //
3
+ // Pure-function module — no DB, no MCP server, just verifies the dedup
4
+ // algebra: same key in window is rejected; different key (cross-from,
5
+ // cross-to, cross-content) is allowed; expired entry restored; disabled
6
+ // window short-circuits everything; structured payload contains both the
7
+ // Chinese hint the LLM is supposed to act on and machine-parseable
8
+ // details.
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ SendDedup,
12
+ buildDuplicateSendPayload,
13
+ readDedupConfig,
14
+ } from "./send_dedup.js";
15
+
16
+ describe("readDedupConfig", () => {
17
+ it("defaults to 300000 ms window and 4096 max keys", () => {
18
+ const cfg = readDedupConfig({});
19
+ expect(cfg.windowMs).toBe(300_000);
20
+ expect(cfg.maxKeys).toBe(4096);
21
+ });
22
+
23
+ it("respects COMMHUB_SEND_DEDUP_WINDOW_MS=0 to disable", () => {
24
+ const cfg = readDedupConfig({ COMMHUB_SEND_DEDUP_WINDOW_MS: "0" });
25
+ expect(cfg.windowMs).toBe(0);
26
+ });
27
+
28
+ it("parses custom window and max keys from env", () => {
29
+ const cfg = readDedupConfig({
30
+ COMMHUB_SEND_DEDUP_WINDOW_MS: "60000",
31
+ COMMHUB_SEND_DEDUP_MAX_KEYS: "256",
32
+ });
33
+ expect(cfg.windowMs).toBe(60_000);
34
+ expect(cfg.maxKeys).toBe(256);
35
+ });
36
+
37
+ it("clamps negative window to 0 and small max keys to 64", () => {
38
+ const cfg = readDedupConfig({
39
+ COMMHUB_SEND_DEDUP_WINDOW_MS: "-1",
40
+ COMMHUB_SEND_DEDUP_MAX_KEYS: "1",
41
+ });
42
+ expect(cfg.windowMs).toBe(0);
43
+ expect(cfg.maxKeys).toBe(64);
44
+ });
45
+ });
46
+
47
+ describe("SendDedup", () => {
48
+ it("flags a repeat send within the window", () => {
49
+ const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
50
+ const t0 = 1_700_000_000_000;
51
+
52
+ expect(d.check("alice", "bob", "hello", t0)).toEqual({ duplicate: false });
53
+ d.record("alice", "bob", "hello", t0);
54
+
55
+ const second = d.check("alice", "bob", "hello", t0 + 1000);
56
+ expect(second.duplicate).toBe(true);
57
+ if (second.duplicate) {
58
+ expect(second.lastSentMs).toBe(t0);
59
+ expect(second.ageMs).toBe(1000);
60
+ }
61
+ });
62
+
63
+ it("allows the same content after the window elapses", () => {
64
+ const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
65
+ const t0 = 1_700_000_000_000;
66
+ d.record("alice", "bob", "hello", t0);
67
+
68
+ const stillIn = d.check("alice", "bob", "hello", t0 + 59_000);
69
+ expect(stillIn.duplicate).toBe(true);
70
+
71
+ const past = d.check("alice", "bob", "hello", t0 + 60_001);
72
+ expect(past).toEqual({ duplicate: false });
73
+ });
74
+
75
+ it("does not conflate different senders, targets, or contents", () => {
76
+ const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
77
+ const t0 = 1_700_000_000_000;
78
+ d.record("alice", "bob", "hello", t0);
79
+
80
+ // Different sender → allowed.
81
+ expect(d.check("carol", "bob", "hello", t0 + 1000)).toEqual({ duplicate: false });
82
+ // Different target → allowed.
83
+ expect(d.check("alice", "dave", "hello", t0 + 1000)).toEqual({ duplicate: false });
84
+ // Different content → allowed (even with trailing whitespace tweak).
85
+ expect(d.check("alice", "bob", "hello ", t0 + 1000)).toEqual({ duplicate: false });
86
+ // Repeated original → still flagged.
87
+ expect(d.check("alice", "bob", "hello", t0 + 1000).duplicate).toBe(true);
88
+ });
89
+
90
+ it("treats windowMs=0 as fully disabled", () => {
91
+ const d = new SendDedup({ windowMs: 0, maxKeys: 32 });
92
+ expect(d.enabled).toBe(false);
93
+ d.record("alice", "bob", "hello", 1);
94
+ expect(d.check("alice", "bob", "hello", 2)).toEqual({ duplicate: false });
95
+ expect(d.size).toBe(0);
96
+ });
97
+
98
+ it("opportunistically evicts expired entries during check", () => {
99
+ const d = new SendDedup({ windowMs: 10_000, maxKeys: 32 });
100
+ d.record("alice", "bob", "hello", 1000);
101
+ d.record("alice", "bob", "world", 1500);
102
+ expect(d.size).toBe(2);
103
+
104
+ // Far past both entries' window — check should evict both.
105
+ expect(d.check("alice", "bob", "third", 100_000)).toEqual({ duplicate: false });
106
+ expect(d.size).toBe(0);
107
+ });
108
+
109
+ it("caps map size by oldest-first eviction when exceeding maxKeys", () => {
110
+ const d = new SendDedup({ windowMs: 600_000, maxKeys: 64 });
111
+ for (let i = 0; i < 100; i++) {
112
+ d.record(`from-${i}`, "bob", `content-${i}`, 1_000 + i);
113
+ }
114
+ // Eviction triggers when size > maxKeys; after eviction we keep ~90 %
115
+ // of maxKeys, so size should land below maxKeys.
116
+ expect(d.size).toBeLessThanOrEqual(64);
117
+ expect(d.size).toBeGreaterThan(0);
118
+ });
119
+
120
+ it("hashes content rather than holding the raw bytes", () => {
121
+ // The key function is the public surface that proves we don't keep
122
+ // raw content in memory between sends — the map keys themselves are
123
+ // safe to log/inspect.
124
+ const key = SendDedup.key("alice", "bob", "secret payload");
125
+ expect(key.startsWith("alice|bob|")).toBe(true);
126
+ expect(key).toMatch(/\|[0-9a-f]{64}$/);
127
+ expect(key).not.toContain("secret");
128
+ });
129
+ });
130
+
131
+ describe("buildDuplicateSendPayload", () => {
132
+ it("contains the Chinese LLM-facing hint with target alias and window minutes", () => {
133
+ const payload = buildDuplicateSendPayload({
134
+ from: "A站Grok",
135
+ to: "A站负责人",
136
+ ageMs: 12_345,
137
+ windowMs: 300_000,
138
+ });
139
+ expect(payload.ok).toBe(false);
140
+ expect(payload.error).toBe("duplicate_send");
141
+ expect(payload.message).toContain("5 分钟内已发给 A站负责人");
142
+ expect(payload.message).toContain("改写内容或等待");
143
+ expect(payload.details.age_ms).toBe(12_345);
144
+ expect(payload.details.window_ms).toBe(300_000);
145
+ expect(payload.details.from).toBe("A站Grok");
146
+ expect(payload.details.target).toBe("A站负责人");
147
+ expect(payload.details.hint_en).toContain("change the content or wait");
148
+ });
149
+
150
+ it("rounds the window display to whole minutes", () => {
151
+ const payload = buildDuplicateSendPayload({
152
+ from: "from-x",
153
+ to: "to-y",
154
+ ageMs: 0,
155
+ windowMs: 90_000,
156
+ });
157
+ // 90 s rounds to 2 min for the human-readable hint.
158
+ expect(payload.message).toContain("2 分钟内");
159
+ });
160
+ });
@@ -0,0 +1,192 @@
1
+ // #212 — commhub-server send_task dedup guardrail.
2
+ //
3
+ // Vincent's A站Grok ran away on 2026-06-10 and sent the same task to the
4
+ // same target 50+ times within five LLM turns (4692 chunks in a single
5
+ // turn). It ignored three STOP replies — by the time a reply landed back
6
+ // in its inbox the LLM had already decided to dispatch again. The dispatch
7
+ // path was the agent's own commhub_send_task MCP tool call against
8
+ // commhub-server's HTTP MCP transport (#204 preview.6 wiring), so the
9
+ // agent-node runtime never saw the bytes and could not intervene
10
+ // client-side.
11
+ //
12
+ // This module is the server-side guardrail: any `send_task` (whether
13
+ // through MCP `tools/call` or the REST `/api/task` endpoint) is checked
14
+ // against an in-memory dedup index keyed by `(from_session, target_alias,
15
+ // sha256(content))`. A second call to the same key within the configured
16
+ // window is rejected with a structured `duplicate_send` error containing
17
+ // a human-readable hint the LLM can act on ("change the task content or
18
+ // wait").
19
+ //
20
+ // Design notes:
21
+ // - Keyed by content hash, not the raw content, so we never hold the
22
+ // task body in memory longer than the request itself.
23
+ // - In-memory only. Process restart wipes the index, which is the
24
+ // desired failure mode (servers restart far more rarely than the
25
+ // 5-minute default window, and a fresh window after restart is safer
26
+ // than rehydrating from disk).
27
+ // - Opportunistic eviction during every `shouldDedup` call keeps the
28
+ // map bounded without a separate sweep timer.
29
+ // - Window = 0 disables the guardrail entirely (escape hatch for the
30
+ // rare cases where a workflow genuinely needs to fan out the same
31
+ // task repeatedly — e.g. a batch sweep that produces identical
32
+ // payloads by design).
33
+ // - The structured response shape is identical between MCP and REST
34
+ // callers so the LLM gets the same parseable hint regardless of
35
+ // transport.
36
+
37
+ import { createHash } from "crypto";
38
+
39
+ export type DedupConfig = {
40
+ /** Window in ms; 0 disables guardrail. Default 300000 (5 min). */
41
+ windowMs: number;
42
+ /** Max keys to retain; opportunistically evicted past this. Default 4096. */
43
+ maxKeys: number;
44
+ };
45
+
46
+ export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConfig {
47
+ const rawWindow = env.COMMHUB_SEND_DEDUP_WINDOW_MS;
48
+ const windowMs = rawWindow !== undefined ? Math.max(0, Number(rawWindow)) : 300_000;
49
+ const rawMax = env.COMMHUB_SEND_DEDUP_MAX_KEYS;
50
+ const maxKeys = rawMax !== undefined ? Math.max(64, Number(rawMax)) : 4096;
51
+ return {
52
+ windowMs: Number.isFinite(windowMs) ? windowMs : 300_000,
53
+ maxKeys: Number.isFinite(maxKeys) ? maxKeys : 4096,
54
+ };
55
+ }
56
+
57
+ export type DedupCheck =
58
+ | { duplicate: false }
59
+ | { duplicate: true; lastSentMs: number; ageMs: number };
60
+
61
+ export class SendDedup {
62
+ private last = new Map<string, number>();
63
+ private cfg: DedupConfig;
64
+
65
+ constructor(cfg: Partial<DedupConfig> = {}) {
66
+ const fallback = readDedupConfig();
67
+ this.cfg = { windowMs: cfg.windowMs ?? fallback.windowMs, maxKeys: cfg.maxKeys ?? fallback.maxKeys };
68
+ }
69
+
70
+ /** Whether the dedup guardrail is enabled at all. */
71
+ get enabled(): boolean {
72
+ return this.cfg.windowMs > 0;
73
+ }
74
+
75
+ get windowMs(): number {
76
+ return this.cfg.windowMs;
77
+ }
78
+
79
+ /** Visible for tests only. */
80
+ get size(): number {
81
+ return this.last.size;
82
+ }
83
+
84
+ static key(from: string, to: string, content: string): string {
85
+ const hash = createHash("sha256").update(content).digest("hex");
86
+ return `${from}|${to}|${hash}`;
87
+ }
88
+
89
+ /**
90
+ * Check whether (from, to, content) was sent recently. Returns
91
+ * `{ duplicate: false }` when the call should proceed, or
92
+ * `{ duplicate: true, lastSentMs, ageMs }` when the caller should be
93
+ * rejected with a `duplicate_send` error.
94
+ *
95
+ * Does NOT record the new send — callers should call `record(...)`
96
+ * AFTER the underlying side effect (inbox insert + pushEvent) succeeds,
97
+ * so failed sends don't accidentally block legitimate retries.
98
+ */
99
+ check(from: string, to: string, content: string, nowMs: number = Date.now()): DedupCheck {
100
+ if (!this.enabled) return { duplicate: false };
101
+ this.evictExpired(nowMs);
102
+ const k = SendDedup.key(from, to, content);
103
+ const lastSentMs = this.last.get(k);
104
+ if (lastSentMs === undefined) return { duplicate: false };
105
+ const ageMs = nowMs - lastSentMs;
106
+ if (ageMs >= this.cfg.windowMs) {
107
+ this.last.delete(k);
108
+ return { duplicate: false };
109
+ }
110
+ return { duplicate: true, lastSentMs, ageMs };
111
+ }
112
+
113
+ /** Record a successful send for future dedup checks. */
114
+ record(from: string, to: string, content: string, nowMs: number = Date.now()): void {
115
+ if (!this.enabled) return;
116
+ const k = SendDedup.key(from, to, content);
117
+ this.last.set(k, nowMs);
118
+ if (this.last.size > this.cfg.maxKeys) this.evictOldest();
119
+ }
120
+
121
+ /** Visible for tests. Drops everything. */
122
+ clear(): void {
123
+ this.last.clear();
124
+ }
125
+
126
+ private evictExpired(nowMs: number): void {
127
+ const cutoff = nowMs - this.cfg.windowMs;
128
+ for (const [k, ts] of this.last) {
129
+ if (ts < cutoff) this.last.delete(k);
130
+ }
131
+ }
132
+
133
+ private evictOldest(): void {
134
+ const target = Math.max(64, Math.floor(this.cfg.maxKeys * 0.9));
135
+ if (this.last.size <= target) return;
136
+ const entries = Array.from(this.last.entries()).sort((a, b) => a[1] - b[1]);
137
+ for (let i = 0; i < this.last.size - target; i++) this.last.delete(entries[i][0]);
138
+ }
139
+ }
140
+
141
+ // Process-wide singleton consumed by both the MCP `send_task` tool and
142
+ // the REST `/api/task` endpoint so they share a single dedup index
143
+ // regardless of which transport the agent hits. Tests reach for it via
144
+ // `sharedSendDedup.clear()` to reset between cases.
145
+ export const sharedSendDedup = new SendDedup();
146
+
147
+ /**
148
+ * Build the structured `duplicate_send` error payload returned to the
149
+ * caller (MCP wraps it inside `content[0].text` JSON; REST returns it
150
+ * directly with HTTP 429). The Chinese hint matches what 通信龙 specified
151
+ * in the #212 dispatch so the LLM has a consistent piece of text to
152
+ * reason about and rewrite around.
153
+ */
154
+ export function buildDuplicateSendPayload(args: {
155
+ from: string;
156
+ to: string;
157
+ ageMs: number;
158
+ windowMs: number;
159
+ }): {
160
+ ok: false;
161
+ error: "duplicate_send";
162
+ message: string;
163
+ details: {
164
+ from: string;
165
+ target: string;
166
+ age_ms: number;
167
+ window_ms: number;
168
+ hint_zh: string;
169
+ hint_en: string;
170
+ };
171
+ } {
172
+ const windowMin = Math.round(args.windowMs / 60_000);
173
+ const hintZh =
174
+ `同内容任务 ${windowMin} 分钟内已发给 ${args.to}, ` +
175
+ `如确需重发请改写内容或等待。 (Last sent ${args.ageMs}ms ago)`;
176
+ const hintEn =
177
+ `Same task content already sent to ${args.to} within the last ` +
178
+ `${windowMin} min — change the content or wait. (Last sent ${args.ageMs}ms ago)`;
179
+ return {
180
+ ok: false,
181
+ error: "duplicate_send",
182
+ message: hintZh,
183
+ details: {
184
+ from: args.from,
185
+ target: args.to,
186
+ age_ms: args.ageMs,
187
+ window_ms: args.windowMs,
188
+ hint_zh: hintZh,
189
+ hint_en: hintEn,
190
+ },
191
+ };
192
+ }
package/src/tools.ts CHANGED
@@ -4,6 +4,7 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
5
  import { getUserNetworkRole } from "./auth.js";
6
6
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
7
+ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
7
8
 
8
9
  function ts(): string {
9
10
  return new Date().toTimeString().slice(0, 8);
@@ -111,12 +112,75 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
111
112
  return sql;
112
113
  };
113
114
 
115
+ type DeliveryTarget =
116
+ | { state: "online"; alias: string; session: any }
117
+ | { state: "offline"; alias: string; session: any; message: string }
118
+ | { state: "not_found"; alias: string; message: string };
119
+
114
120
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
115
121
  const params: any[] = [alias];
116
- let sql = "SELECT status FROM sessions WHERE alias = ?1";
122
+ let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
117
123
  sql = addScope(sql, params, networkId);
118
124
  return db.get<any>(sql, ...params);
119
125
  };
126
+
127
+ const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
128
+ const session = scopedSessionStatus(alias, networkId);
129
+ if (!session) {
130
+ return {
131
+ state: "not_found",
132
+ alias,
133
+ message: `alias not found: ${alias}`,
134
+ };
135
+ }
136
+ const lastSeen = session.last_seen_at || session.updated_at;
137
+ const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
138
+ const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
139
+ if (String(session.status || "").toLowerCase() === "offline" || stale) {
140
+ return {
141
+ state: "offline",
142
+ alias,
143
+ session,
144
+ message: `alias is offline; message queued in inbox: ${alias}`,
145
+ };
146
+ }
147
+ return { state: "online", alias, session };
148
+ };
149
+
150
+ const deliveryTargetReply = (target: DeliveryTarget, ids: Record<string, string> = {}) => {
151
+ if (target.state === "not_found") {
152
+ return {
153
+ content: [{
154
+ type: "text" as const,
155
+ text: JSON.stringify({
156
+ ok: false,
157
+ error: "alias_not_found",
158
+ message: target.message,
159
+ alias: target.alias,
160
+ queued: false,
161
+ ...ids,
162
+ }),
163
+ }],
164
+ };
165
+ }
166
+ if (target.state === "offline") {
167
+ return {
168
+ content: [{
169
+ type: "text" as const,
170
+ text: JSON.stringify({
171
+ ok: false,
172
+ error: "alias_offline",
173
+ message: target.message,
174
+ alias: target.alias,
175
+ queued: true,
176
+ session_status: target.session.status ?? "offline",
177
+ ...ids,
178
+ }),
179
+ }],
180
+ };
181
+ }
182
+ return null;
183
+ };
120
184
  // ═══════════════════════════════════════════
121
185
  // Child Agent Tools (4)
122
186
  // ═══════════════════════════════════════════
@@ -657,6 +721,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
657
721
 
658
722
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
659
723
  const targetAlias = canonical.alias;
724
+ const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
725
+ if (target.state === "not_found") return deliveryTargetReply(target)!;
726
+
727
+ // #212 dedup guardrail. If this exact (from, to, content) has already
728
+ // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
729
+ // we refuse the call and surface a structured `duplicate_send`
730
+ // error. The LLM receives the Chinese hint inside details.message
731
+ // and can act on it (rewrite the task or wait). See A站Grok #212
732
+ // incident: 50+ identical dispatches across 5 LLM turns ignored
733
+ // three STOP replies — the LLM cannot be trusted to debounce
734
+ // itself, so the runtime layer must.
735
+ const dedup = sharedSendDedup.check(from_session, targetAlias, task);
736
+ if (dedup.duplicate) {
737
+ const payload = buildDuplicateSendPayload({
738
+ from: from_session,
739
+ to: targetAlias,
740
+ ageMs: dedup.ageMs,
741
+ windowMs: sharedSendDedup.windowMs,
742
+ });
743
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
744
+ return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
745
+ }
746
+
660
747
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
661
748
  const id = uuidv4();
662
749
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
@@ -680,8 +767,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
680
767
  db.run(touchSql, touchParams);
681
768
  });
682
769
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
683
-
684
- const session = scopedSessionStatus(targetAlias, effectiveNetId);
770
+ // Only stamp the dedup index after the inbox/tasks transaction
771
+ // succeeds, so a failed insert never silently shadows a legitimate
772
+ // retry.
773
+ sharedSendDedup.record(from_session, targetAlias, task);
685
774
 
686
775
  // SSE push by alias.
687
776
  // The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
@@ -694,7 +783,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
694
783
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
695
784
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
696
785
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
697
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
786
+ if (target.state === "online") {
787
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
788
+ }
789
+
790
+ if (target.state === "offline") {
791
+ return {
792
+ content: [{
793
+ type: "text" as const,
794
+ text: JSON.stringify({
795
+ ok: false,
796
+ error: "alias_offline",
797
+ message: target.message,
798
+ alias: targetAlias,
799
+ queued: true,
800
+ task_id: id,
801
+ message_id: id,
802
+ session_status: target.session.status ?? "offline",
803
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
804
+ }),
805
+ }],
806
+ };
807
+ }
698
808
 
699
809
  return {
700
810
  content: [
@@ -704,7 +814,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
704
814
  ok: true,
705
815
  message_id: id,
706
816
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
707
- session_status: session?.status ?? "unknown",
817
+ session_status: target.session?.status ?? "unknown",
708
818
  }),
709
819
  },
710
820
  ],
@@ -723,6 +833,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
723
833
  async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
724
834
  const effectiveNetId = getNetworkId(null);
725
835
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
836
+ const target = resolveDeliveryTarget(alias, effectiveNetId);
837
+ if (target.state === "not_found") return deliveryTargetReply(target)!;
726
838
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
727
839
  const id = uuidv4();
728
840
  db.run(
@@ -731,9 +843,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
731
843
  [id, alias, message, from_session, effectiveNetId ?? null]
732
844
  );
733
845
 
734
- const session = scopedSessionStatus(alias, effectiveNetId);
846
+ if (target.state === "online") {
847
+ pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
848
+ }
735
849
 
736
- pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
850
+ const offlineReply = deliveryTargetReply(target, { message_id: id });
851
+ if (offlineReply) return offlineReply;
737
852
 
738
853
  return {
739
854
  content: [
@@ -742,7 +857,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
742
857
  text: JSON.stringify({
743
858
  ok: true,
744
859
  message_id: id,
745
- session_status: session?.status ?? "unknown",
860
+ session_status: target.session?.status ?? "unknown",
746
861
  }),
747
862
  },
748
863
  ],
@@ -766,6 +881,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
766
881
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
767
882
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
768
883
  const id = uuidv4();
884
+ let taskBefore: { status: string } | null = null;
885
+ if (in_reply_to) {
886
+ const taskParams: any[] = [in_reply_to];
887
+ let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
888
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
889
+ taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
890
+ if (!taskBefore) {
891
+ return {
892
+ content: [{
893
+ type: "text" as const,
894
+ text: JSON.stringify({
895
+ ok: false,
896
+ error: "reply_task_not_found",
897
+ message: `cannot apply reply: task not found (${in_reply_to})`,
898
+ in_reply_to,
899
+ reply_queued: false,
900
+ }),
901
+ }],
902
+ };
903
+ }
904
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
905
+ return {
906
+ content: [{
907
+ type: "text" as const,
908
+ text: JSON.stringify({
909
+ ok: false,
910
+ error: "reply_task_terminal",
911
+ message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
912
+ in_reply_to,
913
+ task_status: taskBefore.status,
914
+ reply_queued: false,
915
+ }),
916
+ }],
917
+ };
918
+ }
919
+ }
769
920
  const replyLogged = db.transaction(() => {
770
921
  db.run(
771
922
  `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
@@ -789,6 +940,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
789
940
  return false;
790
941
  });
791
942
 
943
+ if (in_reply_to && !replyLogged) {
944
+ return {
945
+ content: [{
946
+ type: "text" as const,
947
+ text: JSON.stringify({
948
+ ok: false,
949
+ error: "reply_not_applied",
950
+ message: "reply was not applied to task",
951
+ in_reply_to,
952
+ reply_queued: false,
953
+ }),
954
+ }],
955
+ };
956
+ }
957
+
792
958
  // Log event after commit (outside transaction)
793
959
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
794
960