@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80

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 (136) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +300 -0
  5. package/dist/cli/help.js +81 -9
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1342 -375
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/config.js +11 -8
  63. package/dist/services/controller-fanout.js +116 -0
  64. package/dist/services/dashboard.js +103 -31
  65. package/dist/services/engines.js +121 -76
  66. package/dist/services/eval-snapshots.js +750 -73
  67. package/dist/services/execution-policy.js +170 -0
  68. package/dist/services/external-work-launch.js +110 -0
  69. package/dist/services/harness-adapter.js +147 -24
  70. package/dist/services/harness-config.js +2 -0
  71. package/dist/services/hooks.js +553 -285
  72. package/dist/services/lanes.js +61 -53
  73. package/dist/services/lifecycle-command.js +87 -7
  74. package/dist/services/lifecycle-invocations.js +828 -0
  75. package/dist/services/managed-daemon-binding.js +38 -0
  76. package/dist/services/managed-processes.js +283 -27
  77. package/dist/services/merge-queue.js +179 -104
  78. package/dist/services/merge-server.js +73 -28
  79. package/dist/services/migrations.js +14 -9
  80. package/dist/services/native-daemon-history.js +49 -0
  81. package/dist/services/native-session-control.js +39 -0
  82. package/dist/services/plan-runtime.js +19 -8
  83. package/dist/services/plans.js +28 -25
  84. package/dist/services/projects.js +18 -5
  85. package/dist/services/prompts.js +23 -16
  86. package/dist/services/protocols.js +64 -20
  87. package/dist/services/recovery-observation-budget.js +61 -0
  88. package/dist/services/recovery-snapshot-database.js +107 -0
  89. package/dist/services/run-control-receipt.js +56 -0
  90. package/dist/services/run-control-worker.js +363 -0
  91. package/dist/services/run-control.js +876 -0
  92. package/dist/services/run-controller-adapter.js +222 -0
  93. package/dist/services/run-controller-capture.js +134 -0
  94. package/dist/services/run-controller-process.js +189 -0
  95. package/dist/services/run-controller-recovery.js +222 -0
  96. package/dist/services/run-controller-state.js +36 -0
  97. package/dist/services/run-controller.js +779 -0
  98. package/dist/services/run-engine-bindings.js +1 -1
  99. package/dist/services/run-fork.js +138 -0
  100. package/dist/services/run-observations.js +112 -0
  101. package/dist/services/run-recovery-runtime.js +69 -0
  102. package/dist/services/run-recovery.js +342 -0
  103. package/dist/services/runs.js +368 -74
  104. package/dist/services/runtime-budget.js +434 -0
  105. package/dist/services/runtime-scope-capture.js +50 -0
  106. package/dist/services/runtime-scope-control.js +450 -0
  107. package/dist/services/runtime-scope-resume.js +565 -0
  108. package/dist/services/runtime-scope-stop.js +99 -0
  109. package/dist/services/runtime-scope-worker.js +243 -0
  110. package/dist/services/runtime-service.js +97 -0
  111. package/dist/services/schema-validation.js +10 -8
  112. package/dist/services/sessions.js +35 -69
  113. package/dist/services/stage-blocker.js +17 -7
  114. package/dist/services/stage-context.js +44 -18
  115. package/dist/services/stage-lifecycle.js +99 -102
  116. package/dist/services/stage-pause.js +65 -23
  117. package/dist/services/usage.js +37 -93
  118. package/dist/services/vnext-code-review.js +189 -85
  119. package/dist/services/vnext-code.js +257 -243
  120. package/dist/services/vnext-execution-profile.js +5 -3
  121. package/dist/services/vnext-fanout.js +134 -19
  122. package/dist/services/vnext-merge.js +285 -102
  123. package/dist/services/vnext-plan-review.js +148 -94
  124. package/dist/services/vnext-plan.js +141 -72
  125. package/dist/services/vnext-protocolize.js +129 -56
  126. package/dist/services/vnext-specify.js +87 -64
  127. package/dist/services/work-registry.js +707 -156
  128. package/dist/services/workspace-bootstrap.js +76 -0
  129. package/dist/services/worktrees.js +85 -35
  130. package/dist/shared/errors.js +12 -0
  131. package/dist/storage/database.js +444 -32
  132. package/dist/storage/paths.js +17 -4
  133. package/dist/storage/writer-contract.js +77 -0
  134. package/dist/storage/writer-migration.js +102 -0
  135. package/package.json +11 -4
  136. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,876 @@
1
+ import { setFlowRunRecoveryPaused } from "./runs.js";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import { ensureRunControlStorage } from "../storage/database.js";
5
+ import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
6
+ import { AppError } from "../shared/errors.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { recoveryGuard } from "./run-recovery.js";
9
+ import path from "node:path";
10
+ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
11
+ import { runtimeHarness } from "./execution-policy.js";
12
+ import { harnessKey } from "./harness-config.js";
13
+ import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
14
+ import { reconcileUnfinishedChecks } from "./code-checks.js";
15
+ import { controlStatusReceipt } from "./run-control-receipt.js";
16
+ import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
17
+ /** Durable projection only: status never probes a provider or acquires an owner. */
18
+ export function runControlStatus(context, input) {
19
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
20
+ // One read snapshot prevents a concurrent stop/resume from mixing generations
21
+ // across the request, arbiter and worker projections. Savepoints also nest.
22
+ context.db.exec("SAVEPOINT run_control_status");
23
+ try {
24
+ return readControlStatus(context, input, project);
25
+ }
26
+ finally {
27
+ context.db.exec("RELEASE run_control_status");
28
+ }
29
+ }
30
+ function readControlStatus(context, input, project) {
31
+ const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
32
+ if (!run)
33
+ throw new AppError("not_found", "RUN is not registered", 1);
34
+ const scope = { project_id: project.id, run_id: input.runId };
35
+ const empty = controlStatusReceipt({ ok: true, scope, run_status: run.status, control: null, operations: [], worker: null, resume_operation: null }, input);
36
+ if (input.controlId === null)
37
+ return empty;
38
+ if (!context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'"))
39
+ return empty;
40
+ const row = context.db.get("SELECT c.*, g.generation, g.status AS guard_status, g.settlement_json, g.capture_path FROM run_controls c JOIN run_recovery_guards g ON g.recovery_id = c.recovery_id WHERE c.project_id = ? AND c.run_id = ? AND (? IS NULL OR c.control_id = ?) ORDER BY g.generation DESC LIMIT 1", [project.id, input.runId, input.controlId ?? null, input.controlId ?? null]);
41
+ if (!row)
42
+ return empty;
43
+ const current = recoveryGuard(context, project.id, input.runId);
44
+ const operations = context.db.all("SELECT operation_id, target_json, status, receipt_json, error_json, updated_at FROM run_control_operations WHERE control_id = ? ORDER BY created_at, operation_id", [row.control_id]);
45
+ const worker = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_control_workers'")
46
+ ? context.db.get("SELECT status, process_id, owner_pid, snapshot_json, error_json, updated_at FROM run_control_workers WHERE control_id = ?", [row.control_id]) : undefined;
47
+ const resume = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controller_operations'")
48
+ ? context.db.get("SELECT o.operation_id, o.controller_id, json_extract(o.input_json, '$.request_id') AS request_id, o.status, o.receipt_json, o.error_json, o.updated_at FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.action') = 'control_resume' AND json_extract(o.input_json, '$.control_id') = ? ORDER BY o.created_at DESC, o.rowid DESC LIMIT 1", [project.id, input.runId, row.control_id]) : undefined;
49
+ return controlStatusReceipt({
50
+ ...empty,
51
+ control: {
52
+ control_id: row.control_id, request_id: row.request_id, recovery_id: row.recovery_id,
53
+ generation: row.generation, requested_mode: row.action, status: row.status,
54
+ current: current?.recovery_id === row.recovery_id,
55
+ admission: row.guard_status,
56
+ request: JSON.parse(row.request_json),
57
+ // A completed interrupt operation is not evidence of whole-tree quiescence.
58
+ settlement: row.settlement_json ? JSON.parse(row.settlement_json) : null,
59
+ capture_path: row.capture_path,
60
+ created_at: row.created_at, updated_at: row.updated_at
61
+ },
62
+ operations: operations.map(operation => ({ operation_id: operation.operation_id, target: JSON.parse(operation.target_json), status: operation.status, receipt: operation.receipt_json ? JSON.parse(operation.receipt_json) : null, error: operation.error_json ? JSON.parse(operation.error_json) : null, updated_at: operation.updated_at })),
63
+ worker: worker ? { status: worker.status, process_id: worker.process_id, owner_pid: worker.owner_pid, snapshot: worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null, error: worker.error_json ? JSON.parse(worker.error_json) : null, updated_at: worker.updated_at } : null,
64
+ resume_operation: resume ? { operation_id: resume.operation_id, controller_id: resume.controller_id, request_id: resume.request_id, status: resume.status, receipt: resume.receipt_json ? JSON.parse(resume.receipt_json) : null, error: resume.error_json ? JSON.parse(resume.error_json) : null, updated_at: resume.updated_at } : null
65
+ }, input);
66
+ }
67
+ /** Interrupt retained managed roots independently; a reply does not prove tree settlement. */
68
+ export async function interruptControlledSessions(context, input) {
69
+ return await reconcileOwnedSessions(context, input, true);
70
+ }
71
+ export async function observeControlledSessions(context, input) {
72
+ return await reconcileOwnedSessions(context, input, false);
73
+ }
74
+ /** The capturing owner is the sole live controller exempted from settlement.
75
+ * Its caller retains the verified inventory until immutable publication. */
76
+ export async function observeControllerBoundary(context, input) {
77
+ return await reconcileOwnedSessions(context, input, false, input);
78
+ }
79
+ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
80
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
81
+ const control = input.controlId ? context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND control_id = ?", [project.id, input.runId, input.controlId]) : undefined;
82
+ const request = control && JSON.parse(control.request_json);
83
+ const pauseEscalated = control?.action === "pause" && request?.escalate === "interrupt" && Number.isSafeInteger(request.grace_ms) && Date.now() >= Date.parse(control.created_at) + request.grace_ms;
84
+ if (boundary ? interrupt || control : !control || interrupt && !(control.action === "stop" || pauseEscalated) || control.status !== "requested")
85
+ throw new AppError("run_control_invalid_state", "Interrupt requires the current stop intent or its explicitly elapsed pause escalation", 1);
86
+ const observationId = boundary?.operationId ?? control.control_id;
87
+ const assertCurrent = () => {
88
+ if (boundary) {
89
+ boundary.assertCurrent();
90
+ return;
91
+ }
92
+ const guard = recoveryGuard(context, project.id, input.runId);
93
+ if (guard?.recovery_id !== control.recovery_id || guard.status !== "draining")
94
+ throw new AppError("run_control_superseded", "Stop intent no longer owns this RUN", 1);
95
+ };
96
+ assertCurrent();
97
+ const databaseVersion = () => JSON.stringify([context.db.get("PRAGMA data_version"), context.db.get("SELECT total_changes() AS changes")]);
98
+ const initialDatabaseVersion = databaseVersion();
99
+ const readBindings = () => context.db.all("SELECT project_id, session_id, provider_session_id, harness, parent_session_id, provider_parent_session_id, run_id, status, stop_reason, metadata_json FROM sessions");
100
+ const foreign = (binding) => binding.project_id !== project.id || Boolean(binding.run_id && binding.run_id !== input.runId);
101
+ let bindings = readBindings();
102
+ const underRoot = (binding, root, harness) => {
103
+ const seen = new Set();
104
+ let current = binding;
105
+ while (current && !seen.has(current.session_id)) {
106
+ if (harness && current.harness !== harness)
107
+ return false;
108
+ if (current.provider_session_id === root)
109
+ return true;
110
+ seen.add(current.session_id);
111
+ const parent = current.parent_session_id, nativeParent = current.provider_parent_session_id, currentHarness = current.harness, currentProject = current.project_id;
112
+ if (nativeParent === root)
113
+ return true;
114
+ current = bindings.find(candidate => candidate.project_id === currentProject && candidate.harness === currentHarness && (parent ? candidate.session_id === parent : nativeParent && candidate.provider_session_id === nativeParent));
115
+ }
116
+ return false;
117
+ };
118
+ const controllers = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")
119
+ ? context.db.all("SELECT controller_id, state_json, manifest_json, process_id, process_lease_token FROM run_controllers WHERE project_id = ? AND run_id = ?", [project.id, input.runId]) : [];
120
+ const targets = new Map();
121
+ const unresolved = [];
122
+ const conflicts = new Set();
123
+ const directoryAdapters = new Map();
124
+ const directoryHarnesses = new Map();
125
+ const resourceHomes = new Set([resourceHome(context)]);
126
+ const addTarget = (session, executable, harness = null) => {
127
+ if (session.owner.project_id !== project.id || session.owner.run_id !== input.runId || session.owner.state_dir !== session.state_dir || !path.isAbsolute(session.state_dir))
128
+ throw new AppError("controller_owner_mismatch", "Stored Session does not belong to the controlled RUN", 1);
129
+ if (!executable || !path.isAbsolute(executable))
130
+ throw new AppError("harness_runtime_missing", "Controlled Session has no frozen adapter executable", 1);
131
+ const key = JSON.stringify([session.state_dir, session.id]);
132
+ if (conflicts.has(session.state_dir) || directoryAdapters.has(session.state_dir) && directoryAdapters.get(session.state_dir) !== executable || harness && directoryHarnesses.has(session.state_dir) && directoryHarnesses.get(session.state_dir) !== harness) {
133
+ for (const [targetKey, target] of targets)
134
+ if (target.session.state_dir === session.state_dir)
135
+ targets.delete(targetKey);
136
+ conflicts.add(session.state_dir);
137
+ throw new AppError("controller_target_conflict", "Stored Session has conflicting adapter identities", 1);
138
+ }
139
+ directoryAdapters.set(session.state_dir, executable);
140
+ if (harness)
141
+ directoryHarnesses.set(session.state_dir, harness);
142
+ if (session.owner.resource_home && path.isAbsolute(session.owner.resource_home))
143
+ resourceHomes.add(session.owner.resource_home);
144
+ targets.set(key, { session, executable, harness: harness ?? directoryHarnesses.get(session.state_dir) ?? null });
145
+ };
146
+ for (const row of controllers) {
147
+ let state, manifest;
148
+ try {
149
+ state = JSON.parse(row.state_json);
150
+ manifest = JSON.parse(row.manifest_json);
151
+ if (!Array.isArray(state?.sessions) || !manifest?.adapters || typeof manifest.adapters !== "object")
152
+ throw new Error("Invalid retained inventory");
153
+ if (manifest.resource_home !== undefined) {
154
+ if (!path.isAbsolute(manifest.resource_home))
155
+ throw new Error("Invalid controller resource home");
156
+ resourceHomes.add(manifest.resource_home);
157
+ }
158
+ }
159
+ catch {
160
+ unresolved.push({ controller_id: row.controller_id, status: "pending", reason: "controller_inventory_invalid" });
161
+ continue;
162
+ }
163
+ for (const session of state.sessions) {
164
+ try {
165
+ const executable = manifest.adapters[runtimeHarness(session.profile)]?.executable;
166
+ if (!session.id) {
167
+ const create = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.state_dir') = ? AND json_extract(input_json, '$.args[0]') = 'session' AND json_extract(input_json, '$.args[1]') = 'create' ORDER BY created_at DESC LIMIT 1", [row.controller_id, session.state_dir]);
168
+ if (create)
169
+ session.create_operation_id = create.operation_id;
170
+ }
171
+ addTarget(session, executable, runtimeHarness(session.profile));
172
+ }
173
+ catch (error) {
174
+ unresolved.push({ controller_id: row.controller_id, session_id: typeof session?.id === "string" ? session.id : null, status: "pending", reason: error instanceof AppError ? error.code : "controller_inventory_invalid" });
175
+ }
176
+ }
177
+ }
178
+ const launches = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'work_launches'")
179
+ ? context.db.all("SELECT l.* FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE w.project_id = ? AND w.run_id = ?", [project.id, input.runId]) : [];
180
+ for (const launch of launches) {
181
+ try {
182
+ const profile = JSON.parse(launch.profile_json);
183
+ const binding = profile.runtime_adapter;
184
+ if (!binding)
185
+ throw new AppError("harness_runtime_missing", "External launch has no frozen adapter binding", 1);
186
+ if (binding.owner.work_id !== launch.work_id || binding.owner.operation_id !== launch.operation_id)
187
+ throw new AppError("controller_owner_mismatch", "External launch owner does not match its retained operation", 1);
188
+ addTarget({ id: launch.provider_session_id ?? "", state_dir: launch.state_dir, create_operation_id: `${launch.operation_id}:session.create`, owner: binding.owner }, binding.executable, runtimeHarness(profile.worker));
189
+ }
190
+ catch (error) {
191
+ unresolved.push({ work_id: launch.work_id, operation_id: launch.operation_id, status: "pending", reason: error instanceof AppError ? error.code : "external_inventory_invalid" });
192
+ }
193
+ }
194
+ const merges = context.db.all("SELECT merge_request_id, executor_work_id, dispatch_owner, dispatch_lease_token, adapter_receipt_json FROM merge_requests WHERE project_id = ? AND run_id = ? AND (dispatch_owner IS NOT NULL OR adapter_receipt_json IS NOT NULL)", [project.id, input.runId]);
195
+ for (const merge of merges) {
196
+ try {
197
+ if (!merge.adapter_receipt_json)
198
+ throw new AppError("merge_inventory_missing", "Claimed MERGE has no retained launch receipt", 1);
199
+ const receipt = JSON.parse(merge.adapter_receipt_json);
200
+ if (receipt.controller_id) {
201
+ const controller = controllers.find(row => row.controller_id === receipt.controller_id && row.controller_id === merge.dispatch_owner);
202
+ const state = controller && JSON.parse(controller.state_json);
203
+ if (!state || !state.sessions.some(session => session.id === receipt.session_id && session.state_dir === receipt.state_dir))
204
+ throw new AppError("controller_owner_mismatch", "MERGE receipt has no matching managed Session", 1);
205
+ // The controller inventory already validates and controls this physical root.
206
+ continue;
207
+ }
208
+ if (!receipt.owner)
209
+ throw new AppError("harness_runtime_missing", "MERGE launch has no frozen owner binding", 1);
210
+ if (receipt.owner.role !== "merge" || receipt.owner.work_id !== merge.executor_work_id || !merge.dispatch_lease_token || receipt.owner.operation_id !== `${merge.merge_request_id}:${merge.dispatch_lease_token}`)
211
+ throw new AppError("controller_owner_mismatch", "MERGE owner does not match its retained dispatch", 1);
212
+ addTarget({ id: receipt.session_id ?? "", state_dir: receipt.state_dir, create_operation_id: `${receipt.owner.operation_id}:session.create`, owner: receipt.owner }, receipt.executable, runtimeHarness(receipt.profile));
213
+ }
214
+ catch (error) {
215
+ unresolved.push({ merge_request_id: merge.merge_request_id, status: "pending", reason: error instanceof AppError ? error.code : "merge_inventory_invalid" });
216
+ }
217
+ }
218
+ const processTargets = [];
219
+ const registeredProcesses = [];
220
+ const daemonInventories = new Map();
221
+ const unsafeDaemons = new Set();
222
+ for (const home of resourceHomes) {
223
+ let processes;
224
+ try {
225
+ const all = managedProcessStatus({ ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: home } });
226
+ const homeMatches = (record) => { const ownHome = managedProcessRuntimeHome(record, context); return ownHome === null || ownHome === context.ddFlowHome; };
227
+ for (const record of all.filter(record => record.project_id !== project.id || record.run_id !== input.runId || !homeMatches(record))) {
228
+ try {
229
+ const directory = JSON.parse(record.metadata_json).state_dir;
230
+ if (typeof directory === "string")
231
+ unsafeDaemons.add(canonicalPath(directory, false));
232
+ }
233
+ catch { /* No directory attribution is available. */ }
234
+ }
235
+ processes = all.filter(record => record.project_id === project.id && record.run_id === input.runId && homeMatches(record));
236
+ }
237
+ catch {
238
+ unresolved.push({ resource_home: home, status: "pending", reason: "resource_inventory_unavailable" });
239
+ continue;
240
+ }
241
+ for (const record of processes) {
242
+ registeredProcesses.push({ home, record });
243
+ if (managedProcessRuntimeHome(record, context) !== context.ddFlowHome) {
244
+ unresolved.push({ process_id: record.id, status: "pending", reason: "managed_process_runtime_scope_unknown" });
245
+ continue;
246
+ }
247
+ if (["run-controller", "run-control"].includes(record.kind))
248
+ continue;
249
+ try {
250
+ const metadata = JSON.parse(record.metadata_json);
251
+ if (!metadata.state_dir) {
252
+ // Retain explicit physical evidence for exited resources too; omitting
253
+ // them would make the sealed inventory impossible to account for.
254
+ processTargets.push({ home, record });
255
+ continue;
256
+ }
257
+ if (!path.isAbsolute(metadata.state_dir))
258
+ throw new AppError("controller_owner_mismatch", "Registered state directory is not absolute", 1);
259
+ const executable = metadata.adapter_executable ?? directoryAdapters.get(metadata.state_dir);
260
+ if (!executable)
261
+ throw new AppError("harness_runtime_missing", "Registered runtime has no frozen adapter executable", 1);
262
+ const state = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
263
+ const owner = state.config?.runtime_owner;
264
+ if (!owner || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.project_id !== project.id || owner.run_id !== input.runId || owner.state_dir !== metadata.state_dir || (owner.work_id ?? null) !== record.work_id || !owner.operation_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || owner.dd_flow_home !== context.ddFlowHome || owner.resource_home !== home || owner.adapter_executable !== undefined && owner.adapter_executable !== executable)
265
+ throw new AppError("controller_owner_mismatch", "Retained daemon owner differs from its registered runtime", 1);
266
+ if (!Array.isArray(state.sessions))
267
+ throw new AppError("daemon_inventory_invalid", "Retained daemon has no Session inventory", 1);
268
+ const inventory = daemonInventories.get(metadata.state_dir);
269
+ if (inventory)
270
+ inventory.records.push(record);
271
+ else
272
+ daemonInventories.set(metadata.state_dir, { state, records: [record] });
273
+ // All bindings are checked; the target map deduplicates shared-daemon Sessions.
274
+ for (const item of state.sessions) {
275
+ const id = typeof item === "string" && item.trim() ? item : adapterSessionId(item);
276
+ if (!id)
277
+ throw new AppError("daemon_inventory_invalid", "Retained daemon Session identity is invalid", 1);
278
+ const alias = typeof state.schema_id === "string" ? /^dd-([a-z]+)\/daemon-state@/.exec(state.schema_id)?.[1] : null;
279
+ addTarget({ id, state_dir: metadata.state_dir, owner }, executable, alias ? harnessKey(alias) : null);
280
+ }
281
+ }
282
+ catch (error) {
283
+ try {
284
+ const directory = JSON.parse(record.metadata_json).state_dir;
285
+ if (typeof directory === "string")
286
+ unsafeDaemons.add(canonicalPath(directory, false));
287
+ }
288
+ catch { /* The invalid record remains unresolved below. */ }
289
+ unresolved.push({ process_id: record.id, status: "pending", reason: error instanceof AppError ? error.code : "daemon_inventory_unavailable" });
290
+ }
291
+ }
292
+ }
293
+ const closedDaemons = new Map();
294
+ const nativeVersions = new Map();
295
+ for (const [directory, { state, records }] of daemonInventories) {
296
+ if (!interrupt) {
297
+ try {
298
+ const nativeOperations = retainedDaemonOperations(directory);
299
+ nativeVersions.set(directory, nativeInventoryVersion(directory, nativeOperations));
300
+ for (const operation of nativeOperations)
301
+ if (!["completed", "failed", "interrupted"].includes(String(operation.state)))
302
+ unresolved.push({ state_dir: directory, operation_id: operation.operation_id, status: "pending", reason: "native_operation_unsettled" });
303
+ if (state.shutdown_state !== "clean" && !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token && JSON.parse(record.metadata_json).dispatch_barrier === "run-generation@1"))
304
+ unresolved.push({ state_dir: directory, status: "pending", reason: "native_dispatch_barrier_unverified" });
305
+ }
306
+ catch {
307
+ unresolved.push({ state_dir: directory, status: "pending", reason: "daemon_operation_inventory_invalid" });
308
+ }
309
+ }
310
+ if (state.shutdown_state !== "clean") {
311
+ if (state.shutdown_state === "unclean")
312
+ unresolved.push({ state_dir: directory, status: "pending", reason: "native_shutdown_unclean" });
313
+ if (!state.sessions.length)
314
+ unresolved.push({ state_dir: directory, status: "pending", reason: "daemon_sessions_unknown" });
315
+ continue;
316
+ }
317
+ try {
318
+ const daemonId = state.daemon_id ?? state.config?.daemonId;
319
+ if (!daemonId || state.active_tree === true || state.active_operation || !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.lease_token === state.resource_process.lease_token))
320
+ throw new AppError("shutdown_identity_unknown", "Closed daemon has no exact retained physical owner", 1);
321
+ if (records.some(record => !record.pid || !record.pid_started_at || processTreeIsAlive(record)))
322
+ throw new AppError("shutdown_process_unsettled", "Closed daemon still has an unconfirmed or live owned process", 1);
323
+ const receipts = retainedDaemonOperations(directory);
324
+ // A whole-RUN stop is also the recovery fence for a create whose reply
325
+ // was lost after native allocation. Keep that one outstanding operation
326
+ // observable until terminalization below; unrelated unfinished work is
327
+ // still a shutdown proof failure.
328
+ const unfinished = receipts.filter(receipt => !["completed", "failed", "interrupted"].includes(String(receipt.state)));
329
+ if (unfinished.some(receipt => receipt.daemon_id !== daemonId || String(receipt.operation) !== "session.create" || receipt.session_id !== null))
330
+ throw new AppError("shutdown_operation_unsettled", "Closed daemon retains an unfinished non-create operation", 1);
331
+ const stopped = receipts.find(receipt => receipt.daemon_id === daemonId && receipt.operation === "daemon.stop" && receipt.state === "completed" && receipt.result?.stopped === true && receipt.result?.clean === true);
332
+ if (!stopped)
333
+ throw new AppError("shutdown_receipt_required", "Closed daemon has no clean native shutdown receipt for its current identity", 1);
334
+ closedDaemons.set(directory, { source: "retained_clean_shutdown", daemon_id: daemonId, operation_id: stopped.operation_id, session_ids: state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item)), stopped: true, settled: true, processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })), result: stopped.result });
335
+ }
336
+ catch (error) {
337
+ unresolved.push({ state_dir: directory, status: "pending", reason: error instanceof AppError ? error.code : "shutdown_receipt_invalid" });
338
+ }
339
+ }
340
+ for (const [key, target] of targets) {
341
+ if (target.session.id && bindings.some(binding => foreign(binding) && underRoot(binding, target.session.id, target.harness))) {
342
+ targets.delete(key);
343
+ unresolved.push({ session_id: target.session.id, state_dir: target.session.state_dir, status: "pending", reason: "session_scope_conflict" });
344
+ }
345
+ }
346
+ if (!interrupt)
347
+ for (const controller of controllers) {
348
+ const matches = registeredProcesses.filter(item => item.record.id === controller.process_id && item.record.kind === "run-controller" && item.record.owner_id === controller.controller_id && item.record.lease_token === controller.process_lease_token);
349
+ const record = matches.length === 1 ? matches[0].record : null;
350
+ if (boundary?.controllerId === controller.controller_id && record?.pid === process.pid && record.pid_started_at && processIsAlive(record))
351
+ continue;
352
+ if (!record?.pid || !record.pid_started_at || processTreeIsAlive(record))
353
+ unresolved.push({ controller_id: controller.controller_id, process_id: controller.process_id, status: "pending", reason: record?.pid && record.pid_started_at ? "controller_process_running" : "controller_process_identity_unknown" });
354
+ else
355
+ unresolved.push({ controller_id: controller.controller_id, process_id: record.id, status: "completed", receipt: { source: "controller_exited", resource_home: matches[0].home, pid: record.pid, pid_started_at: record.pid_started_at, registration_sha256: crypto.createHash("sha256").update(JSON.stringify([matches[0].home, record.id, record.owner_id, record.lease_token, record.pid, record.pid_started_at])).digest("hex") } });
356
+ }
357
+ const controllerOperations = !interrupt && controllers.length
358
+ ? context.db.all("SELECT o.operation_id, o.controller_id, o.input_json, o.status FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND o.status IN ('dispatching','outcome_unknown')", [project.id, input.runId]) : [];
359
+ const operationNodes = Promise.all(controllerOperations.map(async (operation) => {
360
+ try {
361
+ const saved = JSON.parse(operation.input_json);
362
+ const args = saved.args;
363
+ if (!Array.isArray(args) || args.some(value => typeof value !== "string") || !saved.state_dir || !saved.executable || saved.owner !== operation.controller_id || conflicts.has(saved.state_dir) || directoryAdapters.get(saved.state_dir) !== saved.executable)
364
+ throw new AppError("controller_operation_binding_unknown", "Unfinished controller operation has no matching frozen runtime binding", 1);
365
+ const method = `${args[0]}.${args[1]}`;
366
+ if (!["session.create", "session.prompt", "session.start", "session.fork", "session.cancel", "session.resume", "daemon.start", "daemon.stop"].includes(method))
367
+ throw new AppError("controller_operation_receipt_required", "Unfinished controller operation needs its original receipt", 1);
368
+ const target = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : null;
369
+ if (!args.includes("--state-dir") || args[args.indexOf("--state-dir") + 1] !== saved.state_dir || args.includes("--session-id") && (!target || !targets.has(JSON.stringify([saved.state_dir, target]))))
370
+ throw new AppError("controller_operation_binding_unknown", "Unfinished operation arguments differ from the retained Session", 1);
371
+ const retained = JSON.parse(controllers.find(row => row.controller_id === operation.controller_id).state_json).sessions.find(session => session.state_dir === saved.state_dir && (!target || session.id === target));
372
+ if (!retained)
373
+ throw new AppError("controller_operation_binding_unknown", "Unfinished operation has no retained controller Session", 1);
374
+ // A create without a native daemon state file has no provider-side
375
+ // receipt to inspect. Do not invoke the adapter again merely to discover
376
+ // that fact; retain the original outcome-unknown operation for the
377
+ // explicit controller fence/recovery path.
378
+ if (method === "session.create" && !fs.existsSync(path.join(saved.state_dir, "daemon.json")))
379
+ return { operation_id: operation.operation_id, status: "pending", reason: "create_observation_unavailable" };
380
+ const harness = runtimeHarness(retained.profile);
381
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome };
382
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
383
+ delete env.DD_FLOW_CONTROL_TOKEN;
384
+ assertCurrent();
385
+ const receipt = await runHarnessAdapter({ executable: saved.executable, args: ["daemon", "operation", "--operation-id", operation.operation_id, "--state-dir", saved.state_dir, "--json"], env, operationId: `${observationId}:observe:${operation.operation_id}`, timeoutMs: 30_000 });
386
+ if (receipt.operation_id !== operation.operation_id || receipt.operation !== method || target && receipt.session_id !== target)
387
+ throw new AppError("controller_operation_mismatch", "Operation observation belongs to another retained dispatch", 1);
388
+ if (!["completed", "failed", "interrupted"].includes(String(receipt.state)))
389
+ return { operation_id: operation.operation_id, status: "pending", reason: "controller_operation_unsettled", receipt };
390
+ if (receipt.state === "completed" && (!receipt.result || typeof receipt.result !== "object" || Array.isArray(receipt.result) || target && method !== "daemon.start" && adapterSessionId(receipt.result) !== target || method === "session.create" && !adapterSessionId(receipt.result)))
391
+ throw new AppError("controller_operation_mismatch", "Terminal operation has no valid result for its retained Session", 1);
392
+ if (receipt.state === "completed" && method === "daemon.start" && (typeof receipt.started_daemon_id !== "string" || !receipt.started_daemon_id || receipt.result.daemon_id !== receipt.started_daemon_id))
393
+ throw new AppError("controller_operation_mismatch", "Startup receipt has no confirmed daemon identity", 1);
394
+ if (receipt.state === "completed" && receipt.result.harness !== undefined && receipt.result.harness !== harness)
395
+ throw new AppError("controller_operation_mismatch", "Terminal operation returned another harness identity", 1);
396
+ if (receipt.state === "failed" && (!receipt.error || typeof receipt.error !== "object" || Array.isArray(receipt.error)))
397
+ throw new AppError("controller_operation_mismatch", "Failed operation has no durable error receipt", 1);
398
+ assertCurrent();
399
+ context.db.run("UPDATE run_controller_operations SET status = ?, receipt_json = ?, error_json = ?, updated_at = ? WHERE operation_id = ? AND status IN ('dispatching','outcome_unknown')", [receipt.state, JSON.stringify(receipt.result ?? receipt), receipt.state === "failed" ? JSON.stringify(receipt.error) : null, context.now(), operation.operation_id]);
400
+ return { operation_id: operation.operation_id, status: "completed", receipt };
401
+ }
402
+ catch (error) {
403
+ return { operation_id: operation.operation_id, status: "pending", reason: error instanceof AppError ? error.code : "controller_operation_observation_failed" };
404
+ }
405
+ }));
406
+ for (const check of context.db.all("SELECT id FROM check_receipts WHERE project_id = ? AND run_id = ? AND status = 'running'", [project.id, input.runId])) {
407
+ let reason = "check_settlement_required";
408
+ if (!interrupt) {
409
+ const matches = registeredProcesses.filter(item => item.record.check_id === check.id);
410
+ // A missing/ambiguous registration is not proof that a writer exited.
411
+ // Reuse normal check reconciliation only after exact physical evidence.
412
+ if (matches.length === 1 && matches[0].record.pid && matches[0].record.pid_started_at && !processTreeIsAlive(matches[0].record)) {
413
+ try {
414
+ assertCurrent();
415
+ const run = context.db.get("SELECT workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
416
+ reconcileUnfinishedChecks({ ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: matches[0].home } }, { projectId: project.id, runId: input.runId, workspaceRoot: run.workspace_root, runHome: run.run_root, receiptId: check.id });
417
+ if (context.db.get("SELECT status FROM check_receipts WHERE project_id = ? AND run_id = ? AND id = ?", [project.id, input.runId, check.id])?.status !== "running")
418
+ continue;
419
+ }
420
+ catch (error) {
421
+ reason = error instanceof AppError ? error.code : "check_reconciliation_failed";
422
+ }
423
+ }
424
+ }
425
+ unresolved.push({ check_id: check.id, status: "pending", reason });
426
+ }
427
+ const uncreatedDirectories = new Map();
428
+ const sessionNodes = Promise.all([...targets.values()].map(async ({ session, executable, harness }) => {
429
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome };
430
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
431
+ delete env.DD_FLOW_CONTROL_TOKEN;
432
+ // An explicit pre-registration rejection proves there is no Session to
433
+ // cancel. A missing id or an arbitrary startup failure does not.
434
+ if (!session.id && !session.create_operation_id && !daemonInventories.has(session.state_dir) && !unsafeDaemons.has(canonicalPath(session.state_dir, false)) && !fs.existsSync(path.join(session.state_dir, "daemon.json"))) {
435
+ const operations = context.db.all("SELECT o.operation_id, o.input_json FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.state_dir') = ?", [project.id, input.runId, session.state_dir]);
436
+ if (operations.length === 1)
437
+ try {
438
+ const operation = operations[0];
439
+ const saved = JSON.parse(operation.input_json);
440
+ const journal = retainedDaemonOperations(session.state_dir);
441
+ const receipt = journal.length === 1 ? journal[0] : null;
442
+ const error = receipt?.error;
443
+ if (saved.executable === executable && saved.owner === session.owner.owner_id && saved.args?.[0] === "daemon" && saved.args?.[1] === "start" && receipt?.operation_id === operation.operation_id && receipt.operation === "daemon.start" && receipt.predecessor_daemon_id === null && receipt.state === "failed" && error?.details?.startup_resources === "not_created" && !registeredProcesses.some(({ record }) => JSON.parse(record.metadata_json).state_dir === session.state_dir)) {
444
+ uncreatedDirectories.set(session.state_dir, JSON.stringify(journal));
445
+ return { state_dir: session.state_dir, status: "completed", receipt: { source: "startup_resources_not_created", operation_id: operation.operation_id, error } };
446
+ }
447
+ }
448
+ catch { /* Incomplete evidence retains the normal unknown outcome. */ }
449
+ }
450
+ if (!session.id && session.create_operation_id) {
451
+ try {
452
+ assertCurrent();
453
+ const create = await runHarnessAdapter({ executable, args: ["daemon", "operation", "--operation-id", session.create_operation_id, "--state-dir", session.state_dir, "--json"], env, operationId: `${observationId}:observe:${session.create_operation_id}`, timeoutMs: 30_000 });
454
+ if (create.operation_id !== session.create_operation_id || create.operation !== "session.create")
455
+ throw new AppError("controller_operation_mismatch", "Create observation belongs to another operation", 1);
456
+ if (create.state === "completed")
457
+ session.id = adapterSessionId(create.result) ?? "";
458
+ }
459
+ catch (error) {
460
+ return { state_dir: session.state_dir, status: "pending", reason: error instanceof AppError ? error.code : "create_observation_failed" };
461
+ }
462
+ }
463
+ const closed = closedDaemons.get(session.state_dir);
464
+ if (!session.id)
465
+ return closed && !session.create_operation_id && Array.isArray(closed.session_ids) && closed.session_ids.length === 0
466
+ ? { state_dir: session.state_dir, status: "completed", receipt: closed }
467
+ : { state_dir: session.state_dir, create_operation_id: session.create_operation_id ?? null, status: "pending", reason: "provider_identity_unknown" };
468
+ bindings = readBindings();
469
+ if (bindings.some(binding => foreign(binding) && underRoot(binding, session.id, harness)))
470
+ return { session_id: session.id, state_dir: session.state_dir, status: "pending", reason: "session_scope_conflict" };
471
+ if (closed)
472
+ return Array.isArray(closed.session_ids) && closed.session_ids.includes(session.id)
473
+ ? { session_id: session.id, state_dir: session.state_dir, harness, status: "completed", receipt: closed }
474
+ : { session_id: session.id, state_dir: session.state_dir, harness, status: "pending", reason: "shutdown_session_unproven" };
475
+ if (!interrupt) {
476
+ try {
477
+ const receipt = await inspectControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, harness, env, operationId: `${observationId}:inspect:${crypto.randomUUID()}`, assertCurrent });
478
+ return { session_id: session.id, state_dir: session.state_dir, harness: harness ?? (typeof receipt.harness === "string" ? receipt.harness : null), status: receipt.settled === true ? "completed" : "pending", receipt };
479
+ }
480
+ catch (error) {
481
+ return { session_id: session.id, state_dir: session.state_dir, status: "pending", reason: error instanceof AppError ? error.code : "session_observation_failed" };
482
+ }
483
+ }
484
+ const key = JSON.stringify([session.state_dir, session.id]);
485
+ const operationId = `${observationId}:cancel:${crypto.createHash("sha256").update(key).digest("hex")}`;
486
+ const now = context.now();
487
+ assertCurrent();
488
+ const inserted = context.db.run("INSERT OR IGNORE INTO run_control_operations (operation_id, control_id, target_json, status, created_at, updated_at) VALUES (?, ?, ?, 'dispatching', ?, ?)", [operationId, observationId, JSON.stringify({ session_id: session.id, state_dir: session.state_dir, executable }), now, now]);
489
+ const prior = context.db.get("SELECT status, receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
490
+ let cancellation;
491
+ let cancellationError;
492
+ try {
493
+ if (!inserted.changes && prior.status === "completed") {
494
+ const saved = JSON.parse(prior.receipt_json);
495
+ assertNativeCancellationReceipt(saved, session.id);
496
+ cancellation = saved;
497
+ }
498
+ else {
499
+ const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent });
500
+ const terminalFailure = status === "failed" ? receipt.error && typeof receipt.error === "object" && !Array.isArray(receipt.error) ? receipt.error : { code: "native_cancel_failed" } : null;
501
+ context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = ?, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(receipt), terminalFailure ? JSON.stringify(terminalFailure) : null, context.now(), operationId]);
502
+ cancellation = receipt;
503
+ if (terminalFailure)
504
+ cancellationError = { code: typeof terminalFailure.code === "string" ? terminalFailure.code : "native_cancel_failed", receipt };
505
+ }
506
+ }
507
+ catch (error) {
508
+ cancellationError = { code: error instanceof AppError ? error.code : "control_observation_failed", message: String(error) };
509
+ context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ? AND status != 'completed'", [JSON.stringify(cancellationError), context.now(), operationId]);
510
+ if (cancellationError.code === "controller_operation_mismatch")
511
+ return { operation_id: operationId, session_id: session.id, status: "outcome_unknown", error: cancellationError };
512
+ }
513
+ try {
514
+ const observed = await inspectControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, harness, env, operationId: `${observationId}:inspect:${crypto.randomUUID()}`, assertCurrent });
515
+ return { operation_id: operationId, session_id: session.id, status: observed.settled === true ? "completed" : "pending", receipt: observed, cancellation, reused: !inserted.changes, ...(cancellationError ? { cancellation_error: cancellationError } : {}) };
516
+ }
517
+ catch (error) {
518
+ return { operation_id: operationId, session_id: session.id, status: "pending", cancellation, cancellation_error: cancellationError, error: { code: error instanceof AppError ? error.code : "control_observation_failed", message: String(error) } };
519
+ }
520
+ }));
521
+ const processResults = Promise.all(processTargets.map(async ({ home, record }) => {
522
+ if (managedLeaseReleased(record))
523
+ return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "managed_lease_released", ...managedProcessBinding(record, home), finished_at: record.finished_at } };
524
+ if (record.kind === "workspace-gate")
525
+ return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
526
+ if (!record.pid || !record.pid_started_at)
527
+ return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
528
+ if (!interrupt && processTreeIsAlive(record))
529
+ return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_running" };
530
+ if (boundary)
531
+ return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "owned_process_exited", pid: record.pid, pid_started_at: record.pid_started_at } };
532
+ const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: home } };
533
+ const operationId = `${observationId}:${interrupt ? "interrupt" : "settle"}-process:${crypto.createHash("sha256").update(JSON.stringify([home, record.id, record.pid, record.pid_started_at, record.lease_token])).digest("hex")}`;
534
+ assertCurrent();
535
+ if (!interrupt) {
536
+ const retained = context.db.get("SELECT status, receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
537
+ const receipt = retained?.receipt_json ? JSON.parse(retained.receipt_json) : null;
538
+ // The physical exit was checked above. Do not invalidate the inventory
539
+ // snapshot by rewriting our own already-completed observation each pass.
540
+ if (retained?.status === "completed" && receipt?.process_id === record.id && receipt.settled === true)
541
+ return { operation_id: operationId, process_id: record.id, status: "completed", receipt, reused: true };
542
+ }
543
+ const inserted = context.db.run("INSERT OR IGNORE INTO run_control_operations (operation_id, control_id, target_json, status, created_at, updated_at) VALUES (?, ?, ?, 'dispatching', ?, ?)", [operationId, observationId, JSON.stringify({ resource_home: home, process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at }), context.now(), context.now()]);
544
+ try {
545
+ assertCurrent();
546
+ const current = managedProcessStatus(resourceContext).find(candidate => candidate.id === record.id);
547
+ if (!current || current.lease_token !== record.lease_token || current.pid !== record.pid || current.pid_started_at !== record.pid_started_at)
548
+ throw new AppError("process_lease_lost", "Controlled process identity changed before settlement", 1);
549
+ // The previous owner may have persisted intent without sending its signal.
550
+ // Stop is repeatable for this exact lease/PID/birth; no work is replayed.
551
+ if (interrupt)
552
+ await stopManagedProcess(resourceContext, { id: record.id, leaseToken: record.lease_token, force: JSON.parse(control.request_json).force === true, graceMs: 500, reason: `run_control:${observationId}` });
553
+ else if (processTreeIsAlive(current))
554
+ throw new AppError("process_tree_not_settled", "This owned process tree is still running", 1);
555
+ else if (!finishManagedProcess(resourceContext, { id: record.id, leaseToken: record.lease_token, state: current.state === "failed" ? "failed" : "stopped", reason: `run_control:${observationId}` }))
556
+ throw new AppError("process_lease_lost", "Controlled process ownership changed before settlement", 1);
557
+ const receipt = { process_id: record.id, settled: true };
558
+ context.db.run("UPDATE run_control_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
559
+ return { operation_id: operationId, process_id: record.id, status: "completed", receipt, reused: !inserted.changes };
560
+ }
561
+ catch (error) {
562
+ const failure = { code: error instanceof AppError ? error.code : error.code ?? "process_control_failed", message: String(error) };
563
+ context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(failure), context.now(), operationId]);
564
+ return { operation_id: operationId, process_id: record.id, status: "pending", reason: failure.code };
565
+ }
566
+ }));
567
+ const [nodes, processNodes, operations] = await Promise.all([sessionNodes, processResults, operationNodes]);
568
+ // Every whole-RUN stop retires its owned daemon boundary once cancellation
569
+ // has been admitted. `force` controls escalation inside that lifecycle; it
570
+ // must not decide whether the lifecycle exists at all.
571
+ const closeNodes = interrupt && control?.action === "stop" ? await Promise.all([...daemonInventories].map(async ([directory, { state, records }]) => {
572
+ if (closedDaemons.has(directory)) {
573
+ const receipt = closedDaemons.get(directory);
574
+ return { state_dir: directory, status: "completed", operation_id: receipt.operation_id, receipt };
575
+ }
576
+ bindings = readBindings();
577
+ const ids = state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item));
578
+ const harness = directoryHarnesses.get(directory) ?? null;
579
+ if (conflicts.has(directory) || unsafeDaemons.has(canonicalPath(directory, false)) || ids.some(id => !id || bindings.some(binding => foreign(binding) && underRoot(binding, id, harness))))
580
+ return { state_dir: directory, status: "pending", reason: "daemon_scope_conflict" };
581
+ const daemonId = state.daemon_id ?? state.config?.daemonId;
582
+ const leader = records.find(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token);
583
+ const executable = directoryAdapters.get(directory) ?? (leader && JSON.parse(leader.metadata_json).adapter_executable);
584
+ if (!daemonId || !leader || !executable)
585
+ return { state_dir: directory, status: "pending", reason: "daemon_close_owner_unproven" };
586
+ let operationId = `${observationId}:close-daemon:${crypto.createHash("sha256").update(JSON.stringify([directory, daemonId])).digest("hex")}`;
587
+ const nativeOperations = retainedDaemonOperations(directory);
588
+ const targetList = [...targets.values()];
589
+ const sessionsSettled = ids.every(id => nodes.some((node, index) => targetList[index]?.session.state_dir === directory && targetList[index]?.session.id === id && node.status === "completed"));
590
+ // A confirmed failed tree guard admits a new shutdown attempt only after
591
+ // fresh tree settlement. Unknown outcomes retain their original ID.
592
+ if (sessionsSettled) {
593
+ while (nativeOperations.some(operation => operation.operation_id === operationId && operation.daemon_id === daemonId && operation.operation === "daemon.stop"
594
+ && operation.state === "failed" && operation.error?.code === "tree_not_settled"))
595
+ operationId += ":finalize";
596
+ }
597
+ assertCurrent();
598
+ const inserted = context.db.run("INSERT OR IGNORE INTO run_control_operations (operation_id, control_id, target_json, status, created_at, updated_at) VALUES (?, ?, ?, 'dispatching', ?, ?)", [operationId, observationId, JSON.stringify({ state_dir: directory, daemon_id: daemonId, executable, cancel_tree: true }), context.now(), context.now()]);
599
+ const prior = context.db.get("SELECT status, receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
600
+ try {
601
+ if (!inserted.changes && prior.status === "completed") {
602
+ const receipt = JSON.parse(prior.receipt_json);
603
+ assertDaemonCloseReceipt(receipt);
604
+ return { operation_id: operationId, state_dir: directory, status: "completed", receipt, reused: true };
605
+ }
606
+ if (inserted.changes) {
607
+ const fresh = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
608
+ const owned = registeredProcesses.find(item => item.record === leader);
609
+ const record = managedProcessStatus({ ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: owned.home } }).find(item => item.id === leader.id);
610
+ if (!record || record.lease_token !== leader.lease_token || record.pid !== leader.pid || record.pid_started_at !== leader.pid_started_at || !processIsAlive(record) || (fresh.daemon_id ?? fresh.config?.daemonId) !== daemonId || fresh.resource_process?.id !== leader.id || fresh.resource_process.lease_token !== leader.lease_token || JSON.stringify(fresh.config?.runtime_owner) !== JSON.stringify(state.config?.runtime_owner))
611
+ throw new AppError("daemon_close_owner_changed", "Daemon ownership changed before its forced close", 1);
612
+ }
613
+ assertCurrent();
614
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome };
615
+ delete env.DD_FLOW_CONTROL_TOKEN;
616
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
617
+ const receipt = await runHarnessAdapter({ executable, args: inserted.changes ? ["daemon", "stop", "--cancel-tree", "--state-dir", directory, "--json"] : ["daemon", "operation", "--operation-id", operationId, "--state-dir", directory, "--json"], env, operationId: inserted.changes ? operationId : `${operationId}:observe`, timeoutMs: 30_000 });
618
+ if (!inserted.changes && (receipt.operation_id !== operationId || receipt.operation !== "daemon.stop" || receipt.daemon_id !== daemonId))
619
+ throw new AppError("controller_operation_mismatch", "Daemon close observation belongs to another operation or incarnation", 1);
620
+ if (receipt.state === "failed") {
621
+ const failure = receipt.error && typeof receipt.error === "object" && !Array.isArray(receipt.error) ? receipt.error : { code: "daemon_close_failed" };
622
+ context.db.run("UPDATE run_control_operations SET status = 'failed', receipt_json = ?, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), JSON.stringify(failure), context.now(), operationId]);
623
+ return { operation_id: operationId, state_dir: directory, status: "pending", reason: typeof failure.code === "string" ? failure.code : "daemon_close_failed", receipt };
624
+ }
625
+ const observed = inserted.changes ? receipt : receipt.state === "completed" ? receipt.result : null;
626
+ if (observed)
627
+ assertDaemonCloseReceipt(observed);
628
+ const status = observed ? "completed" : "outcome_unknown";
629
+ context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(observed ?? receipt), context.now(), operationId]);
630
+ return { operation_id: operationId, state_dir: directory, status, receipt: observed ?? receipt };
631
+ }
632
+ catch (error) {
633
+ const failure = { code: error instanceof AppError ? error.code : "daemon_close_failed", message: String(error) };
634
+ context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(failure), context.now(), operationId]);
635
+ return { operation_id: operationId, state_dir: directory, status: "pending", reason: failure.code };
636
+ }
637
+ })) : [];
638
+ // Once the same control owns a clean whole-tree stop, an older productive
639
+ // request cannot gain a provider reply through this daemon. Seal that fact in
640
+ // the shared native journal before the next observation pass. This prevents a
641
+ // closed daemon from becoming a permanent prerequisite for its own recovery.
642
+ const terminalizedOperations = [];
643
+ if (interrupt && control?.action === "stop")
644
+ for (const node of closeNodes) {
645
+ if (node.status !== "completed")
646
+ continue;
647
+ const directory = node.state_dir;
648
+ const inventory = daemonInventories.get(directory);
649
+ const daemonId = inventory?.state.daemon_id ?? inventory?.state.config?.daemonId;
650
+ const executable = directoryAdapters.get(directory);
651
+ if (!inventory)
652
+ continue;
653
+ if (!daemonId || !executable || !node.operation_id) {
654
+ terminalizedOperations.push({ state_dir: directory, status: "pending", reason: "operation_terminalization_binding_unknown" });
655
+ continue;
656
+ }
657
+ const pending = retainedDaemonOperations(directory).filter(operation => operation.daemon_id === daemonId && ["running", "awaiting_provider"].includes(String(operation.state)) && ["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(String(operation.operation)) && typeof operation.operation_id === "string");
658
+ terminalizedOperations.push(...await Promise.all(pending.map(async (operation) => {
659
+ try {
660
+ const operationId = operation.operation_id, sessionId = typeof operation.session_id === "string" ? operation.session_id : null;
661
+ assertCurrent();
662
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome };
663
+ delete env.DD_FLOW_CONTROL_TOKEN;
664
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
665
+ const interruptArgs = ["daemon", "operation", "interrupt", "--operation-id", operationId, "--control-id", control.control_id, "--stop-operation-id", String(node.operation_id), "--daemon-id", String(daemonId)];
666
+ if (sessionId)
667
+ interruptArgs.push("--session-id", sessionId);
668
+ interruptArgs.push("--state-dir", directory, "--json");
669
+ const receipt = await runHarnessAdapter({ executable, args: interruptArgs, env, operationId: `${observationId}:interrupt-operation:${operationId}`, timeoutMs: 30_000 });
670
+ if (receipt.operation_id !== operationId || receipt.state !== "interrupted" || receipt.daemon_id !== daemonId || receipt.session_id !== sessionId)
671
+ throw new AppError("controller_operation_mismatch", "Interrupted operation receipt belongs to another native request", 1);
672
+ return { operation_id: operationId, state_dir: directory, status: "completed", receipt };
673
+ }
674
+ catch (error) {
675
+ return { operation_id: operation.operation_id, state_dir: directory, status: "pending", reason: error instanceof AppError ? error.code : "operation_terminalization_failed" };
676
+ }
677
+ })));
678
+ }
679
+ let verifyInventory = assertCurrent;
680
+ if (!interrupt) {
681
+ const importVersions = new Map();
682
+ const historicalImport = (binding) => {
683
+ if (binding.status !== "stopped" || binding.stop_reason !== "snapshot_restored"
684
+ || context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [binding.session_id]))
685
+ return false;
686
+ try {
687
+ const marker = JSON.parse(binding.metadata_json).snapshot_import;
688
+ if (typeof marker?.lineage_file !== "string" || typeof marker.lineage_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(marker.lineage_sha256))
689
+ return false;
690
+ if (!fs.lstatSync(marker.lineage_file).isFile() || path.dirname(fs.realpathSync(marker.lineage_file)) !== path.join(fs.realpathSync(context.ddFlowHome), "snapshot-imports"))
691
+ return false;
692
+ const bytes = fs.readFileSync(marker.lineage_file);
693
+ if (crypto.createHash("sha256").update(bytes).digest("hex") !== marker.lineage_sha256)
694
+ return false;
695
+ const lineage = JSON.parse(bytes.toString("utf8"));
696
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.restored_runtime !== context.ddFlowHome || lineage.source_runtime === context.ddFlowHome || lineage.run_id !== input.runId || !Array.isArray(lineage.run_tables?.sessions))
697
+ return false;
698
+ const found = lineage.run_tables.sessions.some((row) => ["project_id", "session_id", "provider_session_id", "harness", "run_id"].every(key => row[key] === binding[key]));
699
+ if (found)
700
+ importVersions.set(marker.lineage_file, marker.lineage_sha256);
701
+ return found;
702
+ }
703
+ catch {
704
+ return false;
705
+ }
706
+ };
707
+ bindings = readBindings();
708
+ for (const target of targets.values())
709
+ if (!daemonInventories.has(target.session.state_dir) && !uncreatedDirectories.has(target.session.state_dir))
710
+ unresolved.push({ state_dir: target.session.state_dir, status: "pending", reason: "runtime_inventory_unverified" });
711
+ for (const node of nodes)
712
+ if ("harness" in node && "session_id" in node && typeof node.session_id === "string" && bindings.some(binding => foreign(binding) && underRoot(binding, node.session_id, node.harness ?? null)))
713
+ unresolved.push({ session_id: node.session_id, status: "pending", reason: "session_scope_conflict" });
714
+ const scoped = new Set(bindings.filter(binding => binding.project_id === project.id && binding.run_id === input.runId).map(binding => binding.session_id));
715
+ for (const link of context.db.all("SELECT ws.id, ws.session_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ?", [project.id, input.runId])) {
716
+ scoped.add(link.session_id);
717
+ if (!bindings.some(binding => binding.project_id === project.id && binding.session_id === link.session_id))
718
+ unresolved.push({ work_session_id: link.id, status: "pending", reason: "work_session_binding_unknown" });
719
+ }
720
+ for (const binding of bindings.filter(item => item.project_id === project.id && scoped.has(item.session_id))) {
721
+ // Imported history is not a live native owner. Keep it in `bindings`
722
+ // above so it can never hide foreign descendants or actual process nodes.
723
+ if (historicalImport(binding))
724
+ continue;
725
+ const covered = nodes.some(node => "harness" in node && node.harness === binding.harness && node.status === "completed" && "session_id" in node && typeof node.session_id === "string" && underRoot(binding, node.session_id, node.harness));
726
+ if (!covered)
727
+ unresolved.push({ session_id: binding.provider_session_id, harness: binding.harness, status: "pending", reason: binding.run_id && binding.run_id !== input.runId ? "session_scope_conflict" : "bound_session_settlement_required" });
728
+ }
729
+ for (const work of context.db.all("SELECT w.work_id FROM works w WHERE w.project_id = ? AND w.run_id = ? AND w.status = 'running' AND NOT EXISTS (SELECT 1 FROM work_sessions ws WHERE ws.work_id = w.work_id)", [project.id, input.runId]))
730
+ unresolved.push({ work_id: work.work_id, status: "pending", reason: "running_work_binding_unknown" });
731
+ const processVersion = (items) => JSON.stringify(items.map(({ home, record }) => [home, record.id, record.kind, record.owner_id, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.pid, record.pid_started_at, record.lease_token, record.state, record.metadata_json]).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))));
732
+ verifyInventory = () => {
733
+ assertCurrent();
734
+ for (const [directory, journal] of uncreatedDirectories)
735
+ if (fs.existsSync(path.join(directory, "daemon.json")) || JSON.stringify(retainedDaemonOperations(directory)) !== journal)
736
+ throw new AppError("owned_inventory_changed", "Rejected startup acquired new runtime evidence", 1);
737
+ const currentProcesses = [...resourceHomes].flatMap(home => managedProcessStatus({ ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: home } }).filter(record => record.project_id === project.id && record.run_id === input.runId && [null, context.ddFlowHome].includes(managedProcessRuntimeHome(record, context))).map(record => ({ home, record })));
738
+ if (initialDatabaseVersion !== databaseVersion() || processVersion(registeredProcesses) !== processVersion(currentProcesses) || [...nativeVersions].some(([directory, version]) => version !== nativeInventoryVersion(directory)) || [...importVersions].some(([file, version]) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== version))
739
+ throw new AppError("owned_inventory_changed", "Owned writers changed after their settlement observation", 1);
740
+ };
741
+ try {
742
+ verifyInventory();
743
+ }
744
+ catch (error) {
745
+ unresolved.push({ status: "pending", reason: error instanceof AppError && error.code === "owned_inventory_changed" ? error.code : "owned_inventory_recheck_failed" });
746
+ }
747
+ }
748
+ assertCurrent();
749
+ // A clean daemon close plus a sealed interruption terminalizes an in-flight
750
+ // create even though its controller Session row never received an id. Do
751
+ // not leave the pre-terminalization `provider_identity_unknown` shadow node
752
+ // in the final result.
753
+ const terminalized = new Set(terminalizedOperations.filter(node => node.status === "completed").map(node => node.operation_id));
754
+ const reconciledNodes = nodes.map(node => {
755
+ const createId = "create_operation_id" in node && typeof node.create_operation_id === "string" ? node.create_operation_id : null;
756
+ return createId && terminalized.has(createId) ? { ...node, status: "completed", reason: undefined, receipt: terminalizedOperations.find(item => item.operation_id === createId)?.receipt } : node;
757
+ });
758
+ const allNodes = [...reconciledNodes, ...processNodes, ...operations, ...closeNodes, ...terminalizedOperations, ...unresolved];
759
+ const settled = !interrupt && allNodes.every(node => node.status === "completed");
760
+ if (settled)
761
+ boundary?.retainVerification(verifyInventory);
762
+ const processBindings = settled ? registeredProcesses.filter(({ record }) => record.kind !== "run-control" && !(boundary && record.kind === "run-controller" && record.owner_id === boundary.controllerId)).map(({ home, record }) => managedProcessBinding(record, home)) : [];
763
+ const pendingReasons = settled ? [] : [...new Set([...(interrupt ? ["settlement_observation_required"] : []), ...allNodes.filter(node => node.status !== "completed").map(node => "reason" in node && typeof node.reason === "string" ? node.reason : "node_settlement_required")])];
764
+ return { ok: true, ...(boundary ? { operation_id: observationId } : { control_id: observationId }), status: "requested", settled, nodes: allNodes, process_bindings: processBindings, resource_homes: [...resourceHomes].sort(), pending_reasons: pendingReasons };
765
+ }
766
+ export function nativeInventoryVersion(directory, operations = retainedDaemonOperations(directory)) {
767
+ const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
768
+ const keys = ["schema_id", "daemon_id", "config", "pid", "provider_pid", "shutdown_state", "active_operation", "active_tree", "sessions", "descendants", "root_session_id", "resource_process", "provider_process", "owned_processes"];
769
+ return crypto.createHash("sha256").update(JSON.stringify([Object.fromEntries(keys.map(key => [key, state[key]])), operations])).digest("hex");
770
+ }
771
+ /** Read the bundled runtime's durable operation journal, including lost replies. */
772
+ export function retainedDaemonOperations(directory) {
773
+ const root = path.join(directory, "operations");
774
+ if (!fs.existsSync(root))
775
+ return [];
776
+ return fs.readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)).map(entry => {
777
+ if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name))
778
+ throw new AppError("daemon_operation_inventory_invalid", "Native operation journal has an invalid entry", 1);
779
+ const folder = path.join(root, entry.name);
780
+ const requested = JSON.parse(fs.readFileSync(path.join(folder, "requested.json"), "utf8"));
781
+ if (requested.schema_id !== "dd-flow/daemon-operation@1" || typeof requested.operation_id !== "string" || crypto.createHash("sha256").update(requested.operation_id).digest("hex") !== entry.name || typeof requested.operation !== "string")
782
+ throw new AppError("daemon_operation_inventory_invalid", "Native operation request has a conflicting identity", 1);
783
+ const outcomePath = ["result.json", "interrupted.json", "observation-lost.json"].map(file => path.join(folder, file)).find(file => fs.existsSync(file));
784
+ const outcome = outcomePath ? JSON.parse(fs.readFileSync(outcomePath, "utf8")) : { state: "running" };
785
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome) || !["completed", "failed", "interrupted", "running", "awaiting_provider"].includes(String(outcome.state)) || ["operation_id", "operation", "daemon_id", "session_id"].some(key => key in outcome && outcome[key] !== requested[key]))
786
+ throw new AppError("daemon_operation_inventory_invalid", "Native operation result has an invalid identity or state", 1);
787
+ if (outcome.state === "completed" && (!outcome.result || typeof outcome.result !== "object" || Array.isArray(outcome.result)) || ["failed", "interrupted"].includes(String(outcome.state)) && (!outcome.error || typeof outcome.error !== "object" || Array.isArray(outcome.error)))
788
+ throw new AppError("daemon_operation_inventory_invalid", "Native operation omits its terminal evidence", 1);
789
+ return { ...requested, ...outcome };
790
+ });
791
+ }
792
+ function assertDaemonCloseReceipt(value) {
793
+ if (!value || typeof value !== "object" || value.stopped !== true)
794
+ throw new AppError("daemon_close_receipt_invalid", "Daemon close has no stopped acknowledgement", 1);
795
+ }
796
+ export function controlLifecycleReply(context, input, result) {
797
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
798
+ const control = runControlDirective(context, project.id, input.runId);
799
+ if (!control || !result || typeof result !== "object" || Array.isArray(result))
800
+ return result;
801
+ return { ...result, control, next: null, next_command: null, next_action: "operator_control", flow_guidance: null, newly_ready: [] };
802
+ }
803
+ /** A lifecycle reply can carry the trusted stop request; it is not a physical ACK. */
804
+ export function runControlDirective(context, projectId, runId) {
805
+ if (!context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'"))
806
+ return null;
807
+ const current = context.db.get("SELECT c.* FROM run_controls c JOIN run_recovery_guards g ON g.recovery_id = c.recovery_id WHERE c.project_id = ? AND c.run_id = ? AND c.status IN ('requested','settled','resuming') AND g.status IN ('draining','sealed','resuming') AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = c.project_id AND run_id = c.run_id)", [projectId, runId]);
808
+ if (!current)
809
+ return null;
810
+ return { control_id: current.control_id, action: current.action, status: current.status, stop_turn: true, instruction: "The operator has fenced this RUN. Preserve the recorded result and end this Turn now. Do not start another Work, check, Stage or provider operation, and do not wait for resume in a sleep loop. Only the runtime may authorize continuation.", acknowledged: false, settled: false };
811
+ }
812
+ /** The durable fence is committed before observation, steering or interruption. */
813
+ export function recordRunControlIntent(context, input) {
814
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !["pause", "stop"].includes(input.action)
815
+ || input.graceMs !== undefined && (!Number.isSafeInteger(input.graceMs) || input.graceMs < 0 || input.graceMs > 3_600_000)
816
+ || input.force !== undefined && typeof input.force !== "boolean"
817
+ || input.escalate !== undefined && input.escalate !== "interrupt"
818
+ || input.action === "pause" && input.force !== undefined
819
+ || input.action === "stop" && (input.graceMs !== undefined || input.escalate !== undefined))
820
+ throw new AppError("usage", "Invalid RUN control intent", 2);
821
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
822
+ const request = { project_root: project.root, run_id: input.runId, action: input.action, ...(input.action === "pause" ? { grace_ms: input.graceMs ?? 30_000, escalate: input.escalate ?? null } : { force: input.force ?? false }) };
823
+ const requestHash = crypto.createHash("sha256").update(JSON.stringify(request)).digest("hex");
824
+ ensureRunControlStorage(context.db);
825
+ context.db.beginWriteTransaction();
826
+ try {
827
+ const existing = context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, input.runId, input.requestId]);
828
+ if (existing) {
829
+ if (existing.request_hash !== requestHash)
830
+ throw new AppError("runtime_request_conflict", "Control request id has different inputs", 1);
831
+ context.db.exec("COMMIT");
832
+ return { ok: true, control_id: existing.control_id, recovery_id: existing.recovery_id, action: existing.action, status: existing.status, reused: true };
833
+ }
834
+ const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
835
+ if (!run)
836
+ throw new AppError("not_found", "RUN is not registered", 1);
837
+ if (["done", "failed", "cancelled"].includes(run.status)) {
838
+ context.db.exec("COMMIT");
839
+ return { ok: true, terminal: true, status: run.status, control_id: null };
840
+ }
841
+ const prior = recoveryGuard(context, project.id, input.runId);
842
+ const active = context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND status IN ('requested','settled','resuming')", [project.id, input.runId]);
843
+ const strongerStop = active?.action === "stop" && input.force === true && JSON.parse(active.request_json).force !== true;
844
+ const escalates = active && (active.action === "pause" || strongerStop || active.status === "resuming" || prior?.status === "resuming") && input.action === "stop" && prior?.recovery_id === active.recovery_id && (["draining", "sealed", "resuming"].includes(prior.status) || active.status === "resuming" && prior.status === "recovered");
845
+ if (active && !escalates)
846
+ throw new AppError("run_control_in_progress", "RUN already has an unresolved control intent", 1, { control_id: active.control_id });
847
+ if (prior && ["draining", "sealed", "resuming"].includes(prior.status) && !escalates)
848
+ throw new AppError("run_recovery_guarded", "Existing recovery must be reconciled before another control intent", 1);
849
+ const now = context.now();
850
+ if (escalates) {
851
+ context.db.run("UPDATE run_controls SET status = 'superseded', updated_at = ? WHERE control_id = ?", [now, active.control_id]);
852
+ context.db.run("UPDATE run_recovery_guards SET status = 'abandoned', updated_at = ? WHERE recovery_id = ?", [now, prior.recovery_id]);
853
+ context.db.run("UPDATE run_recovery_bindings SET status = 'abandoned' WHERE recovery_id = ? AND status = 'prepared'", [prior.recovery_id]);
854
+ }
855
+ const controlId = `CTL-${crypto.randomUUID()}`;
856
+ const recoveryId = `RCV-${crypto.randomUUID()}`;
857
+ const generation = (prior?.generation ?? 0) + 1;
858
+ context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draining', ?, ?, ?)", [recoveryId, project.id, input.runId, generation, JSON.stringify({ control_id: controlId, action: input.action }), now, now]);
859
+ context.db.run("INSERT INTO run_controls (control_id, project_id, run_id, request_id, request_hash, request_json, recovery_id, action, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'requested', ?, ?)", [controlId, project.id, input.runId, input.requestId, requestHash, JSON.stringify(request), recoveryId, input.action, now, now]);
860
+ context.db.run("INSERT INTO run_control_admissions (control_id, work_session_id, work_id, session_id, admitted_at) SELECT ?, ws.id, w.work_id, ws.session_id, ? FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' AND w.status = 'running'", [controlId, now, project.id, input.runId]);
861
+ context.db.run(`INSERT INTO run_control_stage_admissions (control_id, stage, attempt, work_session_id)
862
+ SELECT ?, json_extract(stage.value, '$.stage'), COALESCE(json_extract(stage.value, '$.attempt'), 'try-001'),
863
+ (SELECT a.work_session_id FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id JOIN works w ON w.work_id = a.work_id
864
+ WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage') ORDER BY (w.parent_work_id IS NULL) DESC, a.work_session_id LIMIT 1)
865
+ FROM runs r, json_each(r.index_json, '$.stage_runs') stage
866
+ WHERE r.project_id = ? AND r.id = ? AND json_extract(stage.value, '$.status') = 'running'
867
+ AND EXISTS (SELECT 1 FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage'))`, [controlId, controlId, project.id, input.runId, controlId]);
868
+ setFlowRunRecoveryPaused(context, project.id, input.runId, true);
869
+ context.db.exec("COMMIT");
870
+ return { ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false };
871
+ }
872
+ catch (error) {
873
+ context.db.exec("ROLLBACK");
874
+ throw error;
875
+ }
876
+ }