@deksden-com/dd-flow-cli 0.9.0-beta.123 → 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 (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +23 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/cli/run-cli.js +1 -1
  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 +66 -16
  10. package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
  11. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +39 -10
  12. package/dist/harness-runtime/lib/dd-codex.mjs +11 -25
  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 +9 -10
  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 -32
  21. package/dist/schemas/flow-run.schema.json +1 -0
  22. package/dist/schemas/plan-review-decision.schema.json +3 -3
  23. package/dist/services/code-checks.js +14 -18
  24. package/dist/services/codex-capacity.js +149 -48
  25. package/dist/services/controller-fanout.js +1 -0
  26. package/dist/services/external-work-launch.js +10 -3
  27. package/dist/services/harness-adapter.js +21 -15
  28. package/dist/services/managed-processes.js +14 -6
  29. package/dist/services/merge-server.js +10 -4
  30. package/dist/services/review-deferral.js +23 -0
  31. package/dist/services/run-controller-adapter.js +8 -5
  32. package/dist/services/run-controller-recovery.js +17 -0
  33. package/dist/services/run-controller.js +42 -14
  34. package/dist/services/run-recovery-runtime.js +49 -3
  35. package/dist/services/runs.js +1 -1
  36. package/dist/services/verification-matrix.js +30 -1
  37. package/dist/services/vnext-code-review.js +16 -4
  38. package/dist/services/vnext-merge.js +20 -2
  39. package/dist/services/vnext-plan-review.js +81 -18
  40. package/dist/services/work-registry.js +9 -2
  41. package/package.json +1 -1
@@ -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 });
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
7
7
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import readline from "node:readline";
10
- import { stopProcessGroup } from "./managed-daemon.mjs";
10
+ import { closeBridgeProcess } from "./managed-daemon.mjs";
11
+ import { errorRecord } from "./operation-errors.mjs";
11
12
  import { checkObservedProfile, observeModel, observedFields } from "./model-observations.mjs";
12
13
 
13
14
  import { readZcodeUsage } from "./zcode-usage.mjs";
@@ -423,13 +424,10 @@ export class AcpBridge {
423
424
  async close() {
424
425
  // Productive notification failures belong to flush()/the original Turn.
425
426
  // Draining them must not relabel a successful physical shutdown as failed.
426
- await Promise.allSettled([...this.lifecycleNotifications, this.notifications]);
427
- if (this.child) {
428
- this.child.stdin.end();
429
- await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2000))]);
430
- await stopProcessGroup(this.child, 2_000);
431
- }
432
- await this.journal.flush();
427
+ return await closeBridgeProcess(this, async () => {
428
+ await Promise.allSettled([...this.lifecycleNotifications, this.notifications]);
429
+ await this.journal.flush();
430
+ });
433
431
  }
434
432
  }
435
433
 
@@ -721,9 +719,10 @@ function requireJournal(options) { absolute(options.journal, "--journal"); }
721
719
  function receipt(options, value) { return options.daemonId ? { daemon_id: options.daemonId, ...value } : value; }
722
720
 
723
721
  async function withBridge(options, operation) {
724
- const bridge = new AcpBridge(options);
722
+ const bridge = new AcpBridge(options); let primary;
725
723
  try { const initialized = await bridge.start(); const result = await operation(bridge, initialized); await bridge.flush(); return result; }
726
- finally { await bridge.close(); }
724
+ catch (error) { primary = error; throw error; }
725
+ finally { try { await bridge.close(); } catch (error) { if (!primary) throw error; primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } }
727
726
  }
728
727
 
729
728
  function flowForwarder(options, rootProviderSessionId, bridge) {
@@ -7,11 +7,21 @@ import { reportedError } from "./operation-errors.mjs";
7
7
  import { ObservationClock } from "./observation-clock.mjs";
8
8
  import { runtimeProcess } from "./managed-daemon.mjs";
9
9
 
10
+ function cleanShutdownConfirmed(state) {
11
+ const proof = state.shutdown;
12
+ return state.shutdown_state === "clean" && state.active_tree === false
13
+ && proof?.schema_id === "dd-flow/daemon-shutdown@1" && proof.daemon_id === state.daemon_id
14
+ && proof.result?.clean === true && Array.isArray(proof.required_phases)
15
+ && proof.required_phases.length >= 3 && proof.required_phases.includes("tree")
16
+ && proof.required_phases.includes("daemon_resource")
17
+ && proof.required_phases.every(phase => typeof phase === "string" && proof.phases?.[phase] === true);
18
+ }
19
+
10
20
  /** Explicit same-Session restart retains native stores and the old daemon
11
21
  * receipt; it does not turn a terminal directory into a new execution. */
12
22
  export async function authorizeRetainedDaemonResume(root, previous, sessionId, config) {
13
23
  const sessions = [...(previous.sessions ?? []), ...(previous.root_session_id ? [previous.root_session_id] : [])];
14
- if (previous.shutdown_state !== "clean" || previous.active_tree === true || typeof sessionId !== "string" || !sessions.some(session => (typeof session === "string" ? session : session.provider_session_id) === sessionId)) throw Object.assign(new Error("terminal daemon restart requires a retained, settled Session identity"), { code: "daemon_state_terminal" });
24
+ if (!cleanShutdownConfirmed(previous) || typeof sessionId !== "string" || !sessions.some(session => (typeof session === "string" ? session : session.provider_session_id) === sessionId)) throw Object.assign(new Error("terminal daemon restart requires a retained, settled Session identity and complete shutdown proof"), { code: "daemon_state_terminal" });
15
25
  for (const key of ["cwd", "projectRoot", "ddFlowHome", "ddFlowBin", "provider", "model", "reasoning", "mode", "variant", "agent", "bin", "noFlow"]) {
16
26
  if ((previous.config?.[key] ?? null) !== (config[key] ?? null)) throw Object.assign(new Error(`retained daemon configuration changed: ${key}`), { code: "daemon_config_mismatch" });
17
27
  }
@@ -43,7 +53,7 @@ export async function recoverySettlement(driversRoot) {
43
53
  }
44
54
  else if (entry.name === "daemon.json") {
45
55
  const state = JSON.parse(await readFile(file, "utf8"));
46
- if (state.shutdown_state !== "clean" || state.active_tree === true) throw Object.assign(new Error("provider tree has no clean shutdown receipt"), { code: "recovery_settlement_unconfirmed" });
56
+ if (!cleanShutdownConfirmed(state)) throw Object.assign(new Error("provider tree has no complete clean shutdown receipt"), { code: "recovery_settlement_unconfirmed" });
47
57
  await assertDaemonReplaceable(directory);
48
58
  await reconcileDriverReplies(directory);
49
59
  if (state.config?.resourceHome && state.resource_process?.id) {
@@ -0,0 +1,2 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ export function stopProcessGroup(child: ChildProcess | null | undefined, graceMs?: number, options?: { detached?: boolean }): Promise<void>;
@@ -286,43 +286,111 @@ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild
286
286
  }
287
287
  }
288
288
 
289
- export async function stopProcessGroup(child, graceMs = 1_000) {
289
+ const processStops = new WeakMap();
290
+ export async function closeBridgeProcess(bridge, drain) {
291
+ if (bridge.closePending) return await bridge.closePending;
292
+ const pending = (async () => {
293
+ let primary;
294
+ try {
295
+ if (!bridge.physicallyClosed && bridge.child) {
296
+ const child = bridge.child;
297
+ if (!bridge.stdinEnded && !child.stdin?.destroyed) { bridge.stdinEnded = true; child.stdin?.end(); }
298
+ if (child.exitCode == null && child.signalCode == null) {
299
+ await new Promise(resolve => {
300
+ const done = () => { clearTimeout(timer); child.removeListener("exit", done); resolve(); };
301
+ const timer = setTimeout(done, 2_000); child.once("exit", done);
302
+ });
303
+ }
304
+ await stopProcessGroup(child, 2_000);
305
+ bridge.physicallyClosed = true;
306
+ }
307
+ } catch (error) { primary = error; }
308
+ let drainTimer;
309
+ try { await Promise.race([drain(), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(Object.assign(new Error("bridge drain did not finish"), { code: "bridge_drain_timeout" })), 10_000); })]); }
310
+ catch (error) { if (primary) { primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } else primary = error; }
311
+ finally { clearTimeout(drainTimer); }
312
+ if (primary) throw primary;
313
+ })();
314
+ bridge.closePending = pending;
315
+ try { return await pending; }
316
+ finally { bridge.closePending = null; }
317
+ }
318
+
319
+ export async function stopProcessGroup(child, graceMs = 1_000, { detached = true } = {}) {
290
320
  if (!child?.pid) return;
291
- // Harness bridges are detached on Unix, so their own process group is the
292
- // smallest reliable unit of cleanup. Windows has no equivalent negative-PID
293
- // signal operation; there the direct child is the safe best effort.
294
- const target = process.platform === "win32" ? child.pid : -child.pid;
321
+ if (processStops.has(child)) return await processStops.get(child);
322
+ const pending = stopOwnedChild(child, graceMs, detached);
323
+ processStops.set(child, pending);
324
+ try { return await pending; } finally { processStops.delete(child); }
325
+ }
326
+ async function stopOwnedChild(child, graceMs, detached) {
327
+ const target = process.platform === "win32" || !detached ? child.pid : -child.pid;
328
+ const details = signal => ({ pid: child.pid, pgid: target < 0 ? child.pid : null, signal, platform: process.platform, exit_code: child.exitCode ?? null, signal_code: child.signalCode ?? null, observed_at: new Date().toISOString() });
295
329
  const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
296
- const signal = name => { try { process.kill(target, name); return true; } catch (error) { if (error.code === "ESRCH") return false; throw error; } };
297
- if (!alive()) return;
298
- if ((child.exitCode !== undefined && child.exitCode !== null) || (child.signalCode !== undefined && child.signalCode !== null)) {
299
- // The leader can exit a few ticks before its helpers. Observe their normal
300
- // shutdown before failing ownership; never signal a leaderless group.
301
- const deadline = Date.now() + Math.max(1_000, graceMs);
302
- while (alive() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
303
- if (!alive()) return;
304
- throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: { pid: child.pid } });
305
- }
306
- if (!signal("SIGTERM")) return; // The group may exit after the liveness read.
307
- await new Promise((resolve) => setTimeout(resolve, graceMs));
330
+ const gone = async ms => {
331
+ const deadline = performance.now() + ms;
332
+ while (alive() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
333
+ return !alive();
334
+ };
335
+ const signal = async name => {
336
+ try { process.kill(target, name); return true; }
337
+ catch (error) {
338
+ if (error.code === "ESRCH") return false;
339
+ error.details = { ...error.details, ...details(name), syscall: error.syscall ?? "kill" };
340
+ // Permission denial is not permission to escalate. Observe only the retained target.
341
+ let absent = false;
342
+ try { absent = await gone(Math.min(250, Math.max(25, graceMs))); }
343
+ catch (observation) { error.details.observation_error = errorRecord(observation); }
344
+ if (absent) {
345
+ process.stderr.write(`${JSON.stringify({ cleanup_reobserved: errorRecord(error), physical_exit: "ESRCH" })}\n`);
346
+ return false;
347
+ }
348
+ throw error;
349
+ }
350
+ };
308
351
  if (!alive()) return;
309
- if (child.exitCode != null || child.signalCode != null) return stopProcessGroup(child, graceMs);
310
- if (!signal("SIGKILL")) return;
311
- // Grace controls escalation, not the OS acknowledgement budget. A caller
312
- // testing immediate escalation must still allow the kernel to reap children.
313
- const deadline = Date.now() + Math.max(1_000, graceMs);
314
- while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25));
315
- if (alive()) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete" });
352
+ const leaderExited = () => child.exitCode != null || child.signalCode != null;
353
+ const observeLeaderless = async () => {
354
+ if (await gone(Math.max(1_000, graceMs))) return;
355
+ throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: details(null) });
356
+ };
357
+ if (leaderExited()) return await observeLeaderless();
358
+ if (!await signal("SIGTERM")) return;
359
+ if (await gone(graceMs)) return;
360
+ if (leaderExited()) return await observeLeaderless();
361
+ if (!await signal("SIGKILL")) return;
362
+ if (!await gone(Math.max(1_000, graceMs))) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete", details: details("SIGKILL") });
316
363
  }
317
364
 
318
365
  /** A daemon acknowledgement is not a completed stop until its endpoint closes. */
319
- export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
320
- const result = await request();
321
- const deadline = Date.now() + timeoutMs;
322
- while (Date.now() < deadline) {
323
- try { await lstat(socket); }
324
- catch (error) { if (error.code === "ENOENT") return result; throw error; }
325
- await new Promise((resolve) => setTimeout(resolve, 25));
366
+ export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000, { readState } = {}) {
367
+ const deadline = performance.now() + timeoutMs;
368
+ const expected = await readState?.();
369
+ const identity = state => state?.daemon_id ?? state?.config?.daemonId;
370
+ if (readState && (!identity(expected) || !Number.isSafeInteger(expected?.pid) || expected.pid < 1)) throw Object.assign(new Error('daemon stop has no retained incarnation/PID proof'), { code: 'daemon_cleanup_identity_mismatch' });
371
+ const failure = async observer => {
372
+ const state = await readState?.();
373
+ if (expected && identity(state) !== identity(expected)) throw Object.assign(new Error("daemon incarnation changed during stop"), { code: "daemon_identity_mismatch" });
374
+ const primary = state?.shutdown_state === "cleanup_failed" ? state.cleanup_error : null;
375
+ if (primary) throw Object.assign(new Error(primary.message), primary, { details: { ...primary.details, socket, phase: "confirm_daemon_stop", observer_error: errorRecord(observer) } });
376
+ throw observer;
377
+ };
378
+ let result;
379
+ try { result = await request(); }
380
+ catch (error) { return await failure(error); }
381
+ if (result?.stopped !== true) return await failure(Object.assign(new Error("stop acknowledgement does not prove shutdown"), { code: "daemon_stop_incomplete" }));
382
+ for (;;) {
383
+ const state = await readState?.();
384
+ if (expected && identity(state) !== identity(expected)) return await failure(Object.assign(new Error("daemon incarnation changed"), { code: "daemon_identity_mismatch" }));
385
+ let endpointPresent = true;
386
+ try { await lstat(socket); } catch (error) { if (error.code === "ENOENT") endpointPresent = false; else throw error; }
387
+ let daemonPresent = false;
388
+ if (expected?.pid) { try { process.kill(expected.pid, 0); daemonPresent = true; } catch (error) { if (error.code !== "ESRCH") daemonPresent = true; } }
389
+ if (!endpointPresent && !daemonPresent) {
390
+ if (readState && result.clean === true && (state?.shutdown_state !== 'clean' || state.active_tree !== false)) return await failure(Object.assign(new Error('daemon clean ACK conflicts with its retained state'), { code: 'daemon_stop_incomplete' }));
391
+ return result;
392
+ }
393
+ if (performance.now() >= deadline) return await failure(Object.assign(new Error("daemon stop could not confirm endpoint retirement and owned PID exit"), { code: "daemon_stop_incomplete", details: { socket, daemon_pid: expected?.pid ?? null, endpoint_present: endpointPresent, daemon_present: daemonPresent } }));
394
+ await new Promise(resolve => setTimeout(resolve, 25));
326
395
  }
327
- throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
328
396
  }
@@ -13,6 +13,7 @@
13
13
  "subject": {"$ref": "#/definitions/identity"},
14
14
  "flow_kind": {"type": "string", "minLength": 1},
15
15
  "verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
16
+ "final_check_coverage_contract": {"const": "dd-flow/final-check-coverage@1"},
16
17
  "status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
17
18
  "verdict": {"type": ["string", "null"]},
18
19
  "started_at": {"type": "string", "format": "date-time"},
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/plan-review-decision@3",
3
+ "$id": "dd-flow/plan-review-decision@4",
4
4
  "type": "object",
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "outcome", "summary", "finding_decisions", "correction"],
7
7
  "properties": {
8
- "schema_id": {"const": "dd-flow/plan-review-decision@3"},
8
+ "schema_id": {"const": "dd-flow/plan-review-decision@4"},
9
9
  "outcome": {"enum": ["accepted", "failed", "cancelled"]},
10
10
  "summary": {"type": "string", "minLength": 1},
11
- "finding_decisions": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "decision", "reason"], "properties": {"finding_ref": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "decision": {"enum": ["accepted_fix", "rejected", "deferred_as_DEF", "requires_user", "duplicate"]}, "reason": {"type": "string", "minLength": 1}}}},
11
+ "finding_decisions": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "decision", "reason"], "properties": {"finding_ref": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "decision": {"enum": ["accepted_fix", "rejected", "deferred_as_DEF", "requires_user", "duplicate"]}, "reason": {"type": "string", "minLength": 1}, "duplicate_of": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "def_id": {"type": "string", "pattern": "^DEF-[A-Za-z0-9][A-Za-z0-9_-]*$"}}}},
12
12
  "correction": {"type": "object", "additionalProperties": false, "required": ["status", "previous_plan_revision", "changed_paths", "summary"], "properties": {"status": {"enum": ["applied", "not_required"]}, "previous_plan_revision": {"type": "integer", "minimum": 1}, "changed_paths": {"type": "array", "items": {"type": "string", "minLength": 1}}, "summary": {"type": "string", "minLength": 1}}}
13
13
  }
14
14
  }
@@ -6,7 +6,8 @@ import path from "node:path";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { parse } from "shell-quote";
8
8
  import { assertRunMutationAllowed } from "./run-recovery.js";
9
- import { AppError } from "../shared/errors.js";
9
+ import { AppError, errorRecord } from "../shared/errors.js";
10
+ import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
10
11
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
11
12
  import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
12
13
  import { getResourceDatabase } from "../storage/database.js";
@@ -340,7 +341,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
340
341
  if (processTreeIsAlive(current))
341
342
  throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
342
343
  allocation.release();
343
- finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
344
+ if (!result.cleanupUnconfirmed)
345
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
344
346
  }
345
347
  catch (error) {
346
348
  if (error instanceof AppError && error.code === "check_in_progress")
@@ -825,7 +827,8 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
825
827
  let error = null;
826
828
  let stopping = false;
827
829
  let progress;
828
- let escalation;
830
+ let cleanup;
831
+ let cleanupUnconfirmed = false;
829
832
  const stop = (cause) => {
830
833
  if (stopping)
831
834
  return;
@@ -835,23 +838,21 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
835
838
  clearInterval(progress);
836
839
  progress = undefined;
837
840
  }
838
- if (child.pid) {
839
- terminateProcessGroup(child.pid, "SIGTERM");
840
- escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
841
- }
841
+ cleanup = stopProcessGroup(child, 5_000).catch(cause => {
842
+ cleanupUnconfirmed = true;
843
+ error = `${error}; cleanup: ${JSON.stringify(errorRecord(cause))}`;
844
+ resolve({ exitCode: null, error, aborted: true, cleanupUnconfirmed });
845
+ });
842
846
  };
843
847
  // Register handlers before checking pid or calling persistence callbacks.
844
848
  // Failure is not completion until the spawned process has actually closed.
845
849
  child.once("error", (value) => { error = value.message; });
846
850
  child.stdin.on("error", stop);
847
- child.once("close", (exitCode, signal) => {
851
+ child.once("close", async (exitCode, signal) => {
848
852
  if (progress)
849
853
  clearInterval(progress);
850
- if (escalation)
851
- clearTimeout(escalation);
852
- if (stopping)
853
- terminateProcessGroup(child.pid, "SIGKILL");
854
- resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
854
+ await cleanup;
855
+ resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error), ...(cleanupUnconfirmed ? { cleanupUnconfirmed } : {}) });
855
856
  });
856
857
  if (!child.pid) {
857
858
  error = "check process did not provide a PID";
@@ -890,8 +891,3 @@ function checkShellScript() {
890
891
  'exit "$code"'
891
892
  ].join("\n");
892
893
  }
893
- function terminateProcessGroup(pid, signal) { if (!pid)
894
- return; try {
895
- process.kill(process.platform === "win32" ? pid : -pid, signal);
896
- }
897
- catch { /* already exited */ } }