@sleep2agi/commhub-server 0.9.0-preview.39 → 0.9.0-preview.40

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.39",
3
+ "version": "0.9.0-preview.40",
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",
package/src/db.ts CHANGED
@@ -414,6 +414,7 @@ db.exec(`
414
414
  daemon_node_id TEXT NOT NULL,
415
415
  child_node_id TEXT NOT NULL,
416
416
  child_alias TEXT NOT NULL,
417
+ child_token_id TEXT,
417
418
  action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
418
419
  delete_config INTEGER NOT NULL DEFAULT 1,
419
420
  grace_seconds INTEGER NOT NULL DEFAULT 10,
@@ -431,6 +432,10 @@ db.exec(`
431
432
  `);
432
433
  db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
433
434
  db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
435
+ // #1448 finding-5 — 存 delete 目标 child 的 token_id,让 ack 精确按 token_id 撤 ntok
436
+ // (而非按 name='node:<alias>' 广撤 → 同 alias 在 delete 窗口内重建会误杀新 token)。
437
+ // 幂等迁移:已有库补列,旧行 child_token_id 为 NULL、ack 时 fallback 回按 name 撤。
438
+ try { db.exec(`ALTER TABLE node_stop_requests ADD COLUMN child_token_id TEXT`); } catch { /* 已存在 */ }
434
439
 
435
440
  // Daemon-mediated start request. Kept separate from node_stop_requests:
436
441
  // the deployed stop table has a CHECK(action IN ('stop','delete')) and
@@ -220,3 +220,67 @@ describe("send_desktop_message", () => {
220
220
  expect(auditCount()).toBe(0);
221
221
  });
222
222
  });
223
+
224
+ // #1459 —— 返回值必须反映真实投递态。
225
+ //
226
+ // send_desktop_message 是 fire-and-forget:只 INSERT audit_log(不写 inbox),
227
+ // 再 pushUserEvent 扇出;而 pushUserEvent 在没有订阅者时静默 return。
228
+ // 用户 dashboard 离线/重连时消息永久丢(无 DB 行 ⇒ 无从 reconcile,
229
+ // /api/messages 只 SELECT ... FROM inbox),而调用方拿到的是 ok:true。
230
+ //
231
+ // 本 PR 只做「让丢失可见」这一半:不碰 schema,返回值说实话。
232
+ // 持久化 + 重连补投(让丢失不发生)是另一条,方向已定为按 user_id 寻址的新表。
233
+ describe("#1459 send_desktop_message 返回值反映真实投递态", () => {
234
+ test("🔴 无活跃订阅者 ⇒ delivered:false + 可判读的 reason,而不是笼统 ok:true", async () => {
235
+ // 故意不建 SSE 流:这正是用户 dashboard 关着的情形
236
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
237
+ const before = auditCount();
238
+ const result = await call(tools.send_desktop_message, {
239
+ to_user_id: TARGET_A,
240
+ message: "nobody is listening",
241
+ severity: "info",
242
+ });
243
+
244
+ // ok 仍为 true:调用被接受、审计已落库 —— 这一格没变
245
+ expect(result.ok).toBe(true);
246
+ expect(result.message_id).toStartWith("dm_");
247
+ expect(auditCount()).toBe(before + 1);
248
+
249
+ // 新增的那一格:投递态
250
+ expect(result.delivered).toBe(false);
251
+ expect(result.reason).toBe("no_live_subscriber");
252
+ });
253
+
254
+ test("有活跃订阅者 ⇒ delivered:true 且不带 reason", async () => {
255
+ const stream = createUserEventStream(NET_A, TARGET_A);
256
+ const reader = stream.body!.getReader();
257
+ await readFrame(reader);
258
+
259
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
260
+ const result = await call(tools.send_desktop_message, {
261
+ to_user_id: TARGET_A,
262
+ message: "someone is listening",
263
+ severity: "info",
264
+ });
265
+
266
+ expect(result.delivered).toBe(true);
267
+ expect(result.reason).toBeUndefined();
268
+ // 前提断言:这一条真的送到了订阅者手上,否则 delivered:true 就是空话
269
+ const evt = await readFrame(reader);
270
+ expect(evt.message).toBe("someone is listening");
271
+ await reader.cancel();
272
+ });
273
+
274
+ test("🔴 两种情形必须给出不同读数 —— 防止判据退化成恒真", async () => {
275
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
276
+ const offline = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "off", severity: "info" });
277
+
278
+ const stream = createUserEventStream(NET_A, TARGET_A);
279
+ const reader = stream.body!.getReader();
280
+ await readFrame(reader);
281
+ const online = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "on", severity: "info" });
282
+ await reader.cancel();
283
+
284
+ expect(offline.delivered).not.toBe(online.delivered);
285
+ });
286
+ });
package/src/push.ts CHANGED
@@ -464,6 +464,20 @@ export function pushNetworkObserverEvent(
464
464
  /** Push an event to every active Desktop/web client for one user in one
465
465
  * network. No-op for falsy network/user ids; auth must be enforced by
466
466
  * the caller before invoking this helper. */
467
+ /** 这个用户此刻是否真的有活着的 SSE 订阅者。
468
+ *
469
+ * 与 alias 版 `hasSubscribers` 同形状:投递可达性的**实测**,
470
+ * 调用方据此把「送到了」和「没人听」区分开,而不是一律报成功。 */
471
+ export function hasUserSubscribers(
472
+ networkId: string | null | undefined,
473
+ userId: string | null | undefined,
474
+ ): boolean {
475
+ if (!networkId || !userId) return false;
476
+ const arr = clients.get(userKey(networkId, userId));
477
+ if (!arr) return false;
478
+ return arr.some((c) => !c.closed);
479
+ }
480
+
467
481
  export function pushUserEvent(
468
482
  networkId: string | null | undefined,
469
483
  userId: string | null | undefined,
@@ -50,3 +50,60 @@ describe("start_node Hub -> daemon lifecycle", () => {
50
50
  expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
51
51
  });
52
52
  });
53
+
54
+ // #1448 finding-2 — stale-starting reaper(对齐 update_node_config/restart_node 的 60s
55
+ // single-flight stale-supersede)。start 只受理 stopped;卡在 starting 的节点(门铃丢 /
56
+ // daemon 在 UPDATE starting 后死)改前永远 start 不了(gate 返 node_not_stopped)、也无
57
+ // reaper 拉回 → 永久卡死。改后:in-flight start 请求晾过 60s ⇒ 标 timeout 超越 + 放行
58
+ // 重派;未过阈值 ⇒ 拒 node_already_starting。
59
+ describe("start_node — stale-starting reaper (#1448 finding-2)", () => {
60
+ function seedStartReq(reqId: string, status: string, ageMs: number) {
61
+ db.run(
62
+ `INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
63
+ VALUES(?1,?2,?3,?4,?5,'t',?6,?7)`,
64
+ [reqId, NET, DAEMON, CHILD, CHILD_ALIAS, status, Date.now() - ageMs],
65
+ );
66
+ }
67
+
68
+ test("starting + FRESH in-flight start (< 60s) → refused node_already_starting (not superseded)", async () => {
69
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
70
+ seedStartReq("str_fresh", "delivered", 5_000); // 5s old — within threshold
71
+ const u = handlers(USER);
72
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
73
+ expect(r.ok).toBe(false);
74
+ expect(r.error).toBe("node_already_starting");
75
+ expect(r.existing_request_id).toBe("str_fresh");
76
+ expect(typeof r.age_ms).toBe("number");
77
+ // old request untouched, no new dispatch
78
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_fresh'`)?.status).toBe("delivered");
79
+ });
80
+
81
+ // witnessed-red:改前 gate 直接 `!== 'stopped' → node_not_stopped`,一个 stale 的
82
+ // 'starting' 节点返回 node_not_stopped、永远卡死。改后应超越重派(ok:true)。
83
+ test("starting + STALE in-flight start (> 60s) → supersede old + re-dispatch (converges)", async () => {
84
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
85
+ seedStartReq("str_stale", "delivered", 61_000); // 61s old — past 60s threshold
86
+ const u = handlers(USER);
87
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
88
+ expect(r.ok).toBe(true);
89
+ expect(r.lifecycle_state).toBe("starting");
90
+ expect(typeof r.request_id).toBe("string");
91
+ expect(r.request_id).not.toBe("str_stale"); // fresh request
92
+ // old stale request superseded → terminal, unblocks the child-inflight unique index
93
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_stale'`)?.status).toBe("timeout");
94
+ // exactly one non-terminal start request now (the new one)
95
+ const live = db.all<any>(`SELECT request_id FROM node_start_requests WHERE child_node_id=?1 AND status IN ('pending','delivered')`, CHILD);
96
+ expect(live.map((x:any)=>x.request_id)).toEqual([r.request_id]);
97
+ // audit records the real prior state
98
+ const aud = db.get<any>(`SELECT detail FROM audit_log WHERE target_id=?1`, r.request_id);
99
+ expect(JSON.parse(aud.detail).lifecycle_state_before).toBe("starting");
100
+ });
101
+
102
+ test("starting + NO in-flight row (orphaned state) → self-heals by re-dispatching", async () => {
103
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
104
+ const u = handlers(USER);
105
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
106
+ expect(r.ok).toBe(true);
107
+ expect(r.lifecycle_state).toBe("starting");
108
+ });
109
+ });
@@ -409,6 +409,51 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
409
409
  expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
410
410
  });
411
411
 
412
+ // #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
413
+ // 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
414
+ // f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
415
+ test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
416
+ setupAlphaNetwork();
417
+ // authority: CHILD_A 由 DAEMON_A 创建(resolveDaemonForChild 读 node_create_requests)
418
+ db.run(
419
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
420
+ VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
421
+ [`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
422
+ );
423
+ // 一个同网、存在、但没创建 CHILD_A 的 daemon
424
+ const DAEMON_B = "node_sd_daemon_b";
425
+ seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
426
+ const u = buildHandlers(USER_A_ID);
427
+ // 错 daemon → mismatch(stop + delete 都拦)。改前会放行、路由到错 daemon。
428
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, network_id: NET_A })).error).toBe("daemon_child_mismatch");
429
+ expect((await call(u.delete_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, confirm_alias: CHILD_A_ALIAS, network_id: NET_A })).error).toBe("daemon_child_mismatch");
430
+ // 正确 daemon → 放行(回归钉)
431
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
432
+ });
433
+
434
+ // #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
435
+ // name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
436
+ // token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
437
+ test("#1448 finding-5: delete revokes by token_id, spares a same-alias rebuilt token", async () => {
438
+ setupAlphaNetwork();
439
+ const userTools = buildHandlers(USER_A_ID);
440
+ const dispatch = await call(userTools.delete_node, {
441
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
442
+ });
443
+ expect(dispatch.ok).toBe(true);
444
+ // 派发后、ack 前:同 alias 节点被重建,拿到**同名**新 ntok(不同 token_id,仍 active)。
445
+ const REBUILT_TOK = "tok_sd_child_a_rebuilt";
446
+ db.run(
447
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
448
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
449
+ [REBUILT_TOK, USER_A_ID, NET_A, `node:${CHILD_A_ALIAS}`, `hash_${REBUILT_TOK}`],
450
+ );
451
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
452
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
453
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull(); // 原 token 撤销(精确命中)
454
+ expect(readToken(REBUILT_TOK)?.revoked_at).toBeNull(); // 重建的同名新 token 未被误撤(改前按 name 广撤会红)
455
+ });
456
+
412
457
  test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => {
413
458
  setupAlphaNetwork();
414
459
  const userTools = buildHandlers(USER_A_ID);
@@ -731,6 +776,81 @@ describe("list_my_pending_create_requests HANDLER (#1362 SSE reconnect compensat
731
776
  });
732
777
  });
733
778
 
779
+ describe("list_my_pending_lifecycle_requests HANDLER (#1448 SSE reconnect compensation)", () => {
780
+ function seedStop(reqId: string, daemonId: string, status: string, action: string, ageMs: number) {
781
+ db.run(
782
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
783
+ child_alias, action, delete_config, created_by_token, status, created_at)
784
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 'tok_test', ?7, ?8)`,
785
+ [reqId, NET_A, daemonId, CHILD_A_ID, CHILD_A_ALIAS, action, status, Date.now() - ageMs],
786
+ );
787
+ }
788
+ // child_node_id parameterized: node_start_requests has a partial-unique
789
+ // index on child_node_id WHERE status IN ('pending','delivered'), so the
790
+ // "mine" and "other" pending start rows must target different children.
791
+ function seedStart(reqId: string, daemonId: string, childId: string, status: string, ageMs: number) {
792
+ db.run(
793
+ `INSERT INTO node_start_requests (request_id, network_id, daemon_node_id, child_node_id,
794
+ child_alias, created_by_token, status, created_at)
795
+ VALUES (?1, ?2, ?3, ?4, ?5, 'tok_test', ?6, ?7)`,
796
+ [reqId, NET_A, daemonId, childId, CHILD_A_ALIAS, status, Date.now() - ageMs],
797
+ );
798
+ }
799
+
800
+ test("daemon-bound caller gets only its own PENDING stop/delete + start (excludes delivered + other daemon)", async () => {
801
+ setupAlphaNetwork();
802
+ const otherDaemonId = "node_sd_daemon_other";
803
+ seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
804
+ // mine — should be returned (stop + delete, oldest first)
805
+ seedStop("sr_pending_stop", DAEMON_A_ID, "pending", "stop", 30);
806
+ seedStop("sr_pending_delete", DAEMON_A_ID, "pending", "delete", 20);
807
+ seedStart("str_pending_mine", DAEMON_A_ID, CHILD_A_ID, "pending", 25);
808
+ // excluded — delivered (already pulled) + other daemon's pending
809
+ seedStop("sr_delivered_mine", DAEMON_A_ID, "delivered", "stop", 40);
810
+ seedStop("sr_pending_other", otherDaemonId, "pending", "stop", 10);
811
+ seedStart("str_pending_other", otherDaemonId, "node_sd_child_other", "pending", 10);
812
+
813
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
814
+ const res = await call(daemonTools.list_my_pending_lifecycle_requests, {});
815
+ expect(res.ok).toBe(true);
816
+ expect(res.stop_count).toBe(2);
817
+ expect((res.stop_requests as Array<{ request_id: string }>).map(x => x.request_id))
818
+ .toEqual(["sr_pending_stop", "sr_pending_delete"]); // oldest-first, action preserved
819
+ expect(res.start_count).toBe(1);
820
+ expect((res.start_requests as Array<{ request_id: string }>).map(x => x.request_id))
821
+ .toEqual(["str_pending_mine"]);
822
+ });
823
+
824
+ // Witnessed-red 的核心链:真实 stop_node 派发时 daemon 无 SSE 订阅者 →
825
+ // pushEvent 静默丢门铃 → 行卡 pending / 节点卡 stopping(缺陷态)。修复后这条
826
+ // pending 行会被 list_my_pending_lifecycle_requests 列出 → 重连 reconcile 能重放。
827
+ // 改前(无此工具)该行永远无人发现、节点永久卡 stopping。
828
+ test("stuck-pending after a dropped doorbell IS surfaced for reconnect replay", async () => {
829
+ setupAlphaNetwork();
830
+ const userTools = buildHandlers(USER_A_ID);
831
+ // 无 SSE 订阅者:dispatch 建 pending 行 + pushEvent 丢弃(测试进程里没有 client)
832
+ const disp = await call(userTools.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
833
+ expect(disp.ok).toBe(true);
834
+ const reqId = disp.request_id as string;
835
+ // 缺陷态:行 pending、节点 stopping(门铃没送达,无人推进)
836
+ const row = db.get<{ status: string }>("SELECT status FROM node_stop_requests WHERE request_id = ?1", reqId);
837
+ expect(row?.status).toBe("pending");
838
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
839
+ // 修复:pending 行经新工具对本 daemon 可见 → 重连补偿能拿到并重放
840
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
841
+ const r = await call(daemonTools.list_my_pending_lifecycle_requests, {});
842
+ expect(r.ok).toBe(true);
843
+ expect((r.stop_requests as Array<{ request_id: string }>).map(x => x.request_id)).toContain(reqId);
844
+ });
845
+
846
+ test("non-daemon caller (utok) refused", async () => {
847
+ setupAlphaNetwork();
848
+ const userTools = buildHandlers(USER_A_ID);
849
+ const r = await call(userTools.list_my_pending_lifecycle_requests, {});
850
+ expect(r.ok).toBe(false);
851
+ });
852
+ });
853
+
734
854
  // ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
735
855
  //
736
856
  // 通信龙 #346 ack latent: without this, a stopped node restart-ed via
package/src/tools.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
4
4
  import { parseAliasFilter } from "./alias-filter.js";
5
5
  import { createHash } from "node:crypto";
6
6
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
- import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
7
+ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { pendingInboxCount } from "./inbox-count.js";
10
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
@@ -2382,9 +2382,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2382
2382
  effectiveNetId,
2383
2383
  ],
2384
2384
  );
2385
+ // #1459 — pushUserEvent 在没有订阅者时静默 return,而这个工具既不写
2386
+ // inbox、也没有任何 user 级回读路径(/api/messages 只 SELECT FROM inbox)。
2387
+ // 也就是说用户 dashboard 关着的时候这条消息就永久没了。
2388
+ //
2389
+ // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2390
+ // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
2391
+ // 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
2392
+ //
2393
+ // 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
2394
+ // 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
2395
+ // 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
2396
+ // "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
2397
+ // (如 lost vs queued)即可,现在不预先把语义焊死。
2398
+ const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
2385
2399
  pushUserEvent(effectiveNetId, target.user_id, event);
2386
2400
 
2387
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, message_id: messageId, delivered_to_user_id: target.user_id, network_id: effectiveNetId }) }] };
2401
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2402
+ ok: true,
2403
+ message_id: messageId,
2404
+ delivered,
2405
+ ...(delivered ? {} : { reason: "no_live_subscriber" }),
2406
+ delivered_to_user_id: target.user_id,
2407
+ network_id: effectiveNetId,
2408
+ }) }] };
2388
2409
  },
2389
2410
  );
2390
2411
 
@@ -3320,6 +3341,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3320
3341
  },
3321
3342
  );
3322
3343
 
3344
+ // #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
3345
+ // list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
3346
+ // fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
3347
+ // 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
3348
+ // 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
3349
+ // connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
3350
+ //
3351
+ // 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
3352
+ // 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
3353
+ // 只返回绑定到本 daemon + 本网络的 pending 行。
3354
+ server.tool(
3355
+ "list_my_pending_lifecycle_requests",
3356
+ "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
3357
+ {},
3358
+ async () => {
3359
+ const callerDaemon = resolveCallerDaemonTokenBound();
3360
+ if (!callerDaemon.ok) {
3361
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3362
+ }
3363
+ const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
3364
+ `SELECT request_id, action, child_node_id, child_alias, created_at
3365
+ FROM node_stop_requests
3366
+ WHERE daemon_node_id = ?1
3367
+ AND network_id = ?2
3368
+ AND status = 'pending'
3369
+ ORDER BY created_at ASC`,
3370
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3371
+ );
3372
+ const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
3373
+ `SELECT request_id, child_node_id, child_alias, created_at
3374
+ FROM node_start_requests
3375
+ WHERE daemon_node_id = ?1
3376
+ AND network_id = ?2
3377
+ AND status = 'pending'
3378
+ ORDER BY created_at ASC`,
3379
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3380
+ );
3381
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3382
+ ok: true,
3383
+ stop_count: stop_requests.length,
3384
+ start_count: start_requests.length,
3385
+ stop_requests,
3386
+ start_requests,
3387
+ }) }] };
3388
+ },
3389
+ );
3390
+
3323
3391
  // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
3324
3392
  // child_ntok in one shot. Map evicted on take (one-shot consume).
3325
3393
  //
@@ -3638,6 +3706,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3638
3706
  return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
3639
3707
  }
3640
3708
 
3709
+ // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
3710
+ // daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
3711
+ // 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
3712
+ // 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
3713
+ // 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
3714
+ // 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
3715
+ //
3716
+ // 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
3717
+ // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
3718
+ // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
3719
+ const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
3720
+ if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
3721
+ return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
3722
+ }
3723
+
3641
3724
  // State machine gate.
3642
3725
  const state = node.lifecycle_state ?? "active";
3643
3726
  if (args.action === "stop" && state !== "active") {
@@ -3707,6 +3790,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3707
3790
  const now = Date.now();
3708
3791
  const newState = args.action === "stop" ? "stopping" : "deleting";
3709
3792
 
3793
+ // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
3794
+ // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
3795
+ // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
3796
+ // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
3797
+ const childTokenRow = db.get<{ token_id: string }>(
3798
+ `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
3799
+ node.network_id, `node:${node.alias}`,
3800
+ );
3801
+ const childTokenId = childTokenRow?.token_id ?? null;
3802
+
3710
3803
  // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
3711
3804
  // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
3712
3805
  // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
@@ -3717,12 +3810,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3717
3810
  db.transaction(() => {
3718
3811
  db.run(
3719
3812
  `INSERT INTO node_stop_requests
3720
- (request_id, network_id, daemon_node_id, child_node_id, child_alias, action,
3813
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
3721
3814
  delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
3722
3815
  status, created_at)
3723
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12)`,
3816
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
3724
3817
  [
3725
- requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, args.action,
3818
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
3726
3819
  args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
3727
3820
  callerTokenId || "unknown", now,
3728
3821
  ],
@@ -3903,9 +3996,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3903
3996
  }
3904
3997
  const row = db.get<{
3905
3998
  daemon_node_id: string; status: string; network_id: string;
3906
- child_node_id: string; child_alias: string; action: string;
3999
+ child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
3907
4000
  in_flight_at_dispatch: number; force: number;
3908
- }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
4001
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
3909
4002
  in_flight_at_dispatch, force
3910
4003
  FROM node_stop_requests WHERE request_id = ?1`, request_id);
3911
4004
  if (!row) {
@@ -3946,14 +4039,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3946
4039
  },
3947
4040
  });
3948
4041
  } else if (status === "stopped" && row.action === "delete") {
3949
- // Revoke the child's ntok (name='node:<alias>') in the daemon's
3950
- // network, then DELETE the nodes row. Token revoke pattern mirrors
3951
- // ack_create_request's terminal path.
3952
- db.run(
3953
- `UPDATE api_tokens SET revoked_at = datetime('now')
3954
- WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
3955
- [row.network_id, `node:${row.child_alias}`],
3956
- );
4042
+ // Revoke the child's ntok, then DELETE the nodes row.
4043
+ // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
4044
+ // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
4045
+ // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
4046
+ // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
4047
+ // token」时把新 token 一并误撤。
4048
+ if (row.child_token_id) {
4049
+ db.run(
4050
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4051
+ WHERE token_id = ?1 AND revoked_at IS NULL`,
4052
+ [row.child_token_id],
4053
+ );
4054
+ } else {
4055
+ db.run(
4056
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4057
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4058
+ [row.network_id, `node:${row.child_alias}`],
4059
+ );
4060
+ }
3957
4061
  db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
3958
4062
  auditCreateNodeStrict({
3959
4063
  action: "delete_node_completed",
@@ -4019,8 +4123,43 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4019
4123
  );
4020
4124
  if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4021
4125
  if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
4022
- if ((child.lifecycle_state ?? "active") !== "stopped") {
4023
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: child.lifecycle_state ?? "active" }) }] };
4126
+ // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
4127
+ // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
4128
+ // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
4129
+ // 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
4130
+ // 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
4131
+ // 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
4132
+ const priorState = child.lifecycle_state ?? "active";
4133
+ if (priorState !== "stopped") {
4134
+ if (priorState !== "starting") {
4135
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
4136
+ }
4137
+ const STALE_STARTING_MS = 60_000;
4138
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
4139
+ `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
4140
+ WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
4141
+ ORDER BY created_at DESC LIMIT 1`, child.node_id,
4142
+ );
4143
+ if (inFlight) {
4144
+ // Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
4145
+ // 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
4146
+ const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
4147
+ const age = Date.now() - ageAnchor;
4148
+ if (age <= STALE_STARTING_MS) {
4149
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4150
+ ok: false, error: "node_already_starting",
4151
+ existing_request_id: inFlight.request_id, age_ms: age,
4152
+ hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
4153
+ }) }] };
4154
+ }
4155
+ // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
4156
+ // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
4157
+ db.run(
4158
+ `UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
4159
+ [Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
4160
+ );
4161
+ }
4162
+ // else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
4024
4163
  }
4025
4164
  // The caller may provide daemon_node_id, but it cannot override the
4026
4165
  // authoritative child->daemon relationship recorded at creation.
@@ -4046,12 +4185,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4046
4185
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
4047
4186
  [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
4048
4187
  );
4049
- db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state = 'stopped'`, [child.node_id]);
4188
+ // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
4189
+ // starting);两种入态都合法地转/留到 'starting'。
4190
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
4050
4191
  auditCreateNodeStrict({
4051
4192
  action: "start_node_dispatched", user_id: enforceUserId,
4052
4193
  network_id: child.network_id, target_id: requestId,
4053
4194
  detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
4054
- lifecycle_state_before: "stopped", lifecycle_state_after: "starting", ts_request: now },
4195
+ lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
4055
4196
  });
4056
4197
  });
4057
4198
  } catch (e: any) {