@deksden-com/dd-flow-cli 0.9.0-beta.144 → 0.9.0-beta.149

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 (254) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +13 -8
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +13 -3
  5. package/dist/cli/command-metadata.js +51 -16
  6. package/dist/cli/diagnostic-invocation.js +46 -0
  7. package/dist/cli/diagnostic.js +120 -0
  8. package/dist/cli/help.js +67 -1
  9. package/dist/cli/hook-ingress.js +27 -3
  10. package/dist/cli/memory-flow-admission.js +9 -3
  11. package/dist/cli/memory-init-invocation.js +10 -3
  12. package/dist/cli/memory-init.js +77 -68
  13. package/dist/cli/memory-storage-admission.js +3 -2
  14. package/dist/cli/memory-storage-qualification.js +105 -0
  15. package/dist/cli/memory-storage-transition.js +123 -0
  16. package/dist/cli/memory-upgrade.js +189 -0
  17. package/dist/cli/native-hook-ingress.js +14 -4
  18. package/dist/cli/native-hook-preparation.js +55 -0
  19. package/dist/cli/response-file.js +2 -1
  20. package/dist/cli/run-cli.js +79 -40
  21. package/dist/cli/setup.js +2 -1
  22. package/dist/cli.js +18 -0
  23. package/dist/domain/diagnostic-aspects.js +103 -0
  24. package/dist/domain/diagnostic-flows.js +8 -0
  25. package/dist/domain/stage-handler-identities.js +8 -0
  26. package/dist/harness-runtime/bin/dd-agy.mjs +3 -3
  27. package/dist/harness-runtime/bin/dd-grok.mjs +3 -3
  28. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  29. package/dist/harness-runtime/lib/agy-error-boundary.mjs +58 -0
  30. package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
  31. package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
  32. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  33. package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
  34. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +366 -52
  35. package/dist/harness-runtime/lib/dd-agy.mjs +17 -3
  36. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
  37. package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
  38. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
  39. package/dist/harness-runtime/lib/dd-droid.mjs +71 -29
  40. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +73 -16
  41. package/dist/harness-runtime/lib/dd-grok.mjs +6 -3
  42. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
  43. package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
  44. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
  45. package/dist/harness-runtime/lib/dd-zcode.mjs +44 -14
  46. package/dist/harness-runtime/lib/delegation-instructions.mjs +1 -1
  47. package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
  48. package/dist/harness-runtime/lib/entity-references.d.mts +2 -0
  49. package/dist/harness-runtime/lib/entity-references.mjs +9 -0
  50. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
  51. package/dist/harness-runtime/lib/managed-daemon.mjs +24 -9
  52. package/dist/harness-runtime/lib/native-children.d.mts +8 -1
  53. package/dist/harness-runtime/lib/native-children.mjs +114 -8
  54. package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
  55. package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
  56. package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
  57. package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
  58. package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -3
  59. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  60. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +5 -3
  61. package/dist/harness-runtime/lib/provider-overload-policy.mjs +20 -7
  62. package/dist/harness-runtime/lib/runner-events.mjs +3 -1
  63. package/dist/harness-runtime/lib/setup-progress.mjs +8 -6
  64. package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
  65. package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
  66. package/dist/schemas/diagnostic-work-result.schema.json +296 -0
  67. package/dist/schemas/execution-bundle.schema.json +7 -1
  68. package/dist/schemas/mb-upgrade-stage-result.schema.json +863 -0
  69. package/dist/schemas/memory-repair-work-result.schema.json +9 -2
  70. package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
  71. package/dist/schemas/release-impact.schema.json +3 -3
  72. package/dist/schemas/stage-start-response.schema.json +4 -0
  73. package/dist/services/accepted-intent.js +13 -1
  74. package/dist/services/canon.js +43 -7
  75. package/dist/services/check-environment.js +21 -0
  76. package/dist/services/check-runner-evidence.js +267 -15
  77. package/dist/services/cli-operation-classifier.js +1 -1
  78. package/dist/services/cli-upgrade-allowlist.js +1 -1
  79. package/dist/services/code-checks.js +34 -19
  80. package/dist/services/compatibility-preflight.js +5 -2
  81. package/dist/services/controller-execution.js +10 -4
  82. package/dist/services/controller-fanout.js +34 -10
  83. package/dist/services/controller-memory-task.js +45 -0
  84. package/dist/services/controller-memory-worker.js +49 -0
  85. package/dist/services/dashboard.js +2 -2
  86. package/dist/services/descriptor-run-settlement.js +5 -3
  87. package/dist/services/descriptor-run-start.js +46 -20
  88. package/dist/services/descriptor-stage-coordinator.js +66 -13
  89. package/dist/services/descriptor-stage-settlement.js +2 -1
  90. package/dist/services/descriptor-stage-start.js +40 -4
  91. package/dist/services/diagnostic-evidence.js +73 -0
  92. package/dist/services/diagnostic-report-publication.js +72 -0
  93. package/dist/services/diagnostic-report.js +67 -0
  94. package/dist/services/diagnostic-run-bundle.js +74 -0
  95. package/dist/services/diagnostic-snapshot.js +159 -0
  96. package/dist/services/diagnostic-stage-handlers.js +108 -0
  97. package/dist/services/diagnostic-stage-results.js +132 -0
  98. package/dist/services/diagnostic-start.js +84 -0
  99. package/dist/services/diagnostic-work-batch.js +34 -0
  100. package/dist/services/diagnostic-work-collection.js +97 -0
  101. package/dist/services/diagnostic-work-registration.js +101 -0
  102. package/dist/services/diagnostic-work-results.js +93 -0
  103. package/dist/services/engines.js +64 -14
  104. package/dist/services/eval-snapshots.js +186 -119
  105. package/dist/services/execution-bundles.js +19 -7
  106. package/dist/services/external-work-launch.js +5 -3
  107. package/dist/services/flow-catalog.js +13 -4
  108. package/dist/services/frozen-engine.js +148 -11
  109. package/dist/services/hook-memory-routing.js +188 -0
  110. package/dist/services/hook-recovery-guard.js +14 -0
  111. package/dist/services/hook-session-observation.js +24 -0
  112. package/dist/services/hooks.js +27 -8
  113. package/dist/services/hybrid-contracts.js +4 -13
  114. package/dist/services/hybrid-digest.js +14 -0
  115. package/dist/services/installed-flow-source.js +47 -4
  116. package/dist/services/lifecycle-invocations.js +179 -38
  117. package/dist/services/memory-accepted-composition.js +5 -4
  118. package/dist/services/memory-completed-correction-wave.js +23 -15
  119. package/dist/services/memory-compose-handler.js +163 -21
  120. package/dist/services/memory-composition-acceptance.js +186 -10
  121. package/dist/services/memory-composition-graph.js +42 -11
  122. package/dist/services/memory-composition-registration.js +23 -8
  123. package/dist/services/memory-composition-work-custody.js +14 -9
  124. package/dist/services/memory-composition-work-results.js +15 -3
  125. package/dist/services/memory-corrected-review-batch.js +10 -3
  126. package/dist/services/memory-corrected-review-findings.js +4 -2
  127. package/dist/services/memory-corrected-review-inputs.js +10 -5
  128. package/dist/services/memory-corrected-review-receipt.js +17 -9
  129. package/dist/services/memory-corrected-review-registration.js +3 -2
  130. package/dist/services/memory-correction-check-receipt.js +20 -4
  131. package/dist/services/memory-custody-snapshot.js +36 -0
  132. package/dist/services/memory-finish-evidence.js +44 -22
  133. package/dist/services/memory-finish-handler.js +37 -23
  134. package/dist/services/memory-finish-integration.js +218 -48
  135. package/dist/services/memory-finish-readiness.js +66 -19
  136. package/dist/services/memory-git-integration.js +87 -18
  137. package/dist/services/memory-historical-corrected-review.js +21 -15
  138. package/dist/services/memory-historical-original-review.js +22 -9
  139. package/dist/services/memory-historical-repair-registration.js +14 -8
  140. package/dist/services/memory-historical-second-review.js +30 -11
  141. package/dist/services/memory-init-contexts.js +44 -2
  142. package/dist/services/memory-init-controller-start.js +27 -8
  143. package/dist/services/memory-init-git-policy.js +50 -21
  144. package/dist/services/memory-init-lint-capability.js +24 -6
  145. package/dist/services/memory-init-native-capacity.js +17 -4
  146. package/dist/services/memory-init-preflight.js +21 -17
  147. package/dist/services/memory-init-request-candidate.js +73 -17
  148. package/dist/services/memory-init-request-input.js +23 -7
  149. package/dist/services/memory-init-run-bundle.js +49 -12
  150. package/dist/services/memory-init-storage.js +17 -7
  151. package/dist/services/memory-init-worktrunk-capability.js +3 -7
  152. package/dist/services/memory-layer.js +71 -6
  153. package/dist/services/memory-lint-config.js +111 -2
  154. package/dist/services/memory-native-review-copy.js +5 -67
  155. package/dist/services/memory-normal-flow-entry.js +12 -0
  156. package/dist/services/memory-ordinary-defaults.js +22 -5
  157. package/dist/services/memory-preflight-handler.js +15 -0
  158. package/dist/services/memory-repair-current-view.js +1 -1
  159. package/dist/services/memory-repair-document-delta.js +70 -33
  160. package/dist/services/memory-repair-history.js +12 -7
  161. package/dist/services/memory-repair-physical-binding.js +1 -119
  162. package/dist/services/memory-repair-publication-plan.js +5 -3
  163. package/dist/services/memory-repair-publication-source.js +1 -1
  164. package/dist/services/memory-repair-publication.js +3 -2
  165. package/dist/services/memory-repair-registration.js +2 -1
  166. package/dist/services/memory-repair-work-baseline.js +4 -2
  167. package/dist/services/memory-repair-work-completion.js +32 -12
  168. package/dist/services/memory-repair-work-results.js +21 -2
  169. package/dist/services/memory-repair-workspace.js +11 -2
  170. package/dist/services/memory-repair-writer-copy.js +2 -1
  171. package/dist/services/memory-result-binding.js +78 -12
  172. package/dist/services/memory-retained-review-authority.js +17 -8
  173. package/dist/services/memory-retained-second-baseline.js +1 -1
  174. package/dist/services/memory-retained-second-repairs.js +12 -6
  175. package/dist/services/memory-review-correction-plan.js +47 -12
  176. package/dist/services/memory-review-handler.js +17 -17
  177. package/dist/services/memory-review-inputs.js +55 -9
  178. package/dist/services/memory-review-limits.js +10 -0
  179. package/dist/services/memory-review-receipt.js +2 -2
  180. package/dist/services/memory-review-registration.js +13 -4
  181. package/dist/services/memory-review-work-custody.js +3 -1
  182. package/dist/services/memory-review-work-input.js +26 -7
  183. package/dist/services/memory-review-work-results.js +26 -4
  184. package/dist/services/memory-second-corrected-review-inputs.js +5 -3
  185. package/dist/services/memory-second-correction-plan.js +2 -1
  186. package/dist/services/memory-second-repair-launch.js +4 -4
  187. package/dist/services/memory-second-repair-source.js +1 -1
  188. package/dist/services/memory-stage-dispatch.js +38 -1
  189. package/dist/services/memory-stage-results.js +113 -22
  190. package/dist/services/memory-tool-probe.js +25 -0
  191. package/dist/services/memory-upgrade-analysis-handler.js +243 -0
  192. package/dist/services/memory-upgrade-bootstrap-readiness.js +251 -0
  193. package/dist/services/memory-upgrade-evidence.js +100 -0
  194. package/dist/services/memory-upgrade-inventory.js +442 -0
  195. package/dist/services/memory-upgrade-publication-custody.js +73 -0
  196. package/dist/services/memory-upgrade-publication.js +413 -0
  197. package/dist/services/memory-upgrade-selection.js +430 -0
  198. package/dist/services/memory-upgrade-start.js +60 -0
  199. package/dist/services/memory-upgrade-storage-readiness.js +340 -0
  200. package/dist/services/memory-verification-acceptance.js +38 -17
  201. package/dist/services/memory-verification-checks.js +92 -27
  202. package/dist/services/memory-verification-receipt.js +6 -5
  203. package/dist/services/memory-verify-handler.js +15 -13
  204. package/dist/services/memory-work-result-json.js +12 -0
  205. package/dist/services/memory-work-result-preparation.js +5 -4
  206. package/dist/services/merge-server.js +4 -2
  207. package/dist/services/migrations.js +127 -98
  208. package/dist/services/native-daemon-history.js +2 -1
  209. package/dist/services/physical-startup.js +45 -0
  210. package/dist/services/project-registry.js +97 -0
  211. package/dist/services/project-summary.js +1 -1
  212. package/dist/services/projects.js +3 -93
  213. package/dist/services/prompt-delivery.js +71 -0
  214. package/dist/services/provider-capacity.js +112 -46
  215. package/dist/services/recovery-snapshot-database.js +32 -4
  216. package/dist/services/release-cache.js +8 -0
  217. package/dist/services/repair-intents.js +1 -12
  218. package/dist/services/repair-receipt.js +13 -0
  219. package/dist/services/run-control-worker.js +35 -18
  220. package/dist/services/run-control.js +2 -1
  221. package/dist/services/run-controller-adapter.js +2 -1
  222. package/dist/services/run-controller-process.js +4 -7
  223. package/dist/services/run-controller-recovery.js +8 -2
  224. package/dist/services/run-controller.js +215 -94
  225. package/dist/services/run-fork.js +1 -1
  226. package/dist/services/run-recovery-guard.js +30 -0
  227. package/dist/services/run-recovery.js +11 -44
  228. package/dist/services/run-start-custody.js +17 -5
  229. package/dist/services/runs.js +6 -1
  230. package/dist/services/runtime-scope-resume.js +12 -6
  231. package/dist/services/runtime-scope-stop.js +3 -2
  232. package/dist/services/runtime-scope-worker.js +46 -19
  233. package/dist/services/runtime-service.js +2 -1
  234. package/dist/services/sessions.js +3 -24
  235. package/dist/services/setup.js +63 -13
  236. package/dist/services/stage-handlers.js +22 -7
  237. package/dist/services/stage-pause.js +111 -44
  238. package/dist/services/version-status.js +1 -1
  239. package/dist/services/vnext-code.js +2 -2
  240. package/dist/services/vnext-fanout.js +51 -9
  241. package/dist/services/vnext-plan-review.js +3 -5
  242. package/dist/services/work-registry.js +117 -39
  243. package/dist/services/workspace-bootstrap.js +2 -1
  244. package/dist/services/workspace-files.js +1 -1
  245. package/dist/shared/entity-references.js +1 -8
  246. package/dist/storage/data-preservation.js +65 -0
  247. package/dist/storage/database.js +53 -19
  248. package/dist/storage/import-guard.js +84 -27
  249. package/dist/storage/memory-upgrade-schema-bootstrap.js +38 -0
  250. package/dist/storage/paired-writer-migration.js +424 -0
  251. package/dist/storage/writer-contract.js +11 -4
  252. package/dist/storage/writer-migration.js +313 -52
  253. package/dist/storage/writer-restore.js +217 -0
  254. package/package.json +13 -6
@@ -2,8 +2,8 @@ import fs from "node:fs";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
5
  import { AppError } from "../shared/errors.js";
6
+ import { validateCheckEnvironment } from "./check-environment.js";
7
7
  import { getResourceDatabase } from "../storage/database.js";
8
8
  import { finishManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
9
9
  import { setFlowRunRecoveryPaused } from "./runs.js";
@@ -133,11 +133,10 @@ function prepareSpawn(context, row) {
133
133
  });
134
134
  }
135
135
  /** The physical child records its own birth before resource registration. */
136
- export function claimControllerProcess(context, row) {
136
+ export function claimControllerProcess(context, row, owner = birth()) {
137
137
  const launch = launchRecord(context, row);
138
138
  if (!launch && (!row.process_id || !row.process_lease_token))
139
139
  throw new AppError("controller_owner_mismatch", "Managed registration is not authorized", 1);
140
- const owner = birth();
141
140
  context.db.writeTransaction(() => {
142
141
  // Recovery fences are reconciled by serve's retained failure boundary;
143
142
  // only terminal admission must reject before claiming a physical owner.
@@ -173,13 +172,11 @@ export async function spawnController(context, row) {
173
172
  const manifest = JSON.parse(row.manifest_json);
174
173
  const run = requireRun(context, row.project_id, row.run_id);
175
174
  const controllerId = row.controller_id, root = row.state_dir, token = row.owner_token;
176
- const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
177
175
  try {
178
- if (!fs.existsSync(entry))
179
- throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
176
+ const entry = (await import("./engines.js")).boundRunEngineEntrypoint(context, { projectRoot: run.project_root, runId: run.id });
180
177
  const output = fs.openSync(path.join(root, "controller.log"), "a", 0o600);
181
178
  try {
182
- const child = spawn(process.execPath, [entry, "run", "drive", "serve", "--run", run.id, "--project-root", run.project_root, "--controller-id", controllerId, "--json"], { cwd: run.workspace_root, detached: true, stdio: ["ignore", output, output], env: { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_CONTROLLER_TOKEN: token } });
179
+ const child = spawn(process.execPath, [entry, "run", "drive", "serve", "--run", run.id, "--project-root", run.project_root, "--controller-id", controllerId, "--json"], { cwd: run.workspace_root, detached: true, stdio: ["ignore", output, output], env: { ...context.env, ...validateCheckEnvironment(manifest.check_environment), DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_CONTROLLER_TOKEN: token } });
183
180
  await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
184
181
  context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(receipt_json, '$.child', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify({ pid: child.pid, pid_started_at: child.pid ? processStartedAt(child.pid) : null }), context.now(), launchId(row)]);
185
182
  child.unref();
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { setTimeout as delay } from "node:timers/promises";
@@ -148,7 +149,7 @@ export async function resumeControllerSession(context, row, manifest, state, ass
148
149
  const call = (args, operationId) => controllerAdapter(executionContext, row, manifest, session, args, assertRecoveryOwner, operationId);
149
150
  if (processTreeIsAlive(record)) {
150
151
  const stopped = await call(["daemon", "stop", "--state-dir", session.state_dir, "--json"]);
151
- if (stopped.stopped !== true || stopped.clean !== true)
152
+ if (!retired.daemon_id || stopped.stopped !== true || !nativeShutdownConfirmed(stopped, { daemonId: retired.daemon_id }))
152
153
  throw new AppError("recovery_native_shutdown_unproven", "Recovery requires a clean native close before replacing its daemon", 1);
153
154
  const deadline = Date.now() + 30_000;
154
155
  while (processTreeIsAlive(record) && Date.now() < deadline) {
@@ -255,7 +256,12 @@ export async function resumeControllerSession(context, row, manifest, state, ass
255
256
  || current.process.lease_expires_at <= context.now() || metadata.run_generation !== row.generation || !processIsAlive(current.process))
256
257
  throw new AppError("recovery_capacity_owner_changed", "Replacement native owner changed during capacity wait", 1);
257
258
  },
258
- decision: () => recoveryGuard(context, row.project_id, row.run_id)?.status === "recovered" ? "completed" : "continue",
259
+ decision: (boundary) => {
260
+ if (boundary && (boundary.lifecycle_operation !== "recovery_accept" || boundary.recovery_id !== recovery.recovery_id
261
+ || !context.db.get("SELECT 1 FROM run_recovery_bindings b JOIN hook_events h ON h.event_key = b.hook_event_key WHERE b.recovery_id = ? AND b.status = 'accepted' AND b.daemon_id = ? AND b.provider_session_id = ? AND h.project_id = ? AND h.status = 'claimed' AND h.event_key = ?", [recovery.recovery_id, daemonId, session.id, row.project_id, boundary.native_hook_event_key])))
262
+ return "reconciliation_blocked";
263
+ return recoveryGuard(context, row.project_id, row.run_id)?.status === "recovered" ? "completed" : "continue";
264
+ },
259
265
  retainIntent: intent => retainRecoveryCapacityIntent(executionContext, { projectId: row.project_id, runId: row.run_id, recoveryId: recovery.recovery_id,
260
266
  controllerId: row.controller_id, generation: row.generation, daemonId, intent, prompt: continuationText }),
261
267
  prompt: (operationId, prompt) => sendAck(operationId, prompt) });
@@ -1,15 +1,18 @@
1
- import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
+ import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand, awaitLifecycleInvocation, assertLifecycleInvocationCurrent } from "./lifecycle-invocations.js";
2
+ import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
2
3
  export function recoveryPacketMatchesStage(packet, scope) {
3
4
  return packet.schema_id === 'dd-flow/root-recovery-packet@1' && packet.continuation === 'unfinished_work'
4
5
  && packet.run_id === scope.runId && packet.generation === scope.generation && packet.stage === scope.stage
5
6
  && packet.work_id === scope.workId && packet.paused === false;
6
7
  }
7
8
  import crypto from "node:crypto";
9
+ import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
8
10
  import fs from "node:fs";
9
11
  import path from "node:path";
10
12
  import { setTimeout as delay } from "node:timers/promises";
11
13
  import { fileURLToPath } from "node:url";
12
14
  import { quote } from "shell-quote";
15
+ import { retainedBrowserEnvironment, validateCheckEnvironment } from "./check-environment.js";
13
16
  import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
14
17
  import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
15
18
  import { publishImmutableInput } from "../storage/immutable-input.js";
@@ -20,17 +23,21 @@ import { requireProjectByRoot } from "./projects.js";
20
23
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
21
24
  import { getFlowRunStatus, setFlowRunRecoveryPaused, assertPreparedRunBundleSqlAuthority } from "./runs.js";
22
25
  import { loadExternalStageContext } from "./stage-context.js";
23
- import { flowCommand } from "./stage-pause.js";
26
+ import { flowCommand, stageResumeSourcePath } from "./stage-pause.js";
24
27
  import { sameSessionProfile, runtimeHarness } from "./execution-policy.js";
25
28
  import { selectControllerExecution } from "./controller-execution.js";
26
29
  import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
27
30
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
28
- import { assertManagedProcessLease, confirmManagedProcess, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
31
+ import { assertManagedProcessLease, confirmManagedProcess, prepareManagedProcessConfirmation, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
29
32
  import { managedLeaseMonitor } from "./managed-lease-monitor.js";
33
+ import { physicalStartup } from "./physical-startup.js";
30
34
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
31
35
  import { initCodexHome } from "./hooks.js";
32
36
  import { launchExternalWork } from "./external-work-launch.js";
37
+ import { runControllerMemoryTask } from "./controller-memory-task.js";
33
38
  import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
39
+ import { publishPromptDelivery } from "./prompt-delivery.js";
40
+ import { publishStagePacket } from "./stage-report-renderer.js";
34
41
  import { claimQueuedMergeRequest } from "./merge-server.js";
35
42
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
36
43
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, retainControllerCapacityIntent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
@@ -38,7 +45,7 @@ import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
38
45
  import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerLeaseAllowed } from "./run-controller-process.js";
39
46
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
40
47
  import { retainedChildObserver } from "./controller-child-observer.js";
41
- import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure } from "./provider-capacity.js";
48
+ import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure, ownedProviderBoundary } from "./provider-capacity.js";
42
49
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
43
50
  import { captureControllerBoundary } from "./run-controller-capture.js";
44
51
  import { requestRunControl } from "./run-control-worker.js";
@@ -133,7 +140,8 @@ export function retainRunControllerLaunch(context, input, prepared = prepareRunC
133
140
  }
134
141
  const manifest = { schema_id: routing.kind === "descriptor" ? "dd-flow/run-controller-manifest@2" : "dd-flow/run-controller-manifest@1", request,
135
142
  ...(routing.kind === "descriptor" ? { execution_binding: routing.binding } : { execution_profile: routing.legacy }),
136
- contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget };
143
+ contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget,
144
+ ...(retainedBrowserEnvironment(context.env) ? { check_environment: retainedBrowserEnvironment(context.env) } : {}) };
137
145
  const token = crypto.randomUUID();
138
146
  const now = context.now();
139
147
  const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
@@ -215,92 +223,120 @@ export async function serveRunController(context, input) {
215
223
  const row = requireController(context, input.controllerId);
216
224
  if (row.project_id !== project.id || row.run_id !== input.runId || row.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN)
217
225
  throw new AppError("controller_owner_mismatch", "Managed serve does not own this launch", 1);
218
- claimControllerProcess(context, row);
219
- if (!row.process_id || !row.process_lease_token)
220
- throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
221
- const confirmed = confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
222
- 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);
223
- const state = JSON.parse(row.state_json);
224
- // Resource and RUN commits are distinct effects. A RUN keepalive failure must
225
- // neither erase a resource receipt nor grant RUN dispatch authority.
226
- const mergeLease = controllerLeaseMonitor(attemptMs => {
227
- const expiry = new Date(Date.now() + 120_000).toISOString();
228
- const db = context.db.withDeadline(Date.now() + attemptMs);
229
- if (db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching' LIMIT 1", [row.project_id, row.run_id, row.controller_id]))
230
- db.writeTransaction(() => {
231
- const guard = recoveryGuard({ ...context, db }, row.project_id, row.run_id);
232
- if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
233
- throw new AppError("controller_lease_lost", "RUN generation changed before MERGE keepalive", 1);
234
- db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching'", [expiry, context.now(), row.project_id, row.run_id, row.controller_id]);
235
- });
236
- return { owned: true, lease_expires_at: expiry };
237
- });
238
- const assertLeaseFence = () => {
239
- assertControllerLeaseAllowed(context, row);
240
- };
241
- const heartbeat = setInterval(() => {
242
- lease.background(assertLeaseFence, () => { });
243
- if (state.merge_claim)
244
- mergeLease.background(assertLeaseFence, () => { });
245
- }, 30_000);
246
- heartbeat.unref();
247
- const manifest = JSON.parse(row.manifest_json);
248
- const assertPhysicalOwner = () => {
249
- lease.assertNoRenewal();
250
- if (state.merge_claim)
251
- mergeLease.assertNoRenewal();
252
- // This isolated read already bounds SQLite contention. A wall deadline
253
- // before opening the reader mistakes scheduler delay for heartbeat loss;
254
- // the observed row's actual expiry/token still fences productive dispatch.
255
- assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
256
- const current = requireController(context, row.controller_id);
257
- 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))
258
- throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
259
- };
260
- const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
226
+ let lease;
227
+ let mergeLease;
228
+ let heartbeat;
229
+ let state;
230
+ let claimedProcess = false;
261
231
  let primaryFailure;
262
232
  const retainSecondary = (phase, error) => {
263
233
  process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
264
234
  };
265
235
  try {
236
+ const currentState = state = JSON.parse(row.state_json);
237
+ const startup = physicalStartup(context.databaseDeadlineAt);
238
+ const assertStartingOwner = () => {
239
+ const current = requireController(context, row.controller_id);
240
+ if (current.owner_token !== row.owner_token || current.generation !== row.generation || current.owner_pid !== null || current.status !== "starting")
241
+ throw new AppError("controller_owner_mismatch", "Controller startup no longer owns this launch", 1);
242
+ if (["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
243
+ throw new AppError("invalid_run_state", "A terminal RUN cannot start a physical controller", 1);
244
+ };
245
+ const owner = await startup.birth(assertStartingOwner);
246
+ try {
247
+ claimControllerProcess(context, row, owner);
248
+ }
249
+ finally {
250
+ claimedProcess = row.owner_pid === process.pid;
251
+ }
252
+ if (!row.process_id || !row.process_lease_token)
253
+ throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
254
+ const confirmation = { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid };
255
+ const originalLease = prepareManagedProcessConfirmation(context, confirmation).lease_expires_at;
256
+ const confirmed = await startup.observe(() => confirmManagedProcess(context, confirmation), () => assertControllerLeaseAllowed(context, row), originalLease);
257
+ 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);
258
+ // Resource and RUN commits are distinct effects. A RUN keepalive failure must
259
+ // neither erase a resource receipt nor grant RUN dispatch authority.
260
+ mergeLease = controllerLeaseMonitor(attemptMs => {
261
+ const expiry = new Date(Date.now() + 120_000).toISOString();
262
+ const db = context.db.withDeadline(Date.now() + attemptMs);
263
+ if (db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching' LIMIT 1", [row.project_id, row.run_id, row.controller_id]))
264
+ db.writeTransaction(() => {
265
+ const guard = recoveryGuard({ ...context, db }, row.project_id, row.run_id);
266
+ if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
267
+ throw new AppError("controller_lease_lost", "RUN generation changed before MERGE keepalive", 1);
268
+ db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching'", [expiry, context.now(), row.project_id, row.run_id, row.controller_id]);
269
+ });
270
+ return { owned: true, lease_expires_at: expiry };
271
+ });
272
+ const assertLeaseFence = () => {
273
+ assertControllerLeaseAllowed(context, row);
274
+ };
275
+ heartbeat = setInterval(() => {
276
+ lease.background(assertLeaseFence, () => { });
277
+ if (currentState.merge_claim)
278
+ mergeLease.background(assertLeaseFence, () => { });
279
+ }, 30_000);
280
+ heartbeat.unref();
281
+ const manifest = JSON.parse(row.manifest_json);
282
+ const assertPhysicalOwner = () => {
283
+ lease.assertNoRenewal();
284
+ if (currentState.merge_claim)
285
+ mergeLease.assertNoRenewal();
286
+ // This isolated read already bounds SQLite contention. A wall deadline
287
+ // before opening the reader mistakes scheduler delay for heartbeat loss;
288
+ // the observed row's actual expiry/token still fences productive dispatch.
289
+ assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
290
+ const current = requireController(context, row.controller_id);
291
+ 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))
292
+ throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
293
+ };
294
+ const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
266
295
  await lease.ensureConfirmed(assertLeaseFence);
267
- if (state.merge_claim)
296
+ if (currentState.merge_claim)
268
297
  await mergeLease.ensureConfirmed(assertLeaseFence);
269
298
  context.db.writeTransaction(() => {
270
299
  markControllerDispatchReady(context, row);
271
- 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")
300
+ if (!currentState.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
272
301
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
273
302
  });
274
303
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
275
- if (state.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
276
- await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
277
- await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); if (state.merge_claim)
304
+ if (currentState.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
305
+ await resumeControllerSession(context, row, manifest, currentState, assertPhysicalOwner);
306
+ await executeController(context, row, manifest, currentState, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); if (currentState.merge_claim)
278
307
  await mergeLease.ensureConfirmed(assertLeaseFence); });
279
308
  return controllerReceipt(requireController(context, row.controller_id));
280
309
  }
281
310
  catch (error) {
282
311
  primaryFailure = error;
283
312
  try {
284
- try {
285
- failControllerRecovery(context, row, state, error);
286
- }
287
- catch (secondary) {
288
- retainSecondary("fail_controller_recovery", secondary);
289
- }
290
- if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
291
- && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
292
- const guard = recoveryGuard(context, row.project_id, row.run_id);
293
- const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
294
- if (control) {
295
- // Control is a later state transition, not evidence that the causal
296
- // failure which fenced admission disappeared.
297
- context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
298
- appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
299
- return controllerReceipt(requireController(context, row.controller_id));
313
+ const controlled = context.db.writeTransaction(() => {
314
+ const currentOwner = requireController(context, row.controller_id);
315
+ const sameBinding = currentOwner.owner_token === row.owner_token && currentOwner.generation === row.generation
316
+ && currentOwner.process_id === row.process_id && currentOwner.process_lease_token === row.process_lease_token;
317
+ const ownsFailure = claimedProcess ? currentOwner.owner_pid === process.pid : row.owner_pid === null && currentOwner.owner_pid === null && row.status === "starting" && currentOwner.status === "starting";
318
+ if (!sameBinding || !ownsFailure || ["completed", "cancelled", "superseded"].includes(currentOwner.status))
319
+ throw error;
320
+ try {
321
+ if (state)
322
+ failControllerRecovery(context, row, state, error);
300
323
  }
301
- }
302
- try {
303
- context.db.writeTransaction(() => {
324
+ catch (secondary) {
325
+ retainSecondary("fail_controller_recovery", secondary);
326
+ }
327
+ if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
328
+ && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
329
+ const guard = recoveryGuard(context, row.project_id, row.run_id);
330
+ const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
331
+ if (control) {
332
+ // Control is a later state transition, not evidence that the causal
333
+ // failure which fenced admission disappeared.
334
+ context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
335
+ appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
336
+ return controllerReceipt(requireController(context, row.controller_id));
337
+ }
338
+ }
339
+ try {
304
340
  // A failed capture must not reopen admission when this process exits.
305
341
  const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
306
342
  context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
@@ -308,11 +344,14 @@ export async function serveRunController(context, input) {
308
344
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
309
345
  }
310
346
  appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
311
- });
312
- }
313
- catch (failure) {
314
- retainSecondary("persist_controller_failure", failure);
315
- }
347
+ }
348
+ catch (failure) {
349
+ retainSecondary("persist_controller_failure", failure);
350
+ }
351
+ return null;
352
+ });
353
+ if (controlled)
354
+ return controlled;
316
355
  }
317
356
  catch (secondary) {
318
357
  retainSecondary("controller_failure_boundary", secondary);
@@ -320,12 +359,15 @@ export async function serveRunController(context, input) {
320
359
  throw error;
321
360
  }
322
361
  finally {
323
- lease.close();
324
- mergeLease.close();
325
- clearInterval(heartbeat);
362
+ lease?.close();
363
+ mergeLease?.close();
364
+ if (heartbeat)
365
+ clearInterval(heartbeat);
326
366
  finishOwner();
327
367
  }
328
368
  function finishOwner() {
369
+ if (!claimedProcess || row.owner_pid !== process.pid || !row.process_id || !row.process_lease_token)
370
+ return;
329
371
  try {
330
372
  finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
331
373
  }
@@ -497,7 +539,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
497
539
  const routing = selectControllerExecution(context, requireRun(context, row.project_id, row.run_id), manifest);
498
540
  const policy = routing.policy;
499
541
  const profiles = routing.profiles;
500
- 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 } };
542
+ let executionContext = { ...context, env: { ...context.env, ...validateCheckEnvironment(manifest.check_environment), 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 } };
501
543
  const bindInvocationScope = (session) => {
502
544
  executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
503
545
  projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
@@ -598,9 +640,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
598
640
  const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined, productive ? renewLease : undefined);
599
641
  const confirmStopped = async (stateDir, receipt) => {
600
642
  const fail = () => new AppError("controller_native_shutdown_unproven", "The managed daemon is not proven clean and closed", 1, { state_dir: stateDir, receipt });
601
- if (receipt.stopped !== true || receipt.clean !== true)
602
- throw fail();
603
643
  const binding = requireManagedDaemonBinding(executionContext, { stateDir, projectId: row.project_id, runId: row.run_id });
644
+ if (!binding.state.daemon_id || receipt.stopped !== true || !nativeShutdownConfirmed(receipt, { daemonId: binding.state.daemon_id }))
645
+ throw fail();
604
646
  const deadline = performance.now() + 30_000;
605
647
  while (processTreeIsAlive(binding.process) && performance.now() < deadline)
606
648
  await delay(100);
@@ -645,6 +687,45 @@ async function executeController(context, row, manifest, state, assertOwnership,
645
687
  if (!pending)
646
688
  await reconcilePromptPredecessor(session);
647
689
  const firstOperationId = pending?.root_operation_id ?? operationId ?? `${row.controller_id}:prompt:${crypto.randomUUID()}`;
690
+ const hitlText = async () => {
691
+ const answerId = firstOperationId.slice(0, -`:prompt:${row.generation}`.length);
692
+ const answer = context.db.get("SELECT input_json,receipt_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND status IN ('accepted','completed')", [answerId, row.controller_id]);
693
+ const accepted = answer && JSON.parse(answer.input_json);
694
+ const packet = answer?.receipt_json && JSON.parse(answer.receipt_json).hitl_packet;
695
+ if (!packet || !accepted || packet.generation !== row.generation || packet.pause_id !== accepted.pause_id || packet.stage !== accepted.stage || packet.work_id !== accepted.work_id || packet.answer_file !== accepted.file || packet.answer_sha256 !== accepted.sha256)
696
+ throw new AppError("controller_hitl_packet_invalid", "Accepted HITL continuation lost its exact binding", 1);
697
+ const issued = context.db.get("SELECT id,status,scope_json,command FROM lifecycle_invocations WHERE id = ?", [packet.invocation_id ?? null]);
698
+ if (!issued || issued.scope_json !== executionContext.env.DD_FLOW_INVOCATION_SCOPE)
699
+ throw new AppError("controller_hitl_packet_invalid", "Accepted HITL command belongs to another scope", 1);
700
+ if (issued.status === "executing" || issued.status === "settled") {
701
+ const recovered = await awaitLifecycleInvocation(executionContext, { id: issued.id, command: issued.command, assertCurrent: scope => assertLifecycleInvocationCurrent(executionContext, scope, issued.command) });
702
+ if (!recovered.replay)
703
+ throw new AppError("controller_hitl_reconciliation_required", "Accepted resume is not committed", 1);
704
+ const outcome = recovered.outcome;
705
+ if (!outcome.result)
706
+ throw new AppError("controller_hitl_reconciliation_required", "Accepted resume has no committed continuation", 1);
707
+ if (outcome.result.resume_source_path !== packet.resume_source_path || typeof outcome.result.answer_path !== "string")
708
+ throw new AppError("controller_hitl_packet_invalid", "Committed resume differs from the accepted packet locator", 1);
709
+ publishStagePacket(executionContext, row.project_id, row.run_id, packet.stage, outcome.result.answer_path);
710
+ publishStagePacket(executionContext, row.project_id, row.run_id, packet.stage, packet.resume_source_path);
711
+ const bytes = fs.readFileSync(outcome.result.answer_path);
712
+ if (digestBytes(bytes) !== packet.answer_sha256)
713
+ throw new AppError("context_checksum_mismatch", "Committed answer differs from its accepted bytes", 1);
714
+ fs.mkdirSync(path.dirname(packet.answer_file), { recursive: true, mode: 0o700 });
715
+ publishImmutableInput(packet.answer_file, bytes, "hitl_answer_conflict");
716
+ publishPromptDelivery(outcome.result);
717
+ const delivery = outcome.result.prompt_delivery;
718
+ const promptPath = delivery?.path ?? outcome.result.prompt_path;
719
+ if (typeof promptPath !== "string" || !path.isAbsolute(promptPath))
720
+ throw new AppError("controller_hitl_packet_invalid", "Committed resume has no absolute prompt delivery", 1);
721
+ // Keep continuation bytes stable across a retained overload episode.
722
+ // Its conditional instructions point at this restored exact custody.
723
+ return controllerStageResumePrompt(packet);
724
+ }
725
+ if (issued.status !== "issued" || digestBytes(fs.readFileSync(packet.answer_file)) !== packet.answer_sha256)
726
+ throw new AppError("context_checksum_mismatch", "Accepted HITL answer or command changed before resume", 1);
727
+ return controllerStageResumePrompt(packet);
728
+ };
648
729
  const send = (value, id) => {
649
730
  const retained = context.db.get("SELECT input_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ?", [id, row.controller_id, row.generation]);
650
731
  if (retained) {
@@ -678,7 +759,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
678
759
  promptPending = true;
679
760
  observedPromptOperation = firstOperationId;
680
761
  try {
681
- receipt = await send(text, firstOperationId);
762
+ receipt = await send(hitl ? await hitlText() : text, firstOperationId);
682
763
  }
683
764
  finally {
684
765
  promptPending = false;
@@ -686,9 +767,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
686
767
  }
687
768
  }
688
769
  catch (error) {
689
- const resumeCommand = hitl ? text.split("\n\n")[1] : undefined;
690
- if (hitl && !resumeCommand?.includes(" stage resume "))
691
- throw error;
770
+ const hitlContinuationText = hitl && terminalProviderOverload(error, session.id) ? await hitlText() : undefined;
692
771
  const inspect = (args, id) => runHarnessAdapter({ executable: manifest.adapters[runtimeHarness(session.profile)].executable,
693
772
  args, env: controllerEnvironment(context, manifest, session), runtimeOwner: session.owner, operationId: id, timeoutMs: 30_000, signal: cancellation.signal });
694
773
  const resumed = await continueProviderCapacity({ error, sessionId: session.id, operationId: firstOperationId, signal: cancellation.signal, allowSettledDescendants: true, atomicIntent: true,
@@ -706,7 +785,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
706
785
  assertCurrent: () => {
707
786
  assertOwner();
708
787
  },
709
- decision: async () => {
788
+ decision: async (boundary) => {
710
789
  if (kind === "child") {
711
790
  const target = Object.entries(state.child_capacity ?? {}).find(([, retained]) => retained.operation_id === firstOperationId);
712
791
  if (!target)
@@ -718,6 +797,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
718
797
  }
719
798
  const view = getFlowRunStatus(executionContext, { projectRoot: manifest.request.project_root, runId: row.run_id });
720
799
  const next = view.continuation;
800
+ if (boundary) {
801
+ if (!ownedProviderBoundary(context, { projectId: row.project_id, runId: row.run_id, stage: state.stage, sessionId: session.id, generation: row.generation, scope: "stage" }, boundary))
802
+ return "reconciliation_blocked";
803
+ return boundary.outcome === "paused" ? "paused" : "completed";
804
+ }
721
805
  if (next.kind === "blocked")
722
806
  return "reconciliation_blocked";
723
807
  if (next.kind === "paused" && next.stage !== state.stage)
@@ -740,7 +824,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
740
824
  },
741
825
  retainIntent: intent => retainControllerCapacityIntent(context, row, state, intent, session.id, kind ?? "stage", assertOwner),
742
826
  prompt: (id, value) => send(value, id),
743
- ...(resumeCommand ? { continuationText: `Continue the same accepted HITL task in this Session. First inspect current durable RUN and pause state. Only if the exact pause still awaits the accepted answer, run this previously issued command once:\n\n${resumeCommand}\n\nNever replay an already committed answer. Otherwise use only the current issued successor.` } : {}) });
827
+ ...(hitlContinuationText ? { continuationText: hitlContinuationText } : {}) });
744
828
  if (!resumed)
745
829
  throw error;
746
830
  receipt = resumed;
@@ -804,6 +888,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
804
888
  await prompt(session, fs.readFileSync(file, "utf8"), pending.root_operation_id, pending.kind === "stage" ? undefined : pending.kind);
805
889
  continue;
806
890
  }
891
+ if (next.kind !== "paused" && state.current_session !== null) {
892
+ const committed = context.db.get("SELECT operation_id,receipt_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json,'$.action') = 'user_answer' AND json_extract(receipt_json,'$.hitl_packet.generation') = ? AND json_extract(receipt_json,'$.hitl_packet.stage') = ? ORDER BY created_at LIMIT 1", [row.controller_id, row.generation, state.stage]);
893
+ if (committed) {
894
+ const packet = JSON.parse(committed.receipt_json).hitl_packet;
895
+ const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(packet), `${committed.operation_id}:prompt:${row.generation}`, "hitl");
896
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = json_set(receipt_json,'$.delivered',json('true'),'$.session_id',?), updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [adapterSessionId(receipt), context.now(), committed.operation_id]);
897
+ state.last_continuation = null;
898
+ saveState(context, row, state);
899
+ continue;
900
+ }
901
+ }
807
902
  const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
808
903
  if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
809
904
  throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
@@ -849,13 +944,29 @@ async function executeController(context, row, manifest, state, assertOwnership,
849
944
  assertOwner();
850
945
  // The accepted answer is logical intent; only its generation-bound
851
946
  // adapter operation represents a physical dispatch that stop must drain.
852
- const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
853
- const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(pause.id, command), `${answer.operation_id}:prompt:${row.generation}`, "hitl");
947
+ let invocationId;
948
+ const capture = executionContext.captureLifecycleAssignment;
949
+ executionContext.captureLifecycleAssignment = id => { invocationId = id; capture?.(id); };
950
+ let command;
951
+ try {
952
+ command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
953
+ }
954
+ finally {
955
+ if (capture)
956
+ executionContext.captureLifecycleAssignment = capture;
957
+ else
958
+ delete executionContext.captureLifecycleAssignment;
959
+ }
960
+ if (!invocationId)
961
+ throw new AppError("controller_hitl_packet_invalid", "Accepted resume did not capture its issued identity", 1);
962
+ const packet = { pause_id: pause.id, stage: next.stage, work_id: input.work_id, answer_file: input.file, answer_sha256: input.sha256, command, generation: row.generation, invocation_id: invocationId, resume_source_path: stageResumeSourcePath(pause, fs.readFileSync(input.file, "utf8")) };
963
+ context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [JSON.stringify({ hitl_packet: packet }), context.now(), answer.operation_id]);
964
+ const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(packet), `${answer.operation_id}:prompt:${row.generation}`, "hitl");
854
965
  const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
855
966
  if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
856
967
  throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
857
968
  }
858
- context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
969
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = json_set(receipt_json,'$.delivered',json('true'),'$.session_id',?), updated_at = ? WHERE operation_id = ?", [adapterSessionId(receipt), context.now(), answer.operation_id]);
859
970
  state.last_continuation = null;
860
971
  saveState(context, row, state);
861
972
  setStatus("running");
@@ -1088,7 +1199,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
1088
1199
  continue;
1089
1200
  }
1090
1201
  }
1091
- 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 });
1202
+ const memoryFlow = ["mb-init", "mb-upgrade"].includes(run.flow_kind) || isDiagnosticFlow(run.flow_kind);
1203
+ const fanoutInput = { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 };
1204
+ const fanout = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "fanout", input: fanoutInput }, cancellation.signal)
1205
+ : await nextControllerFanout(executionContext, fanoutInput);
1206
+ if (memoryFlow)
1207
+ await renewLease();
1092
1208
  assertOwner();
1093
1209
  if (fanout.kind === "none") {
1094
1210
  // An ACK-only recovery Turn may leave the original root Work unfinished.
@@ -1129,7 +1245,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
1129
1245
  try {
1130
1246
  await renewLease();
1131
1247
  assertOwner();
1132
- const receipt = await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage, signal: cancellation.signal });
1248
+ const receipt = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "external", input: { projectRoot: run.project_root, runId: run.id, workId: work.work_id, stage: next.stage } }, cancellation.signal)
1249
+ : await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage, signal: cancellation.signal });
1250
+ if (memoryFlow) {
1251
+ await renewLease();
1252
+ assertOwner();
1253
+ }
1133
1254
  if (fatal)
1134
1255
  throw fatal;
1135
1256
  appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
@@ -28,7 +28,7 @@ export function prepareRunFork(input) {
28
28
  }
29
29
  prepareForkEngine(engineVersion, input.integrityChecksum);
30
30
  const manifest = prepareEvalRunSnapshot(snapshot);
31
- if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
31
+ if (!["dd-flow/eval-run-snapshot@5", "dd-flow/eval-run-snapshot@7"].includes(manifest.schema_id) || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
32
32
  throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
33
33
  }
34
34
  const operational = prepareSnapshotOperationalDecision({ snapshot, runId: manifest.run_id, projectRoot: path.join(output, "project"), now: new Date().toISOString(), ...(input.operationalDecisionFile ? { file: input.operationalDecisionFile } : {}) });
@@ -1,6 +1,36 @@
1
+ import crypto from "node:crypto";
1
2
  import { AppError } from "../shared/errors.js";
2
3
  import { CAPACITY_POLICY, MAX_OVERLOAD_RETRIES, overloadExhausted } from "../harness-runtime/lib/provider-overload-policy.mjs";
3
4
  import { assertRunStartupSettled } from "./run-start-custody.js";
5
+ export function recoveryPacketReceipt(binding) {
6
+ if (!binding.packet_json || !binding.packet_sha256 || crypto.createHash("sha256").update(binding.packet_json).digest("hex") !== binding.packet_sha256)
7
+ throw new AppError("recovery_packet_missing", "Recovery requires its original verified Work packet", 1);
8
+ return { packet_json: binding.packet_json, packet_sha256: binding.packet_sha256, packet: JSON.parse(binding.packet_json) };
9
+ }
10
+ /** A cooperative drain permits only the WorkSession present at its fence. */
11
+ export function assertWorkSettlementAllowed(context, projectId, runId, workId) {
12
+ const guard = recoveryGuard(context, projectId, runId);
13
+ if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
14
+ const admission = context.db.get("SELECT a.work_session_id FROM run_control_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN work_sessions ws ON ws.id = a.work_session_id JOIN works w ON w.work_id = a.work_id WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.work_id = ? AND ws.work_id = a.work_id AND ws.session_id = a.session_id AND ws.status = 'running' AND w.status = 'running' AND w.project_id = c.project_id AND w.run_id = c.run_id LIMIT 1", [projectId, runId, guard.recovery_id, workId]);
15
+ if (admission)
16
+ return;
17
+ }
18
+ assertRunMutationAllowed(context, projectId, runId);
19
+ }
20
+ /** Completion rights are pinned to the Stage attempt present at the pause fence. */
21
+ export function assertStageSettlementAllowed(context, projectId, runId, stage, expectedGeneration) {
22
+ const guard = recoveryGuard(context, projectId, runId);
23
+ const generation = guard?.generation ?? 0;
24
+ if (expectedGeneration !== undefined && generation !== expectedGeneration)
25
+ throw new AppError("run_recovery_generation_stale", "RUN execution generation changed while the operation was in flight", 1, { run_id: runId, expected_generation: expectedGeneration, generation });
26
+ if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_control_stage_admissions'")) {
27
+ const admitted = context.db.get(`SELECT a.work_session_id FROM run_control_stage_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN run_control_admissions w ON w.control_id = a.control_id AND w.work_session_id = a.work_session_id JOIN work_sessions ws ON ws.id = w.work_session_id JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id, json_each(r.index_json, '$.stage_runs') live
28
+ WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.stage = ? AND json_extract(live.value, '$.stage') = a.stage AND COALESCE(json_extract(live.value, '$.attempt'), 'try-001') = a.attempt AND ws.work_id = w.work_id AND ws.session_id = w.session_id AND NOT EXISTS (SELECT 1 FROM work_sessions newer WHERE newer.work_id = w.work_id AND newer.status = 'running' AND newer.id <> ws.id) LIMIT 1`, [projectId, runId, guard.recovery_id, stage]);
29
+ if (admitted)
30
+ return generation;
31
+ }
32
+ return assertRunMutationAllowed(context, projectId, runId, expectedGeneration);
33
+ }
4
34
  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; }
5
35
  export function assertRunRecoveryAllowed(context, projectId, runId, expectedGeneration) {
6
36
  const guard = recoveryGuard(context, projectId, runId);