@deksden-com/dd-flow-cli 0.9.0-beta.103 → 0.9.0-beta.105

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 +6 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +32 -11
  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 +2 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
  9. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -6
  10. package/dist/protocol/local-files.js +18 -9
  11. package/dist/runtime/context.js +2 -2
  12. package/dist/services/cleanup.js +81 -32
  13. package/dist/services/code-checks.js +18 -19
  14. package/dist/services/continuation-outcome.js +1 -1
  15. package/dist/services/controller-fanout.js +3 -3
  16. package/dist/services/eval-snapshots.js +69 -41
  17. package/dist/services/execution-policy.js +12 -1
  18. package/dist/services/hooks.js +150 -42
  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 +3 -11
  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 +81 -86
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +19 -64
  38. package/dist/services/run-recovery-runtime.js +24 -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
@@ -1,4 +1,4 @@
1
- import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
+ import { bindLifecyclePacketAssignments, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import { bindStageWork, currentStageWorkIds, stageWorkIds } from "./stage-work-graph.js";
3
3
  import { runtimeCommand } from "./runtime-command.js";
4
4
  import crypto from "node:crypto";
@@ -22,6 +22,8 @@ import { publicSessionIdentity } from "./session-identity.js";
22
22
  import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
23
23
  import { runControlDirective } from "./run-control.js";
24
24
  import { prepareError, prepareJsonFile } from "../cli/input-preparation.js";
25
+ import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
26
+ import { acceptedDecisionPrecedence } from "./stage-context.js";
25
27
  import { reviewEnvironmentAllowed, reviewExecutionCopyHash, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
26
28
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
27
29
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
@@ -62,6 +64,23 @@ export function assertStageLifecycleOwner(context, input) {
62
64
  if (row && completed && (index?.current_stage ?? index?.stage_runs.at(-1)?.stage) === input.stage)
63
65
  owner = context.db.get("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NULL AND w.status = 'completed' AND ws.status = 'completed' AND ws.result_path = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, input.runId, path.join(row.run_root, completed.dir, "stage-report.json")]);
64
66
  }
67
+ if (!owner && input.operation === "stage_finish" && input.stage === "protocolize") {
68
+ const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
69
+ const index = row ? JSON.parse(row.index_json) : null;
70
+ const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
71
+ const root = row && completed ? path.join(row.run_root, completed.dir) : null;
72
+ const accepted = root && completed?.attempt && context.db.get("SELECT 1 FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_report' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.stage') = ? AND json_extract(payload_json, '$.attempt') = ? AND json_extract(payload_json, '$.root') = ? LIMIT 1", [project.id, input.runId, input.stage, completed.attempt, root]);
73
+ if (accepted && completed?.data) {
74
+ owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws
75
+ JOIN works w ON w.work_id = ws.work_id
76
+ WHERE w.project_id = ? AND w.run_id = ?
77
+ AND w.parent_work_id IS NULL AND ws.status = 'completed'
78
+ AND ws.result_path = ? ${scopedWork ? "AND w.work_id = ?" : ""}
79
+ ORDER BY ws.created_at DESC LIMIT 1`, scopedWork
80
+ ? [project.id, input.runId, path.join(root, completed.data), scopedWork.work_id]
81
+ : [project.id, input.runId, path.join(root, completed.data)]);
82
+ }
83
+ }
65
84
  if (!owner)
66
85
  throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
67
86
  const identity = claimStageLifecycleHookEvent(context, {
@@ -258,27 +277,29 @@ function publishReceiptFile(file, contents) {
258
277
  /** Rebuild prompt/context projections from a committed Work-start receipt. */
259
278
  export function materializePendingWorkStarts(context, projectId, runId) {
260
279
  const runHome = requireRunHome(requireRun(context, projectId, runId));
261
- context.db.writeTransaction(() => {
262
- const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json 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' AND ws.start_receipt_json IS NOT NULL", [projectId, runId]);
263
- for (const row of rows) {
264
- // The same coordinator Session can move on to a different stage packet.
265
- // A start receipt is not authority to overwrite that later packet.
266
- let receipt;
267
- try {
268
- receipt = JSON.parse(row.start_receipt_json);
269
- }
270
- catch {
271
- throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
272
- }
273
- const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
274
- if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
275
- continue;
276
- if (receipt.version !== 1 || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
277
- throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
278
- publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
279
- publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
280
+ const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json, ws.start_publication_status 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' AND ws.start_receipt_json IS NOT NULL AND (ws.start_publication_status IS NULL OR ws.start_publication_status != 'published')", [projectId, runId]);
281
+ for (const row of rows) {
282
+ // The same coordinator Session can move on to a different stage packet.
283
+ // A start receipt is not authority to overwrite that later packet.
284
+ let receipt;
285
+ try {
286
+ receipt = JSON.parse(row.start_receipt_json);
280
287
  }
281
- });
288
+ catch {
289
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
290
+ }
291
+ const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.version === 2 || receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
292
+ if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
293
+ continue;
294
+ if (![1, 2].includes(Number(receipt.version)) || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
295
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
296
+ if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'publishing' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status IN ('pending','publishing')", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1)
297
+ continue;
298
+ publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
299
+ publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
300
+ if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1)
301
+ throw new AppError("work_start_projection_conflict", "Work start binding changed during packet publication", 1, { work_id: row.work_id });
302
+ }
282
303
  }
283
304
  function publishStartReceiptFile(file, contents, expectedHash) {
284
305
  const actual = receiptFileHash(file);
@@ -288,45 +309,72 @@ function publishStartReceiptFile(file, contents, expectedHash) {
288
309
  throw new AppError("work_start_projection_conflict", "Work start packet differs from its committed receipt; preserve it for recovery", 1, { path: file, expected_sha256: expectedHash, actual_sha256: actual });
289
310
  }
290
311
  fs.mkdirSync(path.dirname(file), { recursive: true });
291
- publishReceiptFile(file, contents);
312
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
313
+ try {
314
+ fs.writeFileSync(temporary, contents, { flag: "wx" });
315
+ const descriptor = fs.openSync(temporary, "r");
316
+ try {
317
+ fs.fsyncSync(descriptor);
318
+ }
319
+ finally {
320
+ fs.closeSync(descriptor);
321
+ }
322
+ try {
323
+ fs.linkSync(temporary, file);
324
+ }
325
+ catch (error) {
326
+ if (error.code !== "EEXIST")
327
+ throw error;
328
+ if (receiptFileHash(file) !== expectedHash) {
329
+ throw new AppError("work_start_projection_conflict", "Concurrent Work start publication differs from its committed receipt", 1, { path: file, expected_sha256: expectedHash, actual_sha256: receiptFileHash(file) });
330
+ }
331
+ }
332
+ const directory = fs.openSync(path.dirname(file), "r");
333
+ try {
334
+ fs.fsyncSync(directory);
335
+ }
336
+ finally {
337
+ fs.closeSync(directory);
338
+ }
339
+ }
340
+ finally {
341
+ fs.rmSync(temporary, { force: true });
342
+ }
292
343
  }
293
344
  /** Replay only a prepared publication whose exact receipt was committed in SQLite. */
294
- export function materializePendingWorkResults(context, projectId, runId) {
295
- context.db.beginWriteTransaction();
296
- try {
297
- const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL`, [projectId, runId]);
298
- for (const row of rows) {
299
- const intentPath = receiptIntentPath(row.result_path, row.id);
300
- if (!fs.existsSync(intentPath))
301
- continue;
302
- const intent = JSON.parse(fs.readFileSync(intentPath, "utf8"));
303
- const digest = crypto.createHash("sha256").update(row.result).digest("hex");
304
- if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
305
- throw new AppError("work_result_publication_conflict", "Pending result publication does not identify the committed receipt", 1, { work_id: row.work_id, intent_path: intentPath });
306
- const actual = receiptFileHash(row.result_path);
307
- if (actual !== digest && actual !== intent.previous_sha256)
308
- throw new AppError("work_result_artifact_drift", "Result changed after its publication was prepared; preserve it for diagnosis", 1, { work_id: row.work_id, actual_sha256: actual, expected_sha256: digest });
309
- if (fs.existsSync(row.result_path)) {
310
- const previous = fs.readFileSync(row.result_path);
311
- if (!previous.equals(Buffer.from(row.result))) {
312
- const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
313
- try {
314
- fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
315
- }
316
- catch (error) {
317
- if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
318
- throw error;
319
- }
345
+ export function materializePendingWorkResults(context, projectId, runId, workId) {
346
+ const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path, ws.result_receipt_json FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL AND (? IS NULL OR w.work_id = ?)`, [projectId, runId, workId ?? null, workId ?? null]);
347
+ for (const row of rows) {
348
+ const intentPath = receiptIntentPath(row.result_path, row.id);
349
+ const legacyIntent = fs.existsSync(intentPath) ? fs.readFileSync(intentPath, "utf8") : null;
350
+ if (!row.result_receipt_json && !legacyIntent)
351
+ continue;
352
+ const intent = JSON.parse(row.result_receipt_json ?? legacyIntent);
353
+ const digest = crypto.createHash("sha256").update(row.result).digest("hex");
354
+ if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
355
+ throw new AppError("work_result_publication_conflict", "Pending result publication does not identify the committed receipt", 1, { work_id: row.work_id, intent_path: intentPath });
356
+ if (legacyIntent && row.result_receipt_json && legacyIntent !== row.result_receipt_json)
357
+ throw new AppError("work_result_publication_conflict", "File intent differs from the committed result receipt", 1, { work_id: row.work_id, intent_path: intentPath });
358
+ const actual = receiptFileHash(row.result_path);
359
+ if (actual !== digest && actual !== intent.previous_sha256)
360
+ throw new AppError("work_result_artifact_drift", "Result changed after its publication was prepared; preserve it for diagnosis", 1, { work_id: row.work_id, actual_sha256: actual, expected_sha256: digest });
361
+ if (actual !== digest && fs.existsSync(row.result_path)) {
362
+ const previous = fs.readFileSync(row.result_path);
363
+ if (!previous.equals(Buffer.from(row.result))) {
364
+ const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
365
+ try {
366
+ fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
367
+ }
368
+ catch (error) {
369
+ if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
370
+ throw error;
320
371
  }
321
372
  }
373
+ }
374
+ if (actual !== digest)
322
375
  publishReceiptFile(row.result_path, row.result);
376
+ if (legacyIntent)
323
377
  fs.unlinkSync(intentPath);
324
- }
325
- context.db.exec("COMMIT");
326
- }
327
- catch (error) {
328
- context.db.exec("ROLLBACK");
329
- throw error;
330
378
  }
331
379
  }
332
380
  export function mutateWorkDeps(context, input) {
@@ -452,20 +500,14 @@ export function bindRunningWorkSession(context, input) {
452
500
  throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
453
501
  const now = context.now();
454
502
  const id = `WS-${crypto.randomUUID()}`;
455
- context.db.beginWriteTransaction();
456
- try {
503
+ context.db.writeTransaction(() => {
457
504
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
458
505
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
459
506
  if (!stillRunning)
460
507
  context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, active.session_id]);
461
508
  bindSession(context, work, run, identity, now);
462
509
  context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
463
- context.db.exec("COMMIT");
464
- }
465
- catch (error) {
466
- context.db.exec("ROLLBACK");
467
- throw error;
468
- }
510
+ });
469
511
  refreshRunWorkProjection(context, work.project_id, work.run_id);
470
512
  return { work_session_id: id, session_id: identity.sessionId, handed_off: true };
471
513
  }
@@ -474,16 +516,10 @@ export function bindRunningWorkSession(context, input) {
474
516
  }
475
517
  const now = context.now();
476
518
  const id = `WS-${crypto.randomUUID()}`;
477
- context.db.beginWriteTransaction();
478
- try {
519
+ context.db.writeTransaction(() => {
479
520
  bindSession(context, work, run, identity, now);
480
521
  context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
481
- context.db.exec("COMMIT");
482
- }
483
- catch (error) {
484
- context.db.exec("ROLLBACK");
485
- throw error;
486
- }
522
+ });
487
523
  refreshRunWorkProjection(context, work.project_id, work.run_id);
488
524
  return { work_session_id: id, session_id: identity.sessionId };
489
525
  }
@@ -499,7 +535,7 @@ export function bindStageCoordinatorWork(context, input) {
499
535
  if (profile?.settings.execution) {
500
536
  if (!profile.agent_profiles)
501
537
  throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
502
- sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
538
+ sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
503
539
  }
504
540
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
505
541
  if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
@@ -530,14 +566,26 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
530
566
  const linkId = `WS-${crypto.randomUUID()}`;
531
567
  // Recovery can reopen the same Work without retiring its historical packet.
532
568
  // Give each new-generation start its own immutable publication destination.
533
- const packetDirectory = generation > 0 ? path.join(directory, "starts", linkId) : directory;
569
+ const packetDirectory = path.join(directory, "starts", linkId);
534
570
  const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
535
571
  // The first reviewer freezes shared inputs in the same transaction as its claim.
536
572
  const readOnly = parsePayload(work)?.read_only === true;
537
573
  const workerRun = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE
538
574
  ? { ...run, workspace_root: path.resolve(context.env.DD_FLOW_READ_ONLY_WORKSPACE) }
539
575
  : run;
540
- let fingerprint;
576
+ const indexBeforePrepare = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json;
577
+ const preparedReviewInput = readOnly ? assertSharedReviewInput(context, work, run, generation) : null;
578
+ const reviewPaths = preparedReviewInput?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
579
+ const preparedCopy = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE ? prepareBoundReviewCopy(context, work, run, context.env.DD_FLOW_READ_ONLY_WORKSPACE, reviewPaths) : null;
580
+ const fingerprint = readOnly ? preparedReviewInput?.source_sha256 ?? preparedReviewInput?.workspace_fingerprint ?? legacyReviewFingerprint(work, run) ?? workspaceFingerprint(run.workspace_root) : undefined;
581
+ const preparedDependencies = parseDependencies(work).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
582
+ const preparedRecovery = recoveryPromptForWork(context, work.work_id);
583
+ const retryPending = fs.existsSync(path.join(directory, ".retry.json"));
584
+ const finishMarker = `DD_FLOW_FINISH_${crypto.randomUUID()}`;
585
+ const failMarker = `DD_FLOW_FAIL_${crypto.randomUUID()}`;
586
+ let promptTemplate = renderWorkerPrompt(context, work, workerRun, preparedDependencies, { finish: finishMarker, fail: failMarker });
587
+ if (preparedRecovery)
588
+ promptTemplate = `${recoveryPrompt(preparedRecovery)}\n\n${promptTemplate}`;
541
589
  let binding;
542
590
  try {
543
591
  binding = context.db.writeTransaction(() => {
@@ -547,9 +595,11 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
547
595
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
548
596
  if (Object.keys(current).some(key => current[key] !== work[key]))
549
597
  throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
598
+ if (context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json !== indexBeforePrepare)
599
+ throw new AppError("work_not_ready", "RUN revision changed while preparing the Work start packet", 2, { work_id: current.work_id });
550
600
  if (!isReady(context, current))
551
601
  throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
552
- if (fs.existsSync(path.join(directory, ".retry.json")))
602
+ if (retryPending)
553
603
  throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
554
604
  const identity = claimIdentity();
555
605
  assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
@@ -557,27 +607,34 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
557
607
  && context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'work_launches'")
558
608
  && context.db.get("SELECT 1 FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [current.work_id]))
559
609
  throw new AppError("read_only_workspace_unbound", "External reviewer did not inherit its qualified execution workspace", 1, { work_id: current.work_id });
560
- const reviewInput = readOnly ? assertSharedReviewInput(context, current, run, generation) : null;
561
- const reviewPaths = reviewInput?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
562
- const reviewCopy = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE ? boundReviewCopy(context, current, run, identity, context.env.DD_FLOW_READ_ONLY_WORKSPACE, reviewPaths) : null;
610
+ if (preparedCopy) {
611
+ const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [current.work_id]);
612
+ if (!launch || launch.provider_session_id !== identity.providerSessionId || launch.provider_session_id !== preparedCopy.providerSessionId || launch.profile_json !== preparedCopy.profileJson)
613
+ throw new AppError("read_only_workspace_unbound", "Read-only execution copy belongs to another physical Session or launch revision", 1, { work_id: current.work_id });
614
+ }
615
+ const reviewInput = preparedReviewInput;
616
+ const reviewCopy = preparedCopy?.copy ?? null;
617
+ if (reviewInput)
618
+ assertReviewSiblingBaseline(context, current, reviewInput, generation);
563
619
  const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
564
620
  const recovery = recoveryPromptForWork(context, current.work_id);
565
- let prompt = renderWorkerPrompt(context, current, workerRun, dependencyResults);
566
- if (recovery)
567
- prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
568
- if (readOnly)
569
- fingerprint = reviewInput?.source_sha256 ?? reviewInput?.workspace_fingerprint ?? legacyReviewFingerprint(current, run) ?? workspaceFingerprint(run.workspace_root);
621
+ if (JSON.stringify(dependencyResults) !== JSON.stringify(preparedDependencies) || JSON.stringify(recovery) !== JSON.stringify(preparedRecovery))
622
+ throw new AppError("work_not_ready", "Work dependencies or recovery segment changed while preparing its packet", 2, { work_id: current.work_id });
623
+ const assignments = new Set();
624
+ const commands = workerLifecycleCommands({ ...context, captureLifecycleAssignment: (id) => assignments.add(id) }, current, workerRun);
625
+ const prompt = promptTemplate.replace(finishMarker, commands.finish).replace(failMarker, commands.fail);
626
+ bindLifecyclePacketAssignments(context, prompt, assignments);
570
627
  const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: workerRun.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint, ...(reviewInput ? { review_input: reviewInput } : {}), ...(reviewCopy ? { review_copy: reviewCopy } : {}) } : {}) };
571
628
  const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
572
629
  const response = { ok: true, work_id: current.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
573
- const receipt = JSON.stringify({ version: 1, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
630
+ const receipt = JSON.stringify({ version: 2, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
574
631
  const now = context.now();
575
632
  bindSession(context, current, run, identity, now);
576
633
  if (current.parent_work_id && current.launch_policy === "reuse_allowed")
577
634
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, current.work_id]);
578
635
  if (context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, current.work_id]).changes !== 1)
579
636
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
580
- context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
637
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, start_publication_status, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, 'pending', ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
581
638
  if (stage)
582
639
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
583
640
  if (recovery)
@@ -607,6 +664,12 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
607
664
  throw error;
608
665
  }
609
666
  try {
667
+ if (readOnly) {
668
+ const committedWork = requireWork(context, work.work_id);
669
+ assertSharedReviewInput(context, committedWork, run, generation);
670
+ const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE id = ?", [linkId]);
671
+ assertReviewCopyUnchanged(context, committedWork, run, link);
672
+ }
610
673
  materializePendingWorkStarts(context, work.project_id, work.run_id);
611
674
  refreshRunWorkProjection(context, work.project_id, work.run_id);
612
675
  }
@@ -665,6 +728,9 @@ export function prepareWorkSettlement(context, id, status, result) {
665
728
  const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
666
729
  if (work.status === "running" && !link)
667
730
  throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: work.work_id });
731
+ const startPublication = link ? context.db.get("SELECT start_publication_status FROM work_sessions WHERE id = ?", [link.id])?.start_publication_status : null;
732
+ if (startPublication && startPublication !== "published")
733
+ throw new AppError("work_start_publication_pending", "Finish publishing this Work's committed start packet before settlement", 1, { work_id: work.work_id, effect: "no_effect" });
668
734
  return { work, run, link };
669
735
  }
670
736
  export function prepareWorkRetry(context, id, reason) {
@@ -716,54 +782,52 @@ export function prepareWorkRetry(context, id, reason) {
716
782
  return { work, run, directory, journal, pending, prepared, archive };
717
783
  }
718
784
  export function retryWork(context, id, reason) {
719
- const { work, run, directory, journal } = prepareWorkRetry(context, id, reason);
720
- let archive = "";
721
- context.db.beginWriteTransaction();
722
- try {
723
- const { work: current, pending, prepared, archive: targetArchive } = prepareWorkRetry(context, work.work_id, reason);
724
- archive = targetArchive;
725
- if (current.status === "failed") {
726
- if (prepared.result_sha256 !== (current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex")))
727
- throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
728
- fs.mkdirSync(archive, { recursive: true });
729
- if (!pending)
730
- publishReceiptFile(journal, JSON.stringify(prepared));
731
- const receipt = path.join(archive, "accepted-receipt.json");
732
- const receiptBytes = JSON.stringify({ work_id: current.work_id, status: current.status, result: current.result, result_sha256: prepared.result_sha256 });
733
- if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== receiptBytes)
734
- throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
735
- if (!fs.existsSync(receipt))
736
- publishReceiptFile(receipt, receiptBytes);
737
- for (const file of prepared.entries) {
738
- const source = path.join(directory, file), target = path.join(archive, file);
739
- if (fs.existsSync(source)) {
740
- if (fs.existsSync(target))
741
- throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
742
- fs.renameSync(source, target);
743
- }
744
- else if (!fs.existsSync(target))
745
- throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
785
+ const pending = requireWork(context, id);
786
+ // A committed failed result must be published (or diagnosed) before its
787
+ // live path can be archived for the next attempt.
788
+ materializePendingWorkResults(context, pending.project_id, pending.run_id, pending.work_id);
789
+ const { work, run, directory, journal, pending: retainedJournal, prepared, archive } = prepareWorkRetry(context, id, reason);
790
+ if (work.status === "failed") {
791
+ // The durable journal and archive are projections of the failed SQL
792
+ // receipt. They must finish before the short transaction opens a new
793
+ // attempt; a crash at any point is replayed from the same journal.
794
+ if (!retainedJournal)
795
+ publishStartReceiptFile(journal, JSON.stringify(prepared), sha256(JSON.stringify(prepared)));
796
+ fs.mkdirSync(archive, { recursive: true });
797
+ const receipt = path.join(archive, "accepted-receipt.json");
798
+ const receiptBytes = JSON.stringify({ work_id: work.work_id, status: work.status, result: work.result, result_sha256: prepared.result_sha256 });
799
+ publishStartReceiptFile(receipt, receiptBytes, sha256(receiptBytes));
800
+ for (const file of prepared.entries) {
801
+ const source = path.join(directory, file), target = path.join(archive, file);
802
+ if (fs.existsSync(source)) {
803
+ if (fs.existsSync(target))
804
+ throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
805
+ fs.renameSync(source, target);
746
806
  }
747
- for (const folder of [archive, directory]) {
748
- const descriptor = fs.openSync(folder, "r");
749
- try {
750
- fs.fsyncSync(descriptor);
751
- }
752
- finally {
753
- fs.closeSync(descriptor);
754
- }
807
+ else if (!fs.existsSync(target))
808
+ throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
809
+ }
810
+ for (const folder of [archive, directory]) {
811
+ const descriptor = fs.openSync(folder, "r");
812
+ try {
813
+ fs.fsyncSync(descriptor);
814
+ }
815
+ finally {
816
+ fs.closeSync(descriptor);
755
817
  }
818
+ }
819
+ context.db.writeTransaction(() => {
820
+ const current = requireWork(context, work.work_id);
821
+ assertRunMutationAllowed(context, current.project_id, current.run_id);
822
+ if (current.status !== "failed" || current.result !== work.result || current.updated_at !== work.updated_at)
823
+ throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
756
824
  for (const column of ["prompt_path", "result_path"]) {
757
825
  const file = column === "prompt_path" ? "prompt.md" : "result.json";
758
826
  context.db.run(`UPDATE work_sessions SET ${column} = ? WHERE work_id = ? AND ${column} = ?`, [path.join(archive, file), work.work_id, path.join(directory, file)]);
759
827
  }
760
- context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'failed'", [context.now(), work.work_id]);
761
- }
762
- context.db.exec("COMMIT");
763
- }
764
- catch (error) {
765
- context.db.exec("ROLLBACK");
766
- throw error;
828
+ if (context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'failed'", [context.now(), work.work_id]).changes !== 1)
829
+ throw new AppError("work_retry_conflict", "Failed Work changed during retry", 1);
830
+ });
767
831
  }
768
832
  fs.rmSync(journal, { force: true });
769
833
  refreshRunWorkProjection(context, work.project_id, work.run_id);
@@ -786,11 +850,11 @@ function reviewInputPath(root, relative) {
786
850
  }
787
851
  return resolved;
788
852
  }
789
- function boundReviewCopy(context, work, run, identity, requested, reviewPaths) {
853
+ function prepareBoundReviewCopy(context, work, run, requested, reviewPaths) {
790
854
  const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [work.work_id]);
791
855
  const metadata = launch ? JSON.parse(launch.profile_json) : null;
792
856
  const copy = metadata?.review_copy;
793
- if (!copy?.root || !copy.sha256 || !copy.source_sha256 || launch?.provider_session_id !== identity.providerSessionId)
857
+ if (!copy?.root || !copy.sha256 || !copy.source_sha256 || !launch?.provider_session_id)
794
858
  throw new AppError("read_only_workspace_unbound", "Read-only execution copy has no matching trusted launch", 1, { work_id: work.work_id });
795
859
  const expected = reviewInputPath(requireRunHome(run), copy.root);
796
860
  if (path.resolve(requested) !== expected)
@@ -801,7 +865,7 @@ function boundReviewCopy(context, work, run, identity, requested, reviewPaths) {
801
865
  const actual = reviewExecutionCopyHash(expected, run.workspace_root, true, reviewPaths, base);
802
866
  if (actual !== copy.sha256)
803
867
  throw new AppError("read_only_input_changed", "Read-only execution copy changed before Work start", 2, { work_id: work.work_id, expected: copy.sha256, current: actual });
804
- return { root: copy.root, sha256: copy.sha256, source_sha256: copy.source_sha256 };
868
+ return { copy: { root: copy.root, sha256: copy.sha256, source_sha256: copy.source_sha256 }, providerSessionId: launch.provider_session_id, profileJson: launch.profile_json };
805
869
  }
806
870
  function assertReviewCopyUnchanged(context, work, run, link) {
807
871
  if (!link)
@@ -938,7 +1002,11 @@ function assertSharedReviewInput(context, work, run, generation = recoveryGuard(
938
1002
  throw new AppError("review_input_invalid", "Required review input is excluded from external review", 1, { work_id: work.work_id });
939
1003
  const selectedSource = value.read_only_input.version === 3 ? reviewSourceHash(run.workspace_root, reviewPaths, base) : null;
940
1004
  const current = { schema_id: value.read_only_input.version === 3 ? "dd-flow/review-input@3" : "dd-flow/review-input@2", parent_work_id: work.parent_work_id, kind: String(kind), cycle: kind === "code-review" ? String(payload?.review_cycle) : "initial", generation, workspace_fingerprint: selectedSource ?? workspaceFingerprint(run.workspace_root), ...(selectedSource ? { source_policy: "review-inputs@1", source_sha256: selectedSource } : {}), run_inputs: runInputs };
941
- const committed = context.db.all("SELECT ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id = ? AND json_extract(w.payload_json, '$.kind') = ? AND ws.start_receipt_json IS NOT NULL ORDER BY ws.created_at, ws.id", [work.project_id, work.run_id, work.parent_work_id, kind]);
1005
+ assertReviewSiblingBaseline(context, work, current, generation);
1006
+ return current;
1007
+ }
1008
+ function assertReviewSiblingBaseline(context, work, current, generation) {
1009
+ const committed = context.db.all("SELECT ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id = ? AND json_extract(w.payload_json, '$.kind') = ? AND ws.start_receipt_json IS NOT NULL ORDER BY ws.created_at, ws.id", [work.project_id, work.run_id, work.parent_work_id, current.kind]);
942
1010
  for (const row of committed) {
943
1011
  const receipt = JSON.parse(row.start_receipt_json);
944
1012
  if (!receipt.context)
@@ -952,7 +1020,6 @@ function assertSharedReviewInput(context, work, run, generation = recoveryGuard(
952
1020
  throw new AppError("read_only_input_changed", "Reviewer inputs differ from the first committed review start", 2, { work_id: work.work_id, baseline: prior, current });
953
1021
  break;
954
1022
  }
955
- return current;
956
1023
  }
957
1024
  async function settle(context, id, status, result, progress, retry) {
958
1025
  const { work, run, link } = prepareWorkSettlement(context, id, status, result);
@@ -998,28 +1065,28 @@ async function settle(context, id, status, result, progress, retry) {
998
1065
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
999
1066
  }
1000
1067
  }
1068
+ // Shared-input and workspace fingerprint checks read files and may traverse
1069
+ // the project tree. Keep them before the SQLite writer reservation.
1070
+ assertReadOnlyInput();
1071
+ const resultReceipt = link?.result_path && work.parent_work_id !== null
1072
+ ? JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: sha256(result), previous_sha256: receiptFileHash(link.result_path) })
1073
+ : null;
1001
1074
  const now = context.now();
1002
- context.db.beginWriteTransaction();
1003
- try {
1004
- assertReadOnlyInput();
1075
+ context.db.writeTransaction(() => {
1005
1076
  assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
1006
1077
  if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
1007
1078
  throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
1008
- if (link?.result_path && work.parent_work_id !== null)
1009
- publishReceiptFile(receiptIntentPath(link.result_path, link.id), JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: crypto.createHash("sha256").update(result).digest("hex"), previous_sha256: receiptFileHash(link.result_path) }));
1010
- context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
1079
+ if (link && context.db.get("SELECT start_publication_status FROM work_sessions WHERE id = ?", [link.id])?.start_publication_status && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND start_publication_status = 'published'", [link.id]))
1080
+ throw new AppError("work_start_publication_pending", "Finish publishing this Work's committed start packet before settlement", 1, { work_id: id, effect: "no_effect" });
1081
+ if (context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = ? AND updated_at = ?", [status, result, now, now, id, work.status, work.updated_at]).changes !== 1)
1082
+ throw new AppError("work_generation_stale", "Work changed while its result and review inputs were prepared", 1, { work_id: id });
1011
1083
  if (link) {
1012
- context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
1084
+ context.db.run("UPDATE work_sessions SET status = ?, result_receipt_json = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, resultReceipt, now, now, link.id]);
1013
1085
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
1014
1086
  if (!stillRunning)
1015
1087
  context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, link.session_id]);
1016
1088
  }
1017
- context.db.exec("COMMIT");
1018
- }
1019
- catch (error) {
1020
- context.db.exec("ROLLBACK");
1021
- throw error;
1022
- }
1089
+ });
1023
1090
  materializePendingWorkResults(context, work.project_id, work.run_id);
1024
1091
  settleRecoveryWork(context, work.work_id, status);
1025
1092
  refreshRunWorkProjection(context, work.project_id, work.run_id);
@@ -1072,7 +1139,7 @@ function bindSession(context, work, run, identity, now) {
1072
1139
  throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_parent_session_key: parentSession });
1073
1140
  if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
1074
1141
  throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_provider_parent_session_key: providerParentSession, conflicting_native_parent_session_id: context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, providerParentSession])?.provider_session_id ?? null });
1075
- context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = excluded.cwd, updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, run.workspace_root, now, now]);
1142
+ context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = COALESCE(excluded.cwd, sessions.cwd), updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, identity.cwd, now, now]);
1076
1143
  reactivateBoundSession(context, work.project_id, identity.sessionId, now);
1077
1144
  }
1078
1145
  /** Register the trusted physical Session before a paused Work moves to it. */
@@ -1091,7 +1158,7 @@ export function prepareWorkResult(context, id, result) {
1091
1158
  validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
1092
1159
  }
1093
1160
  catch (error) {
1094
- prepareError(error, { work_id: id });
1161
+ prepareError(error, { work_id: id, input_sha256: sha256(result) });
1095
1162
  }
1096
1163
  }
1097
1164
  function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
@@ -1164,13 +1231,25 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
1164
1231
  if (missing.length || unexpected.length || duplicate)
1165
1232
  throw new AppError("review_repair_incomplete", "Review repair must include exactly one resolution for each assigned finding reference", 2, { work_id: workId, missing, unexpected, duplicate });
1166
1233
  }
1167
- function renderWorkerPrompt(context, work, run, dependencies) {
1234
+ function renderWorkerPrompt(context, work, run, dependencies, commands) {
1235
+ const body = renderWorkerPromptBody(context, work, run, dependencies, commands);
1236
+ return ["<accepted_decision_precedence>", `Read ${path.join(requireRunHome(run), "01-specify", "specify.json")} and later accepted RUN decisions relevant to this Work.`, acceptedDecisionPrecedence, "</accepted_decision_precedence>", "", body].join("\n");
1237
+ }
1238
+ function workerLifecycleCommands(context, work, run) {
1239
+ const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
1240
+ return {
1241
+ finish: managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"])),
1242
+ fail: managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]))
1243
+ };
1244
+ }
1245
+ function renderWorkerPromptBody(context, work, run, dependencies, commands) {
1168
1246
  const command = flowCommand(context);
1169
1247
  const packet = codePacket(work);
1170
1248
  const readOnly = parsePayload(work)?.read_only === true;
1171
1249
  const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
1172
- const finishWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"]));
1173
- const failWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]));
1250
+ const lifecycleCommands = commands ?? workerLifecycleCommands(context, work, run);
1251
+ const finishWorkCommand = lifecycleCommands.finish;
1252
+ const failWorkCommand = lifecycleCommands.fail;
1174
1253
  const mergeWork = parsePayload(work)?.kind === "merge";
1175
1254
  const writeBoundary = readOnly
1176
1255
  ? [`Review inputs under workspace_root (${run.workspace_root}) and run_home are read-only. After Work start, work from workspace_root and do not edit project source, PLAN, aspect maps, CODE evidence, or another Work's files.`, "Do not write inside workspace_root, including an isolated review copy. Write only this Work's declared result-input.json and explicitly assigned Work-owned evidence paths under run_home. Report changed or inaccessible review inputs as a blocker; do not repair them."]
@@ -1187,7 +1266,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
1187
1266
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
1188
1267
  if (packet)
1189
1268
  codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
1190
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), `Write the completed JSON to ${resultInputPath}. This is an attempt input, not the canonical result.json receipt. dd-flow validates the file and atomically stores the canonical receipt.`, "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `First write only the result JSON to ${resultInputPath}. Then run this standalone command:\n${finishWorkCommand}`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
1269
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), `Write the completed JSON to ${resultInputPath}. This is an attempt input, not the canonical result.json receipt. dd-flow validates the file and atomically stores the canonical receipt.`, "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, lifecycleRetryInstruction, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `First write only the result JSON to ${resultInputPath}. Then run this standalone command:\n${finishWorkCommand}`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
1191
1270
  }
1192
1271
  export function resultSchemaGuidance(work, runId) {
1193
1272
  const schema = work.result_schema;