@deksden-com/dd-flow-cli 0.9.0-beta.137 → 0.9.0-beta.138

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 (260) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +9 -0
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/argument-values.js +5 -5
  5. package/dist/cli/command-inputs.js +89 -20
  6. package/dist/cli/command-metadata.js +197 -0
  7. package/dist/cli/command-option-descriptions.js +191 -0
  8. package/dist/cli/completion-cli.js +70 -0
  9. package/dist/cli/completion.js +57 -0
  10. package/dist/cli/error-output.js +52 -0
  11. package/dist/cli/executor-onboarding.js +76 -0
  12. package/dist/cli/flows.js +29 -0
  13. package/dist/cli/harnesses.js +84 -0
  14. package/dist/cli/help.js +240 -6
  15. package/dist/cli/input-preparation.js +54 -5
  16. package/dist/cli/memory-flow-admission.js +44 -0
  17. package/dist/cli/memory-init-invocation.js +78 -0
  18. package/dist/cli/memory-init.js +240 -0
  19. package/dist/cli/memory-storage-admission.js +53 -0
  20. package/dist/cli/models.js +39 -0
  21. package/dist/cli/native-hook-ingress.js +1 -1
  22. package/dist/cli/operator-profiles.js +61 -0
  23. package/dist/cli/output-options.js +35 -0
  24. package/dist/cli/response-file.js +71 -0
  25. package/dist/cli/run-cli.js +283 -160
  26. package/dist/cli/setup.js +149 -0
  27. package/dist/cli.js +54 -2
  28. package/dist/domain/stage-handler-identities.js +20 -0
  29. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  30. package/dist/harness-runtime/lib/daemon-operations.mjs +4 -3
  31. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +4 -2
  32. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +11 -5
  33. package/dist/harness-runtime/lib/dd-codex.mjs +22 -10
  34. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -1
  35. package/dist/harness-runtime/lib/dd-droid.mjs +2 -1
  36. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -2
  37. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +6 -4
  38. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +9 -9
  39. package/dist/harness-runtime/lib/delegation-instructions.mjs +5 -3
  40. package/dist/harness-runtime/lib/lifecycle-command.mjs +1 -0
  41. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -0
  42. package/dist/harness-runtime/lib/managed-daemon.mjs +50 -15
  43. package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
  44. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -1
  45. package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
  46. package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
  47. package/dist/harness-runtime/lib/opencode-plugin.mjs +2 -0
  48. package/dist/harness-runtime/lib/operation-progress.mjs +3 -1
  49. package/dist/runtime/context.js +1 -1
  50. package/dist/schemas/discovery-result.schema.json +184 -0
  51. package/dist/schemas/execution-bundle.schema.json +205 -0
  52. package/dist/schemas/execution-settings.schema.json +162 -0
  53. package/dist/schemas/flow-descriptor.schema.json +54 -0
  54. package/dist/schemas/flow-run.schema.json +22 -0
  55. package/dist/schemas/harness-catalog.schema.json +80 -0
  56. package/dist/schemas/mb-init-compose-result.schema.json +457 -0
  57. package/dist/schemas/mb-init-discover-result.schema.json +500 -0
  58. package/dist/schemas/mb-init-finish-result.schema.json +422 -0
  59. package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
  60. package/dist/schemas/mb-init-review-result.schema.json +396 -0
  61. package/dist/schemas/mb-init-verify-result.schema.json +391 -0
  62. package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
  63. package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
  64. package/dist/schemas/memory-composition-work-result.schema.json +38 -0
  65. package/dist/schemas/memory-repair-work-result.schema.json +49 -0
  66. package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
  67. package/dist/schemas/memory-review-work-result.schema.json +43 -0
  68. package/dist/schemas/model-catalog.schema.json +153 -0
  69. package/dist/schemas/profile-settings.schema.json +122 -0
  70. package/dist/schemas/release-manifest.schema.json +291 -0
  71. package/dist/schemas/run-engine-binding.schema.json +1 -1
  72. package/dist/services/check-runner-evidence.js +51 -4
  73. package/dist/services/cli-operation-classifier.js +8 -47
  74. package/dist/services/cli-upgrade-allowlist.js +27 -0
  75. package/dist/services/code-checks.js +387 -34
  76. package/dist/services/controller-execution.js +88 -0
  77. package/dist/services/controller-fanout.js +71 -12
  78. package/dist/services/dashboard.js +8 -2
  79. package/dist/services/descriptor-coordinator-session.js +35 -0
  80. package/dist/services/descriptor-root-work.js +53 -0
  81. package/dist/services/descriptor-run-settlement.js +105 -0
  82. package/dist/services/descriptor-run-start.js +449 -0
  83. package/dist/services/descriptor-stage-coordinator.js +197 -0
  84. package/dist/services/descriptor-stage-settlement.js +222 -0
  85. package/dist/services/descriptor-stage-start.js +139 -0
  86. package/dist/services/discovery/agy-discovery.js +2 -0
  87. package/dist/services/discovery/codex-discovery.js +2 -0
  88. package/dist/services/discovery/droid-discovery.js +2 -0
  89. package/dist/services/discovery/grok-discovery.js +2 -0
  90. package/dist/services/discovery/opencode-discovery.js +2 -0
  91. package/dist/services/discovery/static.js +31 -0
  92. package/dist/services/discovery/zcode-discovery.js +2 -0
  93. package/dist/services/engines.js +39 -22
  94. package/dist/services/eval-snapshots.js +83 -25
  95. package/dist/services/execution-bundles.js +263 -0
  96. package/dist/services/execution-policy.js +20 -16
  97. package/dist/services/executor-onboarding.js +56 -0
  98. package/dist/services/external-work-launch.js +91 -11
  99. package/dist/services/flow-catalog.js +21 -0
  100. package/dist/services/flow-descriptors.js +82 -0
  101. package/dist/services/frozen-engine.js +242 -0
  102. package/dist/services/generic-execution.js +164 -0
  103. package/dist/services/harness-adapter.js +28 -2
  104. package/dist/services/harness-discovery.js +29 -0
  105. package/dist/services/harness-doctor.js +75 -0
  106. package/dist/services/harness-probe-effects.js +51 -0
  107. package/dist/services/harness-version-probe.js +100 -0
  108. package/dist/services/hooks.js +24 -6
  109. package/dist/services/hybrid-contracts.js +483 -0
  110. package/dist/services/ids.js +29 -1
  111. package/dist/services/installation-lock.js +95 -0
  112. package/dist/services/installed-flow-source.js +183 -0
  113. package/dist/services/lifecycle-invocations.js +257 -78
  114. package/dist/services/memory-accepted-composition.js +77 -0
  115. package/dist/services/memory-baseline-preservation.js +65 -0
  116. package/dist/services/memory-completed-correction-wave.js +320 -0
  117. package/dist/services/memory-compose-handler.js +258 -0
  118. package/dist/services/memory-composition-acceptance.js +199 -0
  119. package/dist/services/memory-composition-graph.js +134 -0
  120. package/dist/services/memory-composition-registration.js +146 -0
  121. package/dist/services/memory-composition-work-custody.js +151 -0
  122. package/dist/services/memory-composition-work-results.js +96 -0
  123. package/dist/services/memory-corrected-review-acceptance.js +129 -0
  124. package/dist/services/memory-corrected-review-batch.js +30 -0
  125. package/dist/services/memory-corrected-review-findings.js +106 -0
  126. package/dist/services/memory-corrected-review-inputs.js +210 -0
  127. package/dist/services/memory-corrected-review-projection.js +78 -0
  128. package/dist/services/memory-corrected-review-receipt.js +170 -0
  129. package/dist/services/memory-corrected-review-registration.js +191 -0
  130. package/dist/services/memory-correction-check-receipt.js +219 -0
  131. package/dist/services/memory-correction-input-owner.js +6 -0
  132. package/dist/services/memory-discover-handler.js +104 -0
  133. package/dist/services/memory-finish-evidence.js +435 -0
  134. package/dist/services/memory-finish-handler.js +215 -0
  135. package/dist/services/memory-finish-integration.js +280 -0
  136. package/dist/services/memory-finish-readiness.js +122 -0
  137. package/dist/services/memory-git-integration.js +151 -0
  138. package/dist/services/memory-historical-corrected-review.js +206 -0
  139. package/dist/services/memory-historical-original-review.js +130 -0
  140. package/dist/services/memory-historical-repair-registration.js +99 -0
  141. package/dist/services/memory-historical-second-review.js +128 -0
  142. package/dist/services/memory-init-bootstrap-readiness.js +210 -0
  143. package/dist/services/memory-init-contexts.js +89 -0
  144. package/dist/services/memory-init-controller-start.js +84 -0
  145. package/dist/services/memory-init-engine-release.js +223 -0
  146. package/dist/services/memory-init-git-policy.js +136 -0
  147. package/dist/services/memory-init-lint-capability.js +185 -0
  148. package/dist/services/memory-init-native-capacity.js +241 -0
  149. package/dist/services/memory-init-preflight.js +312 -0
  150. package/dist/services/memory-init-project-bootstrap.js +599 -0
  151. package/dist/services/memory-init-request-candidate.js +157 -0
  152. package/dist/services/memory-init-request-input.js +66 -0
  153. package/dist/services/memory-init-resource-storage.js +125 -0
  154. package/dist/services/memory-init-run-allocation.js +81 -0
  155. package/dist/services/memory-init-run-bundle.js +98 -0
  156. package/dist/services/memory-init-start.js +175 -0
  157. package/dist/services/memory-init-startup-custody.js +80 -0
  158. package/dist/services/memory-init-startup-input.js +240 -0
  159. package/dist/services/memory-init-storage.js +81 -0
  160. package/dist/services/memory-init-stores.js +359 -0
  161. package/dist/services/memory-init-worktrunk-capability.js +172 -0
  162. package/dist/services/memory-layer-publication.js +611 -0
  163. package/dist/services/memory-layer.js +156 -0
  164. package/dist/services/memory-lint-config.js +85 -0
  165. package/dist/services/memory-native-review-copy.js +165 -0
  166. package/dist/services/memory-normal-flow-entry.js +75 -0
  167. package/dist/services/memory-ordinary-defaults.js +112 -0
  168. package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
  169. package/dist/services/memory-preflight-handler.js +259 -0
  170. package/dist/services/memory-repair-current-view.js +299 -0
  171. package/dist/services/memory-repair-document-delta.js +451 -0
  172. package/dist/services/memory-repair-history.js +491 -0
  173. package/dist/services/memory-repair-physical-binding.js +312 -0
  174. package/dist/services/memory-repair-publication-files.js +152 -0
  175. package/dist/services/memory-repair-publication-intent.js +27 -0
  176. package/dist/services/memory-repair-publication-plan.js +160 -0
  177. package/dist/services/memory-repair-publication-source.js +225 -0
  178. package/dist/services/memory-repair-publication.js +794 -0
  179. package/dist/services/memory-repair-registration.js +239 -0
  180. package/dist/services/memory-repair-work-baseline.js +413 -0
  181. package/dist/services/memory-repair-work-completion.js +97 -0
  182. package/dist/services/memory-repair-work-custody.js +73 -0
  183. package/dist/services/memory-repair-work-results.js +89 -0
  184. package/dist/services/memory-repair-workspace.js +252 -0
  185. package/dist/services/memory-repair-writer-copy.js +735 -0
  186. package/dist/services/memory-result-binding.js +173 -0
  187. package/dist/services/memory-retained-review-authority.js +129 -0
  188. package/dist/services/memory-retained-review-results.js +70 -0
  189. package/dist/services/memory-retained-second-baseline.js +102 -0
  190. package/dist/services/memory-retained-second-repairs.js +156 -0
  191. package/dist/services/memory-review-acceptance.js +214 -0
  192. package/dist/services/memory-review-correction-plan.js +171 -0
  193. package/dist/services/memory-review-handler.js +585 -0
  194. package/dist/services/memory-review-inputs.js +200 -0
  195. package/dist/services/memory-review-receipt.js +180 -0
  196. package/dist/services/memory-review-registration.js +108 -0
  197. package/dist/services/memory-review-work-custody.js +124 -0
  198. package/dist/services/memory-review-work-input.js +72 -0
  199. package/dist/services/memory-review-work-results.js +73 -0
  200. package/dist/services/memory-second-corrected-review-inputs.js +165 -0
  201. package/dist/services/memory-second-correction-plan.js +76 -0
  202. package/dist/services/memory-second-repair-baseline.js +91 -0
  203. package/dist/services/memory-second-repair-launch.js +614 -0
  204. package/dist/services/memory-second-repair-registration.js +88 -0
  205. package/dist/services/memory-second-repair-source.js +187 -0
  206. package/dist/services/memory-second-review-acceptance.js +115 -0
  207. package/dist/services/memory-source-observations.js +117 -0
  208. package/dist/services/memory-stage-dispatch.js +28 -0
  209. package/dist/services/memory-stage-results.js +199 -0
  210. package/dist/services/memory-verification-acceptance.js +135 -0
  211. package/dist/services/memory-verification-checks.js +252 -0
  212. package/dist/services/memory-verification-receipt.js +159 -0
  213. package/dist/services/memory-verify-handler.js +135 -0
  214. package/dist/services/memory-work-result-preparation.js +63 -0
  215. package/dist/services/model-catalog.js +109 -0
  216. package/dist/services/model-discovery.js +46 -0
  217. package/dist/services/operator-profiles.js +236 -0
  218. package/dist/services/projects.js +17 -2
  219. package/dist/services/recovery-snapshot-database.js +416 -1
  220. package/dist/services/release-archive-producer.js +52 -0
  221. package/dist/services/release-archive.js +143 -0
  222. package/dist/services/release-cache.js +142 -0
  223. package/dist/services/release-compatibility.js +18 -0
  224. package/dist/services/release-download.js +67 -0
  225. package/dist/services/release-envelope.js +104 -0
  226. package/dist/services/release-manifest.js +45 -0
  227. package/dist/services/release-payload.js +114 -0
  228. package/dist/services/release-producer.js +63 -0
  229. package/dist/services/release-provenance.js +160 -0
  230. package/dist/services/release-registry.js +66 -0
  231. package/dist/services/release-source.js +194 -0
  232. package/dist/services/repair-intents.js +1 -1
  233. package/dist/services/review-copy.js +10 -2
  234. package/dist/services/run-control.js +80 -9
  235. package/dist/services/run-controller-capture.js +10 -3
  236. package/dist/services/run-controller-process.js +21 -0
  237. package/dist/services/run-controller-recovery.js +3 -1
  238. package/dist/services/run-controller-state.js +1 -1
  239. package/dist/services/run-controller.js +101 -40
  240. package/dist/services/run-recovery-guard.js +2 -0
  241. package/dist/services/run-recovery.js +145 -5
  242. package/dist/services/run-start-custody.js +177 -0
  243. package/dist/services/runs.js +319 -25
  244. package/dist/services/setup.js +377 -0
  245. package/dist/services/stage-context.js +9 -1
  246. package/dist/services/stage-handlers.js +74 -0
  247. package/dist/services/stage-report-renderer.js +63 -0
  248. package/dist/services/stage-work-graph.js +17 -0
  249. package/dist/services/vnext-code.js +3 -3
  250. package/dist/services/vnext-execution-profile.js +2 -2
  251. package/dist/services/vnext-fanout.js +70 -9
  252. package/dist/services/vnext-merge.js +8 -3
  253. package/dist/services/vnext-plan-review.js +6 -4
  254. package/dist/services/work-registry.js +616 -71
  255. package/dist/services/workspace-files.js +1 -1
  256. package/dist/storage/database.js +84 -6
  257. package/dist/storage/writer-contract.js +11 -7
  258. package/package.json +13 -3
  259. package/tools/create-canon-release.mjs +33 -0
  260. package/tools/produce-hybrid-release.mjs +41 -0
@@ -292,6 +292,34 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
292
292
  const nativeVersions = new Map();
293
293
  for (const [directory, { state, records }] of daemonInventories) {
294
294
  let failedStart = null;
295
+ // Explicit stop can reconcile a retired owner whose last bookkeeping ACK
296
+ // failed. This is physical retirement, not a fabricated clean shutdown.
297
+ if (control?.action === "stop" && state.shutdown_state === "cleanup_failed")
298
+ try {
299
+ const daemonId = state.daemon_id ?? state.config?.daemonId;
300
+ const daemon = records.filter(record => record.kind.endsWith("-daemon"));
301
+ const provider = records.filter(record => record.kind.endsWith("-provider"));
302
+ const operations = retainedDaemonOperations(directory);
303
+ const owner = state.config?.runtime_owner;
304
+ if (daemonId && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))
305
+ && state.active_tree === false && !state.active_operation && state.shutdown?.daemon_id === daemonId
306
+ && state.shutdown.phases?.tree === true && state.shutdown.phases.provider === true
307
+ && daemon.length === 1 && provider.length === 1 && records.length === 2
308
+ && daemon[0].id === state.resource_process?.id && daemon[0].lease_token === state.resource_process.lease_token && daemon[0].pid === state.pid
309
+ && provider[0].id === state.provider_process?.id && provider[0].lease_token === state.provider_process.lease_token && provider[0].pid === state.provider_pid
310
+ && JSON.parse(daemon[0].metadata_json).dispatch_barrier === "run-generation@1"
311
+ && records.every(record => record.pid && record.pid_started_at && ["stopped", "failed"].includes(record.state) && record.finished_at && !processTreeIsAlive(record))
312
+ && operations.length > 0 && operations.every(operation => ["completed", "failed", "interrupted"].includes(String(operation.state))
313
+ && (operation.operation === "daemon.start" ? operation.daemon_id === null && operation.launch?.runtime_owner === JSON.stringify(owner)
314
+ : operation.daemon_id === daemonId && ["daemon.stop", "daemon.status", "session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.cancel", "session.inspect"].includes(String(operation.operation))
315
+ && (operation.session_id == null || state.sessions.some(item => (typeof item === "string" ? item : adapterSessionId(item)) === operation.session_id))))) {
316
+ nativeVersions.set(directory, nativeInventoryVersion(directory, operations));
317
+ closedDaemons.set(directory, { source: "observed_owned_retirement", daemon_id: daemonId, stopped: true, settled: true,
318
+ session_ids: state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item)),
319
+ processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })) });
320
+ }
321
+ }
322
+ catch { /* Invalid or unfinished evidence remains on the existing recovery path. */ }
295
323
  if (!interrupt) {
296
324
  try {
297
325
  const nativeOperations = retainedDaemonOperations(directory);
@@ -309,16 +337,48 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
309
337
  const saved = JSON.parse(operation.input_json);
310
338
  return saved.owner === owner?.owner_id && saved.executable === directoryAdapters.get(directory) && saved.args?.[0] === "daemon" && saved.args?.[1] === "start" && saved.args.includes("--state-dir") && saved.args[saved.args.indexOf("--state-dir") + 1] === directory;
311
339
  });
340
+ const cleanup = start?.error?.details;
341
+ const proof = cleanup?.startup_child_cleanup;
342
+ let ownedChildGone = false;
343
+ if (proof && !cleanup?.provider_cleanup_failed && !cleanup?.cleanup_failed && provider.length === 0 && !state.provider_process && state.provider_pid == null
344
+ && daemon.length === 1 && daemon[0].pid === null && daemon[0].pid_started_at === null
345
+ && proof.settled === true && proof.physical_exit === "ESRCH" && proof.daemon_id === state.daemon_id
346
+ && proof.process_id === daemon[0].id
347
+ && proof.pid === state.pid && Number.isSafeInteger(proof.pid) && Number(proof.pid) > 0
348
+ && proof.pgid === (process.platform === "win32" ? null : proof.pid)
349
+ && typeof proof.observed_at === "string" && Number.isFinite(Date.parse(proof.observed_at))
350
+ && proof.owner_id === owner?.owner_id && proof.project_id === project.id && proof.run_id === input.runId
351
+ && proof.operation_id === start?.operation_id && proof.operation_id === owner?.operation_id && proof.state_dir === directory) {
352
+ let leaderGone = false;
353
+ try {
354
+ process.kill(Number(proof.pid), 0);
355
+ }
356
+ catch (error) {
357
+ leaderGone = error.code === "ESRCH";
358
+ }
359
+ if (leaderGone) {
360
+ if (process.platform === "win32")
361
+ ownedChildGone = true;
362
+ else {
363
+ try {
364
+ process.kill(-Number(proof.pgid), 0);
365
+ }
366
+ catch (error) {
367
+ ownedChildGone = error.code === "ESRCH";
368
+ }
369
+ }
370
+ }
371
+ }
312
372
  if (state.shutdown_state === "starting" && state.sessions?.length === 0 && state.active_tree !== true && !state.active_operation
313
373
  && start?.operation === "daemon.start" && start.state === "failed" && start.predecessor_daemon_id === null && start.daemon_id === null
314
374
  && typeof state.daemon_id === "string" && controllerStarts.length === 1 && starts.length === 1 && starts[0].operation_id === start.operation_id
315
375
  && launch?.runtime_owner === JSON.stringify(owner)
316
376
  && daemon.length === 1 && provider.length <= 1 && records.length === daemon.length + provider.length
317
377
  && daemon[0].id === state.resource_process?.id && daemon[0].lease_token === state.resource_process.lease_token
318
- && (state.pid == null || state.pid === daemon[0].pid)
378
+ && (state.pid == null || state.pid === daemon[0].pid || ownedChildGone)
319
379
  && (!state.provider_process && state.provider_pid == null && provider.length === 0 || provider.length === 1 && provider[0].id === state.provider_process?.id && provider[0].lease_token === state.provider_process.lease_token && state.provider_pid === provider[0].pid)
320
- && records.every(record => record.pid && record.pid_started_at && !processTreeIsAlive(record) && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at))) {
321
- failedStart = { source: "failed_start_without_create", daemon_id: state.daemon_id, operation_id: start.operation_id, session_ids: [], processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })) };
380
+ && records.every(record => (record.pid && record.pid_started_at && !processTreeIsAlive(record) || ownedChildGone && record === daemon[0]) && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at))) {
381
+ failedStart = { source: "failed_start_without_create", daemon_id: state.daemon_id, operation_id: start.operation_id, session_ids: [], processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })), ...(ownedChildGone ? { startup_child_cleanup: proof } : {}) };
322
382
  closedDaemons.set(directory, failedStart);
323
383
  }
324
384
  for (const operation of nativeOperations)
@@ -331,6 +391,8 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
331
391
  unresolved.push({ state_dir: directory, status: "pending", reason: "daemon_operation_inventory_invalid" });
332
392
  }
333
393
  }
394
+ if (closedDaemons.get(directory)?.source === "observed_owned_retirement")
395
+ continue;
334
396
  if (state.shutdown_state !== "clean") {
335
397
  if (state.shutdown_state === "unclean")
336
398
  unresolved.push({ state_dir: directory, status: "pending", reason: "native_shutdown_unclean" });
@@ -634,11 +696,17 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
634
696
  const nativeOperations = retainedDaemonOperations(directory);
635
697
  const targetList = [...targets.values()];
636
698
  const sessionsSettled = ids.every(id => nodes.some((node, index) => targetList[index]?.session.state_dir === directory && targetList[index]?.session.id === id && node.status === "completed"));
637
- // A confirmed failed tree guard admits a new shutdown attempt only after
638
- // fresh tree settlement. Unknown outcomes retain their original ID.
699
+ // A confirmed failure may retry the same incarnation's settled tree or
700
+ // remaining idempotent resource finish. Unknown outcomes keep their ID.
639
701
  if (sessionsSettled) {
702
+ const remainingFinish = state.shutdown_state === "cleanup_failed" && state.active_tree === false
703
+ && state.shutdown?.daemon_id === daemonId && state.shutdown.phases?.tree === true
704
+ && state.shutdown.required_phases?.includes("daemon_resource")
705
+ && state.shutdown.phases.daemon_resource !== true
706
+ && state.shutdown.required_phases.every(phase => phase === "daemon_resource" || state.shutdown.phases[phase] === true);
640
707
  while (nativeOperations.some(operation => operation.operation_id === operationId && operation.daemon_id === daemonId && operation.operation === "daemon.stop"
641
- && operation.state === "failed" && operation.error?.code === "tree_not_settled"))
708
+ && operation.state === "failed" && (operation.error?.code === "tree_not_settled"
709
+ || remainingFinish && operation.error?.code === "process_maintenance_timeout")))
642
710
  operationId += ":finalize";
643
711
  }
644
712
  assertCurrent();
@@ -697,11 +765,13 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
697
765
  const executable = directoryAdapters.get(directory);
698
766
  if (!inventory)
699
767
  continue;
768
+ 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");
769
+ if (!pending.length)
770
+ continue;
700
771
  if (!daemonId || !executable || !node.operation_id) {
701
772
  terminalizedOperations.push({ state_dir: directory, status: "pending", reason: "operation_terminalization_binding_unknown" });
702
773
  continue;
703
774
  }
704
- 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");
705
775
  terminalizedOperations.push(...await Promise.all(pending.map(async (operation) => {
706
776
  try {
707
777
  const operationId = operation.operation_id, sessionId = typeof operation.session_id === "string" ? operation.session_id : null;
@@ -795,7 +865,8 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
795
865
  verifyInventory();
796
866
  }
797
867
  catch (error) {
798
- unresolved.push({ status: "pending", reason: error instanceof AppError && error.code === "owned_inventory_changed" ? error.code : "owned_inventory_recheck_failed" });
868
+ unresolved.push({ status: "pending", reason: error instanceof AppError && error.code === "owned_inventory_changed" ? error.code : "owned_inventory_recheck_failed",
869
+ ...(error instanceof AppError && error.code === "owned_inventory_changed" ? { components: error.details.components } : {}) });
799
870
  }
800
871
  }
801
872
  assertCurrent();
@@ -818,7 +889,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
818
889
  }
819
890
  export function nativeInventoryVersion(directory, operations = retainedDaemonOperations(directory)) {
820
891
  const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
821
- 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"];
892
+ const keys = ["schema_id", "daemon_id", "config", "pid", "provider_pid", "shutdown_state", "shutdown", "active_operation", "active_tree", "sessions", "descendants", "root_session_id", "resource_process", "provider_process", "owned_processes"];
822
893
  // Only these native verbs are read-only. Unknown verbs remain guarded.
823
894
  const productive = operations.filter(operation => !isObservationOperation(operation.operation));
824
895
  return crypto.createHash("sha256").update(JSON.stringify([Object.fromEntries(keys.map(key => [key, state[key]])), productive])).digest("hex");
@@ -6,7 +6,7 @@ import { createEvalRunSnapshot, verifyControllerBoundarySnapshot } from "./eval-
6
6
  import { observeControllerBoundary } from "./run-control.js";
7
7
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
8
8
  import { appendEvent, digest, errorRecord, requireController, requireRun } from "./run-controller-state.js";
9
- import { getFlowRunStatus } from "./runs.js";
9
+ import { getFlowRunStatus, readFlowRunExecutionBundle } from "./runs.js";
10
10
  import { recoveryObservationBudget, withRecoveryObservation } from "./recovery-observation-budget.js";
11
11
  import { getResourceDatabase } from "../storage/database.js";
12
12
  import { managedProcessBinding, resourceHome } from "./managed-processes.js";
@@ -60,6 +60,7 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
60
60
  throw error;
61
61
  }
62
62
  }
63
+ const preparedBundle = readFlowRunExecutionBundle(context, scope);
63
64
  const assertStartOwner = prepareControllerPublication(context, row, assertPhysicalOwner);
64
65
  context.db.writeTransaction(() => {
65
66
  assertStartOwner();
@@ -82,7 +83,7 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
82
83
  if (!current || !["done", "skipped"].includes(current.status) || digest(identity(current)) !== boundaryKey)
83
84
  throw new AppError("controller_boundary_changed", "Stage identity changed after its capture fence", 1);
84
85
  if (physical) {
85
- const continuation = getFlowRunStatus(context, scope).continuation;
86
+ const continuation = getFlowRunStatus(context, scope, preparedBundle ?? undefined).continuation;
86
87
  const stoppedAtTarget = nextStage === null && manifest.request.stop_after === stage.stage && continuation.kind === "start_stage";
87
88
  if (nextStage ? continuation.kind !== "start_stage" || continuation.stage !== nextStage : continuation.kind !== "terminal" && !stoppedAtTarget)
88
89
  throw new AppError("controller_boundary_changed", "Stage successor changed after its capture fence", 1);
@@ -123,7 +124,13 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
123
124
  assertCurrent();
124
125
  context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(COALESCE(receipt_json, '{}'), '$.recovery_observation', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(observationBudget.state()), context.now(), operationId]);
125
126
  let verify = () => { throw new AppError("controller_boundary_unsettled", "Boundary has no stable writer observation", 1); };
126
- const observation = await withRecoveryObservation(observationBudget, () => observeControllerBoundary(context, { ...scope, controllerId: row.controller_id, operationId, assertCurrent, retainVerification: value => { verify = value; } }));
127
+ const observation = await withRecoveryObservation(observationBudget, () => observeControllerBoundary(context, { ...scope, controllerId: row.controller_id, operationId, assertCurrent, retainVerification: value => {
128
+ verify = () => {
129
+ value();
130
+ if (readFlowRunExecutionBundle(context, scope)?.sha256 !== preparedBundle?.sha256)
131
+ throw new AppError("controller_boundary_changed", "Frozen bundle changed before snapshot publication", 1);
132
+ };
133
+ } }));
127
134
  if (observation.settled === true) {
128
135
  try {
129
136
  createEvalRunSnapshot(context, { ...scope, output, ...(nextStage ? { stageEntry: nextStage } : { candidate: true }), boundary: { controllerId: row.controller_id, operationId, boundaryKey, observation, verify } });
@@ -48,6 +48,22 @@ export function assertControllerRecoveryAllowed(context, row) {
48
48
  || !context.db.get("SELECT 1 FROM run_controls WHERE control_id = ? AND recovery_id = ? AND status = 'resuming'", [state.recovery.control_id, state.recovery.recovery_id]))
49
49
  throw new AppError("run_control_superseded", "Controller launch no longer owns the accepted recovery", 1);
50
50
  }
51
+ /** An admitted physical owner may renew while closing a terminal RUN. This
52
+ * does not admit a new owner or release a recovery/generation fence. */
53
+ export function assertControllerLeaseAllowed(context, row) {
54
+ const current = requireController(context, row.controller_id);
55
+ if (current.owner_token !== row.owner_token || current.generation !== row.generation
56
+ || current.owner_pid !== process.pid || current.process_id !== row.process_id
57
+ || current.process_lease_token !== row.process_lease_token || !current.process_id || !current.process_lease_token
58
+ || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
59
+ throw new AppError("controller_owner_mismatch", "Lease renewal no longer owns this physical controller", 1);
60
+ const run = requireRun(context, current.project_id, current.run_id);
61
+ if ((["done", "failed", "cancelled"].includes(run.status) || current.status === "capturing") && launchRecord(context, current)?.value.dispatch_ready === true) {
62
+ assertControllerRecoveryAllowed(context, current);
63
+ return;
64
+ }
65
+ assertLaunchAllowed(context, current);
66
+ }
51
67
  function retire(context, row, identity) {
52
68
  const record = managedProcessStatus(context).find(item => item.id === identity.id);
53
69
  if (!record || record.kind !== "run-controller" || record.owner_id !== row.controller_id || record.project_id !== row.project_id || record.run_id !== row.run_id
@@ -123,6 +139,10 @@ export function claimControllerProcess(context, row) {
123
139
  throw new AppError("controller_owner_mismatch", "Managed registration is not authorized", 1);
124
140
  const owner = birth();
125
141
  context.db.writeTransaction(() => {
142
+ // Recovery fences are reconciled by serve's retained failure boundary;
143
+ // only terminal admission must reject before claiming a physical owner.
144
+ if (["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
145
+ throw new AppError("invalid_run_state", "A terminal RUN cannot start a physical controller", 1);
126
146
  const claimed = context.db.run("UPDATE run_controllers SET owner_pid = ?, status = 'running', updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [process.pid, context.now(), row.controller_id, row.owner_token]);
127
147
  if (claimed.changes !== 1)
128
148
  throw new AppError("controller_already_owned", "Managed owner has already claimed this launch", 1);
@@ -142,6 +162,7 @@ export function claimControllerProcess(context, row) {
142
162
  }
143
163
  }
144
164
  export function markControllerDispatchReady(context, row) {
165
+ assertLaunchAllowed(context, row);
145
166
  context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(receipt_json, '$.dispatch_ready', json('true')), updated_at = ? WHERE operation_id = ?", [context.now(), launchId(row)]);
146
167
  }
147
168
  export async function spawnController(context, row) {
@@ -10,6 +10,7 @@ import { verifyRecoverySnapshot } from "./eval-snapshots.js";
10
10
  import { recoveryGuard, resumeRunRecovery } from "./run-recovery.js";
11
11
  import { prepareRunRecoveryDispatch, retainRecoveryCapacityIntent } from "./run-recovery-runtime.js";
12
12
  import { runtimeHarness } from "./execution-policy.js";
13
+ import { selectControllerExecution } from "./controller-execution.js";
13
14
  import { managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, processStartedAt } from "./managed-processes.js";
14
15
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, digest, errorRecord } from "./run-controller-state.js";
15
16
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
@@ -74,7 +75,8 @@ export function prepareControllerResume(context, input) {
74
75
  if (!guard || guard.recovery_id !== control?.recovery_id || guard.status !== "sealed" || control.status !== "settled" || !guard.capture_path || JSON.parse(guard.settlement_json ?? "{}").settled !== true)
75
76
  throw new AppError("run_control_not_resumable", "Resume requires the current settled control and its immutable recovery capture", 1);
76
77
  const manifest = JSON.parse(row.manifest_json), state = JSON.parse(row.state_json);
77
- if (!manifest.execution_profile?.settings.execution || !manifest.execution_profile.agent_profiles || !path.isAbsolute(manifest.resource_home) || !path.isAbsolute(manifest.flow_executable))
78
+ selectControllerExecution(context, run, manifest);
79
+ if (!path.isAbsolute(manifest.resource_home) || !path.isAbsolute(manifest.flow_executable))
78
80
  throw new AppError("controller_manifest_invalid", "Resume requires the original frozen controller manifest", 1);
79
81
  if (state.current_session === null || !state.sessions[state.current_session]?.id)
80
82
  throw new AppError("recovery_owner_missing", "Resume requires the retained coordinator Session", 1);
@@ -3,7 +3,7 @@ import { AppError, errorRecord as structuredErrorRecord } from "../shared/errors
3
3
  import { runtimeHarness } from "./execution-policy.js";
4
4
  import { managedProcessRuntimeHome, managedProcessStatus, processTreeIsAlive } from "./managed-processes.js";
5
5
  export function requireRun(context, projectId, runId) {
6
- const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root, status, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
6
+ const run = context.db.get("SELECT id, project_id, flow_kind, project_root, workspace_root, run_root, status, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
7
7
  if (!run?.run_root)
8
8
  throw new AppError("not_found", "Managed RUN has no materialized root", 1, { run_id: runId });
9
9
  return run;
@@ -1,4 +1,9 @@
1
1
  import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ export function recoveryPacketMatchesStage(packet, scope) {
3
+ return packet.schema_id === 'dd-flow/root-recovery-packet@1' && packet.continuation === 'unfinished_work'
4
+ && packet.run_id === scope.runId && packet.generation === scope.generation && packet.stage === scope.stage
5
+ && packet.work_id === scope.workId && packet.paused === false;
6
+ }
2
7
  import crypto from "node:crypto";
3
8
  import fs from "node:fs";
4
9
  import path from "node:path";
@@ -13,10 +18,11 @@ import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
13
18
  import { parseRuntimeBudget } from "./runtime-budget.js";
14
19
  import { requireProjectByRoot } from "./projects.js";
15
20
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
16
- import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
21
+ import { getFlowRunStatus, setFlowRunRecoveryPaused, assertPreparedRunBundleSqlAuthority } from "./runs.js";
17
22
  import { loadExternalStageContext } from "./stage-context.js";
18
23
  import { flowCommand } from "./stage-pause.js";
19
- import { resolveStageTransition, sameSessionProfile, stageExecutionCwd, runtimeHarness } from "./execution-policy.js";
24
+ import { sameSessionProfile, runtimeHarness } from "./execution-policy.js";
25
+ import { selectControllerExecution } from "./controller-execution.js";
20
26
  import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
21
27
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
22
28
  import { assertManagedProcessLease, confirmManagedProcess, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
@@ -29,7 +35,7 @@ import { claimQueuedMergeRequest } from "./merge-server.js";
29
35
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
30
36
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, retainControllerCapacityIntent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
31
37
  import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
32
- import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerRecoveryAllowed } from "./run-controller-process.js";
38
+ import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerLeaseAllowed } from "./run-controller-process.js";
33
39
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
34
40
  import { retainedChildObserver } from "./controller-child-observer.js";
35
41
  import { capacityDelay, continueCodexCapacity, eligibleCodexChildOverload } from "./codex-capacity.js";
@@ -40,6 +46,11 @@ import { storageSessionId } from "./session-identity.js";
40
46
  import { CAPACITY_POLICY, nativeItemsSettled, overloadBurst, refusalObservation, terminalCodexOverload } from "../harness-runtime/lib/codex-capacity-policy.mjs";
41
47
  import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
42
48
  export { resumeRunController } from "./run-controller-recovery.js";
49
+ const preparedLaunchInputs = new WeakMap();
50
+ function launchInputDigest(input) {
51
+ return digest([input.projectRoot, input.runId, input.requestId, input.contextFile, input.contextSha256,
52
+ input.stopAfter ?? null, input.captureRoot ?? null]);
53
+ }
43
54
  export function prepareRunControllerLaunch(context, input) {
44
55
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
45
56
  throw new AppError("usage", "Managed launch needs a request id and context SHA-256", 2);
@@ -60,58 +71,79 @@ export function prepareRunControllerLaunch(context, input) {
60
71
  if (existing) {
61
72
  if (existing.request_hash !== requestHash)
62
73
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
63
- return { kind: "replay", existing };
74
+ selectControllerExecution(context, run, JSON.parse(existing.manifest_json));
75
+ const prepared = { kind: "replay", existing };
76
+ preparedLaunchInputs.set(prepared, { home: context.ddFlowHome, sha256: launchInputDigest(input) });
77
+ return prepared;
64
78
  }
65
79
  const generation = assertRunMutationAllowed(context, project.id, run.id);
66
80
  if (run.status !== "running")
67
81
  throw new AppError("invalid_run_state", "Managed launch requires a running logical RUN", 1, { status: run.status });
68
- const index = JSON.parse(run.index_json);
69
- const execution = index.execution_profile;
70
- if (!execution?.settings.execution || !execution.agent_profiles)
71
- throw new AppError("execution_profile_not_frozen", "Managed launch requires the RUN's frozen execution profiles", 1);
82
+ const routing = selectControllerExecution(context, run);
72
83
  const contexts = readContexts(input.contextFile, input.contextSha256);
84
+ if (routing.bundle) {
85
+ for (const stage of [...Object.keys(contexts), ...(input.stopAfter ? [input.stopAfter] : [])])
86
+ routing.bundle.descriptor.stage(stage);
87
+ }
73
88
  const next = getFlowRunStatus(context, { projectRoot: project.root, runId: run.id }).continuation;
74
89
  if (next.kind !== "start_stage" || !next.stage)
75
90
  throw new AppError("controller_attach_required", "Managed launch cannot replace an attached or unfinished coordinator; reconcile its existing owner", 1, { continuation: next });
76
91
  if (!contexts[next.stage])
77
92
  throw new AppError("controller_context_missing", "Managed context omits the first stage", 2, { stage: next.stage });
78
93
  const adapters = {};
79
- for (const profile of Object.values(execution.agent_profiles)) {
94
+ for (const profile of Object.values(routing.profiles)) {
80
95
  const harness = runtimeHarness(profile);
81
96
  adapters[harness] = { executable: resolveHarnessCommand(context.ddFlowHome, harness, "adapter"), runtime_args: harnessRuntimeArguments(context.ddFlowHome, harness) };
82
97
  }
83
98
  const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
84
99
  if (!fs.existsSync(entry))
85
100
  throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
86
- return { kind: "create", project, run, request, requestHash, generation, budget, execution, contexts, adapters };
101
+ const prepared = { kind: "create", project, run, request, requestHash, generation, budget, routing, contexts, adapters };
102
+ preparedLaunchInputs.set(prepared, { home: context.ddFlowHome, sha256: launchInputDigest(input) });
103
+ return prepared;
87
104
  }
88
- export async function launchRunController(context, input, prepared = prepareRunControllerLaunch(context, input)) {
89
- if (prepared.kind === "replay")
90
- return { ...await spawnController(context, prepared.existing), reused: true };
91
- const { project, run, request, requestHash, generation, budget, execution, contexts, adapters } = prepared;
105
+ /** Retain the existing launch intent and frozen inputs without starting a
106
+ * process. Dispatch remains exclusively owned by launchRunController. */
107
+ export function retainRunControllerLaunch(context, input, prepared = prepareRunControllerLaunch(context, input)) {
108
+ const selection = preparedLaunchInputs.get(prepared);
109
+ if (!selection || selection.home !== context.ddFlowHome || selection.sha256 !== launchInputDigest(input)) {
110
+ throw new AppError("runtime_request_conflict", "Controller launch differs from its prepared request", 1, { effect: "no_effect" });
111
+ }
112
+ if (prepared.kind === "replay") {
113
+ const current = requireController(context, prepared.existing.controller_id);
114
+ if (current.request_hash !== prepared.existing.request_hash)
115
+ throw new AppError("runtime_request_conflict", "Controller launch authority changed after replay preparation", 1);
116
+ selectControllerExecution(context, requireRun(context, current.project_id, current.run_id), JSON.parse(current.manifest_json));
117
+ return { controller: current, reused: true };
118
+ }
119
+ const { project, run, request, requestHash, generation, budget, routing, contexts, adapters } = prepared;
92
120
  assertRunMutationAllowed(context, project.id, run.id, generation);
93
121
  ensureRunControllerStorage(context.db);
94
122
  const controllerId = `DRV-${crypto.randomUUID()}`;
95
123
  const root = path.join(run.run_root, "controllers", controllerId);
96
124
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
97
- const flowExecutable = prepareHarnessFlowExecutable(context, root);
125
+ const flowExecutable = prepareHarnessFlowExecutable(context, root, routing.bundle?.engine());
98
126
  const frozenContexts = {};
99
127
  for (const [stage, value] of Object.entries(contexts)) {
100
128
  const file = path.join(root, `${stage}.context.json`);
101
129
  fs.writeFileSync(file, value.bytes, { flag: "wx", mode: 0o600 });
102
130
  frozenContexts[stage] = { file, sha256: value.sha256 };
103
131
  }
104
- const manifest = { schema_id: "dd-flow/run-controller-manifest@1", request, execution_profile: execution, contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget };
132
+ const manifest = { schema_id: routing.kind === "descriptor" ? "dd-flow/run-controller-manifest@2" : "dd-flow/run-controller-manifest@1", request,
133
+ ...(routing.kind === "descriptor" ? { execution_binding: routing.binding } : { execution_profile: routing.legacy }),
134
+ contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget };
105
135
  const token = crypto.randomUUID();
106
136
  const now = context.now();
107
137
  const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
108
138
  const launcher = { pid: process.pid, pid_started_at: processStartedAt(process.pid) };
109
139
  const duplicate = context.db.writeTransaction(() => {
140
+ if (routing.bundle)
141
+ assertPreparedRunBundleSqlAuthority(context, { projectId: project.id, runId: run.id }, routing.bundle);
110
142
  const duplicate = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
111
143
  if (duplicate) {
112
144
  if (duplicate.request_hash !== requestHash)
113
145
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
114
- return { ...controllerReceipt(duplicate), reused: true };
146
+ return duplicate;
115
147
  }
116
148
  assertRunMutationAllowed(context, project.id, run.id, generation);
117
149
  if (requireRun(context, project.id, run.id).status !== "running")
@@ -126,9 +158,16 @@ export async function launchRunController(context, input, prepared = prepareRunC
126
158
  appendEvent(context, controllerId, "launch_requested", { request_id: input.requestId, generation, request_hash: requestHash });
127
159
  return null;
128
160
  });
129
- if (duplicate)
130
- return duplicate;
131
- return await spawnController(context, requireController(context, controllerId));
161
+ return { controller: duplicate ?? requireController(context, controllerId), reused: duplicate !== null };
162
+ }
163
+ export async function launchRunController(context, input, prepared = prepareRunControllerLaunch(context, input)) {
164
+ const retained = retainRunControllerLaunch(context, input, prepared);
165
+ // A simultaneous publisher already owns dispatch. Preserve the original
166
+ // race behavior: only an explicit replay may reconcile that existing owner.
167
+ if (prepared.kind === "create" && retained.reused)
168
+ return { ...controllerReceipt(retained.controller), reused: true };
169
+ const receipt = await spawnController(context, retained.controller);
170
+ return retained.reused ? { ...receipt, reused: true } : receipt;
132
171
  }
133
172
  /**
134
173
  * A registry write can be temporarily unavailable while another runtime client
@@ -179,6 +218,7 @@ export async function serveRunController(context, input) {
179
218
  throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
180
219
  const confirmed = confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
181
220
  const lease = controllerLeaseMonitor(attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: row.process_id, leaseToken: row.process_lease_token }), confirmed.lease_expires_at);
221
+ const state = JSON.parse(row.state_json);
182
222
  // Resource and RUN commits are distinct effects. A RUN keepalive failure must
183
223
  // neither erase a resource receipt nor grant RUN dispatch authority.
184
224
  const mergeLease = controllerLeaseMonitor(attemptMs => {
@@ -194,21 +234,19 @@ export async function serveRunController(context, input) {
194
234
  return { owned: true, lease_expires_at: expiry };
195
235
  });
196
236
  const assertLeaseFence = () => {
197
- const current = requireController(context, row.controller_id);
198
- if (current.owner_token !== row.owner_token || current.generation !== row.generation)
199
- throw new AppError("controller_owner_mismatch", "Lease renewal no longer owns this controller generation", 1);
200
- assertControllerRecoveryAllowed(context, current);
237
+ assertControllerLeaseAllowed(context, row);
201
238
  };
202
239
  const heartbeat = setInterval(() => {
203
240
  lease.background(assertLeaseFence, () => { });
204
- mergeLease.background(assertLeaseFence, () => { });
241
+ if (state.merge_claim)
242
+ mergeLease.background(assertLeaseFence, () => { });
205
243
  }, 30_000);
206
244
  heartbeat.unref();
207
245
  const manifest = JSON.parse(row.manifest_json);
208
- const state = JSON.parse(row.state_json);
209
246
  const assertPhysicalOwner = () => {
210
247
  lease.assertNoRenewal();
211
- mergeLease.assertNoRenewal();
248
+ if (state.merge_claim)
249
+ mergeLease.assertNoRenewal();
212
250
  assertManagedProcessLease({ ...context, databaseDeadlineAt: Date.now() + 5_000 }, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
213
251
  const current = requireController(context, row.controller_id);
214
252
  if (current.owner_pid !== process.pid || current.owner_token !== row.owner_token || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
@@ -221,7 +259,8 @@ export async function serveRunController(context, input) {
221
259
  };
222
260
  try {
223
261
  await lease.ensureConfirmed(assertLeaseFence);
224
- await mergeLease.ensureConfirmed(assertLeaseFence);
262
+ if (state.merge_claim)
263
+ await mergeLease.ensureConfirmed(assertLeaseFence);
225
264
  context.db.writeTransaction(() => {
226
265
  markControllerDispatchReady(context, row);
227
266
  if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
@@ -230,7 +269,8 @@ export async function serveRunController(context, input) {
230
269
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
231
270
  if (state.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
232
271
  await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
233
- await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); await mergeLease.ensureConfirmed(assertLeaseFence); });
272
+ await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); if (state.merge_claim)
273
+ await mergeLease.ensureConfirmed(assertLeaseFence); });
234
274
  return controllerReceipt(requireController(context, row.controller_id));
235
275
  }
236
276
  catch (error) {
@@ -449,8 +489,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
449
489
  }
450
490
  }
451
491
  };
452
- const policy = manifest.execution_profile.settings.execution;
453
- const profiles = manifest.execution_profile.agent_profiles;
492
+ const routing = selectControllerExecution(context, requireRun(context, row.project_id, row.run_id), manifest);
493
+ const policy = routing.policy;
494
+ const profiles = routing.profiles;
454
495
  let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined, DD_FLOW_CODE_READINESS_OWNER: row.controller_id } };
455
496
  const bindInvocationScope = (session) => {
456
497
  executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
@@ -745,7 +786,10 @@ async function executeController(context, row, manifest, state, assertOwnership,
745
786
  if (state.current_session !== null && !state.sessions[state.current_session].stopped)
746
787
  bindInvocationScope(state.sessions[state.current_session]);
747
788
  const run = requireRun(context, row.project_id, row.run_id);
748
- if (state.pending_capacity && !["done", "failed", "cancelled"].includes(run.status)) {
789
+ const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
790
+ const next = view.continuation;
791
+ terminalFinalization = next.kind === "terminal";
792
+ if (state.pending_capacity && next.kind !== "terminal" && !["done", "failed", "cancelled"].includes(run.status)) {
749
793
  const pending = state.pending_capacity;
750
794
  const session = state.sessions.find(item => item.id === pending.session_id && !item.stopped);
751
795
  const operation = context.db.get("SELECT input_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ?", [pending.root_operation_id, row.controller_id, row.generation]);
@@ -757,9 +801,6 @@ async function executeController(context, row, manifest, state, assertOwnership,
757
801
  await prompt(session, fs.readFileSync(file, "utf8"), pending.root_operation_id, pending.kind === "stage" ? undefined : pending.kind);
758
802
  continue;
759
803
  }
760
- const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
761
- const next = view.continuation;
762
- terminalFinalization = next.kind === "terminal";
763
804
  const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
764
805
  if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
765
806
  throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
@@ -833,8 +874,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
833
874
  loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
834
875
  const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
835
876
  retainControllerContinuation(state, continuation, { code: "stage_entry_nonprogressing", message: "The coordinator returned without starting the assigned Stage", details: { stage: next.stage, attempt: next.attempt ?? null, last_receipt: state.last_receipt ?? null } });
836
- const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
837
- if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
877
+ const transition = routing.transition(next.stage, state.stage ?? undefined);
878
+ if (routing.kind === "sdlc" && next.stage === "merge" && routing.mergeMode === "server" && !state.merge_claim) {
838
879
  const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
839
880
  if (!request || request.status !== "queued")
840
881
  throw new AppError("controller_attach_required", "Managed MERGE needs an unclaimed queued request; reconcile an existing owner", 1, { request });
@@ -847,10 +888,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
847
888
  state.merge_claim = { request_id: claim.request.merge_request_id, token: claim.token, target_workspace: claim.request.target_workspace, work_id: claim.request.executor_work_id };
848
889
  saveState(context, row, state);
849
890
  appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
891
+ await renewLease();
850
892
  }
851
- const stageCwd = stageExecutionCwd({ stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(state.merge_claim ? { mergeTargetWorkspace: state.merge_claim.target_workspace } : {}) });
893
+ const stageCwd = routing.cwd(next.stage, state.merge_claim?.target_workspace);
852
894
  let session = state.current_session === null ? null : state.sessions[state.current_session];
853
- if (session && next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "same_session" && path.resolve(session.cwd) !== stageCwd) {
895
+ if (session && routing.kind === "sdlc" && next.stage === "merge" && routing.mergeMode === "same_session" && path.resolve(session.cwd) !== stageCwd) {
854
896
  throw new AppError("merge_session_workspace_conflict", "Same-session MERGE cannot change the native Session working directory", 1, { session_cwd: session.cwd, target_cwd: stageCwd });
855
897
  }
856
898
  if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator) || path.resolve(session.cwd) !== stageCwd) {
@@ -890,7 +932,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
890
932
  state.child_capacity = {};
891
933
  saveState(context, row, state);
892
934
  setStatus("running");
893
- if (next.stage === "code" || next.stage === "code-review") {
935
+ if (routing.kind === "sdlc" && (next.stage === "code" || next.stage === "code-review")) {
894
936
  // A CODE_REVIEW fork has source files but intentionally no installed
895
937
  // dependencies. Its repair Work executes checks just like CODE Work.
896
938
  const readiness = await prepareVnextCodeWorkspace(executionContext, { projectRoot: run.project_root, runId: run.id, ownerId: row.controller_id, stage: next.stage });
@@ -1016,6 +1058,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
1016
1058
  const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 });
1017
1059
  assertOwner();
1018
1060
  if (fanout.kind === "none") {
1061
+ // An ACK-only recovery Turn may leave the original root Work unfinished.
1062
+ // Continue that retained packet once; never restart its Stage or repeat
1063
+ // an unknown productive dispatch merely because the observer reattached.
1064
+ const recovery = context.db.get(`SELECT o.operation_id,b.packet_json,b.packet_sha256,b.work_id
1065
+ FROM run_controller_operations o JOIN run_recovery_bindings b ON b.recovery_id=json_extract(o.receipt_json,'$.recovery_id')
1066
+ JOIN run_recovery_guards g ON g.recovery_id=b.recovery_id
1067
+ WHERE o.controller_id=? AND o.generation=? AND o.status='completed' AND json_extract(o.input_json,'$.action')='control_resume'
1068
+ AND b.status='accepted' AND g.status='recovered' AND g.project_id=? AND g.run_id=?
1069
+ AND b.provider_session_id=? AND b.daemon_id=json_extract(o.receipt_json,'$.daemon_id') ORDER BY o.created_at DESC LIMIT 1`, [row.controller_id, row.generation, row.project_id, row.run_id, session.id]);
1070
+ if (recovery && digestBytes(recovery.packet_json) === recovery.packet_sha256) {
1071
+ const packet = JSON.parse(recovery.packet_json);
1072
+ const operationId = `${recovery.operation_id}:unfinished-root`;
1073
+ const work = context.db.get('SELECT status FROM works WHERE work_id=? AND project_id=? AND run_id=? AND parent_work_id IS NULL', [recovery.work_id, row.project_id, row.run_id]);
1074
+ if (recoveryPacketMatchesStage(packet, { runId: row.run_id, generation: row.generation, stage: next.stage, workId: recovery.work_id }) && work?.status === 'running'
1075
+ && !context.db.get('SELECT 1 FROM run_controller_operations WHERE operation_id=?', [operationId])) {
1076
+ await prompt(session, `Continue the original unfinished root Work from this accepted recovery packet. Inspect retained receipts and partial effects first. Do not restart the Stage, repeat observed lifecycle operations, create another Work or start a successor Stage. Preserve the packet write scope and result schema; stop at its original boundary.\n\nAccepted recovery packet (JSON data):\n${recovery.packet_json}`, operationId);
1077
+ continue;
1078
+ }
1079
+ }
1019
1080
  assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
1020
1081
  const cause = currentLifecycleFailure(executionContext, { runId: run.id, generation: row.generation, stage: next.stage, turn: state.turns, projectId: row.project_id, projectRoot: run.project_root });
1021
1082
  if (cause)
@@ -1,5 +1,6 @@
1
1
  import { AppError } from "../shared/errors.js";
2
2
  import { CAPACITY_POLICY, overloadBurst } from "../harness-runtime/lib/codex-capacity-policy.mjs";
3
+ import { assertRunStartupSettled } from "./run-start-custody.js";
3
4
  export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
4
5
  export function assertRunRecoveryAllowed(context, projectId, runId, expectedGeneration) {
5
6
  const guard = recoveryGuard(context, projectId, runId);
@@ -11,6 +12,7 @@ export function assertRunRecoveryAllowed(context, projectId, runId, expectedGene
11
12
  return generation;
12
13
  }
13
14
  export function assertRunMutationAllowed(context, projectId, runId, expectedGeneration) {
15
+ assertRunStartupSettled(context, projectId, runId);
14
16
  const generation = assertRunRecoveryAllowed(context, projectId, runId, expectedGeneration);
15
17
  if (context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")
16
18
  && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? AND status IN ('capturing','capture_failed') LIMIT 1", [projectId, runId]))