@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -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 +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  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 +39 -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/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  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 +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  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 +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -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 +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  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/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -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 +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. 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,6 +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 { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
19
+ import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
20
+ import { publicSessionIdentity } from "./session-identity.js";
21
+ import { flowCommand } from "./stage-pause.js";
18
22
  const runSchemaId = "dd-flow/flow-run@3";
19
23
  const runtimeSchemaId = "dd-flow/flow-run@3";
20
24
  const runIdType = "RUN";
@@ -33,22 +37,26 @@ const allowedRunFlowKinds = [
33
37
  "review",
34
38
  "custom"
35
39
  ];
36
- 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
+ }
37
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;
38
55
  const flowContract = input.flowKind === "mb-upgrade"
39
56
  ? loadUpgradeFlowContract(context, projectRoot)
40
57
  : loadProjectFlowContract(projectRoot);
41
- registerProject(context, { root: projectRoot });
42
- const project = requireProjectByRoot(context, projectRoot);
43
58
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
44
- const flowKind = parseRunFlowKind(input.flowKind);
45
- const slug = normalizeSlug(input.slug);
46
- const runId = nextRunId(context, project.id, slug);
47
- const { shortId } = parseFullEntityId(runId);
48
59
  const now = context.now();
49
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
50
- ? snapshotVnextExecutionProfile(projectRoot)
51
- : undefined;
52
60
  const flowFlags = resolveFlowFlags(flowContract, {
53
61
  flowKind,
54
62
  ...(input.preset ? { preset: input.preset } : {}),
@@ -58,6 +66,14 @@ export function startFlowRun(context, input) {
58
66
  snapshotRevision: 1,
59
67
  now
60
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);
61
77
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
62
78
  const runDirRelative = path.posix.join("runs", runId);
63
79
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
@@ -67,8 +83,8 @@ export function startFlowRun(context, input) {
67
83
  short_id: shortId,
68
84
  flow_kind: flowKind,
69
85
  subject: {
70
- id: requiredPlain(input.subjectId, "subject-id"),
71
- title: requiredPlain(input.subjectId, "subject-id")
86
+ id: subjectId,
87
+ title: subjectId
72
88
  },
73
89
  project: {
74
90
  id: project.id,
@@ -124,7 +140,9 @@ export function startFlowRun(context, input) {
124
140
  };
125
141
  ensureDir(path.dirname(runtimePath));
126
142
  ensureDir(runHome);
127
- bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
143
+ if (routingInput)
144
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
145
+ bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
128
146
  const persistedIndex = persistedVnextIndex(index);
129
147
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
130
148
  context.db.run(`INSERT INTO runs
@@ -139,7 +157,7 @@ export function startFlowRun(context, input) {
139
157
  project.root,
140
158
  workspaceRoot,
141
159
  flowKind,
142
- requiredPlain(input.subjectType, "subject-type"),
160
+ subjectType,
143
161
  index.subject.id,
144
162
  index.status,
145
163
  index.verdict,
@@ -171,10 +189,10 @@ export function startFlowRun(context, input) {
171
189
  });
172
190
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
173
191
  }
174
- function snapshotVnextExecutionProfile(projectRoot) {
192
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
175
193
  const profile = loadVnextExecutionProfile(projectRoot);
176
194
  const sourcePath = executionProfilePath(projectRoot);
177
- return {
195
+ return freezeRunExecutionRouting(ddFlowHome, {
178
196
  schema_id: "dd-flow/run-execution-profile@1",
179
197
  source_path: sourcePath,
180
198
  source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
@@ -186,9 +204,41 @@ function snapshotVnextExecutionProfile(projectRoot) {
186
204
  merge_delivery: profile.merge_delivery,
187
205
  merge_cleanup: profile.merge_cleanup,
188
206
  stop_target: profile.stop_target,
189
- code_bootstrap: profile.code_bootstrap
207
+ code_bootstrap: profile.code_bootstrap,
208
+ ...(profile.execution ? { execution: profile.execution } : {})
190
209
  }
191
- };
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 });
192
242
  }
193
243
  /**
194
244
  * A vNext route may materialize a feature checkout after its semantic
@@ -198,6 +248,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
198
248
  export function rebindFlowRunWorkspace(context, input) {
199
249
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
200
250
  const run = resolveRun(context, project.id, input.runId);
251
+ assertRunMutationAllowed(context, project.id, run.id);
201
252
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
202
253
  const index = authoritativeIndex(run);
203
254
  const now = context.now();
@@ -227,7 +278,41 @@ export function getFlowRunStatus(context, input) {
227
278
  const run = resolveRun(context, project.id, input.runId);
228
279
  const index = authoritativeIndex(run);
229
280
  const runtime = readRuntimeSnapshot(run.runtime_path);
230
- 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 };
231
316
  }
232
317
  export function getFlowRunConfig(context, input) {
233
318
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -248,9 +333,12 @@ export function getFlowRunVariables(context, input) {
248
333
  return { ok: true, run_id: run.id, variables };
249
334
  }
250
335
  export function setFlowRunVariable(context, input) {
336
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
337
+ }
338
+ export function prepareFlowRunVariable(context, input) {
251
339
  if (!input.key.startsWith("user."))
252
340
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
253
- return setRunVariable(context, input, input.value);
341
+ return prepareRunVariable(context, input);
254
342
  }
255
343
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
256
344
  export function setRuntimeRunVariable(context, input) {
@@ -259,6 +347,25 @@ export function setRuntimeRunVariable(context, input) {
259
347
  return setRunVariable(context, input, input.value);
260
348
  }
261
349
  export function setFlowRunConfig(context, input) {
350
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
351
+ const now = context.now();
352
+ const settings = runSettings(index);
353
+ const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
354
+ const mode = input.value;
355
+ const source = input.source ?? "user_instruction";
356
+ if (previous.mode === mode && previous.reason === reason && previous.source === source) {
357
+ return { ok: true, run_id: run.id, settings, idempotent: true };
358
+ }
359
+ index.settings = input.key === "plan_review.mode"
360
+ ? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
361
+ : { ...settings, code_review: { mode, source, reason, updated_at: now } };
362
+ index.updated_at = now;
363
+ persistRunState(context, project, run, index);
364
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
365
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
366
+ return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
367
+ }
368
+ export function prepareFlowRunConfig(context, input) {
262
369
  if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
263
370
  throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
264
371
  }
@@ -268,27 +375,30 @@ export function setFlowRunConfig(context, input) {
268
375
  const reason = requiredPlain(input.reason, "reason");
269
376
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
270
377
  const run = resolveRun(context, project.id, input.runId);
378
+ assertRunMutationAllowed(context, project.id, run.id);
271
379
  const index = authoritativeIndex(run);
272
380
  const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
273
381
  if (index.stage_runs.some((stage) => stage.stage === stageName)) {
274
382
  throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
275
383
  }
276
- const now = context.now();
384
+ return { reason, project, run, index };
385
+ }
386
+ /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
387
+ export function freezeFlowRunReviewMode(context, input) {
388
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
389
+ const run = resolveRun(context, project.id, input.runId);
390
+ assertRunMutationAllowed(context, project.id, run.id);
391
+ const index = authoritativeIndex(run);
277
392
  const settings = runSettings(index);
278
- const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
279
- const mode = input.value;
280
- const source = input.source ?? "user_instruction";
281
- if (previous.mode === mode && previous.reason === reason && previous.source === source) {
282
- return { ok: true, run_id: run.id, settings, idempotent: true };
283
- }
284
- index.settings = input.key === "plan_review.mode"
285
- ? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
286
- : { ...settings, code_review: { mode, source, reason, updated_at: now } };
393
+ const key = input.review === "plan" ? "plan_review" : "code_review";
394
+ const current = settings[key];
395
+ if (current.mode !== "auto")
396
+ return;
397
+ const now = context.now();
398
+ index.settings = { ...settings, [key]: { mode: input.mode, source: input.source, reason: requiredPlain(input.reason, "reason"), updated_at: now } };
287
399
  index.updated_at = now;
288
400
  persistRunState(context, project, run, index);
289
- appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
290
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
291
- return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
401
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.review_mode_frozen", payload: { run_id: run.id, review: input.review, mode: input.mode, source: input.source, reason: input.reason } });
292
402
  }
293
403
  export function getFlowRunFlagsStatus(context, input) {
294
404
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -319,8 +429,9 @@ export function getFlowRunFlagsStatus(context, input) {
319
429
  export function reviseFlowRunFlags(context, input) {
320
430
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
321
431
  const run = resolveRun(context, project.id, input.runId);
432
+ assertRunMutationAllowed(context, project.id, run.id);
322
433
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
323
- context.db.exec("BEGIN IMMEDIATE");
434
+ context.db.beginWriteTransaction();
324
435
  let committed = false;
325
436
  try {
326
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]);
@@ -441,17 +552,24 @@ export function listFlowRuns(context, input) {
441
552
  .map(flowRunSummary);
442
553
  return { ok: true, project, runs };
443
554
  }
444
- export function attachFlowRunStage(context, input) {
555
+ export function prepareFlowRunStageAttachment(context, input) {
445
556
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
446
557
  const run = resolveRun(context, project.id, input.runId);
558
+ assertRunMutationAllowed(context, project.id, run.id);
447
559
  const index = authoritativeIndex(run);
448
- const now = context.now();
449
560
  const stage = requiredPlain(input.stage, "stage");
450
- const dir = requiredStageDir(input.dir);
561
+ const dir = prepareStageDirectory(input.dir);
451
562
  const status = parseStageStatus(input.status);
452
563
  const existing = index.stage_runs.find((item) => item.stage === stage);
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();
453
571
  if (status === "running" && existing) {
454
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
572
+ archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
455
573
  }
456
574
  const stageRun = {
457
575
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -487,17 +605,39 @@ export function attachFlowRunStage(context, input) {
487
605
  const updatedRun = requireRunById(context, project.id, run.id);
488
606
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
489
607
  }
490
- 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
+ }
491
623
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
492
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);
493
629
  const index = authoritativeIndex(run);
494
- const now = context.now();
495
630
  const stage = requiredPlain(input.stage, "stage");
496
631
  const status = parseStageStatus(input.status);
497
632
  const existing = index.stage_runs.find((item) => item.stage === stage);
498
633
  if (!existing) {
499
634
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
500
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();
501
641
  const stageRun = {
502
642
  ...existing,
503
643
  status,
@@ -505,6 +645,7 @@ export function completeFlowRunStage(context, input) {
505
645
  ...(input.data ? { data: input.data } : {}),
506
646
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
507
647
  ...(input.report ? { report: input.report } : {}),
648
+ ...(input.usage ? { usage: input.usage } : {}),
508
649
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
509
650
  updated_at: now,
510
651
  ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
@@ -547,6 +688,7 @@ export function completeFlowRunStage(context, input) {
547
688
  export function pauseFlowRunStage(context, input) {
548
689
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
549
690
  const run = resolveRun(context, project.id, input.runId);
691
+ assertRunMutationAllowed(context, project.id, run.id);
550
692
  const index = authoritativeIndex(run);
551
693
  const stage = requiredPlain(input.stage, "stage");
552
694
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -582,6 +724,7 @@ export function pauseFlowRunStage(context, input) {
582
724
  export function resumeFlowRunStage(context, input) {
583
725
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
584
726
  const run = resolveRun(context, project.id, input.runId);
727
+ assertRunMutationAllowed(context, project.id, run.id);
585
728
  const index = authoritativeIndex(run);
586
729
  const stage = requiredPlain(input.stage, "stage");
587
730
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -610,14 +753,23 @@ export function resumeFlowRunStage(context, input) {
610
753
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
611
754
  }
612
755
  /** Pause the current stage for a non-user runtime blocker. */
613
- 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");
614
761
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
615
762
  const run = resolveRun(context, project.id, input.runId);
763
+ assertRunMutationAllowed(context, project.id, run.id);
616
764
  const index = authoritativeIndex(run);
617
765
  const stage = requiredPlain(input.stage, "stage");
618
766
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
619
767
  if (!stageRun || stageRun.status !== "running")
620
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;
621
773
  const now = context.now();
622
774
  stageRun.status = "blocked";
623
775
  stageRun.updated_at = now;
@@ -635,16 +787,20 @@ export function blockFlowRunStage(context, input) {
635
787
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
636
788
  }
637
789
  /** Continue the same stage after its non-user blocker was repaired externally. */
638
- export function unblockFlowRunStage(context, input) {
790
+ export function prepareFlowRunStageUnblock(context, input) {
639
791
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
640
792
  const run = resolveRun(context, project.id, input.runId);
793
+ assertRunMutationAllowed(context, project.id, run.id);
641
794
  const index = authoritativeIndex(run);
642
795
  const stage = requiredPlain(input.stage, "stage");
643
796
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
644
797
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
645
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;
646
803
  const now = context.now();
647
- const blocker = stageRun.blocker;
648
804
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
649
805
  stageRun.status = "running";
650
806
  stageRun.updated_at = now;
@@ -668,15 +824,50 @@ function operationalRunStatus(context, projectId, runId) {
668
824
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
669
825
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
670
826
  }
671
- export function completeFlowRun(context, input) {
672
- 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));
673
854
  const run = resolveRun(context, project.id, input.runId);
855
+ assertRunMutationAllowed(context, project.id, run.id);
674
856
  const index = authoritativeIndex(run);
675
- const now = context.now();
676
857
  const status = parseRunStatus(input.status);
677
- if (!['done', 'blocked', 'cancelled', 'failed'].includes(status)) {
678
- throw new AppError('validation', 'completeFlowRun accepts terminal statuses only; use advanceFlowRun for a live RUN', 2, { status });
858
+ if (!['done', 'cancelled', 'failed'].includes(status)) {
859
+ throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
860
+ }
861
+ if (!input.manualOverrideReason) {
862
+ const activeWork = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [project.id, run.id]);
863
+ if (activeWork)
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 });
679
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();
680
871
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
681
872
  closeOpenStagesForOverride(index, status, now);
682
873
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -685,7 +876,7 @@ export function completeFlowRun(context, input) {
685
876
  index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
686
877
  index.next_action = input.nextAction ?? null;
687
878
  index.updated_at = now;
688
- if (["done", "blocked", "cancelled", "failed"].includes(status)) {
879
+ if (["done", "cancelled", "failed"].includes(status)) {
689
880
  index.completed_at = now;
690
881
  index.finished_at = now;
691
882
  index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
@@ -719,21 +910,90 @@ export function advanceFlowRun(context, input) {
719
910
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
720
911
  const run = resolveRun(context, project.id, input.runId);
721
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);
722
918
  const now = context.now();
723
919
  index.status = input.status;
724
920
  index.verdict = input.verdict;
725
- index.next_action = input.nextAction ?? null;
921
+ index.next_action = control ? null : input.nextAction ?? null;
726
922
  index.updated_at = now;
727
923
  persistRunState(context, project, run, index);
728
- 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 } });
729
- 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 });
730
926
  const updatedRun = requireRunById(context, project.id, run.id);
731
- 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 };
732
992
  }
733
993
  function closeOpenStagesForOverride(index, status, now) {
734
994
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
735
995
  for (const stage of index.stage_runs) {
736
- if (stage.status !== "running" && stage.status !== "pending")
996
+ if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
737
997
  continue;
738
998
  stage.status = stageStatus;
739
999
  stage.updated_at = now;
@@ -755,7 +1015,7 @@ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
755
1015
  context.db.run(`UPDATE works
756
1016
  SET status = ?, updated_at = ?, completed_at = ?
757
1017
  WHERE project_id = ? AND run_id = ?
758
- AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
1018
+ AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
759
1019
  context.db.run(`UPDATE work_sessions
760
1020
  SET status = ?, updated_at = ?, completed_at = ?
761
1021
  WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
@@ -881,7 +1141,21 @@ export function getFlowRunSessions(context, input) {
881
1141
  s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
882
1142
  s.agent_type, s.transcript_path
883
1143
  ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
884
- return { ok: true, schema_id: "dd-flow/run-sessions@3", run_id: run.id, sessions };
1144
+ const identities = new Map(sessions.map((row) => [String(row.session_id), publicSessionIdentity({
1145
+ harness: String(row.harness),
1146
+ provider_session_id: typeof row.provider_session_id === "string" ? row.provider_session_id : null,
1147
+ session_id: String(row.session_id)
1148
+ })]));
1149
+ return {
1150
+ ok: true,
1151
+ schema_id: "dd-flow/run-sessions@4",
1152
+ run_id: run.id,
1153
+ sessions: sessions.map(({ session_id, harness: _harness, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
1154
+ ...row,
1155
+ session: identities.get(String(session_id)) ?? null,
1156
+ ...(typeof parent_session_id === "string" ? { parent_session: identities.get(parent_session_id) ?? null } : {})
1157
+ }))
1158
+ };
885
1159
  }
886
1160
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
887
1161
  const run = requireRunById(context, projectId, runId);
@@ -980,6 +1254,10 @@ function vnextSpecifyGuidance(run, index) {
980
1254
  };
981
1255
  }
982
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";
983
1261
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
984
1262
  const previousRuntime = readRuntimeSnapshot(runtimePath);
985
1263
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -990,22 +1268,30 @@ function persistRunState(context, project, run, index, options = {}) {
990
1268
  }
991
1269
  const persistedIndex = persistedVnextIndex(index);
992
1270
  ensureDir(path.dirname(runtimePath));
993
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
994
- context.db.run(`UPDATE runs
1271
+ context.db.exec("SAVEPOINT persist_run_state");
1272
+ try {
1273
+ context.db.run(`UPDATE runs
995
1274
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
996
1275
  updated_at = ?, completed_at = ?
997
1276
  WHERE project_id = ? AND id = ?`, [
998
- index.status,
999
- index.verdict,
1000
- index.next_action,
1001
- JSON.stringify(persistedIndex),
1002
- runtimePath,
1003
- runtimePath,
1004
- index.updated_at,
1005
- index.completed_at ?? null,
1006
- project.id,
1007
- run.id
1008
- ]);
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
+ }
1009
1295
  }
1010
1296
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1011
1297
  function persistedVnextIndex(index) {
@@ -1047,10 +1333,15 @@ function materializedRunVariables(index) {
1047
1333
  ...(index.variables ?? {})
1048
1334
  };
1049
1335
  }
1050
- function setRunVariable(context, input, value) {
1336
+ function prepareRunVariable(context, input) {
1051
1337
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1052
1338
  const run = resolveRun(context, project.id, input.runId);
1339
+ assertRunMutationAllowed(context, project.id, run.id);
1053
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;
1054
1345
  if (index.variables?.[input.key] === value)
1055
1346
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1056
1347
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
@@ -1101,10 +1392,12 @@ function readRuntimeSnapshot(file) {
1101
1392
  function authoritativeIndex(run) {
1102
1393
  try {
1103
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");
1104
1397
  return { ...state, schema_id: runSchemaId };
1105
1398
  }
1106
1399
  catch (error) {
1107
- 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) });
1108
1401
  }
1109
1402
  }
1110
1403
  function sanitizeTimelineValue(value, key) {
@@ -1133,6 +1426,28 @@ function nextAttempt(existing) {
1133
1426
  const number = Number(existing.attempt.replace("try-", ""));
1134
1427
  return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
1135
1428
  }
1429
+ function assertVnextStageStart(run, index, stage, status, existing) {
1430
+ if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
1431
+ return;
1432
+ if (!vnextStages.some((candidate) => candidate.id === stage))
1433
+ return;
1434
+ if (existing) {
1435
+ if (existing.status === "running")
1436
+ throw new AppError("stage_already_running", "The same stage attempt is already running; continue it instead of starting another attempt", 2, { run_id: run.id, stage, attempt: existing.attempt ?? null });
1437
+ throw new AppError("illegal_stage_transition", "A completed or paused vNext stage cannot be started as a new implicit attempt", 2, { run_id: run.id, stage, status: existing.status });
1438
+ }
1439
+ const completed = index.stage_runs.filter((candidate) => candidate.status === "done" || candidate.status === "skipped").sort((left, right) => right.order - left.order)[0];
1440
+ if (!completed) {
1441
+ if (stage === "specify")
1442
+ return;
1443
+ throw new AppError("illegal_stage_transition", "The vNext flow must start with SPECIFY", 2, { run_id: run.id, stage });
1444
+ }
1445
+ if (!isLegalVnextTransition(completed.stage, stage))
1446
+ throw new AppError("illegal_stage_transition", "The requested stage is not a legal successor of the last accepted stage", 2, { run_id: run.id, from: completed.stage, to: stage });
1447
+ const unfinished = index.stage_runs.find((candidate) => !["done", "skipped"].includes(candidate.status));
1448
+ if (unfinished)
1449
+ throw new AppError("illegal_stage_transition", "Another stage is still active", 2, { run_id: run.id, active_stage: unfinished.stage, active_status: unfinished.status, to: stage });
1450
+ }
1136
1451
  function upsertStage(index, stageRun) {
1137
1452
  const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
1138
1453
  if (position === -1) {
@@ -1143,7 +1458,7 @@ function upsertStage(index, stageRun) {
1143
1458
  }
1144
1459
  index.stage_runs.sort((a, b) => a.order - b.order);
1145
1460
  }
1146
- function resolveRun(context, projectId, idOrAlias) {
1461
+ export function resolveRun(context, projectId, idOrAlias) {
1147
1462
  if (isFullEntityId(idOrAlias)) {
1148
1463
  return requireRunById(context, projectId, idOrAlias);
1149
1464
  }
@@ -1201,7 +1516,7 @@ function flowRunSummary(run) {
1201
1516
  runtime_path: run.runtime_path,
1202
1517
  run_dir: run.run_dir,
1203
1518
  run_index_path: run.run_index_path,
1204
- run_home_path: run.run_home_path ?? null,
1519
+ run_root: run.run_root,
1205
1520
  layout_version: run.layout_version ?? null,
1206
1521
  artifact_root_kind: run.artifact_root_kind ?? null,
1207
1522
  created_at: run.created_at,
@@ -1237,7 +1552,7 @@ function parseStageStatus(value) {
1237
1552
  }
1238
1553
  return value;
1239
1554
  }
1240
- function requiredStageDir(value) {
1555
+ export function prepareStageDirectory(value) {
1241
1556
  const dir = requiredPlain(value, "dir");
1242
1557
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1243
1558
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1245,6 +1560,8 @@ function requiredStageDir(value) {
1245
1560
  return dir;
1246
1561
  }
1247
1562
  function requiredPlain(value, label) {
1563
+ if (typeof value !== "string")
1564
+ throw new AppError("validation", `${label} must be a string`, 2);
1248
1565
  const trimmed = value.trim();
1249
1566
  if (!trimmed) {
1250
1567
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1252,6 +1569,8 @@ function requiredPlain(value, label) {
1252
1569
  return trimmed;
1253
1570
  }
1254
1571
  function normalizeSlug(value) {
1572
+ if (typeof value !== "string")
1573
+ throw new AppError("validation", "--slug must be a string", 2);
1255
1574
  const slug = value
1256
1575
  .normalize("NFKD")
1257
1576
  .replace(/[\u0300-\u036f]/g, "")
@@ -1269,26 +1588,59 @@ function resolveWorkspaceRoot(workspaceRoot) {
1269
1588
  if (!fs.existsSync(absolute)) {
1270
1589
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1271
1590
  }
1591
+ if (!fs.statSync(absolute).isDirectory())
1592
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1272
1593
  return fs.realpathSync(absolute);
1273
1594
  }
1274
- 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) {
1275
1598
  const currentStageDir = path.join(runRoot, stageDir);
1276
1599
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1277
1600
  return;
1278
1601
  }
1279
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1280
- 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) {
1281
1606
  return;
1282
1607
  }
1283
1608
  let attempt = 1;
1284
1609
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1285
1610
  attempt += 1;
1286
1611
  }
1287
- 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
+ }
1288
1618
  ensureDir(archiveDir);
1289
1619
  for (const entry of entries) {
1290
- 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 });
1291
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"));
1292
1644
  }
1293
1645
  function writeJsonFile(file, value) {
1294
1646
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1296,7 +1648,9 @@ function writeJsonFile(file, value) {
1296
1648
  fs.renameSync(tmpFile, file);
1297
1649
  }
1298
1650
  function runArtifactRoot(run) {
1299
- return run.run_home_path ?? path.dirname(run.runtime_path);
1651
+ if (!run.run_root)
1652
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id });
1653
+ return run.run_root;
1300
1654
  }
1301
1655
  export function gitFacts(workspaceRoot) {
1302
1656
  const read = (args) => {
@@ -1311,3 +1665,5 @@ export function gitFacts(workspaceRoot) {
1311
1665
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1312
1666
  };
1313
1667
  }
1668
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1669
+ import { runControlDirective } from "./run-control.js";