@deksden-com/dd-flow-cli 0.9.0-beta.143 → 0.9.0-beta.145

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 (197) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +13 -8
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +9 -3
  5. package/dist/cli/command-metadata.js +33 -14
  6. package/dist/cli/help.js +32 -1
  7. package/dist/cli/hook-ingress.js +27 -3
  8. package/dist/cli/memory-flow-admission.js +7 -3
  9. package/dist/cli/memory-init-invocation.js +10 -3
  10. package/dist/cli/memory-init.js +74 -68
  11. package/dist/cli/memory-storage-admission.js +3 -2
  12. package/dist/cli/memory-storage-qualification.js +105 -0
  13. package/dist/cli/memory-storage-transition.js +123 -0
  14. package/dist/cli/memory-upgrade.js +189 -0
  15. package/dist/cli/native-hook-ingress.js +14 -4
  16. package/dist/cli/native-hook-preparation.js +55 -0
  17. package/dist/cli/response-file.js +2 -1
  18. package/dist/cli/run-cli.js +70 -32
  19. package/dist/cli/setup.js +2 -1
  20. package/dist/cli.js +12 -0
  21. package/dist/domain/stage-handler-identities.js +8 -0
  22. package/dist/harness-runtime/bin/dd-agy.mjs +3 -3
  23. package/dist/harness-runtime/bin/dd-grok.mjs +3 -3
  24. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  25. package/dist/harness-runtime/lib/agy-error-boundary.mjs +58 -0
  26. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +62 -20
  27. package/dist/harness-runtime/lib/dd-agy.mjs +17 -3
  28. package/dist/harness-runtime/lib/dd-droid.mjs +1 -1
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +70 -13
  30. package/dist/harness-runtime/lib/dd-grok.mjs +6 -3
  31. package/dist/harness-runtime/lib/dd-zcode.mjs +44 -14
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +1 -1
  33. package/dist/harness-runtime/lib/entity-references.d.mts +2 -0
  34. package/dist/harness-runtime/lib/entity-references.mjs +9 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +13 -6
  36. package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
  37. package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -3
  38. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  39. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +4 -2
  40. package/dist/harness-runtime/lib/provider-overload-policy.mjs +19 -6
  41. package/dist/harness-runtime/lib/runner-events.mjs +3 -1
  42. package/dist/harness-runtime/lib/setup-progress.mjs +8 -6
  43. package/dist/schemas/mb-upgrade-stage-result.schema.json +863 -0
  44. package/dist/schemas/memory-repair-work-result.schema.json +9 -2
  45. package/dist/schemas/release-impact.schema.json +3 -3
  46. package/dist/schemas/stage-start-response.schema.json +4 -0
  47. package/dist/services/accepted-intent.js +13 -1
  48. package/dist/services/canon.js +43 -7
  49. package/dist/services/cli-operation-classifier.js +1 -1
  50. package/dist/services/cli-upgrade-allowlist.js +1 -1
  51. package/dist/services/code-checks.js +11 -4
  52. package/dist/services/compatibility-preflight.js +5 -2
  53. package/dist/services/controller-fanout.js +21 -4
  54. package/dist/services/controller-memory-task.js +45 -0
  55. package/dist/services/controller-memory-worker.js +48 -0
  56. package/dist/services/dashboard.js +2 -2
  57. package/dist/services/descriptor-run-start.js +26 -19
  58. package/dist/services/descriptor-stage-coordinator.js +30 -10
  59. package/dist/services/descriptor-stage-start.js +40 -4
  60. package/dist/services/engines.js +64 -14
  61. package/dist/services/eval-snapshots.js +104 -93
  62. package/dist/services/external-work-launch.js +5 -3
  63. package/dist/services/flow-catalog.js +8 -4
  64. package/dist/services/frozen-engine.js +113 -11
  65. package/dist/services/hook-memory-routing.js +188 -0
  66. package/dist/services/hook-recovery-guard.js +14 -0
  67. package/dist/services/hook-session-observation.js +24 -0
  68. package/dist/services/hooks.js +27 -8
  69. package/dist/services/hybrid-contracts.js +2 -13
  70. package/dist/services/hybrid-digest.js +14 -0
  71. package/dist/services/installed-flow-source.js +25 -4
  72. package/dist/services/lifecycle-invocations.js +33 -5
  73. package/dist/services/memory-accepted-composition.js +5 -4
  74. package/dist/services/memory-completed-correction-wave.js +23 -15
  75. package/dist/services/memory-compose-handler.js +163 -21
  76. package/dist/services/memory-composition-acceptance.js +186 -10
  77. package/dist/services/memory-composition-graph.js +42 -11
  78. package/dist/services/memory-composition-registration.js +23 -8
  79. package/dist/services/memory-composition-work-custody.js +14 -9
  80. package/dist/services/memory-composition-work-results.js +15 -3
  81. package/dist/services/memory-corrected-review-batch.js +10 -3
  82. package/dist/services/memory-corrected-review-findings.js +4 -2
  83. package/dist/services/memory-corrected-review-inputs.js +10 -5
  84. package/dist/services/memory-corrected-review-receipt.js +17 -9
  85. package/dist/services/memory-corrected-review-registration.js +3 -2
  86. package/dist/services/memory-correction-check-receipt.js +20 -4
  87. package/dist/services/memory-custody-snapshot.js +36 -0
  88. package/dist/services/memory-finish-evidence.js +44 -22
  89. package/dist/services/memory-finish-handler.js +37 -23
  90. package/dist/services/memory-finish-integration.js +218 -48
  91. package/dist/services/memory-finish-readiness.js +66 -19
  92. package/dist/services/memory-git-integration.js +87 -18
  93. package/dist/services/memory-historical-corrected-review.js +21 -15
  94. package/dist/services/memory-historical-original-review.js +22 -9
  95. package/dist/services/memory-historical-repair-registration.js +14 -8
  96. package/dist/services/memory-historical-second-review.js +30 -11
  97. package/dist/services/memory-init-contexts.js +28 -2
  98. package/dist/services/memory-init-controller-start.js +19 -8
  99. package/dist/services/memory-init-git-policy.js +50 -21
  100. package/dist/services/memory-init-lint-capability.js +21 -3
  101. package/dist/services/memory-init-native-capacity.js +13 -0
  102. package/dist/services/memory-init-preflight.js +11 -1
  103. package/dist/services/memory-init-request-candidate.js +73 -17
  104. package/dist/services/memory-init-request-input.js +23 -7
  105. package/dist/services/memory-init-run-bundle.js +49 -12
  106. package/dist/services/memory-init-storage.js +17 -7
  107. package/dist/services/memory-layer.js +68 -4
  108. package/dist/services/memory-lint-config.js +111 -2
  109. package/dist/services/memory-native-review-copy.js +5 -67
  110. package/dist/services/memory-normal-flow-entry.js +12 -0
  111. package/dist/services/memory-ordinary-defaults.js +22 -5
  112. package/dist/services/memory-preflight-handler.js +15 -0
  113. package/dist/services/memory-repair-current-view.js +1 -1
  114. package/dist/services/memory-repair-document-delta.js +70 -33
  115. package/dist/services/memory-repair-history.js +12 -7
  116. package/dist/services/memory-repair-physical-binding.js +1 -119
  117. package/dist/services/memory-repair-publication-plan.js +5 -3
  118. package/dist/services/memory-repair-publication-source.js +1 -1
  119. package/dist/services/memory-repair-publication.js +3 -2
  120. package/dist/services/memory-repair-registration.js +2 -1
  121. package/dist/services/memory-repair-work-baseline.js +4 -2
  122. package/dist/services/memory-repair-work-completion.js +32 -12
  123. package/dist/services/memory-repair-work-results.js +21 -2
  124. package/dist/services/memory-repair-workspace.js +11 -2
  125. package/dist/services/memory-repair-writer-copy.js +2 -1
  126. package/dist/services/memory-result-binding.js +78 -12
  127. package/dist/services/memory-retained-review-authority.js +17 -8
  128. package/dist/services/memory-retained-second-baseline.js +1 -1
  129. package/dist/services/memory-retained-second-repairs.js +12 -6
  130. package/dist/services/memory-review-correction-plan.js +47 -12
  131. package/dist/services/memory-review-handler.js +17 -17
  132. package/dist/services/memory-review-inputs.js +55 -9
  133. package/dist/services/memory-review-limits.js +10 -0
  134. package/dist/services/memory-review-receipt.js +2 -2
  135. package/dist/services/memory-review-registration.js +13 -4
  136. package/dist/services/memory-review-work-custody.js +3 -1
  137. package/dist/services/memory-review-work-input.js +26 -7
  138. package/dist/services/memory-review-work-results.js +26 -4
  139. package/dist/services/memory-second-corrected-review-inputs.js +5 -3
  140. package/dist/services/memory-second-correction-plan.js +2 -1
  141. package/dist/services/memory-second-repair-launch.js +4 -4
  142. package/dist/services/memory-second-repair-source.js +1 -1
  143. package/dist/services/memory-stage-dispatch.js +38 -1
  144. package/dist/services/memory-stage-results.js +113 -22
  145. package/dist/services/memory-upgrade-analysis-handler.js +243 -0
  146. package/dist/services/memory-upgrade-bootstrap-readiness.js +251 -0
  147. package/dist/services/memory-upgrade-evidence.js +100 -0
  148. package/dist/services/memory-upgrade-inventory.js +442 -0
  149. package/dist/services/memory-upgrade-publication-custody.js +73 -0
  150. package/dist/services/memory-upgrade-publication.js +413 -0
  151. package/dist/services/memory-upgrade-selection.js +430 -0
  152. package/dist/services/memory-upgrade-start.js +60 -0
  153. package/dist/services/memory-upgrade-storage-readiness.js +340 -0
  154. package/dist/services/memory-verification-acceptance.js +38 -17
  155. package/dist/services/memory-verification-checks.js +77 -23
  156. package/dist/services/memory-verification-receipt.js +6 -5
  157. package/dist/services/memory-verify-handler.js +15 -13
  158. package/dist/services/memory-work-result-json.js +12 -0
  159. package/dist/services/memory-work-result-preparation.js +5 -4
  160. package/dist/services/merge-server.js +4 -2
  161. package/dist/services/migrations.js +127 -98
  162. package/dist/services/physical-startup.js +45 -0
  163. package/dist/services/project-registry.js +97 -0
  164. package/dist/services/project-summary.js +1 -1
  165. package/dist/services/projects.js +3 -93
  166. package/dist/services/prompt-delivery.js +71 -0
  167. package/dist/services/provider-capacity.js +74 -9
  168. package/dist/services/recovery-snapshot-database.js +32 -4
  169. package/dist/services/repair-intents.js +1 -12
  170. package/dist/services/repair-receipt.js +13 -0
  171. package/dist/services/run-control-worker.js +35 -18
  172. package/dist/services/run-controller-process.js +2 -6
  173. package/dist/services/run-controller-recovery.js +6 -1
  174. package/dist/services/run-controller.js +131 -80
  175. package/dist/services/run-recovery-guard.js +30 -0
  176. package/dist/services/run-recovery.js +11 -44
  177. package/dist/services/run-start-custody.js +17 -5
  178. package/dist/services/runtime-scope-worker.js +46 -19
  179. package/dist/services/sessions.js +3 -24
  180. package/dist/services/setup.js +63 -13
  181. package/dist/services/stage-handlers.js +9 -7
  182. package/dist/services/stage-pause.js +108 -44
  183. package/dist/services/version-status.js +1 -1
  184. package/dist/services/vnext-fanout.js +21 -9
  185. package/dist/services/vnext-plan-review.js +3 -5
  186. package/dist/services/work-registry.js +80 -33
  187. package/dist/services/workspace-files.js +1 -1
  188. package/dist/shared/entity-references.js +1 -8
  189. package/dist/storage/data-preservation.js +65 -0
  190. package/dist/storage/database.js +53 -19
  191. package/dist/storage/import-guard.js +84 -27
  192. package/dist/storage/memory-upgrade-schema-bootstrap.js +38 -0
  193. package/dist/storage/paired-writer-migration.js +424 -0
  194. package/dist/storage/writer-contract.js +11 -4
  195. package/dist/storage/writer-migration.js +313 -52
  196. package/dist/storage/writer-restore.js +217 -0
  197. package/package.json +8 -4
@@ -255,7 +255,12 @@ export async function resumeControllerSession(context, row, manifest, state, ass
255
255
  || current.process.lease_expires_at <= context.now() || metadata.run_generation !== row.generation || !processIsAlive(current.process))
256
256
  throw new AppError("recovery_capacity_owner_changed", "Replacement native owner changed during capacity wait", 1);
257
257
  },
258
- decision: () => recoveryGuard(context, row.project_id, row.run_id)?.status === "recovered" ? "completed" : "continue",
258
+ decision: (boundary) => {
259
+ if (boundary && (boundary.lifecycle_operation !== "recovery_accept" || boundary.recovery_id !== recovery.recovery_id
260
+ || !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])))
261
+ return "reconciliation_blocked";
262
+ return recoveryGuard(context, row.project_id, row.run_id)?.status === "recovered" ? "completed" : "continue";
263
+ },
259
264
  retainIntent: intent => retainRecoveryCapacityIntent(executionContext, { projectId: row.project_id, runId: row.run_id, recoveryId: recovery.recovery_id,
260
265
  controllerId: row.controller_id, generation: row.generation, daemonId, intent, prompt: continuationText }),
261
266
  prompt: (operationId, prompt) => sendAck(operationId, prompt) });
@@ -25,11 +25,13 @@ import { sameSessionProfile, runtimeHarness } from "./execution-policy.js";
25
25
  import { selectControllerExecution } from "./controller-execution.js";
26
26
  import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
27
27
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
28
- import { assertManagedProcessLease, confirmManagedProcess, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
28
+ import { assertManagedProcessLease, confirmManagedProcess, prepareManagedProcessConfirmation, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
29
29
  import { managedLeaseMonitor } from "./managed-lease-monitor.js";
30
+ import { physicalStartup } from "./physical-startup.js";
30
31
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
31
32
  import { initCodexHome } from "./hooks.js";
32
33
  import { launchExternalWork } from "./external-work-launch.js";
34
+ import { runControllerMemoryTask } from "./controller-memory-task.js";
33
35
  import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
34
36
  import { claimQueuedMergeRequest } from "./merge-server.js";
35
37
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
@@ -38,7 +40,7 @@ import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
38
40
  import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerLeaseAllowed } from "./run-controller-process.js";
39
41
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
40
42
  import { retainedChildObserver } from "./controller-child-observer.js";
41
- import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure } from "./provider-capacity.js";
43
+ import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure, ownedProviderBoundary } from "./provider-capacity.js";
42
44
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
43
45
  import { captureControllerBoundary } from "./run-controller-capture.js";
44
46
  import { requestRunControl } from "./run-control-worker.js";
@@ -215,92 +217,120 @@ export async function serveRunController(context, input) {
215
217
  const row = requireController(context, input.controllerId);
216
218
  if (row.project_id !== project.id || row.run_id !== input.runId || row.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN)
217
219
  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); };
220
+ let lease;
221
+ let mergeLease;
222
+ let heartbeat;
223
+ let state;
224
+ let claimedProcess = false;
261
225
  let primaryFailure;
262
226
  const retainSecondary = (phase, error) => {
263
227
  process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
264
228
  };
265
229
  try {
230
+ const currentState = state = JSON.parse(row.state_json);
231
+ const startup = physicalStartup(context.databaseDeadlineAt);
232
+ const assertStartingOwner = () => {
233
+ const current = requireController(context, row.controller_id);
234
+ if (current.owner_token !== row.owner_token || current.generation !== row.generation || current.owner_pid !== null || current.status !== "starting")
235
+ throw new AppError("controller_owner_mismatch", "Controller startup no longer owns this launch", 1);
236
+ if (["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
237
+ throw new AppError("invalid_run_state", "A terminal RUN cannot start a physical controller", 1);
238
+ };
239
+ const owner = await startup.birth(assertStartingOwner);
240
+ try {
241
+ claimControllerProcess(context, row, owner);
242
+ }
243
+ finally {
244
+ claimedProcess = row.owner_pid === process.pid;
245
+ }
246
+ if (!row.process_id || !row.process_lease_token)
247
+ throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
248
+ const confirmation = { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid };
249
+ const originalLease = prepareManagedProcessConfirmation(context, confirmation).lease_expires_at;
250
+ const confirmed = await startup.observe(() => confirmManagedProcess(context, confirmation), () => assertControllerLeaseAllowed(context, row), originalLease);
251
+ 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);
252
+ // Resource and RUN commits are distinct effects. A RUN keepalive failure must
253
+ // neither erase a resource receipt nor grant RUN dispatch authority.
254
+ mergeLease = controllerLeaseMonitor(attemptMs => {
255
+ const expiry = new Date(Date.now() + 120_000).toISOString();
256
+ const db = context.db.withDeadline(Date.now() + attemptMs);
257
+ 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]))
258
+ db.writeTransaction(() => {
259
+ const guard = recoveryGuard({ ...context, db }, row.project_id, row.run_id);
260
+ if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
261
+ throw new AppError("controller_lease_lost", "RUN generation changed before MERGE keepalive", 1);
262
+ 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]);
263
+ });
264
+ return { owned: true, lease_expires_at: expiry };
265
+ });
266
+ const assertLeaseFence = () => {
267
+ assertControllerLeaseAllowed(context, row);
268
+ };
269
+ heartbeat = setInterval(() => {
270
+ lease.background(assertLeaseFence, () => { });
271
+ if (currentState.merge_claim)
272
+ mergeLease.background(assertLeaseFence, () => { });
273
+ }, 30_000);
274
+ heartbeat.unref();
275
+ const manifest = JSON.parse(row.manifest_json);
276
+ const assertPhysicalOwner = () => {
277
+ lease.assertNoRenewal();
278
+ if (currentState.merge_claim)
279
+ mergeLease.assertNoRenewal();
280
+ // This isolated read already bounds SQLite contention. A wall deadline
281
+ // before opening the reader mistakes scheduler delay for heartbeat loss;
282
+ // the observed row's actual expiry/token still fences productive dispatch.
283
+ assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
284
+ const current = requireController(context, row.controller_id);
285
+ 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))
286
+ throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
287
+ };
288
+ const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
266
289
  await lease.ensureConfirmed(assertLeaseFence);
267
- if (state.merge_claim)
290
+ if (currentState.merge_claim)
268
291
  await mergeLease.ensureConfirmed(assertLeaseFence);
269
292
  context.db.writeTransaction(() => {
270
293
  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")
294
+ 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
295
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
273
296
  });
274
297
  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)
298
+ if (currentState.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
299
+ await resumeControllerSession(context, row, manifest, currentState, assertPhysicalOwner);
300
+ await executeController(context, row, manifest, currentState, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); if (currentState.merge_claim)
278
301
  await mergeLease.ensureConfirmed(assertLeaseFence); });
279
302
  return controllerReceipt(requireController(context, row.controller_id));
280
303
  }
281
304
  catch (error) {
282
305
  primaryFailure = error;
283
306
  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));
307
+ const controlled = context.db.writeTransaction(() => {
308
+ const currentOwner = requireController(context, row.controller_id);
309
+ const sameBinding = currentOwner.owner_token === row.owner_token && currentOwner.generation === row.generation
310
+ && currentOwner.process_id === row.process_id && currentOwner.process_lease_token === row.process_lease_token;
311
+ const ownsFailure = claimedProcess ? currentOwner.owner_pid === process.pid : row.owner_pid === null && currentOwner.owner_pid === null && row.status === "starting" && currentOwner.status === "starting";
312
+ if (!sameBinding || !ownsFailure || ["completed", "cancelled", "superseded"].includes(currentOwner.status))
313
+ throw error;
314
+ try {
315
+ if (state)
316
+ failControllerRecovery(context, row, state, error);
300
317
  }
301
- }
302
- try {
303
- context.db.writeTransaction(() => {
318
+ catch (secondary) {
319
+ retainSecondary("fail_controller_recovery", secondary);
320
+ }
321
+ 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)
322
+ && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
323
+ const guard = recoveryGuard(context, row.project_id, row.run_id);
324
+ 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]);
325
+ if (control) {
326
+ // Control is a later state transition, not evidence that the causal
327
+ // failure which fenced admission disappeared.
328
+ 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]);
329
+ appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
330
+ return controllerReceipt(requireController(context, row.controller_id));
331
+ }
332
+ }
333
+ try {
304
334
  // A failed capture must not reopen admission when this process exits.
305
335
  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
336
  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 +338,14 @@ export async function serveRunController(context, input) {
308
338
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
309
339
  }
310
340
  appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
311
- });
312
- }
313
- catch (failure) {
314
- retainSecondary("persist_controller_failure", failure);
315
- }
341
+ }
342
+ catch (failure) {
343
+ retainSecondary("persist_controller_failure", failure);
344
+ }
345
+ return null;
346
+ });
347
+ if (controlled)
348
+ return controlled;
316
349
  }
317
350
  catch (secondary) {
318
351
  retainSecondary("controller_failure_boundary", secondary);
@@ -320,12 +353,15 @@ export async function serveRunController(context, input) {
320
353
  throw error;
321
354
  }
322
355
  finally {
323
- lease.close();
324
- mergeLease.close();
325
- clearInterval(heartbeat);
356
+ lease?.close();
357
+ mergeLease?.close();
358
+ if (heartbeat)
359
+ clearInterval(heartbeat);
326
360
  finishOwner();
327
361
  }
328
362
  function finishOwner() {
363
+ if (!claimedProcess || row.owner_pid !== process.pid || !row.process_id || !row.process_lease_token)
364
+ return;
329
365
  try {
330
366
  finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
331
367
  }
@@ -706,7 +742,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
706
742
  assertCurrent: () => {
707
743
  assertOwner();
708
744
  },
709
- decision: async () => {
745
+ decision: async (boundary) => {
710
746
  if (kind === "child") {
711
747
  const target = Object.entries(state.child_capacity ?? {}).find(([, retained]) => retained.operation_id === firstOperationId);
712
748
  if (!target)
@@ -718,6 +754,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
718
754
  }
719
755
  const view = getFlowRunStatus(executionContext, { projectRoot: manifest.request.project_root, runId: row.run_id });
720
756
  const next = view.continuation;
757
+ if (boundary) {
758
+ if (!ownedProviderBoundary(context, { projectId: row.project_id, runId: row.run_id, stage: state.stage, sessionId: session.id, generation: row.generation, scope: "stage" }, boundary))
759
+ return "reconciliation_blocked";
760
+ return boundary.outcome === "paused" ? "paused" : "completed";
761
+ }
721
762
  if (next.kind === "blocked")
722
763
  return "reconciliation_blocked";
723
764
  if (next.kind === "paused" && next.stage !== state.stage)
@@ -1088,7 +1129,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
1088
1129
  continue;
1089
1130
  }
1090
1131
  }
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 });
1132
+ const memoryFlow = ["mb-init", "mb-upgrade"].includes(run.flow_kind);
1133
+ 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 };
1134
+ const fanout = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "fanout", input: fanoutInput }, cancellation.signal)
1135
+ : await nextControllerFanout(executionContext, fanoutInput);
1136
+ if (memoryFlow)
1137
+ await renewLease();
1092
1138
  assertOwner();
1093
1139
  if (fanout.kind === "none") {
1094
1140
  // An ACK-only recovery Turn may leave the original root Work unfinished.
@@ -1129,7 +1175,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
1129
1175
  try {
1130
1176
  await renewLease();
1131
1177
  assertOwner();
1132
- const receipt = await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage, signal: cancellation.signal });
1178
+ 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)
1179
+ : await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage, signal: cancellation.signal });
1180
+ if (memoryFlow) {
1181
+ await renewLease();
1182
+ assertOwner();
1183
+ }
1133
1184
  if (fatal)
1134
1185
  throw fatal;
1135
1186
  appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
@@ -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);
@@ -1,5 +1,6 @@
1
1
  import { hasLifecyclePacketAssignments, managedLifecycleCommand, recoverLifecycleCommands } from "./lifecycle-invocations.js";
2
- import { recoveryGuard, assertRunMutationAllowed } from "./run-recovery-guard.js";
2
+ import { recoveryGuard, recoveryPacketReceipt } from "./run-recovery-guard.js";
3
+ export { assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryPacketReceipt } from "./run-recovery-guard.js";
3
4
  export { recoveryGuard, assertRunMutationAllowed, assertRunRecoveryAllowed } from "./run-recovery-guard.js";
4
5
  import crypto from "node:crypto";
5
6
  import fs from "node:fs";
@@ -19,11 +20,6 @@ import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKe
19
20
  import { flowCommand } from "./stage-pause.js";
20
21
  import { materializePendingWorkResults } from "./work-registry.js";
21
22
  import { currentStagePrompt } from "./stage-context.js";
22
- export function recoveryPacketReceipt(binding) {
23
- if (!binding.packet_json || !binding.packet_sha256 || crypto.createHash("sha256").update(binding.packet_json).digest("hex") !== binding.packet_sha256)
24
- throw new AppError("recovery_packet_missing", "Recovery requires its original verified Work packet", 1);
25
- return { packet_json: binding.packet_json, packet_sha256: binding.packet_sha256, packet: JSON.parse(binding.packet_json) };
26
- }
27
23
  /** Read-only custody permission for one prepared recovery ACK, not lifecycle admission. */
28
24
  export class PreparedRecoveryCoordinatorRead {
29
25
  #scope;
@@ -90,12 +86,16 @@ export function recoveredCoordinatorBinding(original, input) {
90
86
  export function capturedStageDelivery(capture, scope, originalPrompt) {
91
87
  const db = new DatabaseSync(path.join(capture, "runtime", "db.sqlite"), { readOnly: true });
92
88
  try {
89
+ const resumed = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_events'").get()
90
+ ? db.prepare("SELECT json_extract(payload_json, '$.pause_id') AS pause_id FROM audit_events WHERE event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.work_id') = ? AND json_extract(payload_json, '$.stage') = ? ORDER BY id DESC LIMIT 1").get(scope.runId, scope.workId, scope.stage) : undefined;
93
91
  const rows = db.prepare("SELECT command, outcome_json FROM lifecycle_invocations WHERE status = 'settled' AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') = ? AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ? ORDER BY rowid DESC").all(scope.projectRoot, scope.runId, scope.sessionId, scope.generation);
94
92
  for (const row of rows) {
95
93
  const result = row.outcome_json ? JSON.parse(row.outcome_json).result : null;
96
94
  const parsed = parseLifecycleCommand(row.command);
97
- if (parsed.kind === "none" || !parsed.supported || parsed.invocation.operation !== "stage_start" || parsed.invocation.args.options.get("stage")?.[0] !== scope.stage
98
- || result?.stage !== scope.stage || result.attempt !== scope.attempt || result.work_id !== scope.workId || typeof result.worker_prompt_markdown !== "string")
95
+ if (parsed.kind === "none" || !parsed.supported || !["stage_start", "stage_resume"].includes(parsed.invocation.operation) || parsed.invocation.args.options.get("stage")?.[0] !== scope.stage
96
+ || result?.stage !== scope.stage || parsed.invocation.operation === "stage_start" && result.attempt !== scope.attempt || result.work_id !== scope.workId || typeof result.worker_prompt_markdown !== "string")
97
+ continue;
98
+ if (resumed && (parsed.invocation.operation !== "stage_resume" || result.pause_id !== resumed.pause_id))
99
99
  continue;
100
100
  const text = result.worker_prompt_markdown;
101
101
  if (!text.startsWith(originalPrompt))
@@ -105,6 +105,8 @@ export function capturedStageDelivery(capture, scope, originalPrompt) {
105
105
  throw new AppError("recovery_packet_missing", "Captured Stage delivery has no committed lifecycle assignment", 1);
106
106
  return { text, sha256 };
107
107
  }
108
+ if (resumed)
109
+ throw new AppError("recovery_packet_missing", "Accepted HITL answer has no captured settled current delivery; reconcile its retained resume receipt, never fall back to the pre-answer prompt", 1, { work_id: scope.workId, pause_id: resumed.pause_id });
108
110
  return null;
109
111
  }
110
112
  finally {
@@ -174,42 +176,7 @@ export function prepareRecoveryPayload(value, label) {
174
176
  throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1);
175
177
  return payload;
176
178
  }
177
- /** A cooperative drain permits only the WorkSession present at its fence. */
178
- export function assertWorkSettlementAllowed(context, projectId, runId, workId) {
179
- const guard = recoveryGuard(context, projectId, runId);
180
- if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
181
- 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]);
182
- if (admission)
183
- return;
184
- }
185
- assertRunMutationAllowed(context, projectId, runId);
186
- }
187
- /** Completion rights are pinned to the Stage attempt present at the pause fence. */
188
- export function assertStageSettlementAllowed(context, projectId, runId, stage, expectedGeneration) {
189
- const guard = recoveryGuard(context, projectId, runId);
190
- const generation = guard?.generation ?? 0;
191
- if (expectedGeneration !== undefined && generation !== expectedGeneration)
192
- 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 });
193
- if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_control_stage_admissions'")) {
194
- 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
195
- 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]);
196
- if (admitted)
197
- return generation;
198
- }
199
- return assertRunMutationAllowed(context, projectId, runId, expectedGeneration);
200
- }
201
- /** A recovered RUN never grants the retired process a new execution segment. */
202
- export function assertRecoveryHookCurrent(context, projectId, runId, identity) {
203
- const generation = assertRunMutationAllowed(context, projectId, runId);
204
- if (!generation)
205
- return;
206
- const current = identity.daemonId && context.db.get("SELECT b.recovery_id FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND g.generation = ? AND g.status = 'recovered' AND b.status = 'accepted' AND b.daemon_id = ? LIMIT 1", [projectId, runId, generation, identity.daemonId]);
207
- if (current)
208
- return;
209
- const retired = identity.daemonId && (context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN hook_events he ON he.project_id = w.project_id AND he.event_key = ws.hook_event_id WHERE w.project_id = ? AND w.run_id = ? AND he.daemon_id = ? LIMIT 1`, [projectId, runId, identity.daemonId]) || context.db.get("SELECT b.recovery_id FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND b.daemon_id = ? AND (g.generation <> ? OR b.status = 'abandoned') LIMIT 1", [projectId, runId, identity.daemonId, generation]));
210
- if (!identity.daemonId || retired)
211
- throw new AppError("recovery_daemon_stale", "Lifecycle receipt belongs to an unidentified or retired recovery daemon", 1, { run_id: runId, generation, daemon_id: identity.daemonId });
212
- }
179
+ export { assertRecoveryHookCurrent } from "./hook-recovery-guard.js";
213
180
  export function beginRunRecovery(context, input, interruption = prepareRecoveryPayload(input.interruption, "interruption")) {
214
181
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
215
182
  const run = requireRecoveryRun(context, project.id, input.runId);
@@ -1,8 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
- import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { hybridDigest } from "./hybrid-digest.js";
5
+ import { runContextRelocations, projectPacketLocators } from "./stage-context.js";
5
6
  export const runStartPayloadLimit = 64 * 1024 * 1024;
7
+ // One exact immutable payload per database, not cached custody or returned
8
+ // objects. Every read still checks current SQL hashes, markers and receipts.
9
+ const validatedPayloadBytes = new WeakMap();
6
10
  export function runStartBytesSha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
7
11
  export function refuseRunStart(reason) {
8
12
  throw new AppError("run_start_custody_invalid", "RUN creation requires its exact retained request", 1, { reason, effect: "no_effect", next_action: "reconcile_exact_request" });
@@ -59,8 +63,11 @@ export function validateRunStartPayload(value) {
59
63
  || !entry.path || entry.path.includes("\\") || entry.path.includes("\0") || entry.path.split("/").some(part => !part || part === "." || part === "..")
60
64
  || seen.has(entry.path) || !canonicalBase64(entry.base64))
61
65
  refuseRunStart("invalid_allocation_assets");
62
- if (key === "artifacts" && (!["engine-binding.json", "operational-policy.source.md", "operational-contract.json"].includes(entry.path)
63
- || Buffer.from(entry.base64, "base64").length > 1024 * 1024))
66
+ const upgradeEvidence = value.initial_index.flow_kind === "mb-upgrade" && (["upgrade-baseline.json", "upgrade-target.json"].includes(entry.path)
67
+ || entry.path === `upgrade-target-${runStartBytesSha256(Buffer.from(entry.base64, "base64"))}.bin`
68
+ || entry.path === `upgrade-baseline-${runStartBytesSha256(Buffer.from(entry.base64, "base64"))}.bin`);
69
+ if (key === "artifacts" && (!["engine-binding.json", "operational-policy.source.md", "operational-contract.json"].includes(entry.path) && !upgradeEvidence
70
+ || Buffer.from(entry.base64, "base64").length > (upgradeEvidence ? 16 : 1) * 1024 * 1024))
64
71
  refuseRunStart("invalid_allocation_artifact");
65
72
  seen.add(entry.path);
66
73
  }
@@ -96,7 +103,10 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
96
103
  catch {
97
104
  return refuseRunStart("invalid_allocation_json");
98
105
  }
99
- validateRunStartPayload(payload);
106
+ if (validatedPayloadBytes.get(context.db) !== row.payload_json) {
107
+ validateRunStartPayload(payload);
108
+ validatedPayloadBytes.set(context.db, row.payload_json);
109
+ }
100
110
  if (!object(receipt))
101
111
  refuseRunStart("invalid_allocation_receipt");
102
112
  const run = context.db.get("SELECT project_root, workspace_root, run_root, flow_kind, subject_type, subject_id, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, row.run_id]);
@@ -114,6 +124,8 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
114
124
  if (!object(index))
115
125
  refuseRunStart("allocated_run_changed");
116
126
  const marker = index.start_request, initial = payload.initial_index;
127
+ const relocations = runContextRelocations(context, projectId, row.run_id);
128
+ const currentPath = (value) => projectPacketLocators(value, relocations);
117
129
  if (!payload || payload.schema_id !== "dd-flow/run-start-allocation@1" || payload.project_id !== projectId
118
130
  || payload.request_id !== requestId || payload.run_id !== row.run_id || !Array.isArray(payload.blobs) || !Array.isArray(payload.artifacts)
119
131
  || !initial || initial.run_id !== row.run_id || initial.project?.id !== projectId || initial.start_request !== undefined
@@ -121,7 +133,7 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
121
133
  || !marker || marker.schema_id !== "dd-flow/run-start-request@1" || marker.request_id !== requestId
122
134
  || marker.request_sha256 !== payload.request_sha256 || marker.allocation_sha256 !== row.payload_sha256
123
135
  || receipt.run_id !== row.run_id || receipt.request_id !== requestId || receipt.request_sha256 !== payload.request_sha256 || receipt.allocation_sha256 !== row.payload_sha256
124
- || run.project_root !== payload.project_root || run.workspace_root !== payload.workspace_root || run.run_root !== payload.run_root
136
+ || run.project_root !== currentPath(payload.project_root) || run.workspace_root !== currentPath(payload.workspace_root) || run.run_root !== currentPath(payload.run_root)
125
137
  || run.flow_kind !== initial.flow_kind || run.subject_id !== payload.subject_id || run.subject_type !== payload.subject_type
126
138
  || !object(index.execution_bundle) || hybridDigest(index.execution_bundle) !== hybridDigest(initial.execution_bundle))
127
139
  refuseRunStart("allocated_run_changed");