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

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.0",
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";
@@ -1202,6 +1203,24 @@ Bun.serve({
1202
1203
  const fromSession = body.from || "api";
1203
1204
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1204
1205
  const metaJson = normalizeMetaJson((body as any).meta);
1206
+
1207
+ // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
1208
+ // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
1209
+ // is rejected with a structured `duplicate_send` error so dashboard
1210
+ // dispatch buttons and scripted REST callers get the same guarantee
1211
+ // as agent-driven MCP traffic.
1212
+ const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
1213
+ if (dedup.duplicate) {
1214
+ const payload = buildDuplicateSendPayload({
1215
+ from: fromSession,
1216
+ to: targetAlias,
1217
+ ageMs: dedup.ageMs,
1218
+ windowMs: sharedSendDedup.windowMs,
1219
+ });
1220
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
1221
+ return withCors(req, Response.json(payload, { status: 429 }));
1222
+ }
1223
+
1205
1224
  // Mirror send_task MCP: write inbox + tasks rows in a single
1206
1225
  // transaction so the dispatch is visible to dashboard's Tasks page
1207
1226
  // and the parent_task_id lineage chain. Previously this endpoint
@@ -1237,6 +1256,10 @@ Bun.serve({
1237
1256
  if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
1238
1257
  const targetSession = db.get<any>(sessionSql, ...sessionParams);
1239
1258
  if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
1259
+ // #212 — stamp the dedup index only after the inbox/tasks insert
1260
+ // succeeds. Mirrors the MCP `send_task` path so a failed write
1261
+ // never shadows a legitimate retry.
1262
+ sharedSendDedup.record(fromSession, targetAlias, body.task);
1240
1263
  return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
1241
1264
  }
1242
1265
 
@@ -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);
@@ -657,6 +658,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
657
658
 
658
659
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
659
660
  const targetAlias = canonical.alias;
661
+
662
+ // #212 dedup guardrail. If this exact (from, to, content) has already
663
+ // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
664
+ // we refuse the call and surface a structured `duplicate_send`
665
+ // error. The LLM receives the Chinese hint inside details.message
666
+ // and can act on it (rewrite the task or wait). See A站Grok #212
667
+ // incident: 50+ identical dispatches across 5 LLM turns ignored
668
+ // three STOP replies — the LLM cannot be trusted to debounce
669
+ // itself, so the runtime layer must.
670
+ const dedup = sharedSendDedup.check(from_session, targetAlias, task);
671
+ if (dedup.duplicate) {
672
+ const payload = buildDuplicateSendPayload({
673
+ from: from_session,
674
+ to: targetAlias,
675
+ ageMs: dedup.ageMs,
676
+ windowMs: sharedSendDedup.windowMs,
677
+ });
678
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
679
+ return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
680
+ }
681
+
660
682
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
661
683
  const id = uuidv4();
662
684
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
@@ -680,6 +702,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
680
702
  db.run(touchSql, touchParams);
681
703
  });
682
704
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
705
+ // Only stamp the dedup index after the inbox/tasks transaction
706
+ // succeeds, so a failed insert never silently shadows a legitimate
707
+ // retry.
708
+ sharedSendDedup.record(from_session, targetAlias, task);
683
709
 
684
710
  const session = scopedSessionStatus(targetAlias, effectiveNetId);
685
711