@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127

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 (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -5,8 +5,13 @@ import { commandJson } from "./process-json.mjs";
5
5
  import { errorRecord } from "./operation-errors.mjs";
6
6
  import { writeJsonAtomic } from "./runner-events.mjs";
7
7
  import { runBoundedCommand } from "./native-hook-command.mjs";
8
+ import { createRenewalState, maintenanceRetryable, renewalConfirmed, renewalDelay, renewalFailure, renewalRemaining, RENEWAL_POLICY } from "./lease-renewal.mjs";
8
9
 
9
10
  const leases = new Map();
11
+ // A rejected Promise is not physical exit. Do not start another writer for
12
+ // this exact binding after an unconfirmed maintenance subtree cleanup.
13
+ const maintenancePoison = new Map();
14
+ const maintenanceFlights = new Map();
10
15
 
11
16
  /** Freeze resource ownership independently of whether Subject hooks are enabled. */
12
17
  export function bindRuntimeOwner(config, env = process.env) {
@@ -38,6 +43,7 @@ export function bindRuntimeOwner(config, env = process.env) {
38
43
 
39
44
  /** Reject rebinding before provider setup, including the already-running path. */
40
45
  export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
46
+ config.stateDir = path.resolve(stateDir);
41
47
  const owner = bindRuntimeOwner(config, env);
42
48
  if (owner && path.resolve(owner.state_dir) !== path.resolve(stateDir)) {
43
49
  throw Object.assign(new Error("runtime owner belongs to another state directory"), { code: "runtime_owner_mismatch" });
@@ -52,27 +58,44 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
52
58
  return owner;
53
59
  }
54
60
 
55
- export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
56
- for (const lease of leases.values()) if (!lease.stateDir && lease.error) throw lease.error;
61
+ export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}, progress = async () => {}, waiting = async () => {}) {
62
+ const gate = async () => {
63
+ assertCurrent();
64
+ for (const lease of leases.values()) {
65
+ if (lease.stateDir !== path.resolve(stateDir)) continue;
66
+ await reconfirmLease(lease, assertCurrent);
67
+ assertCurrent();
68
+ }
69
+ };
70
+ await gate();
71
+ await progress('ownership_confirmed');
57
72
  const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
58
73
  const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
59
74
  if (!owner) return;
60
75
  const record = state.resource_process;
61
76
  if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
62
- for (const lease of leases.values()) {
63
- if (lease.stateDir !== stateDir) continue;
64
- if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
65
- // A failed renewal is not a lost lease. Reconfirm before productive work;
66
- // never dispatch on the old lease merely because it has time remaining.
67
- if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
68
- if (lease.error) throw lease.error;
69
- }
77
+ scheduleDaemonProcessHeartbeat(state.config, record);
78
+ const admissionRenewal = createRenewalState();
70
79
  for (;;) {
80
+ await gate();
81
+ admissionRenewal.uncertainSince ??= performance.now();
82
+ let result;
83
+ try { result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch }, { timeoutMs: renewalRemaining(admissionRenewal) }); }
84
+ catch (error) {
85
+ if (!maintenanceRetryable(error) || !renewalFailure(admissionRenewal)) throw error;
86
+ await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(admissionRenewal), renewalRemaining(admissionRenewal))));
87
+ continue;
88
+ }
71
89
  assertCurrent();
72
- const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
73
- assertCurrent();
74
- if (result?.ok === true && result.admitted === true && result.process_id === record.id) return;
75
- if (result?.ok !== true || result.reason !== "provider_capacity" || result.process_id !== record.id) throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
90
+ if (result?.ok !== true || result.process_id !== record.id || result.admitted !== true && result.reason !== "provider_capacity") throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
91
+ requireTimelyMaintenance(admissionRenewal, "admission", result, record);
92
+ // This bound ACK ends admission uncertainty. The post-ACK lease checks
93
+ // have their own episodes; successful renewal must not exhaust an already
94
+ // confirmed admission while the provider/daemon still retain ownership.
95
+ if (!renewalConfirmed(admissionRenewal)) requireTimelyMaintenance(admissionRenewal, "admission", result, record);
96
+ await gate();
97
+ await waiting({ ...result, lease_expires_at: result.lease_expires_at ?? record.lease_expires_at });
98
+ if (result.admitted === true) { await progress('admission_confirmed'); return; }
76
99
  const predecessor = result.blocking_turns?.find(turn => turn?.process_id === record.id
77
100
  && turn?.session_id === dispatch["native-session-id"]
78
101
  && turn?.operation_id !== dispatch["operation-id"]);
@@ -153,20 +176,92 @@ async function budgetState(stateDir) {
153
176
 
154
177
  async function releaseBudget(state, options) {
155
178
  const record = state.resource_process, owner = state.config.runtime_owner;
156
- const receipt = await runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options });
179
+ const receipt = await retryBookkeeping(timeoutMs => runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options }, { timeoutMs }));
157
180
  if (receipt?.ok !== true) throw Object.assign(new Error("runtime budget release was not confirmed"), { code: "runtime_budget_release_unconfirmed" });
158
181
  }
159
182
 
160
- export async function runtimeProcess(config, action, options = {}) {
183
+ export async function runtimeProcess(config, action, options = {}, { timeoutMs = RENEWAL_POLICY.budgetMs, signal } = {}) {
184
+ const resourceHome = config.runtime_owner?.resource_home ?? config.resourceHome;
185
+ if (action === "status" || !options.id || !resourceHome) return executeRuntimeProcess(config, action, options, { timeoutMs, signal });
186
+ const key = JSON.stringify([path.resolve(resourceHome), options.id, options["lease-token"] ?? null]);
187
+ const validBudget = Number.isFinite(timeoutMs) && timeoutMs > 0;
188
+ const deadline = performance.now() + (validBudget ? timeoutMs : 0);
189
+ const prior = maintenanceFlights.get(key);
190
+ let started = false;
191
+ let settle;
192
+ const flight = new Promise(resolve => { settle = resolve; });
193
+ maintenanceFlights.set(key, flight);
194
+ try {
195
+ if (!validBudget) throw Object.assign(new Error("Managed maintenance budget is invalid or exhausted before dispatch"), { code: "process_maintenance_timeout", details: { effect: "no_effect", native_dispatch_started: false } });
196
+ if (prior) {
197
+ let timer;
198
+ try {
199
+ await Promise.race([prior, new Promise((_, reject) => {
200
+ const expire = () => {
201
+ const remaining = deadline - performance.now();
202
+ // A timer wakeup is not proof that its monotonic deadline passed.
203
+ if (remaining > 0) { timer = setTimeout(expire, Math.min(2_147_483_647, Math.ceil(remaining))); return; }
204
+ reject(Object.assign(new Error("managed maintenance queue deadline expired"), { code: "process_maintenance_timeout", details: { effect: "no_effect" } }));
205
+ };
206
+ expire();
207
+ })]);
208
+ } finally { clearTimeout(timer); }
209
+ }
210
+ const remaining = deadline - performance.now();
211
+ if (remaining <= 0) throw Object.assign(new Error("managed maintenance queue deadline expired"), { code: "process_maintenance_timeout", details: { effect: "no_effect" } });
212
+ started = true;
213
+ return await executeRuntimeProcess(config, action, options, { timeoutMs: remaining, signal });
214
+ } catch (error) {
215
+ const lease = leases.get(key);
216
+ const text = value => typeof value === "string" ? value.slice(0, 128) : undefined;
217
+ const request = { phase: started ? "request" : "queue", action, process_id: text(options.id), operation_id: text(options["operation-id"] ?? config.runtime_owner?.operation_id), role: text(lease?.record.kind ?? config.runtime_owner?.role), attempt_kind: prior ? "join" : "fresh", attempt: (lease?.renewal.attempt ?? 0) + 1, remaining_ms: Math.max(0, Math.round(deadline - performance.now())), native_dispatch_started: false, maintenance_effect: started ? "unknown" : "no_effect" };
218
+ // Parent queue/import failures precede any child trace. Keep only bounded,
219
+ // credential-free context and the existing child phase allowlist.
220
+ const childPhases = Array.isArray(error.details?.hook_diagnostics) ? error.details.hook_diagnostics : [];
221
+ error.details = { ...error.details, hook_diagnostics: [request, ...childPhases.slice(-15)] };
222
+ throw error;
223
+ } finally {
224
+ // Even a waiter whose deadline expires must retain its predecessor's
225
+ // physical fence until that predecessor settles.
226
+ const finish = () => {
227
+ settle();
228
+ if (maintenanceFlights.get(key) === flight) maintenanceFlights.delete(key);
229
+ };
230
+ if (prior) void prior.then(finish); else finish();
231
+ }
232
+ }
233
+
234
+ async function executeRuntimeProcess(config, action, options = {}, { timeoutMs, signal }) {
161
235
  const owner = config.runtime_owner;
162
236
  const resourceHome = owner?.resource_home ?? config.resourceHome;
163
237
  const ddFlowHome = owner?.dd_flow_home ?? config.ddFlowHome;
164
238
  const ddFlowBin = owner?.dd_flow_bin ?? config.ddFlowBin;
165
239
  if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
240
+ const poisonKey = token => JSON.stringify([path.resolve(resourceHome), options.id, token ?? null]);
241
+ if (action !== "status" && options.id) {
242
+ const poison = maintenancePoison.get(poisonKey(options["lease-token"])) ?? maintenancePoison.get(poisonKey(null));
243
+ if (poison) throw poison;
244
+ }
166
245
  const args = ["runtime", "process", action];
167
246
  for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
168
- if (action === "heartbeat") return JSON.parse(await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
169
- env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs: 15_000, timeoutCode: "process_heartbeat_timeout" }));
247
+ if (["register", "confirm", "heartbeat", "admission", "check-admission", "finish", "release-turn", "status"].includes(action)) {
248
+ const maintenanceStarted = performance.now();
249
+ process.stderr.write(`${JSON.stringify({ kind: "process_maintenance_phase", phase: "request", observed_at: new Date().toISOString(), operation_id: options['operation-id'] ?? config.runtime_owner?.operation_id ?? null, action, process_id: options.id ?? null, remaining_ms: Math.round(timeoutMs), native_dispatch_started: false })}\n`);
250
+ let output;
251
+ try { output = await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
252
+ env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs,
253
+ timeoutCode: action === "heartbeat" ? "process_heartbeat_timeout" : "process_maintenance_timeout", inheritDeadline: false, cleanupWithinBudget: true,
254
+ maintenanceProgress: { action, processId: options.id ?? null, inactivityMs: RENEWAL_POLICY.attemptMs }, signal }); }
255
+ catch (error) {
256
+ if (action !== "status" && options.id && error.details?.cleanup_unconfirmed) maintenancePoison.set(poisonKey(options["lease-token"]), error);
257
+ throw error;
258
+ }
259
+ let result;
260
+ try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid managed process maintenance response"), { code: "process_maintenance_receipt_invalid", details: { action } }); }
261
+ if (result?.ok === false && result.error?.code) throw Object.assign(new Error(result.error.message ?? "Managed process maintenance failed"), { code: result.error.code, details: result.error.details });
262
+ process.stderr.write(`${JSON.stringify({ kind: "process_maintenance_phase", phase: "ack", observed_at: new Date().toISOString(), duration_ms: Math.round(performance.now() - maintenanceStarted), operation_id: options['operation-id'] ?? config.runtime_owner?.operation_id ?? null, action, process_id: options.id ?? null, lease_expires_at: result.lease_expires_at ?? result.process?.lease_expires_at ?? null, native_dispatch_started: false })}\n`);
263
+ return result;
264
+ }
170
265
  return await commandJson(ddFlowBin, args, {
171
266
  cwd: config.cwd,
172
267
  env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
@@ -177,8 +272,38 @@ export async function registerDaemonProcess(config, input) {
177
272
  const owner = bindRuntimeOwner(config);
178
273
  const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
179
274
  const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
180
- const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
181
- return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
275
+ const options = { ...input, ...scope, id: `PROC-${input.kind}-${randomUUID()}`, "owner-pid": process.pid, "lease-ms": 300_000 };
276
+ const renewal = createRenewalState();
277
+ renewal.uncertainSince = performance.now();
278
+ let result;
279
+ for (;;) {
280
+ try { result = await runtimeProcess(config, "register", options, { timeoutMs: renewalRemaining(renewal) }); break; }
281
+ catch (error) {
282
+ if (!maintenanceRetryable(error) || !renewalFailure(renewal)) throw error;
283
+ // The child has exited before a timeout is retryable. Reconcile the exact
284
+ // startup identity, not an arbitrary PID or another operation's owner.
285
+ let status;
286
+ try { status = await retryBookkeeping(timeoutMs => runtimeProcess(config, "status", {}, { timeoutMs }), renewal); }
287
+ catch (reconciliationError) {
288
+ // Retain the failed write's phases without changing the reconciliation
289
+ // failure's classification or authorizing another startup attempt.
290
+ reconciliationError.details = { ...reconciliationError.details, registration_error: errorRecord(error) };
291
+ throw reconciliationError;
292
+ }
293
+ const retained = status?.processes?.find(record => record.id === options.id);
294
+ if (retained) {
295
+ let metadata; try { metadata = JSON.parse(retained.metadata_json); } catch { /* Invalid binding below. */ }
296
+ if (retained.kind !== options.kind || retained.owner_id !== options.owner || retained.operation_id !== options.operation || retained.state !== "starting" || metadata?.owner_pid !== process.pid) throw Object.assign(new Error("Startup reconciliation changed managed process binding"), { code: "process_maintenance_receipt_invalid" });
297
+ result = { process: retained }; break;
298
+ }
299
+ if (!Array.isArray(status?.processes)) throw Object.assign(new Error("Startup reconciliation has no registry proof"), { code: "process_maintenance_receipt_invalid" });
300
+ await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(renewal), renewalRemaining(renewal))));
301
+ }
302
+ }
303
+ if (!result) return null;
304
+ if (result.process?.id !== options.id || !result.process.lease_token) throw Object.assign(new Error("Managed startup receipt belongs to another registration"), { code: "process_maintenance_receipt_invalid" });
305
+ requireTimelyMaintenance(renewal, "register", result, result.process);
306
+ return { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind, lease_expires_at: result.process.lease_expires_at };
182
307
  }
183
308
 
184
309
  export async function confirmDaemonProcess(config, record, child) {
@@ -187,7 +312,11 @@ export async function confirmDaemonProcess(config, record, child) {
187
312
  await finishDaemonProcess(config, record, "failed", "daemon_spawn_missing_pid");
188
313
  return;
189
314
  }
190
- await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
315
+ const renewal = createRenewalState();
316
+ const result = await retryBookkeeping(timeoutMs => runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 }, { timeoutMs }), renewal);
317
+ if (!result?.process || result.process.id !== record.id || result.process.lease_token !== record.lease_token) throw Object.assign(new Error("Managed process confirmation binding is invalid"), { code: "process_maintenance_receipt_invalid" });
318
+ retainReceipt(record, result.process);
319
+ requireTimelyMaintenance(renewal, "confirm", result, record);
191
320
  }
192
321
 
193
322
  /** Provider bridges use a different process group from their daemon. Publish
@@ -217,29 +346,40 @@ export async function closeManagedBridge(config, state, bridge) {
217
346
 
218
347
  export function scheduleDaemonProcessHeartbeat(config, record) {
219
348
  if (!record?.id || !record.lease_token) return;
220
- if (leases.has(record.id)) return;
221
- const lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
222
- leases.set(record.id, lease);
223
- lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
349
+ const key = leaseKey(config, record);
350
+ if (leases.has(key)) return;
351
+ const lease = { pending: null, error: null, timer: null, lost: false, closing: false, expiresAt: Date.parse(record.lease_expires_at ?? ""), renewal: createRenewalState(), stateDir: path.resolve(config.runtime_owner?.state_dir ?? config.stateDir ?? config.cwd), config, record };
352
+ leases.set(key, lease);
353
+ // Retry inside the same uncertainty episode while native work is active.
354
+ // Waiting for the next 60s tick would exhaust the 30s budget after one BUSY.
355
+ lease.timer = setInterval(() => reconfirmLease(lease, () => {}).catch(() => {}), 60_000);
224
356
  lease.timer.unref();
225
357
  }
226
358
 
227
359
  export async function heartbeatDaemonProcess(config, record) {
228
360
  if (!record?.id || !record.lease_token) return;
229
361
  scheduleDaemonProcessHeartbeat(config, record);
230
- const lease = leases.get(record.id);
362
+ const lease = leases.get(leaseKey(config, record));
363
+ if (lease.closing) throw Object.assign(new Error("Managed process is closing"), { code: "process_closing" });
231
364
  if (lease.lost) throw lease.error;
365
+ if (lease.error && !maintenanceRetryable(lease.error)) throw lease.error;
366
+ if (!renewalRemaining(lease.renewal)) throw lease.error;
232
367
  if (lease.pending) return await lease.pending;
233
368
  lease.pending = (async () => {
234
369
  try {
235
- const result = await runtimeProcess(config, "heartbeat", { id: record.id, "lease-token": record.lease_token, "lease-ms": 300_000 });
236
- if (result?.ok !== true) {
370
+ lease.renewal.uncertainSince ??= performance.now();
371
+ const result = await runtimeProcess(config, "heartbeat", { id: record.id, "lease-token": record.lease_token, "lease-ms": 300_000 }, { timeoutMs: renewalRemaining(lease.renewal) });
372
+ if (result?.ok === false) {
237
373
  lease.lost = true; clearInterval(lease.timer);
238
374
  throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
239
375
  }
240
- lease.error = null;
241
- lease.expiresAt = Date.now() + 300_000;
376
+ if (result?.ok !== true || result.process_id !== record.id) throw Object.assign(new Error("Managed renewal response belongs to another process"), { code: "process_maintenance_receipt_invalid", details: { process_id: record.id } });
377
+ if (!lease.closing) {
378
+ if (!renewalConfirmed(lease.renewal)) requireTimelyMaintenance(lease.renewal, "heartbeat", result, record);
379
+ retainReceipt(record, result); lease.error = null; lease.expiresAt = Date.parse(record.lease_expires_at);
380
+ }
242
381
  } catch (cause) {
382
+ if (!lease.closing) renewalFailure(lease.renewal);
243
383
  lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
244
384
  throw lease.error;
245
385
  }
@@ -249,13 +389,59 @@ export async function heartbeatDaemonProcess(config, record) {
249
389
 
250
390
  export async function finishDaemonProcess(config, process, state = "stopped", reason = "daemon_stopped") {
251
391
  if (process?.id && process.lease_token) {
252
- const lease = leases.get(process.id); if (lease) clearInterval(lease.timer);
253
- const result = await runtimeProcess(config, "finish", { id: process.id, "lease-token": process.lease_token, state, reason });
392
+ const settlement = createRenewalState(); settlement.uncertainSince = performance.now();
393
+ const key = leaseKey(config, process), lease = leases.get(key);
394
+ if (lease) { lease.closing = true; clearInterval(lease.timer); await lease.pending?.catch(() => {}); if (lease.error?.cause?.details?.cleanup_unconfirmed) throw lease.error; }
395
+ const result = await retryBookkeeping(timeoutMs => runtimeProcess(config, "finish", { id: process.id, "lease-token": process.lease_token, state, reason }, { timeoutMs }), settlement);
254
396
  if (result?.ok !== true) throw Object.assign(new Error("managed process finish was not accepted"), { code: "process_lease_lost", details: { process_id: process.id } });
255
- leases.delete(process.id);
397
+ leases.delete(key);
256
398
  }
257
399
  }
258
400
 
401
+ function leaseKey(config, record) { return JSON.stringify([path.resolve(config.runtime_owner?.resource_home ?? config.resourceHome), record.id, record.lease_token]); }
402
+ function retainReceipt(record, receipt) {
403
+ const expiresAt = Date.parse(receipt.lease_expires_at ?? "");
404
+ if (!Number.isFinite(expiresAt) || !/^[a-f0-9]{64}$/.test(receipt.registration_sha256 ?? "") || record.registration_sha256 && record.registration_sha256 !== receipt.registration_sha256) throw Object.assign(new Error("Managed lease receipt has no authoritative expiry/binding"), { code: "process_maintenance_receipt_invalid", details: { process_id: record.id } });
405
+ record.lease_expires_at = receipt.lease_expires_at;
406
+ record.registration_sha256 = receipt.registration_sha256;
407
+ }
408
+ async function reconfirmLease(lease, assertCurrent) {
409
+ for (;;) {
410
+ assertCurrent();
411
+ if (lease.closing || lease.lost) throw lease.error ?? Object.assign(new Error("Managed process is closing"), { code: "process_closing" });
412
+ try { await heartbeatDaemonProcess(lease.config, lease.record); }
413
+ catch (error) {
414
+ if (!maintenanceRetryable(error) || !renewalRemaining(lease.renewal)) throw error;
415
+ await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(lease.renewal), renewalRemaining(lease.renewal))));
416
+ continue;
417
+ }
418
+ assertCurrent();
419
+ if (lease.closing || lease.error || !Number.isFinite(lease.expiresAt) || Date.now() >= lease.expiresAt) throw lease.error ?? Object.assign(new Error("Committed lease receipt expired before admission"), { code: "process_ownership_unknown" });
420
+ return;
421
+ }
422
+ }
423
+ async function retryBookkeeping(action, renewal = createRenewalState()) {
424
+ renewal.uncertainSince ??= performance.now();
425
+ for (;;) {
426
+ try { return await action(renewalRemaining(renewal)); }
427
+ catch (error) { if (!maintenanceRetryable(error) || !renewalFailure(renewal)) throw error; await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(renewal), renewalRemaining(renewal)))); }
428
+ }
429
+ }
430
+
431
+ // A committed ACK is evidence, but cannot revive an expired authority episode.
432
+ // Cleanup-only finish/release receipts remain facts, not productive grants.
433
+ function requireTimelyMaintenance(renewal, action, receipt, record) {
434
+ if (renewalRemaining(renewal)) return;
435
+ const safeText = value => typeof value === "string" ? value.slice(0, 128) : undefined;
436
+ const hash = receipt?.process?.registration_sha256 ?? receipt?.registration_sha256 ?? record?.registration_sha256;
437
+ throw Object.assign(new Error("Managed ownership confirmation arrived after its uncertainty budget"), {
438
+ code: "process_ownership_unknown", details: { action, process_id: safeText(record?.id ?? receipt?.process_id),
439
+ registration_sha256: typeof hash === "string" && /^[a-f0-9]{64}$/.test(hash) ? hash : undefined,
440
+ lease_expires_at: safeText(receipt?.process?.lease_expires_at ?? receipt?.lease_expires_at ?? record?.lease_expires_at),
441
+ effect: action === "admission" && receipt?.admitted !== true ? "no_effect" : "committed", native_dispatch_started: false }
442
+ });
443
+ }
444
+
259
445
  /** Preserve the startup error and retain the lease/resources if cleanup cannot
260
446
  * prove termination. A failed cleanup is not a terminal managed process. */
261
447
  export async function cleanupFailedStart(config, record, child, original) {
@@ -286,43 +472,113 @@ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild
286
472
  }
287
473
  }
288
474
 
289
- export async function stopProcessGroup(child, graceMs = 1_000) {
475
+ const processStops = new WeakMap();
476
+ export async function closeBridgeProcess(bridge, drain, stopOptions = {}) {
477
+ if (bridge.closePending) return await bridge.closePending;
478
+ const pending = (async () => {
479
+ let primary;
480
+ try {
481
+ if (!bridge.physicallyClosed && bridge.child) {
482
+ const child = bridge.child;
483
+ if (!bridge.stdinEnded && !child.stdin?.destroyed) { bridge.stdinEnded = true; child.stdin?.end(); }
484
+ if (child.exitCode == null && child.signalCode == null) {
485
+ await new Promise(resolve => {
486
+ const done = () => { clearTimeout(timer); child.removeListener("exit", done); resolve(); };
487
+ const timer = setTimeout(done, 2_000); child.once("exit", done);
488
+ });
489
+ }
490
+ await stopProcessGroup(child, 2_000, stopOptions);
491
+ bridge.physicallyClosed = true;
492
+ }
493
+ } catch (error) { primary = error; }
494
+ let drainTimer;
495
+ 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); })]); }
496
+ catch (error) { if (primary) { primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } else primary = error; }
497
+ finally { clearTimeout(drainTimer); }
498
+ if (primary) throw primary;
499
+ })();
500
+ bridge.closePending = pending;
501
+ try { return await pending; }
502
+ finally { bridge.closePending = null; }
503
+ }
504
+
505
+ export async function stopProcessGroup(child, graceMs = 1_000, { detached = true, settlementMs = 10_000 } = {}) {
290
506
  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;
507
+ if (processStops.has(child)) return await processStops.get(child);
508
+ const pending = stopOwnedChild(child, graceMs, detached, settlementMs);
509
+ processStops.set(child, pending);
510
+ try { return await pending; } finally { processStops.delete(child); }
511
+ }
512
+ async function stopOwnedChild(child, graceMs, detached, settlementMs) {
513
+ const target = process.platform === "win32" || !detached ? child.pid : -child.pid;
514
+ 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
515
  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));
516
+ const gone = async ms => {
517
+ const deadline = performance.now() + ms;
518
+ while (alive() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
519
+ return !alive();
520
+ };
521
+ const signal = async name => {
522
+ try { process.kill(target, name); return true; }
523
+ catch (error) {
524
+ if (error.code === "ESRCH") return false;
525
+ error.details = { ...error.details, ...details(name), syscall: error.syscall ?? "kill" };
526
+ // Permission denial is not permission to escalate. Observe only the retained target.
527
+ let absent = false;
528
+ try { absent = await gone(Math.min(250, Math.max(25, graceMs))); }
529
+ catch (observation) { error.details.observation_error = errorRecord(observation); }
530
+ if (absent) {
531
+ process.stderr.write(`${JSON.stringify({ cleanup_reobserved: errorRecord(error), physical_exit: "ESRCH" })}\n`);
532
+ return false;
533
+ }
534
+ throw error;
535
+ }
536
+ };
308
537
  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" });
538
+ const leaderExited = () => child.exitCode != null || child.signalCode != null;
539
+ const observeLeaderless = async () => {
540
+ // Signal grace is not a drain deadline. Helpers can retire naturally after
541
+ // their leader exits; observing them does not authorize another signal.
542
+ if (await gone(Math.max(1_000, graceMs, settlementMs))) return;
543
+ 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), settlement_ms: Math.max(1_000, graceMs, settlementMs) } });
544
+ };
545
+ if (leaderExited()) return await observeLeaderless();
546
+ if (!await signal("SIGTERM")) return;
547
+ if (await gone(graceMs)) return;
548
+ if (leaderExited()) return await observeLeaderless();
549
+ if (!await signal("SIGKILL")) return;
550
+ 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
551
  }
317
552
 
318
553
  /** 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));
554
+ export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000, { readState } = {}) {
555
+ const deadline = performance.now() + timeoutMs;
556
+ const expected = await readState?.();
557
+ const identity = state => state?.daemon_id ?? state?.config?.daemonId;
558
+ 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' });
559
+ const failure = async observer => {
560
+ const state = await readState?.();
561
+ if (expected && identity(state) !== identity(expected)) throw Object.assign(new Error("daemon incarnation changed during stop"), { code: "daemon_identity_mismatch" });
562
+ const primary = state?.shutdown_state === "cleanup_failed" ? state.cleanup_error : null;
563
+ if (primary) throw Object.assign(new Error(primary.message), primary, { details: { ...primary.details, socket, phase: "confirm_daemon_stop", observer_error: errorRecord(observer) } });
564
+ throw observer;
565
+ };
566
+ let result;
567
+ try { result = await request(); }
568
+ catch (error) { return await failure(error); }
569
+ if (result?.stopped !== true) return await failure(Object.assign(new Error("stop acknowledgement does not prove shutdown"), { code: "daemon_stop_incomplete" }));
570
+ for (;;) {
571
+ const state = await readState?.();
572
+ if (expected && identity(state) !== identity(expected)) return await failure(Object.assign(new Error("daemon incarnation changed"), { code: "daemon_identity_mismatch" }));
573
+ let endpointPresent = true;
574
+ try { await lstat(socket); } catch (error) { if (error.code === "ENOENT") endpointPresent = false; else throw error; }
575
+ let daemonPresent = false;
576
+ if (expected?.pid) { try { process.kill(expected.pid, 0); daemonPresent = true; } catch (error) { if (error.code !== "ESRCH") daemonPresent = true; } }
577
+ if (!endpointPresent && !daemonPresent) {
578
+ 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' }));
579
+ return result;
580
+ }
581
+ 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 } }));
582
+ await new Promise(resolve => setTimeout(resolve, 25));
326
583
  }
327
- throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
328
584
  }
@@ -0,0 +1,13 @@
1
+ export const NATIVE_CHILD_CONTRACT_VERSION: "native-children@1";
2
+ export interface NativeChild {
3
+ session_id: string;
4
+ parent_session_id: string;
5
+ status: "running" | "unknown" | "completed" | "failed" | "cancelled" | "settled_by_root";
6
+ source: string;
7
+ provenance?: "root_child" | "parent_mismatch";
8
+ terminal_turn_id?: string;
9
+ terminal_observed_at?: string;
10
+ provider_error?: unknown;
11
+ native_turn_items?: { observed?: boolean; possible_effects?: boolean; pending?: boolean };
12
+ }
13
+ export function normalizeNativeChildren(receipt: Record<string, unknown>, rootSessionId: string): NativeChild[];
@@ -0,0 +1,78 @@
1
+ export const NATIVE_CHILD_CONTRACT_VERSION = "native-children@1";
2
+ import { errorRecord } from "./operation-errors.mjs";
3
+
4
+ function nativeFailure(code, message, exitCode, details) { return Object.assign(new Error(message), { code, exitCode, details }); }
5
+
6
+
7
+ /** Normalize provider-observed topology only; never parse assistant prose. */
8
+ export function normalizeNativeChildren(receipt, rootSessionId) {
9
+ const candidates = [];
10
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
11
+ receipt = object(receipt);
12
+ const list = (value) => Array.isArray(value) ? value : [];
13
+ const text = (value, keys) => { for (const key of keys) if (typeof value[key] === "string" && value[key]) return value[key]; return null; };
14
+ const settledTree = new Set(
15
+ list(receipt.tree_observations)
16
+ .filter(value => object(value).settled === true)
17
+ .map(value => text(object(value), ["provider_session_id", "session_id"]))
18
+ .filter(id => id !== null)
19
+ );
20
+ const add = (raw, source, scopedParent = null, fallback = "unknown") => {
21
+ const value = object(raw);
22
+ const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
23
+ const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
24
+ if (!id) return;
25
+ const outcomeError = object(value.outcome_error);
26
+ if (typeof outcomeError.code === "string") {
27
+ const record = errorRecord(outcomeError);
28
+ throw Object.assign(nativeFailure(record.code, record.message, 1, record.details), record, { details: { ...object(record.details), session_id: id, source } });
29
+ }
30
+ const state = String(value.status ?? value.state ?? fallback).toLowerCase();
31
+ if (state === "lost") throw nativeFailure("native_child_outcome_lost", "Native child ended without a recoverable outcome", 1, { session_id: id, parent_session_id: parent, source, native_status: state });
32
+ // Provider idle means no current activity, not a Work result. Only an
33
+ // explicit terminal result or a separately proven root settlement may be
34
+ // terminal here; unknown states stay unknown for reconciliation.
35
+ const status = ["completed", "complete", "done", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled", "interrupted"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
36
+ const rootChild = parent === rootSessionId;
37
+ const settled = rootChild && status === "unknown" && settledTree.has(id) ? "settled_by_root" : status;
38
+ candidates.push({ session_id: id, parent_session_id: parent ?? "", status: settled, source, ...(rootChild ? {} : { provenance: "parent_mismatch" }),
39
+ ...(text(value, ["terminal_turn_id"]) ? { terminal_turn_id: text(value, ["terminal_turn_id"]) } : {}),
40
+ ...(text(value, ["terminal_observed_at"]) ? { terminal_observed_at: text(value, ["terminal_observed_at"]) } : {}),
41
+ ...(value.provider_error !== undefined ? { provider_error: value.provider_error } : {}),
42
+ ...(value.native_turn_items && typeof value.native_turn_items === "object" ? { native_turn_items: value.native_turn_items } : {}) });
43
+ };
44
+ for (const value of list(receipt.descendants)) add(value, "adapter.descendants");
45
+ for (const value of list(receipt.children)) add(value, "opencode.session.children");
46
+ const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
47
+ for (const value of list(subagents.running)) add(value, "zcode/session/subagents", rootSessionId, "running");
48
+ for (const value of list(subagents.completed)) add(value, "zcode/session/subagents", rootSessionId, "completed");
49
+ for (const value of list(object(subagents.ended).items)) {
50
+ const status = String(object(value).status ?? "unknown");
51
+ if (!["success", "failed", "cancelled", "lost"].includes(status)) throw nativeFailure("native_child_outcome_unavailable", "ZCode ended child has no recognized native outcome", 1, { child: value });
52
+ add(value, "zcode/session/subagents", rootSessionId);
53
+ }
54
+ const unique = new Map();
55
+ const rank = { unknown: 0, running: 1, settled_by_root: 2, completed: 3, failed: 3, cancelled: 3 };
56
+ for (const child of candidates) {
57
+ const previous = unique.get(child.session_id);
58
+ if (previous && previous.parent_session_id !== child.parent_session_id) {
59
+ // Keep the contradictory parent visible to the existing provenance gate,
60
+ // independently of input order or which source happened to say success.
61
+ const foreign = [previous, child].filter(item => item.parent_session_id !== rootSessionId).sort((a, b) => a.parent_session_id.localeCompare(b.parent_session_id))[0];
62
+ unique.set(child.session_id, { ...foreign, provenance: "parent_mismatch" });
63
+ continue;
64
+ }
65
+ if (previous && rank[previous.status] === 3 && rank[child.status] === 3 && previous.status !== child.status) throw nativeFailure("native_child_outcome_conflict", "Native child has contradictory terminal outcomes", 1, { session_id: child.session_id, observations: [previous, child] });
66
+ if (previous && previous.status === child.status && previous.terminal_turn_id && child.terminal_turn_id && previous.terminal_turn_id !== child.terminal_turn_id)
67
+ throw nativeFailure("native_child_turn_conflict", "Native child has contradictory terminal Turn identities", 1, { session_id: child.session_id, observations: [previous, child] });
68
+ if (previous && previous.status === child.status) {
69
+ unique.set(child.session_id, { ...previous, ...child,
70
+ ...(child.terminal_turn_id ?? previous.terminal_turn_id ? { terminal_turn_id: child.terminal_turn_id ?? previous.terminal_turn_id } : {}),
71
+ ...(child.provider_error ?? previous.provider_error ? { provider_error: child.provider_error ?? previous.provider_error } : {}),
72
+ ...(child.native_turn_items ?? previous.native_turn_items ? { native_turn_items: child.native_turn_items ?? previous.native_turn_items } : {}) });
73
+ continue;
74
+ }
75
+ if (!previous || rank[child.status] > rank[previous.status]) unique.set(child.session_id, child);
76
+ }
77
+ return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));
78
+ }
@@ -1,3 +1,7 @@
1
1
  export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
2
  export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
3
3
  export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
4
+ export function acceptNativeHookRequest(stateDir: string, binding: Record<string, unknown>, current?: { requests: Map<string, { binding: Record<string, unknown> }>; accepting?: Map<string, Promise<Record<string, unknown>>> } | null): Promise<Record<string, unknown>>;
5
+ export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown, budget: ReturnType<typeof hookBudget>): Promise<void>;
6
+ export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void): Promise<void>;
7
+ export function runBoundedCommand(input: { bin: string; args: string[]; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;