@sleep2agi/commhub-server 0.9.0-preview.141 → 0.9.0-preview.142
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/stop-delete-node.test.ts +59 -0
- package/src/tools.ts +45 -15
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.142",
|
|
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",
|
|
@@ -961,6 +961,65 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
|
|
|
961
961
|
});
|
|
962
962
|
});
|
|
963
963
|
|
|
964
|
+
// ── #1511 — restart_node 在途任务默认拒绝(只提示,不自动重启) ──────
|
|
965
|
+
//
|
|
966
|
+
// 节点侧 exit-75 的 drain 只等 60 秒,超时会硬打断当前回合;所以「正在跑
|
|
967
|
+
// 任务」时 Hub 端在派发前就拒绝,计数规则与 stop_node 完全同一份
|
|
968
|
+
// (#2022/#2029 修订版,实现在 countInFlightTasks)。客户端语义:保存密钥
|
|
969
|
+
// 后若节点忙,不安排重启,只提示等任务完成或去节点机器上手动重启。
|
|
970
|
+
describe("restart_node — in-flight default-refuse (#1511)", () => {
|
|
971
|
+
test("live task row → node_busy_in_flight, no dispatch row, state untouched", async () => {
|
|
972
|
+
setupAlphaNetwork();
|
|
973
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_live", status: "delivered" });
|
|
974
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
975
|
+
expect(r.ok).toBe(false);
|
|
976
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
977
|
+
expect(r.in_flight_count).toBe(1);
|
|
978
|
+
expect(typeof r.hint).toBe("string");
|
|
979
|
+
const rows = db.all<{ update_id: string }>(`SELECT update_id FROM node_config_updates WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
980
|
+
expect(rows.length).toBe(0);
|
|
981
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("unacked legacy inbox rows count too — same rule as stop", async () => {
|
|
985
|
+
setupAlphaNetwork();
|
|
986
|
+
seedInbox(NET_A, CHILD_A_ALIAS, 2);
|
|
987
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
988
|
+
expect(r.ok).toBe(false);
|
|
989
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
990
|
+
expect(r.in_flight_count).toBe(2);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("terminal-only rows → restart dispatches (parity with #2022 stop rule)", async () => {
|
|
994
|
+
setupAlphaNetwork();
|
|
995
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_replied", status: "replied" });
|
|
996
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
997
|
+
expect(r.ok).toBe(true);
|
|
998
|
+
expect(r.apply_mode).toBe("restart_only");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("2h-old live task is never aged out — status wins over age (same as stop)", async () => {
|
|
1002
|
+
setupAlphaNetwork();
|
|
1003
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1004
|
+
task_id: "task_1511_running_120m", status: "running",
|
|
1005
|
+
task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
|
|
1006
|
+
});
|
|
1007
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
1008
|
+
expect(r.ok).toBe(false);
|
|
1009
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("stopped node + live-row corpse → restart still dispatches (un-stop must not be blocked)", async () => {
|
|
1013
|
+
setupAlphaNetwork();
|
|
1014
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
1015
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_corpse", status: "delivered" });
|
|
1016
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
1017
|
+
expect(r.ok).toBe(true);
|
|
1018
|
+
expect(r.apply_mode).toBe("restart_only");
|
|
1019
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
964
1023
|
// ── #1286 stale-deleting 重派出口 ──────────────────────────────────
|
|
965
1024
|
//
|
|
966
1025
|
// 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
|
package/src/tools.ts
CHANGED
|
@@ -4160,6 +4160,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4160
4160
|
);
|
|
4161
4161
|
|
|
4162
4162
|
|
|
4163
|
+
// #1511 — stop_node(§4.3 D4)与 restart_node 共用的「在途任务」计数。
|
|
4164
|
+
// 计数规则见 stop_node 内的长注释(#2022/#2029 修订:能成为「本节点正在做的
|
|
4165
|
+
// 工作」的行才算——tasks 行按状态判、终态不算且不设龄上限;无状态行按 60 分钟
|
|
4166
|
+
// 龄判)。抽成一处后,两个闸门共享同一份 SQL,不再各自漂移。
|
|
4167
|
+
const IN_FLIGHT_MAX_AGE_MINUTES = 60;
|
|
4168
|
+
function countInFlightTasks(alias: string, networkId: string | null): number {
|
|
4169
|
+
const row = db.get<{ n: number }>(
|
|
4170
|
+
`SELECT COUNT(*) AS n FROM inbox i
|
|
4171
|
+
LEFT JOIN tasks t
|
|
4172
|
+
ON i.type = 'task'
|
|
4173
|
+
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
4174
|
+
WHERE i.session_name = ?1 AND i.acked = 0
|
|
4175
|
+
AND COALESCE(i.network_id, ?2) = ?2
|
|
4176
|
+
AND ((t.task_id IS NOT NULL
|
|
4177
|
+
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
4178
|
+
OR (t.task_id IS NULL
|
|
4179
|
+
AND i.created_at >= datetime('now', ?3)))`,
|
|
4180
|
+
alias, networkId, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
4181
|
+
);
|
|
4182
|
+
return row?.n ?? 0;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4163
4185
|
server.tool(
|
|
4164
4186
|
"restart_node",
|
|
4165
4187
|
"Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
|
|
@@ -4189,6 +4211,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4189
4211
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false,
|
|
4190
4212
|
error: "adopted_restart_requires_daemon", message: "Use Stop, then Start for this adopted node." }) }] };
|
|
4191
4213
|
}
|
|
4214
|
+
// #1511 — 正在跑任务时不自动重启,只提示。节点侧 exit-75 的 drain 只等
|
|
4215
|
+
// 60 秒、超时会硬打断当前回合(agent-node cli.ts drainInFlightThink),
|
|
4216
|
+
// 所以 Hub 端在派发前就按 stop_node 同一条在途计数规则拒绝,客户端拿到
|
|
4217
|
+
// 明确二选一:等任务完成后重试,或到节点所在机器手动重启。
|
|
4218
|
+
//
|
|
4219
|
+
// 只对 active 节点生效。stopped 节点的进程已死、任务不可能正在跑,其
|
|
4220
|
+
// unacked 行是 stop 留下的尸体;restart_node 在 stopped 节点上是把
|
|
4221
|
+
// lifecycle 翻回 active 的出口,不能用尸体行把这条路堵死。
|
|
4222
|
+
// lifecycle_state 需单独取:resolveTargetNode 的 SELECT 不含该列。
|
|
4223
|
+
const restartLifecycle = db.get<{ lifecycle_state: string | null }>(
|
|
4224
|
+
"SELECT lifecycle_state FROM nodes WHERE node_id = ?1", nodeId,
|
|
4225
|
+
)?.lifecycle_state ?? null;
|
|
4226
|
+
if (restartLifecycle === "active") {
|
|
4227
|
+
const busyCount = countInFlightTasks(node.alias, node.network_id);
|
|
4228
|
+
if (busyCount > 0) {
|
|
4229
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4230
|
+
ok: false, error: "node_busy_in_flight", in_flight_count: busyCount,
|
|
4231
|
+
hint: "节点正在执行任务,本次未安排重启;等它完成后再试,或到节点所在机器手动重启",
|
|
4232
|
+
}) }] };
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4192
4235
|
// F-B reaper: same stale-supersede semantics as update_node_config,
|
|
4193
4236
|
// with same acked_at-anchored liveness clock (see update_node_config
|
|
4194
4237
|
// for the 通信龙 polish reasoning).
|
|
@@ -5449,21 +5492,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5449
5492
|
// ever retire them, so they get the age ceiling
|
|
5450
5493
|
// IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
|
|
5451
5494
|
// The count stays a per-alias inbox count: one PK join per row.
|
|
5452
|
-
|
|
5453
|
-
const
|
|
5454
|
-
`SELECT COUNT(*) AS n FROM inbox i
|
|
5455
|
-
LEFT JOIN tasks t
|
|
5456
|
-
ON i.type = 'task'
|
|
5457
|
-
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
5458
|
-
WHERE i.session_name = ?1 AND i.acked = 0
|
|
5459
|
-
AND COALESCE(i.network_id, ?2) = ?2
|
|
5460
|
-
AND ((t.task_id IS NOT NULL
|
|
5461
|
-
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
5462
|
-
OR (t.task_id IS NULL
|
|
5463
|
-
AND i.created_at >= datetime('now', ?3)))`,
|
|
5464
|
-
node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
5465
|
-
);
|
|
5466
|
-
const inFlight = inFlightRow?.n ?? 0;
|
|
5495
|
+
// 计数实现在上方 countInFlightTasks。#1511 让 restart_node 共用同一份规则,判据本身未变。
|
|
5496
|
+
const inFlight = countInFlightTasks(node.alias, node.network_id);
|
|
5467
5497
|
if (inFlight > 0 && !args.force) {
|
|
5468
5498
|
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
5469
5499
|
}
|