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

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 (151) hide show
  1. package/CHANGELOG.md +491 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1437 -384
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  18. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  19. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  20. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +502 -0
  22. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  23. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  25. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  26. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  27. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  28. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +387 -0
  29. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  30. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  31. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  32. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +640 -0
  33. package/dist/harness-runtime/lib/dd-zcode.mjs +978 -0
  34. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.mjs +135 -0
  36. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  37. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  38. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  39. package/dist/harness-runtime/lib/managed-daemon.mjs +296 -0
  40. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  41. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  42. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  43. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  44. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  45. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  46. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  47. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  48. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  49. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  50. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  51. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  52. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  53. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  54. package/dist/schemas/agent-profile.schema.json +1 -1
  55. package/dist/schemas/code-review-result.schema.json +1 -1
  56. package/dist/schemas/code-work-batch.schema.json +3 -3
  57. package/dist/schemas/code-work-result.schema.json +3 -3
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +57 -13
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +583 -99
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +165 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +121 -76
  73. package/dist/services/eval-snapshots.js +952 -83
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +165 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +2 -0
  78. package/dist/services/hooks.js +596 -300
  79. package/dist/services/lanes.js +61 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +283 -27
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +73 -28
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/projects.js +18 -5
  93. package/dist/services/prompts.js +25 -16
  94. package/dist/services/protocols.js +64 -20
  95. package/dist/services/recovery-observation-budget.js +61 -0
  96. package/dist/services/recovery-snapshot-database.js +107 -0
  97. package/dist/services/repair-intents.js +40 -0
  98. package/dist/services/review-copy.js +136 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +368 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +231 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +1 -1
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-recovery-runtime.js +69 -0
  112. package/dist/services/run-recovery.js +343 -0
  113. package/dist/services/runs.js +371 -74
  114. package/dist/services/runtime-budget.js +439 -0
  115. package/dist/services/runtime-command.js +43 -0
  116. package/dist/services/runtime-scope-capture.js +50 -0
  117. package/dist/services/runtime-scope-control.js +450 -0
  118. package/dist/services/runtime-scope-resume.js +565 -0
  119. package/dist/services/runtime-scope-stop.js +159 -0
  120. package/dist/services/runtime-scope-worker.js +243 -0
  121. package/dist/services/runtime-service.js +97 -0
  122. package/dist/services/schema-validation.js +10 -8
  123. package/dist/services/sessions.js +35 -69
  124. package/dist/services/stage-blocker.js +17 -7
  125. package/dist/services/stage-context.js +44 -18
  126. package/dist/services/stage-lifecycle.js +99 -102
  127. package/dist/services/stage-pause.js +84 -55
  128. package/dist/services/stage-work-graph.js +35 -0
  129. package/dist/services/usage.js +37 -93
  130. package/dist/services/vnext-code-review.js +190 -80
  131. package/dist/services/vnext-code.js +283 -248
  132. package/dist/services/vnext-execution-profile.js +5 -3
  133. package/dist/services/vnext-fanout.js +172 -17
  134. package/dist/services/vnext-merge.js +296 -105
  135. package/dist/services/vnext-plan-review.js +141 -94
  136. package/dist/services/vnext-plan.js +142 -69
  137. package/dist/services/vnext-protocolize.js +132 -57
  138. package/dist/services/vnext-specify.js +95 -65
  139. package/dist/services/work-registry.js +891 -162
  140. package/dist/services/workspace-bootstrap.js +76 -0
  141. package/dist/services/workspace-files.js +83 -0
  142. package/dist/services/worktrees.js +88 -36
  143. package/dist/shared/entity-references.js +8 -0
  144. package/dist/shared/errors.js +12 -0
  145. package/dist/storage/database.js +459 -32
  146. package/dist/storage/paths.js +17 -4
  147. package/dist/storage/work-references.js +23 -0
  148. package/dist/storage/writer-contract.js +77 -0
  149. package/dist/storage/writer-migration.js +102 -0
  150. package/package.json +24 -13
  151. 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,24 +552,31 @@ 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 }),
477
576
  stage,
478
577
  dir,
479
578
  status,
579
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
480
580
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
481
581
  updated_at: now
482
582
  };
@@ -506,24 +606,49 @@ export function attachFlowRunStage(context, input) {
506
606
  const updatedRun = requireRunById(context, project.id, run.id);
507
607
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
508
608
  }
509
- export function completeFlowRunStage(context, input) {
609
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
610
+ export function withStageSettlement(context, operation) {
611
+ return context.db.writeTransaction(operation);
612
+ }
613
+ export function prepareFlowRunStageCompletion(context, input) {
614
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
615
+ if (value !== undefined)
616
+ requiredPlain(value, name);
617
+ }
618
+ if (input.aliases !== undefined) {
619
+ if (!Array.isArray(input.aliases))
620
+ throw new AppError("validation", "artifact aliases must be an array", 2);
621
+ for (const alias of input.aliases)
622
+ requiredPlain(alias, "artifact alias");
623
+ }
510
624
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
511
625
  const run = resolveRun(context, project.id, input.runId);
626
+ if (["done", "failed", "skipped"].includes(input.status))
627
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
628
+ else
629
+ assertRunMutationAllowed(context, project.id, run.id);
512
630
  const index = authoritativeIndex(run);
513
- const now = context.now();
514
631
  const stage = requiredPlain(input.stage, "stage");
515
632
  const status = parseStageStatus(input.status);
516
633
  const existing = index.stage_runs.find((item) => item.stage === stage);
517
634
  if (!existing) {
518
635
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
519
636
  }
637
+ return { project, run, index, stage, status, existing };
638
+ }
639
+ export function completeFlowRunStage(context, input) {
640
+ const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
641
+ const now = context.now();
642
+ const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
520
643
  const stageRun = {
521
644
  ...existing,
522
645
  status,
523
646
  ...(input.stageReport ? { stage_report: input.stageReport } : {}),
524
647
  ...(input.data ? { data: input.data } : {}),
648
+ ...(dataPath && fs.existsSync(dataPath) ? { data_sha256: crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") } : {}),
525
649
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
526
650
  ...(input.report ? { report: input.report } : {}),
651
+ ...(input.usage ? { usage: input.usage } : {}),
527
652
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
528
653
  updated_at: now,
529
654
  ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
@@ -566,6 +691,7 @@ export function completeFlowRunStage(context, input) {
566
691
  export function pauseFlowRunStage(context, input) {
567
692
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
568
693
  const run = resolveRun(context, project.id, input.runId);
694
+ assertRunMutationAllowed(context, project.id, run.id);
569
695
  const index = authoritativeIndex(run);
570
696
  const stage = requiredPlain(input.stage, "stage");
571
697
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -601,6 +727,7 @@ export function pauseFlowRunStage(context, input) {
601
727
  export function resumeFlowRunStage(context, input) {
602
728
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
603
729
  const run = resolveRun(context, project.id, input.runId);
730
+ assertRunMutationAllowed(context, project.id, run.id);
604
731
  const index = authoritativeIndex(run);
605
732
  const stage = requiredPlain(input.stage, "stage");
606
733
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -629,14 +756,23 @@ export function resumeFlowRunStage(context, input) {
629
756
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
630
757
  }
631
758
  /** Pause the current stage for a non-user runtime blocker. */
632
- export function blockFlowRunStage(context, input) {
759
+ export function prepareFlowRunStageBlock(context, input) {
760
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
761
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
762
+ requiredPlain(input.code, "code");
763
+ requiredPlain(input.summary, "summary");
633
764
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
634
765
  const run = resolveRun(context, project.id, input.runId);
766
+ assertRunMutationAllowed(context, project.id, run.id);
635
767
  const index = authoritativeIndex(run);
636
768
  const stage = requiredPlain(input.stage, "stage");
637
769
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
638
770
  if (!stageRun || stageRun.status !== "running")
639
771
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
772
+ return { project, run, index, stage, stageRun };
773
+ }
774
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
775
+ const { project, run, index, stage, stageRun } = prepared;
640
776
  const now = context.now();
641
777
  stageRun.status = "blocked";
642
778
  stageRun.updated_at = now;
@@ -654,16 +790,20 @@ export function blockFlowRunStage(context, input) {
654
790
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
655
791
  }
656
792
  /** Continue the same stage after its non-user blocker was repaired externally. */
657
- export function unblockFlowRunStage(context, input) {
793
+ export function prepareFlowRunStageUnblock(context, input) {
658
794
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
659
795
  const run = resolveRun(context, project.id, input.runId);
796
+ assertRunMutationAllowed(context, project.id, run.id);
660
797
  const index = authoritativeIndex(run);
661
798
  const stage = requiredPlain(input.stage, "stage");
662
799
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
663
800
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
664
801
  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" });
802
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
803
+ }
804
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
805
+ const { project, run, index, stage, stageRun, blocker } = prepared;
665
806
  const now = context.now();
666
- const blocker = stageRun.blocker;
667
807
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
668
808
  stageRun.status = "running";
669
809
  stageRun.updated_at = now;
@@ -687,11 +827,36 @@ function operationalRunStatus(context, projectId, runId) {
687
827
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
688
828
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
689
829
  }
690
- export function completeFlowRun(context, input) {
691
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
830
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
831
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
832
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
833
+ const run = resolveRun(context, projectId, runId);
834
+ if (!["running", "paused"].includes(run.status))
835
+ return;
836
+ const index = authoritativeIndex(run);
837
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
838
+ ? "paused" : operationalRunStatus(context, projectId, runId);
839
+ if (run.status === status && index.status === status)
840
+ return;
841
+ index.status = status;
842
+ index.updated_at = context.now();
843
+ const project = requireProjectByRoot(context, run.project_root);
844
+ persistRunState(context, project, run, index);
845
+ }
846
+ export function prepareFlowRunCompletion(context, input) {
847
+ if (input.manualOverrideReason !== undefined) {
848
+ requiredPlain(input.manualOverrideReason, "override reason");
849
+ if (!["cancelled", "failed"].includes(input.status))
850
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
851
+ }
852
+ if (input.verdict !== undefined)
853
+ requiredPlain(input.verdict, "verdict");
854
+ if (input.nextAction !== undefined)
855
+ requiredPlain(input.nextAction, "next action");
856
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
692
857
  const run = resolveRun(context, project.id, input.runId);
858
+ assertRunMutationAllowed(context, project.id, run.id);
693
859
  const index = authoritativeIndex(run);
694
- const now = context.now();
695
860
  const status = parseRunStatus(input.status);
696
861
  if (!['done', 'cancelled', 'failed'].includes(status)) {
697
862
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -701,6 +866,11 @@ export function completeFlowRun(context, input) {
701
866
  if (activeWork)
702
867
  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
868
  }
869
+ return { project, run, index, status };
870
+ }
871
+ export function completeFlowRun(context, input) {
872
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
873
+ const now = context.now();
704
874
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
705
875
  closeOpenStagesForOverride(index, status, now);
706
876
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -743,16 +913,85 @@ export function advanceFlowRun(context, input) {
743
913
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
744
914
  const run = resolveRun(context, project.id, input.runId);
745
915
  const index = authoritativeIndex(run);
916
+ 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)))
917
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
918
+ else
919
+ assertRunMutationAllowed(context, project.id, run.id);
920
+ const control = runControlDirective(context, project.id, run.id);
746
921
  const now = context.now();
747
922
  index.status = input.status;
748
923
  index.verdict = input.verdict;
749
- index.next_action = input.nextAction ?? null;
924
+ index.next_action = control ? null : input.nextAction ?? null;
750
925
  index.updated_at = now;
751
926
  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 });
927
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
928
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
754
929
  const updatedRun = requireRunById(context, project.id, run.id);
755
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
930
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
931
+ }
932
+ /**
933
+ * The only legal backward edge in vNext: an integration gate exposed a
934
+ * product defect, so the source must be repaired and reviewed again. Normal
935
+ * stage start remains fail-closed for completed stages.
936
+ */
937
+ export function prepareVnextMergeSourceRepairAttempt(context, input) {
938
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
939
+ const run = resolveRun(context, project.id, input.runId);
940
+ assertRunMutationAllowed(context, project.id, run.id);
941
+ const index = authoritativeIndex(run);
942
+ const priorRepair = index.variables?.["merge.source_repair"];
943
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
944
+ return { run_id: run.id, cycle: priorRepair.cycle };
945
+ const code = index.stage_runs.find((item) => item.stage === "code");
946
+ const review = index.stage_runs.find((item) => item.stage === "code-review");
947
+ const merge = index.stage_runs.find((item) => item.stage === "merge");
948
+ if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
949
+ throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
950
+ run_id: run.id,
951
+ code: code?.status ?? null,
952
+ code_review: review?.status ?? null,
953
+ merge: merge?.status ?? null
954
+ });
955
+ }
956
+ const root = runArtifactRoot(run);
957
+ for (const stage of ["code", "code-review", "merge"])
958
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
959
+ const now = context.now();
960
+ const restarted = {
961
+ ...code,
962
+ status: "running",
963
+ started_at: now,
964
+ updated_at: now,
965
+ attempt: nextAttempt(code)
966
+ };
967
+ delete restarted.completed_at;
968
+ delete restarted.duration_ms;
969
+ delete restarted.stage_report;
970
+ delete restarted.data;
971
+ delete restarted.report;
972
+ delete restarted.artifact_aliases;
973
+ const cycle = `MRR-${input.mergeRequestId}`;
974
+ index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
975
+ index.stage_runs.push(restarted);
976
+ index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
977
+ index.status = "running";
978
+ index.verdict = "merge_source_repair";
979
+ index.next_action = "start_code_repair";
980
+ index.current_stage = "code";
981
+ index.updated_at = now;
982
+ index.attempts = index.stage_runs.map((item) => ({
983
+ stage: item.stage,
984
+ attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
985
+ root: item.dir,
986
+ archive: null,
987
+ status: item.status,
988
+ started_at: item.started_at ?? now,
989
+ finished_at: item.completed_at ?? null
990
+ }));
991
+ persistRunState(context, project, run, index);
992
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
993
+ appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
994
+ return { run_id: run.id, cycle };
756
995
  }
757
996
  function closeOpenStagesForOverride(index, status, now) {
758
997
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
@@ -1018,6 +1257,10 @@ function vnextSpecifyGuidance(run, index) {
1018
1257
  };
1019
1258
  }
1020
1259
  function persistRunState(context, project, run, index, options = {}) {
1260
+ // Fenced productive admission is a logical pause, even while physical work
1261
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1262
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1263
+ index.status = "paused";
1021
1264
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1022
1265
  const previousRuntime = readRuntimeSnapshot(runtimePath);
1023
1266
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -1028,22 +1271,30 @@ function persistRunState(context, project, run, index, options = {}) {
1028
1271
  }
1029
1272
  const persistedIndex = persistedVnextIndex(index);
1030
1273
  ensureDir(path.dirname(runtimePath));
1031
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1032
- context.db.run(`UPDATE runs
1274
+ context.db.exec("SAVEPOINT persist_run_state");
1275
+ try {
1276
+ context.db.run(`UPDATE runs
1033
1277
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1034
1278
  updated_at = ?, completed_at = ?
1035
1279
  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
- ]);
1280
+ index.status,
1281
+ index.verdict,
1282
+ index.next_action,
1283
+ JSON.stringify(persistedIndex),
1284
+ runtimePath,
1285
+ runtimePath,
1286
+ index.updated_at,
1287
+ index.completed_at ?? null,
1288
+ project.id,
1289
+ run.id
1290
+ ]);
1291
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1292
+ context.db.exec("RELEASE persist_run_state");
1293
+ }
1294
+ catch (error) {
1295
+ context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1296
+ throw error;
1297
+ }
1047
1298
  }
1048
1299
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1049
1300
  function persistedVnextIndex(index) {
@@ -1085,10 +1336,15 @@ function materializedRunVariables(index) {
1085
1336
  ...(index.variables ?? {})
1086
1337
  };
1087
1338
  }
1088
- function setRunVariable(context, input, value) {
1339
+ function prepareRunVariable(context, input) {
1089
1340
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1090
1341
  const run = resolveRun(context, project.id, input.runId);
1342
+ assertRunMutationAllowed(context, project.id, run.id);
1091
1343
  const index = authoritativeIndex(run);
1344
+ return { project, run, index };
1345
+ }
1346
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1347
+ const { project, run, index } = prepared;
1092
1348
  if (index.variables?.[input.key] === value)
1093
1349
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1094
1350
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
@@ -1139,10 +1395,12 @@ function readRuntimeSnapshot(file) {
1139
1395
  function authoritativeIndex(run) {
1140
1396
  try {
1141
1397
  const state = JSON.parse(run.index_json);
1398
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1399
+ throw new Error("RUN index must contain stage_runs");
1142
1400
  return { ...state, schema_id: runSchemaId };
1143
1401
  }
1144
1402
  catch (error) {
1145
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1403
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1146
1404
  }
1147
1405
  }
1148
1406
  function sanitizeTimelineValue(value, key) {
@@ -1203,7 +1461,7 @@ function upsertStage(index, stageRun) {
1203
1461
  }
1204
1462
  index.stage_runs.sort((a, b) => a.order - b.order);
1205
1463
  }
1206
- function resolveRun(context, projectId, idOrAlias) {
1464
+ export function resolveRun(context, projectId, idOrAlias) {
1207
1465
  if (isFullEntityId(idOrAlias)) {
1208
1466
  return requireRunById(context, projectId, idOrAlias);
1209
1467
  }
@@ -1297,7 +1555,7 @@ function parseStageStatus(value) {
1297
1555
  }
1298
1556
  return value;
1299
1557
  }
1300
- function requiredStageDir(value) {
1558
+ export function prepareStageDirectory(value) {
1301
1559
  const dir = requiredPlain(value, "dir");
1302
1560
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1303
1561
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1305,6 +1563,8 @@ function requiredStageDir(value) {
1305
1563
  return dir;
1306
1564
  }
1307
1565
  function requiredPlain(value, label) {
1566
+ if (typeof value !== "string")
1567
+ throw new AppError("validation", `${label} must be a string`, 2);
1308
1568
  const trimmed = value.trim();
1309
1569
  if (!trimmed) {
1310
1570
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1312,6 +1572,8 @@ function requiredPlain(value, label) {
1312
1572
  return trimmed;
1313
1573
  }
1314
1574
  function normalizeSlug(value) {
1575
+ if (typeof value !== "string")
1576
+ throw new AppError("validation", "--slug must be a string", 2);
1315
1577
  const slug = value
1316
1578
  .normalize("NFKD")
1317
1579
  .replace(/[\u0300-\u036f]/g, "")
@@ -1329,26 +1591,59 @@ function resolveWorkspaceRoot(workspaceRoot) {
1329
1591
  if (!fs.existsSync(absolute)) {
1330
1592
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1331
1593
  }
1594
+ if (!fs.statSync(absolute).isDirectory())
1595
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1332
1596
  return fs.realpathSync(absolute);
1333
1597
  }
1334
- function archiveExistingStageAttempt(runRoot, stageDir) {
1598
+ /** Move one completed stage attempt and update only known durable path columns.
1599
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1600
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1335
1601
  const currentStageDir = path.join(runRoot, stageDir);
1336
1602
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1337
1603
  return;
1338
1604
  }
1339
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1340
- if (entries.length === 0) {
1605
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1606
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1607
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1608
+ if (entries.length === 0 && !pending) {
1341
1609
  return;
1342
1610
  }
1343
1611
  let attempt = 1;
1344
1612
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1345
1613
  attempt += 1;
1346
1614
  }
1347
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1615
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1616
+ if (!pending) {
1617
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1618
+ const prior = stored ? JSON.parse(stored.index_json) : null;
1619
+ writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1620
+ }
1348
1621
  ensureDir(archiveDir);
1349
1622
  for (const entry of entries) {
1350
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1623
+ const source = path.join(currentStageDir, entry);
1624
+ const target = path.join(archiveDir, entry);
1625
+ if (fs.existsSync(source)) {
1626
+ if (fs.existsSync(target))
1627
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1628
+ fs.renameSync(source, target);
1629
+ }
1630
+ else if (!fs.existsSync(target))
1631
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1351
1632
  }
1633
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1634
+ const newPrefix = `${archiveDir}${path.sep}`;
1635
+ const rewrite = (table, column, where, values) => {
1636
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1637
+ };
1638
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1639
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1640
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1641
+ const receiptScope = "project_id = ? AND run_id = ?";
1642
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1643
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1644
+ // Retain the original stage facts with its immutable artifacts; current
1645
+ // stage_runs stays a projection of the current attempt, not a second ledger.
1646
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1352
1647
  }
1353
1648
  function writeJsonFile(file, value) {
1354
1649
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1373,3 +1668,5 @@ export function gitFacts(workspaceRoot) {
1373
1668
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1374
1669
  };
1375
1670
  }
1671
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1672
+ import { runControlDirective } from "./run-control.js";