@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75

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 (123) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. 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";
@@ -37,20 +39,22 @@ const allowedRunFlowKinds = [
37
39
  ];
38
40
  export function startFlowRun(context, input) {
39
41
  const projectRoot = resolveProjectRoot(input.projectRoot);
42
+ const flowKind = parseRunFlowKind(input.flowKind);
43
+ const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
44
+ if (input.executionRoutingFile !== undefined && !vnext)
45
+ throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
46
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
47
+ const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput) : undefined;
40
48
  const flowContract = input.flowKind === "mb-upgrade"
41
49
  ? loadUpgradeFlowContract(context, projectRoot)
42
50
  : loadProjectFlowContract(projectRoot);
43
51
  registerProject(context, { root: projectRoot });
44
52
  const project = requireProjectByRoot(context, projectRoot);
45
53
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
46
- const flowKind = parseRunFlowKind(input.flowKind);
47
54
  const slug = normalizeSlug(input.slug);
48
55
  const runId = nextRunId(context, project.id, slug);
49
56
  const { shortId } = parseFullEntityId(runId);
50
57
  const now = context.now();
51
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
52
- ? snapshotVnextExecutionProfile(projectRoot)
53
- : undefined;
54
58
  const flowFlags = resolveFlowFlags(flowContract, {
55
59
  flowKind,
56
60
  ...(input.preset ? { preset: input.preset } : {}),
@@ -126,7 +130,9 @@ export function startFlowRun(context, input) {
126
130
  };
127
131
  ensureDir(path.dirname(runtimePath));
128
132
  ensureDir(runHome);
129
- bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
133
+ if (routingInput)
134
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
135
+ bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
130
136
  const persistedIndex = persistedVnextIndex(index);
131
137
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
132
138
  context.db.run(`INSERT INTO runs
@@ -173,10 +179,10 @@ export function startFlowRun(context, input) {
173
179
  });
174
180
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
175
181
  }
176
- function snapshotVnextExecutionProfile(projectRoot) {
182
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
177
183
  const profile = loadVnextExecutionProfile(projectRoot);
178
184
  const sourcePath = executionProfilePath(projectRoot);
179
- return {
185
+ return freezeRunExecutionRouting(ddFlowHome, {
180
186
  schema_id: "dd-flow/run-execution-profile@1",
181
187
  source_path: sourcePath,
182
188
  source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
@@ -188,9 +194,41 @@ function snapshotVnextExecutionProfile(projectRoot) {
188
194
  merge_delivery: profile.merge_delivery,
189
195
  merge_cleanup: profile.merge_cleanup,
190
196
  stop_target: profile.stop_target,
191
- code_bootstrap: profile.code_bootstrap
197
+ code_bootstrap: profile.code_bootstrap,
198
+ ...(profile.execution ? { execution: profile.execution } : {})
192
199
  }
193
- };
200
+ }, routingInput);
201
+ }
202
+ /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
203
+ export function freezeRunExecutionRouting(ddFlowHome, base, input) {
204
+ const execution = input?.execution ?? base.settings.execution;
205
+ const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
206
+ if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
207
+ const mode = base.settings.code_review_mode;
208
+ assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
209
+ }
210
+ return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
211
+ ...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
212
+ settings: { ...base.settings, ...(execution ? { execution } : {}) } };
213
+ }
214
+ /** Called only after a stage-entry import has archived and detached source owners. */
215
+ export function importFlowRunExecutionProfile(context, input) {
216
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
217
+ const run = resolveRun(context, project.id, input.runId);
218
+ assertRunMutationAllowed(context, project.id, run.id);
219
+ const index = authoritativeIndex(run);
220
+ const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
221
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
222
+ || !vnextStages.some(stage => stage.id === input.stage)
223
+ || (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]))
224
+ || index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
225
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
226
+ throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
227
+ }
228
+ index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
229
+ index.updated_at = context.now();
230
+ persistRunState(context, project, run, index);
231
+ 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
232
  }
195
233
  /**
196
234
  * A vNext route may materialize a feature checkout after its semantic
@@ -200,6 +238,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
200
238
  export function rebindFlowRunWorkspace(context, input) {
201
239
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
202
240
  const run = resolveRun(context, project.id, input.runId);
241
+ assertRunMutationAllowed(context, project.id, run.id);
203
242
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
204
243
  const index = authoritativeIndex(run);
205
244
  const now = context.now();
@@ -229,7 +268,41 @@ export function getFlowRunStatus(context, input) {
229
268
  const run = resolveRun(context, project.id, input.runId);
230
269
  const index = authoritativeIndex(run);
231
270
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
271
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
272
+ }
273
+ /** A read-only projection of the next legal lifecycle action.
274
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
275
+ * transition from a model's prose or a hard-coded stage array. */
276
+ function runContinuation(context, run, index) {
277
+ if (["done", "failed", "cancelled"].includes(run.status))
278
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
279
+ const control = runControlDirective(context, run.project_id, run.id);
280
+ if (control)
281
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
282
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
283
+ if (active) {
284
+ 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]);
285
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
286
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
287
+ }
288
+ if (run.status === "waiting_for_user" || run.status === "paused")
289
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
290
+ if (run.status === "blocked")
291
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
292
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
293
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
294
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
295
+ if (!next)
296
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
297
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
298
+ const profile = index.execution_profile;
299
+ const execution = profile?.settings.execution ? profile.agent_profiles
300
+ ? { 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 }) }
301
+ : { status: "unfrozen", reason: "execution_profile_not_frozen" }
302
+ : undefined;
303
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
304
+ }
305
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
306
  }
234
307
  export function getFlowRunConfig(context, input) {
235
308
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -270,6 +343,7 @@ export function setFlowRunConfig(context, input) {
270
343
  const reason = requiredPlain(input.reason, "reason");
271
344
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
272
345
  const run = resolveRun(context, project.id, input.runId);
346
+ assertRunMutationAllowed(context, project.id, run.id);
273
347
  const index = authoritativeIndex(run);
274
348
  const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
275
349
  if (index.stage_runs.some((stage) => stage.stage === stageName)) {
@@ -292,6 +366,23 @@ export function setFlowRunConfig(context, input) {
292
366
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
293
367
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
294
368
  }
369
+ /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
370
+ export function freezeFlowRunReviewMode(context, input) {
371
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
372
+ const run = resolveRun(context, project.id, input.runId);
373
+ assertRunMutationAllowed(context, project.id, run.id);
374
+ const index = authoritativeIndex(run);
375
+ const settings = runSettings(index);
376
+ const key = input.review === "plan" ? "plan_review" : "code_review";
377
+ const current = settings[key];
378
+ if (current.mode !== "auto")
379
+ return;
380
+ const now = context.now();
381
+ index.settings = { ...settings, [key]: { mode: input.mode, source: input.source, reason: requiredPlain(input.reason, "reason"), updated_at: now } };
382
+ index.updated_at = now;
383
+ persistRunState(context, project, run, index);
384
+ 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 } });
385
+ }
295
386
  export function getFlowRunFlagsStatus(context, input) {
296
387
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
297
388
  const run = resolveRun(context, project.id, input.runId);
@@ -321,8 +412,9 @@ export function getFlowRunFlagsStatus(context, input) {
321
412
  export function reviseFlowRunFlags(context, input) {
322
413
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
323
414
  const run = resolveRun(context, project.id, input.runId);
415
+ assertRunMutationAllowed(context, project.id, run.id);
324
416
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
325
- context.db.exec("BEGIN IMMEDIATE");
417
+ context.db.beginWriteTransaction();
326
418
  let committed = false;
327
419
  try {
328
420
  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]);
@@ -446,6 +538,7 @@ export function listFlowRuns(context, input) {
446
538
  export function attachFlowRunStage(context, input) {
447
539
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
448
540
  const run = resolveRun(context, project.id, input.runId);
541
+ assertRunMutationAllowed(context, project.id, run.id);
449
542
  const index = authoritativeIndex(run);
450
543
  const now = context.now();
451
544
  const stage = requiredPlain(input.stage, "stage");
@@ -454,7 +547,7 @@ export function attachFlowRunStage(context, input) {
454
547
  const existing = index.stage_runs.find((item) => item.stage === stage);
455
548
  assertVnextStageStart(run, index, stage, status, existing);
456
549
  if (status === "running" && existing) {
457
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
550
+ archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
458
551
  }
459
552
  const stageRun = {
460
553
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -490,9 +583,17 @@ export function attachFlowRunStage(context, input) {
490
583
  const updatedRun = requireRunById(context, project.id, run.id);
491
584
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
492
585
  }
586
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
587
+ export function withStageSettlement(context, operation) {
588
+ return context.db.writeTransaction(operation);
589
+ }
493
590
  export function completeFlowRunStage(context, input) {
494
591
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
495
592
  const run = resolveRun(context, project.id, input.runId);
593
+ if (["done", "failed", "skipped"].includes(input.status))
594
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
595
+ else
596
+ assertRunMutationAllowed(context, project.id, run.id);
496
597
  const index = authoritativeIndex(run);
497
598
  const now = context.now();
498
599
  const stage = requiredPlain(input.stage, "stage");
@@ -508,6 +609,7 @@ export function completeFlowRunStage(context, input) {
508
609
  ...(input.data ? { data: input.data } : {}),
509
610
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
510
611
  ...(input.report ? { report: input.report } : {}),
612
+ ...(input.usage ? { usage: input.usage } : {}),
511
613
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
512
614
  updated_at: now,
513
615
  ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
@@ -550,6 +652,7 @@ export function completeFlowRunStage(context, input) {
550
652
  export function pauseFlowRunStage(context, input) {
551
653
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
552
654
  const run = resolveRun(context, project.id, input.runId);
655
+ assertRunMutationAllowed(context, project.id, run.id);
553
656
  const index = authoritativeIndex(run);
554
657
  const stage = requiredPlain(input.stage, "stage");
555
658
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -585,6 +688,7 @@ export function pauseFlowRunStage(context, input) {
585
688
  export function resumeFlowRunStage(context, input) {
586
689
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
587
690
  const run = resolveRun(context, project.id, input.runId);
691
+ assertRunMutationAllowed(context, project.id, run.id);
588
692
  const index = authoritativeIndex(run);
589
693
  const stage = requiredPlain(input.stage, "stage");
590
694
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -616,6 +720,7 @@ export function resumeFlowRunStage(context, input) {
616
720
  export function blockFlowRunStage(context, input) {
617
721
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
618
722
  const run = resolveRun(context, project.id, input.runId);
723
+ assertRunMutationAllowed(context, project.id, run.id);
619
724
  const index = authoritativeIndex(run);
620
725
  const stage = requiredPlain(input.stage, "stage");
621
726
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -641,6 +746,7 @@ export function blockFlowRunStage(context, input) {
641
746
  export function unblockFlowRunStage(context, input) {
642
747
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
643
748
  const run = resolveRun(context, project.id, input.runId);
749
+ assertRunMutationAllowed(context, project.id, run.id);
644
750
  const index = authoritativeIndex(run);
645
751
  const stage = requiredPlain(input.stage, "stage");
646
752
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -671,9 +777,26 @@ function operationalRunStatus(context, projectId, runId) {
671
777
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
672
778
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
673
779
  }
780
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
781
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
782
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
783
+ const run = resolveRun(context, projectId, runId);
784
+ if (!["running", "paused"].includes(run.status))
785
+ return;
786
+ const index = authoritativeIndex(run);
787
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
788
+ ? "paused" : operationalRunStatus(context, projectId, runId);
789
+ if (run.status === status && index.status === status)
790
+ return;
791
+ index.status = status;
792
+ index.updated_at = context.now();
793
+ const project = requireProjectByRoot(context, run.project_root);
794
+ persistRunState(context, project, run, index);
795
+ }
674
796
  export function completeFlowRun(context, input) {
675
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
797
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
676
798
  const run = resolveRun(context, project.id, input.runId);
799
+ assertRunMutationAllowed(context, project.id, run.id);
677
800
  const index = authoritativeIndex(run);
678
801
  const now = context.now();
679
802
  const status = parseRunStatus(input.status);
@@ -727,16 +850,85 @@ export function advanceFlowRun(context, input) {
727
850
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
728
851
  const run = resolveRun(context, project.id, input.runId);
729
852
  const index = authoritativeIndex(run);
853
+ 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)))
854
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
855
+ else
856
+ assertRunMutationAllowed(context, project.id, run.id);
857
+ const control = runControlDirective(context, project.id, run.id);
730
858
  const now = context.now();
731
859
  index.status = input.status;
732
860
  index.verdict = input.verdict;
733
- index.next_action = input.nextAction ?? null;
861
+ index.next_action = control ? null : input.nextAction ?? null;
734
862
  index.updated_at = now;
735
863
  persistRunState(context, project, run, index);
736
- 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 } });
737
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
864
+ 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 } });
865
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
738
866
  const updatedRun = requireRunById(context, project.id, run.id);
739
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
867
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
868
+ }
869
+ /**
870
+ * The only legal backward edge in vNext: an integration gate exposed a
871
+ * product defect, so the source must be repaired and reviewed again. Normal
872
+ * stage start remains fail-closed for completed stages.
873
+ */
874
+ export function prepareVnextMergeSourceRepairAttempt(context, input) {
875
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
876
+ const run = resolveRun(context, project.id, input.runId);
877
+ assertRunMutationAllowed(context, project.id, run.id);
878
+ const index = authoritativeIndex(run);
879
+ const priorRepair = index.variables?.["merge.source_repair"];
880
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
881
+ return { run_id: run.id, cycle: priorRepair.cycle };
882
+ const code = index.stage_runs.find((item) => item.stage === "code");
883
+ const review = index.stage_runs.find((item) => item.stage === "code-review");
884
+ const merge = index.stage_runs.find((item) => item.stage === "merge");
885
+ if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
886
+ throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
887
+ run_id: run.id,
888
+ code: code?.status ?? null,
889
+ code_review: review?.status ?? null,
890
+ merge: merge?.status ?? null
891
+ });
892
+ }
893
+ const root = runArtifactRoot(run);
894
+ for (const stage of ["code", "code-review", "merge"])
895
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
896
+ const now = context.now();
897
+ const restarted = {
898
+ ...code,
899
+ status: "running",
900
+ started_at: now,
901
+ updated_at: now,
902
+ attempt: nextAttempt(code)
903
+ };
904
+ delete restarted.completed_at;
905
+ delete restarted.duration_ms;
906
+ delete restarted.stage_report;
907
+ delete restarted.data;
908
+ delete restarted.report;
909
+ delete restarted.artifact_aliases;
910
+ const cycle = `MRR-${input.mergeRequestId}`;
911
+ index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
912
+ index.stage_runs.push(restarted);
913
+ index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
914
+ index.status = "running";
915
+ index.verdict = "merge_source_repair";
916
+ index.next_action = "start_code_repair";
917
+ index.current_stage = "code";
918
+ index.updated_at = now;
919
+ index.attempts = index.stage_runs.map((item) => ({
920
+ stage: item.stage,
921
+ attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
922
+ root: item.dir,
923
+ archive: null,
924
+ status: item.status,
925
+ started_at: item.started_at ?? now,
926
+ finished_at: item.completed_at ?? null
927
+ }));
928
+ persistRunState(context, project, run, index);
929
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
930
+ appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
931
+ return { run_id: run.id, cycle };
740
932
  }
741
933
  function closeOpenStagesForOverride(index, status, now) {
742
934
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
@@ -1002,6 +1194,10 @@ function vnextSpecifyGuidance(run, index) {
1002
1194
  };
1003
1195
  }
1004
1196
  function persistRunState(context, project, run, index, options = {}) {
1197
+ // Fenced productive admission is a logical pause, even while physical work
1198
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1199
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1200
+ index.status = "paused";
1005
1201
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1006
1202
  const previousRuntime = readRuntimeSnapshot(runtimePath);
1007
1203
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -1012,22 +1208,30 @@ function persistRunState(context, project, run, index, options = {}) {
1012
1208
  }
1013
1209
  const persistedIndex = persistedVnextIndex(index);
1014
1210
  ensureDir(path.dirname(runtimePath));
1015
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1016
- context.db.run(`UPDATE runs
1211
+ context.db.exec("SAVEPOINT persist_run_state");
1212
+ try {
1213
+ context.db.run(`UPDATE runs
1017
1214
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1018
1215
  updated_at = ?, completed_at = ?
1019
1216
  WHERE project_id = ? AND id = ?`, [
1020
- index.status,
1021
- index.verdict,
1022
- index.next_action,
1023
- JSON.stringify(persistedIndex),
1024
- runtimePath,
1025
- runtimePath,
1026
- index.updated_at,
1027
- index.completed_at ?? null,
1028
- project.id,
1029
- run.id
1030
- ]);
1217
+ index.status,
1218
+ index.verdict,
1219
+ index.next_action,
1220
+ JSON.stringify(persistedIndex),
1221
+ runtimePath,
1222
+ runtimePath,
1223
+ index.updated_at,
1224
+ index.completed_at ?? null,
1225
+ project.id,
1226
+ run.id
1227
+ ]);
1228
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1229
+ context.db.exec("RELEASE persist_run_state");
1230
+ }
1231
+ catch (error) {
1232
+ context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1233
+ throw error;
1234
+ }
1031
1235
  }
1032
1236
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1033
1237
  function persistedVnextIndex(index) {
@@ -1072,6 +1276,7 @@ function materializedRunVariables(index) {
1072
1276
  function setRunVariable(context, input, value) {
1073
1277
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1074
1278
  const run = resolveRun(context, project.id, input.runId);
1279
+ assertRunMutationAllowed(context, project.id, run.id);
1075
1280
  const index = authoritativeIndex(run);
1076
1281
  if (index.variables?.[input.key] === value)
1077
1282
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
@@ -1187,7 +1392,7 @@ function upsertStage(index, stageRun) {
1187
1392
  }
1188
1393
  index.stage_runs.sort((a, b) => a.order - b.order);
1189
1394
  }
1190
- function resolveRun(context, projectId, idOrAlias) {
1395
+ export function resolveRun(context, projectId, idOrAlias) {
1191
1396
  if (isFullEntityId(idOrAlias)) {
1192
1397
  return requireRunById(context, projectId, idOrAlias);
1193
1398
  }
@@ -1245,7 +1450,7 @@ function flowRunSummary(run) {
1245
1450
  runtime_path: run.runtime_path,
1246
1451
  run_dir: run.run_dir,
1247
1452
  run_index_path: run.run_index_path,
1248
- run_home_path: run.run_home_path ?? null,
1453
+ run_root: run.run_root,
1249
1454
  layout_version: run.layout_version ?? null,
1250
1455
  artifact_root_kind: run.artifact_root_kind ?? null,
1251
1456
  created_at: run.created_at,
@@ -1315,24 +1520,55 @@ function resolveWorkspaceRoot(workspaceRoot) {
1315
1520
  }
1316
1521
  return fs.realpathSync(absolute);
1317
1522
  }
1318
- function archiveExistingStageAttempt(runRoot, stageDir) {
1523
+ /** Move one completed stage attempt and update only known durable path columns.
1524
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1525
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1319
1526
  const currentStageDir = path.join(runRoot, stageDir);
1320
1527
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1321
1528
  return;
1322
1529
  }
1323
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1324
- if (entries.length === 0) {
1530
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1531
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1532
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1533
+ if (entries.length === 0 && !pending) {
1325
1534
  return;
1326
1535
  }
1327
1536
  let attempt = 1;
1328
1537
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1329
1538
  attempt += 1;
1330
1539
  }
1331
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1540
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1541
+ if (!pending) {
1542
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1543
+ const prior = stored ? JSON.parse(stored.index_json) : null;
1544
+ writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1545
+ }
1332
1546
  ensureDir(archiveDir);
1333
1547
  for (const entry of entries) {
1334
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1548
+ const source = path.join(currentStageDir, entry);
1549
+ const target = path.join(archiveDir, entry);
1550
+ if (fs.existsSync(source)) {
1551
+ if (fs.existsSync(target))
1552
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1553
+ fs.renameSync(source, target);
1554
+ }
1555
+ else if (!fs.existsSync(target))
1556
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1335
1557
  }
1558
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1559
+ const newPrefix = `${archiveDir}${path.sep}`;
1560
+ const rewrite = (table, column, where, values) => {
1561
+ 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]);
1562
+ };
1563
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1564
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1565
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1566
+ const receiptScope = "project_id = ? AND run_id = ?";
1567
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1568
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1569
+ // Retain the original stage facts with its immutable artifacts; current
1570
+ // stage_runs stays a projection of the current attempt, not a second ledger.
1571
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1336
1572
  }
1337
1573
  function writeJsonFile(file, value) {
1338
1574
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1340,7 +1576,9 @@ function writeJsonFile(file, value) {
1340
1576
  fs.renameSync(tmpFile, file);
1341
1577
  }
1342
1578
  function runArtifactRoot(run) {
1343
- return run.run_home_path ?? path.dirname(run.runtime_path);
1579
+ if (!run.run_root)
1580
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id });
1581
+ return run.run_root;
1344
1582
  }
1345
1583
  export function gitFacts(workspaceRoot) {
1346
1584
  const read = (args) => {
@@ -1355,3 +1593,5 @@ export function gitFacts(workspaceRoot) {
1355
1593
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1356
1594
  };
1357
1595
  }
1596
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1597
+ import { runControlDirective } from "./run-control.js";