@deksden-com/dd-flow-cli 0.9.0-beta.49 → 0.9.0-beta.51
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.
- package/CHANGELOG.md +14 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +4 -1
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/code-checks.js +237 -32
- package/dist/services/runs.js +16 -0
- package/dist/services/usage.js +4 -3
- package/dist/services/vnext-code-review.js +45 -21
- package/dist/services/vnext-code.js +62 -122
- package/dist/services/vnext-merge.js +73 -24
- package/dist/services/vnext-plan-review.js +24 -19
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/work-registry.js +32 -8
- package/dist/services/workspace-bootstrap.js +29 -12
- package/dist/storage/database.js +6 -0
- package/package.json +1 -1
|
@@ -10,15 +10,17 @@ import { validateSchema } from "./schema-validation.js";
|
|
|
10
10
|
import { flowCommand } from "./stage-pause.js";
|
|
11
11
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
12
12
|
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
13
|
-
import { addVnextCodeRepair } from "./vnext-code.js";
|
|
14
|
-
import { finalCodeCheckDeclarations, runCodeChecks,
|
|
15
|
-
import { addWorkBatch, bindStageCoordinatorWork,
|
|
13
|
+
import { addVnextCodeRepair, resumeAcceptedCodeStage } from "./vnext-code.js";
|
|
14
|
+
import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
|
|
15
|
+
import { addWorkBatch, bindStageCoordinatorWork, finishStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
|
|
16
|
+
import { withStageSettlement } from "./runs.js";
|
|
16
17
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
17
18
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
18
19
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
19
20
|
import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
20
21
|
import { ensureVnextMergeRequest } from "./vnext-merge.js";
|
|
21
22
|
import { validateVnextCodeHandoff } from "./vnext-plan.js";
|
|
23
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
22
24
|
const stage = "code-review";
|
|
23
25
|
const stageDir = vnextStageDirectory(stage);
|
|
24
26
|
const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
|
|
@@ -123,6 +125,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
123
125
|
const mode = reviewContext.mode;
|
|
124
126
|
if (!mode)
|
|
125
127
|
throw new AppError("runtime_missing", "CODE-REVIEW effective mode was not frozen at stage start", 1);
|
|
128
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
126
129
|
if (reviewContext.code_report_sha256 !== sha256File(path.join(home, "05-code", "stage-report.json")))
|
|
127
130
|
throw new AppError("code_review_input_changed", "Accepted CODE report changed after CODE-REVIEW start", 2);
|
|
128
131
|
const handoffFailures = validateVnextCodeHandoff(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home });
|
|
@@ -131,7 +134,9 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
131
134
|
const reportPath = path.join(root, "stage-report.json");
|
|
132
135
|
if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
|
|
133
136
|
const report = readJson(reportPath);
|
|
134
|
-
|
|
137
|
+
const next = report.semantic?.next_action ?? "code_review_completed";
|
|
138
|
+
resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
|
|
139
|
+
return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
|
|
135
140
|
}
|
|
136
141
|
const rootWork = requireRootWork(context, project.id, run.id);
|
|
137
142
|
const coordinator = context.db.get("SELECT session_id, result_path FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [rootWork.work_id]);
|
|
@@ -145,6 +150,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
145
150
|
const cycle = reviewContext.cycle ?? "initial";
|
|
146
151
|
const reviewers = reviewerWorks(context, project.id, run.id, cycle);
|
|
147
152
|
if (mode !== "off") {
|
|
153
|
+
assertCodeReviewCoverage(reviewGroups(home), reviewers);
|
|
148
154
|
const incomplete = reviewers.filter((work) => work.status !== "completed");
|
|
149
155
|
if (incomplete.length)
|
|
150
156
|
throw new AppError("reviewer_jobs_incomplete", "CODE-REVIEW cannot finish while reviewer Work is unsettled", 2, { works: incomplete.map((work) => ({ work_id: work.work_id, status: work.status })) });
|
|
@@ -173,13 +179,10 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
173
179
|
const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
174
180
|
const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
|
|
175
181
|
if (input.retryCheckId) {
|
|
176
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks,
|
|
182
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, reason: input.retryReason });
|
|
177
183
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
178
184
|
}
|
|
179
|
-
const
|
|
180
|
-
if (unchangedFailures.length)
|
|
181
|
-
throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; choose source repair or restore the environment and retry that receipt", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures, retry_command: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${unchangedFailures[0].id} --reason "<environment recovery evidence>"` });
|
|
182
|
-
const receipts = await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.progress ? { progress: input.progress } : {}) });
|
|
185
|
+
const receipts = await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}), ...(input.progress ? { progress: input.progress } : {}) });
|
|
183
186
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
184
187
|
if (failed.length)
|
|
185
188
|
throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
|
|
@@ -200,18 +203,21 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
200
203
|
const acceptedPaths = changedPaths(run.workspace_root);
|
|
201
204
|
const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
|
|
202
205
|
writeReport(root, report);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
206
|
+
withStageSettlement(context, () => {
|
|
207
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
208
|
+
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
209
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
|
|
210
|
+
if (nextAction === "start_merge")
|
|
211
|
+
advanceFlowRun(context, { settlementStage: "code-review", projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
|
|
212
|
+
else {
|
|
213
|
+
finishStageCoordinatorWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
|
|
214
|
+
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
|
|
215
|
+
}
|
|
216
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
217
|
+
});
|
|
212
218
|
return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { 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` } } : {}) };
|
|
213
219
|
}
|
|
214
|
-
function reviewGroups(home) {
|
|
220
|
+
export function reviewGroups(home) {
|
|
215
221
|
const expected = new Set(baselineAspects);
|
|
216
222
|
const declared = [];
|
|
217
223
|
for (const file of findFiles(path.join(home, "03-plan"), "aspect-map.json")) {
|
|
@@ -226,15 +232,33 @@ function reviewGroups(home) {
|
|
|
226
232
|
if (!declared.length)
|
|
227
233
|
return [{ key: "baseline", aspect_ids: [...expected].sort() }];
|
|
228
234
|
const assigned = new Set();
|
|
235
|
+
const scoped = new Set();
|
|
229
236
|
for (const group of declared)
|
|
230
237
|
for (const aspectId of group.aspect_ids) {
|
|
231
|
-
|
|
232
|
-
|
|
238
|
+
const assignment = `${group.key.slice(0, group.key.lastIndexOf("/"))}/${aspectId}`;
|
|
239
|
+
if (scoped.has(assignment))
|
|
240
|
+
throw new AppError("code_review_groups_invalid", "A CODE-REVIEW aspect appears in more than one group of the same protocol", 2, { aspect_id: aspectId });
|
|
241
|
+
scoped.add(assignment);
|
|
233
242
|
assigned.add(aspectId);
|
|
234
243
|
}
|
|
235
244
|
const missing = [...expected].filter((aspectId) => !assigned.has(aspectId)).sort();
|
|
236
245
|
return [...declared, ...(missing.length ? [{ key: "baseline", aspect_ids: missing }] : [])];
|
|
237
246
|
}
|
|
247
|
+
export function assertCodeReviewCoverage(groups, reviewers) {
|
|
248
|
+
const expected = new Map(groups.map(group => [group.key, [...group.aspect_ids].sort()]));
|
|
249
|
+
if (!expected.size || expected.size !== groups.length)
|
|
250
|
+
throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW expected group identities must be nonempty and unique", 2);
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const work of reviewers) {
|
|
253
|
+
const group = readJsonString(work.payload_json).group;
|
|
254
|
+
if (!group?.key || seen.has(group.key) || !expected.has(group.key) || !Array.isArray(group.aspect_ids) || JSON.stringify([...group.aspect_ids].sort()) !== JSON.stringify(expected.get(group.key)))
|
|
255
|
+
throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW Work does not match its expected group", 2, { work_id: work.work_id });
|
|
256
|
+
seen.add(group.key);
|
|
257
|
+
}
|
|
258
|
+
const missing = [...expected.keys()].filter(key => !seen.has(key));
|
|
259
|
+
if (missing.length || !expected.size)
|
|
260
|
+
throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW requires every expected reviewer group", 2, { missing_groups: missing });
|
|
261
|
+
}
|
|
238
262
|
export function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Verify that universal and exclusive constraints (only, any, all, never, whole) and stated exceptions were not narrowed. For every changed mutation guarded by membership, ownership, authorization or parent lifecycle state, trace the decision to the write boundary: the predicate must remain in the write statement or the guard and write must share one explicit transaction with the needed lock. A separate earlier read is not proof of current authority or lifecycle state; report a material finding when this invariant is broken. Evidence closes a claim only when it exercises the named failure mechanism; a sequential negative test does not prove a concurrency race, and proof limits cannot waive an accepted obligation. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Use local finding ids FIND-001, FIND-002, and so on; dd-flow adds the Work-qualified canonical reference. Return dd-flow/code-review-result@1.`; }
|
|
239
263
|
function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_root, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
|
|
240
264
|
function canonicalReviewEvidence(decision, reviewers) {
|
|
@@ -4,21 +4,22 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { aggregateCheckDeclarations,
|
|
7
|
+
import { aggregateCheckDeclarations, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
|
-
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
9
|
+
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, withStageSettlement, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
11
11
|
import { flowCommand } from "./stage-pause.js";
|
|
12
12
|
import { validateVnextCodeHandoff } from "./vnext-plan.js";
|
|
13
13
|
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
14
14
|
import { readVnextSpecifyResult } from "./vnext-specify.js";
|
|
15
|
-
import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph,
|
|
15
|
+
import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishStageCoordinatorWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
|
|
16
16
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
17
17
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
18
18
|
import { ensureVnextMergeRequest } from "./vnext-merge.js";
|
|
19
19
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
20
20
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
21
21
|
import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
22
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
22
23
|
const stage = "code";
|
|
23
24
|
const stageDir = vnextStageDirectory("code");
|
|
24
25
|
export async function startVnextCode(context, input) {
|
|
@@ -36,6 +37,7 @@ export async function startVnextCode(context, input) {
|
|
|
36
37
|
const externalContext = applyExternalStageContext({ stageRoot: existingRoot, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
37
38
|
const prompt = fs.readFileSync(existingPrompt, "utf8");
|
|
38
39
|
const rootWork = requireRootWork(context, project.id, input.runId);
|
|
40
|
+
bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath: existingPrompt, resultPath: path.join(existingRoot, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
39
41
|
const orchestration = readFanoutDescriptor(existingRoot) ?? writeFanoutDescriptor(existingRoot, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
|
|
40
42
|
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")) } };
|
|
41
43
|
}
|
|
@@ -108,8 +110,10 @@ export async function finishVnextCode(context, input) {
|
|
|
108
110
|
const run = requireRun(context, project.id, input.runId);
|
|
109
111
|
const home = requireHome(run);
|
|
110
112
|
const root = path.join(home, stageDir);
|
|
113
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
111
114
|
requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage });
|
|
112
115
|
if (stageStatus(run, stage) === "done" && fs.existsSync(path.join(root, "stage-report.json"))) {
|
|
116
|
+
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"))));
|
|
113
117
|
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"))) };
|
|
114
118
|
}
|
|
115
119
|
const handoffFailures = validateVnextCodeHandoff(context, {
|
|
@@ -158,32 +162,14 @@ export async function finishVnextCode(context, input) {
|
|
|
158
162
|
}
|
|
159
163
|
const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
|
|
160
164
|
if (input.retryCheckId) {
|
|
161
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks,
|
|
165
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
|
|
162
166
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
163
167
|
}
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const release = acquireAggregateGateLock(root);
|
|
170
|
-
try {
|
|
171
|
-
// A prior caller may have completed the slow gate while this caller was
|
|
172
|
-
// waiting to acquire the lock. Reuse that immutable evidence.
|
|
173
|
-
receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks }) ?? await runCodeChecks(context, {
|
|
174
|
-
projectId: project.id,
|
|
175
|
-
runId: run.id,
|
|
176
|
-
runHome: home,
|
|
177
|
-
workspaceRoot: run.workspace_root,
|
|
178
|
-
scope: "aggregate",
|
|
179
|
-
checks,
|
|
180
|
-
...(input.progress ? { progress: input.progress } : {})
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
release();
|
|
185
|
-
}
|
|
186
|
-
}
|
|
168
|
+
const receipts = await runCodeChecks(context, {
|
|
169
|
+
projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, scope: "aggregate", checks,
|
|
170
|
+
...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}),
|
|
171
|
+
...(input.progress ? { progress: input.progress } : {})
|
|
172
|
+
});
|
|
187
173
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
188
174
|
if (failed.length) {
|
|
189
175
|
throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
|
|
@@ -195,9 +181,8 @@ export async function finishVnextCode(context, input) {
|
|
|
195
181
|
retry_command: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
|
|
196
182
|
});
|
|
197
183
|
}
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const projectedVerification = verificationProjection(works, finalReceipts, { historicalReceipts: allReceipts, runId: run.id, runHome: home });
|
|
184
|
+
const finalReceipts = receipts;
|
|
185
|
+
const projectedVerification = verificationProjection(works, finalReceipts, { historicalReceipts: finalReceipts, runId: run.id, runHome: home });
|
|
201
186
|
const unresolvedAcceptance = (projectedVerification.acceptance ?? []).filter((item) => item.status === "unresolved");
|
|
202
187
|
if (unresolvedAcceptance.length)
|
|
203
188
|
throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
|
|
@@ -249,29 +234,32 @@ export async function finishVnextCode(context, input) {
|
|
|
249
234
|
// so an invalid merge policy leaves CODE safely running and retryable.
|
|
250
235
|
const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
|
|
251
236
|
writeReport(root, report);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
237
|
+
withStageSettlement(context, () => {
|
|
238
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
239
|
+
completeFlowRunStage(context, {
|
|
240
|
+
projectRoot,
|
|
241
|
+
runId: run.id,
|
|
242
|
+
stage,
|
|
243
|
+
status: "done",
|
|
244
|
+
data: "stage-report.json",
|
|
245
|
+
dataSchemaId: "dd-flow/stage-report@1",
|
|
246
|
+
report: "stage-report.md",
|
|
247
|
+
stageReport: "stage-report.html"
|
|
248
|
+
});
|
|
249
|
+
const rootWork = requireRootWork(context, project.id, run.id);
|
|
250
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, {
|
|
251
|
+
type: "code_accepted",
|
|
252
|
+
work_id: rootWork.work_id,
|
|
253
|
+
checks: finalReceipts.map((receipt) => receipt.id)
|
|
254
|
+
});
|
|
255
|
+
if (next === "code_completed") {
|
|
256
|
+
finishStageCoordinatorWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
|
|
257
|
+
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
|
|
261
|
+
}
|
|
267
262
|
});
|
|
268
|
-
if (next === "code_completed") {
|
|
269
|
-
await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
|
|
270
|
-
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
|
|
274
|
-
}
|
|
275
263
|
return {
|
|
276
264
|
ok: true,
|
|
277
265
|
run_id: run.id,
|
|
@@ -477,76 +465,6 @@ function coordinatorPrompt(context, input) {
|
|
|
477
465
|
export function profileCommands(workspaceRoot) {
|
|
478
466
|
return aggregateCheckDeclarations(workspaceRoot).map((check) => check.command);
|
|
479
467
|
}
|
|
480
|
-
function latestReceiptsByCommand(receipts) {
|
|
481
|
-
const latest = new Map();
|
|
482
|
-
for (const receipt of receipts)
|
|
483
|
-
for (const ref of receipt.check_refs)
|
|
484
|
-
latest.set(ref, receipt);
|
|
485
|
-
return [...new Set(latest.values())];
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* A CODE finish may be retried after a client loses the long command's output.
|
|
489
|
-
* Reusing complete, same-workspace receipts is correct; rerunning them is both
|
|
490
|
-
* expensive and can race the still-settling original invocation.
|
|
491
|
-
*/
|
|
492
|
-
export function reusableAggregateGateReceipts(context, input) {
|
|
493
|
-
const expected = new Set(input.checks.map((check) => check.id));
|
|
494
|
-
const current = workspaceFingerprint(input.workspaceRoot);
|
|
495
|
-
const latest = new Map();
|
|
496
|
-
for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
|
|
497
|
-
if (receipt.scope === "aggregate")
|
|
498
|
-
for (const ref of receipt.check_refs)
|
|
499
|
-
if (expected.has(ref))
|
|
500
|
-
latest.set(ref, receipt);
|
|
501
|
-
}
|
|
502
|
-
if (latest.size !== expected.size)
|
|
503
|
-
return null;
|
|
504
|
-
const receipts = input.checks.map((check) => latest.get(check.id));
|
|
505
|
-
return receipts.every((receipt) => receipt.status === "passed" && receipt.workspace_fingerprint === current) ? receipts : null;
|
|
506
|
-
}
|
|
507
|
-
/** One aggregate gate per RUN; a second invocation must wait, never duplicate it. */
|
|
508
|
-
function acquireAggregateGateLock(root) {
|
|
509
|
-
const directory = path.join(root, "checks");
|
|
510
|
-
const lock = path.join(directory, ".aggregate-gate.lock");
|
|
511
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
512
|
-
try {
|
|
513
|
-
const descriptor = fs.openSync(lock, "wx");
|
|
514
|
-
fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
|
|
515
|
-
fs.closeSync(descriptor);
|
|
516
|
-
}
|
|
517
|
-
catch (error) {
|
|
518
|
-
if (!isExistingLock(error))
|
|
519
|
-
throw error;
|
|
520
|
-
const activePid = lockPid(lock);
|
|
521
|
-
if (activePid && processAlive(activePid)) {
|
|
522
|
-
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 });
|
|
523
|
-
}
|
|
524
|
-
fs.rmSync(lock, { force: true });
|
|
525
|
-
return acquireAggregateGateLock(root);
|
|
526
|
-
}
|
|
527
|
-
return () => fs.rmSync(lock, { force: true });
|
|
528
|
-
}
|
|
529
|
-
function isExistingLock(error) {
|
|
530
|
-
return Boolean(error && typeof error === "object" && error.code === "EEXIST");
|
|
531
|
-
}
|
|
532
|
-
function lockPid(lock) {
|
|
533
|
-
try {
|
|
534
|
-
const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
|
|
535
|
-
return Number.isInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : null;
|
|
536
|
-
}
|
|
537
|
-
catch {
|
|
538
|
-
return null;
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
function processAlive(pid) {
|
|
542
|
-
try {
|
|
543
|
-
process.kill(pid, 0);
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
catch {
|
|
547
|
-
return false;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
468
|
/**
|
|
551
469
|
* The immutable Work result is a repair history. The stage report is a current
|
|
552
470
|
* acceptance projection, so a registered receipt from an older check epoch
|
|
@@ -770,6 +688,28 @@ function uniqueBy(values, key) {
|
|
|
770
688
|
return true;
|
|
771
689
|
});
|
|
772
690
|
}
|
|
691
|
+
/** Repair pre-atomic terminal stages without repeating accepted checks or rewinding a successor. */
|
|
692
|
+
export function resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next) {
|
|
693
|
+
const index = JSON.parse(run.index_json);
|
|
694
|
+
if ((index.current_stage ?? index.stage_runs.at(-1)?.stage) !== stage)
|
|
695
|
+
return;
|
|
696
|
+
const terminal = next === "code_completed" || next === "code_review_completed";
|
|
697
|
+
if (!terminal && index.next_action === next)
|
|
698
|
+
return;
|
|
699
|
+
withStageSettlement(context, () => {
|
|
700
|
+
if (!terminal) {
|
|
701
|
+
advanceFlowRun(context, { settlementStage: stage, projectRoot, runId: run.id, status: "running", verdict: `${stage.replaceAll("-", "_")}_completed`, nextAction: next });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
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]);
|
|
705
|
+
if (!work)
|
|
706
|
+
throw new AppError("runtime_missing", "Accepted stage has no root coordinator", 1);
|
|
707
|
+
if (work.status !== "completed")
|
|
708
|
+
finishStageCoordinatorWork(context, work.work_id, JSON.stringify(report, null, 2));
|
|
709
|
+
if (index.status !== "done")
|
|
710
|
+
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: next });
|
|
711
|
+
});
|
|
712
|
+
}
|
|
773
713
|
function writeReport(root, report) {
|
|
774
714
|
writeStageReport(root, report);
|
|
775
715
|
}
|
|
@@ -15,7 +15,7 @@ import { validateSchema } from "./schema-validation.js";
|
|
|
15
15
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
16
16
|
import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
|
|
17
17
|
import { addVnextCodeRepair } from "./vnext-code.js";
|
|
18
|
-
import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks,
|
|
18
|
+
import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
|
|
19
19
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
20
20
|
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
|
|
21
21
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
@@ -50,6 +50,7 @@ export function ensureVnextMergeRequest(context, input) {
|
|
|
50
50
|
throw new AppError("merge_source_invalid", "MERGE could not determine the target branch", 1, { target_branch: targetBranch });
|
|
51
51
|
const semantic = planChecks(run.workspace_root, protocols);
|
|
52
52
|
validateMergeAcceptance(run.workspace_root, protocols, semantic);
|
|
53
|
+
const acceptanceRefs = mergeAcceptanceRefs(run.workspace_root, protocols);
|
|
53
54
|
const effective = effectiveCheckDeclarations(targetWorkspace, semantic, ["work", "code", "readiness", "merge"]);
|
|
54
55
|
if (run.workspace_root !== targetWorkspace && effective.length === 0)
|
|
55
56
|
throw new AppError("merge_gate_missing", "A real source/target integration requires at least one semantic or policy merge check", 2, { run_id: run.id, protocol_ids: protocols });
|
|
@@ -59,7 +60,7 @@ export function ensureVnextMergeRequest(context, input) {
|
|
|
59
60
|
const freezeFile = path.join(freezeRoot, "source-freeze.json");
|
|
60
61
|
const gateFile = path.join(freezeRoot, "merge-gate.json");
|
|
61
62
|
fs.mkdirSync(freezeRoot, { recursive: true });
|
|
62
|
-
freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
|
|
63
|
+
freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, acceptanceRefs, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
|
|
63
64
|
let sourceCommit;
|
|
64
65
|
let sourcePaths;
|
|
65
66
|
if (fs.existsSync(freezeFile)) {
|
|
@@ -179,10 +180,14 @@ function startPacket(context, run, request, binding, promptPath, resultPath, pro
|
|
|
179
180
|
export function applyVnextMerge(context, input) {
|
|
180
181
|
const existing = requireRequest(context, input.requestId);
|
|
181
182
|
const receiptFile = applyReceiptPath(context, existing);
|
|
182
|
-
if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
|
|
183
|
-
return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
|
|
184
183
|
const request = requireOwnedActiveRequest(context, input);
|
|
185
184
|
assertRunMutationAllowed(context, request.project_id, request.run_id);
|
|
185
|
+
if (["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(request.checkpoint) && fs.existsSync(receiptFile)) {
|
|
186
|
+
const receipt = JSON.parse(fs.readFileSync(receiptFile, "utf8"));
|
|
187
|
+
if (receipt.merge_request_id !== request.merge_request_id || receipt.work_id !== request.executor_work_id || receipt.source_commit !== request.source_commit)
|
|
188
|
+
throw new AppError("merge_apply_receipt_invalid", "Existing merge apply receipt does not match the active request", 1, { receipt_path: receiptFile });
|
|
189
|
+
return receipt;
|
|
190
|
+
}
|
|
186
191
|
heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
|
|
187
192
|
assertExecutionBaseline(request);
|
|
188
193
|
input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
|
|
@@ -214,6 +219,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
214
219
|
throw new AppError("merge_request_mismatch", "MRG/Work/RUN/project identity does not match", 2);
|
|
215
220
|
if (existing.status === "completed") {
|
|
216
221
|
recoverCompletedMergeRun(context, run, projectRoot, existing);
|
|
222
|
+
releaseMergeLane(context, projectRoot, existing, `MERGE ${existing.merge_request_id} completion recovered`);
|
|
217
223
|
return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
|
|
218
224
|
}
|
|
219
225
|
let request = requireOwnedActiveRequest(context, input);
|
|
@@ -236,29 +242,35 @@ export async function finishVnextMerge(context, input) {
|
|
|
236
242
|
context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
|
|
237
243
|
request = requireRequest(context, request.merge_request_id);
|
|
238
244
|
}
|
|
239
|
-
const
|
|
245
|
+
const gate = readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id), checks = gate.checks;
|
|
240
246
|
if (input.retryCheckId) {
|
|
241
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks,
|
|
247
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
|
|
242
248
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
243
249
|
}
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
250
|
+
const acceptancePath = path.join(requireHome(run), stageDir, "merge-gate-acceptance.json");
|
|
251
|
+
const recovered = readMergeGateAcceptance(context, acceptancePath, request, gate);
|
|
252
|
+
const resumingGate = ["checks_passed", "integration_committed", "delivered"].includes(request.checkpoint);
|
|
253
|
+
if (resumingGate && !recovered.valid)
|
|
254
|
+
return recovery(context, request, "merge_evidence_missing", { checkpoint: request.checkpoint });
|
|
255
|
+
if (request.checkpoint === "checks_passed" && request.accepted_tree && gitValue(request.target_workspace, ["write-tree"]) !== request.accepted_tree)
|
|
256
|
+
return recovery(context, request, "merge_workspace_drift_after_checks", { accepted_tree: request.accepted_tree });
|
|
257
|
+
const receipts = resumingGate ? recovered.receipts : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, expectedProfileHash: gate.profileHash, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}), ...(input.progress ? { progress: input.progress } : {}) });
|
|
248
258
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
249
259
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
250
260
|
if (failed.length) {
|
|
251
261
|
context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
|
|
252
262
|
throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
|
|
253
263
|
}
|
|
254
|
-
const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
|
|
255
264
|
const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
|
|
256
|
-
const missingRefs =
|
|
265
|
+
const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
|
|
257
266
|
if (missingRefs.length)
|
|
258
|
-
throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every merge
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every frozen merge-gate reference", 2, { missing_check_refs: missingRefs });
|
|
268
|
+
if (!request.integration_commit) {
|
|
269
|
+
const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
|
|
270
|
+
writeMergeGateAcceptance(context, acceptancePath, request, gate, acceptedTree, receipts);
|
|
271
|
+
context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
|
|
272
|
+
request = requireRequest(context, request.merge_request_id);
|
|
273
|
+
}
|
|
262
274
|
if (!request.integration_commit) {
|
|
263
275
|
commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
|
|
264
276
|
const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
|
|
@@ -269,11 +281,20 @@ export async function finishVnextMerge(context, input) {
|
|
|
269
281
|
context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
|
|
270
282
|
request = requireRequest(context, request.merge_request_id);
|
|
271
283
|
}
|
|
284
|
+
if (request.integration_commit && request.accepted_tree && gitValue(request.target_workspace, ["rev-parse", `${request.integration_commit}^{tree}`]) !== request.accepted_tree)
|
|
285
|
+
return recovery(context, request, "merge_committed_tree_drift", { accepted_tree: request.accepted_tree, integration_commit: request.integration_commit });
|
|
272
286
|
verifyLocalDelivery(request);
|
|
273
287
|
context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
|
|
274
288
|
request = requireRequest(context, request.merge_request_id);
|
|
275
289
|
performConfiguredCleanup(run, request);
|
|
276
|
-
|
|
290
|
+
const acceptedResult = fs.readFileSync(resultPath, "utf8");
|
|
291
|
+
const executor = context.db.get("SELECT status, result FROM works WHERE work_id = ?", [request.executor_work_id]);
|
|
292
|
+
if (!executor)
|
|
293
|
+
return recovery(context, request, "merge_executor_missing", { work_id: request.executor_work_id });
|
|
294
|
+
if (executor.status === "running")
|
|
295
|
+
await finishWork(context, request.executor_work_id, acceptedResult);
|
|
296
|
+
else if (executor.status !== "completed" || executor.result !== acceptedResult)
|
|
297
|
+
return recovery(context, request, "merge_executor_settlement_mismatch", { work_id: request.executor_work_id, status: executor.status });
|
|
277
298
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
278
299
|
const now = context.now();
|
|
279
300
|
context.db.run("UPDATE merge_requests SET status = 'completed', checkpoint = 'finalized', result_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify(semantic), now, now, request.merge_request_id]);
|
|
@@ -299,14 +320,24 @@ function ensureMergeStageAttached(context, run, projectRoot) {
|
|
|
299
320
|
function recoverCompletedMergeRun(context, run, projectRoot, request) {
|
|
300
321
|
const root = path.join(requireHome(run), stageDir);
|
|
301
322
|
const reportPath = path.join(root, "stage-report.json");
|
|
302
|
-
if (!fs.existsSync(reportPath))
|
|
303
|
-
|
|
323
|
+
if (!fs.existsSync(reportPath)) {
|
|
324
|
+
const semantic = request.result_json ? JSON.parse(request.result_json) : null;
|
|
325
|
+
const gate = readFrozenMergeGate(path.join(root, "merge-gate.json"), request.merge_request_id);
|
|
326
|
+
const accepted = readMergeGateAcceptance(context, path.join(root, "merge-gate-acceptance.json"), request, gate);
|
|
327
|
+
if (!semantic || typeof semantic.summary !== "string" || typeof semantic.verification_summary !== "string" || !Array.isArray(semantic.residual_risks) || !semantic.residual_risks.every(item => typeof item === "string") || !accepted.valid)
|
|
328
|
+
throw new AppError("merge_report_missing", "Completed MERGE has insufficient authoritative evidence to rebuild its report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
|
|
329
|
+
verifyLocalDelivery(request);
|
|
330
|
+
performConfiguredCleanup(run, request);
|
|
331
|
+
writeStageReport(root, mergeReport(context, run, request, semantic, accepted.receipts));
|
|
332
|
+
}
|
|
304
333
|
ensureMergeStageAttached(context, run, projectRoot);
|
|
305
334
|
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@2", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
306
335
|
const rootWork = findRootWork(context, run.project_id, run.id);
|
|
307
336
|
const rootStatus = rootWork.status;
|
|
308
337
|
if (rootStatus === "running")
|
|
309
338
|
finishFanInWork(context, rootWork.work_id, fs.readFileSync(reportPath, "utf8"));
|
|
339
|
+
else if (rootStatus !== "completed")
|
|
340
|
+
throw new AppError("merge_root_settlement_mismatch", "Completed MERGE cannot recover a non-completed root Work", 1, { merge_request_id: request.merge_request_id, work_id: rootWork.work_id, status: rootStatus });
|
|
310
341
|
const runStatus = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id])?.status;
|
|
311
342
|
if (runStatus !== "done")
|
|
312
343
|
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
|
|
@@ -374,21 +405,39 @@ function planChecks(workspace, protocols) { return protocols.flatMap((protocol)
|
|
|
374
405
|
return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.availability === "available").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
|
|
375
406
|
function validateMergeAcceptance(workspace, protocols, checks) { const known = new Set(checks.map((check) => check.canonical_ref)); const missing = mergeAcceptanceRefs(workspace, protocols).filter((ref) => !known.has(ref)); if (missing.length)
|
|
376
407
|
throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a missing check", 2, { check_refs: missing }); }
|
|
377
|
-
function freezeMergeGate(file, input) { const canonical = JSON.stringify(input.checks); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
|
|
408
|
+
function freezeMergeGate(file, input) { const canonical = JSON.stringify({ checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort() }); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
|
|
378
409
|
const existing = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
379
|
-
if (existing.run_id !== input.runId || existing.
|
|
410
|
+
if (existing.run_id !== input.runId || existing.gate_hash !== hash)
|
|
380
411
|
throw new AppError("merge_gate_freeze_conflict", "MERGE gate is already frozen with different checks", 2, { file });
|
|
381
412
|
return;
|
|
382
|
-
} fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@
|
|
413
|
+
} fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@2", run_id: input.runId, protocols: input.protocols, checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort(), gate_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
|
|
383
414
|
function readFrozenMergeGate(file, requestId) { try {
|
|
384
415
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
385
|
-
if (value.schema_id !== "dd-flow/merge-gate@
|
|
416
|
+
if (value.schema_id !== "dd-flow/merge-gate@2" || !Array.isArray(value.checks) || !Array.isArray(value.acceptance_refs) || !value.acceptance_refs.every(ref => typeof ref === "string") || typeof value.gate_hash !== "string" || value.gate_hash !== cryptoHash(JSON.stringify({ checks: value.checks, acceptance_refs: [...value.acceptance_refs].sort() })))
|
|
386
417
|
throw new Error("invalid gate");
|
|
387
|
-
return { checks: value.checks };
|
|
418
|
+
return { checks: value.checks, acceptanceRefs: [...value.acceptance_refs].sort(), profileHash: value.profile_hash ?? null, gateHash: value.gate_hash };
|
|
388
419
|
}
|
|
389
420
|
catch (cause) {
|
|
390
421
|
throw new AppError("merge_gate_missing", "MERGE gate is missing or invalid", 1, { merge_request_id: requestId, file, cause: String(cause) });
|
|
391
422
|
} }
|
|
423
|
+
function frozenGateRefs(gate) { return [...new Set([...gate.checks.map(check => check.canonical_ref ?? check.id), ...gate.acceptanceRefs])].sort(); }
|
|
424
|
+
function writeMergeGateAcceptance(context, file, request, gate, acceptedTree, receipts) { const persisted = new Map(checkReceipts(context, { projectId: request.project_id, runId: request.run_id }).map(receipt => [receipt.id, receipt])); const value = { schema_id: "dd-flow/merge-gate-acceptance@2", merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, gate_hash: gate.gateHash, profile_hash: gate.profileHash, accepted_tree: acceptedTree, receipts: receipts.map(receipt => ({ id: receipt.id, execution_refs: [...(persisted.get(receipt.id)?.check_refs ?? receipt.check_refs)].sort(), binding_refs: [...receipt.check_refs].sort(), input_hash: receipt.input_hash })) }; const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
425
|
+
function readMergeGateAcceptance(context, file, request, gate) { try {
|
|
426
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
427
|
+
if (value.schema_id !== "dd-flow/merge-gate-acceptance@2" || value.merge_request_id !== request.merge_request_id || value.run_id !== request.run_id || value.work_id !== request.executor_work_id || value.gate_hash !== gate.gateHash || value.profile_hash !== gate.profileHash || value.accepted_tree !== request.accepted_tree || !Array.isArray(value.receipts) || !value.receipts.every(receipt => receipt && typeof receipt.id === "string" && typeof receipt.input_hash === "string" && Array.isArray(receipt.execution_refs) && receipt.execution_refs.every(ref => typeof ref === "string") && Array.isArray(receipt.binding_refs) && receipt.binding_refs.every(ref => typeof ref === "string")))
|
|
428
|
+
return { valid: false, receipts: [] };
|
|
429
|
+
const all = checkReceipts(context, { projectId: request.project_id, runId: request.run_id });
|
|
430
|
+
const receipts = value.receipts.map(saved => all.find(receipt => receipt.id === saved.id && receipt.input_hash === saved.input_hash && JSON.stringify([...receipt.check_refs].sort()) === JSON.stringify(saved.execution_refs))).filter((receipt) => Boolean(receipt));
|
|
431
|
+
const bindingRefs = value.receipts.flatMap(receipt => receipt.binding_refs);
|
|
432
|
+
const bindings = new Set(bindingRefs);
|
|
433
|
+
const expected = frozenGateRefs(gate);
|
|
434
|
+
if (receipts.length !== value.receipts.length || bindingRefs.length !== bindings.size || !receipts.every(checkReceiptEvidenceValid) || JSON.stringify([...bindings].sort()) !== JSON.stringify(expected) || gitValue(request.target_workspace, ["write-tree"]) !== value.accepted_tree)
|
|
435
|
+
return { valid: false, receipts: [] };
|
|
436
|
+
return { valid: true, receipts: receipts.map((receipt, index) => ({ ...receipt, check_refs: value.receipts[index].binding_refs })) };
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return { valid: false, receipts: [] };
|
|
440
|
+
} }
|
|
392
441
|
function cryptoHash(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
393
442
|
function mergeAcceptanceRefs(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
|
|
394
443
|
return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.acceptance ?? []).filter((entry) => entry.gate === "merge").flatMap((entry) => (entry.check_refs ?? []).map((ref) => `${protocol}/${ref}`)); }); }
|