@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.133

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 (204) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +101 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +306 -0
  6. package/dist/cli/help.js +88 -12
  7. package/dist/cli/hook-ingress.js +78 -0
  8. package/dist/cli/input-preparation.js +116 -0
  9. package/dist/cli/native-hook-ingress.js +80 -0
  10. package/dist/cli/run-cli.js +1465 -450
  11. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  12. package/dist/cli/runtime-maintenance-ingress.js +52 -0
  13. package/dist/cli/runtime-process.js +153 -0
  14. package/dist/cli.js +17 -2
  15. package/dist/domain/stage-catalog.js +1 -1
  16. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  17. package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
  18. package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
  19. package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
  20. package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
  21. package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
  22. package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
  23. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  24. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  25. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  26. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  27. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  28. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  29. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  30. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  31. package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
  32. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  33. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -0
  34. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  35. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  36. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +190 -0
  37. package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
  39. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
  40. package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
  41. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
  42. package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
  43. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
  44. package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
  45. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
  46. package/dist/harness-runtime/lib/dd-zcode.mjs +1108 -0
  47. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  48. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  49. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  50. package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
  51. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  52. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  53. package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
  54. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  55. package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  58. package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
  59. package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
  60. package/dist/harness-runtime/lib/model-observations.mjs +101 -0
  61. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  62. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  63. package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
  64. package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
  65. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  66. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  67. package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
  68. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  69. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  70. package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
  71. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  72. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  73. package/dist/harness-runtime/lib/process-json.d.mts +5 -0
  74. package/dist/harness-runtime/lib/process-json.mjs +201 -0
  75. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  76. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  77. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  78. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  79. package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
  80. package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
  81. package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
  82. package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
  83. package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
  84. package/dist/protocol/local-files.js +18 -9
  85. package/dist/runtime/context.js +8 -4
  86. package/dist/schemas/agent-profile.schema.json +1 -1
  87. package/dist/schemas/code-review-result.schema.json +1 -1
  88. package/dist/schemas/code-verification.schema.json +7 -5
  89. package/dist/schemas/code-work-batch.schema.json +2 -2
  90. package/dist/schemas/flow-run.schema.json +2 -0
  91. package/dist/schemas/plan-review-decision.schema.json +3 -3
  92. package/dist/schemas/plan-review-result.schema.json +2 -2
  93. package/dist/schemas/run-control-receipt.schema.json +118 -0
  94. package/dist/schemas/run-control-request.schema.json +36 -0
  95. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  96. package/dist/services/branch-context.js +3 -3
  97. package/dist/services/canon.js +11 -8
  98. package/dist/services/check-runner-evidence.js +121 -0
  99. package/dist/services/cleanup.js +133 -40
  100. package/dist/services/cli-operation-classifier.js +22 -24
  101. package/dist/services/code-checks.js +851 -109
  102. package/dist/services/codex-capacity.js +367 -0
  103. package/dist/services/codex-hook-delivery.js +32 -0
  104. package/dist/services/command-context.js +80 -0
  105. package/dist/services/compatibility-preflight.js +13 -3
  106. package/dist/services/config.js +11 -8
  107. package/dist/services/continuation-outcome.js +20 -0
  108. package/dist/services/controller-fanout.js +119 -0
  109. package/dist/services/dashboard.js +135 -56
  110. package/dist/services/engines.js +245 -119
  111. package/dist/services/eval-snapshots.js +1146 -105
  112. package/dist/services/execution-policy.js +181 -0
  113. package/dist/services/external-work-launch.js +230 -0
  114. package/dist/services/harness-adapter.js +329 -36
  115. package/dist/services/harness-config.js +2 -0
  116. package/dist/services/hooks.js +747 -325
  117. package/dist/services/lanes.js +78 -65
  118. package/dist/services/lifecycle-command.js +1 -288
  119. package/dist/services/lifecycle-contract.js +1 -0
  120. package/dist/services/lifecycle-invocations.js +1774 -0
  121. package/dist/services/managed-daemon-binding.js +38 -0
  122. package/dist/services/managed-lease-monitor.js +133 -0
  123. package/dist/services/managed-processes.js +409 -51
  124. package/dist/services/merge-queue.js +228 -149
  125. package/dist/services/merge-server.js +163 -35
  126. package/dist/services/migrations.js +37 -16
  127. package/dist/services/native-daemon-history.js +49 -0
  128. package/dist/services/native-session-control.js +39 -0
  129. package/dist/services/plan-runtime.js +26 -11
  130. package/dist/services/plans.js +28 -25
  131. package/dist/services/projects.js +20 -15
  132. package/dist/services/prompts.js +28 -17
  133. package/dist/services/protocols.js +163 -87
  134. package/dist/services/recovery-observation-budget.js +121 -0
  135. package/dist/services/recovery-snapshot-database.js +145 -0
  136. package/dist/services/repair-intents.js +42 -0
  137. package/dist/services/review-copy.js +157 -0
  138. package/dist/services/review-deferral.js +23 -0
  139. package/dist/services/run-control-receipt.js +56 -0
  140. package/dist/services/run-control-worker.js +411 -0
  141. package/dist/services/run-control.js +985 -0
  142. package/dist/services/run-controller-adapter.js +399 -0
  143. package/dist/services/run-controller-capture.js +179 -0
  144. package/dist/services/run-controller-process.js +187 -0
  145. package/dist/services/run-controller-recovery.js +289 -0
  146. package/dist/services/run-controller-state.js +97 -0
  147. package/dist/services/run-controller.js +1035 -0
  148. package/dist/services/run-engine-bindings.js +1 -1
  149. package/dist/services/run-fork.js +147 -0
  150. package/dist/services/run-observations.js +152 -0
  151. package/dist/services/run-projection.js +22 -64
  152. package/dist/services/run-recovery-guard.js +75 -0
  153. package/dist/services/run-recovery-runtime.js +112 -0
  154. package/dist/services/run-recovery.js +349 -0
  155. package/dist/services/runs.js +558 -217
  156. package/dist/services/runtime-budget.js +430 -0
  157. package/dist/services/runtime-command.js +43 -0
  158. package/dist/services/runtime-scope-capture.js +52 -0
  159. package/dist/services/runtime-scope-control.js +468 -0
  160. package/dist/services/runtime-scope-resume.js +599 -0
  161. package/dist/services/runtime-scope-stop.js +180 -0
  162. package/dist/services/runtime-scope-worker.js +287 -0
  163. package/dist/services/runtime-service.js +96 -0
  164. package/dist/services/schema-validation.js +10 -8
  165. package/dist/services/scope-writer-cleanup.js +27 -0
  166. package/dist/services/sessions.js +35 -69
  167. package/dist/services/stage-blocker.js +17 -7
  168. package/dist/services/stage-context.js +185 -19
  169. package/dist/services/stage-lifecycle.js +98 -107
  170. package/dist/services/stage-pause.js +89 -75
  171. package/dist/services/stage-report-renderer.js +158 -6
  172. package/dist/services/stage-work-graph.js +35 -0
  173. package/dist/services/tooling.js +32 -23
  174. package/dist/services/usage.js +49 -101
  175. package/dist/services/verification-matrix.js +374 -0
  176. package/dist/services/vnext-code-review.js +222 -87
  177. package/dist/services/vnext-code.js +324 -251
  178. package/dist/services/vnext-contracts.js +10 -1
  179. package/dist/services/vnext-execution-profile.js +79 -4
  180. package/dist/services/vnext-fanout.js +177 -17
  181. package/dist/services/vnext-merge.js +335 -113
  182. package/dist/services/vnext-plan-review.js +276 -126
  183. package/dist/services/vnext-plan.js +227 -102
  184. package/dist/services/vnext-protocolize.js +335 -82
  185. package/dist/services/vnext-specify.js +119 -81
  186. package/dist/services/work-registry.js +1038 -192
  187. package/dist/services/workspace-bootstrap.js +87 -0
  188. package/dist/services/workspace-files.js +85 -0
  189. package/dist/services/worktrees.js +98 -44
  190. package/dist/shared/entity-references.js +8 -0
  191. package/dist/shared/errors.js +17 -0
  192. package/dist/storage/agent-output.js +26 -0
  193. package/dist/storage/database.js +781 -37
  194. package/dist/storage/foreign-key-transaction.js +39 -0
  195. package/dist/storage/immutable-input.js +45 -0
  196. package/dist/storage/import-guard.js +102 -0
  197. package/dist/storage/native-cleanup.js +52 -0
  198. package/dist/storage/paths.js +17 -4
  199. package/dist/storage/projection-lock.js +73 -0
  200. package/dist/storage/work-references.js +23 -0
  201. package/dist/storage/writer-contract.js +77 -0
  202. package/dist/storage/writer-migration.js +85 -0
  203. package/package.json +30 -13
  204. package/tools/repair-paused-run-status.mjs +84 -0
@@ -2,10 +2,12 @@ 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";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
10
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
11
  import { appendAudit } from "./audit.js";
10
12
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
13
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -14,9 +16,12 @@ import { recalculateRunUsage } from "./usage.js";
14
16
  import { refreshRunSessionProjection } from "./run-projection.js";
15
17
  import { resolveCanonRoot } from "./canon.js";
16
18
  import { bindCurrentEngineToRun } from "./engines.js";
17
- import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
19
+ import { assertOperationalDecisionSource, executionProfilePath, loadVnextExecutionProfileSnapshot } from "./vnext-execution-profile.js";
20
+ import { loadVnextWorkspacePolicy } from "./vnext-workspace-policy.js";
21
+ import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
18
22
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
23
  import { publicSessionIdentity } from "./session-identity.js";
24
+ import { flowCommand } from "./stage-pause.js";
20
25
  const runSchemaId = "dd-flow/flow-run@3";
21
26
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
27
  const runIdType = "RUN";
@@ -35,22 +40,26 @@ const allowedRunFlowKinds = [
35
40
  "review",
36
41
  "custom"
37
42
  ];
38
- export function startFlowRun(context, input) {
43
+ export function prepareFlowRunStart(context, input) {
44
+ const subjectId = requiredPlain(input.subjectId, "subject-id");
45
+ const subjectType = requiredPlain(input.subjectType, "subject-type");
46
+ const slug = normalizeSlug(input.slug);
47
+ for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
48
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
49
+ throw new AppError("validation", `${name} must be a JSON object`, 2);
50
+ }
39
51
  const projectRoot = resolveProjectRoot(input.projectRoot);
52
+ const flowKind = parseRunFlowKind(input.flowKind);
53
+ const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
54
+ if (input.executionRoutingFile !== undefined && !vnext)
55
+ throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
56
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
57
+ const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput, context.now()) : undefined;
40
58
  const flowContract = input.flowKind === "mb-upgrade"
41
59
  ? loadUpgradeFlowContract(context, projectRoot)
42
60
  : loadProjectFlowContract(projectRoot);
43
- registerProject(context, { root: projectRoot });
44
- const project = requireProjectByRoot(context, projectRoot);
45
61
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
46
- const flowKind = parseRunFlowKind(input.flowKind);
47
- const slug = normalizeSlug(input.slug);
48
- const runId = nextRunId(context, project.id, slug);
49
- const { shortId } = parseFullEntityId(runId);
50
62
  const now = context.now();
51
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
52
- ? snapshotVnextExecutionProfile(projectRoot)
53
- : undefined;
54
63
  const flowFlags = resolveFlowFlags(flowContract, {
55
64
  flowKind,
56
65
  ...(input.preset ? { preset: input.preset } : {}),
@@ -60,17 +69,42 @@ export function startFlowRun(context, input) {
60
69
  snapshotRevision: 1,
61
70
  now
62
71
  });
72
+ return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
73
+ }
74
+ export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
75
+ const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
76
+ // Ordinary settings are already frozen by preparation; only scoped authority
77
+ // requires its live acceptance source to survive final binding unchanged.
78
+ if (executionProfile?.operational_decision) {
79
+ if (crypto.createHash("sha256").update(fs.readFileSync(executionProfile.source_path)).digest("hex") !== executionProfile.source_checksum)
80
+ throw new AppError("execution_profile_changed", "Execution profile changed after RUN preparation", 2);
81
+ if (executionProfile.operational_decision)
82
+ assertOperationalDecisionSource(executionProfile.operational_decision, projectRoot, context.now());
83
+ }
84
+ registerProject(context, { root: projectRoot });
85
+ const project = requireProjectByRoot(context, projectRoot);
86
+ const runId = nextRunId(context, project.id, slug);
87
+ const { shortId } = parseFullEntityId(runId);
63
88
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
64
89
  const runDirRelative = path.posix.join("runs", runId);
65
90
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
91
+ const policyBytes = executionProfile?.operational_decision ? fs.readFileSync(path.resolve(projectRoot, executionProfile.operational_decision.policy.path)) : undefined;
92
+ const policySource = policyBytes ? { path: "operational-policy.source.md", sha256: crypto.createHash("sha256").update(policyBytes).digest("hex") } : undefined;
93
+ if (policySource && policySource.sha256 !== executionProfile?.operational_decision?.policy.sha256)
94
+ throw new AppError("operational_decision_policy_changed", "Policy changed during final RUN binding", 2);
95
+ const binding = { project_id: project.id, run_id: runId, project_root: projectRoot, ...(executionProfile?.operational_decision?.scope.eval_id ? { eval_id: executionProfile.operational_decision.scope.eval_id } : {}), ...(executionProfile?.operational_decision?.scope.execution_id ? { execution_id: executionProfile.operational_decision.scope.execution_id } : {}) };
96
+ const operationalBytes = executionProfile ? `${JSON.stringify({ schema_id: "dd-flow/run-operational-contract@1", binding, settings: { merge_delivery: executionProfile.settings.merge_delivery, merge_cleanup: executionProfile.settings.merge_cleanup }, ...(executionProfile.operational_decision ? { decision: executionProfile.operational_decision, policy_source: policySource } : {}) }, null, 2)}\n` : undefined;
97
+ if (executionProfile && operationalBytes)
98
+ executionProfile.operational_contract = { path: "operational-contract.json", sha256: crypto.createHash("sha256").update(operationalBytes).digest("hex"), ...(policySource ? { policy_source: policySource } : {}), binding };
66
99
  const index = {
67
100
  schema_id: runSchemaId,
68
101
  run_id: runId,
69
102
  short_id: shortId,
70
103
  flow_kind: flowKind,
104
+ ...(["vnext_specify", "vnext_protocolize"].includes(flowKind) ? { verification_matrix_contract: "dd-flow/verification-matrix@1", final_check_coverage_contract: "dd-flow/final-check-coverage@1" } : {}),
71
105
  subject: {
72
- id: requiredPlain(input.subjectId, "subject-id"),
73
- title: requiredPlain(input.subjectId, "subject-id")
106
+ id: subjectId,
107
+ title: subjectId
74
108
  },
75
109
  project: {
76
110
  id: project.id,
@@ -126,9 +160,14 @@ export function startFlowRun(context, input) {
126
160
  };
127
161
  ensureDir(path.dirname(runtimePath));
128
162
  ensureDir(runHome);
163
+ if (policyBytes && policySource)
164
+ fs.writeFileSync(path.join(runHome, policySource.path), policyBytes, { flag: "wx" });
165
+ if (operationalBytes)
166
+ fs.writeFileSync(path.join(runHome, "operational-contract.json"), operationalBytes, { flag: "wx" });
167
+ if (routingInput)
168
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
129
169
  bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
130
170
  const persistedIndex = persistedVnextIndex(index);
131
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
132
171
  context.db.run(`INSERT INTO runs
133
172
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
134
173
  status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
@@ -141,7 +180,7 @@ export function startFlowRun(context, input) {
141
180
  project.root,
142
181
  workspaceRoot,
143
182
  flowKind,
144
- requiredPlain(input.subjectType, "subject-type"),
183
+ subjectType,
145
184
  index.subject.id,
146
185
  index.status,
147
186
  index.verdict,
@@ -162,8 +201,9 @@ export function startFlowRun(context, input) {
162
201
  eventType: "flow_run.started",
163
202
  payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
164
203
  });
165
- appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
166
- appendRunTimeline(runHome, {
204
+ context.db.afterCommit(() => publishRunProjections(context, project.id, runId));
205
+ appendRunTimeline(context, project.id, runId, { at: now, type: "run_started", run_id: runId });
206
+ appendRunTimeline(context, project.id, runId, {
167
207
  at: now,
168
208
  type: "flow_flags_resolved",
169
209
  run_id: runId,
@@ -173,13 +213,16 @@ export function startFlowRun(context, input) {
173
213
  });
174
214
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
175
215
  }
176
- function snapshotVnextExecutionProfile(projectRoot) {
177
- const profile = loadVnextExecutionProfile(projectRoot);
216
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput, now = new Date().toISOString()) {
217
+ const { profile, bytes } = loadVnextExecutionProfileSnapshot(projectRoot);
218
+ if (profile.operational_decision)
219
+ assertOperationalDecisionSource(profile.operational_decision, projectRoot, now);
178
220
  const sourcePath = executionProfilePath(projectRoot);
179
- return {
221
+ const frozen = freezeRunExecutionRouting(ddFlowHome, {
180
222
  schema_id: "dd-flow/run-execution-profile@1",
181
223
  source_path: sourcePath,
182
- source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
224
+ source_checksum: crypto.createHash("sha256").update(bytes).digest("hex"),
225
+ ...(profile.operational_decision ? { operational_decision: profile.operational_decision } : {}),
183
226
  settings: {
184
227
  stage_session_mode: profile.stage_session_mode,
185
228
  plan_review_mode: profile.plan_review_mode,
@@ -188,9 +231,47 @@ function snapshotVnextExecutionProfile(projectRoot) {
188
231
  merge_delivery: profile.merge_delivery,
189
232
  merge_cleanup: profile.merge_cleanup,
190
233
  stop_target: profile.stop_target,
191
- code_bootstrap: profile.code_bootstrap
234
+ code_bootstrap: profile.code_bootstrap,
235
+ ...(profile.execution ? { execution: profile.execution } : {})
192
236
  }
193
- };
237
+ }, routingInput);
238
+ if (frozen.settings.merge_mode === "same_session" && frozen.settings.stop_target === "merge_completed"
239
+ && fs.existsSync(path.join(projectRoot, ".memory-bank", "dd-flow", "project-workspace.json"))
240
+ && loadVnextWorkspacePolicy(projectRoot).workspace.route === "feature_worktree") {
241
+ 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 });
242
+ }
243
+ return frozen;
244
+ }
245
+ /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
246
+ export function freezeRunExecutionRouting(ddFlowHome, base, input) {
247
+ const execution = input?.execution ?? base.settings.execution;
248
+ const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
249
+ if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
250
+ const mode = base.settings.code_review_mode;
251
+ assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
252
+ }
253
+ return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
254
+ ...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
255
+ settings: { ...base.settings, ...(execution ? { execution } : {}) } };
256
+ }
257
+ /** Called only after a stage-entry import has archived and detached source owners. */
258
+ export function importFlowRunExecutionProfile(context, input) {
259
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
260
+ const run = resolveRun(context, project.id, input.runId);
261
+ assertRunMutationAllowed(context, project.id, run.id);
262
+ const index = authoritativeIndex(run);
263
+ const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
264
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
265
+ || !vnextStages.some(stage => stage.id === input.stage)
266
+ || (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'") && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id]))
267
+ || index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
268
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
269
+ throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
270
+ }
271
+ index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
272
+ index.updated_at = context.now();
273
+ persistRunState(context, project, run, index);
274
+ 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 });
194
275
  }
195
276
  /**
196
277
  * A vNext route may materialize a feature checkout after its semantic
@@ -200,6 +281,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
200
281
  export function rebindFlowRunWorkspace(context, input) {
201
282
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
202
283
  const run = resolveRun(context, project.id, input.runId);
284
+ assertRunMutationAllowed(context, project.id, run.id);
203
285
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
204
286
  const index = authoritativeIndex(run);
205
287
  const now = context.now();
@@ -210,7 +292,7 @@ export function rebindFlowRunWorkspace(context, input) {
210
292
  context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
211
293
  persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
212
294
  appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
213
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
295
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
214
296
  return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
215
297
  }
216
298
  function loadUpgradeFlowContract(context, projectRoot) {
@@ -229,7 +311,41 @@ export function getFlowRunStatus(context, input) {
229
311
  const run = resolveRun(context, project.id, input.runId);
230
312
  const index = authoritativeIndex(run);
231
313
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
314
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
315
+ }
316
+ /** A read-only projection of the next legal lifecycle action.
317
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
318
+ * transition from a model's prose or a hard-coded stage array. */
319
+ function runContinuation(context, run, index) {
320
+ if (["done", "failed", "cancelled"].includes(run.status))
321
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
322
+ const control = runControlDirective(context, run.project_id, run.id);
323
+ if (control)
324
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
325
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
326
+ if (active) {
327
+ const runningWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' ORDER BY updated_at DESC LIMIT 1", [run.project_id, run.id]);
328
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
329
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
330
+ }
331
+ if (run.status === "waiting_for_user" || run.status === "paused")
332
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
333
+ if (run.status === "blocked")
334
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
335
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
336
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
337
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
338
+ if (!next)
339
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
340
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
341
+ const profile = index.execution_profile;
342
+ const execution = profile?.settings.execution ? profile.agent_profiles
343
+ ? { 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 }) }
344
+ : { status: "unfrozen", reason: "execution_profile_not_frozen" }
345
+ : undefined;
346
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
347
+ }
348
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
349
  }
234
350
  export function getFlowRunConfig(context, input) {
235
351
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -250,9 +366,12 @@ export function getFlowRunVariables(context, input) {
250
366
  return { ok: true, run_id: run.id, variables };
251
367
  }
252
368
  export function setFlowRunVariable(context, input) {
369
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
370
+ }
371
+ export function prepareFlowRunVariable(context, input) {
253
372
  if (!input.key.startsWith("user."))
254
373
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
255
- return setRunVariable(context, input, input.value);
374
+ return prepareRunVariable(context, input);
256
375
  }
257
376
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
258
377
  export function setRuntimeRunVariable(context, input) {
@@ -261,20 +380,7 @@ export function setRuntimeRunVariable(context, input) {
261
380
  return setRunVariable(context, input, input.value);
262
381
  }
263
382
  export function setFlowRunConfig(context, input) {
264
- if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
265
- throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
266
- }
267
- if (!["auto", "off", "standard", "deep"].includes(input.value)) {
268
- throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
269
- }
270
- const reason = requiredPlain(input.reason, "reason");
271
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
272
- const run = resolveRun(context, project.id, input.runId);
273
- const index = authoritativeIndex(run);
274
- const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
275
- if (index.stage_runs.some((stage) => stage.stage === stageName)) {
276
- throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
277
- }
383
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
278
384
  const now = context.now();
279
385
  const settings = runSettings(index);
280
386
  const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
@@ -289,13 +395,32 @@ export function setFlowRunConfig(context, input) {
289
395
  index.updated_at = now;
290
396
  persistRunState(context, project, run, index);
291
397
  appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
292
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
398
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
293
399
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
294
400
  }
401
+ export function prepareFlowRunConfig(context, input) {
402
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
403
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
404
+ }
405
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
406
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
407
+ }
408
+ const reason = requiredPlain(input.reason, "reason");
409
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
410
+ const run = resolveRun(context, project.id, input.runId);
411
+ assertRunMutationAllowed(context, project.id, run.id);
412
+ const index = authoritativeIndex(run);
413
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
414
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
415
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
416
+ }
417
+ return { reason, project, run, index };
418
+ }
295
419
  /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
296
420
  export function freezeFlowRunReviewMode(context, input) {
297
421
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
298
422
  const run = resolveRun(context, project.id, input.runId);
423
+ assertRunMutationAllowed(context, project.id, run.id);
299
424
  const index = authoritativeIndex(run);
300
425
  const settings = runSettings(index);
301
426
  const key = input.review === "plan" ? "plan_review" : "code_review";
@@ -312,8 +437,8 @@ export function getFlowRunFlagsStatus(context, input) {
312
437
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
313
438
  const run = resolveRun(context, project.id, input.runId);
314
439
  const index = authoritativeIndex(run);
315
- const runtime = readRuntimeSnapshot(run.runtime_path);
316
- const flags = runtime?.flow_flags ?? index.flow_flags;
440
+ const legacyRuntime = index.flow_flags ? null : readRuntimeSnapshot(run.runtime_path);
441
+ const flags = index.flow_flags ?? legacyRuntime?.flow_flags;
317
442
  if (!flags) {
318
443
  return {
319
444
  ok: true,
@@ -331,29 +456,31 @@ export function getFlowRunFlagsStatus(context, input) {
331
456
  snapshot_revision: flags.snapshot_revision,
332
457
  snapshot_checksum: flags.snapshot_checksum,
333
458
  flow_flags: flags,
334
- runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
459
+ runtime_revision: index.runtime_revision ?? legacyRuntime?.runtime_revision ?? null
335
460
  };
336
461
  }
337
462
  export function reviseFlowRunFlags(context, input) {
338
463
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
339
464
  const run = resolveRun(context, project.id, input.runId);
465
+ assertRunMutationAllowed(context, project.id, run.id);
340
466
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
341
- context.db.exec("BEGIN IMMEDIATE");
342
- let committed = false;
343
- try {
467
+ 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]);
468
+ if (priorMutation)
469
+ return { ...JSON.parse(priorMutation.result_json), idempotent: true };
470
+ // Canonical contract bytes are prepared before taking the writer. Revision
471
+ // and idempotency are compared again against SQL authority under the lock.
472
+ const contract = loadProjectFlowContract(run.project_root);
473
+ return context.db.writeTransaction(() => {
344
474
  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]);
345
475
  if (existingMutation) {
346
476
  const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
347
- context.db.exec("COMMIT");
348
- committed = true;
349
477
  return result;
350
478
  }
351
479
  // Read the authority only after the write lock: two callers with the same
352
480
  // expected revision must not both pass the compare-and-set check.
353
481
  const lockedRun = requireRunById(context, project.id, run.id);
354
482
  const index = authoritativeIndex(lockedRun);
355
- const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
356
- const current = runtime?.flow_flags ?? index.flow_flags;
483
+ const current = index.flow_flags;
357
484
  if (!current) {
358
485
  throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
359
486
  }
@@ -370,7 +497,6 @@ export function reviseFlowRunFlags(context, input) {
370
497
  });
371
498
  }
372
499
  const now = context.now();
373
- const contract = loadProjectFlowContract(lockedRun.project_root);
374
500
  const next = resolveFlowFlags(contract, {
375
501
  flowKind: index.flow_kind,
376
502
  preset: input.preset ?? current.preset.applied,
@@ -426,14 +552,12 @@ export function reviseFlowRunFlags(context, input) {
426
552
  context.db.run(`INSERT INTO flow_run_flag_mutations
427
553
  (project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
428
554
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
429
- context.db.exec("COMMIT");
430
- committed = true;
431
555
  appendAudit(context, {
432
556
  projectId: project.id,
433
557
  eventType: "flow_run.flags_revised",
434
558
  payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
435
559
  });
436
- appendRunTimeline(runArtifactRoot(run), {
560
+ appendRunTimeline(context, project.id, run.id, {
437
561
  at: now,
438
562
  type: "flow_flags_revised",
439
563
  run_id: run.id,
@@ -443,12 +567,7 @@ export function reviseFlowRunFlags(context, input) {
443
567
  ...(reason ? { reason } : {})
444
568
  });
445
569
  return result;
446
- }
447
- catch (error) {
448
- if (!committed)
449
- context.db.exec("ROLLBACK");
450
- throw error;
451
- }
570
+ }, "runs.reviseFlowFlags");
452
571
  }
453
572
  export function listFlowRuns(context, input) {
454
573
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -459,113 +578,188 @@ export function listFlowRuns(context, input) {
459
578
  .map(flowRunSummary);
460
579
  return { ok: true, project, runs };
461
580
  }
462
- export function attachFlowRunStage(context, input) {
581
+ export function prepareFlowRunStageAttachment(context, input, archive = false) {
463
582
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
464
583
  const run = resolveRun(context, project.id, input.runId);
465
- const index = authoritativeIndex(run);
466
- const now = context.now();
584
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
467
585
  const stage = requiredPlain(input.stage, "stage");
468
- const dir = requiredStageDir(input.dir);
586
+ const dir = prepareStageDirectory(input.dir);
587
+ const index = authoritativeIndex(run);
469
588
  const status = parseStageStatus(input.status);
470
589
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
590
  assertVnextStageStart(run, index, stage, status, existing);
472
- if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
474
- }
475
- const stageRun = {
476
- ...(existing ?? { order: index.stage_runs.length + 1 }),
477
- stage,
478
- dir,
479
- status,
480
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
481
- updated_at: now
482
- };
483
- if (status === "running") {
484
- stageRun.started_at = now;
485
- delete stageRun.completed_at;
486
- stageRun.duration_ms = null;
487
- stageRun.git = gitFacts(run.workspace_root);
488
- stageRun.attempt = nextAttempt(existing);
591
+ if (archive) {
592
+ const runRoot = runArtifactRoot(run);
593
+ if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
594
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
595
+ }
489
596
  }
490
- upsertStage(index, stageRun);
491
- index.current_stage = stage;
492
- 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 }));
493
- index.updated_at = now;
494
- persistRunState(context, project, run, index);
495
- appendAudit(context, {
496
- projectId: project.id,
497
- eventType: "flow_run.stage_attached",
498
- payload: { run_id: run.id, stage, dir, status }
499
- });
500
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
501
- // A stage transition must not rewrite every Session in the RUN. A RUN may
502
- // contain independent reviewer/worker sessions; their stage is set by the
503
- // stage/work binding that actually owns that Session.
504
- if (status === "running")
505
- refreshRunSessionProjection(context, project.id, run.id);
597
+ return { project, run, index, stage, dir, status, existing, generation, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
598
+ }
599
+ export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
600
+ // Filesystem archive and Git inspection are preparation, never writer work.
601
+ const { project, run, stage, dir, status, generation, git } = prepared;
602
+ const attached = context.db.writeTransaction(() => {
603
+ assertRunMutationAllowed(context, project.id, run.id, generation);
604
+ const latest = requireRunById(context, project.id, run.id);
605
+ const index = authoritativeIndex(latest);
606
+ const existing = index.stage_runs.find((item) => item.stage === stage);
607
+ if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
608
+ throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
609
+ }
610
+ assertVnextStageStart(latest, index, stage, status, existing);
611
+ const now = context.now();
612
+ const stageRun = {
613
+ ...(existing ?? { order: index.stage_runs.length + 1 }),
614
+ stage,
615
+ dir,
616
+ status,
617
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
618
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
619
+ updated_at: now
620
+ };
621
+ if (status === "running") {
622
+ stageRun.started_at = now;
623
+ delete stageRun.completed_at;
624
+ stageRun.duration_ms = null;
625
+ if (git)
626
+ stageRun.git = git;
627
+ stageRun.attempt = prepared.attempt;
628
+ }
629
+ upsertStage(index, stageRun);
630
+ index.current_stage = stage;
631
+ 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 }));
632
+ index.updated_at = now;
633
+ persistRunState(context, project, run, index);
634
+ appendAudit(context, {
635
+ projectId: project.id,
636
+ eventType: "flow_run.stage_attached",
637
+ payload: { run_id: run.id, stage, dir, status }
638
+ });
639
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
640
+ context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
641
+ // A stage transition must not rewrite every Session in the RUN. A RUN may
642
+ // contain independent reviewer/worker sessions; their stage is set by the
643
+ // stage/work binding that actually owns that Session.
644
+ if (status === "running")
645
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
646
+ return { stageRun, index };
647
+ }, "stage.attach");
506
648
  const updatedRun = requireRunById(context, project.id, run.id);
507
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
649
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: attached.stageRun, index: attached.index, flow_guidance: guidanceForRun(context, updatedRun, attached.index) };
650
+ }
651
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
652
+ export function withStageSettlement(context, operation) {
653
+ return context.db.writeTransaction(operation, "stage.settle");
508
654
  }
509
- export function completeFlowRunStage(context, input) {
655
+ export function prepareFlowRunStageCompletion(context, input) {
656
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
657
+ if (value !== undefined)
658
+ requiredPlain(value, name);
659
+ }
660
+ if (input.aliases !== undefined) {
661
+ if (!Array.isArray(input.aliases))
662
+ throw new AppError("validation", "artifact aliases must be an array", 2);
663
+ for (const alias of input.aliases)
664
+ requiredPlain(alias, "artifact alias");
665
+ }
510
666
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
511
667
  const run = resolveRun(context, project.id, input.runId);
668
+ if (["done", "failed", "skipped"].includes(input.status))
669
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
670
+ else
671
+ assertRunMutationAllowed(context, project.id, run.id);
512
672
  const index = authoritativeIndex(run);
513
- const now = context.now();
514
673
  const stage = requiredPlain(input.stage, "stage");
515
674
  const status = parseStageStatus(input.status);
516
675
  const existing = index.stage_runs.find((item) => item.stage === stage);
517
676
  if (!existing) {
518
677
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
519
678
  }
520
- const stageRun = {
521
- ...existing,
522
- status,
523
- ...(input.stageReport ? { stage_report: input.stageReport } : {}),
524
- ...(input.data ? { data: input.data } : {}),
525
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
526
- ...(input.report ? { report: input.report } : {}),
527
- ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
528
- updated_at: now,
529
- ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
530
- ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
531
- : {})
532
- };
533
- upsertStage(index, stageRun);
534
- if (status === "waiting_for_user") {
535
- index.status = "waiting_for_user";
536
- index.verdict = "waiting_for_user";
537
- }
538
- else if (status === "blocked" || status === "failed") {
539
- index.status = status;
540
- index.verdict = status;
541
- }
542
- else if (status === "done" && index.status === "waiting_for_user") {
543
- index.status = "running";
544
- index.verdict = "running";
545
- }
546
- index.current_stage = stage;
547
- 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 }));
548
- index.updated_at = now;
549
- index.execution = {
550
- project_root: index.execution?.project_root ?? run.project_root,
551
- workspace_root: index.execution?.workspace_root ?? run.workspace_root,
552
- git: gitFacts(run.workspace_root)
553
- };
554
- persistRunState(context, project, run, index);
555
- refreshRunSessionProjection(context, project.id, run.id);
556
- appendAudit(context, {
557
- projectId: project.id,
558
- eventType: "flow_run.stage_completed",
559
- payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
560
- });
561
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
679
+ const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
680
+ const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
681
+ if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
682
+ throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
683
+ 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) };
684
+ }
685
+ function stageUsageProjection(context, run, stage, startedAt) {
686
+ if (!startedAt)
687
+ return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
688
+ const value = recalculateRunUsage(context, { projectId: run.project_id, runId: run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
689
+ const totals = value.totals, coverage = value.coverage;
690
+ const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
691
+ const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
692
+ const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
693
+ return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
694
+ }
695
+ export function completeFlowRunStage(context, input, prepared = prepareFlowRunStageCompletion(context, input)) {
696
+ const { project, run, stage, status, dataSha256, git, generation } = prepared;
697
+ const completed = context.db.writeTransaction(() => {
698
+ if (["done", "failed", "skipped"].includes(status))
699
+ assertStageSettlementAllowed(context, project.id, run.id, stage, generation);
700
+ else
701
+ assertRunMutationAllowed(context, project.id, run.id, generation);
702
+ const latest = requireRunById(context, project.id, run.id);
703
+ const index = authoritativeIndex(latest);
704
+ const existing = index.stage_runs.find((item) => item.stage === stage);
705
+ if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
706
+ 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 });
707
+ }
708
+ const now = context.now();
709
+ const stageRun = {
710
+ ...existing,
711
+ status,
712
+ ...(input.stageReport ? { stage_report: input.stageReport } : {}),
713
+ ...(input.data ? { data: input.data } : {}),
714
+ ...(dataSha256 ? { data_sha256: dataSha256 } : {}),
715
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
716
+ ...(input.report ? { report: input.report } : {}),
717
+ usage: input.usage ?? stageUsageProjection(context, latest, stage, existing.started_at),
718
+ ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
719
+ updated_at: now,
720
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
721
+ ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git }
722
+ : {})
723
+ };
724
+ upsertStage(index, stageRun);
725
+ if (status === "waiting_for_user") {
726
+ index.status = "waiting_for_user";
727
+ index.verdict = "waiting_for_user";
728
+ }
729
+ else if (status === "blocked" || status === "failed") {
730
+ index.status = status;
731
+ index.verdict = status;
732
+ }
733
+ else if (status === "done" && index.status === "waiting_for_user") {
734
+ index.status = "running";
735
+ index.verdict = "running";
736
+ }
737
+ index.current_stage = stage;
738
+ 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 }));
739
+ index.updated_at = now;
740
+ index.execution = {
741
+ project_root: index.execution?.project_root ?? run.project_root,
742
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
743
+ git
744
+ };
745
+ persistRunState(context, project, latest, index);
746
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
747
+ appendAudit(context, {
748
+ projectId: project.id,
749
+ eventType: "flow_run.stage_completed",
750
+ payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
751
+ });
752
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
753
+ return { index, stageRun };
754
+ }, "runs.completeFlowRunStage");
562
755
  const updatedRun = requireRunById(context, project.id, run.id);
563
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
756
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
564
757
  }
565
758
  /** Pause one live stage without completing it or creating a new attempt. */
566
759
  export function pauseFlowRunStage(context, input) {
567
760
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
568
761
  const run = resolveRun(context, project.id, input.runId);
762
+ assertRunMutationAllowed(context, project.id, run.id);
569
763
  const index = authoritativeIndex(run);
570
764
  const stage = requiredPlain(input.stage, "stage");
571
765
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -594,13 +788,14 @@ export function pauseFlowRunStage(context, input) {
594
788
  persistRunState(context, project, run, index);
595
789
  refreshRunSessionProjection(context, project.id, run.id);
596
790
  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" } });
597
- 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 });
791
+ 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 });
598
792
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
599
793
  }
600
794
  /** Resume the same stage, Work and attempt after a user answer is recorded. */
601
795
  export function resumeFlowRunStage(context, input) {
602
796
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
603
797
  const run = resolveRun(context, project.id, input.runId);
798
+ assertRunMutationAllowed(context, project.id, run.id);
604
799
  const index = authoritativeIndex(run);
605
800
  const stage = requiredPlain(input.stage, "stage");
606
801
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -624,19 +819,28 @@ export function resumeFlowRunStage(context, input) {
624
819
  persistRunState(context, project, run, index);
625
820
  refreshRunSessionProjection(context, project.id, run.id);
626
821
  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 } });
627
- 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 });
628
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
822
+ 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 });
823
+ 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 });
629
824
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
630
825
  }
631
826
  /** Pause the current stage for a non-user runtime blocker. */
632
- export function blockFlowRunStage(context, input) {
827
+ export function prepareFlowRunStageBlock(context, input) {
828
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
829
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
830
+ requiredPlain(input.code, "code");
831
+ requiredPlain(input.summary, "summary");
633
832
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
634
833
  const run = resolveRun(context, project.id, input.runId);
834
+ assertRunMutationAllowed(context, project.id, run.id);
635
835
  const index = authoritativeIndex(run);
636
836
  const stage = requiredPlain(input.stage, "stage");
637
837
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
638
838
  if (!stageRun || stageRun.status !== "running")
639
839
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
840
+ return { project, run, index, stage, stageRun };
841
+ }
842
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
843
+ const { project, run, index, stage, stageRun } = prepared;
640
844
  const now = context.now();
641
845
  stageRun.status = "blocked";
642
846
  stageRun.updated_at = now;
@@ -650,20 +854,24 @@ export function blockFlowRunStage(context, input) {
650
854
  persistRunState(context, project, run, index);
651
855
  refreshRunSessionProjection(context, project.id, run.id);
652
856
  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 } });
653
- 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 });
857
+ 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 });
654
858
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
655
859
  }
656
860
  /** Continue the same stage after its non-user blocker was repaired externally. */
657
- export function unblockFlowRunStage(context, input) {
861
+ export function prepareFlowRunStageUnblock(context, input) {
658
862
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
659
863
  const run = resolveRun(context, project.id, input.runId);
864
+ assertRunMutationAllowed(context, project.id, run.id);
660
865
  const index = authoritativeIndex(run);
661
866
  const stage = requiredPlain(input.stage, "stage");
662
867
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
663
868
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
664
869
  throw new AppError("invalid_stage_state", "Stage unblock requires its matching runtime blocker", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
870
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
871
+ }
872
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
873
+ const { project, run, index, stage, stageRun, blocker } = prepared;
665
874
  const now = context.now();
666
- const blocker = stageRun.blocker;
667
875
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
668
876
  stageRun.status = "running";
669
877
  stageRun.updated_at = now;
@@ -678,7 +886,7 @@ export function unblockFlowRunStage(context, input) {
678
886
  persistRunState(context, project, run, index);
679
887
  refreshRunSessionProjection(context, project.id, run.id);
680
888
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
681
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
889
+ 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 });
682
890
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
683
891
  }
684
892
  function operationalRunStatus(context, projectId, runId) {
@@ -687,11 +895,36 @@ function operationalRunStatus(context, projectId, runId) {
687
895
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
688
896
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
689
897
  }
690
- export function completeFlowRun(context, input) {
691
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
898
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
899
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
900
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
901
+ const run = resolveRun(context, projectId, runId);
902
+ if (!["running", "paused"].includes(run.status))
903
+ return;
904
+ const index = authoritativeIndex(run);
905
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
906
+ ? "paused" : operationalRunStatus(context, projectId, runId);
907
+ if (run.status === status && index.status === status)
908
+ return;
909
+ index.status = status;
910
+ index.updated_at = context.now();
911
+ const project = requireProjectByRoot(context, run.project_root);
912
+ persistRunState(context, project, run, index);
913
+ }
914
+ export function prepareFlowRunCompletion(context, input) {
915
+ if (input.manualOverrideReason !== undefined) {
916
+ requiredPlain(input.manualOverrideReason, "override reason");
917
+ if (!["cancelled", "failed"].includes(input.status))
918
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
919
+ }
920
+ if (input.verdict !== undefined)
921
+ requiredPlain(input.verdict, "verdict");
922
+ if (input.nextAction !== undefined)
923
+ requiredPlain(input.nextAction, "next action");
924
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
692
925
  const run = resolveRun(context, project.id, input.runId);
926
+ assertRunMutationAllowed(context, project.id, run.id);
693
927
  const index = authoritativeIndex(run);
694
- const now = context.now();
695
928
  const status = parseRunStatus(input.status);
696
929
  if (!['done', 'cancelled', 'failed'].includes(status)) {
697
930
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -701,6 +934,11 @@ export function completeFlowRun(context, input) {
701
934
  if (activeWork)
702
935
  throw new AppError("active_work", "RUN cannot complete while Work remains active", 2, { run_id: run.id, work_id: activeWork.work_id, status: activeWork.status });
703
936
  }
937
+ return { project, run, index, status };
938
+ }
939
+ export function completeFlowRun(context, input) {
940
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
941
+ const now = context.now();
704
942
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
705
943
  closeOpenStagesForOverride(index, status, now);
706
944
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -734,7 +972,7 @@ export function completeFlowRun(context, input) {
734
972
  eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
735
973
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
736
974
  });
737
- appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
975
+ 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 } : {}) });
738
976
  const updatedRun = requireRunById(context, project.id, run.id);
739
977
  return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
740
978
  }
@@ -743,16 +981,21 @@ export function advanceFlowRun(context, input) {
743
981
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
744
982
  const run = resolveRun(context, project.id, input.runId);
745
983
  const index = authoritativeIndex(run);
984
+ if (input.settlementStage && input.status === "running" && index.current_stage === input.settlementStage && index.stage_runs.some(stage => stage.stage === input.settlementStage && ["done", "skipped"].includes(stage.status)))
985
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
986
+ else
987
+ assertRunMutationAllowed(context, project.id, run.id);
988
+ const control = runControlDirective(context, project.id, run.id);
746
989
  const now = context.now();
747
990
  index.status = input.status;
748
991
  index.verdict = input.verdict;
749
- index.next_action = input.nextAction ?? null;
992
+ index.next_action = control ? null : input.nextAction ?? null;
750
993
  index.updated_at = now;
751
994
  persistRunState(context, project, run, index);
752
- appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
753
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
995
+ 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 } });
996
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
754
997
  const updatedRun = requireRunById(context, project.id, run.id);
755
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
998
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
756
999
  }
757
1000
  /**
758
1001
  * The only legal backward edge in vNext: an integration gate exposed a
@@ -762,7 +1005,11 @@ export function advanceFlowRun(context, input) {
762
1005
  export function prepareVnextMergeSourceRepairAttempt(context, input) {
763
1006
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
764
1007
  const run = resolveRun(context, project.id, input.runId);
1008
+ assertRunMutationAllowed(context, project.id, run.id);
765
1009
  const index = authoritativeIndex(run);
1010
+ const priorRepair = index.variables?.["merge.source_repair"];
1011
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
1012
+ return { run_id: run.id, cycle: priorRepair.cycle };
766
1013
  const code = index.stage_runs.find((item) => item.stage === "code");
767
1014
  const review = index.stage_runs.find((item) => item.stage === "code-review");
768
1015
  const merge = index.stage_runs.find((item) => item.stage === "merge");
@@ -776,7 +1023,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
776
1023
  }
777
1024
  const root = runArtifactRoot(run);
778
1025
  for (const stage of ["code", "code-review", "merge"])
779
- archiveExistingStageAttempt(root, vnextStageDirectory(stage));
1026
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
780
1027
  const now = context.now();
781
1028
  const restarted = {
782
1029
  ...code,
@@ -811,7 +1058,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
811
1058
  }));
812
1059
  persistRunState(context, project, run, index);
813
1060
  appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
814
- appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1061
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
815
1062
  return { run_id: run.id, cycle };
816
1063
  }
817
1064
  function closeOpenStagesForOverride(index, status, now) {
@@ -860,7 +1107,7 @@ function closeRunOrchestrators(context, projectId, run, status, now) {
860
1107
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
861
1108
  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)]);
862
1109
  for (const session of active) {
863
- appendRunTimeline(runArtifactRoot(run), {
1110
+ appendRunTimeline(context, projectId, run.id, {
864
1111
  at: now,
865
1112
  type: "session_stopped",
866
1113
  run_id: run.id,
@@ -983,7 +1230,7 @@ export function getFlowRunSessions(context, input) {
983
1230
  }
984
1231
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
985
1232
  const run = requireRunById(context, projectId, runId);
986
- appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
1233
+ appendRunTimeline(context, projectId, run.id, { run_id: run.id, ...event });
987
1234
  }
988
1235
  function guidanceForRun(context, run, index) {
989
1236
  if (run.flow_kind === "vnext_specify")
@@ -1078,32 +1325,31 @@ function vnextSpecifyGuidance(run, index) {
1078
1325
  };
1079
1326
  }
1080
1327
  function persistRunState(context, project, run, index, options = {}) {
1328
+ // Fenced productive admission is a logical pause, even while physical work
1329
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1330
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1331
+ index.status = "paused";
1081
1332
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1082
- const previousRuntime = readRuntimeSnapshot(runtimePath);
1083
- const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
1084
- index.runtime_revision = runtimeRevision;
1085
- const history = [...(previousRuntime?.flag_revision_history ?? [])];
1086
- if (options.flagRevision) {
1087
- history.push(options.flagRevision);
1088
- }
1089
- const persistedIndex = persistedVnextIndex(index);
1090
- ensureDir(path.dirname(runtimePath));
1091
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1092
- context.db.run(`UPDATE runs
1093
- SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1094
- updated_at = ?, completed_at = ?
1095
- WHERE project_id = ? AND id = ?`, [
1096
- index.status,
1097
- index.verdict,
1098
- index.next_action,
1099
- JSON.stringify(persistedIndex),
1100
- runtimePath,
1101
- runtimePath,
1102
- index.updated_at,
1103
- index.completed_at ?? null,
1104
- project.id,
1105
- run.id
1106
- ]);
1333
+ context.db.writeTransaction(() => {
1334
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
1335
+ if (!stored)
1336
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: run.id });
1337
+ const previous = JSON.parse(stored.index_json);
1338
+ const actualRevision = previous.runtime_revision ?? 0;
1339
+ const expectedRevision = index.runtime_revision ?? 0;
1340
+ if (actualRevision !== expectedRevision)
1341
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id, expected_revision: expectedRevision, actual_revision: actualRevision });
1342
+ index.runtime_revision = actualRevision + 1;
1343
+ const history = [...(previous.flag_revision_history ?? [])];
1344
+ if (options.flagRevision)
1345
+ history.push(options.flagRevision);
1346
+ index.flag_revision_history = history.slice(-32);
1347
+ const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1348
+ 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]);
1349
+ if (updated.changes !== 1)
1350
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
1351
+ context.db.afterCommit(() => publishRunProjections(context, project.id, run.id));
1352
+ }, "runs.persistRunState");
1107
1353
  }
1108
1354
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1109
1355
  function persistedVnextIndex(index) {
@@ -1145,35 +1391,64 @@ function materializedRunVariables(index) {
1145
1391
  ...(index.variables ?? {})
1146
1392
  };
1147
1393
  }
1148
- function setRunVariable(context, input, value) {
1394
+ function prepareRunVariable(context, input) {
1149
1395
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1150
1396
  const run = resolveRun(context, project.id, input.runId);
1397
+ assertRunMutationAllowed(context, project.id, run.id);
1151
1398
  const index = authoritativeIndex(run);
1399
+ return { project, run, index };
1400
+ }
1401
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1402
+ const { project, run, index } = prepared;
1152
1403
  if (index.variables?.[input.key] === value)
1153
1404
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1154
1405
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
1155
1406
  index.updated_at = context.now();
1156
1407
  persistRunState(context, project, run, index);
1157
- appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1408
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1158
1409
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
1159
1410
  }
1160
- function appendRunTimeline(runHome, event) {
1161
- ensureDir(runHome);
1162
- const timelinePath = path.join(runHome, "timeline.jsonl");
1163
- let sequence = 0;
1164
- if (fs.existsSync(timelinePath)) {
1165
- const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
1166
- const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
1167
- sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
1168
- }
1169
- const safeEvent = sanitizeTimelineValue(event);
1170
- const enriched = {
1171
- event_id: crypto.randomUUID(),
1172
- sequence: sequence + 1,
1173
- at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
1174
- ...safeEvent
1175
- };
1176
- fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
1411
+ function appendRunTimeline(context, projectId, runId, event) {
1412
+ context.db.writeTransaction(() => {
1413
+ 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]);
1414
+ const sequence = last ? Number(JSON.parse(last.payload_json).sequence) + 1 : 1;
1415
+ const safeEvent = sanitizeTimelineValue(event);
1416
+ const enriched = { event_id: crypto.randomUUID(), sequence, at: typeof safeEvent.at === "string" ? safeEvent.at : context.now(), ...safeEvent };
1417
+ appendAudit(context, { projectId, eventType: "flow_run.timeline", payload: { run_id: runId, sequence, event: enriched } });
1418
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
1419
+ }, "runs.appendRunTimeline");
1420
+ }
1421
+ /** Rebuild projections from committed SQL after taking a per-RUN filesystem lock. */
1422
+ export function publishRunProjections(context, projectId, runId) {
1423
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1424
+ if (!run)
1425
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1426
+ const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, projectId, runId);
1427
+ ensureDir(path.dirname(runtimePath));
1428
+ withProjectionLock(`${runtimePath}.publish-lock`, () => {
1429
+ // A delayed publisher must observe the latest commit after acquiring its lock.
1430
+ const latest = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1431
+ if (!latest)
1432
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1433
+ const index = authoritativeIndex(latest);
1434
+ 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]);
1435
+ const history = mutations.reverse().map((row) => ({
1436
+ revision: row.resulting_revision,
1437
+ checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
1438
+ at: row.created_at,
1439
+ idempotency_key: row.idempotency_key
1440
+ }));
1441
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, index.runtime_revision ?? 1, history.length ? history : index.flag_revision_history ?? []));
1442
+ 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]);
1443
+ if (latest.run_root) {
1444
+ const timelinePath = path.join(latest.run_root, "timeline.jsonl");
1445
+ ensureDir(path.dirname(timelinePath));
1446
+ const bytes = rows.map((row) => JSON.stringify(JSON.parse(row.payload_json).event)).join("\n");
1447
+ const temporary = `${timelinePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1448
+ fs.writeFileSync(temporary, bytes ? `${bytes}\n` : "");
1449
+ fs.renameSync(temporary, timelinePath);
1450
+ }
1451
+ });
1177
1452
  }
1178
1453
  function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
1179
1454
  const rest = { ...index };
@@ -1199,10 +1474,12 @@ function readRuntimeSnapshot(file) {
1199
1474
  function authoritativeIndex(run) {
1200
1475
  try {
1201
1476
  const state = JSON.parse(run.index_json);
1477
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1478
+ throw new Error("RUN index must contain stage_runs");
1202
1479
  return { ...state, schema_id: runSchemaId };
1203
1480
  }
1204
1481
  catch (error) {
1205
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1482
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1206
1483
  }
1207
1484
  }
1208
1485
  function sanitizeTimelineValue(value, key) {
@@ -1263,7 +1540,7 @@ function upsertStage(index, stageRun) {
1263
1540
  }
1264
1541
  index.stage_runs.sort((a, b) => a.order - b.order);
1265
1542
  }
1266
- function resolveRun(context, projectId, idOrAlias) {
1543
+ export function resolveRun(context, projectId, idOrAlias) {
1267
1544
  if (isFullEntityId(idOrAlias)) {
1268
1545
  return requireRunById(context, projectId, idOrAlias);
1269
1546
  }
@@ -1357,7 +1634,7 @@ function parseStageStatus(value) {
1357
1634
  }
1358
1635
  return value;
1359
1636
  }
1360
- function requiredStageDir(value) {
1637
+ export function prepareStageDirectory(value) {
1361
1638
  const dir = requiredPlain(value, "dir");
1362
1639
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1363
1640
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1365,6 +1642,8 @@ function requiredStageDir(value) {
1365
1642
  return dir;
1366
1643
  }
1367
1644
  function requiredPlain(value, label) {
1645
+ if (typeof value !== "string")
1646
+ throw new AppError("validation", `${label} must be a string`, 2);
1368
1647
  const trimmed = value.trim();
1369
1648
  if (!trimmed) {
1370
1649
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1372,6 +1651,8 @@ function requiredPlain(value, label) {
1372
1651
  return trimmed;
1373
1652
  }
1374
1653
  function normalizeSlug(value) {
1654
+ if (typeof value !== "string")
1655
+ throw new AppError("validation", "--slug must be a string", 2);
1375
1656
  const slug = value
1376
1657
  .normalize("NFKD")
1377
1658
  .replace(/[\u0300-\u036f]/g, "")
@@ -1389,26 +1670,84 @@ function resolveWorkspaceRoot(workspaceRoot) {
1389
1670
  if (!fs.existsSync(absolute)) {
1390
1671
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1391
1672
  }
1673
+ if (!fs.statSync(absolute).isDirectory())
1674
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1392
1675
  return fs.realpathSync(absolute);
1393
1676
  }
1394
- function archiveExistingStageAttempt(runRoot, stageDir) {
1677
+ /** Move one completed stage attempt and update only known durable path columns.
1678
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1679
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1395
1680
  const currentStageDir = path.join(runRoot, stageDir);
1396
1681
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1397
1682
  return;
1398
1683
  }
1399
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1400
- if (entries.length === 0) {
1684
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1685
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1686
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1687
+ const prior = row ? JSON.parse(row.index_json) : null;
1688
+ const live = prior?.stage_runs.find((stage) => stage.dir === stageDir);
1689
+ const generation = row ? assertRunMutationAllowed(context, projectId, runId) : 0;
1690
+ if (pending && (pending.project_id && pending.project_id !== projectId || pending.run_id && pending.run_id !== runId))
1691
+ throw new AppError("archive_owner_changed", "Archive journal belongs to another RUN", 1, { journal });
1692
+ if (pending?.generation !== undefined && pending.generation !== generation)
1693
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal, expected_generation: pending.generation, generation });
1694
+ if (pending?.prior_attempt && live?.attempt !== pending.prior_attempt && live?.attempt !== pending.next_attempt)
1695
+ 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 });
1696
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1697
+ if (entries.length === 0 && !pending) {
1401
1698
  return;
1402
1699
  }
1403
1700
  let attempt = 1;
1404
1701
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1405
1702
  attempt += 1;
1406
1703
  }
1407
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1704
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1705
+ 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 };
1706
+ if (!pending) {
1707
+ writeJsonFile(journal, intent);
1708
+ }
1408
1709
  ensureDir(archiveDir);
1409
1710
  for (const entry of entries) {
1410
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1711
+ const source = path.join(currentStageDir, entry);
1712
+ const target = path.join(archiveDir, entry);
1713
+ if (fs.existsSync(source)) {
1714
+ if (fs.existsSync(target))
1715
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1716
+ fs.renameSync(source, target);
1717
+ }
1718
+ else if (!fs.existsSync(target))
1719
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1411
1720
  }
1721
+ context.db.writeTransaction(() => {
1722
+ const current = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1723
+ const currentIndex = current ? JSON.parse(current.index_json) : null;
1724
+ const currentAttempt = currentIndex?.stage_runs.find((stage) => stage.dir === stageDir)?.attempt;
1725
+ if (currentIndex && intent.runtime_revision !== undefined && (currentIndex.runtime_revision ?? 0) !== intent.runtime_revision && currentAttempt !== intent.next_attempt)
1726
+ throw new AppError("archive_owner_changed", "RUN revision changed while an archive was pending", 1, { journal });
1727
+ if (intent.prior_attempt && currentAttempt !== intent.prior_attempt && currentAttempt !== intent.next_attempt)
1728
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal });
1729
+ if (row && generation !== assertRunMutationAllowed(context, projectId, runId))
1730
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal });
1731
+ if (intent.next_attempt === undefined || currentAttempt !== intent.next_attempt) {
1732
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1733
+ const newPrefix = `${archiveDir}${path.sep}`;
1734
+ const rewrite = (table, column, where, values) => {
1735
+ 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]);
1736
+ };
1737
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1738
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1739
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1740
+ const receiptScope = "project_id = ? AND run_id = ?";
1741
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1742
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1743
+ }
1744
+ context.db.afterCommit(() => {
1745
+ if (!fs.existsSync(journal))
1746
+ return;
1747
+ // Retain original Stage facts with the immutable attempt after SQL paths commit.
1748
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1749
+ });
1750
+ });
1412
1751
  }
1413
1752
  function writeJsonFile(file, value) {
1414
1753
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1433,3 +1772,5 @@ export function gitFacts(workspaceRoot) {
1433
1772
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1434
1773
  };
1435
1774
  }
1775
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1776
+ import { runControlDirective } from "./run-control.js";