@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79

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 (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
8
8
  import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { AppError } from "../shared/errors.js";
10
+ import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
10
11
  import { parseRuntimeBudget } from "./runtime-budget.js";
11
12
  import { requireProjectByRoot } from "./projects.js";
12
13
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
@@ -23,15 +24,14 @@ import { launchExternalWork } from "./external-work-launch.js";
23
24
  import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
24
25
  import { claimQueuedMergeRequest } from "./merge-server.js";
25
26
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
26
- import { requireRun, requireController, controllerReceipt, appendEvent, saveState, digest, digestBytes, errorRecord } from "./run-controller-state.js";
27
+ import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
27
28
  import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady } from "./run-controller-process.js";
28
29
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
29
30
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
30
31
  import { captureControllerBoundary } from "./run-controller-capture.js";
31
32
  import { requestRunControl } from "./run-control-worker.js";
32
33
  export { resumeRunController } from "./run-controller-recovery.js";
33
- /** Persist intent before spawning. A repeated client observes the same owner. */
34
- export async function launchRunController(context, input) {
34
+ export function prepareRunControllerLaunch(context, input) {
35
35
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
36
36
  throw new AppError("usage", "Managed launch needs a request id and context SHA-256", 2);
37
37
  if (input.stopAfter !== undefined && !/^[a-z][a-z-]*$/.test(input.stopAfter))
@@ -46,12 +46,12 @@ export async function launchRunController(context, input) {
46
46
  const budget = parseRuntimeBudget(context.env.DD_FLOW_RUNTIME_BUDGET);
47
47
  const request = { project_root: project.root, run_id: run.id, context_sha256: input.contextSha256, stop_after: input.stopAfter ?? null, ...(captureRoot ? { capture_root: captureRoot } : {}), ...(budget ? { budget_sha256: digest(budget) } : {}) };
48
48
  const requestHash = digest(request);
49
- ensureRunControllerStorage(context.db);
50
- const existing = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
49
+ const existing = context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")
50
+ ? context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]) : undefined;
51
51
  if (existing) {
52
52
  if (existing.request_hash !== requestHash)
53
53
  throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
54
- return { ...await spawnController(context, existing), reused: true };
54
+ return { kind: "replay", existing };
55
55
  }
56
56
  const generation = assertRunMutationAllowed(context, project.id, run.id);
57
57
  if (run.status !== "running")
@@ -71,13 +71,21 @@ export async function launchRunController(context, input) {
71
71
  const harness = runtimeHarness(profile);
72
72
  adapters[harness] = { executable: resolveHarnessCommand(context.ddFlowHome, harness, "adapter"), runtime_args: harnessRuntimeArguments(context.ddFlowHome, harness) };
73
73
  }
74
+ const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
75
+ if (!fs.existsSync(entry))
76
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
77
+ return { kind: "create", project, run, request, requestHash, generation, budget, execution, contexts, adapters };
78
+ }
79
+ export async function launchRunController(context, input, prepared = prepareRunControllerLaunch(context, input)) {
80
+ if (prepared.kind === "replay")
81
+ return { ...await spawnController(context, prepared.existing), reused: true };
82
+ const { project, run, request, requestHash, generation, budget, execution, contexts, adapters } = prepared;
83
+ assertRunMutationAllowed(context, project.id, run.id, generation);
84
+ ensureRunControllerStorage(context.db);
74
85
  const controllerId = `DRV-${crypto.randomUUID()}`;
75
86
  const root = path.join(run.run_root, "controllers", controllerId);
76
87
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
77
88
  const flowExecutable = prepareHarnessFlowExecutable(context, root);
78
- const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
79
- if (!fs.existsSync(entry))
80
- throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
81
89
  const frozenContexts = {};
82
90
  for (const [stage, value] of Object.entries(contexts)) {
83
91
  const file = path.join(root, `${stage}.context.json`);
@@ -98,6 +106,8 @@ export async function launchRunController(context, input) {
98
106
  return { ...controllerReceipt(duplicate), reused: true };
99
107
  }
100
108
  assertRunMutationAllowed(context, project.id, run.id, generation);
109
+ if (requireRun(context, project.id, run.id).status !== "running")
110
+ throw new AppError("invalid_run_state", "Managed launch requires a running logical RUN", 1);
101
111
  const owner = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, run.id]);
102
112
  if (owner)
103
113
  throw new AppError("controller_already_owned", "RUN already has an unresolved managed owner; observe or reconcile it", 1, { controller_id: owner.controller_id });
@@ -287,42 +297,66 @@ export async function serveRunController(context, input) {
287
297
  }
288
298
  }
289
299
  }
290
- /** Public HITL input: persist raw bytes, never deliver a turn from this client. */
291
- export function answerRunController(context, input) {
300
+ export function prepareRunControllerAnswer(context, input) {
292
301
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !input.pauseId.trim())
293
302
  throw new AppError("usage", "Answer requires request-id and pause-id", 2);
294
303
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
304
+ const prior = context.db.get(`SELECT o.operation_id, o.status, o.input_json
305
+ FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id
306
+ WHERE c.project_id = ? AND c.run_id = ? AND o.operation_id = c.controller_id || ':answer:' || ?`, [project.id, input.runId, input.requestId]);
307
+ if (prior) {
308
+ const retained = JSON.parse(prior.input_json);
309
+ if (retained.pause_id !== input.pauseId)
310
+ throw new AppError("runtime_request_conflict", "HITL answer request id belongs to another pause", 1, { operation_id: prior.operation_id });
311
+ return { kind: "replay", operationId: prior.operation_id, status: prior.status };
312
+ }
313
+ const { bytes } = prepareFileBytes(input.answerFile, "HITL answer");
314
+ if (!bytes.toString("utf8").trim())
315
+ throw new AppError("validation", "HITL answer must not be empty", 2);
295
316
  const row = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, input.runId]);
296
317
  if (!row)
297
318
  throw new AppError("not_found", "RUN has no managed controller for this answer", 1);
298
- const bytes = fs.readFileSync(path.resolve(input.answerFile));
299
- if (!bytes.toString("utf8").trim())
300
- throw new AppError("validation", "HITL answer must not be empty", 2);
301
319
  const operationId = `${row.controller_id}:answer:${input.requestId}`;
302
320
  const answerHash = digestBytes(bytes);
303
321
  const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
322
+ prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
323
+ return { kind: "create", project, row, bytes, operationId, answerHash, hash };
324
+ }
325
+ function prepareControllerAnswerAcceptance(context, input, projectId, row, operationId, hash) {
326
+ const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
327
+ if (prior) {
328
+ if (prior.input_hash !== hash)
329
+ throw new AppError("runtime_request_conflict", "HITL answer request id has different raw input", 1);
330
+ return { prior };
331
+ }
332
+ const currentOwner = requireController(context, row.controller_id);
333
+ if (["completed", "cancelled", "superseded"].includes(currentOwner.status))
334
+ throw new AppError("invalid_run_state", "The answer's controller is no longer current", 1);
335
+ const competing = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? LIMIT 1", [row.controller_id, input.pauseId]);
336
+ if (competing)
337
+ throw new AppError("runtime_request_conflict", "This HITL pause already has an accepted answer", 1, { operation_id: competing.operation_id });
338
+ const run = requireRun(context, projectId, input.runId);
339
+ if (["done", "failed", "cancelled"].includes(run.status))
340
+ throw new AppError("invalid_run_state", "A terminal RUN cannot accept a new answer", 1);
341
+ const index = JSON.parse(run.index_json);
342
+ const stage = index.stage_runs.find(stage => stage.pause?.id === input.pauseId && ["paused", "waiting_for_user"].includes(stage.status));
343
+ if (!stage?.pause?.work_id)
344
+ throw new AppError("hitl_pause_mismatch", "Answer does not match the RUN's current HITL pause", 1);
345
+ return { run, stage, workId: stage.pause.work_id };
346
+ }
347
+ export function answerRunController(context, input, prepared = prepareRunControllerAnswer(context, input)) {
348
+ if (prepared.kind === "replay")
349
+ return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
350
+ const { project, row, bytes, operationId, answerHash, hash } = prepared;
304
351
  context.db.beginWriteTransaction();
305
352
  try {
306
- const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
307
- if (prior) {
308
- if (prior.input_hash !== hash)
309
- throw new AppError("runtime_request_conflict", "HITL answer request id has different raw input", 1);
353
+ // Recheck only mutable acceptance state under the write transaction; bytes are already prepared.
354
+ const acceptance = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
355
+ if (acceptance.prior) {
310
356
  context.db.exec("COMMIT");
311
- return { ok: true, operation_id: operationId, status: prior.status, reused: true };
357
+ return { ok: true, operation_id: operationId, status: acceptance.prior.status, reused: true };
312
358
  }
313
- const currentOwner = requireController(context, row.controller_id);
314
- if (["completed", "cancelled", "superseded"].includes(currentOwner.status))
315
- throw new AppError("invalid_run_state", "The answer's controller is no longer current", 1);
316
- const competing = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? LIMIT 1", [row.controller_id, input.pauseId]);
317
- if (competing)
318
- throw new AppError("runtime_request_conflict", "This HITL pause already has an accepted answer", 1, { operation_id: competing.operation_id });
319
- const run = requireRun(context, project.id, input.runId);
320
- if (["done", "failed", "cancelled"].includes(run.status))
321
- throw new AppError("invalid_run_state", "A terminal RUN cannot accept a new answer", 1);
322
- const index = JSON.parse(run.index_json);
323
- const stage = index.stage_runs.find(stage => stage.pause?.id === input.pauseId && ["paused", "waiting_for_user"].includes(stage.status));
324
- if (!stage?.pause?.work_id)
325
- throw new AppError("hitl_pause_mismatch", "Answer does not match the RUN's current HITL pause", 1);
359
+ const { run, stage, workId } = acceptance;
326
360
  const file = path.join(row.state_dir, `answer-${digest(operationId)}.md`);
327
361
  try {
328
362
  fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
@@ -336,7 +370,7 @@ export function answerRunController(context, input) {
336
370
  }
337
371
  const now = context.now();
338
372
  const generation = recoveryGuard(context, project.id, run.id)?.generation ?? row.generation;
339
- 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: stage.pause.work_id, file, sha256: answerHash }), now, now]);
373
+ 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]);
340
374
  appendEvent(context, row.controller_id, "answer_accepted", { operation_id: operationId, pause_id: input.pauseId, answer_sha256: answerHash });
341
375
  context.db.exec("COMMIT");
342
376
  return { ok: true, operation_id: operationId, status: "accepted" };
@@ -346,13 +380,29 @@ export function answerRunController(context, input) {
346
380
  throw error;
347
381
  }
348
382
  }
349
- /** Publish a missing stage slice without giving the submitting client dispatch rights. */
350
- export function supplyRunControllerContext(context, input) {
383
+ export function prepareRunControllerContext(context, input) {
351
384
  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))
352
385
  throw new AppError("usage", "Context submission requires request id, stage and SHA-256", 2);
353
386
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
354
387
  const operationId = `${input.controllerId}:context:${input.requestId}`;
355
388
  const hash = digest({ stage: input.stage, sha256: input.contextSha256 });
389
+ const row = requireController(context, input.controllerId);
390
+ if (row.project_id !== project.id || row.run_id !== input.runId)
391
+ throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
392
+ const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
393
+ if (prior) {
394
+ if (prior.input_hash !== hash)
395
+ throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
396
+ return { kind: "replay", operationId, status: prior.status };
397
+ }
398
+ availableControllerContext(context, input, row);
399
+ const { bytes } = loadExternalStageContext({ file: input.contextFile, expectedSha256: input.contextSha256, stage: input.stage });
400
+ return { kind: "create", project, operationId, hash, bytes };
401
+ }
402
+ export function supplyRunControllerContext(context, input, prepared = prepareRunControllerContext(context, input)) {
403
+ if (prepared.kind === "replay")
404
+ return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
405
+ const { project, operationId, hash, bytes } = prepared;
356
406
  context.db.beginWriteTransaction();
357
407
  try {
358
408
  const row = requireController(context, input.controllerId);
@@ -365,16 +415,7 @@ export function supplyRunControllerContext(context, input) {
365
415
  context.db.exec("COMMIT");
366
416
  return { ok: true, operation_id: operationId, status: prior.status, reused: true };
367
417
  }
368
- assertRunMutationAllowed(context, project.id, input.runId, row.generation);
369
- if (!["starting", "running", "waiting_for_context", "waiting_for_user", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(row.status))
370
- throw new AppError("invalid_run_state", "Controller cannot accept a new context in its current state", 1);
371
- const manifest = JSON.parse(row.manifest_json);
372
- if (manifest.contexts[input.stage])
373
- throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
374
- loadExternalStageContext({ file: input.contextFile, expectedSha256: input.contextSha256, stage: input.stage });
375
- const bytes = fs.readFileSync(input.contextFile);
376
- if (digestBytes(bytes) !== input.contextSha256)
377
- throw new AppError("context_checksum_mismatch", "Context changed before publication", 2);
418
+ const manifest = availableControllerContext(context, input, row);
378
419
  const file = path.join(row.state_dir, `${input.stage}.context.json`);
379
420
  try {
380
421
  fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
@@ -398,6 +439,15 @@ export function supplyRunControllerContext(context, input) {
398
439
  throw error;
399
440
  }
400
441
  }
442
+ function availableControllerContext(context, input, row) {
443
+ assertRunMutationAllowed(context, row.project_id, input.runId, row.generation);
444
+ if (!["starting", "running", "waiting_for_context", "waiting_for_user", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(row.status))
445
+ throw new AppError("invalid_run_state", "Controller cannot accept a new context in its current state", 1);
446
+ const manifest = JSON.parse(row.manifest_json);
447
+ if (manifest.contexts[input.stage])
448
+ throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
449
+ return manifest;
450
+ }
401
451
  async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
402
452
  assertOwnership();
403
453
  const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
@@ -580,6 +630,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
580
630
  continue;
581
631
  }
582
632
  loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
633
+ const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
634
+ 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 } });
583
635
  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 });
584
636
  if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
585
637
  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]);
@@ -629,7 +681,6 @@ async function executeController(context, row, manifest, state, assertOwnership,
629
681
  }
630
682
  state.native_baseline = controllerNativeChildren(state.last_receipt ?? {}, session.id).map(child => child.session_id);
631
683
  state.stage = next.stage;
632
- state.last_continuation = null;
633
684
  saveState(context, row, state);
634
685
  setStatus("running");
635
686
  if (next.stage === "code" || next.stage === "code-review") {
@@ -692,9 +743,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
692
743
  saveState(context, row, state);
693
744
  continue;
694
745
  }
695
- if (state.last_continuation === fanout.fingerprint)
696
- throw new AppError("fanout_stage_nonprogressing", "The coordinator repeated an unchanged Work-graph continuation", 1, { stage: next.stage });
697
- state.last_continuation = fanout.fingerprint;
746
+ retainControllerContinuation(state, fanout.fingerprint, { code: "fanout_stage_nonprogressing", message: "The coordinator repeated an unchanged Work-graph continuation", details: { stage: next.stage } });
698
747
  saveState(context, row, state);
699
748
  await prompt(session, fanout.prompt);
700
749
  }
@@ -713,20 +762,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
713
762
  }
714
763
  }
715
764
  function readContexts(file, expected) {
716
- const bytes = fs.readFileSync(path.resolve(file));
765
+ const { bytes } = prepareFileBytes(file, "Managed context package");
717
766
  if (digestBytes(bytes) !== expected)
718
767
  throw new AppError("context_checksum_mismatch", "Managed context package SHA-256 differs", 2);
719
- const value = JSON.parse(bytes.toString("utf8"));
720
- if (value.schema_id !== "dd-flow/managed-context@1" || !value.stages || typeof value.stages !== "object" || Array.isArray(value.stages) || Object.keys(value).some(key => !["schema_id", "stages"].includes(key)))
768
+ const value = prepareJsonText(bytes.toString("utf8"), { file, label: "Managed context package" }).value;
769
+ if (!value || value.schema_id !== "dd-flow/managed-context@1" || !value.stages || typeof value.stages !== "object" || Array.isArray(value.stages) || Object.keys(value).some(key => !["schema_id", "stages"].includes(key)))
721
770
  throw new AppError("validation", "Expected dd-flow/managed-context@1 with stage file/hash references", 2);
722
771
  const result = {};
723
772
  for (const [stage, source] of Object.entries(value.stages)) {
724
773
  if (!/^[a-z][a-z-]*$/.test(stage) || !source || typeof source !== "object" || Object.keys(source).some(key => !["file", "sha256"].includes(key)) || typeof source.file !== "string" || !path.isAbsolute(source.file) || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
725
774
  throw new AppError("validation", "Invalid managed stage context reference", 2);
726
- loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
727
- const content = fs.readFileSync(source.file);
728
- if (digestBytes(content) !== source.sha256)
729
- throw new AppError("context_checksum_mismatch", "Stage context changed while freezing the launch", 2);
775
+ const { bytes: content } = loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
730
776
  result[stage] = { bytes: content, sha256: source.sha256 };
731
777
  }
732
778
  return result;
@@ -4,19 +4,28 @@ 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 { installCurrentEngine, upgradeForkedRunEngine } from "./engines.js";
8
- import { restoreEvalRunSnapshot } from "./eval-snapshots.js";
9
- /** Restore a sealed stage boundary into its own home and replace only its
10
- * copied engine binding. The original snapshot and RUN are read-only. */
11
- export function forkRunFromSnapshot(context, input) {
7
+ import { installCurrentEngine, upgradeForkedRunEngine, prepareForkEngine } from "./engines.js";
8
+ import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore } from "./eval-snapshots.js";
9
+ export function prepareRunFork(input) {
12
10
  const requestId = requireId(input.requestId, "request-id");
13
- const snapshot = canonicalPath(input.snapshot);
14
- const output = path.resolve(input.output);
11
+ const snapshot = canonicalPath(input.snapshot, false);
12
+ const output = canonicalPath(input.output, false);
15
13
  if (snapshot === output || snapshot.startsWith(`${output}${path.sep}`) || output.startsWith(`${snapshot}${path.sep}`)) {
16
14
  throw new AppError("fork_path_conflict", "Fork output must not contain or be contained by its source snapshot", 2, { snapshot, output });
17
15
  }
18
16
  const manifestFile = path.join(snapshot, "snapshot.json");
19
- const manifest = readJson(manifestFile);
17
+ const intentFile = path.join(output, ".fork-intent.json");
18
+ const receiptFile = path.join(output, "fork.json");
19
+ const engineVersion = requireText(input.engineVersion, "engine-version");
20
+ if (fs.existsSync(receiptFile)) {
21
+ const receipt = readJson(receiptFile);
22
+ const prior = receipt?.intent;
23
+ if (!prior || prior.request_id !== requestId || prior.snapshot !== snapshot || prior.engine_version !== engineVersion || prior.integrity_checksum !== (input.integrityChecksum ?? null))
24
+ throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
25
+ return { receipt, output };
26
+ }
27
+ prepareForkEngine(engineVersion, input.integrityChecksum);
28
+ const manifest = prepareEvalRunSnapshot(snapshot);
20
29
  if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
21
30
  throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
22
31
  }
@@ -25,34 +34,39 @@ export function forkRunFromSnapshot(context, input) {
25
34
  request_id: requestId,
26
35
  snapshot,
27
36
  snapshot_sha256: fileHash(manifestFile),
28
- engine_version: requireText(input.engineVersion, "engine-version"),
37
+ engine_version: engineVersion,
29
38
  integrity_checksum: input.integrityChecksum ?? null,
30
39
  target_stage: manifest.stage_entry
31
40
  };
32
- const intentFile = path.join(output, ".fork-intent.json");
33
- const receiptFile = path.join(output, "fork.json");
34
- if (fs.existsSync(receiptFile)) {
35
- const receipt = readJson(receiptFile);
36
- if (hash(receipt.intent) !== hash(intent))
37
- throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
38
- return receipt;
39
- }
41
+ assertForkOutput(output, intentFile, intent);
42
+ return { manifest, snapshot, output, intent, intentFile, receiptFile, requestId };
43
+ }
44
+ function assertForkOutput(output, intentFile, intent) {
45
+ if (fs.existsSync(output) && !fs.statSync(output).isDirectory())
46
+ throw new AppError("fork_output_not_empty", "Fork output must be a directory", 2, { output });
40
47
  if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
41
48
  if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
42
49
  throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
43
50
  const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
44
51
  if (unknown.length)
45
52
  throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
46
- for (const name of ["project", "dd-flow-home"])
47
- fs.rmSync(path.join(output, name), { recursive: true, force: true });
48
53
  }
54
+ }
55
+ export function forkRunFromSnapshot(context, input, prepared = prepareRunFork(input)) {
56
+ if ("receipt" in prepared)
57
+ return prepared.receipt;
58
+ const { manifest, snapshot, output, intent, intentFile, receiptFile, requestId } = prepared;
59
+ assertForkOutput(output, intentFile, intent);
60
+ for (const name of ["project", "dd-flow-home"])
61
+ fs.rmSync(path.join(output, name), { recursive: true, force: true });
49
62
  fs.mkdirSync(output, { recursive: true, mode: 0o700 });
50
63
  writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
51
64
  const projectRoot = path.join(output, "project");
52
65
  const home = path.join(output, "dd-flow-home");
53
66
  fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
54
67
  const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
55
- const restored = restoreEvalRunSnapshot(targetContext, { snapshot, projectRoot });
68
+ const restoreInput = { snapshot, projectRoot };
69
+ const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest));
56
70
  const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
57
71
  try {
58
72
  const runHome = String(restored.run_home);
@@ -69,8 +83,8 @@ export function forkRunFromSnapshot(context, input) {
69
83
  };
70
84
  writeJsonAtomic(path.join(runHome, "fork-lineage.json"), lineage);
71
85
  // Install the immutable artifact belonging to the currently executing
72
- // CLI into the new home. A different exact version must already be
73
- // supplied as an installed engine artifact for this fork home.
86
+ // CLI into the new home. Its exact version/checksum were checked before
87
+ // any interrupted-copy cleanup; snapshots exclude installed engines.
74
88
  installCurrentEngine(restoredContext, { forRouting: true });
75
89
  const binding = upgradeForkedRunEngine(restoredContext, {
76
90
  projectRoot,
@@ -51,12 +51,21 @@ export function requireRecoveryRun(context, projectId, runId) {
51
51
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId });
52
52
  return run;
53
53
  }
54
- function parseObject(value, label) { try {
55
- const parsed = JSON.parse(value);
56
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
57
- return parsed;
54
+ export function prepareRecoveryPayload(value, label) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(value);
58
+ }
59
+ catch {
60
+ throw new AppError("invalid_json", `${label} must be valid JSON`, 2);
61
+ }
62
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
63
+ throw new AppError("validation", `${label} must be a JSON object`, 2);
64
+ const payload = parsed;
65
+ if (label === "settlement" && payload.settled !== true)
66
+ throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1);
67
+ return payload;
58
68
  }
59
- catch { /* validated below */ } throw new AppError("validation", `${label} must be a JSON object`, 2); }
60
69
  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; }
61
70
  export function assertRunMutationAllowed(context, projectId, runId, expectedGeneration) {
62
71
  const guard = recoveryGuard(context, projectId, runId);
@@ -103,10 +112,9 @@ export function assertRecoveryHookCurrent(context, projectId, runId, identity) {
103
112
  if (!identity.daemonId || retired)
104
113
  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 });
105
114
  }
106
- export function beginRunRecovery(context, input) {
115
+ export function beginRunRecovery(context, input, interruption = prepareRecoveryPayload(input.interruption, "interruption")) {
107
116
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
108
117
  const run = requireRecoveryRun(context, project.id, input.runId);
109
- const interruption = parseObject(input.interruption, "interruption");
110
118
  const recoveryId = `RCV-${crypto.randomUUID()}`, now = context.now();
111
119
  let generation;
112
120
  context.db.beginWriteTransaction();
@@ -133,15 +141,12 @@ export function beginRunRecovery(context, input) {
133
141
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_draining", recovery_id: recoveryId, generation, interruption });
134
142
  return { ok: true, recovery: { recovery_id: recoveryId, run_id: run.id, generation, status: "draining", interruption } };
135
143
  }
136
- export function sealRunRecovery(context, input) {
144
+ export function sealRunRecovery(context, input, settlement = prepareRecoveryPayload(input.settlement, "settlement")) {
137
145
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
138
146
  const run = requireRecoveryRun(context, project.id, input.runId);
139
147
  const guard = recoveryGuard(context, project.id, run.id);
140
148
  if (!guard || guard.recovery_id !== input.recoveryId)
141
149
  throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
142
- const settlement = parseObject(input.settlement, "settlement");
143
- if (settlement.settled !== true)
144
- throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1, { recovery_id: guard.recovery_id });
145
150
  if (guard.status === "sealed")
146
151
  return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
147
152
  if (guard.status !== "draining")
@@ -163,7 +168,7 @@ export function sealRunRecovery(context, input) {
163
168
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
164
169
  return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
165
170
  }
166
- export function recordRecoveryCapture(context, input) {
171
+ export function prepareRecoveryCapture(context, input) {
167
172
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
168
173
  const run = requireRecoveryRun(context, project.id, input.runId);
169
174
  const guard = recoveryGuard(context, project.id, run.id);
@@ -175,28 +180,33 @@ export function recordRecoveryCapture(context, input) {
175
180
  if (!fs.existsSync(capture))
176
181
  throw new AppError("recovery_capture_missing", "Recovery capture path is unavailable", 1, { capture });
177
182
  verifyRecoverySnapshot(capture, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
183
+ return { project, run, guard, capture };
184
+ }
185
+ export function recordRecoveryCapture(context, input, prepared = prepareRecoveryCapture(context, input)) {
186
+ const { project, run, guard, capture } = prepared;
178
187
  const updated = context.db.run("UPDATE run_recovery_guards SET capture_path = ?, updated_at = ? WHERE recovery_id = ? AND status = 'sealed' AND (capture_path IS NULL OR capture_path = ?) AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [capture, context.now(), guard.recovery_id, capture, project.id, run.id]);
179
188
  if (updated.changes !== 1)
180
189
  throw new AppError("recovery_state_changed", "Recovery state changed before capture could be bound", 1, { recovery_id: guard.recovery_id });
181
190
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_capture_ready", recovery_id: guard.recovery_id, capture_path: capture });
182
191
  return { ok: true, recovery_id: guard.recovery_id, capture_path: capture };
183
192
  }
184
- export function resumeRunRecovery(context, input) {
193
+ export function prepareRunRecoveryResume(context, input) {
185
194
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
186
195
  const run = requireRecoveryRun(context, project.id, input.runId);
187
196
  const guard = recoveryGuard(context, project.id, run.id);
188
197
  if (!guard || guard.recovery_id !== input.recoveryId)
189
198
  throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
190
- const acceptCommand = acceptanceCommand(context, run.id, guard.recovery_id, run.project_root);
191
199
  if (["resuming", "recovered"].includes(guard.status)) {
192
200
  const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [guard.recovery_id]);
193
201
  if (!binding || binding.daemon_id !== input.daemonId || binding.harness !== input.harness || binding.provider_session_id !== input.nativeSessionId)
194
202
  throw new AppError("recovery_binding_mismatch", "Recovery is already pinned to another physical Session", 1);
195
203
  recoveryPacketReceipt(binding);
196
- return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: guard.status === "recovered", awaiting_acceptance: guard.status === "resuming", accept_command: acceptCommand, reused: true };
204
+ return { project, run, guard, binding, reused: true };
197
205
  }
198
206
  if (guard.status !== "sealed" || !guard.capture_path)
199
207
  throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
208
+ if (!input.daemonId?.trim() || !input.harness?.trim() || !input.nativeSessionId?.trim())
209
+ throw new AppError("recovery_identity_required", "Recovery requires the replacement daemon and retained native Session identity", 1);
200
210
  // The coordinator keeps its same native Session and receives a recovery
201
211
  // prompt from the eval runner. At a stage boundary its last WorkSession is
202
212
  // completed; that retained identity still authorizes acknowledgement, but
@@ -204,6 +214,13 @@ export function resumeRunRecovery(context, input) {
204
214
  // Only delegated Work is returned to the ready graph and therefore gets a
205
215
  // fresh, addressable recovery packet.
206
216
  verifyRecoverySnapshot(guard.capture_path, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
217
+ return { project, run, guard, reused: false };
218
+ }
219
+ export function resumeRunRecovery(context, input, prepared = prepareRunRecoveryResume(context, input)) {
220
+ const { project, run, guard } = prepared;
221
+ const acceptCommand = acceptanceCommand(context, run.id, guard.recovery_id, run.project_root);
222
+ if (prepared.reused)
223
+ return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: guard.status === "recovered", awaiting_acceptance: guard.status === "resuming", accept_command: acceptCommand, reused: true };
207
224
  const now = context.now();
208
225
  let running = [];
209
226
  context.db.beginWriteTransaction();
@@ -211,8 +228,6 @@ export function resumeRunRecovery(context, input) {
211
228
  const current = recoveryGuard(context, project.id, run.id);
212
229
  if (!current || current.recovery_id !== guard.recovery_id || current.generation !== guard.generation || current.status !== "sealed" || current.capture_path !== guard.capture_path)
213
230
  throw new AppError("recovery_state_changed", "Recovery state changed before its resume transaction", 1, { recovery_id: guard.recovery_id });
214
- if (!input.daemonId?.trim() || !input.harness?.trim() || !input.nativeSessionId?.trim())
215
- throw new AppError("recovery_identity_required", "Recovery requires the replacement daemon and retained native Session identity", 1);
216
231
  const owner = context.db.get(`
217
232
  SELECT w.work_id, ws.id, ws.session_id, s.harness, s.provider_session_id,
218
233
  w.status AS work_status, ws.status AS session_status