@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90

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 (143) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +304 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1409 -382
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/command-context.js +80 -0
  63. package/dist/services/config.js +11 -8
  64. package/dist/services/continuation-outcome.js +20 -0
  65. package/dist/services/controller-fanout.js +132 -0
  66. package/dist/services/dashboard.js +103 -31
  67. package/dist/services/engines.js +121 -76
  68. package/dist/services/eval-snapshots.js +750 -73
  69. package/dist/services/execution-policy.js +170 -0
  70. package/dist/services/external-work-launch.js +110 -0
  71. package/dist/services/harness-adapter.js +147 -24
  72. package/dist/services/harness-config.js +2 -0
  73. package/dist/services/hooks.js +587 -299
  74. package/dist/services/lanes.js +61 -53
  75. package/dist/services/lifecycle-command.js +118 -7
  76. package/dist/services/lifecycle-invocations.js +1055 -0
  77. package/dist/services/managed-daemon-binding.js +38 -0
  78. package/dist/services/managed-processes.js +283 -27
  79. package/dist/services/merge-queue.js +179 -104
  80. package/dist/services/merge-server.js +73 -28
  81. package/dist/services/migrations.js +14 -9
  82. package/dist/services/native-daemon-history.js +49 -0
  83. package/dist/services/native-session-control.js +39 -0
  84. package/dist/services/plan-runtime.js +19 -8
  85. package/dist/services/plans.js +28 -25
  86. package/dist/services/projects.js +18 -5
  87. package/dist/services/prompts.js +23 -16
  88. package/dist/services/protocols.js +64 -20
  89. package/dist/services/recovery-observation-budget.js +61 -0
  90. package/dist/services/recovery-snapshot-database.js +107 -0
  91. package/dist/services/repair-intents.js +40 -0
  92. package/dist/services/run-control-receipt.js +56 -0
  93. package/dist/services/run-control-worker.js +363 -0
  94. package/dist/services/run-control.js +876 -0
  95. package/dist/services/run-controller-adapter.js +222 -0
  96. package/dist/services/run-controller-capture.js +134 -0
  97. package/dist/services/run-controller-process.js +189 -0
  98. package/dist/services/run-controller-recovery.js +222 -0
  99. package/dist/services/run-controller-state.js +36 -0
  100. package/dist/services/run-controller.js +789 -0
  101. package/dist/services/run-engine-bindings.js +1 -1
  102. package/dist/services/run-fork.js +138 -0
  103. package/dist/services/run-observations.js +112 -0
  104. package/dist/services/run-recovery-runtime.js +69 -0
  105. package/dist/services/run-recovery.js +343 -0
  106. package/dist/services/runs.js +368 -74
  107. package/dist/services/runtime-budget.js +434 -0
  108. package/dist/services/runtime-command.js +43 -0
  109. package/dist/services/runtime-scope-capture.js +50 -0
  110. package/dist/services/runtime-scope-control.js +450 -0
  111. package/dist/services/runtime-scope-resume.js +565 -0
  112. package/dist/services/runtime-scope-stop.js +99 -0
  113. package/dist/services/runtime-scope-worker.js +243 -0
  114. package/dist/services/runtime-service.js +97 -0
  115. package/dist/services/schema-validation.js +10 -8
  116. package/dist/services/sessions.js +35 -69
  117. package/dist/services/stage-blocker.js +17 -7
  118. package/dist/services/stage-context.js +44 -18
  119. package/dist/services/stage-lifecycle.js +99 -102
  120. package/dist/services/stage-pause.js +84 -55
  121. package/dist/services/stage-work-graph.js +35 -0
  122. package/dist/services/usage.js +37 -93
  123. package/dist/services/vnext-code-review.js +195 -88
  124. package/dist/services/vnext-code.js +285 -252
  125. package/dist/services/vnext-execution-profile.js +5 -3
  126. package/dist/services/vnext-fanout.js +160 -17
  127. package/dist/services/vnext-merge.js +291 -104
  128. package/dist/services/vnext-plan-review.js +139 -81
  129. package/dist/services/vnext-plan.js +138 -69
  130. package/dist/services/vnext-protocolize.js +129 -56
  131. package/dist/services/vnext-specify.js +88 -65
  132. package/dist/services/work-registry.js +716 -168
  133. package/dist/services/workspace-bootstrap.js +76 -0
  134. package/dist/services/worktrees.js +85 -35
  135. package/dist/shared/entity-references.js +8 -0
  136. package/dist/shared/errors.js +12 -0
  137. package/dist/storage/database.js +459 -32
  138. package/dist/storage/paths.js +17 -4
  139. package/dist/storage/work-references.js +23 -0
  140. package/dist/storage/writer-contract.js +77 -0
  141. package/dist/storage/writer-migration.js +102 -0
  142. package/package.json +24 -13
  143. package/tools/repair-paused-run-status.mjs +59 -0
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
6
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
8
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -15,8 +15,10 @@ import { refreshRunSessionProjection } from "./run-projection.js";
15
15
  import { resolveCanonRoot } from "./canon.js";
16
16
  import { bindCurrentEngineToRun } from "./engines.js";
17
17
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
- import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
18
+ import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
19
+ import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
20
  import { publicSessionIdentity } from "./session-identity.js";
21
+ import { flowCommand } from "./stage-pause.js";
20
22
  const runSchemaId = "dd-flow/flow-run@3";
21
23
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
24
  const runIdType = "RUN";
@@ -35,22 +37,26 @@ const allowedRunFlowKinds = [
35
37
  "review",
36
38
  "custom"
37
39
  ];
38
- export function startFlowRun(context, input) {
40
+ export function prepareFlowRunStart(context, input) {
41
+ const subjectId = requiredPlain(input.subjectId, "subject-id");
42
+ const subjectType = requiredPlain(input.subjectType, "subject-type");
43
+ const slug = normalizeSlug(input.slug);
44
+ for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
45
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
46
+ throw new AppError("validation", `${name} must be a JSON object`, 2);
47
+ }
39
48
  const projectRoot = resolveProjectRoot(input.projectRoot);
49
+ const flowKind = parseRunFlowKind(input.flowKind);
50
+ const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
51
+ if (input.executionRoutingFile !== undefined && !vnext)
52
+ throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
53
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
54
+ const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput) : undefined;
40
55
  const flowContract = input.flowKind === "mb-upgrade"
41
56
  ? loadUpgradeFlowContract(context, projectRoot)
42
57
  : loadProjectFlowContract(projectRoot);
43
- registerProject(context, { root: projectRoot });
44
- const project = requireProjectByRoot(context, projectRoot);
45
58
  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
59
  const now = context.now();
51
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
52
- ? snapshotVnextExecutionProfile(projectRoot)
53
- : undefined;
54
60
  const flowFlags = resolveFlowFlags(flowContract, {
55
61
  flowKind,
56
62
  ...(input.preset ? { preset: input.preset } : {}),
@@ -60,6 +66,14 @@ export function startFlowRun(context, input) {
60
66
  snapshotRevision: 1,
61
67
  now
62
68
  });
69
+ return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
70
+ }
71
+ export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
72
+ const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
73
+ registerProject(context, { root: projectRoot });
74
+ const project = requireProjectByRoot(context, projectRoot);
75
+ const runId = nextRunId(context, project.id, slug);
76
+ const { shortId } = parseFullEntityId(runId);
63
77
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
64
78
  const runDirRelative = path.posix.join("runs", runId);
65
79
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
@@ -69,8 +83,8 @@ export function startFlowRun(context, input) {
69
83
  short_id: shortId,
70
84
  flow_kind: flowKind,
71
85
  subject: {
72
- id: requiredPlain(input.subjectId, "subject-id"),
73
- title: requiredPlain(input.subjectId, "subject-id")
86
+ id: subjectId,
87
+ title: subjectId
74
88
  },
75
89
  project: {
76
90
  id: project.id,
@@ -126,6 +140,8 @@ export function startFlowRun(context, input) {
126
140
  };
127
141
  ensureDir(path.dirname(runtimePath));
128
142
  ensureDir(runHome);
143
+ if (routingInput)
144
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
129
145
  bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
130
146
  const persistedIndex = persistedVnextIndex(index);
131
147
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
@@ -141,7 +157,7 @@ export function startFlowRun(context, input) {
141
157
  project.root,
142
158
  workspaceRoot,
143
159
  flowKind,
144
- requiredPlain(input.subjectType, "subject-type"),
160
+ subjectType,
145
161
  index.subject.id,
146
162
  index.status,
147
163
  index.verdict,
@@ -173,10 +189,10 @@ export function startFlowRun(context, input) {
173
189
  });
174
190
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
175
191
  }
176
- function snapshotVnextExecutionProfile(projectRoot) {
192
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
177
193
  const profile = loadVnextExecutionProfile(projectRoot);
178
194
  const sourcePath = executionProfilePath(projectRoot);
179
- return {
195
+ return freezeRunExecutionRouting(ddFlowHome, {
180
196
  schema_id: "dd-flow/run-execution-profile@1",
181
197
  source_path: sourcePath,
182
198
  source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
@@ -188,9 +204,41 @@ function snapshotVnextExecutionProfile(projectRoot) {
188
204
  merge_delivery: profile.merge_delivery,
189
205
  merge_cleanup: profile.merge_cleanup,
190
206
  stop_target: profile.stop_target,
191
- code_bootstrap: profile.code_bootstrap
207
+ code_bootstrap: profile.code_bootstrap,
208
+ ...(profile.execution ? { execution: profile.execution } : {})
192
209
  }
193
- };
210
+ }, routingInput);
211
+ }
212
+ /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
213
+ export function freezeRunExecutionRouting(ddFlowHome, base, input) {
214
+ const execution = input?.execution ?? base.settings.execution;
215
+ const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
216
+ if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
217
+ const mode = base.settings.code_review_mode;
218
+ assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
219
+ }
220
+ return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
221
+ ...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
222
+ settings: { ...base.settings, ...(execution ? { execution } : {}) } };
223
+ }
224
+ /** Called only after a stage-entry import has archived and detached source owners. */
225
+ export function importFlowRunExecutionProfile(context, input) {
226
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
227
+ const run = resolveRun(context, project.id, input.runId);
228
+ assertRunMutationAllowed(context, project.id, run.id);
229
+ const index = authoritativeIndex(run);
230
+ const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
231
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
232
+ || !vnextStages.some(stage => stage.id === input.stage)
233
+ || (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]))
234
+ || index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
235
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
236
+ throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
237
+ }
238
+ index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
239
+ index.updated_at = context.now();
240
+ persistRunState(context, project, run, index);
241
+ appendRunTimeline(run.run_root, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
194
242
  }
195
243
  /**
196
244
  * A vNext route may materialize a feature checkout after its semantic
@@ -200,6 +248,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
200
248
  export function rebindFlowRunWorkspace(context, input) {
201
249
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
202
250
  const run = resolveRun(context, project.id, input.runId);
251
+ assertRunMutationAllowed(context, project.id, run.id);
203
252
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
204
253
  const index = authoritativeIndex(run);
205
254
  const now = context.now();
@@ -229,7 +278,41 @@ export function getFlowRunStatus(context, input) {
229
278
  const run = resolveRun(context, project.id, input.runId);
230
279
  const index = authoritativeIndex(run);
231
280
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
281
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
282
+ }
283
+ /** A read-only projection of the next legal lifecycle action.
284
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
285
+ * transition from a model's prose or a hard-coded stage array. */
286
+ function runContinuation(context, run, index) {
287
+ if (["done", "failed", "cancelled"].includes(run.status))
288
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
289
+ const control = runControlDirective(context, run.project_id, run.id);
290
+ if (control)
291
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
292
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
293
+ if (active) {
294
+ 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]);
295
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
296
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
297
+ }
298
+ if (run.status === "waiting_for_user" || run.status === "paused")
299
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
300
+ if (run.status === "blocked")
301
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
302
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
303
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
304
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
305
+ if (!next)
306
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
307
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
308
+ const profile = index.execution_profile;
309
+ const execution = profile?.settings.execution ? profile.agent_profiles
310
+ ? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
311
+ : { status: "unfrozen", reason: "execution_profile_not_frozen" }
312
+ : undefined;
313
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
314
+ }
315
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
316
  }
234
317
  export function getFlowRunConfig(context, input) {
235
318
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -250,9 +333,12 @@ export function getFlowRunVariables(context, input) {
250
333
  return { ok: true, run_id: run.id, variables };
251
334
  }
252
335
  export function setFlowRunVariable(context, input) {
336
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
337
+ }
338
+ export function prepareFlowRunVariable(context, input) {
253
339
  if (!input.key.startsWith("user."))
254
340
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
255
- return setRunVariable(context, input, input.value);
341
+ return prepareRunVariable(context, input);
256
342
  }
257
343
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
258
344
  export function setRuntimeRunVariable(context, input) {
@@ -261,20 +347,7 @@ export function setRuntimeRunVariable(context, input) {
261
347
  return setRunVariable(context, input, input.value);
262
348
  }
263
349
  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
- }
350
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
278
351
  const now = context.now();
279
352
  const settings = runSettings(index);
280
353
  const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
@@ -292,10 +365,29 @@ export function setFlowRunConfig(context, input) {
292
365
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
293
366
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
294
367
  }
368
+ export function prepareFlowRunConfig(context, input) {
369
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
370
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
371
+ }
372
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
373
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
374
+ }
375
+ const reason = requiredPlain(input.reason, "reason");
376
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
377
+ const run = resolveRun(context, project.id, input.runId);
378
+ assertRunMutationAllowed(context, project.id, run.id);
379
+ const index = authoritativeIndex(run);
380
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
381
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
382
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
383
+ }
384
+ return { reason, project, run, index };
385
+ }
295
386
  /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
296
387
  export function freezeFlowRunReviewMode(context, input) {
297
388
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
298
389
  const run = resolveRun(context, project.id, input.runId);
390
+ assertRunMutationAllowed(context, project.id, run.id);
299
391
  const index = authoritativeIndex(run);
300
392
  const settings = runSettings(index);
301
393
  const key = input.review === "plan" ? "plan_review" : "code_review";
@@ -337,8 +429,9 @@ export function getFlowRunFlagsStatus(context, input) {
337
429
  export function reviseFlowRunFlags(context, input) {
338
430
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
339
431
  const run = resolveRun(context, project.id, input.runId);
432
+ assertRunMutationAllowed(context, project.id, run.id);
340
433
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
341
- context.db.exec("BEGIN IMMEDIATE");
434
+ context.db.beginWriteTransaction();
342
435
  let committed = false;
343
436
  try {
344
437
  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]);
@@ -459,18 +552,24 @@ export function listFlowRuns(context, input) {
459
552
  .map(flowRunSummary);
460
553
  return { ok: true, project, runs };
461
554
  }
462
- export function attachFlowRunStage(context, input) {
555
+ export function prepareFlowRunStageAttachment(context, input) {
463
556
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
464
557
  const run = resolveRun(context, project.id, input.runId);
558
+ assertRunMutationAllowed(context, project.id, run.id);
465
559
  const index = authoritativeIndex(run);
466
- const now = context.now();
467
560
  const stage = requiredPlain(input.stage, "stage");
468
- const dir = requiredStageDir(input.dir);
561
+ const dir = prepareStageDirectory(input.dir);
469
562
  const status = parseStageStatus(input.status);
470
563
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
564
  assertVnextStageStart(run, index, stage, status, existing);
565
+ return { project, run, index, stage, dir, status, existing };
566
+ }
567
+ export function attachFlowRunStage(context, input) {
568
+ // Mutable stage state is re-read immediately before archiving/persisting.
569
+ const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
570
+ const now = context.now();
472
571
  if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
572
+ archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
474
573
  }
475
574
  const stageRun = {
476
575
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -506,17 +605,39 @@ export function attachFlowRunStage(context, input) {
506
605
  const updatedRun = requireRunById(context, project.id, run.id);
507
606
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
508
607
  }
509
- export function completeFlowRunStage(context, input) {
608
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
609
+ export function withStageSettlement(context, operation) {
610
+ return context.db.writeTransaction(operation);
611
+ }
612
+ export function prepareFlowRunStageCompletion(context, input) {
613
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
614
+ if (value !== undefined)
615
+ requiredPlain(value, name);
616
+ }
617
+ if (input.aliases !== undefined) {
618
+ if (!Array.isArray(input.aliases))
619
+ throw new AppError("validation", "artifact aliases must be an array", 2);
620
+ for (const alias of input.aliases)
621
+ requiredPlain(alias, "artifact alias");
622
+ }
510
623
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
511
624
  const run = resolveRun(context, project.id, input.runId);
625
+ if (["done", "failed", "skipped"].includes(input.status))
626
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
627
+ else
628
+ assertRunMutationAllowed(context, project.id, run.id);
512
629
  const index = authoritativeIndex(run);
513
- const now = context.now();
514
630
  const stage = requiredPlain(input.stage, "stage");
515
631
  const status = parseStageStatus(input.status);
516
632
  const existing = index.stage_runs.find((item) => item.stage === stage);
517
633
  if (!existing) {
518
634
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
519
635
  }
636
+ return { project, run, index, stage, status, existing };
637
+ }
638
+ export function completeFlowRunStage(context, input) {
639
+ const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
640
+ const now = context.now();
520
641
  const stageRun = {
521
642
  ...existing,
522
643
  status,
@@ -524,6 +645,7 @@ export function completeFlowRunStage(context, input) {
524
645
  ...(input.data ? { data: input.data } : {}),
525
646
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
526
647
  ...(input.report ? { report: input.report } : {}),
648
+ ...(input.usage ? { usage: input.usage } : {}),
527
649
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
528
650
  updated_at: now,
529
651
  ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
@@ -566,6 +688,7 @@ export function completeFlowRunStage(context, input) {
566
688
  export function pauseFlowRunStage(context, input) {
567
689
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
568
690
  const run = resolveRun(context, project.id, input.runId);
691
+ assertRunMutationAllowed(context, project.id, run.id);
569
692
  const index = authoritativeIndex(run);
570
693
  const stage = requiredPlain(input.stage, "stage");
571
694
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -601,6 +724,7 @@ export function pauseFlowRunStage(context, input) {
601
724
  export function resumeFlowRunStage(context, input) {
602
725
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
603
726
  const run = resolveRun(context, project.id, input.runId);
727
+ assertRunMutationAllowed(context, project.id, run.id);
604
728
  const index = authoritativeIndex(run);
605
729
  const stage = requiredPlain(input.stage, "stage");
606
730
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -629,14 +753,23 @@ export function resumeFlowRunStage(context, input) {
629
753
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
630
754
  }
631
755
  /** Pause the current stage for a non-user runtime blocker. */
632
- export function blockFlowRunStage(context, input) {
756
+ export function prepareFlowRunStageBlock(context, input) {
757
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
758
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
759
+ requiredPlain(input.code, "code");
760
+ requiredPlain(input.summary, "summary");
633
761
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
634
762
  const run = resolveRun(context, project.id, input.runId);
763
+ assertRunMutationAllowed(context, project.id, run.id);
635
764
  const index = authoritativeIndex(run);
636
765
  const stage = requiredPlain(input.stage, "stage");
637
766
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
638
767
  if (!stageRun || stageRun.status !== "running")
639
768
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
769
+ return { project, run, index, stage, stageRun };
770
+ }
771
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
772
+ const { project, run, index, stage, stageRun } = prepared;
640
773
  const now = context.now();
641
774
  stageRun.status = "blocked";
642
775
  stageRun.updated_at = now;
@@ -654,16 +787,20 @@ export function blockFlowRunStage(context, input) {
654
787
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
655
788
  }
656
789
  /** Continue the same stage after its non-user blocker was repaired externally. */
657
- export function unblockFlowRunStage(context, input) {
790
+ export function prepareFlowRunStageUnblock(context, input) {
658
791
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
659
792
  const run = resolveRun(context, project.id, input.runId);
793
+ assertRunMutationAllowed(context, project.id, run.id);
660
794
  const index = authoritativeIndex(run);
661
795
  const stage = requiredPlain(input.stage, "stage");
662
796
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
663
797
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
664
798
  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" });
799
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
800
+ }
801
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
802
+ const { project, run, index, stage, stageRun, blocker } = prepared;
665
803
  const now = context.now();
666
- const blocker = stageRun.blocker;
667
804
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
668
805
  stageRun.status = "running";
669
806
  stageRun.updated_at = now;
@@ -687,11 +824,36 @@ function operationalRunStatus(context, projectId, runId) {
687
824
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
688
825
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
689
826
  }
690
- export function completeFlowRun(context, input) {
691
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
827
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
828
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
829
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
830
+ const run = resolveRun(context, projectId, runId);
831
+ if (!["running", "paused"].includes(run.status))
832
+ return;
833
+ const index = authoritativeIndex(run);
834
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
835
+ ? "paused" : operationalRunStatus(context, projectId, runId);
836
+ if (run.status === status && index.status === status)
837
+ return;
838
+ index.status = status;
839
+ index.updated_at = context.now();
840
+ const project = requireProjectByRoot(context, run.project_root);
841
+ persistRunState(context, project, run, index);
842
+ }
843
+ export function prepareFlowRunCompletion(context, input) {
844
+ if (input.manualOverrideReason !== undefined) {
845
+ requiredPlain(input.manualOverrideReason, "override reason");
846
+ if (!["cancelled", "failed"].includes(input.status))
847
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
848
+ }
849
+ if (input.verdict !== undefined)
850
+ requiredPlain(input.verdict, "verdict");
851
+ if (input.nextAction !== undefined)
852
+ requiredPlain(input.nextAction, "next action");
853
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
692
854
  const run = resolveRun(context, project.id, input.runId);
855
+ assertRunMutationAllowed(context, project.id, run.id);
693
856
  const index = authoritativeIndex(run);
694
- const now = context.now();
695
857
  const status = parseRunStatus(input.status);
696
858
  if (!['done', 'cancelled', 'failed'].includes(status)) {
697
859
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -701,6 +863,11 @@ export function completeFlowRun(context, input) {
701
863
  if (activeWork)
702
864
  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
865
  }
866
+ return { project, run, index, status };
867
+ }
868
+ export function completeFlowRun(context, input) {
869
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
870
+ const now = context.now();
704
871
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
705
872
  closeOpenStagesForOverride(index, status, now);
706
873
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -743,16 +910,85 @@ export function advanceFlowRun(context, input) {
743
910
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
744
911
  const run = resolveRun(context, project.id, input.runId);
745
912
  const index = authoritativeIndex(run);
913
+ 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)))
914
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
915
+ else
916
+ assertRunMutationAllowed(context, project.id, run.id);
917
+ const control = runControlDirective(context, project.id, run.id);
746
918
  const now = context.now();
747
919
  index.status = input.status;
748
920
  index.verdict = input.verdict;
749
- index.next_action = input.nextAction ?? null;
921
+ index.next_action = control ? null : input.nextAction ?? null;
750
922
  index.updated_at = now;
751
923
  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 });
924
+ 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 } });
925
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
754
926
  const updatedRun = requireRunById(context, project.id, run.id);
755
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
927
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
928
+ }
929
+ /**
930
+ * The only legal backward edge in vNext: an integration gate exposed a
931
+ * product defect, so the source must be repaired and reviewed again. Normal
932
+ * stage start remains fail-closed for completed stages.
933
+ */
934
+ export function prepareVnextMergeSourceRepairAttempt(context, input) {
935
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
936
+ const run = resolveRun(context, project.id, input.runId);
937
+ assertRunMutationAllowed(context, project.id, run.id);
938
+ const index = authoritativeIndex(run);
939
+ const priorRepair = index.variables?.["merge.source_repair"];
940
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
941
+ return { run_id: run.id, cycle: priorRepair.cycle };
942
+ const code = index.stage_runs.find((item) => item.stage === "code");
943
+ const review = index.stage_runs.find((item) => item.stage === "code-review");
944
+ const merge = index.stage_runs.find((item) => item.stage === "merge");
945
+ if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
946
+ throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
947
+ run_id: run.id,
948
+ code: code?.status ?? null,
949
+ code_review: review?.status ?? null,
950
+ merge: merge?.status ?? null
951
+ });
952
+ }
953
+ const root = runArtifactRoot(run);
954
+ for (const stage of ["code", "code-review", "merge"])
955
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
956
+ const now = context.now();
957
+ const restarted = {
958
+ ...code,
959
+ status: "running",
960
+ started_at: now,
961
+ updated_at: now,
962
+ attempt: nextAttempt(code)
963
+ };
964
+ delete restarted.completed_at;
965
+ delete restarted.duration_ms;
966
+ delete restarted.stage_report;
967
+ delete restarted.data;
968
+ delete restarted.report;
969
+ delete restarted.artifact_aliases;
970
+ const cycle = `MRR-${input.mergeRequestId}`;
971
+ index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
972
+ index.stage_runs.push(restarted);
973
+ index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
974
+ index.status = "running";
975
+ index.verdict = "merge_source_repair";
976
+ index.next_action = "start_code_repair";
977
+ index.current_stage = "code";
978
+ index.updated_at = now;
979
+ index.attempts = index.stage_runs.map((item) => ({
980
+ stage: item.stage,
981
+ attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
982
+ root: item.dir,
983
+ archive: null,
984
+ status: item.status,
985
+ started_at: item.started_at ?? now,
986
+ finished_at: item.completed_at ?? null
987
+ }));
988
+ persistRunState(context, project, run, index);
989
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
990
+ appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
991
+ return { run_id: run.id, cycle };
756
992
  }
757
993
  function closeOpenStagesForOverride(index, status, now) {
758
994
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
@@ -1018,6 +1254,10 @@ function vnextSpecifyGuidance(run, index) {
1018
1254
  };
1019
1255
  }
1020
1256
  function persistRunState(context, project, run, index, options = {}) {
1257
+ // Fenced productive admission is a logical pause, even while physical work
1258
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1259
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1260
+ index.status = "paused";
1021
1261
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1022
1262
  const previousRuntime = readRuntimeSnapshot(runtimePath);
1023
1263
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -1028,22 +1268,30 @@ function persistRunState(context, project, run, index, options = {}) {
1028
1268
  }
1029
1269
  const persistedIndex = persistedVnextIndex(index);
1030
1270
  ensureDir(path.dirname(runtimePath));
1031
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1032
- context.db.run(`UPDATE runs
1271
+ context.db.exec("SAVEPOINT persist_run_state");
1272
+ try {
1273
+ context.db.run(`UPDATE runs
1033
1274
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1034
1275
  updated_at = ?, completed_at = ?
1035
1276
  WHERE project_id = ? AND id = ?`, [
1036
- index.status,
1037
- index.verdict,
1038
- index.next_action,
1039
- JSON.stringify(persistedIndex),
1040
- runtimePath,
1041
- runtimePath,
1042
- index.updated_at,
1043
- index.completed_at ?? null,
1044
- project.id,
1045
- run.id
1046
- ]);
1277
+ index.status,
1278
+ index.verdict,
1279
+ index.next_action,
1280
+ JSON.stringify(persistedIndex),
1281
+ runtimePath,
1282
+ runtimePath,
1283
+ index.updated_at,
1284
+ index.completed_at ?? null,
1285
+ project.id,
1286
+ run.id
1287
+ ]);
1288
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1289
+ context.db.exec("RELEASE persist_run_state");
1290
+ }
1291
+ catch (error) {
1292
+ context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1293
+ throw error;
1294
+ }
1047
1295
  }
1048
1296
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1049
1297
  function persistedVnextIndex(index) {
@@ -1085,10 +1333,15 @@ function materializedRunVariables(index) {
1085
1333
  ...(index.variables ?? {})
1086
1334
  };
1087
1335
  }
1088
- function setRunVariable(context, input, value) {
1336
+ function prepareRunVariable(context, input) {
1089
1337
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1090
1338
  const run = resolveRun(context, project.id, input.runId);
1339
+ assertRunMutationAllowed(context, project.id, run.id);
1091
1340
  const index = authoritativeIndex(run);
1341
+ return { project, run, index };
1342
+ }
1343
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1344
+ const { project, run, index } = prepared;
1092
1345
  if (index.variables?.[input.key] === value)
1093
1346
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1094
1347
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
@@ -1139,10 +1392,12 @@ function readRuntimeSnapshot(file) {
1139
1392
  function authoritativeIndex(run) {
1140
1393
  try {
1141
1394
  const state = JSON.parse(run.index_json);
1395
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1396
+ throw new Error("RUN index must contain stage_runs");
1142
1397
  return { ...state, schema_id: runSchemaId };
1143
1398
  }
1144
1399
  catch (error) {
1145
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1400
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1146
1401
  }
1147
1402
  }
1148
1403
  function sanitizeTimelineValue(value, key) {
@@ -1203,7 +1458,7 @@ function upsertStage(index, stageRun) {
1203
1458
  }
1204
1459
  index.stage_runs.sort((a, b) => a.order - b.order);
1205
1460
  }
1206
- function resolveRun(context, projectId, idOrAlias) {
1461
+ export function resolveRun(context, projectId, idOrAlias) {
1207
1462
  if (isFullEntityId(idOrAlias)) {
1208
1463
  return requireRunById(context, projectId, idOrAlias);
1209
1464
  }
@@ -1297,7 +1552,7 @@ function parseStageStatus(value) {
1297
1552
  }
1298
1553
  return value;
1299
1554
  }
1300
- function requiredStageDir(value) {
1555
+ export function prepareStageDirectory(value) {
1301
1556
  const dir = requiredPlain(value, "dir");
1302
1557
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1303
1558
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1305,6 +1560,8 @@ function requiredStageDir(value) {
1305
1560
  return dir;
1306
1561
  }
1307
1562
  function requiredPlain(value, label) {
1563
+ if (typeof value !== "string")
1564
+ throw new AppError("validation", `${label} must be a string`, 2);
1308
1565
  const trimmed = value.trim();
1309
1566
  if (!trimmed) {
1310
1567
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1312,6 +1569,8 @@ function requiredPlain(value, label) {
1312
1569
  return trimmed;
1313
1570
  }
1314
1571
  function normalizeSlug(value) {
1572
+ if (typeof value !== "string")
1573
+ throw new AppError("validation", "--slug must be a string", 2);
1315
1574
  const slug = value
1316
1575
  .normalize("NFKD")
1317
1576
  .replace(/[\u0300-\u036f]/g, "")
@@ -1329,26 +1588,59 @@ function resolveWorkspaceRoot(workspaceRoot) {
1329
1588
  if (!fs.existsSync(absolute)) {
1330
1589
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1331
1590
  }
1591
+ if (!fs.statSync(absolute).isDirectory())
1592
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1332
1593
  return fs.realpathSync(absolute);
1333
1594
  }
1334
- function archiveExistingStageAttempt(runRoot, stageDir) {
1595
+ /** Move one completed stage attempt and update only known durable path columns.
1596
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1597
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1335
1598
  const currentStageDir = path.join(runRoot, stageDir);
1336
1599
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1337
1600
  return;
1338
1601
  }
1339
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1340
- if (entries.length === 0) {
1602
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1603
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1604
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1605
+ if (entries.length === 0 && !pending) {
1341
1606
  return;
1342
1607
  }
1343
1608
  let attempt = 1;
1344
1609
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1345
1610
  attempt += 1;
1346
1611
  }
1347
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1612
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1613
+ if (!pending) {
1614
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1615
+ const prior = stored ? JSON.parse(stored.index_json) : null;
1616
+ writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1617
+ }
1348
1618
  ensureDir(archiveDir);
1349
1619
  for (const entry of entries) {
1350
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1620
+ const source = path.join(currentStageDir, entry);
1621
+ const target = path.join(archiveDir, entry);
1622
+ if (fs.existsSync(source)) {
1623
+ if (fs.existsSync(target))
1624
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1625
+ fs.renameSync(source, target);
1626
+ }
1627
+ else if (!fs.existsSync(target))
1628
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1351
1629
  }
1630
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1631
+ const newPrefix = `${archiveDir}${path.sep}`;
1632
+ const rewrite = (table, column, where, values) => {
1633
+ 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]);
1634
+ };
1635
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1636
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1637
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1638
+ const receiptScope = "project_id = ? AND run_id = ?";
1639
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1640
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1641
+ // Retain the original stage facts with its immutable artifacts; current
1642
+ // stage_runs stays a projection of the current attempt, not a second ledger.
1643
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1352
1644
  }
1353
1645
  function writeJsonFile(file, value) {
1354
1646
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1373,3 +1665,5 @@ export function gitFacts(workspaceRoot) {
1373
1665
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1374
1666
  };
1375
1667
  }
1668
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1669
+ import { runControlDirective } from "./run-control.js";