@sleep2agi/commhub-server 0.9.0-preview.36 → 0.9.0-preview.37

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.36",
3
+ "version": "0.9.0-preview.37",
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",
@@ -718,3 +718,60 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
718
718
  expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
719
719
  });
720
720
  });
721
+
722
+ // ── #1286 stale-deleting 重派出口 ──────────────────────────────────
723
+ //
724
+ // 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
725
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
726
+ // 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
727
+ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
728
+ function seedDeletingChild() {
729
+ setupAlphaNetwork();
730
+ db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
731
+ }
732
+ function seedStopRequest(status: string, ageMs: number) {
733
+ db.run(
734
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
735
+ child_alias, action, delete_config, created_by_token, status, created_at)
736
+ VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
737
+ [`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
738
+ CHILD_A_ALIAS, status, Date.now() - ageMs],
739
+ );
740
+ }
741
+ const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
742
+
743
+ test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
744
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
745
+ const r = await call(buildHandlers(USER_A_ID).delete_node, base);
746
+ expect(r.ok).toBe(false);
747
+ expect(r.error).toBe("node_already_deleting");
748
+ expect(typeof r.hint).toBe("string");
749
+ });
750
+
751
+ test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
752
+ seedDeletingChild(); seedStopRequest("pending", 60_000);
753
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
754
+ expect(r.ok).toBe(false);
755
+ expect(r.error).toBe("node_already_deleting");
756
+ });
757
+
758
+ test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
759
+ seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
760
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
761
+ expect(r.ok).toBe(false);
762
+ expect(r.error).toBe("node_already_deleting");
763
+ });
764
+
765
+ test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
766
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
767
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
768
+ expect(r.ok).toBe(true);
769
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
770
+ });
771
+
772
+ test("force + dispatched 晾超 5 分钟:同样放行", async () => {
773
+ seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
774
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
775
+ expect(r.ok).toBe(true);
776
+ });
777
+ });
package/src/tools.ts CHANGED
@@ -3561,7 +3561,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3561
3561
  return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
3562
3562
  }
3563
3563
  if (args.action === "delete" && state === "deleting") {
3564
- return { ok: false, error: "node_already_deleting", current_state: state };
3564
+ // #1286 —— stale-deleting 重派出口。
3565
+ //
3566
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
3567
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
3568
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
3569
+ //
3570
+ // 出口的三个条件,缺一不放行:
3571
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
3572
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
3573
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
3574
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
3575
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
3576
+ const STALE_DELETING_MS = 5 * 60_000;
3577
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
3578
+ `SELECT request_id, status, created_at FROM node_stop_requests
3579
+ WHERE child_node_id = ?1 AND action = 'delete'
3580
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
3581
+ );
3582
+ const lastIsStale = !!last
3583
+ && (last.status === "pending" || last.status === "dispatched")
3584
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
3585
+ if (!(args.force === true && (lastIsStale || !last))) {
3586
+ return {
3587
+ ok: false, error: "node_already_deleting", current_state: state,
3588
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
3589
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
3590
+ hint: last && (last.status === "pending" || last.status === "dispatched")
3591
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
3592
+ : "上一条请求已终态,行应即将转移;稍后重查",
3593
+ };
3594
+ }
3595
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
3596
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
3565
3597
  }
3566
3598
  if (args.action === "delete" && state === "stopping") {
3567
3599
  // Can't start delete while a stop is mid-flight — race-prone.