@deksden-com/dd-flow-cli 0.9.0-beta.136 → 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 (230) hide show
  1. package/CHANGELOG.md +12 -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 +56 -3
  7. package/dist/cli/command-option-descriptions.js +5 -0
  8. package/dist/cli/executor-onboarding.js +8 -3
  9. package/dist/cli/flows.js +29 -0
  10. package/dist/cli/help.js +240 -6
  11. package/dist/cli/input-preparation.js +54 -5
  12. package/dist/cli/memory-flow-admission.js +44 -0
  13. package/dist/cli/memory-init-invocation.js +78 -0
  14. package/dist/cli/memory-init.js +240 -0
  15. package/dist/cli/memory-storage-admission.js +53 -0
  16. package/dist/cli/native-hook-ingress.js +1 -1
  17. package/dist/cli/response-file.js +2 -0
  18. package/dist/cli/run-cli.js +283 -160
  19. package/dist/cli/setup.js +9 -7
  20. package/dist/cli.js +54 -2
  21. package/dist/domain/stage-handler-identities.js +20 -0
  22. package/dist/harness-runtime/DD-ZCODE.md +13 -0
  23. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
  24. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  25. package/dist/harness-runtime/lib/daemon-operations.mjs +4 -3
  26. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +4 -2
  27. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +11 -5
  28. package/dist/harness-runtime/lib/dd-codex.mjs +22 -10
  29. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -1
  30. package/dist/harness-runtime/lib/dd-droid.mjs +2 -1
  31. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -2
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +20 -5
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +45 -10
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
  35. package/dist/harness-runtime/lib/delegation-instructions.mjs +5 -3
  36. package/dist/harness-runtime/lib/lifecycle-command.mjs +1 -0
  37. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/managed-daemon.mjs +50 -15
  39. package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
  40. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -1
  41. package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
  42. package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
  43. package/dist/harness-runtime/lib/opencode-plugin.mjs +5 -1
  44. package/dist/harness-runtime/lib/operation-progress.mjs +3 -1
  45. package/dist/runtime/context.js +1 -1
  46. package/dist/schemas/discovery-result.schema.json +184 -0
  47. package/dist/schemas/execution-bundle.schema.json +205 -0
  48. package/dist/schemas/execution-settings.schema.json +162 -0
  49. package/dist/schemas/flow-descriptor.schema.json +54 -0
  50. package/dist/schemas/flow-run.schema.json +22 -0
  51. package/dist/schemas/harness-catalog.schema.json +80 -0
  52. package/dist/schemas/mb-init-compose-result.schema.json +457 -0
  53. package/dist/schemas/mb-init-discover-result.schema.json +500 -0
  54. package/dist/schemas/mb-init-finish-result.schema.json +422 -0
  55. package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
  56. package/dist/schemas/mb-init-review-result.schema.json +396 -0
  57. package/dist/schemas/mb-init-verify-result.schema.json +391 -0
  58. package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
  59. package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
  60. package/dist/schemas/memory-composition-work-result.schema.json +38 -0
  61. package/dist/schemas/memory-repair-work-result.schema.json +49 -0
  62. package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
  63. package/dist/schemas/memory-review-work-result.schema.json +43 -0
  64. package/dist/schemas/model-catalog.schema.json +153 -0
  65. package/dist/schemas/profile-settings.schema.json +122 -0
  66. package/dist/schemas/release-manifest.schema.json +291 -0
  67. package/dist/schemas/run-engine-binding.schema.json +1 -1
  68. package/dist/services/check-runner-evidence.js +51 -4
  69. package/dist/services/cli-operation-classifier.js +8 -47
  70. package/dist/services/code-checks.js +387 -34
  71. package/dist/services/controller-child-observer.js +99 -0
  72. package/dist/services/controller-execution.js +88 -0
  73. package/dist/services/controller-fanout.js +79 -13
  74. package/dist/services/dashboard.js +8 -2
  75. package/dist/services/descriptor-coordinator-session.js +35 -0
  76. package/dist/services/descriptor-root-work.js +53 -0
  77. package/dist/services/descriptor-run-settlement.js +105 -0
  78. package/dist/services/descriptor-run-start.js +449 -0
  79. package/dist/services/descriptor-stage-coordinator.js +197 -0
  80. package/dist/services/descriptor-stage-settlement.js +222 -0
  81. package/dist/services/descriptor-stage-start.js +139 -0
  82. package/dist/services/engines.js +39 -22
  83. package/dist/services/eval-snapshots.js +83 -25
  84. package/dist/services/execution-bundles.js +263 -0
  85. package/dist/services/execution-policy.js +20 -16
  86. package/dist/services/external-work-launch.js +91 -11
  87. package/dist/services/flow-catalog.js +21 -0
  88. package/dist/services/flow-descriptors.js +82 -0
  89. package/dist/services/frozen-engine.js +242 -0
  90. package/dist/services/generic-execution.js +164 -0
  91. package/dist/services/harness-adapter.js +28 -2
  92. package/dist/services/hooks.js +24 -6
  93. package/dist/services/hybrid-contracts.js +31 -3
  94. package/dist/services/ids.js +29 -1
  95. package/dist/services/installation-lock.js +7 -4
  96. package/dist/services/installed-flow-source.js +183 -0
  97. package/dist/services/lifecycle-invocations.js +257 -78
  98. package/dist/services/memory-accepted-composition.js +77 -0
  99. package/dist/services/memory-baseline-preservation.js +65 -0
  100. package/dist/services/memory-completed-correction-wave.js +320 -0
  101. package/dist/services/memory-compose-handler.js +258 -0
  102. package/dist/services/memory-composition-acceptance.js +199 -0
  103. package/dist/services/memory-composition-graph.js +134 -0
  104. package/dist/services/memory-composition-registration.js +146 -0
  105. package/dist/services/memory-composition-work-custody.js +151 -0
  106. package/dist/services/memory-composition-work-results.js +96 -0
  107. package/dist/services/memory-corrected-review-acceptance.js +129 -0
  108. package/dist/services/memory-corrected-review-batch.js +30 -0
  109. package/dist/services/memory-corrected-review-findings.js +106 -0
  110. package/dist/services/memory-corrected-review-inputs.js +210 -0
  111. package/dist/services/memory-corrected-review-projection.js +78 -0
  112. package/dist/services/memory-corrected-review-receipt.js +170 -0
  113. package/dist/services/memory-corrected-review-registration.js +191 -0
  114. package/dist/services/memory-correction-check-receipt.js +219 -0
  115. package/dist/services/memory-correction-input-owner.js +6 -0
  116. package/dist/services/memory-discover-handler.js +104 -0
  117. package/dist/services/memory-finish-evidence.js +435 -0
  118. package/dist/services/memory-finish-handler.js +215 -0
  119. package/dist/services/memory-finish-integration.js +280 -0
  120. package/dist/services/memory-finish-readiness.js +122 -0
  121. package/dist/services/memory-git-integration.js +151 -0
  122. package/dist/services/memory-historical-corrected-review.js +206 -0
  123. package/dist/services/memory-historical-original-review.js +130 -0
  124. package/dist/services/memory-historical-repair-registration.js +99 -0
  125. package/dist/services/memory-historical-second-review.js +128 -0
  126. package/dist/services/memory-init-bootstrap-readiness.js +210 -0
  127. package/dist/services/memory-init-contexts.js +89 -0
  128. package/dist/services/memory-init-controller-start.js +84 -0
  129. package/dist/services/memory-init-engine-release.js +223 -0
  130. package/dist/services/memory-init-git-policy.js +136 -0
  131. package/dist/services/memory-init-lint-capability.js +185 -0
  132. package/dist/services/memory-init-native-capacity.js +241 -0
  133. package/dist/services/memory-init-preflight.js +312 -0
  134. package/dist/services/memory-init-project-bootstrap.js +599 -0
  135. package/dist/services/memory-init-request-candidate.js +157 -0
  136. package/dist/services/memory-init-request-input.js +66 -0
  137. package/dist/services/memory-init-resource-storage.js +125 -0
  138. package/dist/services/memory-init-run-allocation.js +81 -0
  139. package/dist/services/memory-init-run-bundle.js +98 -0
  140. package/dist/services/memory-init-start.js +175 -0
  141. package/dist/services/memory-init-startup-custody.js +80 -0
  142. package/dist/services/memory-init-startup-input.js +240 -0
  143. package/dist/services/memory-init-storage.js +81 -0
  144. package/dist/services/memory-init-stores.js +359 -0
  145. package/dist/services/memory-init-worktrunk-capability.js +172 -0
  146. package/dist/services/memory-layer-publication.js +611 -0
  147. package/dist/services/memory-layer.js +156 -0
  148. package/dist/services/memory-lint-config.js +85 -0
  149. package/dist/services/memory-native-review-copy.js +165 -0
  150. package/dist/services/memory-normal-flow-entry.js +75 -0
  151. package/dist/services/memory-ordinary-defaults.js +112 -0
  152. package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
  153. package/dist/services/memory-preflight-handler.js +259 -0
  154. package/dist/services/memory-repair-current-view.js +299 -0
  155. package/dist/services/memory-repair-document-delta.js +451 -0
  156. package/dist/services/memory-repair-history.js +491 -0
  157. package/dist/services/memory-repair-physical-binding.js +312 -0
  158. package/dist/services/memory-repair-publication-files.js +152 -0
  159. package/dist/services/memory-repair-publication-intent.js +27 -0
  160. package/dist/services/memory-repair-publication-plan.js +160 -0
  161. package/dist/services/memory-repair-publication-source.js +225 -0
  162. package/dist/services/memory-repair-publication.js +794 -0
  163. package/dist/services/memory-repair-registration.js +239 -0
  164. package/dist/services/memory-repair-work-baseline.js +413 -0
  165. package/dist/services/memory-repair-work-completion.js +97 -0
  166. package/dist/services/memory-repair-work-custody.js +73 -0
  167. package/dist/services/memory-repair-work-results.js +89 -0
  168. package/dist/services/memory-repair-workspace.js +252 -0
  169. package/dist/services/memory-repair-writer-copy.js +735 -0
  170. package/dist/services/memory-result-binding.js +173 -0
  171. package/dist/services/memory-retained-review-authority.js +129 -0
  172. package/dist/services/memory-retained-review-results.js +70 -0
  173. package/dist/services/memory-retained-second-baseline.js +102 -0
  174. package/dist/services/memory-retained-second-repairs.js +156 -0
  175. package/dist/services/memory-review-acceptance.js +214 -0
  176. package/dist/services/memory-review-correction-plan.js +171 -0
  177. package/dist/services/memory-review-handler.js +585 -0
  178. package/dist/services/memory-review-inputs.js +200 -0
  179. package/dist/services/memory-review-receipt.js +180 -0
  180. package/dist/services/memory-review-registration.js +108 -0
  181. package/dist/services/memory-review-work-custody.js +124 -0
  182. package/dist/services/memory-review-work-input.js +72 -0
  183. package/dist/services/memory-review-work-results.js +73 -0
  184. package/dist/services/memory-second-corrected-review-inputs.js +165 -0
  185. package/dist/services/memory-second-correction-plan.js +76 -0
  186. package/dist/services/memory-second-repair-baseline.js +91 -0
  187. package/dist/services/memory-second-repair-launch.js +614 -0
  188. package/dist/services/memory-second-repair-registration.js +88 -0
  189. package/dist/services/memory-second-repair-source.js +187 -0
  190. package/dist/services/memory-second-review-acceptance.js +115 -0
  191. package/dist/services/memory-source-observations.js +117 -0
  192. package/dist/services/memory-stage-dispatch.js +28 -0
  193. package/dist/services/memory-stage-results.js +199 -0
  194. package/dist/services/memory-verification-acceptance.js +135 -0
  195. package/dist/services/memory-verification-checks.js +252 -0
  196. package/dist/services/memory-verification-receipt.js +159 -0
  197. package/dist/services/memory-verify-handler.js +135 -0
  198. package/dist/services/memory-work-result-preparation.js +63 -0
  199. package/dist/services/projects.js +17 -2
  200. package/dist/services/recovery-snapshot-database.js +416 -1
  201. package/dist/services/repair-intents.js +1 -1
  202. package/dist/services/review-copy.js +10 -2
  203. package/dist/services/run-control.js +80 -9
  204. package/dist/services/run-controller-capture.js +10 -3
  205. package/dist/services/run-controller-process.js +21 -0
  206. package/dist/services/run-controller-recovery.js +3 -1
  207. package/dist/services/run-controller-state.js +1 -1
  208. package/dist/services/run-controller.js +184 -48
  209. package/dist/services/run-recovery-guard.js +2 -0
  210. package/dist/services/run-recovery.js +145 -5
  211. package/dist/services/run-start-custody.js +177 -0
  212. package/dist/services/runs.js +319 -25
  213. package/dist/services/stage-context.js +9 -1
  214. package/dist/services/stage-handlers.js +74 -0
  215. package/dist/services/stage-lifecycle.js +2 -1
  216. package/dist/services/stage-report-renderer.js +76 -0
  217. package/dist/services/stage-work-graph.js +17 -0
  218. package/dist/services/vnext-code.js +3 -3
  219. package/dist/services/vnext-execution-profile.js +2 -2
  220. package/dist/services/vnext-fanout.js +136 -10
  221. package/dist/services/vnext-merge.js +8 -3
  222. package/dist/services/vnext-plan-review.js +6 -4
  223. package/dist/services/vnext-protocolize.js +60 -2
  224. package/dist/services/work-registry.js +616 -71
  225. package/dist/services/workspace-files.js +1 -1
  226. package/dist/storage/database.js +84 -6
  227. package/dist/storage/writer-contract.js +11 -7
  228. package/package.json +13 -3
  229. package/tools/create-canon-release.mjs +33 -0
  230. package/tools/produce-hybrid-release.mjs +41 -0
@@ -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,8 +35,9 @@ 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";
40
+ import { retainedChildObserver } from "./controller-child-observer.js";
34
41
  import { capacityDelay, continueCodexCapacity, eligibleCodexChildOverload } from "./codex-capacity.js";
35
42
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
36
43
  import { captureControllerBoundary } from "./run-controller-capture.js";
@@ -39,6 +46,11 @@ import { storageSessionId } from "./session-identity.js";
39
46
  import { CAPACITY_POLICY, nativeItemsSettled, overloadBurst, refusalObservation, terminalCodexOverload } from "../harness-runtime/lib/codex-capacity-policy.mjs";
40
47
  import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
41
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
+ }
42
54
  export function prepareRunControllerLaunch(context, input) {
43
55
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
44
56
  throw new AppError("usage", "Managed launch needs a request id and context SHA-256", 2);
@@ -59,58 +71,79 @@ export function prepareRunControllerLaunch(context, input) {
59
71
  if (existing) {
60
72
  if (existing.request_hash !== requestHash)
61
73
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
62
- 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;
63
78
  }
64
79
  const generation = assertRunMutationAllowed(context, project.id, run.id);
65
80
  if (run.status !== "running")
66
81
  throw new AppError("invalid_run_state", "Managed launch requires a running logical RUN", 1, { status: run.status });
67
- const index = JSON.parse(run.index_json);
68
- const execution = index.execution_profile;
69
- if (!execution?.settings.execution || !execution.agent_profiles)
70
- throw new AppError("execution_profile_not_frozen", "Managed launch requires the RUN's frozen execution profiles", 1);
82
+ const routing = selectControllerExecution(context, run);
71
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
+ }
72
88
  const next = getFlowRunStatus(context, { projectRoot: project.root, runId: run.id }).continuation;
73
89
  if (next.kind !== "start_stage" || !next.stage)
74
90
  throw new AppError("controller_attach_required", "Managed launch cannot replace an attached or unfinished coordinator; reconcile its existing owner", 1, { continuation: next });
75
91
  if (!contexts[next.stage])
76
92
  throw new AppError("controller_context_missing", "Managed context omits the first stage", 2, { stage: next.stage });
77
93
  const adapters = {};
78
- for (const profile of Object.values(execution.agent_profiles)) {
94
+ for (const profile of Object.values(routing.profiles)) {
79
95
  const harness = runtimeHarness(profile);
80
96
  adapters[harness] = { executable: resolveHarnessCommand(context.ddFlowHome, harness, "adapter"), runtime_args: harnessRuntimeArguments(context.ddFlowHome, harness) };
81
97
  }
82
98
  const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
83
99
  if (!fs.existsSync(entry))
84
100
  throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
85
- 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;
86
104
  }
87
- export async function launchRunController(context, input, prepared = prepareRunControllerLaunch(context, input)) {
88
- if (prepared.kind === "replay")
89
- return { ...await spawnController(context, prepared.existing), reused: true };
90
- 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;
91
120
  assertRunMutationAllowed(context, project.id, run.id, generation);
92
121
  ensureRunControllerStorage(context.db);
93
122
  const controllerId = `DRV-${crypto.randomUUID()}`;
94
123
  const root = path.join(run.run_root, "controllers", controllerId);
95
124
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
96
- const flowExecutable = prepareHarnessFlowExecutable(context, root);
125
+ const flowExecutable = prepareHarnessFlowExecutable(context, root, routing.bundle?.engine());
97
126
  const frozenContexts = {};
98
127
  for (const [stage, value] of Object.entries(contexts)) {
99
128
  const file = path.join(root, `${stage}.context.json`);
100
129
  fs.writeFileSync(file, value.bytes, { flag: "wx", mode: 0o600 });
101
130
  frozenContexts[stage] = { file, sha256: value.sha256 };
102
131
  }
103
- 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 };
104
135
  const token = crypto.randomUUID();
105
136
  const now = context.now();
106
137
  const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
107
138
  const launcher = { pid: process.pid, pid_started_at: processStartedAt(process.pid) };
108
139
  const duplicate = context.db.writeTransaction(() => {
140
+ if (routing.bundle)
141
+ assertPreparedRunBundleSqlAuthority(context, { projectId: project.id, runId: run.id }, routing.bundle);
109
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]);
110
143
  if (duplicate) {
111
144
  if (duplicate.request_hash !== requestHash)
112
145
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
113
- return { ...controllerReceipt(duplicate), reused: true };
146
+ return duplicate;
114
147
  }
115
148
  assertRunMutationAllowed(context, project.id, run.id, generation);
116
149
  if (requireRun(context, project.id, run.id).status !== "running")
@@ -125,9 +158,16 @@ export async function launchRunController(context, input, prepared = prepareRunC
125
158
  appendEvent(context, controllerId, "launch_requested", { request_id: input.requestId, generation, request_hash: requestHash });
126
159
  return null;
127
160
  });
128
- if (duplicate)
129
- return duplicate;
130
- 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;
131
171
  }
132
172
  /**
133
173
  * A registry write can be temporarily unavailable while another runtime client
@@ -178,6 +218,7 @@ export async function serveRunController(context, input) {
178
218
  throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
179
219
  const confirmed = confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
180
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);
181
222
  // Resource and RUN commits are distinct effects. A RUN keepalive failure must
182
223
  // neither erase a resource receipt nor grant RUN dispatch authority.
183
224
  const mergeLease = controllerLeaseMonitor(attemptMs => {
@@ -193,21 +234,19 @@ export async function serveRunController(context, input) {
193
234
  return { owned: true, lease_expires_at: expiry };
194
235
  });
195
236
  const assertLeaseFence = () => {
196
- const current = requireController(context, row.controller_id);
197
- if (current.owner_token !== row.owner_token || current.generation !== row.generation)
198
- throw new AppError("controller_owner_mismatch", "Lease renewal no longer owns this controller generation", 1);
199
- assertControllerRecoveryAllowed(context, current);
237
+ assertControllerLeaseAllowed(context, row);
200
238
  };
201
239
  const heartbeat = setInterval(() => {
202
240
  lease.background(assertLeaseFence, () => { });
203
- mergeLease.background(assertLeaseFence, () => { });
241
+ if (state.merge_claim)
242
+ mergeLease.background(assertLeaseFence, () => { });
204
243
  }, 30_000);
205
244
  heartbeat.unref();
206
245
  const manifest = JSON.parse(row.manifest_json);
207
- const state = JSON.parse(row.state_json);
208
246
  const assertPhysicalOwner = () => {
209
247
  lease.assertNoRenewal();
210
- mergeLease.assertNoRenewal();
248
+ if (state.merge_claim)
249
+ mergeLease.assertNoRenewal();
211
250
  assertManagedProcessLease({ ...context, databaseDeadlineAt: Date.now() + 5_000 }, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
212
251
  const current = requireController(context, row.controller_id);
213
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))
@@ -220,7 +259,8 @@ export async function serveRunController(context, input) {
220
259
  };
221
260
  try {
222
261
  await lease.ensureConfirmed(assertLeaseFence);
223
- await mergeLease.ensureConfirmed(assertLeaseFence);
262
+ if (state.merge_claim)
263
+ await mergeLease.ensureConfirmed(assertLeaseFence);
224
264
  context.db.writeTransaction(() => {
225
265
  markControllerDispatchReady(context, row);
226
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")
@@ -229,7 +269,8 @@ export async function serveRunController(context, input) {
229
269
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
230
270
  if (state.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
231
271
  await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
232
- 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); });
233
274
  return controllerReceipt(requireController(context, row.controller_id));
234
275
  }
235
276
  catch (error) {
@@ -448,8 +489,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
448
489
  }
449
490
  }
450
491
  };
451
- const policy = manifest.execution_profile.settings.execution;
452
- 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;
453
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 } };
454
496
  const bindInvocationScope = (session) => {
455
497
  executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
@@ -460,6 +502,52 @@ async function executeController(context, row, manifest, state, assertOwnership,
460
502
  let fatal;
461
503
  let stopping;
462
504
  const cancellation = new AbortController();
505
+ const observationCancellation = new AbortController();
506
+ const childObservers = new Map();
507
+ let promptPending = false;
508
+ let observedPromptOperation = null;
509
+ const childObserver = (session) => {
510
+ if (runtimeHarness(session.profile) !== "zcode-acp" || !state.stage || !["plan-review", "code", "code-review"].includes(state.stage))
511
+ return undefined;
512
+ if (readNativeDaemonState(session.state_dir).capabilities?.retained_child_observation !== true)
513
+ return undefined;
514
+ const scope = { controller_id: row.controller_id, generation: row.generation, stage: state.stage, root_session_id: session.id };
515
+ const key = JSON.stringify(scope);
516
+ let retained = childObservers.get(key);
517
+ if (!retained) {
518
+ retained = retainedChildObserver({ file: path.join(row.state_dir, "native-child-observations.jsonl"), scope, assertOwner: assertOwnership,
519
+ gap: error => { process.stderr.write(`${JSON.stringify({ event: "native_child_observation_gap", scope, error_code: String(error?.code ?? "observation_unavailable") })}\n`); },
520
+ read: async () => {
521
+ assertOwnership();
522
+ const operation = observedPromptOperation;
523
+ const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id });
524
+ let receipt;
525
+ try {
526
+ receipt = await runHarnessAdapter({ executable: manifest.adapters["zcode-acp"].executable,
527
+ args: ["session", "observe-children", "--session-id", session.id, ...sessionArguments(context, manifest, session)],
528
+ env: controllerEnvironment(context, manifest, session), operationId: crypto.randomUUID(), harness: "zcode", timeoutMs: 30_000,
529
+ signal: AbortSignal.any([cancellation.signal, observationCancellation.signal]) });
530
+ }
531
+ catch (error) {
532
+ if (!promptPending || observedPromptOperation !== operation)
533
+ return null;
534
+ throw error;
535
+ }
536
+ if (!promptPending || observedPromptOperation !== operation || receipt.active_operation_id === null)
537
+ return null;
538
+ if (receipt.schema_id !== "dd-zcode/child-observation@1" || receipt.provider_session_id !== session.id || receipt.daemon_id !== binding.state.daemon_id)
539
+ throw new AppError("native_child_observation_conflict", "Retained child observation has foreign daemon/root identity", 1);
540
+ if (receipt.active_operation_id !== operation)
541
+ throw new AppError("native_child_observation_conflict", "Retained observation has a foreign productive operation", 1);
542
+ const children = controllerNativeChildren(receipt, session.id).filter(child => !(state.native_baseline ?? []).includes(child.session_id));
543
+ if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
544
+ throw new AppError("operation_observation_lost", "Retained child terminal identity is unavailable", 1);
545
+ return children;
546
+ } });
547
+ childObservers.set(key, retained);
548
+ }
549
+ return retained;
550
+ };
463
551
  const capacityUncertain = (error) => error instanceof AppError && ["capacity_continuation_blocked", "operation_observation_lost"].includes(error.code) && error.details.capacity_uncertainty === true || Boolean(state.pending_capacity) && (String(error?.code) === "capacity_continuation_blocked" || isObservationLoss(error));
464
552
  const stopAfterFailure = (error) => {
465
553
  // A retained successor may already be running. Fencing/recovery records
@@ -492,10 +580,15 @@ async function executeController(context, row, manifest, state, assertOwnership,
492
580
  }).finally(() => cancellation.abort());
493
581
  return stopping;
494
582
  };
495
- const setStatus = (status) => {
496
- const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
497
- if (changed.changes)
498
- appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
583
+ const setStatus = (status, terminalData) => {
584
+ context.db.writeTransaction(() => {
585
+ const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
586
+ if (changed.changes) {
587
+ appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
588
+ if (terminalData)
589
+ appendEvent(context, row.controller_id, status, terminalData);
590
+ }
591
+ });
499
592
  };
500
593
  const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined, productive ? renewLease : undefined);
501
594
  const confirmStopped = async (stateDir, receipt) => {
@@ -575,7 +668,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
575
668
  throw new AppError("capacity_continuation_conflict", "Retained capacity root has no failure receipt", 1);
576
669
  throw JSON.parse(root.error_json);
577
670
  }
578
- receipt = await send(text, firstOperationId);
671
+ if (childObserver(session)?.failed().length)
672
+ throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids another coordinator dispatch", 1);
673
+ promptPending = true;
674
+ observedPromptOperation = firstOperationId;
675
+ try {
676
+ receipt = await send(text, firstOperationId);
677
+ }
678
+ finally {
679
+ promptPending = false;
680
+ observedPromptOperation = null;
681
+ }
579
682
  }
580
683
  catch (error) {
581
684
  if (!["codex-desktop", "opencode-server"].includes(runtimeHarness(session.profile)))
@@ -640,6 +743,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
640
743
  receipt = resumed;
641
744
  }
642
745
  checkOutcomes();
746
+ await childObserver(session)?.drain();
747
+ childObserver(session)?.completePostTurn(receipt);
748
+ const observationGap = childObserver(session)?.gap();
749
+ if (observationGap)
750
+ throw new AppError("operation_observation_lost", "In-flight child observation remained unavailable at the safe Turn boundary", 1, { observation_error_code: observationGap, provider_session_id: session.id, stage: state.stage });
643
751
  if (fatal)
644
752
  throw fatal;
645
753
  }
@@ -661,6 +769,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
661
769
  return;
662
770
  try {
663
771
  checkOutcomes();
772
+ if (promptPending && state.current_session !== null)
773
+ childObserver(state.sessions[state.current_session])?.tick();
664
774
  }
665
775
  catch (error) {
666
776
  void stopAfterFailure(error);
@@ -676,7 +786,10 @@ async function executeController(context, row, manifest, state, assertOwnership,
676
786
  if (state.current_session !== null && !state.sessions[state.current_session].stopped)
677
787
  bindInvocationScope(state.sessions[state.current_session]);
678
788
  const run = requireRun(context, row.project_id, row.run_id);
679
- 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)) {
680
793
  const pending = state.pending_capacity;
681
794
  const session = state.sessions.find(item => item.id === pending.session_id && !item.stopped);
682
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]);
@@ -688,19 +801,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
688
801
  await prompt(session, fs.readFileSync(file, "utf8"), pending.root_operation_id, pending.kind === "stage" ? undefined : pending.kind);
689
802
  continue;
690
803
  }
691
- const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
692
- const next = view.continuation;
693
- terminalFinalization = next.kind === "terminal";
694
804
  const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
805
+ if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
806
+ throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
695
807
  if (completed && manifest.request.stop_after === state.stage) {
696
808
  // A bounded run is final evidence, not an entry fixture for its successor.
697
809
  // Stop owned observers before capture; keep the controller lease/fence.
698
810
  await settle();
699
811
  if (manifest.request.capture_root)
700
812
  await captureControllerBoundary(executionContext, row, manifest, completed, null, assertPhysicalOwner);
701
- setStatus("stop_target_reached");
702
813
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
703
- appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
814
+ setStatus("stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
704
815
  return;
705
816
  }
706
817
  if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
@@ -763,8 +874,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
763
874
  loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
764
875
  const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
765
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 } });
766
- 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 });
767
- 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) {
768
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]);
769
880
  if (!request || request.status !== "queued")
770
881
  throw new AppError("controller_attach_required", "Managed MERGE needs an unclaimed queued request; reconcile an existing owner", 1, { request });
@@ -777,10 +888,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
777
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 };
778
889
  saveState(context, row, state);
779
890
  appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
891
+ await renewLease();
780
892
  }
781
- 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);
782
894
  let session = state.current_session === null ? null : state.sessions[state.current_session];
783
- 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) {
784
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 });
785
897
  }
786
898
  if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator) || path.resolve(session.cwd) !== stageCwd) {
@@ -820,7 +932,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
820
932
  state.child_capacity = {};
821
933
  saveState(context, row, state);
822
934
  setStatus("running");
823
- if (next.stage === "code" || next.stage === "code-review") {
935
+ if (routing.kind === "sdlc" && (next.stage === "code" || next.stage === "code-review")) {
824
936
  // A CODE_REVIEW fork has source files but intentionally no installed
825
937
  // dependencies. Its repair Work executes checks just like CODE Work.
826
938
  const readiness = await prepareVnextCodeWorkspace(executionContext, { projectRoot: run.project_root, runId: run.id, ownerId: row.controller_id, stage: next.stage });
@@ -839,6 +951,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
839
951
  const session = state.sessions[state.current_session];
840
952
  const baseline = new Set(state.native_baseline ?? []);
841
953
  let children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
954
+ const retainedFailures = childObserver(session)?.failed() ?? [];
955
+ children = controllerNativeChildren({ descendants: [...children, ...retainedFailures] }, session.id);
842
956
  if (runtimeHarness(session.profile) === "codex-desktop" && children.some(child => child.status === "failed")) {
843
957
  const inspected = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
844
958
  state.last_receipt = inspected;
@@ -941,9 +1055,28 @@ async function executeController(context, row, manifest, state, assertOwnership,
941
1055
  continue;
942
1056
  }
943
1057
  }
944
- 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 });
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 });
945
1059
  assertOwner();
946
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
+ }
947
1080
  assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
948
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 });
949
1082
  if (cause)
@@ -1013,6 +1146,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
1013
1146
  }
1014
1147
  finally {
1015
1148
  clearInterval(monitor);
1149
+ const drainedObservations = [...childObservers.values()].map(retained => retained.close());
1150
+ observationCancellation.abort();
1151
+ await Promise.allSettled(drainedObservations);
1016
1152
  if (stopping)
1017
1153
  await stopping;
1018
1154
  observer.db.close?.();
@@ -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]))