@sleep2agi/commhub-server 0.9.0-preview.140 → 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/db.ts +8 -0
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/scheduled-tasks-http.test.ts +41 -0
- package/src/scheduled-tasks.ts +71 -5
- 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",
|
package/src/db.ts
CHANGED
|
@@ -1734,6 +1734,14 @@ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"
|
|
|
1734
1734
|
// Board #733 — schedules an Agent created over MCP name the creating node. NULL = created by a person
|
|
1735
1735
|
// (REST / Dashboard) or a row from before this column; old rows keep NULL and old code never reads it.
|
|
1736
1736
|
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN created_by_node_id TEXT"); } catch {}
|
|
1737
|
+
|
|
1738
|
+
try { db.exec(`CREATE TABLE IF NOT EXISTS scheduled_task_watchers (
|
|
1739
|
+
schedule_id TEXT NOT NULL,
|
|
1740
|
+
user_id TEXT NOT NULL,
|
|
1741
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1742
|
+
PRIMARY KEY (schedule_id, user_id)
|
|
1743
|
+
)`); } catch {}
|
|
1744
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_task_watchers_user ON scheduled_task_watchers(user_id)"); } catch {}
|
|
1737
1745
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_creator_node ON scheduled_tasks(network_id, created_by_node_id)"); } catch {}
|
|
1738
1746
|
// #523 — consecutive-failure alert bookkeeping (scheduled-failures.ts). Additive and nullable:
|
|
1739
1747
|
// an older Hub never reads them, so rolling back is safe. failure_alert_at = ISO time of the last
|
|
@@ -76,7 +76,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
76
76
|
"schedule_id", "network_id", "name", "target_node_id", "target_alias",
|
|
77
77
|
"task_content", "priority", "schedule_type", "timezone", "overlap_policy",
|
|
78
78
|
"misfire_policy", "status", "next_run_at", "last_run_at", "revision",
|
|
79
|
-
"created_at", "updated_at", "schedule",
|
|
79
|
+
"created_at", "updated_at", "schedule", "watchers", "is_watcher", "can_edit", "can_manage_watchers",
|
|
80
80
|
],
|
|
81
81
|
} as const;
|
|
82
82
|
|
|
@@ -635,4 +635,45 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
635
635
|
const runs = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/runs?network_id=${encodeURIComponent(networkId)}`);
|
|
636
636
|
expect(runs.body.runs.length).toBeGreaterThanOrEqual(4);
|
|
637
637
|
});
|
|
638
|
+
|
|
639
|
+
test("a watcher can edit the schedule but cannot cancel it or change watchers", async () => {
|
|
640
|
+
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM network_members WHERE network_id = ?1 AND role = 'viewer'", networkId)!.user_id;
|
|
641
|
+
const schedule = await createSchedule(`watchers-${Date.now()}`);
|
|
642
|
+
const bound = await api(ownerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
|
|
643
|
+
method: "PATCH",
|
|
644
|
+
body: JSON.stringify({ revision: schedule.revision, watchers: [viewerId] }),
|
|
645
|
+
});
|
|
646
|
+
expect(bound.status).toBe(200);
|
|
647
|
+
expect(bound.body.schedule.watchers.map((w: { user_id: string }) => w.user_id)).toEqual([viewerId]);
|
|
648
|
+
expect(bound.body.schedule.can_manage_watchers).toBe(true);
|
|
649
|
+
|
|
650
|
+
const listed = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
651
|
+
const row = listed.body.schedules.find((item: { schedule_id: string }) => item.schedule_id === schedule.schedule_id);
|
|
652
|
+
expect(row?.is_watcher).toBe(true);
|
|
653
|
+
expect(row?.can_edit).toBe(true);
|
|
654
|
+
expect(row?.can_manage_watchers).toBe(false);
|
|
655
|
+
|
|
656
|
+
const edited = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
|
|
657
|
+
method: "PATCH",
|
|
658
|
+
body: JSON.stringify({ revision: row.revision, name: "edited by watcher" }),
|
|
659
|
+
});
|
|
660
|
+
expect(edited.status).toBe(200);
|
|
661
|
+
expect(edited.body.schedule.name).toBe("edited by watcher");
|
|
662
|
+
|
|
663
|
+
const manage = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}?network_id=${encodeURIComponent(networkId)}`, {
|
|
664
|
+
method: "PATCH",
|
|
665
|
+
body: JSON.stringify({ revision: edited.body.schedule.revision, watchers: [] }),
|
|
666
|
+
});
|
|
667
|
+
expect(manage.status).toBe(403);
|
|
668
|
+
expect(manage.body.error).toBe("watcher_cannot_manage_watchers");
|
|
669
|
+
|
|
670
|
+
const cancel = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
671
|
+
expect(cancel.status).toBe(403);
|
|
672
|
+
expect(cancel.body.error).toBe("watcher_cannot_cancel");
|
|
673
|
+
|
|
674
|
+
const runNow = await api(viewerToken, `/api/scheduled-tasks/${schedule.schedule_id}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
675
|
+
expect(runNow.status).toBe(403);
|
|
676
|
+
expect(runNow.body.error).toBe("watcher_cannot_run");
|
|
677
|
+
});
|
|
678
|
+
|
|
638
679
|
});
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -735,6 +735,63 @@ function writeAllowed(ctx: ScheduledRequestContext, networkId: string | null): b
|
|
|
735
735
|
return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
+
|
|
739
|
+
type WatcherPublic = { user_id: string; username: string; display_name: string };
|
|
740
|
+
|
|
741
|
+
function scheduleWatchers(scheduleId: string): WatcherPublic[] {
|
|
742
|
+
return db.all<{ user_id: string; username: string; display_name: string | null }>(
|
|
743
|
+
`SELECT w.user_id, u.username, u.display_name
|
|
744
|
+
FROM scheduled_task_watchers w JOIN users u ON u.user_id = w.user_id
|
|
745
|
+
WHERE w.schedule_id = ?1 ORDER BY u.username`,
|
|
746
|
+
[scheduleId],
|
|
747
|
+
).map(row => ({ user_id: row.user_id, username: row.username, display_name: row.display_name ?? "" }));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function userInNetwork(userId: string, networkId: string): boolean {
|
|
751
|
+
if (db.get("SELECT 1 AS ok FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId])) return true;
|
|
752
|
+
return !!db.get("SELECT 1 AS ok FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function replaceScheduleWatchers(scheduleId: string, networkId: string, raw: unknown): void {
|
|
756
|
+
if (!Array.isArray(raw) || raw.length > 20) throw new ScheduleInputError("invalid_watchers", 400);
|
|
757
|
+
const ids: string[] = [];
|
|
758
|
+
for (const item of raw) {
|
|
759
|
+
const id = typeof item === "string" ? item : item && typeof item === "object" && typeof (item as { user_id?: unknown }).user_id === "string" ? (item as { user_id: string }).user_id : "";
|
|
760
|
+
if (!id || id.length > 200) throw new ScheduleInputError("invalid_watchers", 400);
|
|
761
|
+
if (!ids.includes(id)) ids.push(id);
|
|
762
|
+
}
|
|
763
|
+
for (const id of ids) {
|
|
764
|
+
if (!userInNetwork(id, networkId)) throw new ScheduleInputError("watcher_not_in_network", 400, { user_id: id });
|
|
765
|
+
}
|
|
766
|
+
db.run("DELETE FROM scheduled_task_watchers WHERE schedule_id = ?1", [scheduleId]);
|
|
767
|
+
for (const id of ids) db.run("INSERT INTO scheduled_task_watchers (schedule_id, user_id) VALUES (?1, ?2)", [scheduleId, id]);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function watchingSchedule(scheduleId: string, userId: string | null): boolean {
|
|
771
|
+
if (!userId) return false;
|
|
772
|
+
return !!db.get("SELECT 1 AS ok FROM scheduled_task_watchers WHERE schedule_id = ?1 AND user_id = ?2", [scheduleId, userId]);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function canManageWatchers(row: ScheduledRow, ctx: ScheduledRequestContext): boolean {
|
|
776
|
+
const uid = ctx.auth?.userId ?? null;
|
|
777
|
+
if (ctx.isAdmin) return true;
|
|
778
|
+
if (uid && row.created_by === uid) return true;
|
|
779
|
+
return !row.created_by && writeAllowed(ctx, row.network_id);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function decorateSchedule(row: ScheduledRow, ctx: ScheduledRequestContext): Record<string, unknown> {
|
|
783
|
+
const watchers = scheduleWatchers(row.schedule_id);
|
|
784
|
+
const uid = ctx.auth?.userId ?? null;
|
|
785
|
+
const isWatcher = !!uid && watchers.some(w => w.user_id === uid);
|
|
786
|
+
return {
|
|
787
|
+
...decodeRow(row),
|
|
788
|
+
watchers,
|
|
789
|
+
is_watcher: isWatcher,
|
|
790
|
+
can_edit: writeAllowed(ctx, row.network_id) || isWatcher,
|
|
791
|
+
can_manage_watchers: canManageWatchers(row, ctx),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
738
795
|
export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): Promise<Response | null> {
|
|
739
796
|
const { req, url } = ctx;
|
|
740
797
|
if (url.pathname !== "/api/scheduled-tasks" && !url.pathname.startsWith("/api/scheduled-tasks/")) return null;
|
|
@@ -747,7 +804,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
747
804
|
const status = url.searchParams.get("status");
|
|
748
805
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
749
806
|
sql += " ORDER BY updated_at DESC LIMIT 500";
|
|
750
|
-
return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(
|
|
807
|
+
return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(row => decorateSchedule(row, ctx)) });
|
|
751
808
|
}
|
|
752
809
|
|
|
753
810
|
if (url.pathname === "/api/scheduled-tasks" && req.method === "POST") {
|
|
@@ -758,8 +815,10 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
758
815
|
if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
759
816
|
try {
|
|
760
817
|
const created = insertSchedule(networkId, ctx.auth?.userId ?? null, null, validateScheduleCreate(networkId, body));
|
|
761
|
-
|
|
818
|
+
if (body.watchers !== undefined) replaceScheduleWatchers(created.schedule_id, networkId, body.watchers);
|
|
819
|
+
return Response.json({ ok: true, schedule: decorateSchedule(getScheduleRow(created.schedule_id)!, ctx) }, { status: 201 });
|
|
762
820
|
} catch (e: any) {
|
|
821
|
+
if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
|
|
763
822
|
const code = String(e?.message || "invalid_schedule");
|
|
764
823
|
const status = code === "target_node_not_found" ? 404 : 400;
|
|
765
824
|
return jsonError(code, status);
|
|
@@ -777,8 +836,12 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
777
836
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
778
837
|
return Response.json({ ok: true, ...scheduleRuns(row, limit) });
|
|
779
838
|
}
|
|
780
|
-
if (!sub && req.method === "GET") return Response.json({ ok: true, schedule:
|
|
781
|
-
|
|
839
|
+
if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decorateSchedule(row, ctx) });
|
|
840
|
+
const watcher = watchingSchedule(row.schedule_id, ctx.auth?.userId ?? null);
|
|
841
|
+
const writer = writeAllowed(ctx, row.network_id);
|
|
842
|
+
if (!writer && !watcher) return jsonError("permission_denied", 403);
|
|
843
|
+
if (!writer && (sub === "cancel" || (!sub && req.method === "DELETE"))) return jsonError("watcher_cannot_cancel", 403);
|
|
844
|
+
if (!writer && sub === "run-now") return jsonError("watcher_cannot_run", 403);
|
|
782
845
|
|
|
783
846
|
if (sub === "run-now" && req.method === "POST") {
|
|
784
847
|
if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
|
|
@@ -808,8 +871,11 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
808
871
|
if (!sub && req.method === "PATCH") {
|
|
809
872
|
let body: Record<string, unknown>;
|
|
810
873
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
874
|
+
if (body.watchers !== undefined && !canManageWatchers(row, ctx)) return jsonError("watcher_cannot_manage_watchers", 403);
|
|
811
875
|
try {
|
|
812
|
-
|
|
876
|
+
const updated = patchSchedule(row, body, ctx.auth?.userId ?? null);
|
|
877
|
+
if (body.watchers !== undefined) replaceScheduleWatchers(updated.schedule_id, updated.network_id, body.watchers);
|
|
878
|
+
return Response.json({ ok: true, schedule: decorateSchedule(getScheduleRow(updated.schedule_id)!, ctx) });
|
|
813
879
|
} catch (e: any) {
|
|
814
880
|
if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
|
|
815
881
|
throw e;
|
|
@@ -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
|
}
|