@sleep2agi/commhub-server 0.9.0-preview.95 → 0.9.0-preview.96

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.95",
3
+ "version": "0.9.0-preview.96",
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",
@@ -0,0 +1,269 @@
1
+ // #507 — two live copies of one node (directory copied verbatim: same node token,
2
+ // same alias) used to hold two SSE subscriptions on `${net}:${alias}`. Every new
3
+ // task was pushed to BOTH (each copy ran it), and either copy's shutdown
4
+ // report_status(offline) took the node offline under the other → alias_offline.
5
+ //
6
+ // Pinned here, through the real HTTP entrances (GET /events/:alias with the node
7
+ // token, POST /mcp report_status, POST /api/task, GET /api/stats/sse):
8
+ // 1. newest connection wins: the older node stream gets node_connection_superseded
9
+ // (reason superseded_by_new_connection) and is closed; /api/stats/sse shows 1.
10
+ // 2. a new task reaches exactly ONE node subscriber; a dashboard monitor on the
11
+ // same channel is untouched and still sees it.
12
+ // 3. node_identity_conflict reaches the network observer stream and
13
+ // /api/stats/sse.identity_conflicts, with both remotes and no token.
14
+ // 4. offline only when the last node connection leaves; tasks in between are
15
+ // delivered, not alias_offline.
16
+ // 5. same instance id = a reconnect (replaced_by_reconnect, no conflict);
17
+ // different ids = instance_match "different"; another node's token watching
18
+ // this alias never supersedes it; a plain single-node stop is offline at once.
19
+ //
20
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-identity-conflict-http.test.ts
21
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+ import { createNetworkTokenForNode, register } from "./auth.js";
27
+ import { db } from "./db.js";
28
+
29
+ const dir = mkdtempSync(join(tmpdir(), "anet-node-conflict-"));
30
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
31
+ if (!activeDbPath) throw new Error("node-identity-conflict requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
32
+
33
+ const stamp = Date.now();
34
+ let server: any;
35
+ let base = "";
36
+ let ownerToken = "", ownerId = "", NET = "";
37
+ const nodes: Record<string, { alias: string; nodeId: string; token: string }> = {};
38
+
39
+ type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
40
+
41
+ function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
42
+ const ctrl = new AbortController();
43
+ const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
44
+ let markReady!: () => void;
45
+ sub.ready = new Promise<void>((r) => { markReady = r; });
46
+ (async () => {
47
+ try {
48
+ const res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers }, signal: ctrl.signal });
49
+ if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
50
+ const reader = res.body.getReader();
51
+ const dec = new TextDecoder();
52
+ let buf = "";
53
+ while (true) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ buf += dec.decode(value, { stream: true });
57
+ const lines = buf.split("\n"); buf = lines.pop() || "";
58
+ for (const line of lines) {
59
+ if (!line.startsWith("data: ")) continue;
60
+ const ev = JSON.parse(line.slice(6));
61
+ sub.events.push(ev);
62
+ if (ev.type === "connected") markReady();
63
+ }
64
+ }
65
+ } catch { /* aborted */ }
66
+ sub.done = true;
67
+ markReady();
68
+ })();
69
+ return sub;
70
+ }
71
+
72
+ async function until(cond: () => boolean, what: string, ms = 3000) {
73
+ const t0 = Date.now();
74
+ while (!cond()) {
75
+ if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
76
+ await new Promise((r) => setTimeout(r, 20));
77
+ }
78
+ }
79
+ const settle = () => new Promise((r) => setTimeout(r, 150));
80
+
81
+ async function api(token: string, method: string, path: string, body?: unknown) {
82
+ const res = await fetch(`${base}${path}`, {
83
+ method,
84
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
85
+ body: body === undefined ? undefined : JSON.stringify(body),
86
+ });
87
+ return { status: res.status, body: await res.json().catch(() => null) as any };
88
+ }
89
+
90
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
91
+ const res = await fetch(`${base}/mcp`, {
92
+ method: "POST",
93
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
94
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
95
+ });
96
+ const raw = await res.text();
97
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
98
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
99
+ const text = payload.result.content[0].text;
100
+ if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
101
+ return JSON.parse(text);
102
+ }
103
+
104
+ const reportStatus = (n: { alias: string; nodeId: string; token: string }, status: string) =>
105
+ tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status, node_id: n.nodeId, network_id: NET });
106
+ const storedStatus = (alias: string) =>
107
+ db.get<{ status: string }>("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET)?.status;
108
+ let seq = 0;
109
+ const sendTask = (alias: string) => api(ownerToken, "POST", "/api/task", { alias, task: `conflict task ${stamp} #${++seq}`, network_id: NET });
110
+ const nodeStream = (n: { alias: string; token: string }, headers: Record<string, string> = {}) =>
111
+ subscribe(`/events/${encodeURIComponent(n.alias)}`, n.token, headers);
112
+ const newTasks = (s: Sub) => s.events.filter((e) => e.type === "new_task");
113
+ const sseCount = async (alias: string) => (await api(nodes.a.token, "GET", "/api/stats/sse")).body.sessions[`${NET}:${alias}`] ?? 0;
114
+
115
+ beforeAll(async () => {
116
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
117
+ process.env.HOST = "127.0.0.1";
118
+ const owner = register(`nic_owner_${stamp}`, "NodeConflictOwner123!", undefined, "seed");
119
+ ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
120
+ for (const key of ["a", "b", "c", "d", "e"]) {
121
+ const alias = `nic-${key}-${stamp}`;
122
+ const nodeId = `n_nic_${key}_${stamp}`;
123
+ const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
124
+ expect(minted.ok).toBe(true);
125
+ nodes[key] = { alias, nodeId, token: minted.token! };
126
+ }
127
+ const mod: any = await import("./server.js");
128
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
129
+ base = `http://127.0.0.1:${server.port}`;
130
+ for (const n of Object.values(nodes)) expect((await reportStatus(n, "idle")).ok).toBe(true);
131
+ }, 30_000);
132
+
133
+ afterAll(() => {
134
+ try { server?.stop?.(true); } catch {}
135
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
136
+ });
137
+
138
+ describe("#507 two copies of one node (same token, same alias)", () => {
139
+ const subs: Record<string, Sub> = {};
140
+
141
+ test("newest connection wins: the older node stream is told why and closed", async () => {
142
+ const n = nodes.a;
143
+ subs.observer = subscribe(`/events/network/${NET}`, ownerToken);
144
+ subs.monitor = subscribe(`/events/${encodeURIComponent(n.alias)}?network_id=${NET}`, ownerToken);
145
+ await subs.observer.ready; await subs.monitor.ready;
146
+ subs.copyA = nodeStream(n, { "User-Agent": "copy-A" });
147
+ await subs.copyA.ready;
148
+ expect(await sseCount(n.alias)).toBe(2); // copy A + dashboard monitor
149
+
150
+ subs.copyB = nodeStream(n, { "User-Agent": "copy-B" });
151
+ await subs.copyB.ready;
152
+ await until(() => subs.copyA.done, "copy A's stream to be closed");
153
+ const notice = subs.copyA.events.find((e) => e.type === "node_connection_superseded");
154
+ expect(notice).toBeTruthy();
155
+ expect(notice.reason).toBe("superseded_by_new_connection");
156
+ expect(notice.instance_match).toBe("unknown");
157
+ expect(subs.copyB.done).toBe(false);
158
+ expect(subs.monitor.done).toBe(false); // a monitor is never superseded
159
+ expect(await sseCount(n.alias)).toBe(2); // copy B + monitor
160
+ });
161
+
162
+ test("a new task reaches exactly one node subscriber (the monitor still sees it)", async () => {
163
+ const r = await sendTask(nodes.a.alias);
164
+ expect(r.status).toBe(200);
165
+ expect(r.body.ok).toBe(true);
166
+ await until(() => newTasks(subs.copyB).length >= 1 && newTasks(subs.monitor).length >= 1, "new_task on copy B + monitor");
167
+ await settle();
168
+ expect(newTasks(subs.copyB).length).toBe(1);
169
+ expect(newTasks(subs.copyA).length).toBe(0);
170
+ expect(newTasks(subs.monitor).length).toBe(1);
171
+ });
172
+
173
+ test("node_identity_conflict reaches the observer stream and /api/stats/sse, with remotes and no token", async () => {
174
+ await until(() => subs.observer.events.some((e) => e.type === "node_identity_conflict"), "observer conflict event");
175
+ const ev = subs.observer.events.find((e) => e.type === "node_identity_conflict");
176
+ expect(ev.alias).toBe(nodes.a.alias);
177
+ expect(ev.node_id).toBe(nodes.a.nodeId);
178
+ expect(ev.policy).toBe("newest_connection_wins");
179
+ expect(ev.superseded.user_agent).toBe("copy-A");
180
+ expect(ev.current.user_agent).toBe("copy-B");
181
+ expect(ev.superseded.remote).toMatch(/^127\.0\.0\.1:\d+$/);
182
+ expect(ev.current.remote).toMatch(/^127\.0\.0\.1:\d+$/);
183
+ expect(ev.superseded.remote).not.toBe(ev.current.remote);
184
+ const stats = await api(nodes.a.token, "GET", "/api/stats/sse");
185
+ const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === nodes.a.alias);
186
+ expect(mine.length).toBe(1);
187
+ expect(mine[0].network_id).toBe(NET);
188
+ const raw = JSON.stringify(stats.body) + JSON.stringify(ev);
189
+ expect(raw).not.toContain("ntok_");
190
+ expect(raw).not.toContain(nodes.a.token);
191
+ const audit = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_identity_conflict' AND target_id = ?1", nodes.a.nodeId);
192
+ expect(audit?.n).toBe(1);
193
+ });
194
+
195
+ test("offline only when the last node connection leaves; tasks in between are delivered", async () => {
196
+ const n = nodes.a;
197
+ // Copy A (the superseded one) shuts down and says so.
198
+ const fromA = await reportStatus(n, "offline");
199
+ expect(fromA.ok).toBe(true);
200
+ expect(fromA.offline_deferred).toBe(true);
201
+ expect(storedStatus(n.alias)).not.toBe("offline");
202
+ const r = await sendTask(n.alias);
203
+ expect(r.status).toBe(200);
204
+ expect(r.body.ok).toBe(true);
205
+ expect(r.body.error).toBeUndefined();
206
+ await until(() => newTasks(subs.copyB).length >= 2, "second new_task on copy B");
207
+
208
+ // Copy B stops too: it reports offline while its stream is still open, then exits.
209
+ const fromB = await reportStatus(n, "offline");
210
+ expect(fromB.offline_deferred).toBe(true);
211
+ expect(storedStatus(n.alias)).not.toBe("offline");
212
+ subs.copyB.abort();
213
+ await until(() => storedStatus(n.alias) === "offline", "deferred offline applied once the last node stream closed");
214
+ expect((await sendTask(n.alias)).body.error).toBe("alias_offline");
215
+ subs.monitor.abort(); subs.observer.abort();
216
+ });
217
+ });
218
+
219
+ describe("#507 what is and is not a conflict", () => {
220
+ test("same instance id = a reconnect of the same process: replaced, no conflict recorded", async () => {
221
+ const n = nodes.b;
222
+ const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
223
+ await first.ready;
224
+ const second = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
225
+ await second.ready;
226
+ await until(() => first.done, "old stream replaced");
227
+ expect(first.events.find((e) => e.type === "node_connection_superseded")?.reason).toBe("replaced_by_reconnect");
228
+ const stats = await api(n.token, "GET", "/api/stats/sse");
229
+ expect(stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias)).toEqual([]);
230
+ // No conflict → a stop is exactly as before: offline at once.
231
+ const r = await reportStatus(n, "offline");
232
+ expect(r.offline_deferred).toBeUndefined();
233
+ expect(storedStatus(n.alias)).toBe("offline");
234
+ second.abort();
235
+ });
236
+
237
+ test("different instance ids are reported as instance_match=different", async () => {
238
+ const n = nodes.c;
239
+ const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-c-host1" });
240
+ await first.ready;
241
+ const second = subscribe(`/events/${encodeURIComponent(n.alias)}?instance_id=inst-c-host2`, n.token);
242
+ await second.ready;
243
+ await until(() => first.done, "old stream superseded");
244
+ const stats = await api(n.token, "GET", "/api/stats/sse");
245
+ const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias);
246
+ expect(mine.length).toBe(1);
247
+ expect(mine[0].instance_match).toBe("different");
248
+ expect(mine[0].superseded.instance_id).toBe("inst-c-host1");
249
+ expect(mine[0].current.instance_id).toBe("inst-c-host2");
250
+ second.abort();
251
+ });
252
+
253
+ test("another node's token watching this alias never supersedes the node", async () => {
254
+ const n = nodes.d;
255
+ const own = nodeStream(n);
256
+ await own.ready;
257
+ const watcher = subscribe(`/events/${encodeURIComponent(n.alias)}`, nodes.e.token);
258
+ await watcher.ready;
259
+ await settle();
260
+ expect(own.done).toBe(false);
261
+ expect(own.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
262
+ watcher.abort();
263
+ // And a plain single-node stop (no conflict) is offline immediately, as before.
264
+ const r = await reportStatus(n, "offline");
265
+ expect(r.offline_deferred).toBeUndefined();
266
+ expect(storedStatus(n.alias)).toBe("offline");
267
+ own.abort();
268
+ });
269
+ });
@@ -0,0 +1,142 @@
1
+ // #507 — two live processes holding the same node identity (a node directory
2
+ // copied verbatim and started elsewhere: same node token, same alias).
3
+ //
4
+ // push.ts enforces "at most one node-scoped SSE subscriber per (network, alias)",
5
+ // newest connection wins (policy + rationale next to enforceSingleNodeSubscriber).
6
+ // This module is the Hub-side bookkeeping around that:
7
+ //
8
+ // 1. node_identity_conflict — every supersede between connections that are not
9
+ // provably the same process is logged, written to audit_log, pushed to the
10
+ // network observer stream (dashboard) and kept in a small ring that
11
+ // GET /api/stats/sse returns. Carries both connections' remote info; never
12
+ // a token.
13
+ //
14
+ // 2. offline while another copy is still connected — a copy's shutdown
15
+ // report_status(offline) used to mark the node offline even though the other
16
+ // copy was connected, so new tasks got alias_offline. While a conflict is
17
+ // recent for that alias and a node-scoped subscriber is still connected, the
18
+ // offline is DEFERRED: stored status stays as it was, and the offline is
19
+ // applied only when the last node-scoped subscriber leaves (or dropped when the
20
+ // surviving copy reports a fresh status). Outside a conflict window nothing
21
+ // changes: a normal single-node stop marks offline immediately, as before.
22
+
23
+ import { db, logAudit } from "./db.js";
24
+ import {
25
+ hasNodeSubscriber,
26
+ onNodeIdentityConflict,
27
+ onNodeSubscribersDrained,
28
+ pushEvent,
29
+ pushNetworkObserverEvent,
30
+ type NodeIdentityConflict,
31
+ } from "./push.js";
32
+
33
+ /** How long after a conflict an offline report is treated as "maybe the other copy". */
34
+ export const CONFLICT_WINDOW_MS = 10 * 60 * 1000;
35
+ const RING_MAX = 50;
36
+ /** Observer/audit emission is rate-limited per alias (logs are not). A flapping
37
+ * pair supersedes about once a second; one event a minute is enough to see it. */
38
+ const EMIT_EVERY_MS = 60 * 1000;
39
+
40
+ const ring: NodeIdentityConflict[] = [];
41
+ const lastConflictAt = new Map<string, number>();
42
+ const lastEmittedAt = new Map<string, number>();
43
+ const pendingOffline = new Map<string, { resumeId: string; at: number }>();
44
+
45
+ const k = (alias: string, networkId: string | null | undefined) => `${networkId || "global"}:${alias}`;
46
+
47
+ function publicInfo(i: NodeIdentityConflict["current"]) {
48
+ return {
49
+ instance_id: i.instanceId,
50
+ remote: i.remote,
51
+ user_agent: i.userAgent,
52
+ connected_at: new Date(i.connectedAt).toISOString(),
53
+ };
54
+ }
55
+
56
+ export function conflictPayload(c: NodeIdentityConflict) {
57
+ return {
58
+ type: "node_identity_conflict",
59
+ alias: c.alias,
60
+ node_id: c.nodeId,
61
+ instance_match: c.instanceMatch,
62
+ policy: "newest_connection_wins",
63
+ superseded: publicInfo(c.superseded),
64
+ current: publicInfo(c.current),
65
+ at: new Date(c.at).toISOString(),
66
+ };
67
+ }
68
+
69
+ function handleConflict(c: NodeIdentityConflict): void {
70
+ const key = k(c.alias, c.networkId);
71
+ lastConflictAt.set(key, c.at);
72
+ ring.push(c);
73
+ if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
74
+ const payload = conflictPayload(c);
75
+ console.warn(`[node_identity_conflict] ${c.key} node_id=${c.nodeId ?? "?"} instance_match=${c.instanceMatch} ` +
76
+ `superseded=${c.superseded.remote ?? "?"}(${c.superseded.instanceId ?? "no-instance-id"}) ` +
77
+ `by=${c.current.remote ?? "?"}(${c.current.instanceId ?? "no-instance-id"}) — ` +
78
+ `two processes are using this node's identity (copied node directory?); newest connection kept`);
79
+ const last = lastEmittedAt.get(key) ?? 0;
80
+ if (c.at - last < EMIT_EVERY_MS) return;
81
+ lastEmittedAt.set(key, c.at);
82
+ pushNetworkObserverEvent(c.networkId, payload);
83
+ logAudit(null, null, "node_identity_conflict", "node", c.nodeId ?? c.alias, JSON.stringify(payload), c.current.remote ?? undefined, c.networkId ?? undefined);
84
+ }
85
+
86
+ function handleDrained(alias: string, networkId: string | null): void {
87
+ const key = k(alias, networkId);
88
+ const pending = pendingOffline.get(key);
89
+ if (!pending) return;
90
+ pendingOffline.delete(key);
91
+ try {
92
+ db.run(
93
+ "UPDATE sessions SET status = 'offline', updated_at = datetime('now') WHERE resume_id = ?1 AND alias = ?2 AND network_id = ?3",
94
+ [pending.resumeId, alias, networkId ?? "default"],
95
+ );
96
+ console.log(`[node_identity_conflict] ${key} last node connection gone → deferred offline applied`);
97
+ pushEvent(alias, { type: "status_update", alias, status: "offline", progress: null, host: null, process_telemetry: null }, networkId);
98
+ } catch (e: any) {
99
+ console.log(`[node_identity_conflict] deferred offline failed for ${key}: ${e?.message || e}`);
100
+ }
101
+ }
102
+
103
+ onNodeIdentityConflict(handleConflict);
104
+ onNodeSubscribersDrained(handleDrained);
105
+
106
+ /** report_status hook. Returns true when an offline report must NOT be stored now
107
+ * (another copy of this node is still connected); it is then applied when the last
108
+ * node-scoped subscriber leaves. Any non-offline report clears a pending offline. */
109
+ export function deferOfflineIfAnotherCopyConnected(
110
+ alias: string,
111
+ networkId: string | null | undefined,
112
+ status: string,
113
+ resumeId: string,
114
+ now = Date.now(),
115
+ ): boolean {
116
+ const key = k(alias, networkId);
117
+ if (status !== "offline") {
118
+ pendingOffline.delete(key);
119
+ return false;
120
+ }
121
+ const conflictAt = lastConflictAt.get(key);
122
+ if (conflictAt === undefined || now - conflictAt > CONFLICT_WINDOW_MS) return false;
123
+ if (!hasNodeSubscriber(alias, networkId)) return false;
124
+ pendingOffline.set(key, { resumeId, at: now });
125
+ console.warn(`[node_identity_conflict] ${key} report_status(offline) deferred: a node connection is still open ` +
126
+ `and a second copy was seen ${Math.round((now - conflictAt) / 1000)}s ago`);
127
+ return true;
128
+ }
129
+
130
+ /** Recent conflicts, newest last, optionally limited to one network. */
131
+ export function recentNodeIdentityConflicts(networkId?: string | null) {
132
+ return ring
133
+ .filter((c) => networkId === undefined || c.networkId === networkId)
134
+ .map((c) => ({ network_id: c.networkId, ...conflictPayload(c) }));
135
+ }
136
+
137
+ export function __resetNodeIdentityConflictsForTest(): void {
138
+ ring.length = 0;
139
+ lastConflictAt.clear();
140
+ lastEmittedAt.clear();
141
+ pendingOffline.clear();
142
+ }
package/src/push.ts CHANGED
@@ -88,6 +88,36 @@ type SSEClient = {
88
88
  subscriberUserId?: string;
89
89
  /** 只推满足条件的事件(受限成员的网络观察流用)。缺省 = 全推。 */
90
90
  eventFilter?: (event: Record<string, unknown>) => boolean;
91
+ /** #507 — set only on a node's OWN task stream (/events/<alias> opened with the
92
+ * node token bound to that alias). Dashboard monitors / master-token streams on
93
+ * the same key leave it unset and are never superseded or counted here. */
94
+ node?: NodeSubscriberInfo;
95
+ };
96
+
97
+ /** #507 — what the Hub knows about one node-scoped SSE connection. No tokens. */
98
+ export type NodeSubscriberInfo = {
99
+ nodeId: string | null;
100
+ /** Optional, client-supplied (`X-Anet-Instance-Id` header or `?instance_id=`).
101
+ * null = the client did not say (every agent-node released so far). */
102
+ instanceId: string | null;
103
+ remote: string | null;
104
+ userAgent: string | null;
105
+ connectedAt: number;
106
+ };
107
+
108
+ /** #507 — one supersede of a node-scoped connection by a newer one. */
109
+ export type NodeIdentityConflict = {
110
+ key: string;
111
+ alias: string;
112
+ networkId: string | null;
113
+ nodeId: string | null;
114
+ /** "same" = both connections carried the same instance id (a reconnect of the
115
+ * same process; not reported as a conflict). "different" = both carried ids and
116
+ * they differ (two live copies). "unknown" = at least one carried none. */
117
+ instanceMatch: "same" | "different" | "unknown";
118
+ superseded: NodeSubscriberInfo;
119
+ current: NodeSubscriberInfo;
120
+ at: number;
91
121
  };
92
122
 
93
123
  // 一个 session 可能有多个 SSE 连接(重连时短暂并存)
@@ -158,6 +188,9 @@ function closeClient(client: SSEClient, reason: string): void {
158
188
  // we're done with it.
159
189
  }
160
190
  console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
191
+ if (client.node && reason !== SUPERSEDED_REASON && !hasLiveNodeClient(client.key)) {
192
+ notifyNodeDrained(client.key);
193
+ }
161
194
  if (isUserStreamClient(client)) {
162
195
  touchUserSeen(client.subscriberUserId!);
163
196
  if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
@@ -260,14 +293,114 @@ function ensureLivenessSweep(): void {
260
293
  // Public API
261
294
  // ─────────────────────────────────────────────────────────────────────
262
295
 
263
- /** 创建 SSE Response 并注册到 clients map */
264
- export function createSSEStream(sessionName: string, networkId?: string | null): Response {
296
+ /** 创建 SSE Response 并注册到 clients map。
297
+ * #507 — pass `opts.node` only for a node's own stream (node token bound to this
298
+ * alias); such a stream supersedes any older node-scoped stream on the same key. */
299
+ export function createSSEStream(
300
+ sessionName: string,
301
+ networkId?: string | null,
302
+ opts: { node?: Omit<NodeSubscriberInfo, "connectedAt"> } = {},
303
+ ): Response {
265
304
  return createStreamForKey(
266
305
  clientKey(sessionName, networkId),
267
306
  { type: "connected", session: sessionName, network_id: networkId ?? null },
307
+ opts.node ? { node: { ...opts.node, connectedAt: Date.now() } } : {},
268
308
  );
269
309
  }
270
310
 
311
+ // ── #507 node identity conflict: at most ONE node-scoped subscriber per key ──
312
+ //
313
+ // Before #507 every connection on `${networkId}:${alias}` received every push. A
314
+ // node directory copied verbatim (same node token, same alias) and started
315
+ // elsewhere therefore got every new_task twice — both copies ran it — and either
316
+ // copy's shutdown report_status(offline) marked the node offline while the other
317
+ // was still connected.
318
+ //
319
+ // Policy: NEWEST CONNECTION WINS. Measured reconnect path (agent-node connectSSE →
320
+ // superviseChild): a restarted process's old socket is closed by the OS, so a
321
+ // legit restart never overlaps its own previous stream; an overlap only happens
322
+ // with (a) a half-open ghost the Hub has not reaped yet (up to STUCK_CLOSE_MS) or
323
+ // (b) a second live copy. In (a) the new connection is the real one — keeping the
324
+ // old would park tasks on a dead socket — so the newest must win. In (b) the
325
+ // loser reconnects ~1 s later and wins back: the copies flap, which is loud
326
+ // (log + node_identity_conflict event + /api/stats/sse) instead of silently
327
+ // double-executing. An "only if both alive > N s" rule cannot tell (a) from (b):
328
+ // a ghost looks alive to the Hub until it is reaped.
329
+ export const SUPERSEDED_REASON = "superseded_by_new_connection";
330
+ const REPLACED_REASON = "replaced_by_reconnect";
331
+
332
+ let nodeConflictListener: ((c: NodeIdentityConflict) => void) | null = null;
333
+ let nodeDrainedListener: ((alias: string, networkId: string | null) => void) | null = null;
334
+
335
+ /** Called for every supersede whose instances are not provably the same process. */
336
+ export function onNodeIdentityConflict(cb: ((c: NodeIdentityConflict) => void) | null): void {
337
+ nodeConflictListener = cb;
338
+ }
339
+
340
+ /** Called when the LAST node-scoped subscriber of a key goes away (not when it is
341
+ * superseded — the newer connection is still there). */
342
+ export function onNodeSubscribersDrained(cb: ((alias: string, networkId: string | null) => void) | null): void {
343
+ nodeDrainedListener = cb;
344
+ }
345
+
346
+ function splitClientKey(key: string): { alias: string; networkId: string | null } {
347
+ const i = key.indexOf(":");
348
+ const net = key.slice(0, i);
349
+ return { alias: key.slice(i + 1), networkId: net === "global" ? null : net };
350
+ }
351
+
352
+ function hasLiveNodeClient(key: string): boolean {
353
+ return (clients.get(key) ?? []).some((c) => !c.closed && !!c.node);
354
+ }
355
+
356
+ function notifyNodeDrained(key: string): void {
357
+ if (!nodeDrainedListener) return;
358
+ const { alias, networkId } = splitClientKey(key);
359
+ try { nodeDrainedListener(alias, networkId); } catch (e: any) {
360
+ console.log(`[${ts()}] node drained listener failed: ${e?.message || e}`);
361
+ }
362
+ }
363
+
364
+ /** Is a node-scoped (node-token) subscriber connected on this alias right now? */
365
+ export function hasNodeSubscriber(sessionName: string, networkId?: string | null): boolean {
366
+ return hasLiveNodeClient(clientKey(sessionName, networkId));
367
+ }
368
+
369
+ /** Keep only `keep` among the node-scoped clients of `key`; close the others with
370
+ * a final `node_connection_superseded` frame. Dashboard monitors are untouched. */
371
+ function enforceSingleNodeSubscriber(key: string, keep: SSEClient): void {
372
+ const arr = clients.get(key);
373
+ if (!arr || !keep.node) return;
374
+ for (const old of arr) {
375
+ if (old === keep || old.closed || !old.node) continue;
376
+ const a = old.node.instanceId, b = keep.node.instanceId;
377
+ const instanceMatch: NodeIdentityConflict["instanceMatch"] = a && b ? (a === b ? "same" : "different") : "unknown";
378
+ const reason = instanceMatch === "same" ? REPLACED_REASON : SUPERSEDED_REASON;
379
+ tryEnqueueBytes(old, old.encoder.encode(`data: ${JSON.stringify({
380
+ type: "node_connection_superseded",
381
+ reason,
382
+ instance_match: instanceMatch,
383
+ // The newer connection's network origin, so the loser's log says WHERE the
384
+ // other copy runs. Never a token.
385
+ by: { instance_id: keep.node.instanceId, remote: keep.node.remote },
386
+ })}\n\n`));
387
+ closeClient(old, reason);
388
+ if (instanceMatch !== "same" && nodeConflictListener) {
389
+ const { alias, networkId } = splitClientKey(key);
390
+ try {
391
+ nodeConflictListener({
392
+ key: printableKey(key), alias, networkId,
393
+ nodeId: keep.node.nodeId ?? old.node.nodeId,
394
+ instanceMatch, superseded: old.node, current: keep.node, at: Date.now(),
395
+ });
396
+ } catch (e: any) {
397
+ console.log(`[${ts()}] node conflict listener failed: ${e?.message || e}`);
398
+ }
399
+ }
400
+ }
401
+ pruneClosed(key);
402
+ }
403
+
271
404
  /** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
272
405
  * Shares the exact same registration / backpressure / liveness
273
406
  * machinery as session streams; only the key scheme differs. */
@@ -295,7 +428,7 @@ export function createUserEventStream(networkId: string, userId: string): Respon
295
428
  function createStreamForKey(
296
429
  key: string,
297
430
  initialEvent: Record<string, unknown>,
298
- opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
431
+ opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean; node?: NodeSubscriberInfo } = {},
299
432
  ): Response {
300
433
  const encoder = new TextEncoder();
301
434
  let client: SSEClient;
@@ -310,6 +443,7 @@ function createStreamForKey(
310
443
  key,
311
444
  ...(opts.subscriberUserId ? { subscriberUserId: opts.subscriberUserId } : {}),
312
445
  ...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
446
+ ...(opts.node ? { node: opts.node } : {}),
313
447
  };
314
448
 
315
449
  const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
@@ -318,6 +452,7 @@ function createStreamForKey(
318
452
  clients.get(key)!.push(client);
319
453
  if (presenceUser) touchUserSeen(presenceUser);
320
454
  console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
455
+ if (client.node) enforceSingleNodeSubscriber(key, client);
321
456
 
322
457
  // Send initial connected frame through the backpressure guard so
323
458
  // even the first byte respects the contract.
@@ -406,9 +541,16 @@ function rekeyClient(oldSessionName: string, newSessionName: string, networkId?:
406
541
  for (const c of oldClients) c.key = newKey;
407
542
 
408
543
  const existing = clients.get(newKey) || [];
409
- clients.set(newKey, existing.concat(oldClients));
544
+ const merged = existing.concat(oldClients);
545
+ clients.set(newKey, merged);
410
546
  clients.delete(oldKey);
411
547
  console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
548
+ // #507 — a rename can merge two node-scoped streams onto one key; newest wins.
549
+ const nodeClients = merged.filter((c) => !c.closed && c.node);
550
+ if (nodeClients.length > 1) {
551
+ const newest = nodeClients.reduce((a, b) => (b.node!.connectedAt >= a.node!.connectedAt ? b : a));
552
+ enforceSingleNodeSubscriber(newKey, newest);
553
+ }
412
554
  return oldClients.length;
413
555
  }
414
556
 
package/src/server.ts CHANGED
@@ -18,6 +18,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
18
18
  import { z } from "zod/v4";
19
19
  import { registerTools } from "./tools.js";
20
20
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
21
+ import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
21
22
  import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
22
23
  import { assertNodeActive } from "./lifecycle-guard.js";
23
24
  import { pendingInboxCount } from "./inbox-count.js";
@@ -245,6 +246,24 @@ function getClientIP(req: Request, server?: any): string {
245
246
  return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
246
247
  }
247
248
 
249
+ // #507 — what identifies one node-scoped SSE connection (no tokens). instance_id is
250
+ // optional and client-supplied (`X-Anet-Instance-Id` header or `?instance_id=`); no
251
+ // released agent-node sends it yet, so absence means "unknown instance".
252
+ const INSTANCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
253
+ function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, alias: string, networkId: string) {
254
+ const rawInstance = req.headers.get("x-anet-instance-id") ?? new URL(req.url).searchParams.get("instance_id");
255
+ const instanceId = rawInstance && INSTANCE_ID_RE.test(rawInstance) ? rawInstance : null;
256
+ const direct = server?.requestIP?.(req);
257
+ const fwd = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
258
+ const hop = direct?.address ? `${direct.address}:${direct.port}` : null;
259
+ const remote = fwd ? `${fwd}${hop ? ` via ${hop}` : ""}` : hop;
260
+ const bound = tokenId
261
+ ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id ?? null
262
+ : null;
263
+ const nodeId = bound ?? db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
264
+ return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
265
+ }
266
+
248
267
  function isLocalhostIP(ip: string): boolean {
249
268
  return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
250
269
  }
@@ -1134,6 +1153,12 @@ return Bun.serve({
1134
1153
  nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
1135
1154
  }));
1136
1155
  if (streamDenied) return streamDenied;
1156
+ // #507 — the node's OWN stream (token bound to this alias) is node-scoped:
1157
+ // at most one per (network, alias), newest wins. A node token watching some
1158
+ // other alias stays a plain monitor and can never supersede that node.
1159
+ if (authCtx.tokenName === `node:${sessionName}` && authCtx.networkId === scopedNetId) {
1160
+ return createSSEStream(sessionName, scopedNetId, { node: nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId) });
1161
+ }
1137
1162
  return createSSEStream(sessionName, scopedNetId);
1138
1163
  }
1139
1164
 
@@ -2573,13 +2598,13 @@ return Bun.serve({
2573
2598
  const bound = restAuth.networkId;
2574
2599
  const sse = getSSEStats();
2575
2600
  const sessions = Object.fromEntries(Object.entries(sse.sessions).filter(([key]) => key.split(":").includes(bound)));
2576
- return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound }));
2601
+ return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound, identity_conflicts: recentNodeIdentityConflicts(bound) }));
2577
2602
  }
2578
2603
  if (restAuth && !isAdmin) {
2579
2604
  return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
2580
2605
  }
2581
2606
  const sse = getSSEStats();
2582
- return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions }));
2607
+ return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions, identity_conflicts: recentNodeIdentityConflicts() }));
2583
2608
  }
2584
2609
 
2585
2610
  // ── REST: all sessions status ──
package/src/tools.ts CHANGED
@@ -8,6 +8,7 @@ import { parseAliasFilter } from "./alias-filter.js";
8
8
  import { createHash } from "node:crypto";
9
9
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
10
10
  import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
11
+ import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
11
12
  import { assertNodeActive } from "./lifecycle-guard.js";
12
13
  import { pendingInboxCount } from "./inbox-count.js";
13
14
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
@@ -963,6 +964,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
963
964
  db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
964
965
  } catch {}
965
966
  }
967
+ // #507 — a second copy of this node (same token + alias) may still be connected;
968
+ // its sibling's shutdown offline must not take the node offline under it.
969
+ const offlineDeferred = deferOfflineIfAnotherCopyConnected(effectiveAlias, sessionNetId, status, resume_id);
970
+ const priorStatus = offlineDeferred
971
+ ? db.get<{ status: string | null }>("SELECT status FROM sessions WHERE resume_id = ?1", resume_id)?.status
972
+ : null;
973
+ const storedStatus = offlineDeferred ? (priorStatus && priorStatus !== "offline" ? priorStatus : "idle") : status;
966
974
  const trimmedOutput = output?.slice(0, 4000);
967
975
  const hostHostname = host?.hostname || hn || null;
968
976
  const hostIp = host?.ip || clientIP || null;
@@ -1063,7 +1071,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1063
1071
  external_schedules = COALESCE(?34, sessions.external_schedules),
1064
1072
  peer_reply_inbox_capable = ?35,
1065
1073
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
1066
- [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
1074
+ [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
1067
1075
  );
1068
1076
  if (handover) {
1069
1077
  // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
@@ -1133,7 +1141,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1133
1141
  type: "status_update",
1134
1142
  alias: effectiveAlias,
1135
1143
  ...(canonical.renamed ? { renamed_from: alias } : {}),
1136
- status,
1144
+ status: storedStatus,
1137
1145
  progress: progress ?? null,
1138
1146
  host: statusHostTelemetry,
1139
1147
  process_telemetry: statusProcessTelemetry,
@@ -1205,6 +1213,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1205
1213
  resume_id,
1206
1214
  alias: effectiveAlias,
1207
1215
  ...(canonical.renamed ? { renamed_from: alias } : {}),
1216
+ ...(offlineDeferred ? { offline_deferred: true, reason: "node_identity_conflict" } : {}),
1208
1217
  inbox_count: row?.cnt ?? 0,
1209
1218
  }),
1210
1219
  },