faberun 0.25.0 → 0.26.0
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/package.json +1 -1
- package/skills/faberun/references/contract.md +34 -28
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/contract/task-packet.mjs +4 -1
- package/src/contract/worker-result.mjs +35 -4
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +29 -24
- package/src/engine/resume.mjs +49 -12
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
package/src/plan/pipeline.mjs
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
* (sizing, routing, freeze), never as one long-lived process. Separate from
|
|
5
5
|
* `template.mjs` (which only builds the one-node contracts) and from
|
|
6
6
|
* `freeze.mjs` (which only turns a plan into a validated contract on disk):
|
|
7
|
-
* this module
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* decides when a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* this module sequences the draft and stages the sizing/routing/freeze
|
|
8
|
+
* assembly both the round loop and the final freeze run through. The round
|
|
9
|
+
* loop itself — checking each round's plan against the contract it would
|
|
10
|
+
* freeze into, comparing a revision's write set against the plan it revised,
|
|
11
|
+
* and the finding bookkeeping that decides when a round is done — lives in
|
|
12
|
+
* `rounds.mjs`; the contested-plan exit every unresolvable path returns
|
|
13
|
+
* through lives in `contest.mjs`. `launch` and `wait` are the only two seams
|
|
14
|
+
* that touch a process or the wall clock, so a test drives the whole pipeline
|
|
15
|
+
* through `runContract` in-process, deterministically.
|
|
14
16
|
*/
|
|
15
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
18
|
import { dirname, join, relative, resolve } from "node:path";
|
|
17
19
|
import { CONTRACT_VERSION, DEFAULT_MAX_TURNS, PROTOCOL_SCHEMA_VERSION, validateContract } from "../contract/index.mjs";
|
|
18
20
|
import { discoveryOutput } from "../contract/worker-result.mjs";
|
|
@@ -21,18 +23,28 @@ import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
|
21
23
|
import { appendSeatAllowanceEvent, readJournal } from "../campaign/journal.mjs";
|
|
22
24
|
import { readCampaign } from "../campaign/record.mjs";
|
|
23
25
|
import { campaignCli } from "../cli/campaign.mjs";
|
|
24
|
-
import { appendJsonl
|
|
25
|
-
import { stableJson } from "../util.mjs";
|
|
26
|
+
import { appendJsonl } from "../run/store.mjs";
|
|
26
27
|
import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
27
28
|
import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
|
|
29
|
+
import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
|
|
28
30
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
29
31
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
30
|
-
import {
|
|
32
|
+
import { checkPlanProofs } from "./proof-check.mjs";
|
|
33
|
+
import { collectHumanSteps } from "./human-step.mjs";
|
|
34
|
+
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
|
|
31
35
|
import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
|
|
32
36
|
import { resolveRuntimes } from "./routing.mjs";
|
|
33
|
-
import {
|
|
37
|
+
import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
34
38
|
import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
|
|
35
39
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
40
|
+
import { contestPlan } from "./contest.mjs";
|
|
41
|
+
import { runReviewRounds } from "./rounds.mjs";
|
|
42
|
+
|
|
43
|
+
// `droppedWriteFindings` and `unresolvedFindings` are defined in
|
|
44
|
+
// `rounds.mjs`, the only module that calls them: this is a compatibility
|
|
45
|
+
// re-export, not a second home, kept because `test/plan/template.test.mjs`
|
|
46
|
+
// already names this path.
|
|
47
|
+
export { droppedWriteFindings, unresolvedFindings } from "./rounds.mjs";
|
|
36
48
|
|
|
37
49
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
38
50
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
@@ -48,7 +60,7 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
|
48
60
|
/** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
|
|
49
61
|
/** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
|
|
50
62
|
/** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[], warnings: string[]}} FrozenPipelineResult */
|
|
51
|
-
/** @typedef {
|
|
63
|
+
/** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
|
|
52
64
|
|
|
53
65
|
/**
|
|
54
66
|
* The session id every automated journal entry this pipeline writes carries.
|
|
@@ -84,7 +96,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
|
|
|
84
96
|
const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
85
97
|
|
|
86
98
|
/**
|
|
87
|
-
* @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
|
|
99
|
+
* @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, reviewers?: string[], runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
|
|
88
100
|
* `targetedFix` allows a plan with a single node. Sizing refuses one by
|
|
89
101
|
* default because a phase that decomposes into one node is usually a plan
|
|
90
102
|
* that was never decomposed; a targeted fix is the case where one node is
|
|
@@ -94,7 +106,7 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
|
94
106
|
export async function runPlanningPipeline(options) {
|
|
95
107
|
const {
|
|
96
108
|
specPath, campaignId, phase, runtimes, launch, wait,
|
|
97
|
-
reviewRounds = 2, runtimeDefaults = {}, verification = {}, targetedFix = false,
|
|
109
|
+
reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false,
|
|
98
110
|
} = options;
|
|
99
111
|
const ask = options.ask ?? askPlanningRuntimes;
|
|
100
112
|
// Implementation work is sized by what it writes; exploratory work -- an
|
|
@@ -147,6 +159,10 @@ export async function runPlanningPipeline(options) {
|
|
|
147
159
|
// from the spec's prose.
|
|
148
160
|
const parsedSpec = parseSpec(specText);
|
|
149
161
|
const repoFacts = collectRepoFacts(cwd, { requirements: parsedSpec.requirements });
|
|
162
|
+
// R16: an operator step a requirement's constraints declare, detected once
|
|
163
|
+
// here so both the freeze below and a caller inspecting the pipeline agree
|
|
164
|
+
// on the same list.
|
|
165
|
+
const humanSteps = collectHumanSteps(parsedSpec.requirements);
|
|
150
166
|
const repoFactsPath = join(scratchDir, "repo-facts.json");
|
|
151
167
|
writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
|
|
152
168
|
const relativeRepoFactsPath = relative(cwd, repoFactsPath);
|
|
@@ -172,14 +188,24 @@ export async function runPlanningPipeline(options) {
|
|
|
172
188
|
* @returns {Promise<{contract: ValidatedContract, output: Record<string, unknown>}>}
|
|
173
189
|
*/
|
|
174
190
|
const runStage = async (kind, inputs) => {
|
|
175
|
-
|
|
176
|
-
|
|
191
|
+
// R19: a review or spec-review stage's runtime comes from the planner's
|
|
192
|
+
// own reviewer list, resolved fresh at every stage so a refusal recorded
|
|
193
|
+
// between rounds is not repeated.
|
|
194
|
+
const reviewerId = kind === "review" || kind === "spec-review" ? firstEligibleReviewer(reviewers, runtimes) ?? undefined : undefined;
|
|
195
|
+
// A stage number whose run already exists belongs to an earlier `plan` of
|
|
196
|
+
// this phase and is skipped: measured 2026-09-25, re-planning a phase
|
|
197
|
+
// reused `draft-1`, whose run the first attempt had left, and the launch
|
|
198
|
+
// died at bootstrap on "run already exists".
|
|
199
|
+
let contractPath, raw, validated;
|
|
200
|
+
do {
|
|
201
|
+
const stageN = nextN();
|
|
202
|
+
contractPath = join(plansDir, "nodes", `${kind}-${stageN}.contract.json`);
|
|
203
|
+
raw = buildPlanningContract(kind, {
|
|
204
|
+
campaignId, phase, n: stageN, runtimes, runtimeDefaults, ...(reviewerId === undefined ? {} : { reviewerId }), cwd: relative(join(plansDir, "nodes"), cwd) || ".", ...inputs,
|
|
205
|
+
});
|
|
206
|
+
validated = validateContract(raw, contractPath);
|
|
207
|
+
} while (existsSync(runDirectory(validated.cwd, validated.id)));
|
|
177
208
|
mkdirSync(dirname(contractPath), { recursive: true });
|
|
178
|
-
const relativeCwd = relative(dirname(contractPath), cwd) || ".";
|
|
179
|
-
const raw = buildPlanningContract(kind, {
|
|
180
|
-
campaignId, phase, n: stageN, runtimes, runtimeDefaults, cwd: relativeCwd, ...inputs,
|
|
181
|
-
});
|
|
182
|
-
const validated = validateContract(raw, contractPath);
|
|
183
209
|
writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
184
210
|
await launch(contractPath, validated);
|
|
185
211
|
const runDir = runDirectory(validated.cwd, validated.id);
|
|
@@ -207,12 +233,6 @@ export async function runPlanningPipeline(options) {
|
|
|
207
233
|
// a finding back out once the plan moved under it.
|
|
208
234
|
/** @type {PlanFindingOutput[]} */
|
|
209
235
|
let findings = [];
|
|
210
|
-
// The writes the most recent revise dropped, computed where the revise's
|
|
211
|
-
// output is validated and held for the next round: merged in there, after
|
|
212
|
-
// the review's own findings, so a drop is never cleaned away by a fresh
|
|
213
|
-
// review passing over a plan that no longer declares the write.
|
|
214
|
-
/** @type {PlanFindingOutput[]} */
|
|
215
|
-
let droppedWrites = [];
|
|
216
236
|
try {
|
|
217
237
|
plan = validatePlanOutput(draft.output.plan);
|
|
218
238
|
} catch (error) {
|
|
@@ -222,6 +242,17 @@ export async function runPlanningPipeline(options) {
|
|
|
222
242
|
? { runId: draft.contract.id, invalid: findings[0].text }
|
|
223
243
|
: { runId: draft.contract.id, nodeCount: plan.nodes.length });
|
|
224
244
|
|
|
245
|
+
// R15: a deterministic check for a DoD proof no node can satisfy, run once
|
|
246
|
+
// against the drafted plan before the first review round grades it — a
|
|
247
|
+
// finding this raises is exactly as actionable to a revise as a reviewer's
|
|
248
|
+
// own, and raising it before review means review never spends a round
|
|
249
|
+
// re-discovering what a mechanical check already knows for certain.
|
|
250
|
+
if (plan !== null) {
|
|
251
|
+
const proofFindings = checkPlanProofs(plan, repoFacts, cwd);
|
|
252
|
+
if (proofFindings.length > 0) findings = [...findings, ...proofFindings];
|
|
253
|
+
logStage("proof-check", { findingsCount: proofFindings.length });
|
|
254
|
+
}
|
|
255
|
+
|
|
225
256
|
const workingPlanPath = join(scratchDir, "plan.working.json");
|
|
226
257
|
const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
|
|
227
258
|
|
|
@@ -229,25 +260,28 @@ export async function runPlanningPipeline(options) {
|
|
|
229
260
|
* End the pipeline the way an unresolvable plan already ends: the contested
|
|
230
261
|
* result, the outstanding findings that forced it, and an open question on
|
|
231
262
|
* the campaign journal. Every no-valid-plan exit funnels through here.
|
|
263
|
+
* `currentPlan` and the `resume` context ride along on the written record
|
|
264
|
+
* so `faberun plan --resolve` (R9) can continue from it without redrafting
|
|
265
|
+
* or re-collecting repo facts.
|
|
232
266
|
*
|
|
233
267
|
* @param {number} round
|
|
268
|
+
* @param {PlanFindingOutput[]} currentFindings
|
|
269
|
+
* @param {PlanOutput|null} currentPlan
|
|
234
270
|
* @returns {Promise<ContestedPipelineResult>}
|
|
235
271
|
*/
|
|
236
|
-
const contest =
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
|
|
245
|
-
]);
|
|
246
|
-
return { status: "contested", plansDir, planPath, findings, round };
|
|
247
|
-
};
|
|
272
|
+
const contest = (round, currentFindings, currentPlan) => contestPlan({
|
|
273
|
+
campaignId, phase, cwd, plansDir, sessionId: PLANNER_SESSION_ID, findings: currentFindings, round, logStage,
|
|
274
|
+
plan: currentPlan ?? null,
|
|
275
|
+
resume: {
|
|
276
|
+
campaignId, phase, specPath: relativeSpecPath, specDigest, reviewRounds,
|
|
277
|
+
runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
248
280
|
|
|
249
|
-
// Which deterministic stage is running,
|
|
250
|
-
//
|
|
281
|
+
// Which deterministic stage is running, so the freeze-wrap catch below
|
|
282
|
+
// names the stage that threw when the thrown error carries no `planStage`
|
|
283
|
+
// of its own (`assembleFrozenPlan` tags sizing and routing failures; a
|
|
284
|
+
// plain error reaching the catch after that call is `freezePlan`'s).
|
|
251
285
|
let stage = "sizing";
|
|
252
286
|
// The last review round entered, so the freeze-wrap catch contests with
|
|
253
287
|
// the count of rounds that actually ran.
|
|
@@ -255,178 +289,40 @@ export async function runPlanningPipeline(options) {
|
|
|
255
289
|
|
|
256
290
|
/**
|
|
257
291
|
* The one assembly a plan freezes through, one home for the expression the
|
|
258
|
-
* in-round pre-flight and the final freeze must run identically
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* `stage` as it goes; see the declaration above.
|
|
292
|
+
* in-round pre-flight and the final freeze must run identically. Delegates
|
|
293
|
+
* to `assembleFrozenPlan`, exported below, so `resolve.mjs` can run the
|
|
294
|
+
* exact same reshape over a plan it read back off disk instead of holding
|
|
295
|
+
* it in this closure.
|
|
263
296
|
*
|
|
264
297
|
* @param {PlanOutput} currentPlan
|
|
265
298
|
* @returns {AssembledPlan}
|
|
266
299
|
*/
|
|
267
|
-
const assembleFrozenNodes = (currentPlan) => {
|
|
268
|
-
stage = "sizing";
|
|
269
|
-
const sizing = applySizingRules(
|
|
270
|
-
{ nodes: currentPlan.nodes.map(toSizingNode), justification: currentPlan.justification },
|
|
271
|
-
{ nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts, minWriteFiles: MIN_WRITE_FILES, turnCeiling: DEFAULT_MAX_TURNS, packageMode, readVolume: (path) => fileLineCount(join(cwd, path)), targetedFix },
|
|
272
|
-
);
|
|
273
|
-
stage = "routing";
|
|
274
|
-
const routing = resolveRuntimes(sizing.plan.nodes, {
|
|
275
|
-
table: [...DEFAULT_ROUTING_TABLE],
|
|
276
|
-
runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
|
|
277
|
-
availability: availabilityOf(runtimes),
|
|
278
|
-
runtimeDefaults,
|
|
279
|
-
});
|
|
280
|
-
stage = "freeze";
|
|
281
|
-
return {
|
|
282
|
-
sizing,
|
|
283
|
-
routing,
|
|
284
|
-
nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
|
|
285
|
-
// The declarations a reviewer saw, remapped onto the nodes sizing
|
|
286
|
-
// actually produced: a merge removes a node id, and a declaration that
|
|
287
|
-
// named it must now name the node that absorbed it or freeze would see
|
|
288
|
-
// an unknown assignment.
|
|
289
|
-
phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
|
|
290
|
-
};
|
|
291
|
-
};
|
|
300
|
+
const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
|
|
292
301
|
|
|
293
302
|
/**
|
|
294
|
-
* The raw contract exactly as `freezePlan` will assemble and validate it
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* of sizing's too (`maxParallel`), and the in-round pre-flight must validate
|
|
298
|
-
* the same bytes the freeze writes. `validateContract` never
|
|
299
|
-
* reads the path it is given — only `dirname(resolve(contractPath))` to
|
|
300
|
-
* resolve `raw.cwd` — so the pre-flight passes the path the contract will
|
|
301
|
-
* occupy, `contract.json` inside `plansDir`, and validates in memory
|
|
302
|
-
* without writing or deleting anything.
|
|
303
|
+
* The raw contract exactly as `freezePlan` will assemble and validate it.
|
|
304
|
+
* Delegates to `frozenContractRawOf`, exported below for the same reason
|
|
305
|
+
* as `assembleFrozenPlan`.
|
|
303
306
|
*
|
|
304
307
|
* @param {AssembledPlan} assembly
|
|
305
308
|
* @returns {JsonObject}
|
|
306
309
|
*/
|
|
307
|
-
const frozenContractRaw = ({
|
|
308
|
-
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
309
|
-
contractVersion: CONTRACT_VERSION,
|
|
310
|
-
id: `${campaignId}-${phase}`,
|
|
311
|
-
campaignId,
|
|
312
|
-
goal: campaign.goal,
|
|
313
|
-
// `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
|
|
314
|
-
// `cwd` has to point back at the repo root from there, exactly like
|
|
315
|
-
// `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
|
|
316
|
-
cwd: relative(plansDir, cwd) || ".",
|
|
317
|
-
// Sizing's `parallelisable` conclusion, which nothing else carries: a
|
|
318
|
-
// contract node has no `parallel` field, so a plan that says nothing here
|
|
319
|
-
// freezes at the validator's default of 1 and every independent node it
|
|
320
|
-
// proved waits for its turn. What the engine does with the number is the
|
|
321
|
-
// scheduler's business; declaring it is this stage's.
|
|
322
|
-
maxParallel: provenParallelism(sizing.plan),
|
|
323
|
-
runtimes,
|
|
324
|
-
runtimeDefaults,
|
|
325
|
-
// The operator's ratchets, carried verbatim: which suites a repository
|
|
326
|
-
// runs on every node is the operator's policy, supplied through
|
|
327
|
-
// `--verification`, never derived from repository facts. Validated at the
|
|
328
|
-
// flag boundary, so freeze neither re-derives nor edits them.
|
|
329
|
-
...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
|
|
330
|
-
...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
|
|
331
|
-
nodes,
|
|
332
|
-
});
|
|
310
|
+
const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification });
|
|
333
311
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// Merged over what is still open, never substituted for it: a round's
|
|
345
|
-
// reviewer grades the plan in front of it and may simply not mention
|
|
346
|
-
// an objection the last round raised, which a plain assignment here
|
|
347
|
-
// threw away.
|
|
348
|
-
findings = mergeFindings(findings, validateFindings(review.output.findings));
|
|
349
|
-
} catch (error) {
|
|
350
|
-
// The review said nothing usable about the plan, so the plan cannot be
|
|
351
|
-
// treated as clean: the malformed-output finding is critical and
|
|
352
|
-
// drives the same revise-or-contest path a real critical finding does,
|
|
353
|
-
// with the still-outstanding findings riding along.
|
|
354
|
-
invalidFindings = invalidPlanFinding(`review-r${round}`, error);
|
|
355
|
-
findings = [...findings, invalidFindings];
|
|
356
|
-
}
|
|
357
|
-
// The contract this plan would freeze into is checked here, inside the
|
|
358
|
-
// round and after the review's findings merged into the open ones,
|
|
359
|
-
// because freeze runs after the last one: caught here, a plan that
|
|
360
|
-
// cannot freeze still has a revise left to fix it. The failure is a
|
|
361
|
-
// critical finding, never an auto-filled acknowledgement — scope
|
|
362
|
-
// closure exists to force the per-file decision (declare a write, or
|
|
363
|
-
// acknowledge a read-only importer), and the revise worker, which can
|
|
364
|
-
// read the repository, makes it from the validator's own message.
|
|
365
|
-
/** @type {PlanFindingOutput|null} */
|
|
366
|
-
let freezeFailure = null;
|
|
367
|
-
try {
|
|
368
|
-
assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
|
|
369
|
-
} catch (error) {
|
|
370
|
-
freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
|
|
371
|
-
findings = [...findings, freezeFailure];
|
|
372
|
-
}
|
|
373
|
-
logStage("review", {
|
|
374
|
-
round,
|
|
375
|
-
runId: review.contract.id,
|
|
376
|
-
findingsCount: findings.length,
|
|
377
|
-
criticalCount: findings.filter((finding) => finding.severity === "critical").length,
|
|
378
|
-
...(freezeFailure === null ? {} : { freezeFailed: freezeFailure.text }),
|
|
379
|
-
...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
// The revise's write-drops land here — after the review above, before the
|
|
383
|
-
// critical check below — so a drop survives into the same
|
|
384
|
-
// revise-or-contest decision a review finding reaches. Merged by id: a
|
|
385
|
-
// drop the last round already carried and this revise made again is one
|
|
386
|
-
// finding, not two.
|
|
387
|
-
findings = mergeFindings(findings, droppedWrites);
|
|
388
|
-
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
389
|
-
if (criticalFindings.length === 0) break;
|
|
390
|
-
if (round === reviewRounds) return await contest(round);
|
|
391
|
-
const findingsPath = join(scratchDir, `findings-round-${round}.json`);
|
|
392
|
-
writeJsonAtomic(findingsPath, findings);
|
|
393
|
-
const revise = await runStage("revise", {
|
|
394
|
-
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, findingsPath), packageMode,
|
|
395
|
-
});
|
|
396
|
-
// Kept for the write-drop comparison: the plan the revise revised, against
|
|
397
|
-
// the plan it produced.
|
|
398
|
-
const planBeforeRevise = plan;
|
|
399
|
-
/** @type {PlanFindingOutput|null} */
|
|
400
|
-
let invalid = null;
|
|
401
|
-
try {
|
|
402
|
-
plan = validatePlanOutput(revise.output.plan);
|
|
403
|
-
} catch (error) {
|
|
404
|
-
// The revise output was rejected wholesale, so the round's review
|
|
405
|
-
// findings are still outstanding and ride along to the next round.
|
|
406
|
-
invalid = invalidPlanFinding(`revise-r${round}`, error);
|
|
407
|
-
plan = null;
|
|
408
|
-
findings = [...findings, invalid];
|
|
409
|
-
}
|
|
410
|
-
// Null when the revise output was refused: there is no revised write set
|
|
411
|
-
// to compare, and the refused-output finding already drives the round.
|
|
412
|
-
droppedWrites = droppedWriteFindings(planBeforeRevise, plan);
|
|
413
|
-
// What the next round starts from: the findings this revise did not move
|
|
414
|
-
// the plan under. Everything else — a node it changed, a node it removed,
|
|
415
|
-
// and the pipeline's own shape findings, which the next round re-derives
|
|
416
|
-
// — is dropped here rather than carried forever.
|
|
417
|
-
findings = unresolvedFindings(findings, planBeforeRevise, plan);
|
|
418
|
-
logStage("revise", {
|
|
419
|
-
round,
|
|
420
|
-
runId: revise.contract.id,
|
|
421
|
-
droppedWrites: droppedWrites.length,
|
|
422
|
-
carriedFindings: findings.length,
|
|
423
|
-
...(invalid === null ? {} : { invalid: invalid.text }),
|
|
424
|
-
});
|
|
425
|
-
}
|
|
312
|
+
const roundsResult = await runReviewRounds({
|
|
313
|
+
reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
|
|
314
|
+
relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
|
|
315
|
+
runStage, assembleFrozenNodes, frozenContractRaw, contest,
|
|
316
|
+
invalidPlanFinding, logStage,
|
|
317
|
+
});
|
|
318
|
+
if (!roundsResult.resolved) return roundsResult.result;
|
|
319
|
+
plan = roundsResult.plan;
|
|
320
|
+
findings = roundsResult.findings;
|
|
321
|
+
roundsRun = roundsResult.roundsRun;
|
|
426
322
|
// Reached only when no review round is configured (reviewRounds <= 0) and
|
|
427
323
|
// the draft never validated: no revise exists to reach, so contested is the
|
|
428
324
|
// end rather than a silent crash at sizing.
|
|
429
|
-
if (plan === null) return await contest(0);
|
|
325
|
+
if (plan === null) return await contest(0, findings, null);
|
|
430
326
|
|
|
431
327
|
// The pre-flight ran this exact assembly through the same validator in the
|
|
432
328
|
// round that just broke, so a failure here is nearly impossible — but
|
|
@@ -449,6 +345,7 @@ export async function runPlanningPipeline(options) {
|
|
|
449
345
|
: ["the frozen contract carries neither sharedVerification nor finalVerification, so no repository ratchet runs on its nodes and no final check closes the phase; pass --verification <file> if the target repository has ratchets every node must run"];
|
|
450
346
|
try {
|
|
451
347
|
assembled = assembleFrozenNodes(plan);
|
|
348
|
+
stage = "freeze";
|
|
452
349
|
logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
|
|
453
350
|
logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
|
|
454
351
|
highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
@@ -458,27 +355,37 @@ export async function runPlanningPipeline(options) {
|
|
|
458
355
|
// The pipeline's own pinned spec bytes: a wrong or missing digest is the
|
|
459
356
|
// first thing the Campaign Brief refuses on, never a summary.
|
|
460
357
|
spec: { path: relativeSpecPath, digest: specDigest },
|
|
358
|
+
humanSteps,
|
|
461
359
|
facts: repoFacts,
|
|
462
360
|
provenance: {
|
|
463
361
|
targetGitHead: repoFacts.gitHead,
|
|
464
362
|
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
465
|
-
reviewer:
|
|
363
|
+
reviewer: reviewerProvenanceOf(reviewers, runtimes),
|
|
466
364
|
sizing: assembled.sizing.transformations,
|
|
467
365
|
findings,
|
|
468
366
|
},
|
|
469
367
|
});
|
|
470
|
-
logStage("freeze", {
|
|
368
|
+
logStage("freeze", {
|
|
369
|
+
contractId: `${campaignId}-${phase}`,
|
|
370
|
+
highestRiskTier,
|
|
371
|
+
...(humanSteps.length ? { humanSteps: humanSteps.length } : {}),
|
|
372
|
+
...(freezeWarnings.length ? { warnings: freezeWarnings } : {}),
|
|
373
|
+
});
|
|
471
374
|
} catch (error) {
|
|
472
375
|
// The stage line the pipeline would otherwise have stopped short of, the
|
|
473
376
|
// failure carried as the critical finding that names it, and the
|
|
474
|
-
// contested end every other unresolvable plan takes.
|
|
475
|
-
|
|
476
|
-
|
|
377
|
+
// contested end every other unresolvable plan takes. `planStage` names a
|
|
378
|
+
// sizing or routing failure precisely; anything else reaching here is
|
|
379
|
+
// `freezePlan`'s, so the outer `stage` (bumped to "freeze" once assembly
|
|
380
|
+
// itself succeeded) is the fallback.
|
|
381
|
+
const failedStage = /** @type {{planStage?: string}} */ (error)?.planStage ?? stage;
|
|
382
|
+
logStage(failedStage, { failed: error instanceof Error ? error.message : String(error) });
|
|
383
|
+
findings = [...findings, invalidPlanFinding(failedStage, error)];
|
|
477
384
|
// freezePlan removes contract.json itself when validation refuses it; a
|
|
478
385
|
// failure after that write and before its return would otherwise leave a
|
|
479
386
|
// contract.json no contested result may have.
|
|
480
387
|
rmSync(join(plansDir, "contract.json"), { force: true });
|
|
481
|
-
return await contest(roundsRun);
|
|
388
|
+
return await contest(roundsRun, findings, plan);
|
|
482
389
|
}
|
|
483
390
|
|
|
484
391
|
// A delta only means something between two samples of the same seat: freeze
|
|
@@ -568,122 +475,23 @@ const SCOPE_CLOSURE_RESOLUTION = "Resolve it by declaring each named file in wri
|
|
|
568
475
|
* `SCOPE_CLOSURE_RESOLUTION` after the validator's verbatim message, which
|
|
569
476
|
* stays intact because it names the exact paths the reviser must act on.
|
|
570
477
|
*
|
|
478
|
+
* Used both outside any round (the draft validation above, and the freeze
|
|
479
|
+
* catch inside `runPlanningPipeline`) and inside one, so `rounds.mjs` takes
|
|
480
|
+
* it as an injected function rather than importing it, which would cycle
|
|
481
|
+
* back into this module. Exported for `resolve.mjs`, which reaches the same
|
|
482
|
+
* freeze catch from a plan read back off disk instead of one this module
|
|
483
|
+
* just assembled.
|
|
484
|
+
*
|
|
571
485
|
* @param {string} label
|
|
572
486
|
* @param {unknown} error
|
|
573
487
|
* @returns {PlanFindingOutput}
|
|
574
488
|
*/
|
|
575
|
-
function invalidPlanFinding(label, error) {
|
|
489
|
+
export function invalidPlanFinding(label, error) {
|
|
576
490
|
const text = error instanceof Error ? error.message : String(error);
|
|
577
491
|
const guided = text.startsWith(SCOPE_CLOSURE_MESSAGE_PREFIX) ? `${text} ${SCOPE_CLOSURE_RESOLUTION}` : text;
|
|
578
492
|
return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text: guided };
|
|
579
493
|
}
|
|
580
494
|
|
|
581
|
-
/**
|
|
582
|
-
* `incoming` merged over `carried`, keyed by finding id. A finding both
|
|
583
|
-
* rounds raise is the newer reviewer's own re-judgement of the same
|
|
584
|
-
* objection, severity included, so it replaces the carried copy instead of
|
|
585
|
-
* duplicating it — this merge never rewrites a severity of its own. Carried
|
|
586
|
-
* findings keep their order and come first, so the oldest outstanding
|
|
587
|
-
* objection is at the top of the file the reviser reads.
|
|
588
|
-
*
|
|
589
|
-
* @param {PlanFindingOutput[]} carried
|
|
590
|
-
* @param {PlanFindingOutput[]} incoming
|
|
591
|
-
* @returns {PlanFindingOutput[]}
|
|
592
|
-
*/
|
|
593
|
-
function mergeFindings(carried, incoming) {
|
|
594
|
-
const merged = new Map(carried.map((finding) => [finding.id, finding]));
|
|
595
|
-
for (const finding of incoming) merged.set(finding.id, finding);
|
|
596
|
-
return [...merged.values()];
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* The findings still open against the plan a revise produced. A review round
|
|
601
|
-
* is under no obligation to repeat what the last one found, so replacing the
|
|
602
|
-
* finding set each round silently drops any objection the new reviewer is
|
|
603
|
-
* quiet about: a spike run froze a plan carrying a defect round 1 had named
|
|
604
|
-
* and round 2 did not repeat, and the worker refused the packet with
|
|
605
|
-
* context_missing.
|
|
606
|
-
*
|
|
607
|
-
* What counts as resolved is read off the two plans, never off the reviewer's
|
|
608
|
-
* silence. A finding whose node the revise removed is moot. A finding whose
|
|
609
|
-
* node the revise changed at all was acted on — the next review grades the
|
|
610
|
-
* changed node and can object again in its own words. A finding against a
|
|
611
|
-
* node the revise left identical was not addressed, and stays open. That rule
|
|
612
|
-
* is also what keeps a carried finding from making convergence impossible:
|
|
613
|
-
* every one of them clears the moment the reviser touches the node it names,
|
|
614
|
-
* so a plan that answers its objections still freezes inside the round
|
|
615
|
-
* budget, and a plan that does not still ends contested at it. A finding
|
|
616
|
-
* naming no node of the plan — the pipeline's own `nodeId: "plan"` shape and
|
|
617
|
-
* freeze failures — is re-derived from scratch by the next round's pre-flight,
|
|
618
|
-
* so carrying it would double it.
|
|
619
|
-
*
|
|
620
|
-
* @param {PlanFindingOutput[]} findings everything open at the end of the round
|
|
621
|
-
* @param {PlanOutput|null} previousPlan the plan the revise revised
|
|
622
|
-
* @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
|
|
623
|
-
* @returns {PlanFindingOutput[]}
|
|
624
|
-
*/
|
|
625
|
-
export function unresolvedFindings(findings, previousPlan, revisedPlan) {
|
|
626
|
-
// No revised plan to measure against: the refused-output finding drives the
|
|
627
|
-
// next round and everything raised so far is still outstanding.
|
|
628
|
-
if (!revisedPlan) return findings;
|
|
629
|
-
const before = new Map((previousPlan?.nodes ?? []).map((node) => [node.id, stableJson(node)]));
|
|
630
|
-
const after = new Map(revisedPlan.nodes.map((node) => [node.id, stableJson(node)]));
|
|
631
|
-
return findings.filter((finding) => after.has(finding.nodeId) && before.get(finding.nodeId) === after.get(finding.nodeId));
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* The write files the plan going into a revise declared that the revised plan
|
|
636
|
-
* no longer declares, one finding per (node id, path). This is the check the
|
|
637
|
-
* scope-closure validator cannot make: a shrink satisfies closure without a
|
|
638
|
-
* judgement about any importer, so the cheap move needs a comparator of its
|
|
639
|
-
* own. Severity is critical — a drop is not automatically wrong, but it is
|
|
640
|
-
* always worth a second look, and only a critical finding reaches the round
|
|
641
|
-
* loop's revise-or-contest decision; major would ride along in the findings
|
|
642
|
-
* file while the plan froze. Nodes are matched by id alone: a node the
|
|
643
|
-
* revision renamed or removed entirely is out of scope, because tracking
|
|
644
|
-
* identity across a rename is a judgement about the graph this check does
|
|
645
|
-
* not make — a removed node's writes were reviewed as a removal, not as a
|
|
646
|
-
* silent shrink.
|
|
647
|
-
*
|
|
648
|
-
* Membership is judged against the whole revised plan, not against the node
|
|
649
|
-
* that used to hold the path. A revise that splits one node in two and hands
|
|
650
|
-
* a file to the new sibling has not dropped that file: it is still declared,
|
|
651
|
-
* still reviewable, and the graph change is visible in the plan. Measured
|
|
652
|
-
* 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
|
|
653
|
-
* exactly that move a critical and contested a sound plan — the draft's only
|
|
654
|
-
* node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
|
|
655
|
-
* layered them into worktree-preserve-ref-verb and
|
|
656
|
-
* cancel-preserves-integrated-heads, which is the decomposition this
|
|
657
|
-
* repository's own layering asks for.
|
|
658
|
-
*
|
|
659
|
-
* @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
|
|
660
|
-
* @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
|
|
661
|
-
* @returns {PlanFindingOutput[]}
|
|
662
|
-
*/
|
|
663
|
-
export function droppedWriteFindings(previousPlan, revisedPlan) {
|
|
664
|
-
if (!previousPlan || !revisedPlan) return [];
|
|
665
|
-
const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
|
|
666
|
-
const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
|
|
667
|
-
/** @type {PlanFindingOutput[]} */
|
|
668
|
-
const findings = [];
|
|
669
|
-
for (const node of revisedPlan.nodes) {
|
|
670
|
-
const previousWrites = before.get(node.id);
|
|
671
|
-
if (!previousWrites) continue;
|
|
672
|
-
let dropped = 0;
|
|
673
|
-
for (const path of previousWrites) {
|
|
674
|
-
if (stillDeclared.has(path)) continue;
|
|
675
|
-
dropped += 1;
|
|
676
|
-
findings.push({
|
|
677
|
-
id: `dropped-write-${node.id}-${dropped}`,
|
|
678
|
-
severity: "critical",
|
|
679
|
-
nodeId: node.id,
|
|
680
|
-
text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return findings;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
495
|
/**
|
|
688
496
|
* The phase declarations a reviewer saw, remapped onto the nodes sizing
|
|
689
497
|
* actually produced. `applySizingRules` is the only stage that changes the
|
|
@@ -727,6 +535,109 @@ export function carryPhaseDeclarations(phases, transformations) {
|
|
|
727
535
|
});
|
|
728
536
|
}
|
|
729
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Tag an error with the sizing/routing sub-stage that threw it, so a catch
|
|
540
|
+
* far from here (this module's own freeze-wrap, or `resolve.mjs`'s) can name
|
|
541
|
+
* it precisely instead of defaulting to whichever stage it last saw start.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} planStage
|
|
544
|
+
* @param {unknown} error
|
|
545
|
+
* @returns {Error}
|
|
546
|
+
*/
|
|
547
|
+
function taggedStageError(planStage, error) {
|
|
548
|
+
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
549
|
+
return Object.assign(wrapped, { planStage });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The one assembly a plan freezes through: sizing reshapes the drafted
|
|
554
|
+
* nodes, routing assigns worker and judge, and `toContractNode` renders the
|
|
555
|
+
* contract shape. Exported (rather than kept a `runPlanningPipeline`
|
|
556
|
+
* closure) so both the in-round pre-flight there and `resolve.mjs`'s
|
|
557
|
+
* freeze-only resume run the identical reshape over a plan, wherever that
|
|
558
|
+
* plan came from — a fresh draft/revise or one read back off a contested
|
|
559
|
+
* `plan.json`.
|
|
560
|
+
*
|
|
561
|
+
* @param {PlanOutput} currentPlan
|
|
562
|
+
* @param {{repoFacts: import("./repo-facts.mjs").RepoFacts, packageMode: import("./sizing.mjs").PackageMode, targetedFix: boolean, phase: string, cwd: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}}} ctx
|
|
563
|
+
* @returns {AssembledPlan}
|
|
564
|
+
*/
|
|
565
|
+
export function assembleFrozenPlan(currentPlan, ctx) {
|
|
566
|
+
const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults } = ctx;
|
|
567
|
+
let sizing;
|
|
568
|
+
try {
|
|
569
|
+
sizing = applySizingRules(
|
|
570
|
+
{ nodes: currentPlan.nodes.map(toSizingNode), justification: currentPlan.justification },
|
|
571
|
+
{ nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts, minWriteFiles: MIN_WRITE_FILES, turnCeiling: DEFAULT_MAX_TURNS, packageMode, readVolume: (path) => fileLineCount(join(cwd, path)), targetedFix },
|
|
572
|
+
);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
throw taggedStageError("sizing", error);
|
|
575
|
+
}
|
|
576
|
+
let routing;
|
|
577
|
+
try {
|
|
578
|
+
routing = resolveRuntimes(sizing.plan.nodes, {
|
|
579
|
+
table: [...DEFAULT_ROUTING_TABLE],
|
|
580
|
+
runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
|
|
581
|
+
availability: availabilityOf(runtimes),
|
|
582
|
+
runtimeDefaults,
|
|
583
|
+
});
|
|
584
|
+
} catch (error) {
|
|
585
|
+
throw taggedStageError("routing", error);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
sizing,
|
|
589
|
+
routing,
|
|
590
|
+
nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
|
|
591
|
+
// The declarations a reviewer saw, remapped onto the nodes sizing
|
|
592
|
+
// actually produced: a merge removes a node id, and a declaration that
|
|
593
|
+
// named it must now name the node that absorbed it or freeze would see
|
|
594
|
+
// an unknown assignment.
|
|
595
|
+
phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The raw contract exactly as `freezePlan` will assemble and validate it,
|
|
601
|
+
* schemaVersion and contractVersion included. It takes the whole assembly
|
|
602
|
+
* rather than its nodes because the contract carries a run-level conclusion
|
|
603
|
+
* of sizing's too (`maxParallel`), and the in-round pre-flight must validate
|
|
604
|
+
* the same bytes the freeze writes. Exported alongside `assembleFrozenPlan`
|
|
605
|
+
* for the same reason.
|
|
606
|
+
*
|
|
607
|
+
* @param {AssembledPlan} assembly
|
|
608
|
+
* @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites}} ctx
|
|
609
|
+
* @returns {JsonObject}
|
|
610
|
+
*/
|
|
611
|
+
export function frozenContractRawOf({ sizing, nodes }, ctx) {
|
|
612
|
+
const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification } = ctx;
|
|
613
|
+
return {
|
|
614
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
615
|
+
contractVersion: CONTRACT_VERSION,
|
|
616
|
+
id: `${campaignId}-${phase}`,
|
|
617
|
+
campaignId,
|
|
618
|
+
goal: campaignGoal,
|
|
619
|
+
// `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
|
|
620
|
+
// `cwd` has to point back at the repo root from there, exactly like
|
|
621
|
+
// `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
|
|
622
|
+
cwd: relative(plansDir, cwd) || ".",
|
|
623
|
+
// Sizing's `parallelisable` conclusion, which nothing else carries: a
|
|
624
|
+
// contract node has no `parallel` field, so a plan that says nothing here
|
|
625
|
+
// freezes at the validator's default of 1 and every independent node it
|
|
626
|
+
// proved waits for its turn. What the engine does with the number is the
|
|
627
|
+
// scheduler's business; declaring it is this stage's.
|
|
628
|
+
maxParallel: provenParallelism(sizing.plan),
|
|
629
|
+
runtimes,
|
|
630
|
+
runtimeDefaults,
|
|
631
|
+
// The operator's ratchets, carried verbatim: which suites a repository
|
|
632
|
+
// runs on every node is the operator's policy, supplied through
|
|
633
|
+
// `--verification`, never derived from repository facts. Validated at the
|
|
634
|
+
// flag boundary, so freeze neither re-derives nor edits them.
|
|
635
|
+
...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
|
|
636
|
+
...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
|
|
637
|
+
nodes,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
730
641
|
/**
|
|
731
642
|
* Write the pipeline's final frozen plan record and the `plan.json.sha256`
|
|
732
643
|
* sidecar over those exact bytes. This is the last write to plan.json: after
|