@sleep2agi/commhub-server 0.9.0-preview.36 → 0.9.0-preview.38
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 +1 -1
- package/src/create-node.ts +1 -0
- package/src/stop-delete-node.test.ts +112 -0
- package/src/tools.ts +61 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.38",
|
|
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/create-node.ts
CHANGED
|
@@ -306,6 +306,7 @@ export function auditCreateNodeStrict(input: {
|
|
|
306
306
|
export function auditCreateNode(input: {
|
|
307
307
|
action:
|
|
308
308
|
| "create_node_dispatched"
|
|
309
|
+
| "create_node_dispatch_attempted"
|
|
309
310
|
| "create_node_rejected"
|
|
310
311
|
| "create_node_succeeded"
|
|
311
312
|
| "create_node_sweeper_revoked"
|
|
@@ -36,6 +36,7 @@ interface Reply { ok?: boolean; error?: string; [k: string]: unknown; }
|
|
|
36
36
|
function cleanup() {
|
|
37
37
|
for (const n of [NET_A, NET_B]) {
|
|
38
38
|
try { db.run("DELETE FROM node_stop_requests WHERE network_id = ?1", [n]); } catch {}
|
|
39
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [n]); } catch {}
|
|
39
40
|
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
|
|
40
41
|
try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {}
|
|
41
42
|
// PR1.2a restart_node tests leave node_config_updates rows; clear
|
|
@@ -676,6 +677,60 @@ describe("list_my_children HANDLER (RFC-027 PR1.1)", () => {
|
|
|
676
677
|
});
|
|
677
678
|
});
|
|
678
679
|
|
|
680
|
+
describe("create_node audit wording (#1362)", () => {
|
|
681
|
+
test("create_node records a dispatch attempt, not guaranteed SSE delivery", async () => {
|
|
682
|
+
setupAlphaNetwork();
|
|
683
|
+
const tools = buildHandlers(USER_A_ID);
|
|
684
|
+
const r = await call(tools.create_node, {
|
|
685
|
+
daemon_node_id: DAEMON_A_ID,
|
|
686
|
+
node_spec: {
|
|
687
|
+
name: "sd-new-child",
|
|
688
|
+
runtime: "claude-agent-sdk",
|
|
689
|
+
model: "x",
|
|
690
|
+
},
|
|
691
|
+
network_id: NET_A,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(r.ok).toBe(true);
|
|
695
|
+
expect(typeof r.request_id).toBe("string");
|
|
696
|
+
expect(readAudit("create_node_dispatch_attempted", NET_A).length).toBe(1);
|
|
697
|
+
expect(readAudit("create_node_dispatched", NET_A).length).toBe(0);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("list_my_pending_create_requests HANDLER (#1362 SSE reconnect compensation)", () => {
|
|
702
|
+
test("daemon-bound caller gets only its own pending create requests", async () => {
|
|
703
|
+
setupAlphaNetwork();
|
|
704
|
+
const otherDaemonId = "node_sd_daemon_other";
|
|
705
|
+
seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
|
|
706
|
+
db.run(
|
|
707
|
+
`INSERT INTO node_create_requests
|
|
708
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
709
|
+
VALUES
|
|
710
|
+
('cr_pending_mine', ?1, 'child-pending-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?3, 'tok_test'),
|
|
711
|
+
('cr_delivered_mine', ?1, 'child-delivered-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'delivered', NULL, ?4, 'tok_test'),
|
|
712
|
+
('cr_pending_other', ?5, 'child-pending-other', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?6, 'tok_test')`,
|
|
713
|
+
[DAEMON_A_ID, NET_A, Date.now(), Date.now() + 1, otherDaemonId, Date.now() + 2],
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
717
|
+
const r = await call(daemonTools.list_my_pending_create_requests, {});
|
|
718
|
+
|
|
719
|
+
expect(r.ok).toBe(true);
|
|
720
|
+
expect(r.count).toBe(1);
|
|
721
|
+
expect(r.requests).toEqual([
|
|
722
|
+
expect.objectContaining({ request_id: "cr_pending_mine", child_name: "child-pending-mine" }),
|
|
723
|
+
]);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("non-daemon caller (utok) refused", async () => {
|
|
727
|
+
setupAlphaNetwork();
|
|
728
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
729
|
+
const r = await call(userTools.list_my_pending_create_requests, {});
|
|
730
|
+
expect(r.ok).toBe(false);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
679
734
|
// ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
|
|
680
735
|
//
|
|
681
736
|
// 通信龙 #346 ack latent: without this, a stopped node restart-ed via
|
|
@@ -718,3 +773,60 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
|
|
|
718
773
|
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
719
774
|
});
|
|
720
775
|
});
|
|
776
|
+
|
|
777
|
+
// ── #1286 stale-deleting 重派出口 ──────────────────────────────────
|
|
778
|
+
//
|
|
779
|
+
// 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
|
|
780
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
781
|
+
// 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
|
|
782
|
+
describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
|
|
783
|
+
function seedDeletingChild() {
|
|
784
|
+
setupAlphaNetwork();
|
|
785
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
786
|
+
}
|
|
787
|
+
function seedStopRequest(status: string, ageMs: number) {
|
|
788
|
+
db.run(
|
|
789
|
+
`INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
|
|
790
|
+
child_alias, action, delete_config, created_by_token, status, created_at)
|
|
791
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
|
|
792
|
+
[`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
|
|
793
|
+
CHILD_A_ALIAS, status, Date.now() - ageMs],
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
|
|
797
|
+
|
|
798
|
+
test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
|
|
799
|
+
seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
|
|
800
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, base);
|
|
801
|
+
expect(r.ok).toBe(false);
|
|
802
|
+
expect(r.error).toBe("node_already_deleting");
|
|
803
|
+
expect(typeof r.hint).toBe("string");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
|
|
807
|
+
seedDeletingChild(); seedStopRequest("pending", 60_000);
|
|
808
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
809
|
+
expect(r.ok).toBe(false);
|
|
810
|
+
expect(r.error).toBe("node_already_deleting");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
|
|
814
|
+
seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
|
|
815
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
816
|
+
expect(r.ok).toBe(false);
|
|
817
|
+
expect(r.error).toBe("node_already_deleting");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
|
|
821
|
+
seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
|
|
822
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
823
|
+
expect(r.ok).toBe(true);
|
|
824
|
+
expect((r.request_id as string).startsWith("sr_")).toBe(true);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("force + dispatched 晾超 5 分钟:同样放行", async () => {
|
|
828
|
+
seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
|
|
829
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
830
|
+
expect(r.ok).toBe(true);
|
|
831
|
+
});
|
|
832
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -3215,9 +3215,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3215
3215
|
// SSE handler resolves the rest via MCP call.
|
|
3216
3216
|
pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
|
|
3217
3217
|
|
|
3218
|
-
// §4.5 audit —
|
|
3218
|
+
// §4.5 audit — request queued and doorbell attempted. pushEvent is
|
|
3219
|
+
// fire-and-forget, so actual SSE delivery is intentionally not claimed.
|
|
3219
3220
|
auditCreateNode({
|
|
3220
|
-
action: "
|
|
3221
|
+
action: "create_node_dispatch_attempted",
|
|
3221
3222
|
user_id: enforceUserId,
|
|
3222
3223
|
network_id: networkIdForChild,
|
|
3223
3224
|
target_id: requestId,
|
|
@@ -3237,6 +3238,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3237
3238
|
},
|
|
3238
3239
|
);
|
|
3239
3240
|
|
|
3241
|
+
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
3242
|
+
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
3243
|
+
// any still-pending requests bound to its own daemon token.
|
|
3244
|
+
server.tool(
|
|
3245
|
+
"list_my_pending_create_requests",
|
|
3246
|
+
"Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
|
|
3247
|
+
{},
|
|
3248
|
+
async () => {
|
|
3249
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
3250
|
+
if (!callerDaemon.ok) {
|
|
3251
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3252
|
+
}
|
|
3253
|
+
const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
3254
|
+
`SELECT request_id, child_name, created_at
|
|
3255
|
+
FROM node_create_requests
|
|
3256
|
+
WHERE daemon_node_id = ?1
|
|
3257
|
+
AND network_id = ?2
|
|
3258
|
+
AND status = 'pending'
|
|
3259
|
+
ORDER BY created_at ASC`,
|
|
3260
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
3261
|
+
);
|
|
3262
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
|
|
3263
|
+
},
|
|
3264
|
+
);
|
|
3265
|
+
|
|
3240
3266
|
// §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
|
|
3241
3267
|
// child_ntok in one shot. Map evicted on take (one-shot consume).
|
|
3242
3268
|
//
|
|
@@ -3561,7 +3587,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3561
3587
|
return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
|
|
3562
3588
|
}
|
|
3563
3589
|
if (args.action === "delete" && state === "deleting") {
|
|
3564
|
-
|
|
3590
|
+
// #1286 —— stale-deleting 重派出口。
|
|
3591
|
+
//
|
|
3592
|
+
// 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
|
|
3593
|
+
// 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
|
|
3594
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
3595
|
+
//
|
|
3596
|
+
// 出口的三个条件,缺一不放行:
|
|
3597
|
+
// ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
|
|
3598
|
+
// ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
|
|
3599
|
+
// —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
|
|
3600
|
+
// ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
|
|
3601
|
+
// 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
|
|
3602
|
+
const STALE_DELETING_MS = 5 * 60_000;
|
|
3603
|
+
const last = db.get<{ request_id: string; status: string; created_at: number }>(
|
|
3604
|
+
`SELECT request_id, status, created_at FROM node_stop_requests
|
|
3605
|
+
WHERE child_node_id = ?1 AND action = 'delete'
|
|
3606
|
+
ORDER BY created_at DESC LIMIT 1`, node.node_id,
|
|
3607
|
+
);
|
|
3608
|
+
const lastIsStale = !!last
|
|
3609
|
+
&& (last.status === "pending" || last.status === "dispatched")
|
|
3610
|
+
&& (Date.now() - last.created_at) > STALE_DELETING_MS;
|
|
3611
|
+
if (!(args.force === true && (lastIsStale || !last))) {
|
|
3612
|
+
return {
|
|
3613
|
+
ok: false, error: "node_already_deleting", current_state: state,
|
|
3614
|
+
// 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
|
|
3615
|
+
...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
|
|
3616
|
+
hint: last && (last.status === "pending" || last.status === "dispatched")
|
|
3617
|
+
? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
|
|
3618
|
+
: "上一条请求已终态,行应即将转移;稍后重查",
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
// 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
|
|
3622
|
+
// daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
|
|
3565
3623
|
}
|
|
3566
3624
|
if (args.action === "delete" && state === "stopping") {
|
|
3567
3625
|
// Can't start delete while a stop is mid-flight — race-prone.
|