@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +23 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/cli/run-cli.js +3 -0
  5. package/dist/domain/stage-catalog.js +1 -1
  6. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  7. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  8. package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
  9. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +117 -21
  10. package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
  11. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +58 -13
  12. package/dist/harness-runtime/lib/dd-codex.mjs +16 -26
  13. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
  14. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
  15. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
  16. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
  17. package/dist/harness-runtime/lib/dd-zcode.mjs +24 -14
  18. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  19. package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
  20. package/dist/harness-runtime/lib/managed-daemon.mjs +100 -31
  21. package/dist/harness-runtime/lib/model-observations.mjs +16 -9
  22. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  23. package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
  24. package/dist/schemas/flow-run.schema.json +1 -0
  25. package/dist/schemas/plan-review-decision.schema.json +3 -3
  26. package/dist/services/code-checks.js +14 -18
  27. package/dist/services/codex-capacity.js +251 -31
  28. package/dist/services/controller-fanout.js +1 -0
  29. package/dist/services/eval-snapshots.js +13 -8
  30. package/dist/services/external-work-launch.js +36 -11
  31. package/dist/services/harness-adapter.js +21 -15
  32. package/dist/services/managed-processes.js +14 -6
  33. package/dist/services/merge-server.js +35 -12
  34. package/dist/services/review-copy.js +21 -0
  35. package/dist/services/review-deferral.js +23 -0
  36. package/dist/services/run-controller-adapter.js +33 -8
  37. package/dist/services/run-controller-recovery.js +26 -2
  38. package/dist/services/run-controller.js +91 -24
  39. package/dist/services/run-observations.js +7 -2
  40. package/dist/services/run-recovery-runtime.js +49 -3
  41. package/dist/services/runs.js +1 -1
  42. package/dist/services/stage-report-renderer.js +25 -2
  43. package/dist/services/verification-matrix.js +57 -8
  44. package/dist/services/vnext-code-review.js +22 -8
  45. package/dist/services/vnext-merge.js +20 -2
  46. package/dist/services/vnext-plan-review.js +87 -19
  47. package/dist/services/work-registry.js +11 -2
  48. 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
- const previous = await readJson(paths.state);
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
- await removeDroidAuth(state.config); const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
83
- await save({ shutdown_state: clean ? "clean" : "unclean", active_tree: false, cleanup }); await finishDaemonProcess(state.config, state.resource_process);
84
- return { stopped: true, clean, settled: true, cleanup, _shutdown: true };
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) { stopping = false; await save({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); throw 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.close();
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, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
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
- const previous = await readState(paths.dir); 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
+ 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
- await this.persist({ active_tree: false, shutdown_state: "stopping" });
391
- return { daemon_id: this.state.daemon_id, stopped: true, clean: true, _shutdown: true };
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); const server = net.createServer((connection) => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", (chunk) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline < 0) return; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); void (async () => { let request; try { request = JSON.parse(line); if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request"); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; 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(() => {});
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 } = {}) { const target = executable(bin, args); return await new Promise((resolve, reject) => { const child = spawn(target.command, target.args, { cwd: config.cwd, env: isolatedEnv(config), stdio: ["pipe", "pipe", "pipe"] }); let stdout = "", stderr = ""; const timer = setTimeout(() => { child.kill("SIGTERM"); reject(new DaemonError("opencode_timeout", `${args[0]} timed out`, true)); }, timeoutMs); child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk); child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk); child.on("error", reject); child.on("close", code => { clearTimeout(timer); code === 0 ? resolve(stdout) : reject(new DaemonError("opencode_cli_failed", stderr.trim() || `${target.command} exited ${code}`)); }); if (stdin) child.stdin.end(stdin); else child.stdin.end(); }); }
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
- // The durable clean receipt must follow the owned physical stop barrier.
191
- await stopProcessGroup(this.child);
192
- await this.persist({ active_tree: false, shutdown_state: "stopping" });
193
- return { stopped: true, clean: true, _shutdown: true };
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); const server = net.createServer(connection => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", chunk => { buffer += chunk; const i = buffer.indexOf("\n"); if (i < 0) return; void (async () => { let request; try { request = JSON.parse(buffer.slice(0, i)); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; 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(() => {});
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, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
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
- const previous = await readState(paths.dir);
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 });