@sleep2agi/commhub-server 0.9.0-preview.118 → 0.9.0-preview.119
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/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/db.ts +6 -0
- package/src/node-lifecycle-read-http.test.ts +30 -0
- package/src/node-lifecycle-read.ts +17 -1
- package/src/start-node.test.ts +158 -3
- package/src/tool-audience-http.test.ts +6 -1
- package/src/tools.ts +60 -16
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.119",
|
|
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",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
export const forkRecoveryRequestSchema = z.object({
|
|
4
|
+
kind: z.literal("fork_on_missing_ordinal"), confirmed: z.literal(true),
|
|
5
|
+
}).strict();
|
|
6
|
+
const threadId = z.string().length(36).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
7
|
+
export const forkRecoveryResultSchema = z.discriminatedUnion("state", [
|
|
8
|
+
z.object({ state: z.literal("forked"), old_thread_id: threadId, new_thread_id: threadId }).strict(),
|
|
9
|
+
z.object({ state: z.literal("not_observed") }).strict(),
|
|
10
|
+
z.object({ state: z.literal("unknown"), reason: z.enum([
|
|
11
|
+
"invalid_request_id", "history_unreadable", "history_invalid", "request_ambiguous", "mapping_invalid",
|
|
12
|
+
"result_not_reported", "result_invalid",
|
|
13
|
+
]) }).strict(),
|
|
14
|
+
]).refine(r => r.state !== "forked" || r.old_thread_id !== r.new_thread_id);
|
|
15
|
+
|
|
16
|
+
// Read paths re-validate stored data, not just the write-time MCP schema.
|
|
17
|
+
export function publicForkResult(raw: unknown) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = forkRecoveryResultSchema.safeParse(typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
20
|
+
if (parsed.success) return parsed.data;
|
|
21
|
+
} catch { /* corrupted/legacy storage must not expose arbitrary fields */ }
|
|
22
|
+
return { state: "unknown", reason: raw == null ? "result_not_reported" : "result_invalid" } as const;
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
test("legacy start table upgrades twice without changing ordinary rows or losing fork receipts", () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "fork-migration-"));
|
|
11
|
+
const path = join(root, "legacy.db");
|
|
12
|
+
try {
|
|
13
|
+
let db = new Database(path);
|
|
14
|
+
db.exec(`CREATE TABLE node_start_requests (
|
|
15
|
+
request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, daemon_node_id TEXT NOT NULL,
|
|
16
|
+
child_node_id TEXT NOT NULL, child_alias TEXT NOT NULL, created_by_token TEXT NOT NULL,
|
|
17
|
+
status TEXT NOT NULL DEFAULT 'pending', error TEXT, child_pid INTEGER,
|
|
18
|
+
created_at INTEGER NOT NULL, delivered_at INTEGER, acked_at INTEGER);
|
|
19
|
+
INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
|
|
20
|
+
VALUES('str_legacy','net_fixture','node_daemon','node_child','fixture','fixture','started',1);`);
|
|
21
|
+
db.close();
|
|
22
|
+
const migrate = () => execFileSync(process.execPath, ["-e",
|
|
23
|
+
`await import(${JSON.stringify(fileURLToPath(new URL("./db.ts", import.meta.url)))}); process.exit(0);`],
|
|
24
|
+
{ env: { ...process.env, COMMHUB_DB: path }, timeout: 15_000, stdio: "pipe" });
|
|
25
|
+
migrate();
|
|
26
|
+
db = new Database(path);
|
|
27
|
+
expect(db.query("SELECT status,fork_recovery_json,fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
|
|
28
|
+
.toEqual({ status: "started", fork_recovery_json: null, fork_result_json: null });
|
|
29
|
+
const receipt = JSON.stringify({ state: "not_observed" });
|
|
30
|
+
db.query("UPDATE node_start_requests SET fork_result_json=? WHERE request_id='str_legacy'").run(receipt);
|
|
31
|
+
db.close();
|
|
32
|
+
migrate();
|
|
33
|
+
db = new Database(path);
|
|
34
|
+
expect(db.query("SELECT fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
|
|
35
|
+
.toEqual({ fork_result_json: receipt });
|
|
36
|
+
db.close();
|
|
37
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
38
|
+
});
|
package/src/db.ts
CHANGED
|
@@ -504,6 +504,12 @@ db.exec(`
|
|
|
504
504
|
`);
|
|
505
505
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_daemon ON node_start_requests(daemon_node_id, status)`);
|
|
506
506
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_child ON node_start_requests(child_node_id)`);
|
|
507
|
+
// Additive: old rows remain ordinary starts (NULL). Keep the request and the
|
|
508
|
+
// observed side effect separate from launcher status, including startup failure.
|
|
509
|
+
for (const column of ["fork_recovery_json", "fork_result_json"]) {
|
|
510
|
+
try { db.exec(`ALTER TABLE node_start_requests ADD COLUMN ${column} TEXT`); }
|
|
511
|
+
catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
512
|
+
}
|
|
507
513
|
try {
|
|
508
514
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_start_req_child_inflight
|
|
509
515
|
ON node_start_requests(child_node_id)
|
|
@@ -140,6 +140,36 @@ test("cross-network request and node guesses do not disclose data", async () =>
|
|
|
140
140
|
expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), b.token)).status).toBe(403);
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
|
+
test("fork receipt uses the same authenticated node visibility and redacts corrupt storage", async () => {
|
|
144
|
+
const receipt = { state: "forked", old_thread_id: "01a11846-d796-72f1-af68-8d9215a65dc8", new_thread_id: "01a11900-0000-7000-8000-000000000001" };
|
|
145
|
+
const path = query("start", "request_id=start_read_failed");
|
|
146
|
+
try {
|
|
147
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'",
|
|
148
|
+
[JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true }), JSON.stringify(receipt)]);
|
|
149
|
+
expect((await get(path)).body.request.fork_recovery).toEqual({ requested: true, result: receipt });
|
|
150
|
+
expect((await get(path, b.token)).status).toBe(404);
|
|
151
|
+
expect((await get(path, limited.token)).status).toBe(404);
|
|
152
|
+
expect((await get(path, daemonToken)).status).toBe(403);
|
|
153
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
154
|
+
expect((await get(path, limited.token)).body.request.fork_recovery.result).toEqual(receipt);
|
|
155
|
+
db.run("UPDATE node_start_requests SET fork_result_json=?1 WHERE request_id='start_read_failed'", [JSON.stringify({ ...receipt, snapshot: "/private/secret" })]);
|
|
156
|
+
const corrupted = await get(path, limited.token);
|
|
157
|
+
expect(corrupted.body.request.fork_recovery.result).toEqual({ state: "unknown", reason: "result_invalid" });
|
|
158
|
+
expect(JSON.stringify(corrupted)).not.toContain("/private");
|
|
159
|
+
// Corrupt confirmation cannot assert requested:true, but must not erase an
|
|
160
|
+
// independently valid recorded fork (nor make the request look ordinary).
|
|
161
|
+
for (const raw of ["broken /private/secret", "null", "{}", JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: false }),
|
|
162
|
+
JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true, snapshot: "/private/secret" })]) {
|
|
163
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'", [raw, JSON.stringify(receipt)]);
|
|
164
|
+
const invalid = await get(path, limited.token);
|
|
165
|
+
expect(invalid.body.request.fork_recovery).toEqual({ requested: null, result: receipt });
|
|
166
|
+
expect(JSON.stringify(invalid)).not.toContain("/private");
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
|
|
170
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=NULL,fork_result_json=NULL WHERE request_id='start_read_failed'");
|
|
171
|
+
}
|
|
172
|
+
});
|
|
143
173
|
test("same-network restricted viewer without node grants cannot read requests", async () => {
|
|
144
174
|
for (const kind of ["adopt", "start", "stop"]) {
|
|
145
175
|
expect((await get(query(kind, `node_id=${ids.active}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { db } from "./db.js";
|
|
2
2
|
import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js";
|
|
3
|
+
import { forkRecoveryRequestSchema, publicForkResult } from "./codex-fork-contract.js";
|
|
3
4
|
|
|
4
5
|
// Exact public codes only; daemon exception text must never reach node viewers.
|
|
5
6
|
const publicLifecycleErrors = new Set([
|
|
7
|
+
"codex_fork_confirmation_invalid", "codex_fork_recovery_unsupported", "codex_fork_cli_unsupported",
|
|
8
|
+
"codex_fork_result_invalid", "codex_fork_outcome_unknown",
|
|
6
9
|
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
7
10
|
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
8
11
|
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
@@ -91,9 +94,22 @@ export function lifecycleRequestResponse(url: URL, scope: RestNetworkScope): Res
|
|
|
91
94
|
const params: any[] = [requestId ?? nodeId];
|
|
92
95
|
const sql = scopeNodes(`SELECT r.request_id,r.${nodeColumn} AS node_id,r.network_id,r.daemon_node_id,
|
|
93
96
|
r.status,r.error,r.created_at,${kind === "adopt" ? "r.updated_at" : "r.acked_at,r.delivered_at"}
|
|
97
|
+
${kind === "start" ? ",r.fork_recovery_json,r.fork_result_json" : ""}
|
|
94
98
|
FROM ${table} r JOIN nodes n ON n.node_id=r.${nodeColumn} AND n.network_id=r.network_id
|
|
95
99
|
WHERE r.${requestId ? "request_id" : nodeColumn}=?1${kind === "stop" ? " AND r.action='stop'" : ""}`, params, scope);
|
|
96
100
|
const row = db.get<Record<string, unknown>>(sql + " ORDER BY r.created_at DESC,r.request_id DESC LIMIT 1", ...params);
|
|
97
101
|
if (!row && requestId) return fail("request_not_found", 404);
|
|
98
|
-
return Response.json({ ok: true, request:
|
|
102
|
+
if (!row) return Response.json({ ok: true, request: null });
|
|
103
|
+
const { fork_recovery_json, fork_result_json, ...publicRow } = row;
|
|
104
|
+
let confirmed: true | null = null;
|
|
105
|
+
if (kind === "start" && typeof fork_recovery_json === "string") {
|
|
106
|
+
try {
|
|
107
|
+
if (forkRecoveryRequestSchema.safeParse(JSON.parse(fork_recovery_json)).success) confirmed = true;
|
|
108
|
+
} catch { /* damaged confirmation is unknown; keep any independently valid fork evidence */ }
|
|
109
|
+
}
|
|
110
|
+
return Response.json({ ok: true, request: { kind, ...publicRow, error: publicLifecycleError(row.error),
|
|
111
|
+
...(kind === "start" && fork_recovery_json != null ? { fork_recovery: {
|
|
112
|
+
requested: confirmed, result: publicForkResult(fork_result_json),
|
|
113
|
+
} } : {}),
|
|
114
|
+
} });
|
|
99
115
|
}
|
package/src/start-node.test.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { db } from "./db.js";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { handleStartDoorbell } from "../../agent-node/src/runtime/start-daemon.js";
|
|
10
|
+
import { lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
import { createCodexForkCapabilityMonitor } from "../../agent-node/src/runtime/codex-fork-capability.js";
|
|
13
|
+
import { attachCodexForkCapability, buildConfigSnapshot } from "../../agent-node/src/runtime/config-apply.js";
|
|
5
14
|
|
|
6
15
|
const NET = "net_start_node"; const USER = "u_start_node";
|
|
7
16
|
const DAEMON = "node_start_daemon"; const DAEMON_ALIAS = "start-daemon";
|
|
@@ -25,18 +34,132 @@ beforeEach(() => { cleanup();
|
|
|
25
34
|
afterAll(cleanup);
|
|
26
35
|
|
|
27
36
|
function handlers(user: string | null, daemon = false) {
|
|
28
|
-
const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = {};
|
|
29
|
-
const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; return orig(n,d,schema,h); };
|
|
37
|
+
const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = { $schemas: {} };
|
|
38
|
+
const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; out.$schemas[n] = schema; return orig(n,d,schema,h); };
|
|
30
39
|
registerTools(s, undefined, daemon ? NET : null, user, null, daemon, daemon ? "tok_start_daemon" : null); return out;
|
|
31
40
|
}
|
|
32
41
|
async function call(h: any, args: any) { return JSON.parse((await h(args)).content[0].text); }
|
|
33
42
|
|
|
43
|
+
describe("confirmed fork request/result transport (#822)", () => {
|
|
44
|
+
const recovery = { kind: "fork_on_missing_ordinal", confirmed: true };
|
|
45
|
+
const forked = { state: "forked", old_thread_id: "01a11846-d796-72f1-af68-8d9215a65dc8",
|
|
46
|
+
new_thread_id: "01a11900-0000-7000-8000-000000000001" };
|
|
47
|
+
function enable() {
|
|
48
|
+
db.run(`UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2`, [JSON.stringify({ role: "host_supervisor",
|
|
49
|
+
daemon_capabilities: { codex_fork_recovery: { protocol: 1, cli_supported: true } } }), DAEMON]);
|
|
50
|
+
}
|
|
51
|
+
function read(requestId: string, networkId = NET) {
|
|
52
|
+
return lifecycleRequestResponse(new URL(`http://hub/api/node-lifecycle-requests?kind=start&request_id=${requestId}`),
|
|
53
|
+
{ networkId, networkIds: null });
|
|
54
|
+
}
|
|
55
|
+
test("real capability probe survives report schema/storage and opens only the recovery gate", async () => {
|
|
56
|
+
const root = mkdtempSync(join(tmpdir(), "fork-capability-report-"));
|
|
57
|
+
try {
|
|
58
|
+
const bin = join(root, "anet-fixture");
|
|
59
|
+
writeFileSync(bin, `#!${process.execPath}\nconsole.log('--fork-on-resume-failure --fork-recovery-request-id <str_id> --yes');`, { mode: 0o700 });
|
|
60
|
+
const monitor = createCodexForkCapabilityMonitor({ bin: () => bin, cwd: root });
|
|
61
|
+
await monitor.refresh();
|
|
62
|
+
const d = handlers(null, true), u = handlers(USER);
|
|
63
|
+
const snapshot = attachCodexForkCapability(buildConfigSnapshot({ role: "host_supervisor" }, false, 0), monitor.current());
|
|
64
|
+
const wire = z.object(d.$schemas.report_status).parse({ alias: DAEMON_ALIAS, node_id: DAEMON,
|
|
65
|
+
resume_id: "fork-capability-daemon", status: "idle", config_snapshot: snapshot });
|
|
66
|
+
await call(d.report_status, wire);
|
|
67
|
+
const stored = JSON.parse(db.get<any>(`SELECT config_snapshot FROM nodes WHERE node_id=?1`, DAEMON).config_snapshot);
|
|
68
|
+
expect(stored.daemon_capabilities.codex_fork_recovery).toEqual({ protocol: 1, cli_supported: true });
|
|
69
|
+
expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).ok).toBe(true);
|
|
70
|
+
// Bad diagnostics are dropped, not a reason to reject the entire heartbeat.
|
|
71
|
+
for (const bad of [null, { protocol: 2, cli_supported: true }, { protocol: 1, cli_supported: "yes" }]) {
|
|
72
|
+
const parsed = d.$schemas.report_status.config_snapshot.parse({ ...snapshot,
|
|
73
|
+
daemon_capabilities: { can_create_nodes: true, codex_fork_recovery: bad } });
|
|
74
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
75
|
+
expect(parsed.daemon_capabilities.codex_fork_recovery).toBeUndefined();
|
|
76
|
+
}
|
|
77
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
78
|
+
});
|
|
79
|
+
test("requires explicit confirmation and affirmative capability before creating any request", async () => {
|
|
80
|
+
const u = handlers(USER);
|
|
81
|
+
for (const value of [null, {}, { ...recovery, confirmed: false }]) {
|
|
82
|
+
expect((await call(u.start_node, { node_id: CHILD, fork_recovery: value })).error).toBe("codex_fork_confirmation_invalid");
|
|
83
|
+
}
|
|
84
|
+
expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).error).toBe("codex_fork_recovery_unsupported");
|
|
85
|
+
expect(db.all(`SELECT * FROM node_start_requests WHERE network_id=?1`, NET)).toEqual([]);
|
|
86
|
+
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD).lifecycle_state).toBe("stopped");
|
|
87
|
+
enable();
|
|
88
|
+
expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).ok).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
test("persists one confirmation and refuses old daemon pull without marking delivered", async () => {
|
|
91
|
+
enable(); const u = handlers(USER), d = handlers(null, true);
|
|
92
|
+
const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
|
|
93
|
+
expect((await call(d.get_start_request, { request_id })).error).toBe("codex_fork_recovery_unsupported");
|
|
94
|
+
const row = db.get<any>(`SELECT * FROM node_start_requests WHERE request_id=?1`, request_id);
|
|
95
|
+
expect(row.status).toBe("pending"); expect(JSON.parse(row.fork_recovery_json)).toEqual(recovery);
|
|
96
|
+
expect(await call(d.get_start_request, { request_id, fork_recovery_capable: true })).toMatchObject({
|
|
97
|
+
ok: true, request_id, managed: "created", fork_recovery: recovery,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
test.each([0, 7])("Hub → daemon → real CLI fixture exit %s → persisted fork and scoped read", async exitCode => {
|
|
101
|
+
enable(); const u = handlers(USER), d = handlers(null, true);
|
|
102
|
+
const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
|
|
103
|
+
const root = mkdtempSync(join(tmpdir(), "fork-flow-"));
|
|
104
|
+
try {
|
|
105
|
+
const nodeDir = join(root, CHILD_ALIAS); mkdirSync(nodeDir);
|
|
106
|
+
writeFileSync(join(nodeDir, "config.json"), JSON.stringify({ node_id: CHILD, alias: CHILD_ALIAS,
|
|
107
|
+
runtime: "codex-app-server", codexCopresence: true }), { mode: 0o600 });
|
|
108
|
+
const bin = join(root, "anet-fixture");
|
|
109
|
+
// Real executable: validates argv, writes a CLI-shaped mapping and exits.
|
|
110
|
+
// It is not a real Codex/model recovery test.
|
|
111
|
+
writeFileSync(bin, `#!${process.execPath}\nimport {writeFileSync} from 'node:fs';
|
|
112
|
+
const args=process.argv.slice(2);
|
|
113
|
+
if(args.join(' ')==='node start --help') { console.log('--fork-on-resume-failure --fork-recovery-request-id <str_id> --yes'); process.exit(0); }
|
|
114
|
+
if(JSON.stringify(args)!==${JSON.stringify(JSON.stringify(["node", "start", CHILD_ALIAS, "--fork-on-resume-failure", "--yes", "--fork-recovery-request-id", request_id]))}) process.exit(99);
|
|
115
|
+
writeFileSync(${JSON.stringify(join(nodeDir, "codex-fork-recovery.json"))},JSON.stringify({forks:[{requestId:${JSON.stringify(request_id)},oldThreadId:${JSON.stringify(forked.old_thread_id)},newThreadId:${JSON.stringify(forked.new_thread_id)},snapshot:'/private/snapshot'}]}));
|
|
116
|
+
process.exit(${exitCode});`, { mode: 0o700 });
|
|
117
|
+
await handleStartDoorbell({ request_id }, { workDir: root, nodesRoot: root, anetBin: () => bin,
|
|
118
|
+
callCommHub: (tool, args) => call(d[tool], args), log() {}, warn() {} });
|
|
119
|
+
const row = db.get<any>(`SELECT * FROM node_start_requests WHERE request_id=?1`, request_id);
|
|
120
|
+
expect(row.status).toBe(exitCode === 0 ? "started" : "start_failed");
|
|
121
|
+
expect(JSON.parse(row.fork_result_json)).toEqual(forked);
|
|
122
|
+
const publicResult = await read(request_id).json();
|
|
123
|
+
expect(publicResult.request.fork_recovery).toEqual({ requested: true, result: forked });
|
|
124
|
+
expect(JSON.stringify(publicResult)).not.toContain("/private");
|
|
125
|
+
expect(JSON.stringify(publicResult)).not.toContain("fork_result_json");
|
|
126
|
+
expect(read(request_id, "net_unrelated").status).toBe(404);
|
|
127
|
+
// Terminal replay must not overwrite the original side effect receipt.
|
|
128
|
+
expect(await call(d.ack_start_request, { request_id, status: "start_failed", fork_recovery: { state: "not_observed" } }))
|
|
129
|
+
.toMatchObject({ ok: true, idempotent: true });
|
|
130
|
+
expect(JSON.parse(db.get<any>(`SELECT fork_result_json FROM node_start_requests WHERE request_id=?1`, request_id).fork_result_json)).toEqual(forked);
|
|
131
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
132
|
+
});
|
|
133
|
+
test("missing result is unknown, malformed result rejected; ordinary receipts keep old shape", async () => {
|
|
134
|
+
const u = handlers(USER), d = handlers(null, true);
|
|
135
|
+
const ordinary = await call(u.start_node, { node_id: CHILD });
|
|
136
|
+
expect((await call(d.ack_start_request, { request_id: ordinary.request_id, status: "start_failed", fork_recovery: forked })).error).toBe("codex_fork_result_invalid");
|
|
137
|
+
await call(d.ack_start_request, { request_id: ordinary.request_id, status: "start_failed" });
|
|
138
|
+
expect((await read(ordinary.request_id).json()).request).not.toHaveProperty("fork_recovery");
|
|
139
|
+
enable(); const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
|
|
140
|
+
expect((await call(d.ack_start_request, { request_id, status: "start_failed", fork_recovery: { ...forked, snapshot: "/private" } })).error).toBe("codex_fork_result_invalid");
|
|
141
|
+
await call(d.ack_start_request, { request_id, status: "start_failed", error: "codex_fork_cli_unsupported" });
|
|
142
|
+
expect((await read(request_id).json()).request).toMatchObject({ error: "codex_fork_cli_unsupported",
|
|
143
|
+
fork_recovery: { result: { state: "unknown", reason: "result_not_reported" } } });
|
|
144
|
+
db.run(`UPDATE node_start_requests SET fork_result_json=?1 WHERE request_id=?2`, [JSON.stringify({ ...forked, snapshot: "/private" }), request_id]);
|
|
145
|
+
expect((await read(request_id).json()).request.fork_recovery.result).toEqual({ state: "unknown", reason: "result_invalid" });
|
|
146
|
+
});
|
|
147
|
+
test("stale recovery cannot be silently superseded by the ordinary start reaper", async () => {
|
|
148
|
+
enable(); const u = handlers(USER);
|
|
149
|
+
const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
|
|
150
|
+
db.run(`UPDATE node_start_requests SET created_at=?1 WHERE request_id=?2`, [Date.now() - 70_000, request_id]);
|
|
151
|
+
expect(await call(u.start_node, { node_id: CHILD })).toMatchObject({ ok: false,
|
|
152
|
+
error: "codex_fork_outcome_unknown", existing_request_id: request_id });
|
|
153
|
+
expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, request_id).status).toBe("pending");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
34
157
|
describe("start_node Hub -> daemon lifecycle", () => {
|
|
35
158
|
test("dispatch, authenticated pull, ack transitions stopped -> starting -> active", async () => {
|
|
36
159
|
const u = handlers(USER); const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
37
160
|
expect(dispatch.ok).toBe(true); expect(dispatch.lifecycle_state).toBe("starting");
|
|
38
161
|
const d = handlers(null, true); const pulled = await call(d.get_start_request, { request_id: dispatch.request_id });
|
|
39
|
-
expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS });
|
|
162
|
+
expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS, start_completion_capable: true });
|
|
40
163
|
const ack = await call(d.ack_start_request, { request_id: dispatch.request_id, status: "started", child_pid: 4321 });
|
|
41
164
|
expect(ack.ok).toBe(true);
|
|
42
165
|
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("active");
|
|
@@ -49,6 +172,38 @@ describe("start_node Hub -> daemon lifecycle", () => {
|
|
|
49
172
|
db.run(`UPDATE nodes SET lifecycle_state='stopped' WHERE node_id=?1`, CHILD);
|
|
50
173
|
expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
|
|
51
174
|
});
|
|
175
|
+
test("launcher progress refreshes stale-start clock, never marks active, and failure stays stopped", async () => {
|
|
176
|
+
const u = handlers(USER), d = handlers(null, true);
|
|
177
|
+
const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
178
|
+
const request_id = dispatch.request_id;
|
|
179
|
+
await call(d.get_start_request, { request_id });
|
|
180
|
+
db.run(`UPDATE node_start_requests SET created_at=?1, delivered_at=?1 WHERE request_id=?2`, [Date.now() - 70_000, request_id]);
|
|
181
|
+
expect(await call(d.ack_start_request, { request_id, status: "starting" })).toEqual({ ok: true, status: "starting" });
|
|
182
|
+
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("starting");
|
|
183
|
+
expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, request_id)?.status).toBe("delivered");
|
|
184
|
+
expect((await call(u.start_node, { child_node_id: CHILD, network_id: NET })).error).toBe("node_already_starting");
|
|
185
|
+
expect(await call(d.ack_start_request, { request_id, status: "start_failed", error: "codex_launcher_exit:1" })).toEqual({ ok: true, status: "start_failed" });
|
|
186
|
+
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("stopped");
|
|
187
|
+
expect(db.all<any>(`SELECT action FROM audit_log WHERE target_id=?1`, request_id).map(r => r.action)).toEqual(["start_node_dispatched"]);
|
|
188
|
+
// A heartbeat already in flight cannot resurrect a terminal request.
|
|
189
|
+
expect(await call(d.ack_start_request, { request_id, status: "starting" })).toMatchObject({ status: "start_failed", idempotent: true });
|
|
190
|
+
});
|
|
191
|
+
test.each([0, 7])("dispatch → daemon → real launcher exit %s → Hub lifecycle", async (exitCode) => {
|
|
192
|
+
const root = mkdtempSync(join(tmpdir(), "start-flow-"));
|
|
193
|
+
try {
|
|
194
|
+
mkdirSync(join(root, CHILD_ALIAS));
|
|
195
|
+
writeFileSync(join(root, CHILD_ALIAS, "config.json"), JSON.stringify({ node_id: CHILD, alias: CHILD_ALIAS, codexCopresence: true }), { mode: 0o600 });
|
|
196
|
+
const u = handlers(USER), d = handlers(null, true);
|
|
197
|
+
const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
198
|
+
await handleStartDoorbell({ request_id: dispatch.request_id }, {
|
|
199
|
+
workDir: root, nodesRoot: root, anetBin: () => process.execPath,
|
|
200
|
+
spawnChild: ((_bin, _args, opts) => spawn(process.execPath, ["-e", `setTimeout(() => process.exit(${exitCode}), 25)`], opts)) as typeof spawn,
|
|
201
|
+
callCommHub: (tool, args) => call(d[tool], args), log: () => {}, warn: () => {},
|
|
202
|
+
});
|
|
203
|
+
expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, dispatch.request_id)?.status).toBe(exitCode === 0 ? "started" : "start_failed");
|
|
204
|
+
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe(exitCode === 0 ? "active" : "stopped");
|
|
205
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
206
|
+
});
|
|
52
207
|
});
|
|
53
208
|
|
|
54
209
|
// #1448 finding-2 — stale-starting reaper(对齐 update_node_config/restart_node 的 60s
|
|
@@ -192,7 +192,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
192
192
|
// so any new node tool needs this; ceilings keep the same ~400–750 B margin.
|
|
193
193
|
// Review follow-up: descriptions / schemas cut further → node 59 / 61,100 B, all 85 / 76,749 B (−507 B each),
|
|
194
194
|
// leaving ≥ 700 B node margin even with #2488 (+116 B) merged.
|
|
195
|
-
|
|
195
|
+
// Board #822 adds confirmed fork fields, not tools. Same-image main 102dd0f2 → candidate:
|
|
196
|
+
// all 87 / 77,646 B → 78,976 B (+1,330); node 61 / 61,986 B → 62,368 B (+382);
|
|
197
|
+
// user 58 / 56,950 B → 57,163 B (+213). start_node adds 213 B, report_status 169 B;
|
|
198
|
+
// hidden pull/ack schemas account for the remaining 948 B. Keep strict receipt validation
|
|
199
|
+
// and unchanged audiences; budget only this measured addition (~130/520 B node/all margin).
|
|
200
|
+
const CEILINGS = { node: 62_500, user: 57_500, all: 79_500 } as const;
|
|
196
201
|
test("node / user / all", async () => {
|
|
197
202
|
const node = await listTools(T.node_normal);
|
|
198
203
|
const user = await listTools(T.owner);
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
3
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
4
|
+
import { forkRecoveryRequestSchema, forkRecoveryResultSchema, publicForkResult } from "./codex-fork-contract.js";
|
|
4
5
|
import { lockNodeAlias } from "./node-token-ownership.js";
|
|
5
6
|
import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
|
|
6
7
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -840,6 +841,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
840
841
|
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
841
842
|
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
842
843
|
daemon_capabilities: z.object({
|
|
844
|
+
codex_fork_recovery: z.object({ protocol: z.literal(1), cli_supported: z.boolean() }).optional().catch(undefined),
|
|
843
845
|
adopt_capable: z.boolean().optional(),
|
|
844
846
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
845
847
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
@@ -5393,9 +5395,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5393
5395
|
{
|
|
5394
5396
|
...NODE_ID_ALIAS_FIELDS,
|
|
5395
5397
|
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
5398
|
+
fork_recovery: forkRecoveryRequestSchema.optional(),
|
|
5396
5399
|
network_id: z.string().max(200).optional(),
|
|
5397
5400
|
},
|
|
5398
|
-
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
|
|
5401
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId, fork_recovery }) => {
|
|
5399
5402
|
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
5400
5403
|
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
5401
5404
|
const child_node_id = idArg.node_id;
|
|
@@ -5414,6 +5417,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5414
5417
|
);
|
|
5415
5418
|
if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
5416
5419
|
if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
5420
|
+
if (fork_recovery !== undefined) {
|
|
5421
|
+
const fail = (error: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error }) }] });
|
|
5422
|
+
if (!forkRecoveryRequestSchema.safeParse(fork_recovery).success) return fail("codex_fork_confirmation_invalid");
|
|
5423
|
+
// Confirmation is additional to the existing caller/child authority.
|
|
5424
|
+
// Require an affirmative new capability; missing means old/unsupported.
|
|
5425
|
+
if (createdDaemon(child_node_id) !== resolvedDaemonId || activeBinding(child_node_id)) return fail("codex_fork_recovery_unsupported");
|
|
5426
|
+
const daemon = db.get<{ config_snapshot: string | null; network_id: string }>(
|
|
5427
|
+
`SELECT config_snapshot, network_id FROM nodes WHERE node_id=?1`, resolvedDaemonId);
|
|
5428
|
+
let snapshot: any;
|
|
5429
|
+
try { snapshot = JSON.parse(daemon?.config_snapshot || "{}"); } catch { /* unsupported */ }
|
|
5430
|
+
const capability = snapshot?.daemon_capabilities?.codex_fork_recovery;
|
|
5431
|
+
if (daemon?.network_id !== child.network_id || snapshot?.role !== "host_supervisor"
|
|
5432
|
+
|| capability?.protocol !== 1 || capability?.cli_supported !== true) return fail("codex_fork_recovery_unsupported");
|
|
5433
|
+
}
|
|
5417
5434
|
// #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
|
|
5418
5435
|
// 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
|
|
5419
5436
|
// (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
|
|
@@ -5426,8 +5443,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5426
5443
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
|
|
5427
5444
|
}
|
|
5428
5445
|
const STALE_STARTING_MS = 60_000;
|
|
5429
|
-
const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
|
|
5430
|
-
`SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
|
|
5446
|
+
const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null; fork_recovery_json: string | null }>(
|
|
5447
|
+
`SELECT request_id, created_at, delivered_at, acked_at, fork_recovery_json FROM node_start_requests
|
|
5431
5448
|
WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
|
|
5432
5449
|
ORDER BY created_at DESC LIMIT 1`, child.node_id,
|
|
5433
5450
|
);
|
|
@@ -5443,6 +5460,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5443
5460
|
hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
|
|
5444
5461
|
}) }] };
|
|
5445
5462
|
}
|
|
5463
|
+
// A timed-out receipt cannot prove a confirmed fork did not happen.
|
|
5464
|
+
// Do not create a second recovery generation via the ordinary reaper.
|
|
5465
|
+
if (inFlight.fork_recovery_json) return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
5466
|
+
ok: false, error: "codex_fork_outcome_unknown", existing_request_id: inFlight.request_id,
|
|
5467
|
+
}) }] };
|
|
5446
5468
|
// Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
|
|
5447
5469
|
// node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
|
|
5448
5470
|
db.run(
|
|
@@ -5472,9 +5494,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5472
5494
|
db.run(
|
|
5473
5495
|
`INSERT INTO node_start_requests
|
|
5474
5496
|
(request_id, network_id, daemon_node_id, child_node_id, child_alias,
|
|
5475
|
-
created_by_token, status, created_at)
|
|
5476
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
|
|
5477
|
-
[requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now
|
|
5497
|
+
created_by_token, status, created_at, fork_recovery_json)
|
|
5498
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
|
|
5499
|
+
[requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now,
|
|
5500
|
+
fork_recovery ? JSON.stringify(fork_recovery) : null],
|
|
5478
5501
|
);
|
|
5479
5502
|
// #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
|
|
5480
5503
|
// starting);两种入态都合法地转/留到 'starting'。
|
|
@@ -5497,19 +5520,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5497
5520
|
server.tool(
|
|
5498
5521
|
"get_start_request",
|
|
5499
5522
|
"Host supervisor pulls an authenticated pending start request.",
|
|
5500
|
-
{ request_id: z.string().min(1).max(200) },
|
|
5501
|
-
async ({ request_id }) => {
|
|
5523
|
+
{ request_id: z.string().min(1).max(200), fork_recovery_capable: z.boolean().optional() },
|
|
5524
|
+
async ({ request_id, fork_recovery_capable }) => {
|
|
5502
5525
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5503
5526
|
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5504
|
-
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
5505
|
-
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
|
|
5527
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string; fork_recovery_json: string | null }>(
|
|
5528
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status, fork_recovery_json FROM node_start_requests WHERE request_id = ?1`, request_id,
|
|
5506
5529
|
);
|
|
5507
5530
|
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
5508
5531
|
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
5509
5532
|
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
5510
5533
|
if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
|
|
5534
|
+
let recovery: unknown;
|
|
5535
|
+
if (row.fork_recovery_json !== null) {
|
|
5536
|
+
if (fork_recovery_capable !== true || createdDaemon(row.child_node_id) !== row.daemon_node_id || activeBinding(row.child_node_id))
|
|
5537
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_recovery_unsupported" }) }] };
|
|
5538
|
+
try { recovery = forkRecoveryRequestSchema.parse(JSON.parse(row.fork_recovery_json)); }
|
|
5539
|
+
catch { return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_confirmation_invalid" }) }] }; }
|
|
5540
|
+
}
|
|
5511
5541
|
db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
|
|
5512
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias
|
|
5542
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias, start_completion_capable: true,
|
|
5543
|
+
...(recovery ? { managed: "created", fork_recovery: recovery } : {}) }) }] };
|
|
5513
5544
|
},
|
|
5514
5545
|
);
|
|
5515
5546
|
|
|
@@ -5518,15 +5549,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5518
5549
|
"Host supervisor reports start completion or failure.",
|
|
5519
5550
|
{
|
|
5520
5551
|
request_id: z.string().min(1).max(200),
|
|
5521
|
-
status: z.enum(["started", "start_failed"]),
|
|
5552
|
+
status: z.enum(["starting", "started", "start_failed"]),
|
|
5522
5553
|
child_pid: z.number().int().positive().optional(),
|
|
5523
5554
|
error: z.string().max(1000).optional(),
|
|
5555
|
+
fork_recovery: forkRecoveryResultSchema.optional(),
|
|
5524
5556
|
},
|
|
5525
|
-
async ({ request_id, status, child_pid, error: ackError }) => {
|
|
5557
|
+
async ({ request_id, status, child_pid, error: ackError, fork_recovery }) => {
|
|
5526
5558
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5527
5559
|
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5528
|
-
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
5529
|
-
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
|
|
5560
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string; fork_recovery_json: string | null }>(
|
|
5561
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status, fork_recovery_json FROM node_start_requests WHERE request_id=?1`, request_id,
|
|
5530
5562
|
);
|
|
5531
5563
|
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
5532
5564
|
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
@@ -5537,13 +5569,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5537
5569
|
if (!['pending', 'delivered'].includes(row.status)) {
|
|
5538
5570
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
|
|
5539
5571
|
}
|
|
5572
|
+
if (fork_recovery !== undefined && (!row.fork_recovery_json || status === "starting"
|
|
5573
|
+
|| !forkRecoveryResultSchema.safeParse(fork_recovery).success)) {
|
|
5574
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_result_invalid" }) }] };
|
|
5575
|
+
}
|
|
5576
|
+
// A finite co-presence launcher can spend minutes restoring a large thread.
|
|
5577
|
+
// Refresh the existing stale-start clock, without claiming startup completed.
|
|
5578
|
+
if (status === "starting") {
|
|
5579
|
+
db.run(`UPDATE node_start_requests SET acked_at=?1 WHERE request_id=?2`, [Date.now(), request_id]);
|
|
5580
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "starting" }) }] };
|
|
5581
|
+
}
|
|
5540
5582
|
if (status === "started" && !child_pid) {
|
|
5541
5583
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
|
|
5542
5584
|
}
|
|
5543
5585
|
const now = Date.now();
|
|
5544
5586
|
try {
|
|
5545
5587
|
db.transaction(() => {
|
|
5546
|
-
db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4 WHERE request_id=?5`,
|
|
5588
|
+
db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4,fork_result_json=?6 WHERE request_id=?5`,
|
|
5589
|
+
[status, ackError || null, child_pid || null, now, request_id,
|
|
5590
|
+
row.fork_recovery_json ? JSON.stringify(publicForkResult(fork_recovery)) : null]);
|
|
5547
5591
|
db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
|
|
5548
5592
|
if (status === "started") auditCreateNodeStrict({
|
|
5549
5593
|
action: "start_node_completed", network_id: row.network_id, target_id: request_id,
|