@deksden-com/dd-flow-cli 0.9.0-beta.0 → 0.9.0-beta.7
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 +56 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +2 -2
- package/dist/cli/run-cli.js +105 -7
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cleanup.js +18 -8
- package/dist/services/code-checks.js +194 -44
- package/dist/services/eval-snapshots.js +5 -2
- package/dist/services/hooks.js +25 -22
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +5 -0
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +50 -6
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-pause.js +31 -16
- package/dist/services/usage.js +81 -57
- package/dist/services/vnext-code-review.js +46 -12
- package/dist/services/vnext-code.js +59 -22
- package/dist/services/vnext-merge.js +102 -42
- package/dist/services/vnext-plan-review.js +31 -13
- package/dist/services/vnext-plan.js +57 -9
- package/dist/services/work-registry.js +130 -27
- package/dist/storage/database.js +125 -2
- package/package.json +12 -12
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -4,7 +4,7 @@ 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, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
@@ -135,8 +135,22 @@ export async function finishVnextCode(context, input) {
|
|
|
135
135
|
}
|
|
136
136
|
// Reject a malformed semantic receipt before running the expensive gate.
|
|
137
137
|
const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id });
|
|
138
|
-
if (verification.verdict
|
|
139
|
-
|
|
138
|
+
if (verification.verdict === "blocked") {
|
|
139
|
+
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
|
+
}
|
|
141
|
+
if (verification.verdict === "passed" && verification.unresolved.length > 0) {
|
|
142
|
+
throw new AppError("verification_contradictory", "A passed CODE verification cannot contain unresolved obligations", 2, { unresolved: verification.unresolved });
|
|
143
|
+
}
|
|
144
|
+
if (verification.verdict === "needs_repair") {
|
|
145
|
+
const repair = addVnextCodeRepair(context, {
|
|
146
|
+
projectRoot,
|
|
147
|
+
runId: run.id,
|
|
148
|
+
originWorkIds: works.filter((work) => work.status === "completed").map((work) => work.work_id),
|
|
149
|
+
semanticUnresolved: verification.unresolved.length ? verification.unresolved : [verification.summary],
|
|
150
|
+
verificationPath: input.verificationFile,
|
|
151
|
+
objective: verification.summary
|
|
152
|
+
});
|
|
153
|
+
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." };
|
|
140
154
|
}
|
|
141
155
|
const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
|
|
142
156
|
const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
|
|
@@ -174,18 +188,26 @@ export async function finishVnextCode(context, input) {
|
|
|
174
188
|
}
|
|
175
189
|
const allReceipts = checkReceipts(context, { projectId: project.id, runId: run.id });
|
|
176
190
|
const finalReceipts = latestReceiptsByCommand(allReceipts);
|
|
191
|
+
const projectedVerification = verificationProjection(works, finalReceipts);
|
|
192
|
+
const unresolvedAcceptance = (projectedVerification.acceptance ?? []).filter((item) => item.status === "unresolved");
|
|
193
|
+
if (unresolvedAcceptance.length)
|
|
194
|
+
throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
|
|
177
195
|
const now = context.now();
|
|
178
196
|
const timing = stageTiming(home, stage, now);
|
|
179
|
-
const
|
|
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 });
|
|
180
203
|
const next = nextAction(run, changedPaths);
|
|
181
|
-
writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : next === "start_merge" ? "CODE complete; MERGE is queued." : "CODE complete; this RUN reached its configured terminal boundary.");
|
|
182
204
|
const report = {
|
|
183
205
|
schema_id: "dd-flow/stage-report@1",
|
|
184
206
|
run_id: run.id,
|
|
185
207
|
stage,
|
|
186
208
|
generated_at: now,
|
|
187
209
|
verdict: "done",
|
|
188
|
-
verification:
|
|
210
|
+
verification: projectedVerification,
|
|
189
211
|
semantic: {
|
|
190
212
|
result: `Completed ${works.length} CODE Work item${works.length === 1 ? "" : "s"}; ${finalReceipts.filter((receipt) => receipt.status === "passed").length} current final check receipt${finalReceipts.length === 1 ? "" : "s"} passed.`,
|
|
191
213
|
acceptance: [...expected],
|
|
@@ -213,6 +235,10 @@ export async function finishVnextCode(context, input) {
|
|
|
213
235
|
artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" },
|
|
214
236
|
validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" }
|
|
215
237
|
};
|
|
238
|
+
// Creating the MERGE request is part of accepting this terminal handoff.
|
|
239
|
+
// Do it before materialising a terminal stage report or changing RUN state,
|
|
240
|
+
// 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;
|
|
216
242
|
writeReport(root, report);
|
|
217
243
|
completeFlowRunStage(context, {
|
|
218
244
|
projectRoot,
|
|
@@ -237,7 +263,6 @@ export async function finishVnextCode(context, input) {
|
|
|
237
263
|
else {
|
|
238
264
|
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
|
|
239
265
|
}
|
|
240
|
-
const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
|
|
241
266
|
return {
|
|
242
267
|
ok: true,
|
|
243
268
|
run_id: run.id,
|
|
@@ -255,8 +280,8 @@ export function addVnextCodeRepair(context, input) {
|
|
|
255
280
|
const home = requireHome(run);
|
|
256
281
|
if (!input.objective.trim())
|
|
257
282
|
throw new AppError("validation", "Repair objective must not be empty", 2);
|
|
258
|
-
if (!input.checkReceiptId && !input.reviewFindingIds?.length) {
|
|
259
|
-
throw new AppError("validation", "Repair needs
|
|
283
|
+
if (!input.checkReceiptId && !input.reviewFindingIds?.length && !input.semanticUnresolved?.length) {
|
|
284
|
+
throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
|
|
260
285
|
}
|
|
261
286
|
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 = ?", [
|
|
262
287
|
project.id,
|
|
@@ -269,11 +294,19 @@ export function addVnextCodeRepair(context, input) {
|
|
|
269
294
|
}
|
|
270
295
|
const origins = [...new Set(input.originWorkIds)].map((id) => requireCodeWork(context, project.id, run.id, id));
|
|
271
296
|
const packets = origins.map((work) => packet(work));
|
|
272
|
-
const invariantPackets = receipt
|
|
297
|
+
const invariantPackets = receipt || input.semanticUnresolved?.length
|
|
273
298
|
? codeWorks(context, project.id, run.id).map((work) => packet(work)).filter((value) => !value.repair)
|
|
274
299
|
: packets;
|
|
275
300
|
const first = packets[0];
|
|
276
|
-
|
|
301
|
+
// A later repair may be caused by any accepted aggregate declaration, not
|
|
302
|
+
// only the checks copied into its immediate repair parent. Otherwise a
|
|
303
|
+
// repair that fixes one failed aggregate check can make a second, already
|
|
304
|
+
// declared aggregate failure impossible to repair. The immutable source
|
|
305
|
+
// of repair eligibility is the original CODE graph.
|
|
306
|
+
const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
|
|
307
|
+
.map((work) => packet(work))
|
|
308
|
+
.filter((value) => !value.repair)
|
|
309
|
+
.flatMap((value) => value.checks));
|
|
277
310
|
const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
|
|
278
311
|
if (receipt && !failedCheck) {
|
|
279
312
|
throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
|
|
@@ -281,7 +314,8 @@ export function addVnextCodeRepair(context, input) {
|
|
|
281
314
|
declaration_id: receipt.declaration_id
|
|
282
315
|
});
|
|
283
316
|
}
|
|
284
|
-
const
|
|
317
|
+
const semanticRepair = Boolean(input.semanticUnresolved?.length);
|
|
318
|
+
const key = input.reviewFindingIds?.length ? "code-review-repair" : semanticRepair ? "code-verification-repair" : "code-gate-repair";
|
|
285
319
|
const receiptWriteScope = receipt ? receiptRepairPaths(run.workspace_root, receipt) : [];
|
|
286
320
|
const repair = {
|
|
287
321
|
schema_id: "dd-flow/code-work-packet@5",
|
|
@@ -291,22 +325,23 @@ export function addVnextCodeRepair(context, input) {
|
|
|
291
325
|
repair: {
|
|
292
326
|
origin_work_ids: origins.map((work) => work.work_id),
|
|
293
327
|
...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
|
|
294
|
-
...(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)) } : {})
|
|
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 } : {})
|
|
295
330
|
},
|
|
296
331
|
task: input.objective,
|
|
297
332
|
semantic_spine: {
|
|
298
|
-
user_outcome: receipt ? `Restore the accepted behavior after aggregate failure: ${input.objective}` : `Resolve accepted CODE-REVIEW finding: ${input.objective}`,
|
|
333
|
+
user_outcome: receipt ? `Restore the accepted behavior after aggregate failure: ${input.objective}` : semanticRepair ? `Close the unresolved CODE verification: ${input.objective}` : `Resolve accepted CODE-REVIEW finding: ${input.objective}`,
|
|
299
334
|
component_responsibility: "Diagnose and repair the failed accepted CODE result without changing unrelated behavior.",
|
|
300
335
|
must_preserve: unique(invariantPackets.flatMap((value) => value.semantic_spine.must_preserve)),
|
|
301
336
|
non_goals: unique(invariantPackets.flatMap((value) => value.semantic_spine.non_goals)),
|
|
302
|
-
acceptance_contribution: receipt ? `Make failed check pass: ${receipt.command}` : `Resolve CODE-REVIEW finding(s): ${input.reviewFindingIds.join(", ")}`
|
|
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(", ")}`
|
|
303
338
|
},
|
|
304
339
|
requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
|
|
305
340
|
acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
|
|
306
341
|
// A CODE-REVIEW repair changes delivered code/evidence, never the
|
|
307
342
|
// already accepted PLAN or its ownership declaration.
|
|
308
343
|
document_updates: [],
|
|
309
|
-
required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...packets.flatMap((value) => value.required_read)]),
|
|
344
|
+
required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...(semanticRepair && input.verificationPath ? [input.verificationPath] : []), ...packets.flatMap((value) => value.required_read)]),
|
|
310
345
|
discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
|
|
311
346
|
// These are collision-avoidance hints. Receipt paths enrich the coordinator
|
|
312
347
|
// picture but never restrict the repair's project-local edits.
|
|
@@ -314,7 +349,7 @@ export function addVnextCodeRepair(context, input) {
|
|
|
314
349
|
// Receipts record the resolved shell command. A repair must retain the
|
|
315
350
|
// accepted declaration (including its immutable @check alias) and only
|
|
316
351
|
// change when it runs, so work finish can validate it again.
|
|
317
|
-
checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}) }),
|
|
352
|
+
checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}), ...(semanticRepair ? { semanticChecks: declaredChecks } : {}) }),
|
|
318
353
|
provides_checks: [],
|
|
319
354
|
stop_conditions: unique([
|
|
320
355
|
...invariantPackets.flatMap((value) => value.stop_conditions),
|
|
@@ -339,16 +374,18 @@ export function addVnextCodeRepair(context, input) {
|
|
|
339
374
|
type: "code_repair_created",
|
|
340
375
|
work_id: id,
|
|
341
376
|
origin_work_ids: input.originWorkIds,
|
|
342
|
-
...(receipt ? { check_receipt_id: receipt.id } : { review_finding_ids: input.reviewFindingIds })
|
|
377
|
+
...(receipt ? { check_receipt_id: receipt.id } : input.reviewFindingIds?.length ? { review_finding_ids: input.reviewFindingIds } : { semantic_unresolved: input.semanticUnresolved })
|
|
343
378
|
});
|
|
344
379
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
345
380
|
return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
|
|
346
381
|
}
|
|
347
|
-
/**
|
|
382
|
+
/** Retain the causal declaration for worker context; run_at keeps aggregate gates at stage scope. */
|
|
348
383
|
export function selectRepairChecks(input) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
|
|
388
|
+
const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
|
|
352
389
|
return uniqueBy(checks, (check) => check.id);
|
|
353
390
|
}
|
|
354
391
|
function receiptRepairPaths(workspaceRoot, receipt) {
|
|
@@ -14,6 +14,7 @@ import { writeStageReport } from "./stage-report-renderer.js";
|
|
|
14
14
|
import { bindStageCoordinatorWork, createChildWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
|
|
15
15
|
import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
|
|
16
16
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
17
|
+
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
|
|
17
18
|
const stage = "merge";
|
|
18
19
|
const stageDir = vnextStageDirectory(stage);
|
|
19
20
|
export function isVnextMergeRun(context, input) {
|
|
@@ -35,18 +36,49 @@ export function ensureVnextMergeRequest(context, input) {
|
|
|
35
36
|
const sourceBranch = gitValue(run.workspace_root, ["branch", "--show-current"]);
|
|
36
37
|
if (!sourceBranch)
|
|
37
38
|
throw new AppError("merge_source_invalid", "MERGE requires a named source branch", 1, { workspace_root: run.workspace_root });
|
|
38
|
-
commitPendingSource(run.workspace_root, run.id, ignoredGitPaths(context, project.id));
|
|
39
|
-
const sourceCommit = gitValue(run.workspace_root, ["rev-parse", "HEAD"]);
|
|
40
39
|
const targetBranch = workspaceRoute(home).integration_branch;
|
|
41
|
-
if (!sourceCommit || !targetBranch)
|
|
42
|
-
throw new AppError("merge_source_invalid", "MERGE could not freeze source commit or target branch", 1, { source_commit: sourceCommit, target_branch: targetBranch });
|
|
43
40
|
const targetWorkspace = projectRoot;
|
|
44
|
-
|
|
41
|
+
// Validate every immutable prerequisite before touching the source tree. A
|
|
42
|
+
// rejected MERGE handoff must not leave a new commit behind or let a caller
|
|
43
|
+
// subsequently mark CODE/CODE-REVIEW complete without a queue request.
|
|
44
|
+
if (!targetBranch)
|
|
45
|
+
throw new AppError("merge_source_invalid", "MERGE could not determine the target branch", 1, { target_branch: targetBranch });
|
|
45
46
|
const semantic = planChecks(run.workspace_root, protocols);
|
|
46
47
|
validateMergeAcceptance(run.workspace_root, protocols, semantic);
|
|
47
48
|
const effective = effectiveCheckDeclarations(run.workspace_root, semantic, ["merge"]);
|
|
48
49
|
if (run.workspace_root !== targetWorkspace && effective.length === 0)
|
|
49
50
|
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 });
|
|
51
|
+
const ignored = ignoredGitPaths(context, project.id);
|
|
52
|
+
const accepted = input.acceptedPaths ? [...new Set(input.acceptedPaths.filter((item) => !ignored.includes(item)))].sort() : null;
|
|
53
|
+
const freezeRoot = path.join(home, stageDir);
|
|
54
|
+
const freezeFile = path.join(freezeRoot, "source-freeze.json");
|
|
55
|
+
fs.mkdirSync(freezeRoot, { recursive: true });
|
|
56
|
+
let sourceCommit;
|
|
57
|
+
let sourcePaths;
|
|
58
|
+
if (fs.existsSync(freezeFile)) {
|
|
59
|
+
const frozen = JSON.parse(fs.readFileSync(freezeFile, "utf8"));
|
|
60
|
+
sourceCommit = frozen.run_id === run.id && typeof frozen.source_commit === "string" ? frozen.source_commit : "";
|
|
61
|
+
sourcePaths = Array.isArray(frozen.paths) ? frozen.paths : [];
|
|
62
|
+
if (!sourceCommit || gitValue(run.workspace_root, ["rev-parse", "HEAD"]) !== sourceCommit || (accepted && JSON.stringify(accepted) !== JSON.stringify(sourcePaths)))
|
|
63
|
+
throw new AppError("merge_source_freeze_conflict", "Existing MERGE source freeze does not match the accepted source", 2, { freeze_file: freezeFile });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
sourcePaths = [...new Set(meaningfulStatus(run.workspace_root, ignored).map(statusPath))].sort();
|
|
67
|
+
if (accepted) {
|
|
68
|
+
const unexpected = sourcePaths.filter((item) => !accepted.includes(item));
|
|
69
|
+
const absent = accepted.filter((item) => !sourcePaths.includes(item));
|
|
70
|
+
if (unexpected.length || absent.length)
|
|
71
|
+
throw new AppError("merge_source_drift", "MERGE source differs from the accepted terminal-stage file set", 2, { unexpected, absent });
|
|
72
|
+
}
|
|
73
|
+
const sourceFingerprint = workspaceFingerprint(run.workspace_root);
|
|
74
|
+
commitPendingSource(run.workspace_root, run.id, ignored);
|
|
75
|
+
sourceCommit = gitValue(run.workspace_root, ["rev-parse", "HEAD"]);
|
|
76
|
+
if (sourceCommit)
|
|
77
|
+
fs.writeFileSync(freezeFile, `${JSON.stringify({ schema_id: "dd-flow/merge-source-freeze@1", run_id: run.id, source_workspace: run.workspace_root, source_branch: sourceBranch, source_commit: sourceCommit, precommit_workspace_fingerprint: sourceFingerprint, paths: sourcePaths, at: context.now() }, null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
if (!sourceCommit)
|
|
80
|
+
throw new AppError("merge_source_invalid", "MERGE could not freeze source commit", 1, { source_commit: sourceCommit, target_branch: targetBranch });
|
|
81
|
+
const enqueueTarget = gitValue(targetWorkspace, ["rev-parse", targetBranch], true);
|
|
50
82
|
const root = requireRootWork(context, project.id, run.id);
|
|
51
83
|
let child;
|
|
52
84
|
let requestId;
|
|
@@ -83,38 +115,50 @@ export async function startVnextMerge(context, input) {
|
|
|
83
115
|
fs.mkdirSync(workRoot, { recursive: true });
|
|
84
116
|
const resultPath = path.join(workRoot, "result.json");
|
|
85
117
|
const promptPath = path.join(workRoot, "prompt.md");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
ensureLaneWorkspace(context, { projectRoot, lane: "merge", workspacePath: request.target_workspace, branch: request.target_branch });
|
|
119
|
+
const lane = await waitAcquireLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, timeoutSeconds: 3600, pollIntervalSeconds: 15, ttlSeconds: 7200, reason: `MERGE ${request.merge_request_id}`, ...(input.progress ? { progress: input.progress } : {}) });
|
|
120
|
+
if (!lane.acquired)
|
|
121
|
+
throw new AppError("merge_lane_wait_failed", "MERGE could not acquire the project integration lane", 1, { merge_request_id: request.merge_request_id, status: lane.status ?? "unknown" });
|
|
122
|
+
try {
|
|
123
|
+
if (["active", "action_required"].includes(request.status)) {
|
|
124
|
+
const pendingWork = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
|
|
125
|
+
const binding = pendingWork?.status === "created"
|
|
126
|
+
? startStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) })
|
|
127
|
+
: bindStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
128
|
+
const prompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : mergePrompt(context, { run, request, resultPath, projectRoot });
|
|
129
|
+
if (!fs.existsSync(promptPath))
|
|
130
|
+
fs.writeFileSync(promptPath, prompt);
|
|
131
|
+
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
132
|
+
ensureMergeStageAttached(context, run, projectRoot);
|
|
133
|
+
return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext);
|
|
134
|
+
}
|
|
135
|
+
const current = requireRequest(context, request.merge_request_id);
|
|
136
|
+
const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
|
|
137
|
+
const checkedOutTarget = gitValue(current.target_workspace, ["branch", "--show-current"], true);
|
|
138
|
+
if (checkedOutTarget !== current.target_branch)
|
|
139
|
+
throw new AppError("merge_target_branch_mismatch", "Integration workspace is not on the configured target branch", 1, { expected: current.target_branch, actual: checkedOutTarget, target_workspace: current.target_workspace });
|
|
140
|
+
const targetStatus = meaningfulStatus(current.target_workspace, ignoredGitPaths(context, project.id));
|
|
141
|
+
if (targetStatus.length)
|
|
142
|
+
throw new AppError("merge_target_dirty", "Integration workspace must be clean before MERGE starts", 1, { target_workspace: current.target_workspace, status: targetStatus });
|
|
143
|
+
const claimed = context.db.run("UPDATE merge_requests SET status = 'active', execution_target_head = ?, lock_acquired_at = ?, checkpoint = 'baseline_locked', updated_at = ? WHERE merge_request_id = ? AND status IN ('queued','dispatching','action_required')", [targetHead, context.now(), context.now(), current.merge_request_id]);
|
|
144
|
+
if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
|
|
145
|
+
throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
|
|
146
|
+
const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
147
|
+
const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
|
|
148
|
+
fs.writeFileSync(promptPath, prompt);
|
|
94
149
|
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [promptPath, resultPath, context.now(), current.executor_work_id]);
|
|
151
|
+
attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
|
|
152
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_started", merge_request_id: current.merge_request_id, work_id: current.executor_work_id, target_head: targetHead });
|
|
153
|
+
return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const latest = requireRequest(context, request.merge_request_id);
|
|
157
|
+
if (["active", "dispatching"].includes(latest.status))
|
|
158
|
+
context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_start_failed", error: error instanceof Error ? error.message : String(error) }), context.now(), latest.merge_request_id]);
|
|
159
|
+
releaseMergeLane(context, projectRoot, latest, `MERGE ${latest.merge_request_id} start failed`);
|
|
160
|
+
throw error;
|
|
97
161
|
}
|
|
98
|
-
await waitForTurn(context, request, input.progress);
|
|
99
|
-
const current = requireRequest(context, request.merge_request_id);
|
|
100
|
-
const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
|
|
101
|
-
const checkedOutTarget = gitValue(current.target_workspace, ["branch", "--show-current"], true);
|
|
102
|
-
if (checkedOutTarget !== current.target_branch)
|
|
103
|
-
throw new AppError("merge_target_branch_mismatch", "Integration workspace is not on the configured target branch", 1, { expected: current.target_branch, actual: checkedOutTarget, target_workspace: current.target_workspace });
|
|
104
|
-
const targetStatus = meaningfulStatus(current.target_workspace, ignoredGitPaths(context, project.id));
|
|
105
|
-
if (targetStatus.length)
|
|
106
|
-
throw new AppError("merge_target_dirty", "Integration workspace must be clean before MERGE starts", 1, { target_workspace: current.target_workspace, status: targetStatus });
|
|
107
|
-
const claimed = context.db.run("UPDATE merge_requests SET status = 'active', execution_target_head = ?, lock_acquired_at = ?, checkpoint = 'baseline_locked', updated_at = ? WHERE merge_request_id = ? AND status IN ('queued','dispatching','action_required')", [targetHead, context.now(), context.now(), current.merge_request_id]);
|
|
108
|
-
if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
|
|
109
|
-
throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
|
|
110
|
-
const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
111
|
-
const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
|
|
112
|
-
fs.writeFileSync(promptPath, prompt);
|
|
113
|
-
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
114
|
-
context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [promptPath, resultPath, context.now(), current.executor_work_id]);
|
|
115
|
-
attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
|
|
116
|
-
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_started", merge_request_id: current.merge_request_id, work_id: current.executor_work_id, target_head: targetHead });
|
|
117
|
-
return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
|
|
118
162
|
}
|
|
119
163
|
function startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext) {
|
|
120
164
|
return { ok: true, run_id: run.id, stage, merge_request_id: request.merge_request_id, work_id: request.executor_work_id, session_binding: binding.session_binding ?? binding, prompt_path: promptPath, result_path: resultPath, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), queue: queueStatus(context, request), effective_checks: effectiveMergeChecks(run, request), next: { apply_command: applyCommand(context, request, projectRoot), finish_command: finishCommand(context, run.id, request, projectRoot) } };
|
|
@@ -125,6 +169,7 @@ export function applyVnextMerge(context, input) {
|
|
|
125
169
|
if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
|
|
126
170
|
return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
|
|
127
171
|
const request = requireOwnedActiveRequest(context, input);
|
|
172
|
+
heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
|
|
128
173
|
assertExecutionBaseline(request);
|
|
129
174
|
input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
|
|
130
175
|
let outcome = "applied_clean";
|
|
@@ -157,6 +202,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
157
202
|
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" };
|
|
158
203
|
}
|
|
159
204
|
let request = requireOwnedActiveRequest(context, input);
|
|
205
|
+
heartbeatLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
|
|
160
206
|
if (request.run_id !== run.id)
|
|
161
207
|
throw new AppError("merge_request_mismatch", "MRG does not belong to RUN", 2);
|
|
162
208
|
if (request.checkpoint === "baseline_locked" || !fs.existsSync(applyReceiptPath(context, request)))
|
|
@@ -218,6 +264,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
218
264
|
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
|
|
219
265
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
220
266
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_completed", merge_request_id: request.merge_request_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit });
|
|
267
|
+
releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} completed`);
|
|
221
268
|
return { ok: true, run_id: run.id, stage, outcome: "completed", merge_request_id: request.merge_request_id, work_id: request.executor_work_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit, report_path: path.join(root, "stage-report.json"), next_action: "merge_completed" };
|
|
222
269
|
}
|
|
223
270
|
export function getVnextMergeRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = input.requestId ? requireRequest(context, input.requestId) : input.runId ? requestForRun(context, project.id, input.runId) : null; if (!request || request.project_id !== project.id)
|
|
@@ -277,15 +324,28 @@ function performConfiguredCleanup(run, request) {
|
|
|
277
324
|
const policy = executionSettings(run).merge_cleanup?.source ?? "retain";
|
|
278
325
|
const receipt = cleanupReceiptPath(run);
|
|
279
326
|
let action = "retained";
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
327
|
+
let error = null;
|
|
328
|
+
try {
|
|
329
|
+
if (policy === "delete_after_success" && path.resolve(request.source_workspace) !== path.resolve(request.target_workspace)) {
|
|
330
|
+
git(request.target_workspace, ["worktree", "remove", request.source_workspace]);
|
|
331
|
+
git(request.target_workspace, ["branch", "-d", request.source_branch]);
|
|
332
|
+
action = "deleted";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (cause) {
|
|
336
|
+
// Cleanup is post-delivery hygiene. It must never invalidate an integration
|
|
337
|
+
// commit which already passed the merge gate and was delivered.
|
|
338
|
+
action = "action_required";
|
|
339
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
340
|
+
}
|
|
341
|
+
fs.writeFileSync(receipt, `${JSON.stringify({ schema_id: "dd-flow/merge-cleanup-receipt@1", merge_request_id: request.merge_request_id, policy, action, source_workspace: request.source_workspace, source_branch: request.source_branch, ...(error ? { error } : {}), at: new Date().toISOString() }, null, 2)}\n`);
|
|
342
|
+
}
|
|
343
|
+
function releaseMergeLane(context, projectRoot, request, reason) {
|
|
344
|
+
try {
|
|
345
|
+
releaseLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, reason });
|
|
284
346
|
}
|
|
285
|
-
|
|
347
|
+
catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
|
|
286
348
|
}
|
|
287
|
-
function waitForTurn(context, request, progress) { return new Promise((resolve) => { const poll = () => { const current = requireRequest(context, request.merge_request_id); const ahead = queueAhead(context, current); const active = context.db.get("SELECT merge_request_id FROM merge_requests WHERE project_id = ? AND status IN ('active','action_required','recovery_required') AND merge_request_id <> ? LIMIT 1", [current.project_id, current.merge_request_id]); if (ahead === 0 && !active)
|
|
288
|
-
return resolve(); progress?.(`MERGE ${current.merge_request_id} is waiting: ${ahead} request(s) ahead; next update in 15 seconds`); setTimeout(poll, 15_000); }; poll(); }); }
|
|
289
349
|
function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
|
|
290
350
|
function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
|
|
291
351
|
function requireOwnedActiveRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.executor_work_id !== input.workId || !["active", "action_required"].includes(request.status))
|
|
@@ -14,6 +14,7 @@ import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
|
14
14
|
import { nextWorkId } from "./ids.js";
|
|
15
15
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
16
16
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
17
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
17
18
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
18
19
|
import { capacityProbe, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
19
20
|
const stage = "plan-review";
|
|
@@ -48,7 +49,7 @@ export function startVnextPlanReview(context, input) {
|
|
|
48
49
|
if (existing?.status === "running") {
|
|
49
50
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
50
51
|
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
51
|
-
return { ...preparedExisting(context, { projectRoot, projectId: project.id, run, root, workId: existing.work_id, hookEventId: input.hookEventId, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}), requested, effective, groups, batchChecksum: checksum(batch), planChecksum:
|
|
52
|
+
return { ...preparedExisting(context, { projectRoot, projectId: project.id, run, root, workId: existing.work_id, hookEventId: input.hookEventId, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}), requested, effective, groups, batchChecksum: checksum(batch), planChecksum: planSetChecksum(home, run.workspace_root) }), ...(externalContext ? { external_context: externalContext } : {}) };
|
|
52
53
|
}
|
|
53
54
|
const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
|
|
54
55
|
if (priorStage?.status === "done") {
|
|
@@ -61,7 +62,7 @@ export function startVnextPlanReview(context, input) {
|
|
|
61
62
|
fs.mkdirSync(root, { recursive: true });
|
|
62
63
|
const now = context.now();
|
|
63
64
|
const batchChecksum = checksum(batch);
|
|
64
|
-
const planChecksum =
|
|
65
|
+
const planChecksum = planSetChecksum(home, run.workspace_root);
|
|
65
66
|
if (effective === "off") {
|
|
66
67
|
const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIds });
|
|
67
68
|
if (failures.length)
|
|
@@ -131,7 +132,7 @@ export function dispatchVnextPlanReview(context, input) {
|
|
|
131
132
|
const pending = groups.filter((group) => !latest(group));
|
|
132
133
|
if (pending.length) {
|
|
133
134
|
const file = path.join(root, ".dispatch.json");
|
|
134
|
-
writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
|
|
135
|
+
writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", read_only: true, group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
|
|
135
136
|
try {
|
|
136
137
|
addWorkBatch(context, { parentWorkId: parent.work_id, file });
|
|
137
138
|
}
|
|
@@ -190,14 +191,18 @@ export function finishVnextPlanReview(context, input) {
|
|
|
190
191
|
if (!Number.isInteger(reviewedPlanRevision) || reviewedPlanRevision < 1)
|
|
191
192
|
throw new AppError("runtime_missing", "PLAN-REVIEW starting revision is unavailable", 1);
|
|
192
193
|
const planRevision = currentPlanRevision(home, run.workspace_root);
|
|
193
|
-
|
|
194
|
+
const currentPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
195
|
+
if (decision.outcome === "blocked") {
|
|
196
|
+
throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
|
|
197
|
+
}
|
|
198
|
+
if (["failed", "cancelled"].includes(decision.outcome)) {
|
|
194
199
|
preserveDecisionReceipt(root, decisionPath);
|
|
195
200
|
return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision });
|
|
196
201
|
}
|
|
197
202
|
if (children.some((child) => child.status === "created" || child.status === "running"))
|
|
198
203
|
throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
|
|
199
204
|
const latestChildren = latestReviewChildren(children);
|
|
200
|
-
const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision });
|
|
205
|
+
const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision, workspaceRoot: run.workspace_root, runHome: home, runId: run.id });
|
|
201
206
|
if (evidenceFailures.length)
|
|
202
207
|
throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
|
|
203
208
|
if (decision.outcome !== "accepted")
|
|
@@ -209,17 +214,21 @@ export function finishVnextPlanReview(context, input) {
|
|
|
209
214
|
const needsCorrection = latestChildren.some((child) => ["needs_changes", "blocked"].includes(reviewerVerdict(child.result)));
|
|
210
215
|
if (latestChildren.some((child) => child.status !== "completed"))
|
|
211
216
|
throw new AppError("blocked", "PLAN-REVIEW cannot accept incomplete reviewer evidence", 2);
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
const canonicalFindings = canonicalReviewerFindings(latestChildren);
|
|
218
|
+
const allFindingIds = canonicalFindings.map(({ finding_ref }) => finding_ref);
|
|
219
|
+
const materialFindingIds = canonicalFindings.filter(({ finding }) => ["blocker", "high", "medium"].includes(finding.severity)).map(({ finding_ref }) => finding_ref);
|
|
220
|
+
const decisionFindingIds = decision.finding_decisions.map((finding) => finding.finding_ref).filter((id) => Boolean(id));
|
|
221
|
+
const decidedFindingIds = new Set(decisionFindingIds);
|
|
222
|
+
const unknownDecisions = decisionFindingIds.filter((id) => !allFindingIds.includes(id));
|
|
223
|
+
if (new Set(allFindingIds).size !== allFindingIds.length || decidedFindingIds.size !== decisionFindingIds.length || unknownDecisions.length || materialFindingIds.some((id) => !decidedFindingIds.has(id)))
|
|
224
|
+
throw new AppError("review_evidence_invalid", "Reviewer findings and coordinator decisions must use unique known canonical references, with every material finding decided", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: decisionFindingIds, unknown_decisions: unknownDecisions });
|
|
216
225
|
if (needsCorrection) {
|
|
217
226
|
if (decision.correction.status !== "applied")
|
|
218
227
|
throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
|
|
219
228
|
if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
|
|
220
229
|
throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
|
|
221
230
|
}
|
|
222
|
-
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision) {
|
|
231
|
+
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
|
|
223
232
|
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
|
|
224
233
|
}
|
|
225
234
|
const planFailures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
|
|
@@ -233,7 +242,7 @@ export function finishVnextPlanReview(context, input) {
|
|
|
233
242
|
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
|
|
234
243
|
}
|
|
235
244
|
preserveDecisionReceipt(root, decisionPath);
|
|
236
|
-
const finalPlanChecksum =
|
|
245
|
+
const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
237
246
|
const finalBatchChecksum = checksum(batch);
|
|
238
247
|
const registered = registerCode(context, project.id, run.id, batch);
|
|
239
248
|
const now = context.now();
|
|
@@ -258,7 +267,7 @@ function orchestratorPrompt(context, input) {
|
|
|
258
267
|
const decision = path.join(input.root, "decision.json");
|
|
259
268
|
const revision = currentPlanRevision(input.home, input.run.workspace_root);
|
|
260
269
|
const workspaceContract = ["<workspace_contract>", `- route: ${input.workspaceRoute.route}`, `- feature branch: ${input.workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${input.workspaceRoute.base_ref ?? "not applicable"}`, `- read/write workspace: ${input.run.workspace_root}`, "The CLI verified this frozen route. All plan and correction writes belong in the named workspace; project root remains only the stable lifecycle identity. Do not create, switch, merge or delete branches/worktrees.", "</workspace_contract>"].join("\n");
|
|
261
|
-
return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch requests capacity, run exactly one concurrent fan-out of ${capacityProbeFanoutSize} probes. This measures the harness limit; it is not a task to obtain ${capacityProbeFanoutSize} successful probes. Start #01…#${capacityProbeFanoutSize} once, all together, using all-settled handling so one rejection does not hide the other outcomes. A rejected launch is expected evidence. Never retry, replace, or add a probe. Each started probe calls no tools, reads no files, creates no children, waits ${capacityProbeHoldSeconds} seconds, then returns exactly AGENT-NN. For cleanup, wait at most ${capacityProbeDeadlineSeconds} seconds from the first launch, terminate every unfinished probe, then release every finished probe session that the harness permits. Only after that cleanup record the number of launches that started successfully, not the number of replacement attempts or late completions: ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<successful-initial-launches>")}. Capacity probes are not Works and are never registered.`, "After dispatch, launch at most the measured capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted |
|
|
270
|
+
return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch requests capacity, run exactly one concurrent fan-out of ${capacityProbeFanoutSize} probes. This measures the harness limit; it is not a task to obtain ${capacityProbeFanoutSize} successful probes. Start #01…#${capacityProbeFanoutSize} once, all together, using all-settled handling so one rejection does not hide the other outcomes. A rejected launch is expected evidence. Never retry, replace, or add a probe. Each started probe calls no tools, reads no files, creates no children, waits ${capacityProbeHoldSeconds} seconds, then returns exactly AGENT-NN. For cleanup, wait at most ${capacityProbeDeadlineSeconds} seconds from the first launch, terminate every unfinished probe, then release every finished probe session that the harness permits. Only after that cleanup record the number of launches that started successfully, not the number of replacement attempts or late completions: ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<successful-initial-launches>")}. Capacity probes are not Works and are never registered.`, "After dispatch, launch at most the measured capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
|
|
262
271
|
}
|
|
263
272
|
function reviewGroups(home, workspaceRoot) {
|
|
264
273
|
const root = path.join(home, "03-plan");
|
|
@@ -419,6 +428,14 @@ function reviewerEvidenceFailures(context, input) {
|
|
|
419
428
|
const actual = result.aspects.map((item) => item.aspect_id);
|
|
420
429
|
if (actual.length !== group.aspect_ids.length || new Set(actual).size !== actual.length || group.aspect_ids.some((id) => !actual.includes(id)) || result.aspects.some((item) => item.evidence_refs.length === 0))
|
|
421
430
|
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_coverage_invalid", expected_aspects: group.aspect_ids, actual_aspects: actual });
|
|
431
|
+
for (const ref of result.aspects.flatMap((item) => [...item.evidence_refs, ...item.findings.flatMap((finding) => finding.evidence_refs)])) {
|
|
432
|
+
try {
|
|
433
|
+
assertPortableArtifactRef(ref, { workspaceRoot: input.workspaceRoot, runHome: input.runHome, runId: input.runId });
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_evidence_ref_invalid", ref, detail: error instanceof Error ? error.message : String(error) });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
422
439
|
const workSession = context.db.get("SELECT session_id, status FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [child.work_id]);
|
|
423
440
|
if (!workSession?.session_id || workSession.status !== "completed" || workSession.session_id === parent?.session_id)
|
|
424
441
|
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_session_not_fresh", parent_session_id: parent?.session_id ?? null, reviewer_session_id: workSession?.session_id ?? null });
|
|
@@ -445,7 +462,7 @@ function finishTerminalReview(context, input) {
|
|
|
445
462
|
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [childStatus, input.decision.summary, now, now, rootWork]);
|
|
446
463
|
closeRunningWorkSession(context, rootWork, childStatus, now);
|
|
447
464
|
const settledChildren = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND task <> 'Capacity probe: return ready and finish this Work.'", [input.projectId, input.run.id, input.parentWorkId]);
|
|
448
|
-
const report = reportFor({ run: input.run, mode: input.contextFile.system?.effective_mode ?? "standard", requested: input.contextFile.system?.requested_mode ?? "auto", outcome: input.decision.outcome, groups: input.contextFile.groups ?? [], batchChecksum: input.contextFile.system?.batch_checksum ?? checksum(input.batch), planChecksum: input.contextFile.system?.plan_checksum ??
|
|
465
|
+
const report = reportFor({ run: input.run, mode: input.contextFile.system?.effective_mode ?? "standard", requested: input.contextFile.system?.requested_mode ?? "auto", outcome: input.decision.outcome, groups: input.contextFile.groups ?? [], batchChecksum: input.contextFile.system?.batch_checksum ?? checksum(input.batch), planChecksum: input.contextFile.system?.plan_checksum ?? planSetChecksum(requireHome(input.run), input.run.workspace_root), code: {}, now, projectRoot: input.projectRoot, decision: input.decision, children: latestReviewChildren(settledChildren), flow: flowCommand(context) });
|
|
449
466
|
writeReport(input.root, report);
|
|
450
467
|
refreshRunWorkProjection(context, input.projectId, input.run.id);
|
|
451
468
|
const stageStatus = input.decision.outcome === "cancelled" ? "skipped" : input.decision.outcome === "failed" ? "failed" : "blocked";
|
|
@@ -497,3 +514,4 @@ catch {
|
|
|
497
514
|
} }
|
|
498
515
|
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
499
516
|
function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
517
|
+
function planSetChecksum(home, workspaceRoot) { const entries = protocolIdsForReview(home).sort().map((protocolId) => { const file = path.join(workspaceRoot, ".memory-bank", "protocol", protocolId, "plan.json"); return { protocol_id: protocolId, path: path.relative(workspaceRoot, file).split(path.sep).join("/"), sha256: checksum(file) }; }); return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex"); }
|