@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
@@ -1,9 +1,12 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { AppError } from "../shared/errors.js";
3
+ import { validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
3
4
  export const recoveryObservationBudgetMs = 120_000;
4
5
  const activeObservation = new AsyncLocalStorage();
5
- export const withRecoveryObservation = (budget, action) => activeObservation.run(budget, action);
6
+ export const withRecoveryObservation = (budget, action, retainActive = false) => activeObservation.run(retainActive ? activeObservation.getStore() ?? budget : budget, action);
6
7
  export const recoveryRpcTimeout = (maximum) => {
8
+ if (maximum != null)
9
+ validateObservationDuration(maximum);
7
10
  const observation = activeObservation.getStore();
8
11
  // An unbounded productive transport wait is still bounded while recovery is
9
12
  // actively reconciling. Outside recovery, its owner policy decides when to
@@ -23,19 +26,32 @@ export function renewRecoveryObservation(snapshot, requestId) {
23
26
  throw new AppError("recovery_observation_invalid", "Retained reconciliation identities are malformed", 1);
24
27
  if (ids.includes(requestId))
25
28
  return snapshot;
26
- return { ...snapshot, recovery_observation: { budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
29
+ return { ...snapshot, recovery_observation: { policy_id: "settlement-inactivity@1", observer_started: false, budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, progress_markers: [], observation_gaps: 0, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
27
30
  }
28
31
  /**
29
- * Measures only time this owner can actually observe. Large scheduler gaps
30
- * reset the sample instead of turning host sleep into evidence about a remote
31
- * process. Persisting the remaining budget makes a recovered owner continue
32
- * reconciliation rather than silently receiving a new unlimited wait.
32
+ * New episodes measure silence between confirmed retirement facts; legacy
33
+ * receipts keep cumulative active-observer accounting. A gap never proves
34
+ * native death, and repeated gaps exhaust observation rather than grant time.
35
+ * Remaining time and seen proofs survive owner replacement.
33
36
  */
34
37
  export function recoveryObservationBudget(saved, monotonic = () => performance.now()) {
35
38
  const prior = saved && typeof saved === "object" ? saved.recovery_observation : null;
39
+ if (prior?.policy_id !== undefined && prior.policy_id !== "settlement-inactivity@1")
40
+ throw new AppError("recovery_observation_invalid", "Unknown retained observation policy", 1);
41
+ const sliding = !prior || prior.policy_id === "settlement-inactivity@1";
42
+ if (prior?.progress_markers !== undefined && (!Array.isArray(prior.progress_markers) || prior.progress_markers.some(marker => typeof marker !== "string"))
43
+ || prior?.observation_gaps !== undefined && (!Number.isSafeInteger(prior.observation_gaps) || prior.observation_gaps < 0)
44
+ || prior?.observer_started !== undefined && typeof prior.observer_started !== "boolean")
45
+ throw new AppError("recovery_observation_invalid", "Retained observation progress is malformed", 1);
46
+ const markers = new Set(prior?.progress_markers ?? []);
47
+ let gaps = prior?.observation_gaps ?? 0;
36
48
  let remaining = typeof prior?.remaining_ms === "number" && Number.isFinite(prior.remaining_ms)
37
49
  ? Math.max(0, Math.min(recoveryObservationBudgetMs, prior.remaining_ms))
38
50
  : prior == null ? recoveryObservationBudgetMs : 0;
51
+ // A restored observer cannot know what happened while it was absent. Retain
52
+ // one bounded uncertainty episode; repeated reconstruction never grants time.
53
+ if (sliding && prior && prior.observer_started !== false && ++gaps > 1)
54
+ remaining = 0;
39
55
  let last = monotonic();
40
56
  let pollIndex = Number.isSafeInteger(prior?.poll_index) && prior.poll_index >= 0 ? prior.poll_index : 0;
41
57
  const observation = {
@@ -47,10 +63,25 @@ export function recoveryObservationBudget(saved, monotonic = () => performance.n
47
63
  // or death and therefore does not consume the reconciliation budget.
48
64
  if (elapsed >= 0 && elapsed <= 60_000)
49
65
  remaining = Math.max(0, remaining - elapsed);
66
+ else if (sliding && ++gaps > 1)
67
+ remaining = 0;
50
68
  return remaining === 0;
51
69
  },
52
- state: () => ({ budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
70
+ observe: snapshot => {
71
+ observation.exhausted();
72
+ if (!sliding || remaining === 0)
73
+ return;
74
+ const proof = settlementProgressMarkers(snapshot);
75
+ const advanced = proof.some(marker => !markers.has(marker));
76
+ proof.forEach(marker => markers.add(marker));
77
+ if (advanced) {
78
+ remaining = recoveryObservationBudgetMs;
79
+ gaps = 0;
80
+ }
81
+ },
82
+ state: () => ({ ...(sliding ? { policy_id: "settlement-inactivity@1", observer_started: true, progress_markers: [...markers].sort(), observation_gaps: gaps } : {}), budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
53
83
  timeout: (maximum = 30_000) => {
84
+ validateObservationDuration(maximum);
54
85
  if (observation.exhausted())
55
86
  throw new AppError("recovery_observation_budget_exhausted", "Recovery observation budget exhausted; explicit reconciliation is required", 1);
56
87
  return Math.max(1, Math.min(maximum, Math.ceil(remaining)));
@@ -59,3 +90,32 @@ export function recoveryObservationBudget(saved, monotonic = () => performance.n
59
90
  };
60
91
  return observation;
61
92
  }
93
+ /** Only confirmed settlement facts advance recovery. Polls, errors, timestamps,
94
+ * and native model activity cannot replenish physical retirement observation. */
95
+ export function settlementProgressMarkers(snapshot) {
96
+ const result = [];
97
+ const visit = (value, prefix, depth) => {
98
+ if (!value || typeof value !== "object" || depth > 12)
99
+ return;
100
+ if (Array.isArray(value)) {
101
+ for (const item of value)
102
+ visit(item, prefix, depth + 1);
103
+ return;
104
+ }
105
+ const object = value;
106
+ const identity = ["operation_id", "process_id", "session_id", "run_id", "control_id", "scope_id", "id"].map(key => typeof object[key] === "string" ? `${key}:${object[key]}` : "").filter(Boolean).join("|");
107
+ const location = `${prefix}/${identity}`;
108
+ for (const key of ["physical_settled", "native_settled", "settled", "retired", "published", "committed"])
109
+ if (object[key] === true)
110
+ result.push(`${location}/${key}`);
111
+ if (["completed", "released", "sealed", "verified", "retired"].includes(String(object.status)))
112
+ result.push(`${location}/status:${String(object.status)}`);
113
+ if (object.state === "settled")
114
+ result.push(`${location}/state:settled`);
115
+ for (const [key, item] of Object.entries(object))
116
+ if (key !== "recovery_observation" && key !== "error" && key !== "metadata" && key !== "capture_error")
117
+ visit(item, `${location}/${key}`, depth + 1);
118
+ };
119
+ visit(snapshot, "", 0);
120
+ return [...new Set(result)].sort();
121
+ }
@@ -1,7 +1,7 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { AppError } from "../shared/errors.js";
4
+ import { AppError, errorRecord } from "../shared/errors.js";
5
5
  import { registerStoreWriter } from "../storage/writer-contract.js";
6
6
  import { managedProcessRuntimeHome } from "./managed-processes.js";
7
7
  import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
@@ -56,6 +56,9 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
56
56
  rules[table] = ownRun;
57
57
  for (const table of ["plan_bindings", "plan_progress", "worktree_records", "audit_events"])
58
58
  rules[table] = `${ownProject} AND protocol_id IN (${protocols})`;
59
+ // Relocation proofs are RUN-owned provenance needed to map unchanged context
60
+ // bytes through subsequent recovery generations, not portable live authority.
61
+ rules.audit_events = `${ownProject} AND (protocol_id IN (${protocols}) OR (event_type = 'flow_run.publication_relocation' AND json_extract(payload_json, '$.run_id') IN (SELECT run_id FROM capture_scope)))`;
59
62
  for (const table of ["run_controller_events", "run_controller_operations"])
60
63
  rules[table] = `controller_id IN (SELECT controller_id FROM run_controllers WHERE ${ownRun})`;
61
64
  for (const table of ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers"])
@@ -89,7 +92,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
89
92
  fs.rmSync(output, { force: true });
90
93
  }
91
94
  catch (error) {
92
- cleanup.push({ target: output, phase: "remove_incomplete_output", message: String(error) });
95
+ cleanup.push({ target: output, phase: "remove_incomplete_output", message: String(error), error: errorRecord(error) });
93
96
  }
94
97
  }
95
98
  if (failed || cleanup.length) {
@@ -98,7 +101,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
98
101
  primary.details.snapshot_cleanup_errors = cleanup;
99
102
  throw primary;
100
103
  }
101
- throw new AppError("snapshot_database_failed", primary instanceof Error ? primary.message : "Snapshot database cleanup failed", 1, { ...(failed ? { cause: String(primary) } : {}), snapshot_cleanup_errors: cleanup });
104
+ throw new AppError("snapshot_database_failed", primary instanceof Error ? primary.message : "Snapshot database cleanup failed", 1, { ...(failed ? { cause: errorRecord(primary) } : {}), snapshot_cleanup_errors: cleanup });
102
105
  }
103
106
  }
104
107
  /** Physical leases are evidence, not authority to resurrect on another host. */
@@ -136,7 +139,7 @@ export function readRecoveryResourceEvidence(resourceHome, projectId, runId, aut
136
139
  primary.details.snapshot_cleanup_errors = cleanup;
137
140
  throw primary;
138
141
  }
139
- throw new AppError("snapshot_resource_evidence_failed", primary instanceof Error ? primary.message : "Resource evidence cleanup failed", 1, { ...(failed ? { cause: String(primary) } : {}), snapshot_cleanup_errors: cleanup });
142
+ throw new AppError("snapshot_resource_evidence_failed", primary instanceof Error ? primary.message : "Resource evidence cleanup failed", 1, { ...(failed ? { cause: errorRecord(primary) } : {}), snapshot_cleanup_errors: cleanup });
140
143
  }
141
144
  return result;
142
145
  }
@@ -0,0 +1,23 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { assertPortableArtifactRef } from "./portable-refs.js";
6
+ /** A deferral names one project-local file, never an arbitrary path. */
7
+ export function reviewDefEvidence(workspaceRoot, defId) {
8
+ try {
9
+ if (!defId || !/^DEF-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(defId))
10
+ throw new Error("invalid DEF identifier");
11
+ const relative = `.memory-bank/defs/${defId}.md`;
12
+ const file = assertPortableArtifactRef(relative, { workspaceRoot, runHome: workspaceRoot, runId: "" });
13
+ const directory = fs.realpathSync(path.join(workspaceRoot, ".memory-bank", "defs"));
14
+ const root = fs.realpathSync(workspaceRoot);
15
+ const directoryRelative = path.relative(root, directory);
16
+ if (directoryRelative === ".." || directoryRelative.startsWith(`..${path.sep}`) || path.isAbsolute(directoryRelative) || path.dirname(file) !== directory || !fs.statSync(file).isFile())
17
+ throw new Error("DEF must be a regular file in the project defs directory");
18
+ return { def_id: defId, path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
19
+ }
20
+ catch (error) {
21
+ throw new AppError("deferral_invalid", "A deferral requires a readable project-local named DEF", 2, { def_id: defId ?? null, cause: error instanceof Error ? error.message : String(error) });
22
+ }
23
+ }
@@ -50,7 +50,7 @@ export function controlStatusReceipt(observation, request = {}) {
50
50
  requested_mode: request.action ?? (activeResume ? "resume" : text(control.requested_mode)),
51
51
  applied_mode: settled ? control.requested_mode : state === "running" ? "resume" : null,
52
52
  state, settled, nodes, unsettled_reasons: reasons,
53
- resumability: settled || state === "recovery_required" ? "recovery_required" : ["running", "not_requested", "terminal_noop", "superseded"].includes(state) ? "not_applicable" : "pending",
53
+ resumability: ["done", "failed", "cancelled"].includes(String(observation.run_status)) ? "not_applicable" : settled || state === "recovery_required" ? "recovery_required" : ["running", "not_requested", "terminal_noop", "superseded"].includes(state) ? "not_applicable" : "pending",
54
54
  recovery_id: text(control.recovery_id)
55
55
  };
56
56
  }
@@ -5,11 +5,12 @@ import os from "node:os";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { setTimeout as delay } from "node:timers/promises";
8
- import { AppError } from "../shared/errors.js";
8
+ import { AppError, errorRecord } from "../shared/errors.js";
9
9
  import { resolveProjectRoot } from "../storage/paths.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { getResourceDatabase } from "../storage/database.js";
12
- import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessRuntimeHome, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess, resourceHome } from "./managed-processes.js";
12
+ import { assertManagedProcessLease, confirmManagedProcess, finishManagedProcess, renewManagedProcess, managedProcessRuntimeHome, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess, resourceHome } from "./managed-processes.js";
13
+ import { managedLeaseMonitor } from "./managed-lease-monitor.js";
13
14
  import { interruptControlledSessions, observeControlledSessions, recordRunControlIntent, runControlStatus } from "./run-control.js";
14
15
  import { recoveryGuard, recordRecoveryCapture, sealRunRecovery } from "./run-recovery.js";
15
16
  import { createEvalRunSnapshot, verifyRecoverySnapshot } from "./eval-snapshots.js";
@@ -106,13 +107,13 @@ export async function waitForControlWorkerAdmission(context, controlId, ownerTok
106
107
  if (!worker || worker.owner_token !== ownerToken)
107
108
  return "admitted";
108
109
  // A running worker without a resource lease is still in the registration
109
- // gap; only a physically live retained child can remain pending at expiry.
110
+ // gap; a known child not proven dead can remain pending, not admitted.
110
111
  if (worker.process_id || ["failed", "completed", "superseded"].includes(worker.status))
111
112
  return "admitted";
112
113
  if (Date.now() >= deadline) {
113
- if (launched?.pid_started_at && processIsAlive(launched))
114
+ if (launched && processIsAlive(launched))
114
115
  return "pending";
115
- throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease and is not alive", 1, { control_id: controlId, timeout_ms: timeoutMs });
116
+ throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease and is not alive", 1, { control_id: controlId, timeout_ms: timeoutMs, launched });
116
117
  }
117
118
  await delay(25);
118
119
  }
@@ -128,8 +129,8 @@ function reclaimControlWorker(context, input, projectId, token, home) {
128
129
  return false;
129
130
  const saved = JSON.parse(previous.snapshot_json ?? "{}");
130
131
  const requested = input.reconcileId ? renewRecoveryObservation(saved, input.reconcileId) : saved;
131
- if (recoveryObservationBudget(requested).state().remaining_ms === 0)
132
- return false;
132
+ const observation = recoveryObservationBudget(requested);
133
+ const exhausted = observation.state().remaining_ms === 0;
133
134
  const registry = fs.existsSync(path.join(home, "runtime.sqlite")) ? getResourceDatabase(home) : null;
134
135
  let record = previous.process_id ? registry?.get("SELECT * FROM managed_processes WHERE id = ?", [previous.process_id]) : null;
135
136
  const operationId = launchOperationId(input.controlId, previous.owner_token);
@@ -180,17 +181,30 @@ function reclaimControlWorker(context, input, projectId, token, home) {
180
181
  }
181
182
  // The resource store and RUN store are separate: retirement is idempotent,
182
183
  // and a crash before the RUN CAS leaves the same dead owner reconcilable.
184
+ if (exhausted && previous.status === "failed" && saved.status === "recovery_blocked"
185
+ && saved.recovery_observation?.remaining_ms === 0)
186
+ return false;
183
187
  if (record && !finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: "control_owner_crash_reconciled" }))
184
188
  return false;
185
189
  const launcherBirth = processStartedAt(process.pid);
186
190
  return context.db.writeTransaction(() => {
187
- const changed = context.db.run(`UPDATE run_control_workers SET owner_token = ?, owner_pid = NULL, status = 'starting', process_id = NULL, process_lease_token = NULL, updated_at = ?
191
+ // An exhausted episode still needs an operator-visible receipt, but only
192
+ // after the same exact dead-owner proof and generation CAS as replacement.
193
+ const blockedError = { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget", ...(previous.error_json ? { cause: JSON.parse(previous.error_json) } : {}) };
194
+ const blocked = { ...saved, status: "recovery_blocked", settled: false,
195
+ pending_reasons: [...new Set([...(Array.isArray(saved.pending_reasons) ? saved.pending_reasons : []), "recovery_observation_budget_exhausted"])],
196
+ error: blockedError, recovery_observation: observation.state() };
197
+ const fields = exhausted ? "status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ?"
198
+ : "owner_token = ?, owner_pid = NULL, status = 'starting', process_id = NULL, process_lease_token = NULL, updated_at = ?";
199
+ const values = exhausted ? [JSON.stringify(blocked), previous.error_json ?? JSON.stringify(blockedError), context.now()] : [token, context.now()];
200
+ const changed = context.db.run(`UPDATE run_control_workers SET ${fields}
188
201
  WHERE control_id = ? AND owner_token = ? AND process_id IS ? AND owner_pid IS ? AND status = ?
202
+ AND snapshot_json IS ? AND error_json IS ?
189
203
  AND (SELECT receipt_json FROM run_control_operations WHERE operation_id = ?) IS ?
190
204
  AND EXISTS (SELECT 1 FROM run_controls c JOIN run_recovery_guards g ON g.recovery_id = c.recovery_id
191
205
  WHERE c.control_id = run_control_workers.control_id AND c.status = 'requested' AND g.status IN ('draining','sealed')
192
- AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [token, context.now(), input.controlId, previous.owner_token, previous.process_id, previous.owner_pid, previous.status, operationId, launch?.receipt_json ?? null]);
193
- if (changed.changes !== 1)
206
+ AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [...values, input.controlId, previous.owner_token, previous.process_id, previous.owner_pid, previous.status, previous.snapshot_json ?? null, previous.error_json, operationId, launch?.receipt_json ?? null]);
207
+ if (changed.changes !== 1 || exhausted)
194
208
  return false;
195
209
  if (input.reconcileId) {
196
210
  const snapshot = renewRecoveryObservation(JSON.parse(previous.snapshot_json ?? "{}"), input.reconcileId);
@@ -223,129 +237,174 @@ export async function serveRunControl(context, input) {
223
237
  context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [`${launchOperationId(input.controlId, worker.owner_token)}:worker-claim`, input.controlId, JSON.stringify({ kind: "control_worker_self_claim" }), JSON.stringify({ pid: process.pid, pid_started_at: workerBirth }), now, now]);
224
238
  });
225
239
  const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: worker.resource_home } };
226
- let leaseLost = false;
240
+ const lease = managedLeaseMonitor(attemptMs => renewManagedProcess({ ...resourceContext, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: worker.process_id, leaseToken: worker.process_lease_token }));
241
+ let timerFailure;
242
+ let primaryFailure;
243
+ const assertControlCurrent = () => {
244
+ const latest = recoveryGuard(context, project.id, input.runId);
245
+ if (latest?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(latest.status))
246
+ throw new AppError("run_recovery_generation_stale", "Control intent was superseded during renewal", 1);
247
+ };
248
+ const ownedContext = { ...context, assertControlOwner: () => {
249
+ lease.assertNoRenewal();
250
+ assertManagedProcessLease(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid });
251
+ assertControlCurrent();
252
+ } };
227
253
  const heartbeat = setInterval(() => {
228
- try {
229
- if (!heartbeatManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token }))
230
- leaseLost = true;
231
- }
232
- catch {
233
- leaseLost = true;
234
- }
254
+ if (!worker.process_id)
255
+ return;
256
+ lease.background(assertControlCurrent, error => { timerFailure = error; });
235
257
  }, 10_000);
236
258
  heartbeat.unref();
237
- try {
238
- if (!worker.process_id) {
239
- const log = path.join(worker.state_dir, "control.log");
240
- const record = registerManagedProcess(resourceContext, { kind: "run-control", ownerId: input.controlId, projectId: project.id, runId: input.runId, operationId: `control:${input.controlId}`, uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { control_id: input.controlId } });
241
- worker.process_id = record.id;
242
- worker.process_lease_token = record.lease_token;
243
- context.db.run("UPDATE run_control_workers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), input.controlId, worker.owner_token, process.pid]);
244
- }
245
- if (!worker.process_lease_token)
246
- throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
247
- confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
248
- let priorObservation = null;
259
+ let cleanupFailure;
260
+ const result = await (async () => {
249
261
  try {
250
- priorObservation = worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null;
251
- }
252
- catch { /* A malformed prior snapshot cannot grant a new budget. */
253
- priorObservation = { recovery_observation: { remaining_ms: 0 } };
254
- }
255
- const observation = recoveryObservationBudget(priorObservation);
256
- for (;;) {
257
- if (leaseLost)
258
- throw new AppError("run_control_lease_lost", "Control worker lost its physical ownership lease", 1);
259
- const current = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
260
- const guard = recoveryGuard(context, project.id, input.runId);
261
- if (current.status !== "requested" || guard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(guard.status)) {
262
- const status = current.status === "settled" ? "completed" : "superseded";
263
- context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
264
- return { ok: true, control_id: input.controlId, status };
262
+ if (!worker.process_id) {
263
+ const log = path.join(worker.state_dir, "control.log");
264
+ const record = registerManagedProcess(resourceContext, { kind: "run-control", ownerId: input.controlId, projectId: project.id, runId: input.runId, operationId: `control:${input.controlId}`, uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { control_id: input.controlId } });
265
+ worker.process_id = record.id;
266
+ worker.process_lease_token = record.lease_token;
267
+ context.db.run("UPDATE run_control_workers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), input.controlId, worker.owner_token, process.pid]);
265
268
  }
266
- if (observation.exhausted()) {
267
- const latest = context.db.get("SELECT snapshot_json FROM run_control_workers WHERE control_id = ?", [input.controlId]);
268
- let retained = {};
269
- try {
270
- retained = JSON.parse(latest?.snapshot_json ?? "{}") ?? {};
271
- }
272
- catch { /* Retain a blocked status even when the old projection is malformed. */ }
273
- const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
274
- error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
275
- context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
276
- return { ok: true, control_id: input.controlId, ...snapshot };
269
+ if (!worker.process_lease_token)
270
+ throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
271
+ confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
272
+ let priorObservation = null;
273
+ try {
274
+ priorObservation = worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null;
277
275
  }
278
- if (guard.status === "sealed") {
279
- // Capture retries are local evidence work. Never dispatch another
280
- // native interrupt/inspect merely because publication previously failed.
281
- const settlement = JSON.parse(guard.settlement_json ?? "{}");
282
- try {
283
- if (settlement.settled !== true)
284
- throw new AppError("run_control_settlement_missing", "Sealed control has no settled tree receipt", 1);
285
- const root = path.resolve(context.env.DD_FLOW_RECOVERY_HOME ?? path.join(os.homedir(), ".dd-flow-recovery"));
286
- const capture = guard.capture_path ?? path.join(root, crypto.createHash("sha256").update(context.ddFlowHome).digest("hex"), project.id, input.runId, guard.recovery_id);
287
- const scope = { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id };
288
- if (!fs.existsSync(capture))
289
- createEvalRunSnapshot(context, { ...scope, output: capture });
290
- if (!guard.capture_path)
291
- recordRecoveryCapture(context, { ...scope, capturePath: capture });
292
- else {
293
- const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
294
- verifyRecoverySnapshot(capture, { ...scope, workspaceRoot: run.workspace_root });
276
+ catch { /* A malformed prior snapshot cannot grant a new budget. */
277
+ priorObservation = { recovery_observation: { remaining_ms: 0 } };
278
+ }
279
+ const observation = recoveryObservationBudget(priorObservation);
280
+ context.db.run("UPDATE run_control_workers SET snapshot_json = json_set(COALESCE(snapshot_json, '{}'), '$.recovery_observation', json(?)), updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(observation.state()), context.now(), input.controlId, worker.owner_token]);
281
+ for (;;) {
282
+ if (timerFailure)
283
+ throw timerFailure;
284
+ await lease.ensureConfirmed(assertControlCurrent);
285
+ const current = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
286
+ const guard = recoveryGuard(context, project.id, input.runId);
287
+ if (current.status !== "requested" || guard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(guard.status)) {
288
+ const status = current.status === "settled" ? "completed" : "superseded";
289
+ context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
290
+ return { ok: true, control_id: input.controlId, status };
291
+ }
292
+ observation.observe({ control_id: input.controlId, status: guard.status, ...(guard.capture_path ? { published: true } : {}) });
293
+ if (observation.exhausted()) {
294
+ const final = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
295
+ const finalGuard = recoveryGuard(context, project.id, input.runId);
296
+ if (final.status !== "requested" || finalGuard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(finalGuard.status)) {
297
+ const status = final.status === "settled" ? "completed" : "superseded";
298
+ context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
299
+ return { ok: true, control_id: input.controlId, status };
300
+ }
301
+ const latest = context.db.get("SELECT snapshot_json FROM run_control_workers WHERE control_id = ?", [input.controlId]);
302
+ let retained = {};
303
+ try {
304
+ retained = JSON.parse(latest?.snapshot_json ?? "{}") ?? {};
295
305
  }
296
- const applied = context.db.run(`UPDATE run_controls SET status = 'settled', updated_at = ?
297
- WHERE control_id = ? AND status = 'requested' AND recovery_id = ?
298
- AND EXISTS (SELECT 1 FROM run_control_workers w WHERE w.control_id = run_controls.control_id AND w.owner_token = ? AND w.status = 'running')
299
- AND EXISTS (SELECT 1 FROM run_recovery_guards g WHERE g.recovery_id = run_controls.recovery_id AND g.status = 'sealed' AND g.capture_path = ? AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [context.now(), input.controlId, guard.recovery_id, worker.owner_token, capture]);
300
- if (applied.changes !== 1)
301
- throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
302
- const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
303
- context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
306
+ catch { /* Retain a blocked status even when the old projection is malformed. */ }
307
+ const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
308
+ error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
309
+ context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
304
310
  return { ok: true, control_id: input.controlId, ...snapshot };
305
311
  }
312
+ if (guard.status === "sealed") {
313
+ // Capture retries are local evidence work. Never dispatch another
314
+ // native interrupt/inspect merely because publication previously failed.
315
+ const settlement = JSON.parse(guard.settlement_json ?? "{}");
316
+ try {
317
+ if (settlement.settled !== true)
318
+ throw new AppError("run_control_settlement_missing", "Sealed control has no settled tree receipt", 1);
319
+ const root = path.resolve(context.env.DD_FLOW_RECOVERY_HOME ?? path.join(os.homedir(), ".dd-flow-recovery"));
320
+ const capture = guard.capture_path ?? path.join(root, crypto.createHash("sha256").update(context.ddFlowHome).digest("hex"), project.id, input.runId, guard.recovery_id);
321
+ const scope = { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id };
322
+ if (!fs.existsSync(capture))
323
+ createEvalRunSnapshot(context, { ...scope, output: capture });
324
+ await lease.ensureConfirmed(assertControlCurrent);
325
+ if (!guard.capture_path)
326
+ recordRecoveryCapture(context, { ...scope, capturePath: capture });
327
+ else {
328
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
329
+ verifyRecoverySnapshot(capture, { ...scope, workspaceRoot: run.workspace_root });
330
+ }
331
+ await lease.ensureConfirmed(assertControlCurrent);
332
+ const applied = context.db.run(`UPDATE run_controls SET status = 'settled', updated_at = ?
333
+ WHERE control_id = ? AND status = 'requested' AND recovery_id = ?
334
+ AND EXISTS (SELECT 1 FROM run_control_workers w WHERE w.control_id = run_controls.control_id AND w.owner_token = ? AND w.status = 'running')
335
+ AND EXISTS (SELECT 1 FROM run_recovery_guards g WHERE g.recovery_id = run_controls.recovery_id AND g.status = 'sealed' AND g.capture_path = ? AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [context.now(), input.controlId, guard.recovery_id, worker.owner_token, capture]);
336
+ if (applied.changes !== 1)
337
+ throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
338
+ const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
339
+ context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
340
+ return { ok: true, control_id: input.controlId, ...snapshot };
341
+ }
342
+ catch (error) {
343
+ observation.exhausted();
344
+ const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"], recovery_observation: observation.state() };
345
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
346
+ await delay(observation.nextDelay());
347
+ continue;
348
+ }
349
+ }
350
+ const request = JSON.parse(current.request_json);
351
+ const interrupt = current.action === "stop" || request.escalate === "interrupt" && Date.now() >= Date.parse(current.created_at) + (request.grace_ms ?? 30_000);
352
+ let snapshot;
353
+ try {
354
+ snapshot = await withRecoveryObservation(observation, async () => {
355
+ const cancellation = interrupt ? await interruptControlledSessions(ownedContext, input) : null;
356
+ await lease.ensureConfirmed(assertControlCurrent);
357
+ return { ...await observeControlledSessions(ownedContext, input), ...(cancellation ? { cancellation } : {}) };
358
+ });
359
+ }
306
360
  catch (error) {
307
- observation.exhausted();
308
- const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"], recovery_observation: observation.state() };
309
- context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
310
- await delay(observation.nextDelay());
311
- continue;
361
+ snapshot = { ok: true, control_id: input.controlId, status: "requested", settled: false, pending_reasons: ["control_observation_failed"], error: failure(error) };
362
+ }
363
+ await lease.ensureConfirmed(assertControlCurrent);
364
+ observation.observe(snapshot);
365
+ snapshot = { ...snapshot, recovery_observation: observation.state() };
366
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
367
+ if (snapshot.settled === true) {
368
+ try {
369
+ sealRunRecovery(context, { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id, settlement: JSON.stringify(snapshot) });
370
+ continue;
371
+ }
372
+ catch (error) {
373
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify({ ...snapshot, pending_reasons: ["recovery_seal_required"], error: failure(error) }), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
374
+ }
312
375
  }
376
+ await delay(observation.nextDelay());
313
377
  }
314
- const request = JSON.parse(current.request_json);
315
- const interrupt = current.action === "stop" || request.escalate === "interrupt" && Date.now() >= Date.parse(current.created_at) + (request.grace_ms ?? 30_000);
316
- let snapshot;
378
+ }
379
+ catch (error) {
380
+ primaryFailure = error;
317
381
  try {
318
- snapshot = await withRecoveryObservation(observation, async () => {
319
- const cancellation = interrupt ? await interruptControlledSessions(context, input) : null;
320
- return { ...await observeControlledSessions(context, input), ...(cancellation ? { cancellation } : {}) };
321
- });
382
+ context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
322
383
  }
323
- catch (error) {
324
- snapshot = { ok: true, control_id: input.controlId, status: "requested", settled: false, pending_reasons: ["control_observation_failed"], error: failure(error) };
384
+ catch (secondary) {
385
+ process.stderr.write(`${JSON.stringify({ primary: failure(error), phase: "persist_control_failure", secondary: failure(secondary) })}\n`);
325
386
  }
326
- observation.exhausted();
327
- snapshot = { ...snapshot, recovery_observation: observation.state() };
328
- context.db.run("UPDATE run_control_workers SET snapshot_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
329
- if (snapshot.settled === true) {
330
- try {
331
- sealRunRecovery(context, { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id, settlement: JSON.stringify(snapshot) });
332
- continue;
333
- }
334
- catch (error) {
335
- context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify({ ...snapshot, pending_reasons: ["recovery_seal_required"], error: failure(error) }), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
336
- }
387
+ throw error;
388
+ }
389
+ finally {
390
+ lease.close();
391
+ clearInterval(heartbeat);
392
+ try {
393
+ if (worker.process_id && worker.process_lease_token)
394
+ finishManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, state: "stopped", reason: "control_owner_exited" });
395
+ }
396
+ catch (secondary) {
397
+ cleanupFailure = secondary;
398
+ if (primaryFailure)
399
+ process.stderr.write(`${JSON.stringify({ primary: failure(primaryFailure), phase: "finish_control_owner", secondary: failure(secondary) })}\n`);
337
400
  }
338
- await delay(observation.nextDelay());
339
401
  }
340
- }
341
- catch (error) {
342
- context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
343
- throw error;
344
- }
345
- finally {
346
- clearInterval(heartbeat);
347
- if (worker.process_id && worker.process_lease_token)
348
- finishManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, state: "stopped", reason: "control_owner_exited" });
349
- }
402
+ })();
403
+ if (cleanupFailure !== undefined)
404
+ throw cleanupFailure;
405
+ return result;
406
+ }
407
+ function failure(error) {
408
+ const record = errorRecord(error);
409
+ return { ...record, code: record.code === "unexpected" ? "run_control_failed" : record.code };
350
410
  }
351
- function failure(error) { return { code: error instanceof AppError ? error.code : "run_control_failed", message: error instanceof Error ? error.message : String(error) }; }