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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +23 -8
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +3 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -1
  9. package/dist/protocol/local-files.js +18 -9
  10. package/dist/runtime/context.js +2 -2
  11. package/dist/services/cleanup.js +81 -32
  12. package/dist/services/code-checks.js +18 -19
  13. package/dist/services/continuation-outcome.js +1 -1
  14. package/dist/services/controller-fanout.js +12 -4
  15. package/dist/services/eval-snapshots.js +69 -41
  16. package/dist/services/execution-policy.js +12 -1
  17. package/dist/services/external-work-launch.js +2 -1
  18. package/dist/services/hooks.js +153 -43
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +5 -12
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +83 -88
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +22 -64
  38. package/dist/services/run-recovery-runtime.js +25 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { withProjectionLock } from "../storage/projection-lock.js";
6
+ import { reconcileRunStageReports } from "./stage-report-renderer.js";
5
7
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
8
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
9
  import { AppError } from "../shared/errors.js";
@@ -15,6 +17,7 @@ import { refreshRunSessionProjection } from "./run-projection.js";
15
17
  import { resolveCanonRoot } from "./canon.js";
16
18
  import { bindCurrentEngineToRun } from "./engines.js";
17
19
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
20
+ import { loadVnextWorkspacePolicy } from "./vnext-workspace-policy.js";
18
21
  import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
19
22
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
20
23
  import { publicSessionIdentity } from "./session-identity.js";
@@ -144,7 +147,6 @@ export function startFlowRun(context, input, prepared = prepareFlowRunStart(cont
144
147
  fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
145
148
  bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
146
149
  const persistedIndex = persistedVnextIndex(index);
147
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
148
150
  context.db.run(`INSERT INTO runs
149
151
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
150
152
  status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
@@ -178,8 +180,9 @@ export function startFlowRun(context, input, prepared = prepareFlowRunStart(cont
178
180
  eventType: "flow_run.started",
179
181
  payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
180
182
  });
181
- appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
182
- appendRunTimeline(runHome, {
183
+ context.db.afterCommit(() => publishRunProjections(context, project.id, runId));
184
+ appendRunTimeline(context, project.id, runId, { at: now, type: "run_started", run_id: runId });
185
+ appendRunTimeline(context, project.id, runId, {
183
186
  at: now,
184
187
  type: "flow_flags_resolved",
185
188
  run_id: runId,
@@ -192,7 +195,7 @@ export function startFlowRun(context, input, prepared = prepareFlowRunStart(cont
192
195
  function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
193
196
  const profile = loadVnextExecutionProfile(projectRoot);
194
197
  const sourcePath = executionProfilePath(projectRoot);
195
- return freezeRunExecutionRouting(ddFlowHome, {
198
+ const frozen = freezeRunExecutionRouting(ddFlowHome, {
196
199
  schema_id: "dd-flow/run-execution-profile@1",
197
200
  source_path: sourcePath,
198
201
  source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
@@ -208,6 +211,12 @@ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
208
211
  ...(profile.execution ? { execution: profile.execution } : {})
209
212
  }
210
213
  }, routingInput);
214
+ if (frozen.settings.merge_mode === "same_session" && frozen.settings.stop_target === "merge_completed"
215
+ && fs.existsSync(path.join(projectRoot, ".memory-bank", "dd-flow", "project-workspace.json"))
216
+ && loadVnextWorkspacePolicy(projectRoot).workspace.route === "feature_worktree") {
217
+ throw new AppError("merge_session_workspace_conflict", "Feature-worktree MERGE targets the integration checkout; use server MERGE or a qualified new Session", 2, { project_root: projectRoot });
218
+ }
219
+ return frozen;
211
220
  }
212
221
  /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
213
222
  export function freezeRunExecutionRouting(ddFlowHome, base, input) {
@@ -238,7 +247,7 @@ export function importFlowRunExecutionProfile(context, input) {
238
247
  index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
239
248
  index.updated_at = context.now();
240
249
  persistRunState(context, project, run, index);
241
- appendRunTimeline(run.run_root, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
250
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
242
251
  }
243
252
  /**
244
253
  * A vNext route may materialize a feature checkout after its semantic
@@ -259,7 +268,7 @@ export function rebindFlowRunWorkspace(context, input) {
259
268
  context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
260
269
  persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
261
270
  appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
262
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
271
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
263
272
  return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
264
273
  }
265
274
  function loadUpgradeFlowContract(context, projectRoot) {
@@ -307,7 +316,7 @@ function runContinuation(context, run, index) {
307
316
  const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
308
317
  const profile = index.execution_profile;
309
318
  const execution = profile?.settings.execution ? profile.agent_profiles
310
- ? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
319
+ ? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
311
320
  : { status: "unfrozen", reason: "execution_profile_not_frozen" }
312
321
  : undefined;
313
322
  return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
@@ -362,7 +371,7 @@ export function setFlowRunConfig(context, input) {
362
371
  index.updated_at = now;
363
372
  persistRunState(context, project, run, index);
364
373
  appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
365
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
374
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
366
375
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
367
376
  }
368
377
  export function prepareFlowRunConfig(context, input) {
@@ -404,8 +413,8 @@ export function getFlowRunFlagsStatus(context, input) {
404
413
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
405
414
  const run = resolveRun(context, project.id, input.runId);
406
415
  const index = authoritativeIndex(run);
407
- const runtime = readRuntimeSnapshot(run.runtime_path);
408
- const flags = runtime?.flow_flags ?? index.flow_flags;
416
+ const legacyRuntime = index.flow_flags ? null : readRuntimeSnapshot(run.runtime_path);
417
+ const flags = index.flow_flags ?? legacyRuntime?.flow_flags;
409
418
  if (!flags) {
410
419
  return {
411
420
  ok: true,
@@ -423,7 +432,7 @@ export function getFlowRunFlagsStatus(context, input) {
423
432
  snapshot_revision: flags.snapshot_revision,
424
433
  snapshot_checksum: flags.snapshot_checksum,
425
434
  flow_flags: flags,
426
- runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
435
+ runtime_revision: index.runtime_revision ?? legacyRuntime?.runtime_revision ?? null
427
436
  };
428
437
  }
429
438
  export function reviseFlowRunFlags(context, input) {
@@ -431,22 +440,23 @@ export function reviseFlowRunFlags(context, input) {
431
440
  const run = resolveRun(context, project.id, input.runId);
432
441
  assertRunMutationAllowed(context, project.id, run.id);
433
442
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
434
- context.db.beginWriteTransaction();
435
- let committed = false;
436
- try {
443
+ const priorMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
444
+ if (priorMutation)
445
+ return { ...JSON.parse(priorMutation.result_json), idempotent: true };
446
+ // Canonical contract bytes are prepared before taking the writer. Revision
447
+ // and idempotency are compared again against SQL authority under the lock.
448
+ const contract = loadProjectFlowContract(run.project_root);
449
+ return context.db.writeTransaction(() => {
437
450
  const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
438
451
  if (existingMutation) {
439
452
  const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
440
- context.db.exec("COMMIT");
441
- committed = true;
442
453
  return result;
443
454
  }
444
455
  // Read the authority only after the write lock: two callers with the same
445
456
  // expected revision must not both pass the compare-and-set check.
446
457
  const lockedRun = requireRunById(context, project.id, run.id);
447
458
  const index = authoritativeIndex(lockedRun);
448
- const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
449
- const current = runtime?.flow_flags ?? index.flow_flags;
459
+ const current = index.flow_flags;
450
460
  if (!current) {
451
461
  throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
452
462
  }
@@ -463,7 +473,6 @@ export function reviseFlowRunFlags(context, input) {
463
473
  });
464
474
  }
465
475
  const now = context.now();
466
- const contract = loadProjectFlowContract(lockedRun.project_root);
467
476
  const next = resolveFlowFlags(contract, {
468
477
  flowKind: index.flow_kind,
469
478
  preset: input.preset ?? current.preset.applied,
@@ -519,14 +528,12 @@ export function reviseFlowRunFlags(context, input) {
519
528
  context.db.run(`INSERT INTO flow_run_flag_mutations
520
529
  (project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
521
530
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
522
- context.db.exec("COMMIT");
523
- committed = true;
524
531
  appendAudit(context, {
525
532
  projectId: project.id,
526
533
  eventType: "flow_run.flags_revised",
527
534
  payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
528
535
  });
529
- appendRunTimeline(runArtifactRoot(run), {
536
+ appendRunTimeline(context, project.id, run.id, {
530
537
  at: now,
531
538
  type: "flow_flags_revised",
532
539
  run_id: run.id,
@@ -536,12 +543,7 @@ export function reviseFlowRunFlags(context, input) {
536
543
  ...(reason ? { reason } : {})
537
544
  });
538
545
  return result;
539
- }
540
- catch (error) {
541
- if (!committed)
542
- context.db.exec("ROLLBACK");
543
- throw error;
544
- }
546
+ }, "runs.reviseFlowFlags");
545
547
  }
546
548
  export function listFlowRuns(context, input) {
547
549
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -556,9 +558,9 @@ export function prepareFlowRunStageAttachment(context, input) {
556
558
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
557
559
  const run = resolveRun(context, project.id, input.runId);
558
560
  assertRunMutationAllowed(context, project.id, run.id);
559
- const index = authoritativeIndex(run);
560
561
  const stage = requiredPlain(input.stage, "stage");
561
562
  const dir = prepareStageDirectory(input.dir);
563
+ const index = authoritativeIndex(run);
562
564
  const status = parseStageStatus(input.status);
563
565
  const existing = index.stage_runs.find((item) => item.stage === stage);
564
566
  assertVnextStageStart(run, index, stage, status, existing);
@@ -567,9 +569,12 @@ export function prepareFlowRunStageAttachment(context, input) {
567
569
  export function attachFlowRunStage(context, input) {
568
570
  // Mutable stage state is re-read immediately before archiving/persisting.
569
571
  const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
572
+ const runRoot = runArtifactRoot(run);
573
+ if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)))
574
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
570
575
  const now = context.now();
571
576
  if (status === "running" && existing) {
572
- archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
577
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
573
578
  }
574
579
  const stageRun = {
575
580
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -597,7 +602,8 @@ export function attachFlowRunStage(context, input) {
597
602
  eventType: "flow_run.stage_attached",
598
603
  payload: { run_id: run.id, stage, dir, status }
599
604
  });
600
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
605
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
606
+ context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
601
607
  // A stage transition must not rewrite every Session in the RUN. A RUN may
602
608
  // contain independent reviewer/worker sessions; their stage is set by the
603
609
  // stage/work binding that actually owns that Session.
@@ -608,7 +614,7 @@ export function attachFlowRunStage(context, input) {
608
614
  }
609
615
  /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
610
616
  export function withStageSettlement(context, operation) {
611
- return context.db.writeTransaction(operation);
617
+ return context.db.writeTransaction(operation, "stage.settle");
612
618
  }
613
619
  export function prepareFlowRunStageCompletion(context, input) {
614
620
  for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
@@ -634,58 +640,74 @@ export function prepareFlowRunStageCompletion(context, input) {
634
640
  if (!existing) {
635
641
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
636
642
  }
637
- return { project, run, index, stage, status, existing };
638
- }
639
- export function completeFlowRunStage(context, input) {
640
- const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
641
- const now = context.now();
642
643
  const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
643
- const stageRun = {
644
- ...existing,
645
- status,
646
- ...(input.stageReport ? { stage_report: input.stageReport } : {}),
647
- ...(input.data ? { data: input.data } : {}),
648
- ...(dataPath && fs.existsSync(dataPath) ? { data_sha256: crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") } : {}),
649
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
650
- ...(input.report ? { report: input.report } : {}),
651
- ...(input.usage ? { usage: input.usage } : {}),
652
- ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
653
- updated_at: now,
654
- ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
655
- ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
656
- : {})
657
- };
658
- upsertStage(index, stageRun);
659
- if (status === "waiting_for_user") {
660
- index.status = "waiting_for_user";
661
- index.verdict = "waiting_for_user";
662
- }
663
- else if (status === "blocked" || status === "failed") {
664
- index.status = status;
665
- index.verdict = status;
666
- }
667
- else if (status === "done" && index.status === "waiting_for_user") {
668
- index.status = "running";
669
- index.verdict = "running";
670
- }
671
- index.current_stage = stage;
672
- index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
673
- index.updated_at = now;
674
- index.execution = {
675
- project_root: index.execution?.project_root ?? run.project_root,
676
- workspace_root: index.execution?.workspace_root ?? run.workspace_root,
677
- git: gitFacts(run.workspace_root)
678
- };
679
- persistRunState(context, project, run, index);
680
- refreshRunSessionProjection(context, project.id, run.id);
681
- appendAudit(context, {
682
- projectId: project.id,
683
- eventType: "flow_run.stage_completed",
684
- payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
685
- });
686
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
644
+ const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
645
+ if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
646
+ throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
647
+ return { project, run, index, stage, status, existing, dataSha256, git: input.git ?? gitFacts(run.workspace_root), generation: ["done", "failed", "skipped"].includes(status) ? assertStageSettlementAllowed(context, project.id, run.id, stage) : assertRunMutationAllowed(context, project.id, run.id) };
648
+ }
649
+ export function completeFlowRunStage(context, input, prepared = prepareFlowRunStageCompletion(context, input)) {
650
+ const { project, run, stage, status, dataSha256, git, generation } = prepared;
651
+ const completed = context.db.writeTransaction(() => {
652
+ if (["done", "failed", "skipped"].includes(status))
653
+ assertStageSettlementAllowed(context, project.id, run.id, stage, generation);
654
+ else
655
+ assertRunMutationAllowed(context, project.id, run.id, generation);
656
+ const latest = requireRunById(context, project.id, run.id);
657
+ const index = authoritativeIndex(latest);
658
+ const existing = index.stage_runs.find((item) => item.stage === stage);
659
+ if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
660
+ throw new AppError("stage_revision_changed", "Stage attempt changed during finish preparation", 1, { run_id: run.id, stage, expected_attempt: prepared.existing.attempt, actual_attempt: existing?.attempt ?? null });
661
+ }
662
+ const now = context.now();
663
+ const stageRun = {
664
+ ...existing,
665
+ status,
666
+ ...(input.stageReport ? { stage_report: input.stageReport } : {}),
667
+ ...(input.data ? { data: input.data } : {}),
668
+ ...(dataSha256 ? { data_sha256: dataSha256 } : {}),
669
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
670
+ ...(input.report ? { report: input.report } : {}),
671
+ ...(input.usage ? { usage: input.usage } : {}),
672
+ ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
673
+ updated_at: now,
674
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
675
+ ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git }
676
+ : {})
677
+ };
678
+ upsertStage(index, stageRun);
679
+ if (status === "waiting_for_user") {
680
+ index.status = "waiting_for_user";
681
+ index.verdict = "waiting_for_user";
682
+ }
683
+ else if (status === "blocked" || status === "failed") {
684
+ index.status = status;
685
+ index.verdict = status;
686
+ }
687
+ else if (status === "done" && index.status === "waiting_for_user") {
688
+ index.status = "running";
689
+ index.verdict = "running";
690
+ }
691
+ index.current_stage = stage;
692
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
693
+ index.updated_at = now;
694
+ index.execution = {
695
+ project_root: index.execution?.project_root ?? run.project_root,
696
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
697
+ git
698
+ };
699
+ persistRunState(context, project, latest, index);
700
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
701
+ appendAudit(context, {
702
+ projectId: project.id,
703
+ eventType: "flow_run.stage_completed",
704
+ payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
705
+ });
706
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
707
+ return { index, stageRun };
708
+ }, "runs.completeFlowRunStage");
687
709
  const updatedRun = requireRunById(context, project.id, run.id);
688
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
710
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
689
711
  }
690
712
  /** Pause one live stage without completing it or creating a new attempt. */
691
713
  export function pauseFlowRunStage(context, input) {
@@ -720,7 +742,7 @@ export function pauseFlowRunStage(context, input) {
720
742
  persistRunState(context, project, run, index);
721
743
  refreshRunSessionProjection(context, project.id, run.id);
722
744
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_paused", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, reason: "waiting_for_user" } });
723
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
745
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
724
746
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
725
747
  }
726
748
  /** Resume the same stage, Work and attempt after a user answer is recorded. */
@@ -751,8 +773,8 @@ export function resumeFlowRunStage(context, input) {
751
773
  persistRunState(context, project, run, index);
752
774
  refreshRunSessionProjection(context, project.id, run.id);
753
775
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_resumed", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath } });
754
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
755
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
776
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
777
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
756
778
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
757
779
  }
758
780
  /** Pause the current stage for a non-user runtime blocker. */
@@ -786,7 +808,7 @@ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStage
786
808
  persistRunState(context, project, run, index);
787
809
  refreshRunSessionProjection(context, project.id, run.id);
788
810
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_blocked", payload: { run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, retryable: input.retryable } });
789
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
811
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
790
812
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
791
813
  }
792
814
  /** Continue the same stage after its non-user blocker was repaired externally. */
@@ -818,7 +840,7 @@ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunSta
818
840
  persistRunState(context, project, run, index);
819
841
  refreshRunSessionProjection(context, project.id, run.id);
820
842
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
821
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
843
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
822
844
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
823
845
  }
824
846
  function operationalRunStatus(context, projectId, runId) {
@@ -904,7 +926,7 @@ export function completeFlowRun(context, input) {
904
926
  eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
905
927
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
906
928
  });
907
- appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
929
+ appendRunTimeline(context, project.id, run.id, { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
908
930
  const updatedRun = requireRunById(context, project.id, run.id);
909
931
  return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
910
932
  }
@@ -925,7 +947,7 @@ export function advanceFlowRun(context, input) {
925
947
  index.updated_at = now;
926
948
  persistRunState(context, project, run, index);
927
949
  appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
928
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
950
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
929
951
  const updatedRun = requireRunById(context, project.id, run.id);
930
952
  return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
931
953
  }
@@ -990,7 +1012,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
990
1012
  }));
991
1013
  persistRunState(context, project, run, index);
992
1014
  appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
993
- appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1015
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
994
1016
  return { run_id: run.id, cycle };
995
1017
  }
996
1018
  function closeOpenStagesForOverride(index, status, now) {
@@ -1039,7 +1061,7 @@ function closeRunOrchestrators(context, projectId, run, status, now) {
1039
1061
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
1040
1062
  WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
1041
1063
  for (const session of active) {
1042
- appendRunTimeline(runArtifactRoot(run), {
1064
+ appendRunTimeline(context, projectId, run.id, {
1043
1065
  at: now,
1044
1066
  type: "session_stopped",
1045
1067
  run_id: run.id,
@@ -1162,7 +1184,7 @@ export function getFlowRunSessions(context, input) {
1162
1184
  }
1163
1185
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
1164
1186
  const run = requireRunById(context, projectId, runId);
1165
- appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
1187
+ appendRunTimeline(context, projectId, run.id, { run_id: run.id, ...event });
1166
1188
  }
1167
1189
  function guidanceForRun(context, run, index) {
1168
1190
  if (run.flow_kind === "vnext_specify")
@@ -1262,39 +1284,26 @@ function persistRunState(context, project, run, index, options = {}) {
1262
1284
  if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1263
1285
  index.status = "paused";
1264
1286
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1265
- const previousRuntime = readRuntimeSnapshot(runtimePath);
1266
- const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
1267
- index.runtime_revision = runtimeRevision;
1268
- const history = [...(previousRuntime?.flag_revision_history ?? [])];
1269
- if (options.flagRevision) {
1270
- history.push(options.flagRevision);
1271
- }
1272
- const persistedIndex = persistedVnextIndex(index);
1273
- ensureDir(path.dirname(runtimePath));
1274
- context.db.exec("SAVEPOINT persist_run_state");
1275
- try {
1276
- context.db.run(`UPDATE runs
1277
- SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1278
- updated_at = ?, completed_at = ?
1279
- WHERE project_id = ? AND id = ?`, [
1280
- index.status,
1281
- index.verdict,
1282
- index.next_action,
1283
- JSON.stringify(persistedIndex),
1284
- runtimePath,
1285
- runtimePath,
1286
- index.updated_at,
1287
- index.completed_at ?? null,
1288
- project.id,
1289
- run.id
1290
- ]);
1291
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1292
- context.db.exec("RELEASE persist_run_state");
1293
- }
1294
- catch (error) {
1295
- context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1296
- throw error;
1297
- }
1287
+ context.db.writeTransaction(() => {
1288
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
1289
+ if (!stored)
1290
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: run.id });
1291
+ const previous = JSON.parse(stored.index_json);
1292
+ const actualRevision = previous.runtime_revision ?? 0;
1293
+ const expectedRevision = index.runtime_revision ?? 0;
1294
+ if (actualRevision !== expectedRevision)
1295
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id, expected_revision: expectedRevision, actual_revision: actualRevision });
1296
+ index.runtime_revision = actualRevision + 1;
1297
+ const history = [...(previous.flag_revision_history ?? [])];
1298
+ if (options.flagRevision)
1299
+ history.push(options.flagRevision);
1300
+ index.flag_revision_history = history.slice(-32);
1301
+ const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1302
+ WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
1303
+ if (updated.changes !== 1)
1304
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
1305
+ context.db.afterCommit(() => publishRunProjections(context, project.id, run.id));
1306
+ }, "runs.persistRunState");
1298
1307
  }
1299
1308
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1300
1309
  function persistedVnextIndex(index) {
@@ -1350,26 +1359,50 @@ function setRunVariable(context, input, value, prepared = prepareRunVariable(con
1350
1359
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
1351
1360
  index.updated_at = context.now();
1352
1361
  persistRunState(context, project, run, index);
1353
- appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1362
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1354
1363
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
1355
1364
  }
1356
- function appendRunTimeline(runHome, event) {
1357
- ensureDir(runHome);
1358
- const timelinePath = path.join(runHome, "timeline.jsonl");
1359
- let sequence = 0;
1360
- if (fs.existsSync(timelinePath)) {
1361
- const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
1362
- const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
1363
- sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
1364
- }
1365
- const safeEvent = sanitizeTimelineValue(event);
1366
- const enriched = {
1367
- event_id: crypto.randomUUID(),
1368
- sequence: sequence + 1,
1369
- at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
1370
- ...safeEvent
1371
- };
1372
- fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
1365
+ function appendRunTimeline(context, projectId, runId, event) {
1366
+ context.db.writeTransaction(() => {
1367
+ const last = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id DESC LIMIT 1", [projectId, runId]);
1368
+ const sequence = last ? Number(JSON.parse(last.payload_json).sequence) + 1 : 1;
1369
+ const safeEvent = sanitizeTimelineValue(event);
1370
+ const enriched = { event_id: crypto.randomUUID(), sequence, at: typeof safeEvent.at === "string" ? safeEvent.at : context.now(), ...safeEvent };
1371
+ appendAudit(context, { projectId, eventType: "flow_run.timeline", payload: { run_id: runId, sequence, event: enriched } });
1372
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
1373
+ }, "runs.appendRunTimeline");
1374
+ }
1375
+ /** Rebuild projections from committed SQL after taking a per-RUN filesystem lock. */
1376
+ export function publishRunProjections(context, projectId, runId) {
1377
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1378
+ if (!run)
1379
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1380
+ const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, projectId, runId);
1381
+ ensureDir(path.dirname(runtimePath));
1382
+ withProjectionLock(`${runtimePath}.publish-lock`, () => {
1383
+ // A delayed publisher must observe the latest commit after acquiring its lock.
1384
+ const latest = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1385
+ if (!latest)
1386
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1387
+ const index = authoritativeIndex(latest);
1388
+ const mutations = context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]);
1389
+ const history = mutations.reverse().map((row) => ({
1390
+ revision: row.resulting_revision,
1391
+ checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
1392
+ at: row.created_at,
1393
+ idempotency_key: row.idempotency_key
1394
+ }));
1395
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, index.runtime_revision ?? 1, history.length ? history : index.flag_revision_history ?? []));
1396
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id", [projectId, runId]);
1397
+ if (latest.run_root) {
1398
+ const timelinePath = path.join(latest.run_root, "timeline.jsonl");
1399
+ ensureDir(path.dirname(timelinePath));
1400
+ const bytes = rows.map((row) => JSON.stringify(JSON.parse(row.payload_json).event)).join("\n");
1401
+ const temporary = `${timelinePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1402
+ fs.writeFileSync(temporary, bytes ? `${bytes}\n` : "");
1403
+ fs.renameSync(temporary, timelinePath);
1404
+ }
1405
+ });
1373
1406
  }
1374
1407
  function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
1375
1408
  const rest = { ...index };
@@ -1604,6 +1637,16 @@ export function archiveExistingStageAttempt(context, projectId, runId, runRoot,
1604
1637
  }
1605
1638
  const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1606
1639
  const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1640
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1641
+ const prior = row ? JSON.parse(row.index_json) : null;
1642
+ const live = prior?.stage_runs.find((stage) => stage.dir === stageDir);
1643
+ const generation = row ? assertRunMutationAllowed(context, projectId, runId) : 0;
1644
+ if (pending && (pending.project_id && pending.project_id !== projectId || pending.run_id && pending.run_id !== runId))
1645
+ throw new AppError("archive_owner_changed", "Archive journal belongs to another RUN", 1, { journal });
1646
+ if (pending?.generation !== undefined && pending.generation !== generation)
1647
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal, expected_generation: pending.generation, generation });
1648
+ if (pending?.prior_attempt && live?.attempt !== pending.prior_attempt && live?.attempt !== pending.next_attempt)
1649
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal, prior_attempt: pending.prior_attempt, actual_attempt: live?.attempt ?? null });
1607
1650
  const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1608
1651
  if (entries.length === 0 && !pending) {
1609
1652
  return;
@@ -1613,10 +1656,9 @@ export function archiveExistingStageAttempt(context, projectId, runId, runRoot,
1613
1656
  attempt += 1;
1614
1657
  }
1615
1658
  const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1659
+ const intent = pending ?? { archiveDir, entries, stage_run: live ?? null, project_id: projectId, run_id: runId, prior_attempt: live?.attempt ?? null, next_attempt: nextAttempt(live), runtime_revision: prior?.runtime_revision ?? 0, generation };
1616
1660
  if (!pending) {
1617
- const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1618
- const prior = stored ? JSON.parse(stored.index_json) : null;
1619
- writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1661
+ writeJsonFile(journal, intent);
1620
1662
  }
1621
1663
  ensureDir(archiveDir);
1622
1664
  for (const entry of entries) {
@@ -1630,20 +1672,36 @@ export function archiveExistingStageAttempt(context, projectId, runId, runRoot,
1630
1672
  else if (!fs.existsSync(target))
1631
1673
  throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1632
1674
  }
1633
- const oldPrefix = `${currentStageDir}${path.sep}`;
1634
- const newPrefix = `${archiveDir}${path.sep}`;
1635
- const rewrite = (table, column, where, values) => {
1636
- context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1637
- };
1638
- const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1639
- rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1640
- rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1641
- const receiptScope = "project_id = ? AND run_id = ?";
1642
- for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1643
- rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1644
- // Retain the original stage facts with its immutable artifacts; current
1645
- // stage_runs stays a projection of the current attempt, not a second ledger.
1646
- fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1675
+ context.db.writeTransaction(() => {
1676
+ const current = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1677
+ const currentIndex = current ? JSON.parse(current.index_json) : null;
1678
+ const currentAttempt = currentIndex?.stage_runs.find((stage) => stage.dir === stageDir)?.attempt;
1679
+ if (currentIndex && intent.runtime_revision !== undefined && (currentIndex.runtime_revision ?? 0) !== intent.runtime_revision && currentAttempt !== intent.next_attempt)
1680
+ throw new AppError("archive_owner_changed", "RUN revision changed while an archive was pending", 1, { journal });
1681
+ if (intent.prior_attempt && currentAttempt !== intent.prior_attempt && currentAttempt !== intent.next_attempt)
1682
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal });
1683
+ if (row && generation !== assertRunMutationAllowed(context, projectId, runId))
1684
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal });
1685
+ if (intent.next_attempt === undefined || currentAttempt !== intent.next_attempt) {
1686
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1687
+ const newPrefix = `${archiveDir}${path.sep}`;
1688
+ const rewrite = (table, column, where, values) => {
1689
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1690
+ };
1691
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1692
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1693
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1694
+ const receiptScope = "project_id = ? AND run_id = ?";
1695
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1696
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1697
+ }
1698
+ context.db.afterCommit(() => {
1699
+ if (!fs.existsSync(journal))
1700
+ return;
1701
+ // Retain original Stage facts with the immutable attempt after SQL paths commit.
1702
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1703
+ });
1704
+ });
1647
1705
  }
1648
1706
  function writeJsonFile(file, value) {
1649
1707
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;