@deksden-com/dd-flow-cli 0.9.0-beta.122 → 0.9.0-beta.124
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/CHANGELOG.md +18 -0
- package/README.md +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +3 -0
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +117 -21
- package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +58 -13
- package/dist/harness-runtime/lib/dd-codex.mjs +16 -26
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
- package/dist/harness-runtime/lib/dd-zcode.mjs +24 -14
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +100 -31
- package/dist/harness-runtime/lib/model-observations.mjs +16 -9
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/services/code-checks.js +14 -18
- package/dist/services/codex-capacity.js +251 -31
- package/dist/services/controller-fanout.js +1 -0
- package/dist/services/eval-snapshots.js +13 -8
- package/dist/services/external-work-launch.js +36 -11
- package/dist/services/harness-adapter.js +21 -15
- package/dist/services/managed-processes.js +14 -6
- package/dist/services/merge-server.js +35 -12
- package/dist/services/review-copy.js +21 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-controller-adapter.js +33 -8
- package/dist/services/run-controller-recovery.js +26 -2
- package/dist/services/run-controller.js +91 -24
- package/dist/services/run-observations.js +7 -2
- package/dist/services/run-recovery-runtime.js +49 -3
- package/dist/services/runs.js +1 -1
- package/dist/services/stage-report-renderer.js +25 -2
- package/dist/services/verification-matrix.js +57 -8
- package/dist/services/vnext-code-review.js +22 -8
- package/dist/services/vnext-merge.js +20 -2
- package/dist/services/vnext-plan-review.js +87 -19
- package/dist/services/work-registry.js +11 -2
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { DroidError, DroidRuntime, absolute, droidPaths, droidVersion, prepareDroidHome, readJson, removeDroidAuth, processSnapshot } from "./dd-droid.mjs";
|
|
7
|
-
import { durableDaemonDispatch, inspectDaemonOperation } from "./daemon-operations.mjs";
|
|
7
|
+
import { durableDaemonDispatch, inspectDaemonOperation, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
8
8
|
import { observedTimeout } from "./observation-clock.mjs";
|
|
9
9
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
10
10
|
import { errorRecord } from "./operation-errors.mjs";
|
|
@@ -33,8 +33,9 @@ export async function startDaemon(options) {
|
|
|
33
33
|
config.cwd = await realpath(config.cwd); config.projectRoot = await realpath(config.projectRoot);
|
|
34
34
|
if (config.mode !== "auto-high") throw new DroidError("droid_mode_unsupported", "The qualified Droid profile requires auto-high");
|
|
35
35
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
36
|
+
let previous = await readJson(paths.state); assertDaemonRestartable(previous);
|
|
36
37
|
try { const live = await callDaemon(paths.dir, "daemon.status", {}, 1_000); if (!sameConfig(live.config, config)) throw new DroidError("daemon_config_mismatch", "Droid daemon already uses another configuration"); return { ...live, already_running: true }; } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; }
|
|
37
|
-
|
|
38
|
+
previous = await readJson(paths.state); assertDaemonRestartable(previous);
|
|
38
39
|
if (previous) {
|
|
39
40
|
const rows = await processSnapshot();
|
|
40
41
|
if (rows.some(row => !row.zombie && ([previous.pid, previous.provider_pid].includes(row.pid) || previous.owned_processes?.some(old => old.pid === row.pid && old.started === row.started)))) throw new DroidError("invalid_harness_crash", "Previous daemon or provider ownership remains; refusing another owner");
|
|
@@ -53,11 +54,11 @@ export async function startDaemon(options) {
|
|
|
53
54
|
throw new DroidError("daemon_start_failed", "Droid daemon did not become ready");
|
|
54
55
|
} catch (error) { await cleanupFailedStart(config, resource, child, error); throw error; }
|
|
55
56
|
}
|
|
56
|
-
export async function stopDaemon(options) { return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, 30_000), droidPaths(options.stateDir).socket, 30_000); }
|
|
57
|
+
export async function stopDaemon(options) { return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, 30_000), droidPaths(options.stateDir).socket, 30_000, { readState: () => readJson(droidPaths(options.stateDir).state) }); }
|
|
57
58
|
export async function serveDaemon(stateDir) {
|
|
58
59
|
const paths = droidPaths(stateDir), state = await readJson(paths.state); if (!state?.config) throw new DroidError("daemon_state_missing", "Missing Droid daemon state");
|
|
59
60
|
let saving = Promise.resolve(), busy = null, stopping = false;
|
|
60
|
-
const save = patch => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString() }); const snapshot = structuredClone(state); saving = saving.then(() => writeJsonAtomic(paths.state, snapshot)); return saving; };
|
|
61
|
+
const save = patch => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString() }); const snapshot = structuredClone(state); saving = saving.catch(() => {}).then(() => writeJsonAtomic(paths.state, snapshot)); return saving; };
|
|
61
62
|
const runtime = new DroidRuntime(state.config, state, save);
|
|
62
63
|
const dispatch = async request => {
|
|
63
64
|
const params = request.params ?? {}, operation = request.operation;
|
|
@@ -71,24 +72,29 @@ export async function serveDaemon(stateDir) {
|
|
|
71
72
|
return { ...inspected, cancelled: true, settled: cleanup.settled, cleanup };
|
|
72
73
|
}
|
|
73
74
|
if (operation === "daemon.stop") {
|
|
74
|
-
if (stopping) throw new DroidError("operation_busy", "Droid shutdown is already in progress");
|
|
75
|
-
if (!params.cancelTree && (busy || (runtime.rootId && !(await runtime.inspect(runtime.rootId)).settled))) throw new DroidError("tree_not_settled", "Droid still owns unfinished work");
|
|
76
75
|
stopping = true;
|
|
77
76
|
try {
|
|
77
|
+
return await completeDaemonShutdown(paths.dir, state, save, async () => {
|
|
78
|
+
if (!params.cancelTree && (busy || (runtime.rootId && !(await runtime.inspect(runtime.rootId)).settled))) throw new DroidError("tree_not_settled", "Droid still owns unfinished work");
|
|
79
|
+
commitDaemonShutdown(paths.dir);
|
|
78
80
|
const cleanup = await runtime.closeTree(params.cancelTree === true);
|
|
79
81
|
if (runtime.rootId) try { await runtime.inspect(runtime.rootId, { ignoreActive: true }); } catch (error) { cleanup.inspection_error = errorRecord(error); }
|
|
80
82
|
// Native evidence failures remain visible, but cannot strand copied
|
|
81
83
|
// credentials or the daemon after the owned process tree is gone.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
|
|
85
|
+
return { clean, cleanup };
|
|
86
|
+
}, [
|
|
87
|
+
["auth", () => removeDroidAuth(state.config)],
|
|
88
|
+
["daemon_resource", () => finishDaemonProcess(state.config, state.resource_process)]
|
|
89
|
+
]);
|
|
85
90
|
}
|
|
86
|
-
catch (error) {
|
|
91
|
+
catch (error) { if (error.code === "tree_not_settled" && !runtime.closed) stopping = false; throw error; }
|
|
87
92
|
}
|
|
88
93
|
if (busy || stopping) throw new DroidError("operation_busy", "Another Droid operation is active");
|
|
89
94
|
busy = operation;
|
|
90
95
|
try {
|
|
91
96
|
await save({ active_operation: busy });
|
|
97
|
+
assertDaemonDispatch(paths.dir);
|
|
92
98
|
if (operation === "session.create") { if (params.prompt) throw new DroidError("invalid_create", "create does not execute a prompt"); return await runtime.create(request.id); }
|
|
93
99
|
if (operation === "session.resume") return await runtime.resume(params.sessionId);
|
|
94
100
|
if (["session.prompt", "session.start"].includes(operation)) return await runtime.prompt(params.sessionId, params.prompt, request.id);
|
|
@@ -114,10 +120,11 @@ export async function serveDaemon(stateDir) {
|
|
|
114
120
|
// durable stop receipt while the Unix pathname is atomically retired.
|
|
115
121
|
// Unlinking it from the request handler races that final response and
|
|
116
122
|
// can leave a stop caller observing the socket for its whole timeout.
|
|
117
|
-
if (_shutdown) server
|
|
123
|
+
if (_shutdown) retireDaemonEndpoint(server);
|
|
118
124
|
socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request.id, ok: true, result: receipt })}\n`);
|
|
119
|
-
} catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
|
|
125
|
+
} catch (error) { await retireFailedDaemon(server, state, error, save); socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
|
|
120
126
|
}); });
|
|
127
|
+
trackDaemonConnections(server);
|
|
121
128
|
try { await runtime.start(); scheduleDaemonProcessHeartbeat(state.config, state.resource_process); await save({ shutdown_state: "running" }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); }
|
|
122
129
|
catch (error) { let cleanup; try { cleanup = await runtime.closeTree(true); await removeDroidAuth(state.config); } catch (failure) { await save({ shutdown_state: "cleanup_failed", startup_error: errorRecord(error), cleanup_error: errorRecord(failure) }); throw error; } await save({ shutdown_state: "failed", startup_error: errorRecord(error), cleanup }); await finishDaemonProcess(state.config, state.resource_process, "failed", "startup_failed"); throw error; }
|
|
123
130
|
await new Promise(resolve => server.once("close", resolve));
|
|
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import net from "node:net";
|
|
8
|
-
import { durableDaemonDispatch as dispatchOperation } from "./daemon-operations.mjs";
|
|
8
|
+
import { durableDaemonDispatch as dispatchOperation, completeDaemonShutdown, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon, assertDaemonDispatch, assertDaemonRestartable } from "./daemon-operations.mjs";
|
|
9
9
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
@@ -13,7 +13,7 @@ import path from "node:path";
|
|
|
13
13
|
import { AcpBridge } from "./dd-zcode.mjs";
|
|
14
14
|
import { observeModel, readModelObservations } from "./model-observations.mjs";
|
|
15
15
|
import { cancelSessionWithBridge, createSessionWithBridge, doctor, forkSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge } from "./dd-grok.mjs";
|
|
16
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart,
|
|
16
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
17
17
|
|
|
18
18
|
const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
|
|
19
19
|
const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
|
|
@@ -169,8 +169,10 @@ export async function startDaemon(options) {
|
|
|
169
169
|
await prepareRuntimeOwner(config, paths.dir);
|
|
170
170
|
if (!config.model || !config.reasoning) throw new DaemonError("profile_required", "--model and --reasoning are required");
|
|
171
171
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
172
|
+
let previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
172
173
|
try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); if (!status.ready) throw new DaemonError("daemon_start_in_progress", "an existing daemon is still starting", true); return { ...status, already_running: true }; } catch (error) { if (error.code !== "daemon_not_running") throw error; }
|
|
173
|
-
|
|
174
|
+
previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
175
|
+
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
|
|
174
176
|
await removeSocket(paths.socket, paths.dir); await mkdir(paths.home, { recursive: true, mode: 0o700 }); const importedSession = await materializeSessionArchive(paths, config.sessionArchive); const copiedAuth = await copyAuth(paths, authSource); await writeIsolatedConfig(paths); if (!noFlow) await writeHook(paths, config, absolute(options.entryPath, "dd-grok entry")); const versions = await doctor({ bin: config.bin }); if (importedSession && importedSession.grok_version !== versions.versions.grok) throw new DaemonError("session_archive_version_mismatch", "Grok Build session archive was created by a different CLI version", false, { archive_version: importedSession.grok_version, current_version: versions.versions.grok }); const configIsolation = await inspectIsolatedConfig(config);
|
|
175
177
|
const resourceProcess = await registerDaemonProcess(config, { kind: "grok-daemon", owner: `grok:${path.basename(paths.dir)}`, operation: `grok-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
176
178
|
const daemonId = randomUUID();
|
|
@@ -179,11 +181,11 @@ export async function startDaemon(options) {
|
|
|
179
181
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 120_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); const failure = grokStartupFailure(await readState(paths.dir)); if (failure) throw failure; try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
180
182
|
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
|
|
181
183
|
}
|
|
182
|
-
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
184
|
+
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); }
|
|
183
185
|
|
|
184
186
|
export class Runtime {
|
|
185
187
|
constructor(paths, state, bridge, initialized) { this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized; this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item])); this.descendants = new Map((state.descendants ?? []).map(child => [child.provider_session_id, child])); for (const session of this.sessions.values()) if (session.parent_provider_session_id && !this.descendants.has(session.provider_session_id)) this.descendants.set(session.provider_session_id, { provider_session_id: session.provider_session_id, parent_provider_session_id: session.parent_provider_session_id, status: "unknown", source: "legacy.daemon_state", outcome_error: { code: "native_child_outcome_unavailable", message: "Retained Grok child has no durable outcome receipt" } }); this.toolUsage = new Map(); this.loadedSessionId = null; this.active = null; this.server = null; this.ready = false; this.persisting = Promise.resolve(); }
|
|
186
|
-
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()], descendants: [...this.descendants.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot)); return this.persisting; }
|
|
188
|
+
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()], descendants: [...this.descendants.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.catch(() => {}).then(() => writeState(this.paths.state, snapshot)); return this.persisting; }
|
|
187
189
|
options(params) { const sessionId = params.sessionId; const session = sessionId ? this.sessions.get(sessionId) : null; const root = session?.root_provider_session_id ?? sessionId; return { ...this.state.config, ...params, cwd: params.cwd ?? session?.cwd ?? this.state.config.cwd, initialized: this.initialized, daemonId: this.state.daemon_id, rootProviderSessionId: root, toolUsage: this.toolUsage, allowBackground: true, liveSession: params.liveSession ?? Boolean(sessionId && sessionId === this.loadedSessionId) }; }
|
|
188
190
|
track(result, before = null) { if (!result?.provider_session_id) return; const session = this.sessions.get(result.provider_session_id); const root = result.parent_provider_session_id ? (this.sessions.get(result.parent_provider_session_id)?.root_provider_session_id ?? result.parent_provider_session_id) : (session?.root_provider_session_id ?? result.provider_session_id); this.sessions.set(result.provider_session_id, { native_root_receipt: session?.native_root_receipt ?? null, provider_session_id: result.provider_session_id, adapter_session_id: result.adapter_session_id ?? result.provider_session_id, parent_provider_session_id: result.parent_provider_session_id ?? session?.parent_provider_session_id ?? null, root_provider_session_id: root, cwd: result.cwd ?? result.target?.newCwd ?? result.info?.cwd ?? session?.cwd ?? null }); for (const child of this.running(result)) this.recordDescendant({ ...child, stale_activity: Boolean(before && before.get(child.sessionId ?? child.subagentId ?? child.provider_session_id) !== this.descendants.get(child.sessionId ?? child.subagentId ?? child.provider_session_id)) }, root, "x.ai/subagent/list_running", "running"); for (const child of result.descendants ?? []) this.recordDescendant(child, root, child.source ?? "x.ai/subagent/event", child.status ?? "unknown"); }
|
|
189
191
|
recordDescendant(value, root, source, fallback) {
|
|
@@ -339,6 +341,7 @@ export class Runtime {
|
|
|
339
341
|
}
|
|
340
342
|
async requireSettled() { const running = await this.refreshTree(); if (running.length) throw new DaemonError("tree_not_settled", "daemon still owns a running Session tree", false, { sessions: running }); }
|
|
341
343
|
async productive(name, task, sessionId) {
|
|
344
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
342
345
|
if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
|
|
343
346
|
this.active = name;
|
|
344
347
|
// Only a new native turn invalidates its own root proof. Creation, fork
|
|
@@ -346,6 +349,7 @@ export class Runtime {
|
|
|
346
349
|
if (name === "session.prompt" && this.sessions.has(sessionId)) this.sessions.get(sessionId).native_root_receipt = null;
|
|
347
350
|
try {
|
|
348
351
|
await this.persist({ active_tree: true, active_operation: name });
|
|
352
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
349
353
|
const before = new Map(this.descendants);
|
|
350
354
|
const result = await task();
|
|
351
355
|
this.track(result, before);
|
|
@@ -371,8 +375,9 @@ export class Runtime {
|
|
|
371
375
|
}
|
|
372
376
|
async dispatch(operation, params) {
|
|
373
377
|
const assertDispatch = captureDispatchGuard(this);
|
|
378
|
+
if (["session.create", "session.prompt", "session.fork", "session.archive"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
374
379
|
if (operation === "daemon.ready") return { ready: this.ready, daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, config_isolation: this.state.config_isolation, shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status, auth_status: this.state.auth_status, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config: this.state.config };
|
|
375
|
-
if (operation === "daemon.status") { let observationError; if (!this.active) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { ...await this.dispatch("daemon.ready", {}), observation_error: observationError ?? this.state.observation_error ?? null }; }
|
|
380
|
+
if (operation === "daemon.status") { let observationError; if (!this.active && !this.state.shutdown?.phases?.tree) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { ...await this.dispatch("daemon.ready", {}), observation_error: observationError ?? this.state.observation_error ?? null }; }
|
|
376
381
|
if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root"); const observed = await inspectSessionWithBridge(this.bridge, this.options({ sessionId, liveSession: true })); this.track(observed); session = this.sessions.get(sessionId); if (!session || session.root_provider_session_id !== roots[0].provider_session_id) throw new DaemonError("hook_identity_unknown", "hook Session is not a member of the retained native tree"); await this.persist(); } return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} }; }
|
|
377
382
|
if (operation === "session.create") return await this.productive(operation, async () => { const result = await createSessionWithBridge(this.bridge, { ...this.options(params), onSessionCreated: async (session) => { this.track(session); this.loadedSessionId = session.provider_session_id; await this.persist(); } }, this.initialized); this.loadedSessionId = result.provider_session_id; return result; });
|
|
378
383
|
if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }, params.sessionId); }
|
|
@@ -381,18 +386,23 @@ export class Runtime {
|
|
|
381
386
|
if (operation === "session.cancel") { if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon"); cancelPendingDispatch(this); const result = await cancelSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); await this.persist({ active_tree: Boolean(this.active) || !result.settled }); return result; }
|
|
382
387
|
if (operation === "session.archive") { await this.requireSettled(); return await this.productive(operation, async () => { let session = this.sessions.get(params.sessionId); if (!session?.cwd) { const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result); session = this.sessions.get(result.provider_session_id); } return await exportSessionArchive(this.paths, session.provider_session_id, absolute(session.cwd, "Grok Build session cwd"), params.archiveDir, this.state.versions); }); }
|
|
383
388
|
if (operation === "daemon.stop") {
|
|
389
|
+
return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), async () => {
|
|
384
390
|
if (params.cancelTree) cancelPendingDispatch(this);
|
|
385
391
|
await waitForSettlement({
|
|
386
392
|
knownSessions: () => [...this.sessions.values()].filter(s => !s.parent_provider_session_id).map(s => s.provider_session_id),
|
|
387
393
|
observe: async () => ({ sessions: await this.refreshTree(), active: Boolean(this.active), active_operation: this.active }),
|
|
388
394
|
...(params.cancelTree ? { cancel: id => cancelSessionWithBridge(this.bridge, this.options({ sessionId: id, liveSession: true })) } : {})
|
|
389
395
|
});
|
|
390
|
-
|
|
391
|
-
|
|
396
|
+
return { clean: true };
|
|
397
|
+
}, [
|
|
398
|
+
["provider", () => this.bridge.close()],
|
|
399
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
|
|
400
|
+
["auth", () => unlink(path.join(this.paths.home, "auth.json")).catch(error => { if (error.code !== "ENOENT") throw error; })],
|
|
401
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
402
|
+
]);
|
|
392
403
|
}
|
|
393
404
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
394
405
|
}
|
|
395
|
-
async shutdown() { await closeManagedBridge(this.state.config, this.state, this.bridge); await finishDaemonProcess(this.state.config, this.state.resource_process); await unlink(path.join(this.paths.home, "auth.json")).catch(error => { if (error.code !== "ENOENT") throw error; }); await this.persist({ shutdown_state: "clean", stopped_at: new Date().toISOString() }); await new Promise((resolve) => this.server.close(resolve)); await removeSocket(this.paths.socket, this.paths.dir); }
|
|
396
406
|
}
|
|
397
407
|
|
|
398
408
|
export async function serveDaemon(stateDir) {
|
|
@@ -426,6 +436,28 @@ export async function serveDaemon(stateDir) {
|
|
|
426
436
|
}
|
|
427
437
|
}
|
|
428
438
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
429
|
-
await removeSocket(paths.socket, paths.dir);
|
|
439
|
+
await removeSocket(paths.socket, paths.dir);
|
|
440
|
+
const server = net.createServer(connection => {
|
|
441
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
442
|
+
connection.on("data", chunk => {
|
|
443
|
+
if (consumed) return;
|
|
444
|
+
buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
445
|
+
void (async () => {
|
|
446
|
+
let request;
|
|
447
|
+
try {
|
|
448
|
+
request = JSON.parse(buffer.slice(0, at));
|
|
449
|
+
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
450
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
451
|
+
if (result._shutdown) retireDaemonEndpoint(server);
|
|
452
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
455
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
456
|
+
}
|
|
457
|
+
})();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
trackDaemonConnections(server); runtime.server = server;
|
|
461
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false }); runtime.ready = true; return await new Promise(() => {});
|
|
430
462
|
}
|
|
431
463
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -5,8 +5,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { appendFile, chmod, copyFile, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import net from "node:net";
|
|
8
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
8
|
+
import { durableDaemonDispatch, completeDaemonShutdown, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon, assertDaemonDispatch, assertDaemonRestartable } from "./daemon-operations.mjs";
|
|
9
9
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
10
11
|
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -23,7 +24,25 @@ async function readState(dir) { try { return JSON.parse(await readFile(locations
|
|
|
23
24
|
async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
24
25
|
function executable(bin, args = []) { return /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args }; }
|
|
25
26
|
function isolatedEnv(config) { return { ...process.env, XDG_DATA_HOME: config.dataHome, XDG_CONFIG_HOME: config.configHome, XDG_CACHE_HOME: config.cacheHome, OPENCODE_CONFIG_DIR: config.adapterDir, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: config.password, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.ddFlowBin ? { DD_OPENCODE_DD_FLOW_BIN: config.ddFlowBin, DD_OPENCODE_NODE_BIN: process.execPath, DD_OPENCODE_PROJECT_ROOT: config.projectRoot } : {}), DD_OPENCODE_DAEMON_ID: config.daemonId, DD_OPENCODE_PROVIDER: config.provider, DD_OPENCODE_MODEL: config.model, DD_OPENCODE_VARIANT: config.variant, DD_OPENCODE_AGENT: config.agent, DD_OPENCODE_SERVER_VERSION: config.version ?? "unknown", DD_OPENCODE_PLUGIN_SHA256: config.pluginSha256 ?? "unknown" }; }
|
|
26
|
-
async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
|
|
27
|
+
async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
|
|
28
|
+
const target = executable(bin, args);
|
|
29
|
+
return await new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn(target.command, target.args, { cwd: config.cwd, env: isolatedEnv(config), detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
31
|
+
let stdout = "", stderr = "", timeoutError, inputError, cleanup;
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
timeoutError = new DaemonError("opencode_timeout", `${args[0]} timed out`, true);
|
|
34
|
+
cleanup = stopProcessGroup(child, 1_000).catch(error => { timeoutError.details = { cleanup_error: errorRecord(error) }; reject(timeoutError); });
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk); child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
|
|
37
|
+
child.on("error", error => { clearTimeout(timer); reject(error); });
|
|
38
|
+
child.on("close", async code => { clearTimeout(timer); await cleanup; if (timeoutError) reject(timeoutError); else if (inputError) reject(inputError); else if (code === 0) resolve(stdout); else reject(new DaemonError("opencode_cli_failed", stderr.trim() || `${target.command} exited ${code}`)); });
|
|
39
|
+
child.stdin.on("error", error => {
|
|
40
|
+
inputError ??= error;
|
|
41
|
+
cleanup ??= stopProcessGroup(child, 1_000).catch(cause => { clearTimeout(timer); inputError.details = { ...inputError.details, cleanup_error: errorRecord(cause) }; reject(timeoutError ?? inputError); });
|
|
42
|
+
});
|
|
43
|
+
child.stdin.end(stdin ?? "");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
27
46
|
async function forwardUsage(config, sessionId, snapshot) {
|
|
28
47
|
if (!config.ddFlowBin) return;
|
|
29
48
|
const payload = { provider_session_id: sessionId, daemon_id: config.daemonId, observed_at: new Date().toISOString(), usage_scope: "physical_session", completeness: "complete", usage: { inputTokens: snapshot.input_tokens, outputTokens: snapshot.output_tokens, reasoningTokens: snapshot.reasoning_tokens, cacheReadInputTokens: snapshot.cached_input_tokens, cacheWriteInputTokens: snapshot.cache_write_tokens, totalTokens: snapshot.total_tokens, costUsd: snapshot.cost_usd }, tool_calls: snapshot.tool_calls };
|
|
@@ -53,8 +72,10 @@ export async function startDaemon(options) {
|
|
|
53
72
|
const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
54
73
|
const previous = await readState(paths.dir), daemonId = randomUUID(), noFlow = options.noFlow === true, cwd = await realpath(absolute(options.cwd, "--cwd")); const config = { cwd, journal: options.journal ? absolute(options.journal, "--journal") : paths.journal, bin: options.bin ?? process.env.DD_OPENCODE_BIN ?? "opencode", provider: String(options.provider ?? ""), model: String(options.model ?? ""), variant: String(options.variant ?? "default"), agent: String(options.agent ?? "build"), projectRoot: noFlow ? cwd : await realpath(absolute(options.projectRoot, "--project-root")), ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : (process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null), env: noFlow ? {} : { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") }, sessionArchive: options.sessionArchive ? absolute(options.sessionArchive, "--session-archive") : null, dataHome: paths.data, configHome: paths.config, cacheHome: paths.cache, adapterDir: path.join(paths.config, "opencode"), password: randomBytes(24).toString("hex"), daemonId, noFlow };
|
|
55
74
|
await prepareRuntimeOwner(config, paths.dir);
|
|
75
|
+
assertDaemonRestartable(previous);
|
|
56
76
|
if (!config.provider || !config.model) throw new DaemonError("profile_required", "--provider and --model are required");
|
|
57
77
|
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
78
|
+
assertDaemonRestartable(await readState(paths.dir));
|
|
58
79
|
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree");
|
|
59
80
|
await removeSocket(paths); const authSource = options.authPath ? absolute(options.authPath, "--auth-path") : path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); await prepare(paths, config, authSource); config.version = (await run(config.bin, ["--version"], config)).trim(); if (config.version !== OPENCODE_BASELINE.version) throw new DaemonError("unsupported_version", `unsupported OpenCode version ${config.version}; expected ${OPENCODE_BASELINE.version}`); const imported = await importArchive(paths, config, config.sessionArchive);
|
|
60
81
|
const resourceProcess = await registerDaemonProcess(config, { kind: "opencode-daemon", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
@@ -63,11 +84,11 @@ export async function startDaemon(options) {
|
|
|
63
84
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 20_000; while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
64
85
|
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
65
86
|
}
|
|
66
|
-
export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); };
|
|
87
|
+
export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); };
|
|
67
88
|
|
|
68
89
|
export class Runtime {
|
|
69
90
|
constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map((state.sessions ?? []).map(x => [x.provider_session_id, x])); this.active = null; this.persisting = Promise.resolve(); }
|
|
70
|
-
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(() => writeJson(this.paths.state, snapshot)); return this.persisting; }
|
|
91
|
+
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.catch(() => {}).then(() => writeJson(this.paths.state, snapshot)); return this.persisting; }
|
|
71
92
|
async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
72
93
|
async childTree(id, seen = new Set()) { if (seen.has(id)) return []; seen.add(id); const direct = await this.client.children(id) ?? []; const nested = []; for (const child of direct) nested.push(child, ...await this.childTree(child.id, seen)); return nested; }
|
|
73
94
|
async describe(id) { const [info, messages, children, statuses] = await Promise.all([this.client.inspect(id), this.client.messages(id), this.childTree(id), this.client.statuses()]); if (path.resolve(info.directory) !== path.resolve(this.state.config.cwd)) throw new DaemonError("foreign_session", "OpenCode Session directory is outside the controlled workspace", false, { session_directory: info.directory, workspace: this.state.config.cwd }); const status = statuses?.[id]?.type ?? "idle", parent = info.parentID ?? null, existing = this.sessions.get(id); const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: parent ? (this.sessions.get(parent)?.root_provider_session_id ?? parent) : (existing?.root_provider_session_id ?? id), cwd: info.directory }; this.sessions.set(id, record); for (const child of children) if (!this.sessions.has(child.id)) this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: child.parentID ?? id, root_provider_session_id: record.root_provider_session_id, cwd: child.directory }); const unsettledChildren = children.filter(child => (statuses?.[child.id]?.type ?? "idle") !== "idle").map(child => child.id); const lastAssistant = messages.findLast(message => message.info?.role === "assistant"); const assistantText = (lastAssistant?.parts ?? []).filter(part => part.type === "text").map(part => part.text ?? "").join(""); await this.observeModels(id, messages, parent); this.journaledTools ??= new Map(); journalToolObservations(this.state.config.journal, messages.flatMap(message => (message.parts ?? []).map(part => openCodeToolObservation(part, id, message.info?.id))), this.journaledTools); return { ...record, observed_profile: { provider: lastAssistant?.info?.providerID ?? null, model: lastAssistant?.info?.modelID ?? null, reasoning: lastAssistant?.info?.variant ?? null, mode: info.agent ?? null }, assistant_text: assistantText, info, status, settled: status === "idle" && unsettledChildren.length === 0, unsettled_children: unsettledChildren, tree_observations: children.map(child => ({ provider_session_id: child.id, status: statuses?.[child.id]?.type ?? "idle", settled: (statuses?.[child.id]?.type ?? "idle") === "idle" })), children, usage: usageSnapshot(messages, id), history_sha256: historyDigest(messages), message_count: messages.length }; }
|
|
@@ -86,10 +107,12 @@ export class Runtime {
|
|
|
86
107
|
async requireSettled(id) { const value = await this.describe(id); if (!value.settled) throw new DaemonError("tree_not_settled", "OpenCode Session is not idle", false, { session_id: id, status: value.status }); return value; }
|
|
87
108
|
async reconcileUsage(observed) { const errors = []; try { await forwardUsage(this.state.config, observed.provider_session_id, observed.usage); } catch (error) { errors.push({ session_id: observed.provider_session_id, phase: "usage", ...errorPayload(error) }); } for (const child of observed.children.filter(item => (item.parentID ?? observed.provider_session_id) === observed.provider_session_id)) { try { errors.push(...await this.reconcileUsage(await this.describe(child.id))); } catch (error) { errors.push({ session_id: child.id, phase: "usage_child", ...errorPayload(error) }); } } return errors; }
|
|
88
109
|
async productive(name, task) {
|
|
110
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
89
111
|
if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
|
|
90
112
|
this.active = name; let result, completed = false;
|
|
91
113
|
try {
|
|
92
114
|
await this.persist({ active_tree: true, active_operation: name });
|
|
115
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
93
116
|
result = await task(); completed = true;
|
|
94
117
|
await this.persist({ active_tree: !result?.settled, active_operation: null });
|
|
95
118
|
await this.journal(name, result); return result;
|
|
@@ -118,6 +141,7 @@ export class Runtime {
|
|
|
118
141
|
}
|
|
119
142
|
async dispatch(operation, params) {
|
|
120
143
|
const assertDispatch = captureDispatchGuard(this);
|
|
144
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.archive"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
121
145
|
if (operation === "daemon.status") { const { password, ...config } = this.state.config; return { daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, auth_status: this.state.auth_status, shutdown_state: this.state.shutdown_state, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config }; }
|
|
122
146
|
if (operation === "daemon.doctor") { const doc = await this.client.request("GET", "/doc", undefined, { directory: null, timeoutMs: 5_000 }); const apiFingerprint = createHash("sha256").update(typeof doc === "string" ? doc : JSON.stringify(doc)).digest("hex"); if (apiFingerprint !== OPENCODE_BASELINE.api_fingerprint) throw new DaemonError("api_fingerprint_mismatch", "OpenCode API differs from the qualified baseline", false, { expected: OPENCODE_BASELINE.api_fingerprint, actual: apiFingerprint }); return { compatible: true, versions: this.state.versions, api_fingerprint: apiFingerprint, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status }; }
|
|
123
147
|
if (operation === "session.create") return this.productive(operation, async () => {
|
|
@@ -177,6 +201,7 @@ export class Runtime {
|
|
|
177
201
|
}
|
|
178
202
|
if (operation === "session.archive") { const observed = await this.requireSettled(params.sessionId); const payload = await run(this.state.config.bin, ["export", params.sessionId], this.state.config); const manifest = { schema_id: ARCHIVE_SCHEMA, provider_session_id: params.sessionId, source_cwd: observed.cwd, opencode_version: this.state.versions.opencode, history_sha256: observed.history_sha256, exported_at: new Date().toISOString(), payload: Buffer.from(payload).toString("base64") }; const file = absolute(params.archiveFile, "--archive-file"); await writeJson(file, manifest); return { archive_file: file, ...manifest, payload: undefined, sha256: createHash("sha256").update(await readFile(file)).digest("hex") }; }
|
|
179
203
|
if (operation === "daemon.stop") {
|
|
204
|
+
return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), async () => {
|
|
180
205
|
if (params.cancelTree) cancelPendingDispatch(this);
|
|
181
206
|
await waitForSettlement({
|
|
182
207
|
knownSessions: () => [...this.sessions.keys()],
|
|
@@ -187,18 +212,15 @@ export class Runtime {
|
|
|
187
212
|
},
|
|
188
213
|
...(params.cancelTree ? { cancel: id => this.cancelTree(id) } : {})
|
|
189
214
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
return { clean: true };
|
|
216
|
+
}, [
|
|
217
|
+
["provider", () => stopProcessGroup(this.child)],
|
|
218
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.providerProcess)],
|
|
219
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
220
|
+
]);
|
|
194
221
|
}
|
|
195
222
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
196
223
|
}
|
|
197
|
-
async shutdown() {
|
|
198
|
-
try { await stopProcessGroup(this.child); await finishDaemonProcess(this.state.config, this.providerProcess, "stopped", "daemon_stopped"); }
|
|
199
|
-
catch (error) { await this.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }); throw error; }
|
|
200
|
-
await finishDaemonProcess(this.state.config, this.state.resource_process); await this.persist({ shutdown_state: "clean", stopped_at: new Date().toISOString() }); await new Promise(resolve => this.server.close(resolve)); await removeSocket(this.paths);
|
|
201
|
-
}
|
|
202
224
|
}
|
|
203
225
|
export async function serveDaemon(stateDir) {
|
|
204
226
|
const paths = locations(stateDir), state = await readState(paths.dir); if (state?.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing");
|
|
@@ -211,7 +233,28 @@ export async function serveDaemon(stateDir) {
|
|
|
211
233
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
212
234
|
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
213
235
|
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
214
|
-
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
236
|
+
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
237
|
+
const server = net.createServer(connection => {
|
|
238
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
239
|
+
connection.on("data", chunk => {
|
|
240
|
+
if (consumed) return;
|
|
241
|
+
buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
242
|
+
void (async () => {
|
|
243
|
+
let request;
|
|
244
|
+
try {
|
|
245
|
+
request = JSON.parse(buffer.slice(0, at));
|
|
246
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
247
|
+
if (result._shutdown) retireDaemonEndpoint(server);
|
|
248
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
251
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
trackDaemonConnections(server); runtime.server = server;
|
|
257
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false, server_port: port }); return await new Promise(() => {});
|
|
215
258
|
} catch (error) {
|
|
216
259
|
await cleanupFailedStart(state.config, providerProcess, child, error); throw error;
|
|
217
260
|
}
|
|
@@ -3,9 +3,9 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import net from "node:net";
|
|
6
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
6
|
+
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart,
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -163,13 +163,14 @@ export async function startDaemon(options) {
|
|
|
163
163
|
await prepareRuntimeOwner(config, paths.dir);
|
|
164
164
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
165
165
|
await chmod(paths.dir, 0o700);
|
|
166
|
+
let previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
166
167
|
try {
|
|
167
168
|
const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
|
|
168
169
|
if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
|
|
169
170
|
return { ...status, already_running: true };
|
|
170
171
|
} catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
171
172
|
|
|
172
|
-
|
|
173
|
+
previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
173
174
|
if (previous?.shutdown_state === "clean") {
|
|
174
175
|
if (!sameNativeRouting(previous.config, config)) throw new DaemonError("daemon_config_mismatch", "retained ZCode native storage routing changed");
|
|
175
176
|
await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
@@ -227,7 +228,7 @@ export async function startDaemon(options) {
|
|
|
227
228
|
export async function stopDaemon(options) {
|
|
228
229
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
229
230
|
const { socket } = locations(options.stateDir);
|
|
230
|
-
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
|
|
231
|
+
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs, { readState: () => readState(options.stateDir) });
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
export class DaemonRuntime {
|
|
@@ -241,7 +242,7 @@ export class DaemonRuntime {
|
|
|
241
242
|
persist(patch = {}) {
|
|
242
243
|
Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
|
|
243
244
|
const snapshot = structuredClone(this.state);
|
|
244
|
-
this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
|
|
245
|
+
this.persisting = this.persisting.catch(() => {}).then(() => writeState(this.paths.state, snapshot));
|
|
245
246
|
return this.persisting;
|
|
246
247
|
}
|
|
247
248
|
|
|
@@ -448,6 +449,7 @@ export class DaemonRuntime {
|
|
|
448
449
|
}
|
|
449
450
|
|
|
450
451
|
async dispatch(operation, params) {
|
|
452
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
451
453
|
if (operation === "daemon.status") return {
|
|
452
454
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
453
455
|
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
@@ -548,12 +550,17 @@ export class DaemonRuntime {
|
|
|
548
550
|
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
549
551
|
return result;
|
|
550
552
|
}
|
|
551
|
-
if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true)
|
|
553
|
+
if (operation === "daemon.stop") return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), () => this.prepareStop(params.cancelTree === true), [
|
|
554
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
|
|
555
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
556
|
+
]);
|
|
552
557
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
553
558
|
}
|
|
554
559
|
|
|
555
560
|
async prepareStop(cancel) {
|
|
561
|
+
if (this.activeProductive && !cancel) throw new DaemonError("tree_not_settled", "productive operation is running");
|
|
556
562
|
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
563
|
+
if (!this.state.native_stop_proof || this.state.native_stop_proof.daemon_id !== this.state.daemon_id) {
|
|
557
564
|
const unsettled = [];
|
|
558
565
|
for (const session of this.sessions.values()) {
|
|
559
566
|
if (session.close_receipt) {
|
|
@@ -603,6 +610,10 @@ export class DaemonRuntime {
|
|
|
603
610
|
}
|
|
604
611
|
}
|
|
605
612
|
if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
|
|
613
|
+
commitDaemonShutdown(this.paths.dir);
|
|
614
|
+
await this.persist({ native_stop_proof: { daemon_id: this.state.daemon_id, sessions: [...this.sessions.keys()], observed_at: new Date().toISOString() } });
|
|
615
|
+
}
|
|
616
|
+
commitDaemonShutdown(this.paths.dir);
|
|
606
617
|
// A cooperative close can make the native Session non-resident while its
|
|
607
618
|
// original ACP prompt is still pending. For an explicitly whole-RUN
|
|
608
619
|
// cancellation this daemon owns the bridge process group, so retire that
|
|
@@ -614,7 +625,7 @@ export class DaemonRuntime {
|
|
|
614
625
|
if (typeof this.bridge.close === "function") await this.bridge.close();
|
|
615
626
|
bridgeClosed = true;
|
|
616
627
|
} catch (error) {
|
|
617
|
-
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
|
|
628
|
+
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details);
|
|
618
629
|
}
|
|
619
630
|
}
|
|
620
631
|
const deadline = Date.now() + 5000;
|
|
@@ -625,24 +636,11 @@ export class DaemonRuntime {
|
|
|
625
636
|
// A bridge without a close method occurs only in in-memory diagnostics: it
|
|
626
637
|
// owns no OS process boundary. Production AcpBridge always has one.
|
|
627
638
|
try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
|
|
628
|
-
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
|
|
639
|
+
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details); }
|
|
629
640
|
await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null, active_request: null, physical_settlement: { confirmed_at: new Date().toISOString(), bridge_pid: this.bridge.child?.pid ?? null } });
|
|
630
641
|
return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
|
|
631
642
|
}
|
|
632
643
|
|
|
633
|
-
async shutdown() {
|
|
634
|
-
// prepareStop has already closed the owned bridge. Signal shutdown may get
|
|
635
|
-
// here directly, so close remains idempotent.
|
|
636
|
-
await closeManagedBridge(this.state.config, this.state, this.bridge);
|
|
637
|
-
await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
|
|
638
|
-
const resourceProcess = this.state.resource_process;
|
|
639
|
-
if (resourceProcess?.id && resourceProcess.lease_token) {
|
|
640
|
-
try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
|
|
641
|
-
catch { /* Keep the state; the next reconciler owns stale processes. */ }
|
|
642
|
-
}
|
|
643
|
-
await new Promise((resolve) => this.server.close(resolve));
|
|
644
|
-
await removeSocket(this.paths.socket, this.paths.dir);
|
|
645
|
-
}
|
|
646
644
|
}
|
|
647
645
|
|
|
648
646
|
export function zcodeBridgeEnv(config, daemonId) {
|
|
@@ -660,11 +658,13 @@ export async function serveDaemon(stateDir) {
|
|
|
660
658
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
661
659
|
await removeSocket(paths.socket, paths.dir);
|
|
662
660
|
const server = net.createServer((connection) => {
|
|
663
|
-
connection.setEncoding("utf8"); let buffer = "";
|
|
661
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
664
662
|
connection.on("data", (chunk) => {
|
|
663
|
+
if (consumed) return;
|
|
665
664
|
buffer += chunk;
|
|
666
665
|
const newline = buffer.indexOf("\n");
|
|
667
666
|
if (newline < 0) return;
|
|
667
|
+
consumed = true;
|
|
668
668
|
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
669
669
|
void (async () => {
|
|
670
670
|
let request;
|
|
@@ -672,15 +672,17 @@ export async function serveDaemon(stateDir) {
|
|
|
672
672
|
request = JSON.parse(line);
|
|
673
673
|
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
674
674
|
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
675
|
+
if (result?._shutdown) retireDaemonEndpoint(server);
|
|
675
676
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
676
|
-
if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
|
|
677
677
|
} catch (error) {
|
|
678
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
678
679
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
679
680
|
}
|
|
680
681
|
})();
|
|
681
682
|
});
|
|
682
683
|
});
|
|
683
684
|
runtime.server = server;
|
|
685
|
+
trackDaemonConnections(server);
|
|
684
686
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
|
|
685
687
|
await chmod(paths.socket, 0o600);
|
|
686
688
|
await runtime.persist({ shutdown_state: "running", active_tree: false });
|