@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.124
4
+
5
+ ### Patch Changes
6
+
7
+ - Stop only two confirmed continuation overload refusals within the inclusive two-minute window, preserving native refusal times and exact owner admission across recovery.
8
+
9
+ Retain final PLAN check obligations, completion markers and native output bytes in verification evidence; require canonical fixes for duplicated CODE P0/P1 findings.
10
+
11
+ Join native daemon cleanup before durable stop acknowledgement and reconcile already-closed provider bridges without replay.
12
+
3
13
  ## 0.9.0-beta.123
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -3,6 +3,29 @@
3
3
  See [dd-zcode integration](src/harness-runtime/DD-ZCODE.md) for native transport,
4
4
  adapter ownership and the versioned bridge contract.
5
5
 
6
+ ## Native daemon shutdown
7
+
8
+ `daemon.stop` fences productive dispatch before awaiting settlement. A successful
9
+ durable stop receipt follows native tree settlement, physical provider exit,
10
+ journal drain and required resource/auth finalization. The endpoint then retires
11
+ before ACK; clients also verify the retained daemon PID has exited. A missing
12
+ socket alone is not physical shutdown proof.
13
+
14
+ Partial cleanup preserves the same incarnation's completed phases in
15
+ `daemon.json` (`dd-flow/daemon-shutdown@1`). A fresh control operation can finish
16
+ remaining cleanup without replaying agent work or inspecting a closed pipe.
17
+ Failed operation IDs remain immutable. Unknown ownership and persistent EPERM
18
+ remain failures; never remove sockets or repair runtime databases manually to
19
+ make an outcome look clean.
20
+
21
+ Shutdown proof records `required_phases`; every recorded phase must be complete,
22
+ including native tree and daemon resource settlement. A `clean` label without
23
+ this proof cannot authorize retained restart. `stopping`, `cleanup_failed` and
24
+ `unclean` cannot start a replacement native bridge. Forced AGY termination
25
+ without a current native terminal receipt is unclean, even after a previous
26
+ successful Turn. Mandatory stream-journal failures remain failures after
27
+ physical cleanup. Listener retirement must not unlink a replacement endpoint.
28
+
6
29
  ## Managed temporary HTTP services
7
30
 
8
31
  For an interactive scenario, `runtime process start --run <RUN-ID>
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.123",
4
- "cli_commit": "0d75e9e7e18868fbe9eee8d51e4c4d06ab03a8cd",
5
- "built_at": "2026-09-30T12:08:03.924Z",
3
+ "cli_version": "0.9.0-beta.124",
4
+ "cli_commit": "7bc8e8d17471c2cd33a93ca25e55770a4cb6e336",
5
+ "built_at": "2026-10-01T12:53:56.722Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
- "commit": "bbb3845083d7c1984bb61d813dd6700fe695180a",
8
+ "commit": "2e57b987ec91b7c3b0fa97f6169047802a1233fb",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
11
11
  "memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
@@ -2658,7 +2658,7 @@ async function dispatchRuntime(context, command, parsed, progress, prepared = {}
2658
2658
  if (!run || run.status !== "running" && !retainedHitlPrompt && !acceptedHitlPrompt && !["judge", "probe"].includes(metadata.role ?? ""))
2659
2659
  throw new AppError("invalid_run_state", "Subject dispatch requires a running RUN or its retained recovery/HITL prompt", 1);
2660
2660
  if (!recovering)
2661
- assertCapacityOwnerAdmission(context, record, optionalOption(parsed, "operation-id"));
2661
+ assertCapacityOwnerAdmission(context, record, optionalOption(parsed, "operation-id"), { nativeSessionId: optionalOption(parsed, "native-session-id"), promptSha256: optionalOption(parsed, "prompt-sha256") });
2662
2662
  }
2663
2663
  const budget = reserveProviderTurn(context, record, { operationId: optionalOption(parsed, "operation-id") ?? "", operation: optionalOption(parsed, "operation") ?? "", sessionId: optionalOption(parsed, "native-session-id"), daemonId: optionalOption(parsed, "daemon-id") });
2664
2664
  return { ok: true, ...budget, process_id: record.id, generation: metadata.run_generation ?? null };
@@ -3,7 +3,7 @@ export const vnextStages = [
3
3
  { id: "specify", directory: "01-specify", result: "specify.json", schema: "dd-flow/vnext-specify-result@4", next: ["protocolize"] },
4
4
  { id: "protocolize", directory: "02-protocolize", result: "protocolize-result.json", schema: "dd-flow/vnext-protocolize-result@3", next: ["plan"] },
5
5
  { id: "plan", directory: "03-plan", result: "stage-report.json", schema: "dd-flow/stage-report@2", next: ["plan-review", "code"] },
6
- { id: "plan-review", directory: "04-plan-review", result: "decision.json", schema: "dd-flow/plan-review-decision@3", next: ["code"] },
6
+ { id: "plan-review", directory: "04-plan-review", result: "decision.json", schema: "dd-flow/plan-review-decision@4", next: ["code"] },
7
7
  { id: "code", directory: "05-code", result: "code-verification.json", schema: "dd-flow/code-verification@2", next: ["code-review", "merge"] },
8
8
  { id: "code-review", directory: "06-code-review", result: "decision.json", schema: "dd-flow/code-review-decision@3", next: ["merge"] },
9
9
  { id: "merge", directory: "07-merge", result: "stage-report.json", schema: "dd-flow/stage-report@2", next: [] }
@@ -0,0 +1,10 @@
1
+ export const CAPACITY_POLICY: "codex-overload-burst@1";
2
+ export const OVERLOAD_WINDOW_MS: number;
3
+ export type CapacityRefusal = { policy: string; ordinal: number; session_id: string; turn_id: string; first_observed_at: string; native_terminal_at?: string; native_terminal_basis?: string };
4
+ export function parseCapacityOperation(id: string): { root: string; ordinal: number };
5
+ export function normalizeCodexFailure(error: unknown, sessionId?: string, turnId?: string): null | { code: string; session_id: string; turn_id: string; native_error: Record<string, unknown>; observed_at: unknown; terminal_at: unknown; native_turn_items: unknown; primary_error: { details?: Record<string, unknown> } };
6
+ export function terminalCodexOverload(error: unknown, sessionId?: string, turnId?: string): boolean;
7
+ export function nativeItemsSettled(items: unknown): boolean;
8
+ export function capacityBackoff(error: unknown, ordinal: number, now?: number): number;
9
+ export function refusalObservation(error: unknown, ordinal: number, firstObservedAt: string): CapacityRefusal;
10
+ export function overloadBurst(refusals: CapacityRefusal[]): null | { policy: string; basis: string; elapsed_ms: number; refusals: CapacityRefusal[] };
@@ -0,0 +1,89 @@
1
+ /** Pure policy shared by the controller and pinned-engine EVAL consumers. */
2
+ export const CAPACITY_POLICY = "codex-overload-burst@1";
3
+ export const OVERLOAD_WINDOW_MS = 120_000;
4
+
5
+ function policyError(code, message, details = {}) { return Object.assign(new Error(message), { code, details }); }
6
+
7
+ export function parseCapacityOperation(id) {
8
+ if (typeof id !== "string" || !id) throw policyError("capacity_continuation_conflict", "Missing capacity operation identity");
9
+ const match = /^(.*):capacity:(\d+):session\.prompt$/.exec(id);
10
+ if (!match) return { root: id, ordinal: 0 };
11
+ const ordinal = Number(match[2]);
12
+ if (!Number.isSafeInteger(ordinal) || ordinal < 1 || String(ordinal) !== match[2]) throw policyError("capacity_continuation_conflict", "Capacity ordinal is not a canonical positive safe integer", { operation_id: id });
13
+ return { root: match[1], ordinal };
14
+ }
15
+
16
+ export function normalizeCodexFailure(error, expectedSessionId, expectedTurnId) {
17
+ let current = error;
18
+ for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
19
+ if (current.code === "turn_interrupted") {
20
+ const details = current.details ?? {}, native = details.provider_error;
21
+ const code = typeof native?.codexErrorInfo === "string" ? native.codexErrorInfo : native?.codexErrorInfo?.code;
22
+ if (details.terminal_status !== "failed" || typeof details.provider_session_id !== "string" || !details.provider_session_id
23
+ || expectedSessionId !== undefined && details.provider_session_id !== expectedSessionId
24
+ || typeof details.turn_id !== "string" || !details.turn_id || details.native_turn_id !== details.turn_id
25
+ || expectedTurnId !== undefined && details.turn_id !== expectedTurnId || typeof code !== "string") return null;
26
+ return { code, session_id: details.provider_session_id, turn_id: details.turn_id, native_error: native,
27
+ observed_at: details.observed_at ?? null, terminal_at: details.terminal_at ?? null, terminal_basis: details.terminal_basis ?? null,
28
+ native_turn_items: details.native_turn_items ?? null, primary_error: current };
29
+ }
30
+ // No arbitrary recursive cause search: an ownership/hook/lifecycle error wins.
31
+ if (current.code !== "harness_adapter_failed") return null;
32
+ current = current.details?.cause ?? current.cause;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function terminalCodexOverload(error, expectedSessionId, expectedTurnId) {
38
+ return normalizeCodexFailure(error, expectedSessionId, expectedTurnId)?.code === "serverOverloaded";
39
+ }
40
+
41
+ export function nativeItemsSettled(items) {
42
+ return items?.observed === true && typeof items.possible_effects === "boolean" && items.pending !== true
43
+ && (items.possible_effects === false || items.pending === false);
44
+ }
45
+
46
+ export function capacityBackoff(error, ordinal, now = Date.now()) {
47
+ if (!Number.isSafeInteger(ordinal) || ordinal < 1 || !Number.isFinite(now)) throw policyError("capacity_continuation_conflict", "Invalid capacity timing identity");
48
+ const native = normalizeCodexFailure(error)?.native_error ?? error?.details?.provider_error ?? error;
49
+ const header = Object.entries(native?.headers ?? {}).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
50
+ const value = header ?? native?.retryAfter ?? native?.retry_after;
51
+ let retry = 0;
52
+ if (value !== undefined && value !== null) {
53
+ retry = typeof value === "number" || typeof value === "string" && /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) * 1000 : typeof value === "string" ? Math.max(0, Date.parse(value) - now) : NaN;
54
+ if (!Number.isFinite(retry) || retry < 0 || !Number.isFinite(now + retry) || now + retry > 8.64e15)
55
+ throw policyError("capacity_retry_metadata_invalid", "Retry-After cannot be represented safely", { retry_after: value });
56
+ }
57
+ return Math.max(ordinal === 1 ? 5_000 : 15_000, retry);
58
+ }
59
+
60
+ export function refusalObservation(error, ordinal, firstObservedAt) {
61
+ const failure = normalizeCodexFailure(error);
62
+ const observed = Date.parse(firstObservedAt);
63
+ if (!failure || !Number.isSafeInteger(ordinal) || ordinal < 0 || !Number.isFinite(observed))
64
+ throw policyError("capacity_continuation_conflict", "Refusal lacks an immutable native identity/time");
65
+ if (failure.observed_at !== null && (typeof failure.observed_at !== "string" || !Number.isFinite(Date.parse(failure.observed_at)) || Date.parse(failure.observed_at) !== observed))
66
+ throw policyError("capacity_continuation_conflict", "Native refusal observation time is invalid or changed");
67
+ const native = failure.terminal_at && Date.parse(failure.terminal_at);
68
+ return { policy: CAPACITY_POLICY, ordinal, session_id: failure.session_id, turn_id: failure.turn_id,
69
+ first_observed_at: firstObservedAt, ...(Number.isFinite(native) && typeof failure.terminal_basis === "string" && failure.terminal_basis ? { native_terminal_at: failure.terminal_at, native_terminal_basis: failure.terminal_basis } : {}) };
70
+ }
71
+
72
+ export function overloadBurst(refusals) {
73
+ for (const [index, refusal] of refusals.entries()) {
74
+ const previous = refusals[index - 1];
75
+ if (typeof refusal.first_observed_at !== "string" || !Number.isFinite(Date.parse(refusal.first_observed_at))
76
+ || previous && Date.parse(refusal.first_observed_at) < Date.parse(previous.first_observed_at))
77
+ throw policyError("capacity_continuation_conflict", "Refusal observation clock reversed or is invalid");
78
+ }
79
+ const continuations = refusals.filter(item => item.ordinal > 0);
80
+ const pair = continuations.slice(-2);
81
+ if (pair.length !== 2) return null;
82
+ const [previous, current] = pair;
83
+ if (previous.session_id !== current.session_id || previous.turn_id === current.turn_id || current.ordinal !== previous.ordinal + 1)
84
+ throw policyError("capacity_continuation_conflict", "Refusal chain identity is inconsistent");
85
+ const basis = previous.native_terminal_at && current.native_terminal_at && previous.native_terminal_basis && previous.native_terminal_basis === current.native_terminal_basis ? "native_terminal_at" : "first_observed_at";
86
+ const elapsed = Date.parse(current[basis]) - Date.parse(previous[basis]);
87
+ if (!Number.isFinite(elapsed) || elapsed < 0) throw policyError("capacity_continuation_conflict", "Refusal clock reversed or is invalid");
88
+ return elapsed <= OVERLOAD_WINDOW_MS ? { policy: CAPACITY_POLICY, basis, elapsed_ms: elapsed, refusals: pair } : null;
89
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lstatSync } from "node:fs";
2
3
  import { mkdir, readFile, readdir, link, unlink, open } from "node:fs/promises";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
@@ -10,6 +11,99 @@ import { readModelObservations, modelAttribution } from "./model-observations.mj
10
11
  const productive = new Set(["session.create", "session.prompt", "session.start", "session.fork", "session.resume"]);
11
12
  const controls = new Map();
12
13
  const dispatchGenerations = new Map();
14
+ const shutdowns = new Map();
15
+
16
+ export function assertDaemonDispatch(root) {
17
+ const stop = shutdowns.get(root);
18
+ if (stop?.pending || stop?.committed) throw Object.assign(new Error("daemon shutdown fences productive dispatch"), { code: "daemon_stopping" });
19
+ }
20
+ export function assertDaemonRestartable(state) {
21
+ if (["unclean", "cleanup_failed", "stopping"].includes(state?.shutdown_state)) throw Object.assign(new Error("previous daemon shutdown is unresolved; do not restart a native bridge for cleanup"), { code: "daemon_state_terminal", details: { daemon_id: state.daemon_id ?? state.config?.daemonId, shutdown_state: state.shutdown_state } });
22
+ }
23
+ export function commitDaemonShutdown(root) {
24
+ if (typeof root !== 'string') return; // In-memory adapter diagnostics own no daemon endpoint.
25
+ const fence = shutdowns.get(root) ?? { pending: 0 };
26
+ fence.committed = true; shutdowns.set(root, fence);
27
+ }
28
+
29
+ /** The same incarnation may retry remaining cleanup, never a native Turn. */
30
+ export async function completeDaemonShutdown(root, state, persist, prepare, phases) {
31
+ const daemonId = state.daemon_id ?? state.config?.daemonId;
32
+ let shutdown = state.shutdown;
33
+ if (!shutdown || shutdown.daemon_id !== daemonId) shutdown = { schema_id: "dd-flow/daemon-shutdown@1", daemon_id: daemonId, generation: dispatchGenerations.get(root) ?? 0, phases: {} };
34
+ shutdown = { ...shutdown, required_phases: ["tree", ...phases.map(([phase]) => phase)] };
35
+ try {
36
+ if (!shutdown.phases.tree) {
37
+ const result = await prepare();
38
+ shutdown = { ...shutdown, phases: { tree: true }, tree_observed_at: new Date().toISOString(), result: result ?? { clean: true } };
39
+ commitDaemonShutdown(root);
40
+ await persist({ shutdown, shutdown_state: "stopping", active_tree: false });
41
+ }
42
+ commitDaemonShutdown(root);
43
+ for (const [phase, action] of phases) {
44
+ if (shutdown.phases[phase]) continue;
45
+ await action();
46
+ shutdown = { ...shutdown, phases: { ...shutdown.phases, [phase]: true } };
47
+ await persist({ shutdown });
48
+ }
49
+ const result = { ...shutdown.result, daemon_id: daemonId, stopped: true, settled: true, shutdown_contract: "dd-flow/daemon-shutdown@1", _shutdown: true };
50
+ await persist({ shutdown, shutdown_state: result.clean === true ? "clean" : "unclean", active_tree: false, stopped_at: new Date().toISOString(), cleanup_error: null });
51
+ return result;
52
+ } catch (error) {
53
+ if (error.code === "tree_not_settled" && !shutdowns.get(root)?.committed) throw error;
54
+ try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
55
+ catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ const daemonConnections = new WeakMap();
61
+ export function trackDaemonConnections(server) {
62
+ const sockets = new Set(); daemonConnections.set(server, sockets);
63
+ server.once("listening", () => {
64
+ const endpoint = server.address();
65
+ if (typeof endpoint !== "string") return;
66
+ try { const info = lstatSync(endpoint); daemonEndpoints.set(server, { endpoint, dev: info.dev, ino: info.ino }); }
67
+ catch (error) { daemonEndpoints.set(server, { endpoint, error }); }
68
+ });
69
+ server.on("connection", socket => { sockets.add(socket); socket.on("error", () => {}); socket.once("close", () => sockets.delete(socket)); });
70
+ }
71
+ const daemonEndpoints = new WeakMap();
72
+
73
+ /** Retire the listener after durable publication, before ACK; no provider IO. */
74
+ export function retireDaemonEndpoint(server, exitCode = 0) {
75
+ if (!server.listening) return;
76
+ const retained = daemonEndpoints.get(server);
77
+ let endpointError = retained?.error;
78
+ if (retained && !endpointError) {
79
+ try {
80
+ const current = lstatSync(retained.endpoint);
81
+ if (current.dev !== retained.dev || current.ino !== retained.ino || !current.isSocket()) endpointError = Object.assign(new Error("daemon endpoint was replaced; refusing to unlink another listener"), { code: "daemon_endpoint_identity_mismatch", details: { socket: retained.endpoint, phase: "retire_listener" } });
82
+ } catch (error) { if (error.code !== "ENOENT") endpointError = error; }
83
+ }
84
+ if (endpointError) {
85
+ // libuv's close unlinks by pathname, not inode. Exit without closing its
86
+ // listener handle when the name belongs to a different incarnation.
87
+ const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); process.exit(1); }, 1_000);
88
+ timer.unref();
89
+ throw endpointError;
90
+ }
91
+ const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); }, 1_000);
92
+ timer.unref();
93
+ server.close(() => { clearTimeout(timer); process.exit(exitCode); });
94
+ }
95
+
96
+ export async function retireFailedDaemon(server, state, error, persist) {
97
+ // An already-finished registry lease cannot own a listening productive daemon.
98
+ if (state.shutdown?.phases?.daemon_resource) {
99
+ // Durable result publication and listener retirement occur after the
100
+ // cleanup helper returns. Their failures still invalidate overall clean.
101
+ if (persist) try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
102
+ catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
103
+ process.stderr.write(`${JSON.stringify({ shutdown_failure: errorRecord(error) })}\n`);
104
+ try { retireDaemonEndpoint(server, 1); } catch { /* Retirement already scheduled a nonzero exit without unlinking a foreign endpoint. */ }
105
+ }
106
+ }
13
107
 
14
108
  async function controlDispatch(root, request, action) {
15
109
  const key = request.operation === "daemon.stop" ? `stop:${request.params?.cancelTree === true}` : `cancel:${request.params?.sessionId ?? ""}`;
@@ -122,13 +216,16 @@ export async function durableDaemonDispatch(root, request, action, observe) {
122
216
  if (!productive.has(request.operation) && !control) return await action();
123
217
  const generation = dispatchGenerations.get(root) ?? 0;
124
218
  if (control) dispatchGenerations.set(root, generation + 1);
125
- const assertCurrent = () => { if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
126
- const result = await journalDispatch(root, request, async () => {
219
+ const assertCurrent = () => { assertDaemonDispatch(root); if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
220
+ const stop = request.operation === "daemon.stop" ? (shutdowns.get(root) ?? { pending: 0, committed: false }) : null;
221
+ if (stop) { stop.pending++; shutdowns.set(root, stop); }
222
+ let result;
223
+ try { result = await journalDispatch(root, request, async () => {
127
224
  // Persist before admission so a racing fence can observe the writer.
128
225
  if (!control) await assertDaemonOwnership(root, { "operation-id": request.id, operation: request.operation, "native-session-id": request.params?.sessionId, "prompt-sha256": typeof request.params?.prompt === "string" ? createHash("sha256").update(request.params.prompt).digest("hex") : undefined }, assertCurrent);
129
226
  if (!control) assertCurrent();
130
227
  return control ? await controlDispatch(root, request, action) : await action();
131
- });
228
+ }); } finally { if (stop) { stop.pending--; if (!stop.pending && !stop.committed) shutdowns.delete(root); } }
132
229
  if (control) return result;
133
230
  let settlement;
134
231
  try {
@@ -10,7 +10,7 @@ import { isDeepStrictEqual } from "node:util";
10
10
  import { ObservationClock } from "./observation-clock.mjs";
11
11
  import { hookBudget } from "./native-hook-command.mjs";
12
12
  import { agyWorkspaceHooks } from "./agy-hooks.mjs";
13
- import { durableDaemonDispatch } from "./daemon-operations.mjs";
13
+ import { durableDaemonDispatch, completeDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
14
14
  import path from "node:path";
15
15
  import { observeModel } from "./model-observations.mjs";
16
16
  import { AGY_VERSION_TIMEOUT_MS, AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
@@ -210,7 +210,9 @@ export async function startDaemon(options) {
210
210
  const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
211
211
  const previous = await readState(paths.dir), noFlow = options.noFlow === true, cwd = await realpath(absolute(options.cwd, "--cwd")), config = { cwd, journal: options.journal ? absolute(options.journal, "--journal") : paths.journal, bin: options.bin ?? process.env.DD_AGY_BIN ?? "agy", provider: String(options.provider ?? "google"), model: String(options.model ?? "gemini-3.1-pro-high"), reasoning: String(options.reasoning ?? "high"), mode: String(options.mode ?? "accept-edits"), 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") }, entryPath: absolute(options.entryPath, "dd-agy entry"), daemonId: randomUUID(), resumeSessionId: options.sessionId ?? null, geminiDir: paths.gemini, appDataDir: "runtime", temporary: paths.temporary, noFlow };
212
212
  await prepareRuntimeOwner(config, paths.dir);
213
+ assertDaemonRestartable(previous);
213
214
  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; }
215
+ assertDaemonRestartable(await readState(paths.dir));
214
216
  if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
215
217
  if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous Antigravity daemon died with an active or unproven tree");
216
218
  await removeSocket(paths); await prepare(paths, config);
@@ -235,11 +237,11 @@ export async function startDaemon(options) {
235
237
  throw error;
236
238
  }
237
239
  }
238
- 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); };
240
+ 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) }); };
239
241
 
240
242
  export class Runtime {
241
243
  constructor(paths, state) { this.paths = paths; this.state = state; this.config = state.config; this.child = null; this.providerProcess = state.provider_process ?? null; this.providerFinalization = Promise.resolve(); this.buffer = ""; this.init = null; this.readyPromise = null; this.active = null; this.exited = false; this.toolObservations = new ToolObservations(); for (const reason of state.tool_observation_gaps ?? []) this.toolObservations.gaps.add(reason); for (const event of state.tool_observations ?? []) this.toolObservations.add(event); if (state.tool_calls?.length && !state.tool_observations) this.toolObservations.gaps.add("legacy_tools_not_attributed"); this.turnGeneration = state.turn_generation ?? 0; this.descendants = new Map((state.descendants ?? []).map(child => [child.provider_session_id, child])); this.lastResult = state.last_result ?? null; this.sessionObservations = new Map(state.session_observations ?? []); this.startupError = null; this.persisting = Promise.resolve(); this.draining = Promise.resolve(); this.lastActivityAt = state.last_activity_at ?? null; this.usageIngest = null; this.lastProviderEvent = null; }
242
- persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values(), ...this.toolObservations.sources.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); const queued = performance.now(); this.persisting = this.persisting.then(async () => { const started = performance.now(); await writeJson(this.paths.state, snapshot); this.persistTiming = { queue_ms: Math.round(started - queued), write_ms: Math.round(performance.now() - started) }; }); return this.persisting; }
244
+ persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values(), ...this.toolObservations.sources.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); const queued = performance.now(); this.persisting = this.persisting.catch(() => {}).then(async () => { const started = performance.now(); await writeJson(this.paths.state, snapshot); this.persistTiming = { queue_ms: Math.round(started - queued), write_ms: Math.round(performance.now() - started) }; }); return this.persisting; }
243
245
  async journal(kind, payload = {}) { const line = `${JSON.stringify({ schema_id: "dd-agy/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), kind, ...payload })}\n`; for (const journal of new Set([this.paths.journal, this.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
244
246
  args(sessionId) { return ["--input-format", "stream-json", "--output-format", "stream-json", "--model", this.config.model, "--effort", this.config.reasoning, "--mode", this.config.mode, "--dangerously-skip-permissions", "--print-timeout", "6h", `--gemini_dir=${this.paths.gemini}`, "--app_data_dir=runtime", ...(sessionId ? ["--conversation", sessionId] : ["--new-project"])]; }
245
247
  requireSessionIdentity(requestedId) {
@@ -257,12 +259,16 @@ export class Runtime {
257
259
  if (["init", "step_update", "result"].includes(event.event)) this.markActivity();
258
260
  }
259
261
  async start(sessionId = null) {
262
+ if (this.drainingError) throw this.drainingError;
263
+ assertDaemonDispatch(this.paths?.dir);
260
264
  if (this.child && !this.exited) {
261
265
  await this.readyPromise;
262
266
  this.requireSessionIdentity(sessionId);
263
267
  return this.init;
264
268
  }
265
269
  await this.providerFinalization;
270
+ if (this.drainingError) throw this.drainingError;
271
+ assertDaemonDispatch(this.paths?.dir);
266
272
  const target = executable(this.config.bin, this.args(sessionId)); this.exited = false; this.buffer = ""; this.init = null;
267
273
  let readyResolve, readyReject; this.readyPromise = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
268
274
  // The provider can reject before durable registration finishes. Keep a
@@ -273,12 +279,12 @@ export class Runtime {
273
279
  const providerProcess = this.providerProcess;
274
280
  try { await this.persist({ provider_process: providerProcess }); }
275
281
  catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
276
- try { this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
282
+ try { assertDaemonDispatch(this.paths?.dir); this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
277
283
  catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
278
284
  const providerChild = this.child;
279
285
  let allowProviderFinalization;
280
286
  const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
281
- this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; this.draining = this.draining.then(() => this.drain(readyResolve)).catch(error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } }); });
287
+ this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; this.draining = this.draining.then(() => this.drain(readyResolve)).catch(error => { this.drainingError ??= error; readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } }); });
282
288
  this.child.stderr.setEncoding("utf8").on("data", chunk => void this.journal("stderr", { text: String(chunk).slice(0, 4000) }));
283
289
  this.child.on("error", error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } });
284
290
  // A dead leader is not yet a finalized tree: stop must also join the
@@ -426,6 +432,7 @@ export class Runtime {
426
432
  try {
427
433
  await this.persist({ active_tree: true, last_hook_rejection: null });
428
434
  assertDispatch();
435
+ assertDaemonDispatch(this.paths?.dir);
429
436
  if (this.active !== current) throw new DaemonError("operation_cancelled", "prompt reservation was superseded");
430
437
  this.child.stdin.write(`${JSON.stringify({ event: "user", message: { content: text } })}\n`, error => {
431
438
  if (error) { if (this.active === current) this.active = null; current.reject(error); }
@@ -489,19 +496,41 @@ export class Runtime {
489
496
  }
490
497
  async finalizeProviderForControl() {
491
498
  const pending = this.providerFinalization;
492
- try { await pending; }
493
- catch (error) {
494
- // A leaderless group may have exited naturally since the last check.
495
- // Only control may recheck it; startup never retries failed cleanup.
496
- if (this.providerFinalization !== pending) return await this.providerFinalization;
497
- if (error.code !== "process_group_ownership_unknown" || !this.retryProviderFinalization) throw error;
498
- this.providerFinalization = this.retryProviderFinalization(5_000);
499
- await this.providerFinalization;
499
+ try {
500
+ try { await pending; }
501
+ catch (error) {
502
+ // Control retries only retained physical/resource cleanup, never a
503
+ // missing mandatory stream record or a productive provider request.
504
+ if (this.providerFinalization !== pending) await this.providerFinalization;
505
+ else {
506
+ if (!this.retryProviderFinalization) throw error;
507
+ this.providerFinalization = this.retryProviderFinalization(5_000);
508
+ await this.providerFinalization;
509
+ }
510
+ }
511
+ } catch (error) {
512
+ if (!this.drainingError) throw error;
513
+ this.drainingError.cleanup_error = errorRecord(error);
514
+ this.drainingError.details = { ...this.drainingError.details, cleanup_error: this.drainingError.cleanup_error };
515
+ throw this.drainingError;
500
516
  }
517
+ if (this.drainingError) throw this.drainingError;
518
+ }
519
+ async prepareStop(cancelTree) {
520
+ const current = this.active, priorResult = this.lastResult;
521
+ const emptyTree = !current && !this.state.active_tree && !this.state.unclaimed_activity && !priorResult && !this.descendants.size;
522
+ if (cancelTree) await this.cancel();
523
+ else if (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled)) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running");
524
+ // A physical kill is settlement, not a native clean terminal receipt.
525
+ // In particular, the previous Turn's SUCCESS cannot certify a cancelled
526
+ // in-flight Turn that exited without its own result.
527
+ const currentResult = !current || current.terminalResult === this.lastResult || this.lastResult !== priorResult;
528
+ const clean = emptyTree || (currentResult && this.receipt().settled);
529
+ return { clean, ...(clean ? {} : { native_settlement: { confirmed: false, reason: "native_terminal_unconfirmed" } }) };
501
530
  }
502
531
  async cancel() { cancelPendingDispatch(this); if (!this.child) return { ...this.receipt(), settled: Boolean(this.exited) }; if (!this.exited) { try { process.kill(-this.child.pid, "SIGINT"); } catch (error) { if (error.code !== "ESRCH") throw error; } const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await stopProcessGroup(this.child, 5_000); } await this.finalizeProviderForControl(); await this.persist({ active_tree: false }); return { ...this.receipt(), cancelled: true, settled: Boolean(this.exited) };
503
532
  }
504
- async close(cancelTree) { if (cancelTree) cancelPendingDispatch(this); if (!cancelTree && (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled))) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running"); if (cancelTree) await this.cancel(); else if (this.child && !this.exited) this.child.stdin.end(); const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await this.cancel(); await this.providerFinalization; }
533
+ async close(cancelTree) { if (cancelTree) cancelPendingDispatch(this); if (!cancelTree && !this.exited && (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled))) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running"); if (cancelTree) await this.cancel(); else if (this.child && !this.exited) this.child.stdin.end(); const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await this.cancel(); await this.finalizeProviderForControl(); }
505
534
  async abortProvider(reason) {
506
535
  const child = this.child, providerProcess = this.providerProcess;
507
536
  await stopProcessGroup(child);
@@ -514,7 +543,27 @@ export class Runtime {
514
543
  export async function serveDaemon(stateDir) {
515
544
  const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
516
545
  scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
517
- const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.on("error", () => {}); socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
546
+ const runtime = new Runtime(paths, state); await removeSocket(paths);
547
+ const server = net.createServer(socket => {
548
+ let buffer = "", consumed = false; socket.setEncoding("utf8");
549
+ socket.on("data", chunk => {
550
+ if (consumed) return;
551
+ buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
552
+ void (async () => {
553
+ let request;
554
+ try {
555
+ request = JSON.parse(buffer.slice(0, at));
556
+ const result = await durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} }));
557
+ if (result._shutdown) retireDaemonEndpoint(server);
558
+ socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
559
+ } catch (error) {
560
+ await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
561
+ socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
562
+ }
563
+ })();
564
+ });
565
+ });
566
+ trackDaemonConnections(server); runtime.server = server;
518
567
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
519
568
  try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
520
569
  catch (error) {
@@ -528,6 +577,7 @@ export async function serveDaemon(stateDir) {
528
577
 
529
578
  async function dispatch(runtime, request) {
530
579
  if (request.schema_id !== REQUEST_SCHEMA) throw new DaemonError("daemon_protocol_mismatch", "unsupported dd-agy request schema"); const params = request.params ?? {};
580
+ if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(request.operation)) assertDaemonDispatch(runtime.paths.dir);
531
581
  if (request.operation === "daemon.status") { await runtime.observeHookFailures(); return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, active_operation: runtime.active?.operationId ?? null, last_hook_rejection: runtime.state.last_hook_rejection ?? null, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() }; }
532
582
  if (request.operation === "session.create") { if (params.sessionId) throw new DaemonError("invalid_create", "create does not accept a Session ID"); if (params.prompt) throw new DaemonError("invalid_create", "create does not execute a prompt; use session.prompt"); await runtime.start(); await runtime.zeroUsage(); return runtime.receipt(); }
533
583
  if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch, request.id); }
@@ -557,7 +607,7 @@ async function dispatch(runtime, request) {
557
607
  }
558
608
  catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}, current); throw error; }
559
609
  }
560
- if (request.operation === "daemon.stop") { await runtime.close(params.cancelTree === true); await runtime.persist({ shutdown_state: "clean", active_tree: false }); await runtime.finishResource(); setImmediate(() => runtime.server?.close()); return { stopped: true, clean: true, settled: true, daemon_id: runtime.config.daemonId }; }
610
+ if (request.operation === "daemon.stop") return await completeDaemonShutdown(runtime.paths.dir, runtime.state, patch => runtime.persist(patch), () => runtime.prepareStop(params.cancelTree === true), [["provider", () => runtime.close(false)], ["daemon_resource", () => runtime.finishResource()]]);
561
611
  throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
562
612
  }
563
613
  import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
@@ -4,6 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { checkObservedProfile } from "./model-observations.mjs";
7
+ import { stopProcessGroup } from "./managed-daemon.mjs";
8
+ import { errorRecord } from "./operation-errors.mjs";
7
9
 
8
10
  const DEFAULT_MODEL = "gemini-3.1-pro-high";
9
11
  export const AGY_VERSION_TIMEOUT_MS = 30_000;
@@ -19,14 +21,22 @@ export function executable(bin, args = []) {
19
21
  export async function runAgy(bin, args, options = {}) {
20
22
  const target = executable(bin, args);
21
23
  return await new Promise((resolve, reject) => {
22
- const child = spawn(target.command, target.args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"] });
24
+ const child = spawn(target.command, target.args, { cwd: options.cwd, env: options.env ?? process.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
23
25
  let stdout = "", stderr = "", settled = false;
24
- const timer = setTimeout(() => { child.kill("SIGTERM"); finish(new AgyError("agy_timeout", `${args[0] ?? "agy"} timed out`, true)); }, options.timeoutMs ?? 30_000);
26
+ let timeoutError, inputError, cleanup;
27
+ const timer = setTimeout(() => {
28
+ timeoutError = new AgyError("agy_timeout", `${args[0] ?? "agy"} timed out`, true);
29
+ cleanup = stopProcessGroup(child, 1_000).catch(error => { timeoutError.cleanup_error = errorRecord(error); timeoutError.details = { cleanup_error: timeoutError.cleanup_error }; finish(timeoutError); });
30
+ }, options.timeoutMs ?? 30_000);
25
31
  const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
26
32
  child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
27
33
  child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
28
34
  child.on("error", error => finish(error));
29
- child.on("close", code => code === 0 ? finish(null, { stdout, stderr, code }) : finish(new AgyError("agy_cli_failed", stderr.trim() || stdout.trim() || `agy exited ${code}`, false, { exit_code: code })));
35
+ child.on("close", async code => { await cleanup; if (timeoutError) finish(timeoutError); else if (inputError) finish(inputError); else if (code === 0) finish(null, { stdout, stderr, code }); else finish(new AgyError("agy_cli_failed", stderr.trim() || stdout.trim() || `agy exited ${code}`, false, { exit_code: code })); });
36
+ child.stdin.on("error", error => {
37
+ inputError ??= error;
38
+ cleanup ??= stopProcessGroup(child, 1_000).catch(cause => { inputError.details = { ...inputError.details, cleanup_error: errorRecord(cause) }; finish(timeoutError ?? inputError); });
39
+ });
30
40
  child.stdin.end(options.stdin ?? "");
31
41
  });
32
42
  }