@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75

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 (123) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,644 @@
1
+ import { observedTimeout } from "./observation-clock.mjs";
2
+ import { spawn } from "node:child_process";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
+ import net from "node:net";
6
+ import { durableDaemonDispatch } from "./daemon-operations.mjs";
7
+ import path from "node:path";
8
+ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
9
+
10
+ import {
11
+ AcpBridge,
12
+ cancelSessionWithBridge,
13
+ cancelChildWithBridge,
14
+ createSessionWithBridge,
15
+ doctor,
16
+ forkSessionWithBridge,
17
+ inspectSessionWithBridge,
18
+ promptSessionWithBridge,
19
+ zcodeChildSessionIds,
20
+ observeClosedZcodeTree,
21
+ } from "./dd-zcode.mjs";
22
+
23
+ const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
24
+ const RESPONSE_SCHEMA = "dd-zcode/daemon-response@1";
25
+ const STATE_SCHEMA = "dd-zcode/daemon-state@1";
26
+ // ACP silence starts diagnostics; it is not a provider-failure deadline.
27
+ export const DEFAULT_LIVENESS_TIMEOUT_MS = 600_000;
28
+ export const DEFAULT_REQUEST_TIMEOUT_MS = null;
29
+
30
+ function positiveDuration(value, name, fallback) {
31
+ const duration = value ?? fallback;
32
+ if (!Number.isFinite(duration) || duration <= 0) throw new DaemonError("invalid_duration", `${name} must be a positive finite duration`, false, { name, value: duration });
33
+ return duration;
34
+ }
35
+
36
+ function optionalDuration(value, name, fallback) {
37
+ if (value === null) return null;
38
+ if (value === undefined && fallback === null) return null;
39
+ return positiveDuration(value, name, fallback);
40
+ }
41
+
42
+ class DaemonError extends Error {
43
+ constructor(code, message, retryable = false, details) {
44
+ super(message); this.code = code; this.retryable = retryable; this.details = details;
45
+ }
46
+ }
47
+
48
+ function absolute(value, label) {
49
+ if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`);
50
+ return path.resolve(value);
51
+ }
52
+
53
+ function locations(stateDir) {
54
+ const dir = absolute(stateDir, "--state-dir");
55
+ const localSocket = path.join(dir, "daemon.sock");
56
+ const socket = Buffer.byteLength(localSocket) < 100
57
+ ? localSocket
58
+ : `/tmp/dd-zcode-${createHash("sha256").update(dir).digest("hex").slice(0, 24)}.sock`;
59
+ return { dir, socket, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log") };
60
+ }
61
+
62
+ async function readState(stateDir) {
63
+ const file = locations(stateDir).state;
64
+ try { return JSON.parse(await readFile(file, "utf8")); }
65
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
66
+ }
67
+
68
+ async function writeState(file, value) {
69
+ const temporary = `${file}.${process.pid}.tmp`;
70
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
71
+ await rename(temporary, file);
72
+ }
73
+
74
+ async function removeSocket(socket, dir) {
75
+ if (socket !== locations(dir).socket) throw new DaemonError("unsafe_socket", "daemon socket does not match the exact state directory");
76
+ try {
77
+ const info = await lstat(socket);
78
+ if (!info.isSocket()) throw new DaemonError("unsafe_socket", "refusing to remove a non-socket daemon path");
79
+ await unlink(socket);
80
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
81
+ }
82
+
83
+ function errorPayload(error) {
84
+ return {
85
+ code: error.code ?? "operation_failed",
86
+ message: error.message ?? String(error),
87
+ retryable: error.retryable === true,
88
+ ...(error.details === undefined ? {} : { details: error.details }),
89
+ };
90
+ }
91
+
92
+ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
93
+ const { socket } = locations(stateDir);
94
+ const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
95
+ return await new Promise((resolve, reject) => {
96
+ const client = net.createConnection(socket);
97
+ let buffer = ""; let settled = false;
98
+ // A prompt has its own sliding ACP liveness deadline. Do not add an
99
+ // independent wall-clock deadline here: it can cut off an actively
100
+ // producing Turn merely because it is long.
101
+ const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
102
+ ? observedTimeout(() => { client.destroy(); reject(new DaemonError("daemon_timeout", `${operation} timed out`, true)); }, timeoutMs)
103
+ : null;
104
+ const finish = (error, value) => {
105
+ if (settled) return;
106
+ settled = true; if (timer) clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value);
107
+ };
108
+ client.setEncoding("utf8");
109
+ client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
110
+ client.on("data", (chunk) => {
111
+ buffer += chunk;
112
+ const newline = buffer.indexOf("\n");
113
+ if (newline < 0) return;
114
+ try {
115
+ const response = JSON.parse(buffer.slice(0, newline));
116
+ if (response.schema_id !== RESPONSE_SCHEMA || response.id !== request.id) return finish(new DaemonError("daemon_protocol_mismatch", "invalid daemon response"));
117
+ if (!response.ok) return finish(new DaemonError(response.error?.code ?? "operation_failed", response.error?.message ?? "daemon operation failed", response.error?.retryable, response.error?.details));
118
+ finish(null, response.result);
119
+ } catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); }
120
+ });
121
+ client.on("error", (error) => finish(new DaemonError("daemon_not_running", error.message, true)));
122
+ client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
123
+ client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
124
+ });
125
+ }
126
+
127
+ function sameConfig(state, config) {
128
+ return ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs", "requestTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
129
+ }
130
+
131
+ export async function startDaemon(options) {
132
+ const paths = locations(options.stateDir);
133
+ const hasFlowContext = Boolean(options.ddFlowHome || options.projectRoot || options.ddFlowBin);
134
+ if (hasFlowContext && (!options.ddFlowHome || !options.projectRoot)) {
135
+ throw new DaemonError("flow_context_incomplete", "--dd-flow-home and --project-root must be provided together for ZCode lifecycle forwarding");
136
+ }
137
+ const config = {
138
+ cwd: absolute(options.cwd, "--cwd"),
139
+ journal: absolute(options.journal, "--journal"),
140
+ bin: options.bin ?? null,
141
+ zcodePath: options.zcodePath ?? null,
142
+ // The worker itself invokes `dd-flow` from PATH. Use that same executable
143
+ // for the trusted event bridge unless a pinned executable was supplied.
144
+ ddFlowBin: hasFlowContext ? (options.ddFlowBin ? absolute(options.ddFlowBin, "--dd-flow-bin") : "dd-flow") : null,
145
+ ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null,
146
+ projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
147
+ resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
148
+ env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
149
+ livenessTimeoutMs: positiveDuration(options.livenessTimeoutMs, "livenessTimeoutMs", DEFAULT_LIVENESS_TIMEOUT_MS),
150
+ requestTimeoutMs: optionalDuration(options.requestTimeoutMs, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS),
151
+ };
152
+ await prepareRuntimeOwner(config, paths.dir);
153
+ await mkdir(paths.dir, { recursive: true, mode: 0o700 });
154
+ await chmod(paths.dir, 0o700);
155
+ try {
156
+ const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
157
+ if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
158
+ return { ...status, already_running: true };
159
+ } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
160
+
161
+ const previous = await readState(paths.dir);
162
+ if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
163
+ if (previous?.shutdown_state === "running" && previous.active_tree) {
164
+ throw new DaemonError("invalid_harness_crash", "the previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
165
+ }
166
+ await removeSocket(paths.socket, paths.dir);
167
+ const versions = await doctor({ bin: config.bin, zcodePath: config.zcodePath });
168
+ if (hasFlowContext && versions.compatible !== true) {
169
+ throw new DaemonError("zcode_lifecycle_unqualified", "ZCode native event identity is not qualified for managed CLI admission; inspect lifecycle_qualification before starting E2E.", false, { startup_resources: "not_created", observed_runtime: versions.observed_runtime, lifecycle_qualification: versions.lifecycle_qualification });
170
+ }
171
+ const resourceProcess = await registerDaemonProcess(config, {
172
+ kind: "zcode-daemon", owner: `zcode:${path.basename(paths.dir)}`, operation: `zcode-daemon:${path.basename(paths.dir)}`,
173
+ stdout: paths.log, stderr: paths.log,
174
+ });
175
+ const state = {
176
+ schema_id: STATE_SCHEMA,
177
+ daemon_id: randomUUID(),
178
+ pid: null,
179
+ socket: paths.socket,
180
+ cwd: config.cwd,
181
+ journal: config.journal,
182
+ started_at: new Date().toISOString(),
183
+ shutdown_state: "starting",
184
+ active_tree: false,
185
+ recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start",
186
+ versions: versions.versions,
187
+ config,
188
+ sessions: previous?.sessions ?? [],
189
+ resource_process: resourceProcess,
190
+ };
191
+ await writeState(paths.state, state);
192
+ const log = await open(paths.log, "a", 0o600);
193
+ const child = spawn(process.execPath, [absolute(options.entryPath, "dd-zcode entry"), "daemon", "serve", "--state-dir", paths.dir], {
194
+ cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd],
195
+ });
196
+ child.unref();
197
+ await log.close();
198
+ try {
199
+ await confirmDaemonProcess(config, resourceProcess, child);
200
+ const deadline = Date.now() + 15_000;
201
+ while (Date.now() < deadline) {
202
+ await new Promise((resolve) => setTimeout(resolve, 100));
203
+ try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
204
+ catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
205
+ }
206
+ throw new DaemonError("daemon_start_failed", "daemon did not become ready", true);
207
+ } catch (error) {
208
+ await cleanupFailedStart(config, resourceProcess, child, error);
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ export async function stopDaemon(options) {
214
+ const timeoutMs = options.timeoutMs ?? 30_000;
215
+ const { socket } = locations(options.stateDir);
216
+ return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
217
+ }
218
+
219
+ export class DaemonRuntime {
220
+ constructor(paths, state, bridge, initialized) {
221
+ this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
222
+ this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
223
+ this.liveAdapters = new Set();
224
+ this.activeProductive = null; this.server = null; this.persisting = Promise.resolve();
225
+ }
226
+
227
+ persist(patch = {}) {
228
+ Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
229
+ const snapshot = structuredClone(this.state);
230
+ this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
231
+ const resourceProcess = this.state.resource_process;
232
+ if (resourceProcess?.id && resourceProcess.lease_token) {
233
+ this.persisting = this.persisting.then(async () => {
234
+ try { await heartbeatDaemonProcess(this.state.config, resourceProcess); }
235
+ catch { /* A crashed client is recovered through the durable lease. */ }
236
+ });
237
+ }
238
+ return this.persisting;
239
+ }
240
+
241
+ options(params) {
242
+ const known = this.sessions.get(params.sessionId);
243
+ const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
244
+ const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
245
+ return {
246
+ ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
247
+ daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
248
+ assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); },
249
+ // The bridge owns the quiet timer; the daemon owns the durable status.
250
+ // Do not infer provider state or cancel here.
251
+ onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
252
+ const request = this.state.active_request;
253
+ // `sessionId` is ACP identity; the durable request's `session_id` is
254
+ // native/provider identity. These strings can coincide but are not
255
+ // interchangeable.
256
+ if (!request || request.adapter_session_id !== sessionId) return;
257
+ const adapterSessionId = request.adapter_session_id;
258
+ let diagnostics;
259
+ try {
260
+ // Both are non-mutating native observations. In particular, do not
261
+ // inspect/resume a running child: some ZCode children are known to
262
+ // be non-resident and unsubscribable while their request is alive.
263
+ const [resident, topology] = await Promise.all([
264
+ this.bridge.request("zcode/session/resident", { sessionId: adapterSessionId }, 5_000),
265
+ this.bridge.request("zcode/session/retainedSubagents", { sessionId: adapterSessionId }, 5_000),
266
+ ]);
267
+ diagnostics = {
268
+ observed_at: new Date().toISOString(), adapter_session_id: adapterSessionId,
269
+ resident: resident?.resident === true,
270
+ child_session_ids: zcodeChildSessionIds(topology?.topology ?? topology),
271
+ };
272
+ } catch (error) {
273
+ diagnostics = { observed_at: new Date().toISOString(), adapter_session_id: adapterSessionId, observation_error: errorPayload(error) };
274
+ }
275
+ // The probe is asynchronous. Do not recreate a completed or
276
+ // reconnected request with a stale callback snapshot.
277
+ const current = this.state.active_request;
278
+ if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation) return;
279
+ await this.persist({ active_request: {
280
+ ...current,
281
+ observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
282
+ } });
283
+ },
284
+ // Native create publishes its identities before post-create work. Keep
285
+ // that allocation in the same durable request record so recovery can
286
+ // distinguish "no Session allocated" from "allocated, later step lost".
287
+ onSessionAliasAllocated: async (identity) => {
288
+ const current = this.state.active_request;
289
+ await this.persist({
290
+ active_request: {
291
+ ...(current ?? {
292
+ request_id: randomUUID(),
293
+ session_id: params.sessionId ?? null,
294
+ adapter_session_id: identity.adapter_session_id,
295
+ generation: this.controlGeneration ?? 0,
296
+ dispatched_at: new Date().toISOString(),
297
+ }),
298
+ allocation: {
299
+ adapter_session_id: identity.adapter_session_id,
300
+ provider_session_id: null,
301
+ cwd: identity.cwd,
302
+ allocated_at: new Date().toISOString(),
303
+ },
304
+ },
305
+ });
306
+ },
307
+ onSessionAllocated: async (identity) => {
308
+ const current = this.state.active_request;
309
+ // A late identity is evidence, not permission to resurrect a completed
310
+ // request or overwrite another operation's ownership.
311
+ this.track(identity);
312
+ if (!current || current.allocation?.adapter_session_id !== identity.adapter_session_id) {
313
+ await this.persist({});
314
+ return;
315
+ }
316
+ await this.persist({
317
+ active_request: {
318
+ ...(current ?? {
319
+ request_id: randomUUID(),
320
+ session_id: params.sessionId ?? null,
321
+ adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
322
+ generation: this.controlGeneration ?? 0,
323
+ dispatched_at: new Date().toISOString(),
324
+ }),
325
+ allocation: {
326
+ adapter_session_id: identity.adapter_session_id,
327
+ provider_session_id: identity.provider_session_id,
328
+ cwd: identity.cwd,
329
+ allocated_at: new Date().toISOString(),
330
+ },
331
+ },
332
+ });
333
+ },
334
+ };
335
+ }
336
+
337
+ track(result, topology = result?.evidence?.subagents) {
338
+ if (result?.provider_session_id) { const existing = this.sessions.get(result.provider_session_id); const suppliedParent = result.parent_provider_session_id;
339
+ if (existing && suppliedParent !== undefined && suppliedParent !== existing.parent_provider_session_id) {
340
+ throw new DaemonError("session_ancestry_conflict", "ZCode Session parent changed after it was recorded", false, { session_id: result.provider_session_id, expected_parent: existing.parent_provider_session_id, actual_parent: suppliedParent });
341
+ }
342
+ const parent = suppliedParent ?? existing?.parent_provider_session_id ?? null;
343
+ const parentRoot = parent ? this.sessions.get(parent)?.root_provider_session_id ?? null : null;
344
+ this.sessions.set(result.provider_session_id, {
345
+ provider_session_id: result.provider_session_id,
346
+ adapter_session_id: result.adapter_session_id ?? existing?.adapter_session_id ?? result.provider_session_id,
347
+ parent_provider_session_id: parent,
348
+ root_provider_session_id: existing?.root_provider_session_id ?? (parent ? parentRoot : result.provider_session_id),
349
+ topology: topology ?? existing?.topology ?? null,
350
+ known_child_session_ids: [...new Set([...(existing?.known_child_session_ids ?? zcodeChildSessionIds(existing?.topology)), ...zcodeChildSessionIds(topology)])],
351
+ completed_turns: existing?.completed_turns ?? 0,
352
+ close_receipt: result.close?.closed === true ? result : existing?.close_receipt ?? null,
353
+ }); }
354
+ if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
355
+ for (const child of topology?.running ?? []) {
356
+ const adapter = child.childSessionId ?? child.adapterSessionId ?? child.sessionId;
357
+ if (typeof adapter === "string" && adapter) this.liveAdapters.add(adapter);
358
+ }
359
+ }
360
+
361
+ treeRunning(result) {
362
+ const closed = this.sessions.get(result?.provider_session_id)?.close_receipt;
363
+ if (closed) return closed.settled !== true;
364
+ const evidence = result?.evidence ?? result;
365
+ const children = evidence?.subagents?.running;
366
+ return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
367
+ }
368
+
369
+ async productive(name, task, request = null) {
370
+ if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
371
+ this.activeProductive = name;
372
+ this.activeGeneration = this.controlGeneration ?? 0;
373
+ try {
374
+ await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
375
+ const result = await task();
376
+ this.track(result);
377
+ const running = this.treeRunning(result);
378
+ await this.persist({ active_tree: running, active_operation: null, active_request: null });
379
+ return { ...result, settled: !running };
380
+ } catch (error) {
381
+ // Keep an allocated identity visible until the controller seals the
382
+ // outcome. Clearing it here was the source of `provider_identity_unknown`
383
+ // after a late/partial create: the native Session existed, but the
384
+ // daemon state no longer carried the only correlation to it.
385
+ const active = this.state?.active_request;
386
+ try {
387
+ await this.persist({
388
+ active_operation: null,
389
+ active_request: active?.allocation
390
+ ? { ...active, outcome: "unknown", error: errorPayload(error) }
391
+ : null,
392
+ });
393
+ } catch (cleanupError) {
394
+ // Preserve the operation's primary failure if durable cleanup itself
395
+ // is unavailable. The caller will retain the outcome as unknown and
396
+ // the next recovery pass can retry the state write.
397
+ if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
398
+ }
399
+ throw error;
400
+ } finally { this.activeProductive = null; this.activeGeneration = null; }
401
+ }
402
+
403
+ async requireSettled(params) {
404
+ if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
405
+ const known = this.sessions.get(params.sessionId);
406
+ // A freshly created ZCode Session has no prior Turn and is not yet exposed
407
+ // by the provider's subagent index. Its first prompt is therefore safe.
408
+ if (known && (known.completed_turns ?? 0) === 0) return null;
409
+ const observed = await inspectSessionWithBridge(this.bridge, this.options(params));
410
+ const runningChildren = observed.subagents?.running ?? [];
411
+ if (this.treeRunning(observed)) {
412
+ throw new DaemonError("tree_not_settled", "the prior ZCode agent tree is not fully settled", false, { session_id: params.sessionId, running_children: runningChildren.map((child) => child.sessionId ?? child.childSessionId ?? null) });
413
+ }
414
+ this.track(observed, observed.subagents);
415
+ return observed;
416
+ }
417
+
418
+ async dispatch(operation, params) {
419
+ if (operation === "daemon.status") return {
420
+ daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
421
+ cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
422
+ started_at: this.state.started_at, updated_at: this.state.updated_at,
423
+ shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
424
+ active_tree: this.state.active_tree, active_operation: this.activeProductive, active_request: this.state.active_request ?? null,
425
+ sessions: [...this.sessions.values()], config: this.state.config,
426
+ };
427
+ if (operation === "hook.resolve") {
428
+ const sessionId = String(params.sessionId ?? "");
429
+ const session = this.sessions.get(sessionId);
430
+ if (!session?.root_provider_session_id) throw new DaemonError("hook_identity_unknown", "ZCode hook Session is not a tracked controlled Session", false, { session_id: sessionId });
431
+ 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) ?? {} };
432
+ }
433
+ if (operation === "session.create") {
434
+ const request = {
435
+ request_id: randomUUID(),
436
+ session_id: params.sessionId ?? null,
437
+ adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
438
+ generation: this.controlGeneration ?? 0,
439
+ dispatched_at: new Date().toISOString(),
440
+ request_timeout_ms: this.state.config.requestTimeoutMs,
441
+ };
442
+ return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized), request);
443
+ }
444
+ if (operation === "session.prompt") {
445
+ if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
446
+ const known = this.sessions.get(params.sessionId);
447
+ const request = {
448
+ request_id: randomUUID(),
449
+ session_id: params.sessionId,
450
+ adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
451
+ generation: this.controlGeneration ?? 0,
452
+ dispatched_at: new Date().toISOString(),
453
+ request_timeout_ms: this.state.config.requestTimeoutMs,
454
+ };
455
+ const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, { ...this.options(params), timeoutMs: request.request_timeout_ms }); }, request);
456
+ const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
457
+ await this.persist(); return result;
458
+ }
459
+ if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
460
+ if (operation === "session.inspect" || operation === "session.resume") {
461
+ const closed = this.sessions.get(params.sessionId)?.close_receipt;
462
+ if (closed) {
463
+ if (operation === "session.resume") throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
464
+ const topology = closed.topology;
465
+ if (topology?.root_session_id !== params.sessionId || !Array.isArray(topology.child_session_ids) || topology.child_session_ids.some(id => typeof id !== "string" || !id)) return { ...closed, settled: false, observation_error: { code: "topology_incomplete", message: "closed Session has no native topology identity" } };
466
+ const previous = this.state.settlement_observations?.[params.sessionId];
467
+ // A confirmed closed tree cannot resume through this daemon. Retain
468
+ // that terminal evidence even after its bridge has been retired.
469
+ for (const receipt of [previous, closed]) {
470
+ if (receipt?.close?.closed === true && receipt.settled === true
471
+ && receipt.topology?.complete === true && receipt.topology.root_session_id === params.sessionId
472
+ && Array.isArray(receipt.topology.child_session_ids)) return receipt;
473
+ }
474
+ const known = [...new Set([...topology.child_session_ids, ...(previous?.topology?.child_session_ids ?? [])])];
475
+ const observation = { ...closed, ...await observeClosedZcodeTree(this.bridge, params.sessionId, known), observed_at: new Date().toISOString() };
476
+ // Observations are not ownership inventory: their timestamps must not
477
+ // invalidate a drain fence on every read.
478
+ this.state.settlement_observations ??= {};
479
+ this.state.settlement_observations[params.sessionId] = observation;
480
+ await this.persist();
481
+ return observation;
482
+ }
483
+ const adapter = params.adapterSessionId ?? params.sessionId;
484
+ if (!this.liveAdapters.has(adapter) && this.activeProductive) {
485
+ throw new DaemonError(
486
+ "child_inspection_unavailable",
487
+ "cannot inspect an unregistered child while a daemon-owned turn is running; inspection would resume and mutate it",
488
+ true,
489
+ { session_id: params.sessionId },
490
+ );
491
+ }
492
+ const result = await inspectSessionWithBridge(this.bridge, this.options(params));
493
+ // A child can be inspected through its parent's live bridge, but is not
494
+ // an independently daemon-owned root Session. Do not promote it into
495
+ // `sessions`: daemon shutdown would then treat the child as a second
496
+ // root and could reject an otherwise settled parent tree.
497
+ if (this.sessions.has(result.provider_session_id)) this.track(result, result.subagents);
498
+ else if (result.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
499
+ await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
500
+ return { ...result, settled: !this.treeRunning(result) && !this.activeProductive };
501
+ }
502
+ if (operation === "session.cancel") {
503
+ if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
504
+ const closed = this.sessions.get(params.sessionId)?.close_receipt;
505
+ if (closed) return closed;
506
+ this.controlGeneration = (this.controlGeneration ?? 0) + 1;
507
+ const result = await cancelSessionWithBridge(this.bridge, this.options(params));
508
+ this.track(result, result.after);
509
+ await this.persist({ active_tree: result.settled !== true && (this.treeRunning(result) || Boolean(this.activeProductive)) });
510
+ return result;
511
+ }
512
+ if (operation === "session.cancel-child") {
513
+ const result = await cancelChildWithBridge(this.bridge, this.options(params));
514
+ this.track(result, result.after);
515
+ await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
516
+ return result;
517
+ }
518
+ if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true);
519
+ throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
520
+ }
521
+
522
+ async prepareStop(cancel) {
523
+ if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
524
+ const unsettled = [];
525
+ for (const session of this.sessions.values()) {
526
+ if (session.close_receipt) {
527
+ const observed = await this.dispatch("session.inspect", { sessionId: session.provider_session_id });
528
+ if (observed.settled !== true) unsettled.push(session.provider_session_id);
529
+ continue;
530
+ }
531
+ const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
532
+ let observed;
533
+ if (!cancel) observed = await inspectSessionWithBridge(this.bridge, options);
534
+ if (cancel || !observed || this.treeRunning(observed)) {
535
+ if (!cancel) unsettled.push(session.provider_session_id);
536
+ else {
537
+ let cancelled;
538
+ try { cancelled = await cancelSessionWithBridge(this.bridge, options); }
539
+ catch (error) { unsettled.push(session.provider_session_id); continue; }
540
+ this.track(cancelled, cancelled.after);
541
+ await this.persist({ active_tree: cancelled.settled !== true });
542
+ if (cancelled.close?.closed === true) {
543
+ if (!cancelled.settled) unsettled.push(session.provider_session_id);
544
+ continue;
545
+ }
546
+ // A cooperative cancel needs a final read. A close receipt is
547
+ // terminal itself: native session/read correctly rejects it as no
548
+ // longer resident, so treating that rejection as unsettled would
549
+ // turn a successful close into an immortal daemon.
550
+ if (!cancelled.settled) {
551
+ try { observed = await inspectSessionWithBridge(this.bridge, options); }
552
+ catch (error) { unsettled.push(session.provider_session_id); continue; }
553
+ if (this.treeRunning(observed)) unsettled.push(session.provider_session_id);
554
+ }
555
+ }
556
+ }
557
+ }
558
+ if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
559
+ // A cooperative close can make the native Session non-resident while its
560
+ // original ACP prompt is still pending. For an explicitly whole-RUN
561
+ // cancellation this daemon owns the bridge process group, so retire that
562
+ // group now rather than reporting a clean-looking but still live tree.
563
+ // A per-child cancellation never takes this path.
564
+ let bridgeClosed = false;
565
+ if (this.activeProductive && cancel) {
566
+ try {
567
+ if (typeof this.bridge.close === "function") await this.bridge.close();
568
+ bridgeClosed = true;
569
+ } catch (error) {
570
+ throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
571
+ }
572
+ }
573
+ const deadline = Date.now() + 5000;
574
+ while (this.activeProductive && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
575
+ if (this.activeProductive) throw new DaemonError("tree_not_settled", "productive operation did not settle after cancellation");
576
+ // The owned bridge process group is the physical stop barrier. A native
577
+ // closed/resident receipt only proves API state and cannot seal recovery.
578
+ // A bridge without a close method occurs only in in-memory diagnostics: it
579
+ // owns no OS process boundary. Production AcpBridge always has one.
580
+ try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
581
+ catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
582
+ 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 } });
583
+ return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
584
+ }
585
+
586
+ async shutdown() {
587
+ // prepareStop has already closed the owned bridge. Signal shutdown may get
588
+ // here directly, so close remains idempotent.
589
+ await this.bridge.close();
590
+ await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
591
+ const resourceProcess = this.state.resource_process;
592
+ if (resourceProcess?.id && resourceProcess.lease_token) {
593
+ try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
594
+ catch { /* Keep the state; the next reconciler owns stale processes. */ }
595
+ }
596
+ await new Promise((resolve) => this.server.close(resolve));
597
+ await removeSocket(this.paths.socket, this.paths.dir);
598
+ }
599
+ }
600
+
601
+ export async function serveDaemon(stateDir) {
602
+ const paths = locations(stateDir);
603
+ const state = await readState(paths.dir);
604
+ if (!state || state.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing or incompatible");
605
+ const bridge = new AcpBridge(state.config);
606
+ const initialized = await bridge.start();
607
+ const runtime = new DaemonRuntime(paths, state, bridge, initialized);
608
+ await removeSocket(paths.socket, paths.dir);
609
+ const server = net.createServer((connection) => {
610
+ connection.setEncoding("utf8"); let buffer = "";
611
+ connection.on("data", (chunk) => {
612
+ buffer += chunk;
613
+ const newline = buffer.indexOf("\n");
614
+ if (newline < 0) return;
615
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
616
+ void (async () => {
617
+ let request;
618
+ try {
619
+ request = JSON.parse(line);
620
+ if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
621
+ const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
622
+ connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
623
+ if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
624
+ } catch (error) {
625
+ connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
626
+ }
627
+ })();
628
+ });
629
+ });
630
+ runtime.server = server;
631
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
632
+ await chmod(paths.socket, 0o600);
633
+ await runtime.persist({ shutdown_state: "running", active_tree: false });
634
+ let shuttingDown = false;
635
+ const onSignal = () => {
636
+ if (shuttingDown) return;
637
+ shuttingDown = true;
638
+ void runtime.shutdown().catch(async () => { await runtime.persist({ shutdown_state: "running" }); }).finally(() => process.exit(1));
639
+ };
640
+ process.on("SIGTERM", onSignal);
641
+ process.on("SIGINT", onSignal);
642
+ return await new Promise(() => {});
643
+ }
644
+ import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";