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

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 (121) hide show
  1. package/CHANGELOG.md +396 -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 +470 -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/dispatch-fence.mjs +14 -0
  26. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  27. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  28. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  29. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  30. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  39. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  40. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  41. package/dist/runtime/context.js +3 -1
  42. package/dist/schemas/agent-profile.schema.json +1 -1
  43. package/dist/schemas/code-review-result.schema.json +2 -2
  44. package/dist/schemas/code-work-batch.schema.json +4 -3
  45. package/dist/schemas/code-work-result.schema.json +4 -4
  46. package/dist/schemas/harness-config.schema.json +23 -0
  47. package/dist/schemas/plan-review-result.schema.json +2 -2
  48. package/dist/schemas/run-control-receipt.schema.json +99 -0
  49. package/dist/schemas/run-control-request.schema.json +36 -0
  50. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  51. package/dist/services/cleanup.js +8 -5
  52. package/dist/services/cli-operation-classifier.js +11 -1
  53. package/dist/services/code-checks.js +543 -96
  54. package/dist/services/controller-fanout.js +120 -0
  55. package/dist/services/dashboard.js +9 -0
  56. package/dist/services/engines.js +50 -19
  57. package/dist/services/eval-snapshots.js +639 -60
  58. package/dist/services/execution-policy.js +177 -0
  59. package/dist/services/external-work-launch.js +110 -0
  60. package/dist/services/harness-adapter.js +147 -24
  61. package/dist/services/harness-config.js +68 -0
  62. package/dist/services/hooks.js +413 -168
  63. package/dist/services/lanes.js +1 -1
  64. package/dist/services/lifecycle-command.js +74 -6
  65. package/dist/services/lifecycle-invocations.js +637 -0
  66. package/dist/services/managed-daemon-binding.js +38 -0
  67. package/dist/services/managed-processes.js +138 -22
  68. package/dist/services/merge-queue.js +6 -6
  69. package/dist/services/merge-server.js +75 -29
  70. package/dist/services/migrations.js +1 -1
  71. package/dist/services/native-daemon-history.js +49 -0
  72. package/dist/services/native-session-control.js +39 -0
  73. package/dist/services/projects.js +7 -3
  74. package/dist/services/prompts.js +4 -2
  75. package/dist/services/protocols.js +11 -2
  76. package/dist/services/recovery-observation-budget.js +61 -0
  77. package/dist/services/recovery-snapshot-database.js +107 -0
  78. package/dist/services/run-control-receipt.js +56 -0
  79. package/dist/services/run-control-worker.js +363 -0
  80. package/dist/services/run-control.js +876 -0
  81. package/dist/services/run-controller-adapter.js +211 -0
  82. package/dist/services/run-controller-capture.js +134 -0
  83. package/dist/services/run-controller-process.js +189 -0
  84. package/dist/services/run-controller-recovery.js +222 -0
  85. package/dist/services/run-controller-state.js +31 -0
  86. package/dist/services/run-controller.js +733 -0
  87. package/dist/services/run-engine-bindings.js +20 -62
  88. package/dist/services/run-fork.js +124 -0
  89. package/dist/services/run-observations.js +112 -0
  90. package/dist/services/run-recovery-runtime.js +69 -0
  91. package/dist/services/run-recovery.js +327 -0
  92. package/dist/services/runs.js +280 -40
  93. package/dist/services/runtime-budget.js +362 -0
  94. package/dist/services/runtime-scope-capture.js +50 -0
  95. package/dist/services/runtime-scope-control.js +450 -0
  96. package/dist/services/runtime-scope-resume.js +543 -0
  97. package/dist/services/runtime-scope-stop.js +99 -0
  98. package/dist/services/runtime-scope-worker.js +210 -0
  99. package/dist/services/runtime-service.js +96 -0
  100. package/dist/services/schema-validation.js +12 -12
  101. package/dist/services/stage-context.js +2 -2
  102. package/dist/services/stage-lifecycle.js +32 -10
  103. package/dist/services/stage-pause.js +25 -13
  104. package/dist/services/usage.js +37 -93
  105. package/dist/services/vnext-code-review.js +149 -89
  106. package/dist/services/vnext-code.js +223 -215
  107. package/dist/services/vnext-execution-profile.js +5 -3
  108. package/dist/services/vnext-fanout.js +135 -21
  109. package/dist/services/vnext-merge.js +258 -89
  110. package/dist/services/vnext-plan-review.js +75 -64
  111. package/dist/services/vnext-plan.js +77 -29
  112. package/dist/services/vnext-protocolize.js +21 -14
  113. package/dist/services/vnext-specify.js +26 -16
  114. package/dist/services/work-registry.js +592 -154
  115. package/dist/services/workspace-bootstrap.js +76 -0
  116. package/dist/shared/errors.js +12 -0
  117. package/dist/storage/database.js +341 -31
  118. package/dist/storage/writer-contract.js +77 -0
  119. package/dist/storage/writer-migration.js +102 -0
  120. package/package.json +20 -13
  121. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,26 +1,44 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
- import { spawn } from "node:child_process";
3
+ import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
8
+ import { aggregateCheckDeclarations, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths } from "./code-checks.js";
8
9
  import { requireProjectByRoot } from "./projects.js";
9
- import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
10
+ import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, withStageSettlement, getFlowRunVariables, gitFacts } from "./runs.js";
10
11
  import { validateSchema } from "./schema-validation.js";
11
12
  import { flowCommand } from "./stage-pause.js";
12
13
  import { validateVnextCodeHandoff } from "./vnext-plan.js";
13
14
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
15
  import { readVnextSpecifyResult } from "./vnext-specify.js";
15
- import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
16
+ import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishStageCoordinatorWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
16
17
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
18
  import { writeStageReport } from "./stage-report-renderer.js";
18
- import { ensureVnextMergeRequest } from "./vnext-merge.js";
19
+ import { prepareVnextMergeRequest } from "./vnext-merge.js";
19
20
  import { assertStageStartHookEvent } from "./hooks.js";
20
21
  import { applyExternalStageContext } from "./stage-context.js";
21
22
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
23
+ import { assertRunMutationAllowed } from "./run-recovery.js";
22
24
  const stage = "code";
23
25
  const stageDir = vnextStageDirectory("code");
26
+ /** A controller prepares slow, deterministic project tooling before it asks an
27
+ * agent to enter CODE or a restored CODE_REVIEW. The subsequent lifecycle tool call remains the sole
28
+ * authority for attaching and binding the Stage. */
29
+ export async function prepareVnextCodeWorkspace(context, input) {
30
+ if (context.env.DD_FLOW_CODE_READINESS_OWNER !== input.ownerId) {
31
+ throw new AppError("code_readiness_owner_mismatch", "CODE readiness preparation must use its controller's frozen owner", 1, { owner_id: input.ownerId });
32
+ }
33
+ const projectRoot = resolveProjectRoot(input.projectRoot);
34
+ const project = requireProjectByRoot(context, projectRoot);
35
+ const run = requireRun(context, project.id, input.runId);
36
+ assertRunMutationAllowed(context, project.id, run.id);
37
+ requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, stage: input.stage ?? stage });
38
+ const root = path.join(requireHome(run), stageDir);
39
+ fs.mkdirSync(root, { recursive: true });
40
+ return ensureCodeWorkspaceReady(context, project.id, run, root, [], input.progress, { force: input.stage !== "code-review" });
41
+ }
24
42
  export async function startVnextCode(context, input) {
25
43
  const projectRoot = resolveProjectRoot(input.projectRoot);
26
44
  const project = requireProjectByRoot(context, projectRoot);
@@ -31,10 +49,12 @@ export async function startVnextCode(context, input) {
31
49
  const existingRoot = path.join(home, stageDir);
32
50
  const existingPrompt = path.join(existingRoot, "stage-prompt.md");
33
51
  const existingStage = stageStatus(run, stage);
52
+ const sourceRepairAttempt = isMergeSourceRepairAttempt(run);
34
53
  if (existingStage && fs.existsSync(existingPrompt)) {
35
54
  const externalContext = applyExternalStageContext({ stageRoot: existingRoot, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
36
55
  const prompt = fs.readFileSync(existingPrompt, "utf8");
37
56
  const rootWork = requireRootWork(context, project.id, input.runId);
57
+ bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath: existingPrompt, resultPath: path.join(existingRoot, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
38
58
  const orchestration = readFanoutDescriptor(existingRoot) ?? writeFanoutDescriptor(existingRoot, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
39
59
  return { ok: true, resumed: true, run_id: run.id, stage, stage_status: existingStage, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), orchestration, graph: codeWorkGraph(context, project.id, run.id), next: { refresh_graph_command: `${flowCommand(context)} work ls --run ${run.id} --ready --project-root ${JSON.stringify(projectRoot)} --json`, finish_command: finishCommand(context, input.runId, projectRoot, path.join(existingRoot, "code-verification.json")) } };
40
60
  }
@@ -53,7 +73,8 @@ export async function startVnextCode(context, input) {
53
73
  file: batch,
54
74
  projectRoot: run.workspace_root,
55
75
  ddFlowHome: context.ddFlowHome,
56
- runId: input.runId
76
+ runId: input.runId,
77
+ runRoot: home
57
78
  });
58
79
  validateWorkBatchFile(batch);
59
80
  const root = path.join(home, stageDir);
@@ -72,14 +93,16 @@ export async function startVnextCode(context, input) {
72
93
  resultPath: path.join(root, "stage-report.json"),
73
94
  ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {})
74
95
  });
75
- attachFlowRunStage(context, {
76
- projectRoot,
77
- runId: input.runId,
78
- stage,
79
- dir: stageDir,
80
- status: "running",
81
- dataSchemaId: "dd-flow/stage-report@1"
82
- });
96
+ if (!sourceRepairAttempt) {
97
+ attachFlowRunStage(context, {
98
+ projectRoot,
99
+ runId: input.runId,
100
+ stage,
101
+ dir: stageDir,
102
+ status: "running",
103
+ dataSchemaId: "dd-flow/stage-report@1"
104
+ });
105
+ }
83
106
  const graph = codeWorkGraph(context, project.id, input.runId);
84
107
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
85
108
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "code_started", work_id: rootWork.work_id, graph });
@@ -104,8 +127,10 @@ export async function finishVnextCode(context, input) {
104
127
  const run = requireRun(context, project.id, input.runId);
105
128
  const home = requireHome(run);
106
129
  const root = path.join(home, stageDir);
130
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
107
131
  requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage });
108
132
  if (stageStatus(run, stage) === "done" && fs.existsSync(path.join(root, "stage-report.json"))) {
133
+ resumeAcceptedCodeStage(context, run, projectRoot, stage, JSON.parse(fs.readFileSync(path.join(root, "stage-report.json"), "utf8")), nextAction(run, changedPathsFromReport(path.join(root, "stage-report.json"))));
109
134
  return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: path.join(root, "stage-report.json"), next_action: nextAction(run, changedPathsFromReport(path.join(root, "stage-report.json"))) };
110
135
  }
111
136
  const handoffFailures = validateVnextCodeHandoff(context, {
@@ -134,7 +159,7 @@ export async function finishVnextCode(context, input) {
134
159
  throw new AppError("obligation_coverage_incomplete", "CODE graph does not cover every accepted obligation", 2, { missing });
135
160
  }
136
161
  // Reject a malformed semantic receipt before running the expensive gate.
137
- const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id });
162
+ const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id, runRoot: home });
138
163
  if (verification.verdict === "blocked") {
139
164
  return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, instruction: "CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again." };
140
165
  }
@@ -153,29 +178,15 @@ export async function finishVnextCode(context, input) {
153
178
  return { ok: true, run_id: run.id, stage, outcome: "repair_required", verification, repair, instruction: "The semantic verification is not accepted. Run the returned repair Work, then update code-verification.json and invoke this same stage finish command again." };
154
179
  }
155
180
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
156
- const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
157
- if (unchangedFailures.length)
158
- throw new AppError("code_gate_repair_required", "CODE final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
159
- let receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks });
160
- if (!receipts) {
161
- const release = acquireAggregateGateLock(root);
162
- try {
163
- // A prior caller may have completed the slow gate while this caller was
164
- // waiting to acquire the lock. Reuse that immutable evidence.
165
- receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks }) ?? await runCodeChecks(context, {
166
- projectId: project.id,
167
- runId: run.id,
168
- runHome: home,
169
- workspaceRoot: run.workspace_root,
170
- scope: "aggregate",
171
- checks,
172
- ...(input.progress ? { progress: input.progress } : {})
173
- });
174
- }
175
- finally {
176
- release();
177
- }
178
- }
181
+ if (input.retryCheckId) {
182
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
183
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
184
+ }
185
+ const receipts = await runCodeChecks(context, {
186
+ projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, scope: "aggregate", checks,
187
+ ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}),
188
+ ...(input.progress ? { progress: input.progress } : {})
189
+ });
179
190
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
180
191
  if (failed.length) {
181
192
  throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
@@ -183,23 +194,21 @@ export async function finishVnextCode(context, input) {
183
194
  outcome: "repair_required",
184
195
  retry_after_workspace_change: true,
185
196
  workspace_fingerprint: failed[0].workspace_fingerprint,
186
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`
197
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
198
+ retry_command: finishCommand(context, run.id, projectRoot, input.verificationFile, failed[0].id)
187
199
  });
188
200
  }
189
- const allReceipts = checkReceipts(context, { projectId: project.id, runId: run.id });
190
- const finalReceipts = latestReceiptsByCommand(allReceipts);
191
- const projectedVerification = verificationProjection(works, finalReceipts);
201
+ const finalReceipts = receipts;
202
+ const projectedVerification = verificationProjection(works, finalReceipts, { historicalReceipts: finalReceipts, runId: run.id, runHome: home });
192
203
  const unresolvedAcceptance = (projectedVerification.acceptance ?? []).filter((item) => item.status === "unresolved");
193
204
  if (unresolvedAcceptance.length)
194
205
  throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
195
206
  const now = context.now();
196
207
  const timing = stageTiming(home, stage, now);
197
- const reportedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
198
- const observedChangedPaths = workspaceChangedPaths(run.workspace_root);
199
- const changedPaths = observedChangedPaths ?? reportedPaths;
200
- const missingReportedPaths = observedChangedPaths === null ? [] : reportedPaths.filter((item) => !observedChangedPaths.includes(item));
201
- if (missingReportedPaths.length)
202
- throw new AppError("changed_path_not_materialized", "CODE Work reported paths that are not changed in the accepted workspace", 2, { paths: missingReportedPaths });
208
+ // Work result paths are provenance, not a mutable filesystem oracle. The
209
+ // stage reports one shared Git observation; ignored machine repairs are
210
+ // rejected before a Work can become completed.
211
+ const changedPaths = workspaceChangedPaths(run.workspace_root);
203
212
  const next = nextAction(run, changedPaths);
204
213
  const report = {
205
214
  schema_id: "dd-flow/stage-report@1",
@@ -238,31 +247,41 @@ export async function finishVnextCode(context, input) {
238
247
  // Creating the MERGE request is part of accepting this terminal handoff.
239
248
  // Do it before materialising a terminal stage report or changing RUN state,
240
249
  // so an invalid merge policy leaves CODE safely running and retryable.
241
- const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
242
- writeReport(root, report);
243
- completeFlowRunStage(context, {
244
- projectRoot,
245
- runId: run.id,
246
- stage,
247
- status: "done",
248
- data: "stage-report.json",
249
- dataSchemaId: "dd-flow/stage-report@1",
250
- report: "stage-report.md",
251
- stageReport: "stage-report.html"
252
- });
253
- const rootWork = requireRootWork(context, project.id, run.id);
254
- appendFlowRunTimelineEvent(context, project.id, run.id, {
255
- type: "code_accepted",
256
- work_id: rootWork.work_id,
257
- checks: finalReceipts.map((receipt) => receipt.id)
250
+ let mergeRequest = null;
251
+ assertRunMutationAllowed(context, project.id, run.id, generation);
252
+ const prepareMerge = next === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
253
+ withStageSettlement(context, () => {
254
+ assertRunMutationAllowed(context, project.id, run.id, generation);
255
+ const currentWorks = codeWorks(context, project.id, run.id);
256
+ if (currentWorks.length !== works.length || currentWorks.some(work => work.status !== "completed" || !works.some(accepted => accepted.work_id === work.work_id && accepted.result === work.result))) {
257
+ throw new AppError("code_graph_incomplete", "CODE results changed while the final checks were running", 2);
258
+ }
259
+ mergeRequest = prepareMerge?.() ?? null;
260
+ writeReport(root, report);
261
+ completeFlowRunStage(context, {
262
+ projectRoot,
263
+ runId: run.id,
264
+ stage,
265
+ status: "done",
266
+ data: "stage-report.json",
267
+ dataSchemaId: "dd-flow/stage-report@1",
268
+ report: "stage-report.md",
269
+ stageReport: "stage-report.html"
270
+ });
271
+ const rootWork = requireRootWork(context, project.id, run.id);
272
+ appendFlowRunTimelineEvent(context, project.id, run.id, {
273
+ type: "code_accepted",
274
+ work_id: rootWork.work_id,
275
+ checks: finalReceipts.map((receipt) => receipt.id)
276
+ });
277
+ if (next === "code_completed") {
278
+ finishStageCoordinatorWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
279
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
280
+ }
281
+ else {
282
+ advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
283
+ }
258
284
  });
259
- if (next === "code_completed") {
260
- await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
261
- completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
262
- }
263
- else {
264
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
265
- }
266
285
  return {
267
286
  ok: true,
268
287
  run_id: run.id,
@@ -270,7 +289,7 @@ export async function finishVnextCode(context, input) {
270
289
  outcome: "accepted",
271
290
  report_path: path.join(root, "stage-report.json"),
272
291
  next_action: next,
273
- ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {})
292
+ ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json`) } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {})
274
293
  };
275
294
  }
276
295
  export function addVnextCodeRepair(context, input) {
@@ -280,7 +299,12 @@ export function addVnextCodeRepair(context, input) {
280
299
  const home = requireHome(run);
281
300
  if (!input.objective.trim())
282
301
  throw new AppError("validation", "Repair objective must not be empty", 2);
283
- if (!input.checkReceiptId && !input.reviewFindingIds?.length && !input.semanticUnresolved?.length) {
302
+ if (input.reviewCycle) {
303
+ const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle);
304
+ if (previous)
305
+ return { ok: true, run_id: run.id, repair_work_id: previous.work_id, start_command: workStartCommand(context, previous), reused: true };
306
+ }
307
+ if (!input.checkReceiptId && !input.reviewFindings?.length && !input.semanticUnresolved?.length) {
284
308
  throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
285
309
  }
286
310
  const receipt = input.checkReceiptId ? context.db.get("SELECT id, declaration_id, run_id, command, status, receipt_path, stdout_path, stderr_path FROM check_receipts WHERE project_id = ? AND id = ?", [
@@ -303,10 +327,10 @@ export function addVnextCodeRepair(context, input) {
303
327
  // repair that fixes one failed aggregate check can make a second, already
304
328
  // declared aggregate failure impossible to repair. The immutable source
305
329
  // of repair eligibility is the original CODE graph.
306
- const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
330
+ const declaredChecks = effectiveCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
307
331
  .map((work) => packet(work))
308
332
  .filter((value) => !value.repair)
309
- .flatMap((value) => value.checks));
333
+ .flatMap((value) => value.checks), ["work", "code", "readiness", "merge"]);
310
334
  const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
311
335
  if (receipt && !failedCheck) {
312
336
  throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
@@ -315,7 +339,7 @@ export function addVnextCodeRepair(context, input) {
315
339
  });
316
340
  }
317
341
  const semanticRepair = Boolean(input.semanticUnresolved?.length);
318
- const key = input.reviewFindingIds?.length ? "code-review-repair" : semanticRepair ? "code-verification-repair" : "code-gate-repair";
342
+ const key = input.reviewFindings?.length ? "code-review-repair" : semanticRepair ? "code-verification-repair" : "code-gate-repair";
319
343
  const receiptWriteScope = receipt ? receiptRepairPaths(run.workspace_root, receipt) : [];
320
344
  const repair = {
321
345
  schema_id: "dd-flow/code-work-packet@5",
@@ -325,8 +349,12 @@ export function addVnextCodeRepair(context, input) {
325
349
  repair: {
326
350
  origin_work_ids: origins.map((work) => work.work_id),
327
351
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
328
- ...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []), review_check_refs: unique(input.reviewCheckRefs ?? (input.reviewChecks ?? []).map((check) => check.id)) } : {}),
329
- ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {})
352
+ ...(input.reviewFindings?.length ? { review_findings: input.reviewFindings } : {}),
353
+ ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {}),
354
+ // `run_at` remains the accepted aggregate placement. This separate
355
+ // repair obligation is deliberately executed before this repair Work is
356
+ // accepted, so a local fix cannot close without re-proving its cause.
357
+ verification_check_refs: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}), ...(semanticRepair ? { semanticChecks: declaredChecks } : {}) })
330
358
  },
331
359
  task: input.objective,
332
360
  semantic_spine: {
@@ -334,14 +362,14 @@ export function addVnextCodeRepair(context, input) {
334
362
  component_responsibility: "Diagnose and repair the failed accepted CODE result without changing unrelated behavior.",
335
363
  must_preserve: unique(invariantPackets.flatMap((value) => value.semantic_spine.must_preserve)),
336
364
  non_goals: unique(invariantPackets.flatMap((value) => value.semantic_spine.non_goals)),
337
- acceptance_contribution: receipt ? `Make failed check pass: ${receipt.command}` : semanticRepair ? `Resolve CODE verification gap(s): ${input.semanticUnresolved.join("; ")}` : `Resolve CODE-REVIEW finding(s): ${input.reviewFindingIds.join(", ")}`
365
+ acceptance_contribution: receipt ? `Make failed check pass: ${receipt.command}` : semanticRepair ? `Resolve CODE verification gap(s): ${input.semanticUnresolved.join("; ")}` : `Resolve CODE-REVIEW finding(s): ${input.reviewFindings.map((finding) => finding.finding_ref).join(", ")}`
338
366
  },
339
367
  requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
340
368
  acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
341
369
  // A CODE-REVIEW repair changes delivered code/evidence, never the
342
370
  // already accepted PLAN or its ownership declaration.
343
371
  document_updates: [],
344
- required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...(semanticRepair && input.verificationPath ? [input.verificationPath] : []), ...packets.flatMap((value) => value.required_read)]),
372
+ required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewFindings?.flatMap((finding) => finding.evidence_refs) ?? []), ...(semanticRepair && input.verificationPath ? [input.verificationPath] : []), ...packets.flatMap((value) => value.required_read)]),
345
373
  discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
346
374
  // These are collision-avoidance hints. Receipt paths enrich the coordinator
347
375
  // picture but never restrict the repair's project-local edits.
@@ -356,7 +384,8 @@ export function addVnextCodeRepair(context, input) {
356
384
  ...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : ["Do not modify accepted PLAN artifacts, code-work-batch.json, or review decisions. Stop and report if the finding cannot be repaired within accepted scope."])
357
385
  ]),
358
386
  depends_on: origins.map((work) => work.work_id),
359
- result_schema: "dd-flow/code-work-result@2"
387
+ result_schema: "dd-flow/code-work-result@3",
388
+ ...(input.reviewCycle ? { review_cycle: input.reviewCycle } : {})
360
389
  };
361
390
  const file = path.join(home, stageDir, `.repair-${crypto.randomUUID()}.json`);
362
391
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -374,16 +403,13 @@ export function addVnextCodeRepair(context, input) {
374
403
  type: "code_repair_created",
375
404
  work_id: id,
376
405
  origin_work_ids: input.originWorkIds,
377
- ...(receipt ? { check_receipt_id: receipt.id } : input.reviewFindingIds?.length ? { review_finding_ids: input.reviewFindingIds } : { semantic_unresolved: input.semanticUnresolved })
406
+ ...(receipt ? { check_receipt_id: receipt.id } : input.reviewFindings?.length ? { review_finding_ids: input.reviewFindings.map((finding) => finding.finding_ref) } : { semantic_unresolved: input.semanticUnresolved })
378
407
  });
379
408
  refreshRunWorkProjection(context, project.id, run.id);
380
409
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
381
410
  }
382
- /** Retain the causal declaration for worker context; run_at keeps aggregate gates at stage scope. */
411
+ /** Retain the causal declaration for worker context; run_at still records its original gate. */
383
412
  export function selectRepairChecks(input) {
384
- // Gate placement belongs to the accepted PLAN. `runCodeChecks` executes only
385
- // work-scoped entries, while retaining the aggregate declaration tells the
386
- // worker exactly what will be rerun by the coordinator.
387
413
  const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
388
414
  const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
389
415
  return uniqueBy(checks, (check) => check.id);
@@ -439,7 +465,7 @@ function coordinatorPrompt(context, input) {
439
465
  "",
440
466
  "<execution_commands>",
441
467
  "Launch only entries listed in graph.ready. Use at most the known available slot count. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain; the coordinator owns dispatch and the stage conclusion, not implementation Work. Every child starts with its exact start_command and receives its complete packet from dd-flow. After a Work finishes, use the graph returned by work finish to launch newly ready Work. To refresh the parent graph yourself use the exact command: " + `${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`,
442
- "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return: do not inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
468
+ "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return once: a completed command with a non-zero exit and structured `code_gate_failed` output is its terminal result, not a reason to keep waiting. Read that returned error and run its repair command; do not inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
443
469
  `A repairable engine, harness, or environment failure is not a user question. Record it without finishing CODE: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-stdin --retryable --project-root ${JSON.stringify(input.projectRoot)} --json. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
444
470
  `When every CODE and repair Work is completed, write ${path.join(input.root, "code-verification.json")} using the exact contract below. Mark passed only when all accepted requirements and current-gate acceptance criteria are implemented or explicitly evidenced; list every remaining issue in unresolved. Every evidence_refs item must already exist as a relative workspace path or run://${input.run.id}/ path. Do not claim a browser or other check receipt that was not retained. Then finish: ${finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "code-verification.json"))}`,
445
471
  "If the aggregate gate fails, do not stop after `code_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use its returned repair command with the relevant completed origin Work IDs and a concise repair objective, then stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
@@ -461,103 +487,57 @@ function coordinatorPrompt(context, input) {
461
487
  export function profileCommands(workspaceRoot) {
462
488
  return aggregateCheckDeclarations(workspaceRoot).map((check) => check.command);
463
489
  }
464
- function latestReceiptsByCommand(receipts) {
465
- const latest = new Map();
466
- for (const receipt of receipts)
467
- for (const ref of receipt.check_refs)
468
- latest.set(ref, receipt);
469
- return [...new Set(latest.values())];
470
- }
471
490
  /**
472
- * A CODE finish may be retried after a client loses the long command's output.
473
- * Reusing complete, same-workspace receipts is correct; rerunning them is both
474
- * expensive and can race the still-settling original invocation.
491
+ * The immutable Work result is a repair history. The stage report is a current
492
+ * acceptance projection, so a registered receipt from an older check epoch
493
+ * must never stand in for the receipt that currently proves the declaration.
475
494
  */
476
- export function reusableAggregateGateReceipts(context, input) {
477
- const expected = new Set(input.checks.map((check) => check.id));
478
- const current = workspaceFingerprint(input.workspaceRoot);
479
- const latest = new Map();
480
- for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
481
- if (receipt.scope === "aggregate")
482
- for (const ref of receipt.check_refs)
483
- if (expected.has(ref))
484
- latest.set(ref, receipt);
485
- }
486
- if (latest.size !== expected.size)
487
- return null;
488
- const receipts = input.checks.map((check) => latest.get(check.id));
489
- return receipts.every((receipt) => receipt.status === "passed" && receipt.workspace_fingerprint === current) ? receipts : null;
490
- }
491
- /** One aggregate gate per RUN; a second invocation must wait, never duplicate it. */
492
- function acquireAggregateGateLock(root) {
493
- const directory = path.join(root, "checks");
494
- const lock = path.join(directory, ".aggregate-gate.lock");
495
- fs.mkdirSync(directory, { recursive: true });
496
- try {
497
- const descriptor = fs.openSync(lock, "wx");
498
- fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
499
- fs.closeSync(descriptor);
500
- }
501
- catch (error) {
502
- if (!isExistingLock(error))
503
- throw error;
504
- const activePid = lockPid(lock);
505
- if (activePid && processAlive(activePid)) {
506
- throw new AppError("code_gate_in_progress", "CODE aggregate gate is already running; wait for that exact stage finish command to return", 1, { pid: activePid, lock });
507
- }
508
- fs.rmSync(lock, { force: true });
509
- return acquireAggregateGateLock(root);
510
- }
511
- return () => fs.rmSync(lock, { force: true });
512
- }
513
- function isExistingLock(error) {
514
- return Boolean(error && typeof error === "object" && error.code === "EEXIST");
515
- }
516
- function lockPid(lock) {
517
- try {
518
- const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
519
- return Number.isInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : null;
520
- }
521
- catch {
522
- return null;
523
- }
524
- }
525
- function processAlive(pid) {
526
- try {
527
- process.kill(pid, 0);
528
- return true;
529
- }
530
- catch {
531
- return false;
532
- }
533
- }
534
- export function verificationProjection(works, receipts) {
495
+ export function verificationProjection(works, receipts, options = {}) {
535
496
  const packets = works.map((work) => packet(work)).filter((value) => value !== null);
536
497
  const declarations = uniqueBy(packets.flatMap((value) => value.checks), (value) => value.id);
537
498
  const receiptByDeclaration = new Map(receipts.flatMap((receipt) => receipt.check_refs.map((ref) => [ref, receipt])));
538
- const evidence = new Map();
499
+ const rawEvidence = new Map();
539
500
  for (const work of works) {
540
501
  const result = readJsonResult(work.result);
541
502
  for (const item of result.evidence ?? [])
542
- if (item.criterion_id)
543
- evidence.set(item.criterion_id, unique([...(evidence.get(item.criterion_id) ?? []), ...(item.refs ?? [])]));
503
+ if (item.obligation_ref)
504
+ rawEvidence.set(item.obligation_ref, unique([...(rawEvidence.get(item.obligation_ref) ?? []), ...(item.refs ?? [])]));
544
505
  }
545
506
  const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
546
- const hasEvidence = (criterionId) => (evidence.get(criterionId)?.length ?? 0) > 0;
547
- const isPassed = (checkId, criterionId) => receiptByDeclaration.get(checkId)?.status === "passed" || (declarations.find((check) => check.id === checkId)?.run_at === "external" && hasEvidence(criterionId));
507
+ const historicalReceiptRefs = new Set((options.historicalReceipts ?? receipts).flatMap((receipt) => receiptReferences(receipt, options)));
508
+ const acceptanceEvidence = new Map(acceptance.map((item) => {
509
+ const reported = rawEvidence.get(item.criterion_id) ?? [];
510
+ const current = unique(item.check_refs.flatMap((checkRef) => {
511
+ const receipt = receiptByDeclaration.get(checkRef);
512
+ return receipt?.status === "passed" ? [currentReceiptRef(receipt, options)] : [];
513
+ }));
514
+ const evidenceRefs = unique([...reported.filter((ref) => !historicalReceiptRefs.has(ref)), ...current]);
515
+ const historical = reported.filter((ref) => historicalReceiptRefs.has(ref) && !current.includes(ref));
516
+ return [item.criterion_id, { evidenceRefs, historical }];
517
+ }));
518
+ const hasEvidence = (criterionId) => (acceptanceEvidence.get(criterionId)?.evidenceRefs.length ?? 0) > 0;
519
+ const isPassed = (checkId) => receiptByDeclaration.get(checkId)?.status === "passed";
548
520
  return {
549
521
  checks: declarations.map((check) => {
550
522
  const receipt = receiptByDeclaration.get(check.id);
551
- const externallyEvidenced = check.run_at === "external" && acceptance.some((item) => item.check_refs.includes(check.id) && hasEvidence(item.criterion_id));
552
- return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (externallyEvidenced ? "evidence_recorded" : ["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
523
+ return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
553
524
  }),
554
525
  acceptance: acceptance.map((item) => {
555
- const external = item.check_refs.some((id) => declarations.find((check) => check.id === id)?.run_at === "external");
556
- const confirmed = item.check_refs.every((id) => isPassed(id, item.criterion_id)) && hasEvidence(item.criterion_id);
557
- return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? (external ? "confirmed_with_external_evidence" : "confirmed") : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.get(item.criterion_id) ?? [] };
526
+ const confirmed = item.check_refs.every((id) => isPassed(id)) && hasEvidence(item.criterion_id);
527
+ const evidence = acceptanceEvidence.get(item.criterion_id) ?? { evidenceRefs: [], historical: [] };
528
+ return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? "confirmed" : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.evidenceRefs, ...(evidence.historical.length ? { reported_evidence_refs: evidence.historical } : {}) };
558
529
  })
559
530
  };
560
531
  }
532
+ function currentReceiptRef(receipt, options) {
533
+ if (!options.runId || !options.runHome)
534
+ return receipt.receipt_path;
535
+ const relative = path.relative(options.runHome, receipt.receipt_path).split(path.sep).join("/");
536
+ return relative && !relative.startsWith("../") ? `run://${options.runId}/${relative}` : receipt.receipt_path;
537
+ }
538
+ function receiptReferences(receipt, options) {
539
+ return unique([receipt.receipt_path, currentReceiptRef(receipt, options)]);
540
+ }
561
541
  function readJsonResult(value) { try {
562
542
  const parsed = JSON.parse(value ?? "{}");
563
543
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -609,75 +589,79 @@ function requireRootWork(context, projectId, runId) {
609
589
  return work;
610
590
  }
611
591
  function requireRun(context, projectId, runId) {
612
- const run = context.db.get("SELECT id, project_id, run_home_path, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
592
+ const run = context.db.get("SELECT id, project_id, run_root, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
613
593
  if (!run)
614
594
  throw new AppError("not_found", "RUN is not registered", 1);
615
595
  return run;
616
596
  }
617
597
  /** CODE, not PROTOCOLIZE, makes a checkout ready for project tooling. */
618
- async function ensureCodeWorkspaceReady(context, projectId, run, root, readinessChecks, progress) {
598
+ export function isReusablePreparedCodeReadiness(value, expected) {
599
+ if (!value || typeof value !== "object" || Array.isArray(value))
600
+ return false;
601
+ const receipt = value;
602
+ return receipt.schema_id === "dd-flow/workspace-readiness@2"
603
+ && receipt.status === "passed"
604
+ && receipt.prepared_by === expected.ownerId
605
+ && receipt.workspace_root === expected.workspaceRoot
606
+ && receipt.command === expected.command
607
+ && receipt.policy_ref === expected.policyRef;
608
+ }
609
+ function readPreparedReadiness(file) {
610
+ try {
611
+ return JSON.parse(fs.readFileSync(file, "utf8"));
612
+ }
613
+ catch {
614
+ return null;
615
+ }
616
+ }
617
+ async function ensureCodeWorkspaceReady(context, projectId, run, root, readinessChecks, progress, options = {}) {
619
618
  const profile = JSON.parse(run.index_json);
620
619
  const bootstrap = profile.execution_profile?.settings?.code_bootstrap;
621
620
  if (!bootstrap || typeof bootstrap.command !== "string" || typeof bootstrap.policy_ref !== "string") {
622
621
  throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
623
622
  }
624
623
  const receiptPath = path.join(root, "workspace-readiness.json");
625
- const prior = readReadiness(receiptPath);
626
- if (prior?.workspace_root === run.workspace_root && prior.command === bootstrap.command && prior.status === "passed" && JSON.stringify(prior.check_declarations ?? []) === JSON.stringify(readinessChecks))
627
- return { ...prior, reused: true };
624
+ const ownerId = context.env.DD_FLOW_CODE_READINESS_OWNER;
625
+ if (!options.force && ownerId && isReusablePreparedCodeReadiness(readPreparedReadiness(receiptPath), {
626
+ ownerId,
627
+ workspaceRoot: run.workspace_root,
628
+ command: bootstrap.command,
629
+ policyRef: bootstrap.policy_ref
630
+ })) {
631
+ progress?.("workspace bootstrap already prepared by this controller");
632
+ return readPreparedReadiness(receiptPath);
633
+ }
634
+ // Unowned and stale receipts are never trusted: a direct stage entry still
635
+ // performs its own readiness check. Only the same controller can hand a
636
+ // freshly prepared workspace to its immediately following lifecycle call.
628
637
  const startedAt = new Date().toISOString();
629
638
  progress?.(`workspace bootstrap started: ${bootstrap.command}`);
630
- const result = await runBootstrap(bootstrap.command, run.workspace_root, codeExecutionEnvironment(run.workspace_root), progress);
639
+ const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
631
640
  const bootstrapPassed = result.exit_code === 0 && !result.error;
632
641
  const checks = bootstrapPassed && readinessChecks.length
633
642
  ? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
634
643
  : [];
635
- const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
644
+ const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", ...(ownerId ? { prepared_by: ownerId } : {}), process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
636
645
  fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
637
646
  if (receipt.status !== "passed")
638
647
  throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
639
648
  progress?.("workspace bootstrap passed");
640
649
  return receipt;
641
650
  }
642
- function runBootstrap(command, cwd, environment, progress) {
643
- return new Promise((resolve) => {
644
- const started = Date.now();
645
- const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
646
- let stdout = "";
647
- let stderr = "";
648
- let error = null;
649
- child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
650
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
651
- const heartbeat = setInterval(() => progress?.(`workspace bootstrap still running (${Math.floor((Date.now() - started) / 1000)}s)`), 15_000);
652
- const timeout = setTimeout(() => child.kill("SIGTERM"), 15 * 60 * 1000);
653
- child.once("error", (value) => { error = value.message; });
654
- child.once("close", (exitCode, signal) => {
655
- clearInterval(heartbeat);
656
- clearTimeout(timeout);
657
- resolve({ exit_code: exitCode, stdout, stderr, error: error ?? (signal ? `terminated by ${signal}` : null) });
658
- });
659
- });
660
- }
661
- function readReadiness(file) {
662
- try {
663
- return JSON.parse(fs.readFileSync(file, "utf8"));
664
- }
665
- catch {
666
- return null;
667
- }
668
- }
669
651
  function requireHome(run) {
670
- if (!run.run_home_path)
671
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1);
672
- return run.run_home_path;
652
+ if (!run.run_root)
653
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
654
+ return run.run_root;
673
655
  }
674
- function finishCommand(context, runId, projectRoot, verificationFile) {
675
- return `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`;
656
+ export function finishCommand(context, runId, projectRoot, verificationFile, retryCheckId) {
657
+ const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : "";
658
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`);
676
659
  }
677
660
  function readVerification(context, input) {
678
661
  const file = path.resolve(input.file);
679
- validateSchema({ schemaName: "code-verification", file, projectRoot: input.projectRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
680
- return JSON.parse(fs.readFileSync(file, "utf8"));
662
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
663
+ validateSchema({ schemaName: "code-verification", file, data, projectRoot: input.projectRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.runRoot });
664
+ return data;
681
665
  }
682
666
  function verificationForFinish(context, input) {
683
667
  return readVerification(context, input);
@@ -699,6 +683,8 @@ export function writeProtocolFlowStatus(workspaceRoot, runHome, runId, status) {
699
683
  }
700
684
  function nextAction(run, changedPaths) {
701
685
  const index = JSON.parse(run.index_json);
686
+ if (index.variables?.["merge.source_repair"])
687
+ return "start_code_review";
702
688
  const requested = index.settings?.code_review?.mode ?? "auto";
703
689
  const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
704
690
  const stopTarget = index.execution_profile?.settings?.stop_target;
@@ -714,19 +700,19 @@ function stageStatus(run, name) {
714
700
  return null;
715
701
  }
716
702
  }
717
- function changedPathsFromReport(file) {
703
+ function isMergeSourceRepairAttempt(run) {
718
704
  try {
719
- const value = JSON.parse(fs.readFileSync(file, "utf8"));
720
- return Array.isArray(value.semantic?.changed_files) ? value.semantic.changed_files.filter((item) => typeof item === "string") : [];
705
+ const value = JSON.parse(run.index_json);
706
+ return Boolean(value.variables?.["merge.source_repair"]);
721
707
  }
722
708
  catch {
723
- return [];
709
+ return false;
724
710
  }
725
711
  }
726
- function resultPaths(result) {
712
+ function changedPathsFromReport(file) {
727
713
  try {
728
- const value = JSON.parse(result ?? "");
729
- return Array.isArray(value.changed_paths) ? value.changed_paths.filter((item) => typeof item === "string") : [];
714
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
715
+ return Array.isArray(value.semantic?.changed_files) ? value.semantic.changed_files.filter((item) => typeof item === "string") : [];
730
716
  }
731
717
  catch {
732
718
  return [];
@@ -745,6 +731,28 @@ function uniqueBy(values, key) {
745
731
  return true;
746
732
  });
747
733
  }
734
+ /** Repair pre-atomic terminal stages without repeating accepted checks or rewinding a successor. */
735
+ export function resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next) {
736
+ const index = JSON.parse(run.index_json);
737
+ if ((index.current_stage ?? index.stage_runs.at(-1)?.stage) !== stage)
738
+ return;
739
+ const terminal = next === "code_completed" || next === "code_review_completed";
740
+ if (!terminal && index.next_action === next)
741
+ return;
742
+ withStageSettlement(context, () => {
743
+ if (!terminal) {
744
+ advanceFlowRun(context, { settlementStage: stage, projectRoot, runId: run.id, status: "running", verdict: `${stage.replaceAll("-", "_")}_completed`, nextAction: next });
745
+ return;
746
+ }
747
+ const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [run.project_id, run.id]);
748
+ if (!work)
749
+ throw new AppError("runtime_missing", "Accepted stage has no root coordinator", 1);
750
+ if (work.status !== "completed")
751
+ finishStageCoordinatorWork(context, work.work_id, JSON.stringify(report, null, 2));
752
+ if (index.status !== "done")
753
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: next });
754
+ });
755
+ }
748
756
  function writeReport(root, report) {
749
757
  writeStageReport(root, report);
750
758
  }