@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.106

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 (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +23 -8
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +3 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -1
  9. package/dist/protocol/local-files.js +18 -9
  10. package/dist/runtime/context.js +2 -2
  11. package/dist/services/cleanup.js +81 -32
  12. package/dist/services/code-checks.js +18 -19
  13. package/dist/services/continuation-outcome.js +1 -1
  14. package/dist/services/controller-fanout.js +12 -4
  15. package/dist/services/eval-snapshots.js +69 -41
  16. package/dist/services/execution-policy.js +12 -1
  17. package/dist/services/external-work-launch.js +2 -1
  18. package/dist/services/hooks.js +153 -43
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +5 -12
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +83 -88
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +22 -64
  38. package/dist/services/run-recovery-runtime.js +25 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { quote } from "shell-quote";
8
8
  import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
9
9
  import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
10
+ import { publishImmutableInput } from "../storage/immutable-input.js";
10
11
  import { AppError } from "../shared/errors.js";
11
12
  import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
12
13
  import { parseRuntimeBudget } from "./runtime-budget.js";
@@ -15,14 +16,14 @@ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
15
16
  import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
16
17
  import { loadExternalStageContext } from "./stage-context.js";
17
18
  import { flowCommand } from "./stage-pause.js";
18
- import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
19
+ import { resolveStageTransition, sameSessionProfile, stageExecutionCwd, runtimeHarness } from "./execution-policy.js";
19
20
  import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
20
21
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
21
- import { confirmManagedProcess, heartbeatManagedProcess, finishManagedProcess, processTreeIsAlive, resourceHome } from "./managed-processes.js";
22
+ import { assertManagedProcessLease, confirmManagedProcess, heartbeatManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
22
23
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
23
24
  import { initCodexHome } from "./hooks.js";
24
25
  import { launchExternalWork } from "./external-work-launch.js";
25
- import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
26
+ import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
26
27
  import { claimQueuedMergeRequest } from "./merge-server.js";
27
28
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
28
29
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
@@ -98,13 +99,12 @@ export async function launchRunController(context, input, prepared = prepareRunC
98
99
  const token = crypto.randomUUID();
99
100
  const now = context.now();
100
101
  const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
101
- context.db.beginWriteTransaction();
102
- try {
102
+ const launcher = { pid: process.pid, pid_started_at: processStartedAt(process.pid) };
103
+ const duplicate = context.db.writeTransaction(() => {
103
104
  const duplicate = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
104
105
  if (duplicate) {
105
106
  if (duplicate.request_hash !== requestHash)
106
107
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
107
- context.db.exec("COMMIT");
108
108
  return { ...controllerReceipt(duplicate), reused: true };
109
109
  }
110
110
  assertRunMutationAllowed(context, project.id, run.id, generation);
@@ -116,14 +116,12 @@ export async function launchRunController(context, input, prepared = prepareRunC
116
116
  if (context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' LIMIT 1", [project.id, run.id]))
117
117
  throw new AppError("controller_attach_required", "A WorkSession acquired the RUN before managed launch", 1);
118
118
  context.db.run("INSERT INTO run_controllers (controller_id, project_id, run_id, request_id, request_hash, generation, status, owner_token, manifest_json, state_json, state_dir, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?)", [controllerId, project.id, run.id, input.requestId, requestHash, generation, token, JSON.stringify(manifest), JSON.stringify(state), root, now, now]);
119
- recordControllerLauncher(context, requireController(context, controllerId));
119
+ recordControllerLauncher(context, requireController(context, controllerId), undefined, launcher);
120
120
  appendEvent(context, controllerId, "launch_requested", { request_id: input.requestId, generation, request_hash: requestHash });
121
- context.db.exec("COMMIT");
122
- }
123
- catch (error) {
124
- context.db.exec("ROLLBACK");
125
- throw error;
126
- }
121
+ return null;
122
+ });
123
+ if (duplicate)
124
+ return duplicate;
127
125
  return await spawnController(context, requireController(context, controllerId));
128
126
  }
129
127
  /**
@@ -136,24 +134,34 @@ export async function launchRunController(context, input, prepared = prepareRunC
136
134
  export function controllerLeaseMonitor(heartbeat) {
137
135
  let lost = false;
138
136
  let unconfirmed = null;
137
+ let confirmedUntil = 0;
139
138
  const lostError = () => new AppError("controller_lease_lost", "Managed owner lease could not be confirmed", 1);
139
+ const uncertainError = () => new AppError("controller_lease_unconfirmed", "Managed owner lease requires renewal outside the RUN writer", 1);
140
140
  return {
141
141
  tick() {
142
142
  try {
143
143
  if (!heartbeat())
144
144
  lost = true;
145
- else
145
+ else {
146
146
  unconfirmed = null;
147
+ confirmedUntil = Date.now() + 14 * 60_000;
148
+ }
147
149
  }
148
150
  catch (error) {
149
151
  unconfirmed = error;
150
152
  }
151
153
  },
152
- isConfirmed() { return !lost && unconfirmed === null; },
154
+ isConfirmed() { return !lost && unconfirmed === null && Date.now() < confirmedUntil; },
155
+ assertNoRenewal() {
156
+ if (lost)
157
+ throw lostError();
158
+ if (unconfirmed !== null || Date.now() >= confirmedUntil)
159
+ throw uncertainError();
160
+ },
153
161
  assertConfirmed() {
154
162
  if (lost)
155
163
  throw lostError();
156
- if (unconfirmed === null)
164
+ if (unconfirmed === null && Date.now() < confirmedUntil)
157
165
  return;
158
166
  try {
159
167
  if (!heartbeat()) {
@@ -161,6 +169,7 @@ export function controllerLeaseMonitor(heartbeat) {
161
169
  throw lostError();
162
170
  }
163
171
  unconfirmed = null;
172
+ confirmedUntil = Date.now() + 14 * 60_000;
164
173
  }
165
174
  catch (error) {
166
175
  if (error instanceof AppError)
@@ -194,6 +203,7 @@ export async function serveRunController(context, input) {
194
203
  throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
195
204
  confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
196
205
  const lease = controllerLeaseMonitor(() => heartbeatManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token }));
206
+ lease.assertConfirmed();
197
207
  const heartbeat = setInterval(() => {
198
208
  try {
199
209
  lease.tick();
@@ -209,7 +219,8 @@ export async function serveRunController(context, input) {
209
219
  const manifest = JSON.parse(row.manifest_json);
210
220
  const state = JSON.parse(row.state_json);
211
221
  const assertPhysicalOwner = () => {
212
- lease.assertConfirmed();
222
+ lease.assertNoRenewal();
223
+ assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
213
224
  const current = requireController(context, row.controller_id);
214
225
  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))
215
226
  throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
@@ -220,21 +231,15 @@ export async function serveRunController(context, input) {
220
231
  process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
221
232
  };
222
233
  try {
223
- context.db.exec("SAVEPOINT controller_ready_status");
224
- try {
234
+ context.db.writeTransaction(() => {
225
235
  markControllerDispatchReady(context, row);
226
236
  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")
227
237
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
228
- context.db.exec("RELEASE controller_ready_status");
229
- }
230
- catch (error) {
231
- context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
232
- throw error;
233
- }
238
+ });
234
239
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
235
240
  if (state.recovery)
236
241
  await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
237
- await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner);
242
+ await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner, () => lease.assertConfirmed());
238
243
  return controllerReceipt(requireController(context, row.controller_id));
239
244
  }
240
245
  catch (error) {
@@ -258,25 +263,19 @@ export async function serveRunController(context, input) {
258
263
  return controllerReceipt(requireController(context, row.controller_id));
259
264
  }
260
265
  }
261
- context.db.exec("SAVEPOINT controller_failure_status");
262
266
  try {
263
- // A failed capture must not reopen admission when this process exits.
264
- 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]);
265
- 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]);
266
- if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
267
- setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
268
- }
269
- appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
270
- context.db.exec("RELEASE controller_failure_status");
267
+ context.db.writeTransaction(() => {
268
+ // A failed capture must not reopen admission when this process exits.
269
+ 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]);
270
+ 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]);
271
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
272
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
273
+ }
274
+ appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
275
+ });
271
276
  }
272
277
  catch (failure) {
273
278
  retainSecondary("persist_controller_failure", failure);
274
- try {
275
- context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
276
- }
277
- catch (rollback) {
278
- retainSecondary("rollback_controller_failure", rollback);
279
- }
280
279
  }
281
280
  }
282
281
  catch (secondary) {
@@ -321,7 +320,9 @@ export function prepareRunControllerAnswer(context, input) {
321
320
  const operationId = `${row.controller_id}:answer:${input.requestId}`;
322
321
  const answerHash = digestBytes(bytes);
323
322
  const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
324
- prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
323
+ const previous = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
324
+ if (previous.prior)
325
+ return { kind: "replay", operationId, status: previous.prior.status };
325
326
  return { kind: "create", project, row, bytes, operationId, answerHash, hash };
326
327
  }
327
328
  function prepareControllerAnswerAcceptance(context, input, projectId, row, operationId, hash) {
@@ -350,37 +351,26 @@ export function answerRunController(context, input, prepared = prepareRunControl
350
351
  if (prepared.kind === "replay")
351
352
  return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
352
353
  const { project, row, bytes, operationId, answerHash, hash } = prepared;
353
- context.db.beginWriteTransaction();
354
- try {
354
+ const previous = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
355
+ if (previous.prior)
356
+ return { ok: true, operation_id: operationId, status: previous.prior.status, reused: true };
357
+ const file = path.join(row.state_dir, `answer-${digest(operationId)}-${answerHash}.md`);
358
+ publishImmutableInput(file, bytes, "runtime_request_conflict");
359
+ return context.db.writeTransaction(() => {
355
360
  // Recheck only mutable acceptance state under the write transaction; bytes are already prepared.
356
361
  const acceptance = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
357
362
  if (acceptance.prior) {
358
- context.db.exec("COMMIT");
359
363
  return { ok: true, operation_id: operationId, status: acceptance.prior.status, reused: true };
360
364
  }
361
365
  const { run, stage, workId } = acceptance;
362
- const file = path.join(row.state_dir, `answer-${digest(operationId)}.md`);
363
- try {
364
- fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
365
- }
366
- catch (error) {
367
- if (error.code !== "EEXIST")
368
- throw error;
369
- // A crash before COMMIT may leave the immutable answer file behind.
370
- if (!fs.readFileSync(file).equals(bytes))
371
- throw new AppError("runtime_request_conflict", "Persisted answer bytes differ from this retry", 1);
372
- }
366
+ if (requireController(context, row.controller_id).state_dir !== row.state_dir)
367
+ throw new AppError("controller_owner_mismatch", "Controller input directory changed before acceptance", 1);
373
368
  const now = context.now();
374
369
  const generation = recoveryGuard(context, project.id, run.id)?.generation ?? row.generation;
375
370
  context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, generation, hash, JSON.stringify({ action: "user_answer", pause_id: input.pauseId, stage: stage.stage, work_id: workId, file, sha256: answerHash }), now, now]);
376
371
  appendEvent(context, row.controller_id, "answer_accepted", { operation_id: operationId, pause_id: input.pauseId, answer_sha256: answerHash });
377
- context.db.exec("COMMIT");
378
372
  return { ok: true, operation_id: operationId, status: "accepted" };
379
- }
380
- catch (error) {
381
- context.db.exec("ROLLBACK");
382
- throw error;
383
- }
373
+ });
384
374
  }
385
375
  export function prepareRunControllerContext(context, input) {
386
376
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-z][a-z-]*$/.test(input.stage) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
@@ -405,8 +395,19 @@ export function supplyRunControllerContext(context, input, prepared = prepareRun
405
395
  if (prepared.kind === "replay")
406
396
  return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
407
397
  const { project, operationId, hash, bytes } = prepared;
408
- context.db.beginWriteTransaction();
409
- try {
398
+ const preparedOwner = requireController(context, input.controllerId);
399
+ if (preparedOwner.project_id !== project.id || preparedOwner.run_id !== input.runId)
400
+ throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
401
+ const existing = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
402
+ if (existing) {
403
+ if (existing.input_hash !== hash)
404
+ throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
405
+ return { ok: true, operation_id: operationId, status: existing.status, reused: true };
406
+ }
407
+ availableControllerContext(context, input, preparedOwner);
408
+ const file = path.join(preparedOwner.state_dir, `${input.stage}-${input.contextSha256}.context.json`);
409
+ publishImmutableInput(file, bytes, "stage_context_replacement");
410
+ return context.db.writeTransaction(() => {
410
411
  const row = requireController(context, input.controllerId);
411
412
  if (row.project_id !== project.id || row.run_id !== input.runId)
412
413
  throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
@@ -414,32 +415,18 @@ export function supplyRunControllerContext(context, input, prepared = prepareRun
414
415
  if (prior) {
415
416
  if (prior.input_hash !== hash)
416
417
  throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
417
- context.db.exec("COMMIT");
418
418
  return { ok: true, operation_id: operationId, status: prior.status, reused: true };
419
419
  }
420
420
  const manifest = availableControllerContext(context, input, row);
421
- const file = path.join(row.state_dir, `${input.stage}.context.json`);
422
- try {
423
- fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
424
- }
425
- catch (error) {
426
- if (error.code !== "EEXIST")
427
- throw error;
428
- if (!fs.readFileSync(file).equals(bytes))
429
- throw new AppError("stage_context_replacement", "Unpublished context bytes differ from this request", 1);
430
- }
421
+ if (row.state_dir !== preparedOwner.state_dir)
422
+ throw new AppError("controller_owner_mismatch", "Controller input directory changed before acceptance", 1);
431
423
  manifest.contexts[input.stage] = { file, sha256: input.contextSha256 };
432
424
  const now = context.now();
433
425
  context.db.run("UPDATE run_controllers SET manifest_json = ?, updated_at = ? WHERE controller_id = ?", [JSON.stringify(manifest), now, row.controller_id]);
434
426
  context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, row.generation, hash, JSON.stringify({ action: "stage_context", stage: input.stage, file, sha256: input.contextSha256 }), now, now]);
435
427
  appendEvent(context, row.controller_id, "context_accepted", { operation_id: operationId, stage: input.stage, context_sha256: input.contextSha256 });
436
- context.db.exec("COMMIT");
437
428
  return { ok: true, operation_id: operationId, status: "accepted" };
438
- }
439
- catch (error) {
440
- context.db.exec("ROLLBACK");
441
- throw error;
442
- }
429
+ });
443
430
  }
444
431
  function availableControllerContext(context, input, row) {
445
432
  assertRunMutationAllowed(context, row.project_id, input.runId, row.generation);
@@ -450,7 +437,7 @@ function availableControllerContext(context, input, row) {
450
437
  throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
451
438
  return manifest;
452
439
  }
453
- async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
440
+ async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner, renewLease) {
454
441
  assertOwnership();
455
442
  const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
456
443
  const checkOutcomes = () => assertLifecycleOutcomes(observer, { projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
@@ -578,6 +565,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
578
565
  monitor.unref();
579
566
  try {
580
567
  for (;;) {
568
+ // Renewal touches the resource registry; never do it from a callback that
569
+ // may already own the RUN writer.
570
+ renewLease();
581
571
  assertOwner();
582
572
  if (state.current_session !== null && !state.sessions[state.current_session].stopped)
583
573
  bindInvocationScope(state.sessions[state.current_session]);
@@ -623,7 +613,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
623
613
  // The accepted answer is logical intent; only its generation-bound
624
614
  // adapter operation represents a physical dispatch that stop must drain.
625
615
  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 });
626
- const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
616
+ const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(pause.id, command), `${answer.operation_id}:prompt:${row.generation}`);
627
617
  const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
628
618
  if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
629
619
  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" });
@@ -650,7 +640,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
650
640
  loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
651
641
  const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
652
642
  retainControllerContinuation(state, continuation, { code: "stage_entry_nonprogressing", message: "The coordinator returned without starting the assigned Stage", details: { stage: next.stage, attempt: next.attempt ?? null, last_receipt: state.last_receipt ?? null } });
653
- const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
643
+ const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
654
644
  if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
655
645
  const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
656
646
  if (!request || request.status !== "queued")
@@ -665,8 +655,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
665
655
  saveState(context, row, state);
666
656
  appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
667
657
  }
658
+ const stageCwd = stageExecutionCwd({ stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(state.merge_claim ? { mergeTargetWorkspace: state.merge_claim.target_workspace } : {}) });
668
659
  let session = state.current_session === null ? null : state.sessions[state.current_session];
669
- if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator)) {
660
+ if (session && next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "same_session" && path.resolve(session.cwd) !== stageCwd) {
661
+ throw new AppError("merge_session_workspace_conflict", "Same-session MERGE cannot change the native Session working directory", 1, { session_cwd: session.cwd, target_cwd: stageCwd });
662
+ }
663
+ if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator) || path.resolve(session.cwd) !== stageCwd) {
670
664
  if (session)
671
665
  await stopSession(session);
672
666
  assertOwner();
@@ -677,7 +671,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
677
671
  owner.role = "merge";
678
672
  owner.work_id = state.merge_claim.work_id;
679
673
  }
680
- session = { id: "", profile: transition.coordinator, state_dir: directory, cwd: next.stage === "merge" && state.merge_claim ? state.merge_claim.target_workspace : run.workspace_root, journal: path.join(directory, "adapter.events.jsonl"), owner };
674
+ session = { id: "", profile: transition.coordinator, state_dir: directory, cwd: stageCwd, journal: path.join(directory, "adapter.events.jsonl"), owner };
681
675
  if (runtimeHarness(session.profile) === "codex-desktop") {
682
676
  session.codex_home = path.join(directory, "codex-home");
683
677
  initCodexHome(context, { projectRoot: run.project_root, profile: `controller-${digest(directory).slice(0, 16)}`, targetHome: session.codex_home, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
@@ -722,9 +716,10 @@ async function executeController(context, row, manifest, state, assertOwnership,
722
716
  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 });
723
717
  assertOwner();
724
718
  if (fanout.kind === "none") {
725
- const cause = currentLifecycleFailure(executionContext, { runId: run.id, generation: row.generation, stage: next.stage });
719
+ assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
720
+ const cause = currentLifecycleFailure(executionContext, { runId: run.id, generation: row.generation, stage: next.stage, turn: state.turns, projectId: row.project_id, projectRoot: run.project_root });
726
721
  if (cause)
727
- throw new AppError(cause.code, cause.message, 1, { ...cause.details, invocation_id: cause.invocation_id, controller_wrapper: "incomplete_subject_turn", stage: next.stage });
722
+ throw new AppError(cause.code, cause.message, 1, { ...cause.details, ...(cause.invocation_id ? { invocation_id: cause.invocation_id } : {}), ...(cause.hook_event_id ? { hook_event_id: cause.hook_event_id } : {}), controller_wrapper: "incomplete_subject_turn", stage: next.stage });
728
723
  throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
729
724
  }
730
725
  if (fanout.kind === "wait") {
@@ -4,8 +4,9 @@ import path from "node:path";
4
4
  import { createContext } from "../runtime/context.js";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { canonicalPath } from "../storage/paths.js";
7
+ import { importGuardPath, rearmCompletedSnapshotImport } from "../storage/import-guard.js";
7
8
  import { installCurrentEngine, upgradeForkedRunEngine, prepareForkEngine } from "./engines.js";
8
- import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore } from "./eval-snapshots.js";
9
+ import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore, restoredWorkspaceRoot } from "./eval-snapshots.js";
9
10
  export function prepareRunFork(input) {
10
11
  const requestId = requireId(input.requestId, "request-id");
11
12
  const snapshot = canonicalPath(input.snapshot, false);
@@ -47,7 +48,8 @@ function assertForkOutput(output, intentFile, intent) {
47
48
  if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
48
49
  if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
49
50
  throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
50
- const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
51
+ const guardName = path.basename(importGuardPath(path.join(output, "dd-flow-home")));
52
+ const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home", guardName].includes(name));
51
53
  if (unknown.length)
52
54
  throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
53
55
  }
@@ -57,16 +59,18 @@ export function forkRunFromSnapshot(context, input, prepared = prepareRunFork(in
57
59
  return prepared.receipt;
58
60
  const { manifest, snapshot, output, intent, intentFile, receiptFile, requestId } = prepared;
59
61
  assertForkOutput(output, intentFile, intent);
62
+ const projectRoot = path.join(output, "project");
63
+ const home = path.join(output, "dd-flow-home");
64
+ const prearmedImportId = rearmCompletedSnapshotImport({ home, projectRoot,
65
+ workspaceRoot: restoredWorkspaceRoot(manifest, projectRoot, home), sourceManifestSha256: intent.snapshot_sha256 });
60
66
  for (const name of ["project", "dd-flow-home"])
61
67
  fs.rmSync(path.join(output, name), { recursive: true, force: true });
62
68
  fs.mkdirSync(output, { recursive: true, mode: 0o700 });
63
69
  writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
64
- const projectRoot = path.join(output, "project");
65
- const home = path.join(output, "dd-flow-home");
66
70
  fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
67
- const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
71
+ const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" }, "initialize", prearmedImportId ?? undefined);
68
72
  const restoreInput = { snapshot, projectRoot };
69
- const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest));
73
+ const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest), prearmedImportId ?? undefined);
70
74
  const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
71
75
  try {
72
76
  const runHome = String(restored.run_home);
@@ -1,8 +1,13 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
  import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
4
3
  import { publicSessionIdentity } from "./session-identity.js";
4
+ import { publishRunProjections } from "./runs.js";
5
5
  export function refreshRunSessionProjection(context, projectId, runId) {
6
+ // Review children can finish concurrently in separate processes. Reserve the
7
+ // writer before reading the RUN index so a sibling cannot invalidate it.
8
+ context.db.writeTransaction(() => refreshRunSessionProjectionLocked(context, projectId, runId), "run-projection.refresh");
9
+ }
10
+ function refreshRunSessionProjectionLocked(context, projectId, runId) {
6
11
  const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
7
12
  if (!run)
8
13
  return;
@@ -27,7 +32,7 @@ export function refreshRunSessionProjection(context, projectId, runId) {
27
32
  missing: coverage.missing_unit_ids,
28
33
  diagnostics: coverage.diagnostics
29
34
  };
30
- const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
35
+ const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root, index) : {};
31
36
  const workers = workerProjection(context, projectId, runId);
32
37
  const vnext = run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize";
33
38
  const hadLegacyCoverage = vnext && ("session_coverage" in index || "usage_coverage" in index);
@@ -42,8 +47,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
42
47
  && JSON.stringify(index.works ?? []) === JSON.stringify(works)
43
48
  && index.root_work_id === rootWorkId
44
49
  && (vnext || JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage))
45
- && JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
46
- && JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
50
+ && (!plan.plan_ref || JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref))
51
+ && (!plan.plan_progress || JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress))
47
52
  && (vnext || JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
48
53
  && !hadLegacyCoverage)
49
54
  return;
@@ -59,44 +64,23 @@ export function refreshRunSessionProjection(context, projectId, runId) {
59
64
  if (!vnext)
60
65
  index.workers = workers;
61
66
  index.updated_at = context.now();
62
- const runtime = readJson(run.runtime_path);
63
- const runtimeRevision = typeof runtime?.runtime_revision === "number"
64
- ? runtime.runtime_revision + 1
65
- : typeof index.runtime_revision === "number"
66
- ? index.runtime_revision + 1
67
- : 1;
68
- index.runtime_revision = runtimeRevision;
69
- const authoritative = { ...(runtime ?? {}), ...index, schema_id: String(index.schema_id ?? runtime?.schema_id ?? "dd-flow/flow-run@3") };
70
- authoritative.sessions = sessions;
71
- authoritative.works = works;
72
- authoritative.root_work_id = rootWorkId;
73
- if (vnext) {
74
- delete authoritative.session_coverage;
75
- delete authoritative.usage_coverage;
76
- delete authoritative.workers;
77
- delete authoritative.current_stage;
78
- delete authoritative.attempts;
79
- }
80
- else
81
- authoritative.session_coverage = sessionCoverage;
82
- if (plan.plan_ref)
83
- authoritative.plan_ref = plan.plan_ref;
84
- if (plan.plan_progress)
85
- authoritative.plan_progress = plan.plan_progress;
86
- if (!vnext)
87
- authoritative.workers = workers;
88
- authoritative.updated_at = index.updated_at;
89
- authoritative.runtime_revision = runtimeRevision;
90
- if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
91
- writeJson(run.runtime_path, authoritative);
92
- context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
67
+ index.runtime_revision = typeof index.runtime_revision === "number" ? index.runtime_revision + 1 : 1;
68
+ const updated = context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [JSON.stringify(index), index.updated_at, projectId, runId, run.index_json]);
69
+ if (updated.changes !== 1)
70
+ throw new Error("RUN projection changed during preparation");
71
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
93
72
  }
94
- function planProjection(context, projectId, protocolId, projectRoot) {
95
- const binding = context.db.get("SELECT plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
73
+ function planProjection(context, projectId, protocolId, projectRoot, index) {
74
+ const binding = context.db.get("SELECT plan_id, plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
96
75
  if (!binding)
97
76
  return {};
98
77
  const planPath = path.relative(projectRoot, binding.plan_path).split(path.sep).join("/");
99
- const planId = readPlanId(binding.plan_path);
78
+ const priorRef = index.plan_ref && typeof index.plan_ref === "object" && !Array.isArray(index.plan_ref) ? index.plan_ref : {};
79
+ const planId = binding.plan_id ?? (typeof priorRef.plan_id === "string" ? priorRef.plan_id : null);
80
+ // A pre-migration binding without a RUN plan_ref needs canonical rebind before
81
+ // this projection can be updated; never invent a plan identifier.
82
+ if (!planId)
83
+ return {};
100
84
  const progress = Object.fromEntries(context.db.all("SELECT item_id, status, summary, evidence_json FROM plan_progress WHERE project_id = ? AND protocol_id = ? ORDER BY item_id", [projectId, protocolId]).map((row) => [row.item_id, {
101
85
  status: row.status,
102
86
  ...(row.summary ? { summary: row.summary } : {}),
@@ -112,18 +96,6 @@ function planProjection(context, projectId, protocolId, projectRoot) {
112
96
  plan_progress: progress
113
97
  };
114
98
  }
115
- function readPlanId(planPath) {
116
- try {
117
- const value = JSON.parse(fs.readFileSync(planPath, "utf8"));
118
- if (value && typeof value === "object" && !Array.isArray(value) && typeof value.plan_id === "string") {
119
- return value.plan_id;
120
- }
121
- }
122
- catch {
123
- // The canonical validator owns malformed-plan errors; projection stays bounded.
124
- }
125
- return "unknown-plan";
126
- }
127
99
  function workerProjection(context, projectId, runId) {
128
100
  return Object.fromEntries(context.db.all(`SELECT j.job_id, j.plan_item_id, j.status, j.worker_session_id, s.harness, s.provider_session_id
129
101
  FROM flow_jobs j LEFT JOIN sessions s ON s.project_id = j.project_id AND s.session_id = j.worker_session_id
@@ -182,17 +154,3 @@ function parseCoverageUnits(value) {
182
154
  return [];
183
155
  }
184
156
  }
185
- function readJson(file) {
186
- try {
187
- return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : undefined;
188
- }
189
- catch {
190
- return undefined;
191
- }
192
- }
193
- function writeJson(file, value) {
194
- fs.mkdirSync(path.dirname(file), { recursive: true });
195
- const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
196
- fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
197
- fs.renameSync(temporary, file);
198
- }
@@ -4,16 +4,17 @@ import { AppError } from "../shared/errors.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { acceptanceCommand, recoveryGuard, recoveryPacketReceipt, requireRecoveryRun } from "./run-recovery.js";
7
- import { processIsAlive } from "./managed-processes.js";
7
+ import { managedProcessBinding, processIsAlive, resourceHome } from "./managed-processes.js";
8
+ import { getResourceDatabase } from "../storage/database.js";
8
9
  import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
+ import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
9
11
  /** Pin retained Session loading and its first acknowledgement Turn, never a general fence bypass. */
10
12
  export function prepareRunRecoveryDispatch(context, input) {
11
13
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(input.operationId) || !path.isAbsolute(input.stateDir))
12
14
  throw new AppError("usage", "Recovery dispatch requires an operation id and absolute state directory", 2);
13
15
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
14
16
  const run = requireRecoveryRun(context, project.id, input.runId);
15
- context.db.beginWriteTransaction();
16
- try {
17
+ const prepared = context.db.readSnapshot(() => {
17
18
  const guard = recoveryGuard(context, project.id, run.id);
18
19
  const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
19
20
  if (!guard || guard.recovery_id !== input.recoveryId || !binding || !["resuming", "recovered"].includes(guard.status))
@@ -23,8 +24,7 @@ export function prepareRunRecoveryDispatch(context, input) {
23
24
  const prior = JSON.parse(binding.runtime_dispatch_json);
24
25
  if (prior.state_dir !== input.stateDir || prior.operation_id !== input.operationId)
25
26
  throw new AppError("recovery_binding_mismatch", "Recovery dispatch is already pinned to another operation", 1);
26
- context.db.exec("COMMIT");
27
- return prior;
27
+ return { dispatch: prior, guard, binding };
28
28
  }
29
29
  if (guard.status !== "resuming" || binding.status !== "prepared")
30
30
  throw new AppError("recovery_state_changed", "Recovery acknowledgement is no longer awaiting dispatch", 1);
@@ -34,16 +34,27 @@ export function prepareRunRecoveryDispatch(context, input) {
34
34
  const metadata = JSON.parse(record.metadata_json);
35
35
  if (metadata.state_dir !== input.stateDir || metadata.run_generation !== guard.generation || metadata.dispatch_barrier !== "run-generation@1" || !["subject", "merge"].includes(metadata.role ?? ""))
36
36
  throw new AppError("recovery_runtime_mismatch", "Replacement daemon lacks this recovery generation's native dispatch barrier", 1);
37
- const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n`;
38
- const dispatch = { process_id: record.id, owner_id: record.owner_id, state_dir: input.stateDir, daemon_id: binding.daemon_id, operation_id: input.operationId, resume_operation_id: `${input.operationId}:load`, prompt_sha256: crypto.createHash("sha256").update(prompt).digest("hex"), prompt };
39
- context.db.run("UPDATE run_recovery_bindings SET runtime_dispatch_json = ? WHERE recovery_id = ? AND status = 'prepared' AND runtime_dispatch_json IS NULL", [JSON.stringify(dispatch), binding.recovery_id]);
40
- context.db.exec("COMMIT");
41
- return dispatch;
42
- }
43
- catch (error) {
44
- context.db.exec("ROLLBACK");
45
- throw error;
37
+ return { guard, binding, record };
38
+ });
39
+ let dispatch = prepared.dispatch;
40
+ if (!dispatch) {
41
+ const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, prepared.guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n\n${lifecycleRetryInstruction}\n`;
42
+ dispatch = { process_id: prepared.record.id, owner_id: prepared.record.owner_id, state_dir: input.stateDir, daemon_id: prepared.binding.daemon_id, operation_id: input.operationId, resume_operation_id: `${input.operationId}:load`, prompt_sha256: crypto.createHash("sha256").update(prompt).digest("hex"), prompt };
46
43
  }
44
+ const registry = prepared.record ? getResourceDatabase(resourceHome(context)) : undefined;
45
+ return context.db.writeTransaction(() => {
46
+ const guard = recoveryGuard(context, project.id, run.id);
47
+ const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
48
+ if (JSON.stringify(guard) !== JSON.stringify(prepared.guard) || JSON.stringify(binding) !== JSON.stringify(prepared.binding))
49
+ throw new AppError("recovery_state_changed", "Recovery generation changed while dispatch was prepared", 1);
50
+ if (prepared.record) {
51
+ const current = registry.get("SELECT * FROM managed_processes WHERE id = ?", [prepared.record.id]);
52
+ if (!current || current.state !== "running" || current.lease_expires_at <= context.now() || managedProcessBinding(current, resourceHome(context)).registration_sha256 !== managedProcessBinding(prepared.record, resourceHome(context)).registration_sha256)
53
+ throw new AppError("recovery_runtime_mismatch", "Recovery replacement daemon lease changed before dispatch publication", 1);
54
+ context.db.run("UPDATE run_recovery_bindings SET runtime_dispatch_json = ? WHERE recovery_id = ? AND status = 'prepared' AND runtime_dispatch_json IS NULL", [JSON.stringify(dispatch), prepared.binding.recovery_id]);
55
+ }
56
+ return dispatch;
57
+ });
47
58
  }
48
59
  /** The caller has already checked PID/start/lease and runtime-home identity. */
49
60
  export function recoveryRuntimeAdmission(context, record, input) {