faberun 0.26.1 → 0.26.3
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/src/cli/brand.mjs +1 -1
- package/src/cli/plan.mjs +7 -1
- package/src/cli.mjs +1 -0
- package/src/contract/runtime.mjs +4 -3
- package/src/harnesses/claude/index.mjs +5 -2
- package/src/harnesses/index.mjs +11 -0
- package/src/plan/contest.mjs +1 -0
- package/src/plan/pipeline.mjs +16 -9
- package/src/plan/preflight.mjs +20 -8
- package/src/plan/resolve.mjs +3 -3
- package/src/plan/routing.mjs +72 -53
- package/src/plan/template.mjs +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.3",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/brand.mjs
CHANGED
|
@@ -199,7 +199,7 @@ export function renderUsage() {
|
|
|
199
199
|
"contract validate <contract.json>",
|
|
200
200
|
"spec validate <file> [--strict-traceability] [--json]",
|
|
201
201
|
"spec scaffold <path> [--id <id>]",
|
|
202
|
-
"plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--detach] [--json]",
|
|
202
|
+
"plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--judge-independence same-vendor] [--detach] [--json]",
|
|
203
203
|
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
204
204
|
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
205
205
|
"seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
|
package/src/cli/plan.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
|
|
5
5
|
* sequencing and every decision the pipeline makes.
|
|
6
6
|
*/
|
|
7
|
+
import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
7
8
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { dirname, join, resolve } from "node:path";
|
|
9
10
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
@@ -144,7 +145,7 @@ export function loadVerificationSuites(path) {
|
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* @param {string} target
|
|
147
|
-
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, reviewers?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, detach?: boolean, resolve?: string, answer?: string[], json?: boolean}} values
|
|
148
|
+
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, reviewers?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, "judge-independence"?: string, detach?: boolean, resolve?: string, answer?: string[], json?: boolean}} values
|
|
148
149
|
* @returns {Promise<void>}
|
|
149
150
|
*/
|
|
150
151
|
export async function planCli(target, values) {
|
|
@@ -166,6 +167,9 @@ export async function planCli(target, values) {
|
|
|
166
167
|
? loadVerificationSuites(values.verification)
|
|
167
168
|
: {};
|
|
168
169
|
const packageMode = packageModeOf(values.package);
|
|
170
|
+
const judgeIndependence = values["judge-independence"] === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: validateJudgeIndependence(values["judge-independence"], "--judge-independence");
|
|
169
173
|
|
|
170
174
|
if (values.detach === true) {
|
|
171
175
|
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
@@ -176,6 +180,7 @@ export async function planCli(target, values) {
|
|
|
176
180
|
if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
|
|
177
181
|
if (packageMode !== "implementation") argv.push("--package", packageMode);
|
|
178
182
|
if (values["targeted-fix"] === true) argv.push("--targeted-fix");
|
|
183
|
+
if (judgeIndependence !== undefined) argv.push("--judge-independence", judgeIndependence);
|
|
179
184
|
const failurePath = planBootstrapFailurePath(process.cwd(), campaignId, phase);
|
|
180
185
|
// Read the campaign before creating anything: the failure record lives
|
|
181
186
|
// inside the campaign tree, so a typo in --campaign would otherwise leave
|
|
@@ -222,6 +227,7 @@ export async function planCli(target, values) {
|
|
|
222
227
|
verification,
|
|
223
228
|
targetedFix: values["targeted-fix"] === true,
|
|
224
229
|
packageMode,
|
|
230
|
+
...(judgeIndependence === undefined ? {} : { judgeIndependence }),
|
|
225
231
|
launch: async (contractPath, contract) => {
|
|
226
232
|
const child = detachSelf("run", contractPath);
|
|
227
233
|
if (child.pid === undefined) throw new Error("detached planning run has no pid");
|
package/src/cli.mjs
CHANGED
|
@@ -128,6 +128,7 @@ export const COMMAND_OPTIONS = {
|
|
|
128
128
|
verification: { type: "string" },
|
|
129
129
|
package: { type: "string" },
|
|
130
130
|
"targeted-fix": { type: "boolean" },
|
|
131
|
+
"judge-independence": { type: "string" },
|
|
131
132
|
detach: { type: "boolean" },
|
|
132
133
|
resolve: { type: "string" },
|
|
133
134
|
answer: { type: "string", multiple: true },
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
|
|
13
13
|
import { composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
14
|
-
import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
14
|
+
import { harnessCapabilities, readOnlyModeOf, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
15
15
|
import { canonicalProvider } from "./provider.mjs";
|
|
16
16
|
import { stableJson } from "../util.mjs";
|
|
17
17
|
/** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
|
|
@@ -246,8 +246,9 @@ export function judgeWriteWarnings(runtimes, defaults, nodes) {
|
|
|
246
246
|
const runtime = runtimes[/** @type {string} */ (id)];
|
|
247
247
|
const execution = resolvePermissionExecution(runtime);
|
|
248
248
|
if (!execution.field || runtime[execution.field] === undefined || !writesWorkspace(runtime)) return [];
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
const readOnlyMode = readOnlyModeOf(runtime);
|
|
250
|
+
if (readOnlyMode === null) return [];
|
|
251
|
+
return [`judge runtime ${id} declares ${execution.field} ${execution.mode}; a judge's verdict reaches the gate without writing, so declare ${execution.field} ${readOnlyMode}`];
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
254
|
/**
|
|
@@ -52,8 +52,11 @@ export const claudeHarness = {
|
|
|
52
52
|
signalsProcesses: true,
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
-
// Headless acceptEdits denies Bash; bypassPermissions executes commands
|
|
56
|
-
|
|
55
|
+
// Headless acceptEdits denies Bash; bypassPermissions executes commands;
|
|
56
|
+
// plan neither edits nor runs anything, the mode a judge or a preflight ask
|
|
57
|
+
// needs (measured 2026-09-27: an Opus judge left on the acceptEdits default
|
|
58
|
+
// edited the review it was grading and was blocked judge_protocol).
|
|
59
|
+
permissionExecution: { field: "permissionMode", executingModes: ["bypassPermissions"], defaultMode: "acceptEdits", readOnlyModes: ["plan"] },
|
|
57
60
|
|
|
58
61
|
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
|
59
62
|
executable(runtime) {
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -157,6 +157,17 @@ export function resolvePermissionExecution(runtime) {
|
|
|
157
157
|
return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The mode in which a runtime's harness cannot write its workspace, or `null`
|
|
162
|
+
* when the harness declares none.
|
|
163
|
+
*
|
|
164
|
+
* @param {{harness: string}} runtime
|
|
165
|
+
* @returns {string|null}
|
|
166
|
+
*/
|
|
167
|
+
export function readOnlyModeOf(runtime) {
|
|
168
|
+
return getHarness(runtime.harness).permissionExecution?.readOnlyModes?.[0] ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
/**
|
|
161
172
|
* Whether a runtime can write its workspace. False only in a mode its adapter
|
|
162
173
|
* declares read-only; RM-058 measured a codex reviewer under `read-only`
|
package/src/plan/contest.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { writeJsonAtomic } from "../run/store.mjs";
|
|
|
44
44
|
* targetedFix: boolean,
|
|
45
45
|
* approveBelow: "standard"|"high"|"none",
|
|
46
46
|
* repoFacts: import("./repo-facts.mjs").RepoFacts,
|
|
47
|
+
* judgeIndependence?: "same-vendor",
|
|
47
48
|
* }} ContestResumeContext
|
|
48
49
|
*/
|
|
49
50
|
/** @typedef {{formatVersion: 1, status: "contested", rounds: number, findings: PlanFindingOutput[], plan: PlanOutput|null, resume: ContestResumeContext}} ContestedPlanRecord */
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { campaignCli } from "../cli/campaign.mjs";
|
|
|
26
26
|
import { appendJsonl } from "../run/store.mjs";
|
|
27
27
|
import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
28
28
|
import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
|
|
29
|
+
import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
29
30
|
import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
|
|
30
31
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
31
32
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
@@ -96,7 +97,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
|
|
|
96
97
|
const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
97
98
|
|
|
98
99
|
/**
|
|
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
|
|
100
|
+
* @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, judgeIndependence?: "same-vendor", launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
|
|
100
101
|
* `targetedFix` allows a plan with a single node. Sizing refuses one by
|
|
101
102
|
* default because a phase that decomposes into one node is usually a plan
|
|
102
103
|
* that was never decomposed; a targeted fix is the case where one node is
|
|
@@ -106,8 +107,9 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
|
106
107
|
export async function runPlanningPipeline(options) {
|
|
107
108
|
const {
|
|
108
109
|
specPath, campaignId, phase, runtimes, launch, wait,
|
|
109
|
-
reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false,
|
|
110
|
+
reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false, judgeIndependence,
|
|
110
111
|
} = options;
|
|
112
|
+
if (judgeIndependence !== undefined) validateJudgeIndependence(judgeIndependence, "judgeIndependence");
|
|
111
113
|
const ask = options.ask ?? askPlanningRuntimes;
|
|
112
114
|
// Implementation work is sized by what it writes; exploratory work -- an
|
|
113
115
|
// audit, a review, a survey -- by what it reads, because it writes one
|
|
@@ -123,7 +125,7 @@ export async function runPlanningPipeline(options) {
|
|
|
123
125
|
const campaignPath = campaignTree(cwd, campaignId);
|
|
124
126
|
const campaign = readCampaign(campaignPath);
|
|
125
127
|
if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
|
|
126
|
-
refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode);
|
|
128
|
+
refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
|
|
127
129
|
refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
|
|
128
130
|
|
|
129
131
|
const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
|
|
@@ -275,6 +277,7 @@ export async function runPlanningPipeline(options) {
|
|
|
275
277
|
resume: {
|
|
276
278
|
campaignId, phase, specPath: relativeSpecPath, specDigest, reviewRounds,
|
|
277
279
|
runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts,
|
|
280
|
+
...(judgeIndependence === undefined ? {} : { judgeIndependence }),
|
|
278
281
|
},
|
|
279
282
|
});
|
|
280
283
|
|
|
@@ -297,7 +300,7 @@ export async function runPlanningPipeline(options) {
|
|
|
297
300
|
* @param {PlanOutput} currentPlan
|
|
298
301
|
* @returns {AssembledPlan}
|
|
299
302
|
*/
|
|
300
|
-
const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
|
|
303
|
+
const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
|
|
301
304
|
|
|
302
305
|
/**
|
|
303
306
|
* The raw contract exactly as `freezePlan` will assemble and validate it.
|
|
@@ -307,7 +310,7 @@ export async function runPlanningPipeline(options) {
|
|
|
307
310
|
* @param {AssembledPlan} assembly
|
|
308
311
|
* @returns {JsonObject}
|
|
309
312
|
*/
|
|
310
|
-
const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification });
|
|
313
|
+
const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence });
|
|
311
314
|
|
|
312
315
|
const roundsResult = await runReviewRounds({
|
|
313
316
|
reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
|
|
@@ -559,11 +562,11 @@ function taggedStageError(planStage, error) {
|
|
|
559
562
|
* `plan.json`.
|
|
560
563
|
*
|
|
561
564
|
* @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
|
|
565
|
+
* @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}, judgeIndependence?: "same-vendor"}} ctx
|
|
563
566
|
* @returns {AssembledPlan}
|
|
564
567
|
*/
|
|
565
568
|
export function assembleFrozenPlan(currentPlan, ctx) {
|
|
566
|
-
const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults } = ctx;
|
|
569
|
+
const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence } = ctx;
|
|
567
570
|
let sizing;
|
|
568
571
|
try {
|
|
569
572
|
sizing = applySizingRules(
|
|
@@ -580,6 +583,7 @@ export function assembleFrozenPlan(currentPlan, ctx) {
|
|
|
580
583
|
runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
|
|
581
584
|
availability: availabilityOf(runtimes),
|
|
582
585
|
runtimeDefaults,
|
|
586
|
+
...(judgeIndependence === undefined ? {} : { judgeIndependence }),
|
|
583
587
|
});
|
|
584
588
|
} catch (error) {
|
|
585
589
|
throw taggedStageError("routing", error);
|
|
@@ -605,11 +609,11 @@ export function assembleFrozenPlan(currentPlan, ctx) {
|
|
|
605
609
|
* for the same reason.
|
|
606
610
|
*
|
|
607
611
|
* @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
|
|
612
|
+
* @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites, judgeIndependence?: "same-vendor"}} ctx
|
|
609
613
|
* @returns {JsonObject}
|
|
610
614
|
*/
|
|
611
615
|
export function frozenContractRawOf({ sizing, nodes }, ctx) {
|
|
612
|
-
const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification } = ctx;
|
|
616
|
+
const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence } = ctx;
|
|
613
617
|
return {
|
|
614
618
|
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
615
619
|
contractVersion: CONTRACT_VERSION,
|
|
@@ -628,6 +632,9 @@ export function frozenContractRawOf({ sizing, nodes }, ctx) {
|
|
|
628
632
|
maxParallel: provenParallelism(sizing.plan),
|
|
629
633
|
runtimes,
|
|
630
634
|
runtimeDefaults,
|
|
635
|
+
// The operator's opt-in, carried verbatim: the frozen contract is
|
|
636
|
+
// validated under the same judge-independence rule its routing used.
|
|
637
|
+
...(judgeIndependence === undefined ? {} : { judgeIndependence }),
|
|
631
638
|
// The operator's ratchets, carried verbatim: which suites a repository
|
|
632
639
|
// runs on every node is the operator's policy, supplied through
|
|
633
640
|
// `--verification`, never derived from repository facts. Validated at the
|
package/src/plan/preflight.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { liveSilenceCause } from "../engine/live-silence.mjs";
|
|
|
11
11
|
import { harnessCapabilities } from "../harnesses/index.mjs";
|
|
12
12
|
import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
|
|
13
13
|
import { effectiveProvider } from "../contract/provider.mjs";
|
|
14
|
+
import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The runtimes a planning run will spend, asked once before its first stage.
|
|
@@ -90,10 +91,18 @@ export function refusePlanningSilence(checks, cwd) {
|
|
|
90
91
|
*
|
|
91
92
|
* @param {Record<string, Record<string, unknown>>} runtimes
|
|
92
93
|
* @param {{worker?: string, judge?: string}} runtimeDefaults
|
|
94
|
+
* In same-vendor mode (`--judge-independence same-vendor`) a shared vendor is
|
|
95
|
+
* admitted exactly as the contract validator admits it: the judge's tier is
|
|
96
|
+
* declared and at or above that of the worker and of every fallback runtime of
|
|
97
|
+
* the same vendor. Measured 2026-09-27: without the mode on `plan`, a
|
|
98
|
+
* single-provider catalogue (Sonnet worker, Opus planner and judge) could not
|
|
99
|
+
* plan at all, though its frozen contract would have validated in the mode.
|
|
100
|
+
*
|
|
93
101
|
* @param {"implementation"|"exploratory"} packageMode
|
|
102
|
+
* @param {"same-vendor"} [judgeIndependence]
|
|
94
103
|
* @returns {void}
|
|
95
104
|
*/
|
|
96
|
-
export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode) {
|
|
105
|
+
export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence) {
|
|
97
106
|
const worker = runtimeDefaults.worker;
|
|
98
107
|
if (!worker || !runtimes[worker]) return;
|
|
99
108
|
if (packageMode === "implementation") {
|
|
@@ -105,16 +114,19 @@ export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode
|
|
|
105
114
|
}
|
|
106
115
|
const judge = runtimeDefaults.judge;
|
|
107
116
|
if (!judge || !runtimes[judge]) return;
|
|
108
|
-
/** @type {
|
|
109
|
-
|
|
117
|
+
const judgeVendor = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[judge]));
|
|
118
|
+
if (judgeVendor === undefined) return;
|
|
110
119
|
const seen = new Set();
|
|
111
120
|
for (let id = /** @type {string|undefined} */ (worker); id && runtimes[id] && !seen.has(id); id = /** @type {string|undefined} */ (runtimes[id].fallback)) {
|
|
112
121
|
seen.add(id);
|
|
113
122
|
const provider = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[id]));
|
|
114
|
-
if (provider !==
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
if (provider !== judgeVendor) continue;
|
|
124
|
+
if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
|
|
125
|
+
throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with worker ${worker} or its fallback, so no frozen node could route its judge; name a judge of another vendor, or pass --judge-independence same-vendor`);
|
|
126
|
+
}
|
|
127
|
+
const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (runtimes[id]), /** @type {{harness: string, model: string}} */ (runtimes[judge]));
|
|
128
|
+
if (refusal) {
|
|
129
|
+
throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with ${id === worker ? "worker" : "worker fallback"} ${id} under --judge-independence same-vendor, but ${refusal.message}`);
|
|
130
|
+
}
|
|
119
131
|
}
|
|
120
132
|
}
|
package/src/plan/resolve.mjs
CHANGED
|
@@ -98,7 +98,7 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
|
|
|
98
98
|
if (record.status !== "contested") throw new Error(`plan at ${planPath} is not contested (status: ${record.status})`);
|
|
99
99
|
if (!record.plan) throw new Error(`plan at ${planPath} has no plan to resume: the draft or revise that produced it never validated; run faberun plan again instead of --resolve`);
|
|
100
100
|
if (!record.resume) throw new Error(`plan at ${planPath} predates faberun plan --resolve and carries no resume context; run faberun plan again`);
|
|
101
|
-
const { campaignId, phase, specPath, specDigest, runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts } = record.resume;
|
|
101
|
+
const { campaignId, phase, specPath, specDigest, runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts, judgeIndependence } = record.resume;
|
|
102
102
|
// R16: recomputed from the same spec bytes the contest recorded, so a plan
|
|
103
103
|
// resumed through --resolve carries the same declared operator steps a
|
|
104
104
|
// fresh freeze would.
|
|
@@ -137,12 +137,12 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
|
|
|
137
137
|
: ["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"];
|
|
138
138
|
let stage = "sizing";
|
|
139
139
|
try {
|
|
140
|
-
const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
|
|
140
|
+
const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
|
|
141
141
|
stage = "freeze";
|
|
142
142
|
logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
|
|
143
143
|
logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
|
|
144
144
|
const highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
145
|
-
const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification }), {
|
|
145
|
+
const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence }), {
|
|
146
146
|
outDir: plansDir,
|
|
147
147
|
phases: assembled.phases,
|
|
148
148
|
spec: { path: specPath, digest: specDigest },
|
package/src/plan/routing.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
|
|
15
15
|
import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
|
|
16
16
|
import { effectiveProvider } from "../contract/provider.mjs";
|
|
17
|
+
import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
17
18
|
|
|
18
19
|
/** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
|
|
19
20
|
/** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
|
|
@@ -27,12 +28,18 @@ import { effectiveProvider } from "../contract/provider.mjs";
|
|
|
27
28
|
* reader, `isRuntimeAvailable` in runtime-discovery.mjs.
|
|
28
29
|
* @typedef {{available: boolean, exhaustedUntil: string|null, observedAt?: string|null, window?: string|null, remaining?: number|null, [key: string]: unknown}} RoutingAvailability
|
|
29
30
|
*/
|
|
30
|
-
/** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
|
|
31
|
+
/** @typedef {{vendor: string, harness?: string, model?: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
|
|
31
32
|
/** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
|
|
32
33
|
/** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
|
|
33
34
|
/** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge", strategy?: RoutingStrategy}} RoutingRule */
|
|
34
35
|
/** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Why a runtime may not take a role on a node, or `null` when it may: the
|
|
38
|
+
* judge's bar follows the worker actually chosen (its vendor, its fallback
|
|
39
|
+
* chain, and in same-vendor mode the tier rule); the worker has none.
|
|
40
|
+
* @typedef {(id: string, runtime: RoutingRuntime) => string|null} JudgeBar
|
|
41
|
+
*/
|
|
42
|
+
/** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>, judgeIndependence?: "same-vendor"}} RoutingConfig */
|
|
36
43
|
/** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}, strategy: {worker: AppliedStrategy|null, judge: AppliedStrategy|null}, reason: {worker: string, judge: string}}} RoutingAssignment */
|
|
37
44
|
/** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
|
|
38
45
|
/** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
|
|
@@ -53,6 +60,7 @@ export function resolveRuntimes(nodes, config, options = {}) {
|
|
|
53
60
|
const runtimeDefaults = config.runtimeDefaults ?? {};
|
|
54
61
|
const overrides = config.overrides ?? {};
|
|
55
62
|
const previous = options.previous ?? {};
|
|
63
|
+
const judgeIndependence = config.judgeIndependence;
|
|
56
64
|
|
|
57
65
|
// A strategy name outside the vocabulary is an authored-table defect, not an
|
|
58
66
|
// unobservable datum: it fails fast, before any node is resolved.
|
|
@@ -69,13 +77,13 @@ export function resolveRuntimes(nodes, config, options = {}) {
|
|
|
69
77
|
|
|
70
78
|
for (const node of nodes) {
|
|
71
79
|
const override = overrides[node.id] ?? {};
|
|
72
|
-
const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override,
|
|
80
|
+
const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, judgeBar: NO_BAR, previousId: previous[node.id]?.worker });
|
|
73
81
|
if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
|
|
74
|
-
// The judge's
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override,
|
|
82
|
+
// The judge's bar follows the worker runtime that was actually chosen,
|
|
83
|
+
// never the row that named it — a worker unmet leaves nothing to
|
|
84
|
+
// conflict with, so the judge resolves without restriction.
|
|
85
|
+
const judgeBar = worker.runtimeId ? judgeBarFor(worker.runtimeId, runtimes, judgeIndependence) : NO_BAR;
|
|
86
|
+
const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, judgeBar, previousId: previous[node.id]?.judge });
|
|
79
87
|
if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
|
|
80
88
|
assignments[node.id] = {
|
|
81
89
|
worker: worker.runtimeId,
|
|
@@ -94,8 +102,8 @@ export function resolveRuntimes(nodes, config, options = {}) {
|
|
|
94
102
|
return { assignments, unmet };
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
/** @type {
|
|
98
|
-
const
|
|
105
|
+
/** @type {JudgeBar} */
|
|
106
|
+
const NO_BAR = () => null;
|
|
99
107
|
|
|
100
108
|
/**
|
|
101
109
|
* Precedence for one role on one node: an explicit node override, then the
|
|
@@ -110,16 +118,16 @@ const EMPTY_VENDORS = Object.freeze(new Set());
|
|
|
110
118
|
*
|
|
111
119
|
* @param {RoutingNode} node
|
|
112
120
|
* @param {"worker"|"judge"} role
|
|
113
|
-
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap,
|
|
121
|
+
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, judgeBar: JudgeBar, previousId: string|undefined}} context
|
|
114
122
|
* @returns {{runtimeId: string|null, rule: string, strategy: AppliedStrategy|null, reason: string}}
|
|
115
123
|
*/
|
|
116
124
|
function resolveRole(node, role, context) {
|
|
117
|
-
const { runtimes, availability, runtimeDefaults, table, override,
|
|
125
|
+
const { runtimes, availability, runtimeDefaults, table, override, judgeBar, previousId } = context;
|
|
118
126
|
|
|
119
127
|
if (override[role] !== undefined) {
|
|
120
128
|
const id = override[role];
|
|
121
129
|
return {
|
|
122
|
-
runtimeId: admits(id, runtimes, availability,
|
|
130
|
+
runtimeId: admits(id, runtimes, availability, judgeBar) ? id : null,
|
|
123
131
|
rule: "override",
|
|
124
132
|
strategy: "declared",
|
|
125
133
|
reason: `operator override on node ${node.id}`,
|
|
@@ -129,7 +137,7 @@ function resolveRole(node, role, context) {
|
|
|
129
137
|
if (runtimeDefaults[role] !== undefined) {
|
|
130
138
|
const id = runtimeDefaults[role];
|
|
131
139
|
return {
|
|
132
|
-
runtimeId: admits(id, runtimes, availability,
|
|
140
|
+
runtimeId: admits(id, runtimes, availability, judgeBar) ? id : null,
|
|
133
141
|
rule: "runtimeDefaults",
|
|
134
142
|
strategy: "declared",
|
|
135
143
|
reason: "operator runtimeDefaults",
|
|
@@ -141,13 +149,13 @@ function resolveRole(node, role, context) {
|
|
|
141
149
|
&& (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
|
|
142
150
|
if (row) {
|
|
143
151
|
const strategy = row.strategy ?? "priority";
|
|
144
|
-
const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability,
|
|
152
|
+
const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability, judgeBar, previousId });
|
|
145
153
|
return { runtimeId: decision.runtimeId, rule: ruleLabel(row), strategy, reason: decision.reason };
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
const discovered = role === "worker"
|
|
149
|
-
? cheapestAvailable(runtimes, availability,
|
|
150
|
-
: strongestAvailable(runtimes, availability,
|
|
157
|
+
? cheapestAvailable(runtimes, availability, judgeBar)
|
|
158
|
+
: strongestAvailable(runtimes, availability, judgeBar);
|
|
151
159
|
return {
|
|
152
160
|
runtimeId: discovered,
|
|
153
161
|
rule: "discovery",
|
|
@@ -168,12 +176,12 @@ function resolveRole(node, role, context) {
|
|
|
168
176
|
*
|
|
169
177
|
* @param {RoutingStrategy} strategy
|
|
170
178
|
* @param {string[]} prefer
|
|
171
|
-
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>,
|
|
179
|
+
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, judgeBar: JudgeBar, previousId: string|undefined}} context
|
|
172
180
|
* @returns {{runtimeId: string|null, reason: string}}
|
|
173
181
|
*/
|
|
174
182
|
function chooseByStrategy(strategy, prefer, context) {
|
|
175
|
-
const { runtimes, availability,
|
|
176
|
-
const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability,
|
|
183
|
+
const { runtimes, availability, judgeBar, previousId } = context;
|
|
184
|
+
const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability, judgeBar) }));
|
|
177
185
|
const admissible = candidates.filter((candidate) => candidate.admissible);
|
|
178
186
|
if (admissible.length === 0) return { runtimeId: null, reason: "no admissible candidate in the prefer list" };
|
|
179
187
|
|
|
@@ -185,10 +193,10 @@ function chooseByStrategy(strategy, prefer, context) {
|
|
|
185
193
|
// where the prefer list does not name it -- reusing it is the strategy's
|
|
186
194
|
// whole point. It yields by name whenever it would violate availability
|
|
187
195
|
// or vendor distinction, the yields `admits` already speaks.
|
|
188
|
-
if (admits(previousId, runtimes, availability,
|
|
196
|
+
if (admits(previousId, runtimes, availability, judgeBar)) {
|
|
189
197
|
return { runtimeId: previousId, reason: `attempt-affinity: previous attempt's runtime ${previousId} still admissible` };
|
|
190
198
|
}
|
|
191
|
-
return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability,
|
|
199
|
+
return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability, judgeBar)}; prefer order decided` };
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
if (strategy === "cost") {
|
|
@@ -222,54 +230,68 @@ function chooseByStrategy(strategy, prefer, context) {
|
|
|
222
230
|
* @param {string} id
|
|
223
231
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
224
232
|
* @param {Record<string, RoutingAvailability>} availability
|
|
225
|
-
* @param {
|
|
233
|
+
* @param {JudgeBar} judgeBar
|
|
226
234
|
* @returns {string}
|
|
227
235
|
*/
|
|
228
|
-
function blockedWhy(id, runtimes, availability,
|
|
236
|
+
function blockedWhy(id, runtimes, availability, judgeBar) {
|
|
229
237
|
const runtime = runtimes[id];
|
|
230
238
|
if (!runtime) return "is not declared in runtimes";
|
|
231
|
-
|
|
232
|
-
if (provider !== undefined && forbiddenVendors.has(provider)) return `carries forbidden vendor ${provider}`;
|
|
233
|
-
return "is unavailable";
|
|
239
|
+
return judgeBar(id, runtime) ?? "is unavailable";
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
/**
|
|
237
243
|
* @param {string} id
|
|
238
244
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
239
245
|
* @param {Record<string, RoutingAvailability>} availability
|
|
240
|
-
* @param {
|
|
246
|
+
* @param {JudgeBar} judgeBar
|
|
241
247
|
* @returns {boolean}
|
|
242
248
|
*/
|
|
243
|
-
function admits(id, runtimes, availability,
|
|
249
|
+
function admits(id, runtimes, availability, judgeBar) {
|
|
244
250
|
const runtime = runtimes[id];
|
|
245
251
|
if (!runtime) return false;
|
|
246
|
-
|
|
247
|
-
if (provider !== undefined && forbiddenVendors.has(provider)) return false;
|
|
252
|
+
if (judgeBar(id, runtime) !== null) return false;
|
|
248
253
|
return isRuntimeAvailable(availability[id]);
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
|
-
*
|
|
253
|
-
* vendor of each runtime reachable through
|
|
254
|
-
*
|
|
255
|
-
* once a worker is
|
|
257
|
+
* The judge's bar for one chosen worker: the worker's own vendor, plus the
|
|
258
|
+
* vendor of each runtime reachable through its declared `fallback` chain —
|
|
259
|
+
* the same independence the contract validator enforces statically
|
|
260
|
+
* (`markSameProviderReviewNodes`), applied here once a worker is chosen.
|
|
261
|
+
* Outside same-vendor mode any of those vendors bars the judge. In
|
|
262
|
+
* same-vendor mode a shared vendor admits the judge only when the tier rule
|
|
263
|
+
* does against every chain runtime of that vendor, and the worker's own
|
|
264
|
+
* runtime never judges itself (`runtime-discovery.mjs` excludes it too).
|
|
256
265
|
*
|
|
257
266
|
* @param {string} workerId
|
|
258
267
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
259
|
-
* @
|
|
268
|
+
* @param {"same-vendor"|undefined} judgeIndependence
|
|
269
|
+
* @returns {JudgeBar}
|
|
260
270
|
*/
|
|
261
|
-
function
|
|
262
|
-
|
|
271
|
+
function judgeBarFor(workerId, runtimes, judgeIndependence) {
|
|
272
|
+
/** @type {RoutingRuntime[]} */
|
|
273
|
+
const chain = [];
|
|
263
274
|
const seen = new Set();
|
|
264
275
|
/** @type {string|undefined} */
|
|
265
276
|
let id = workerId;
|
|
266
277
|
while (id !== undefined && runtimes[id] && !seen.has(id)) {
|
|
267
278
|
seen.add(id);
|
|
268
|
-
|
|
269
|
-
if (provider !== undefined) vendors.add(provider);
|
|
279
|
+
chain.push(runtimes[id]);
|
|
270
280
|
id = runtimes[id].fallback;
|
|
271
281
|
}
|
|
272
|
-
|
|
282
|
+
const vendors = new Set(chain.map((runtime) => effectiveProvider(runtime)).filter((provider) => provider !== undefined));
|
|
283
|
+
return (candidateId, candidate) => {
|
|
284
|
+
const provider = effectiveProvider(candidate);
|
|
285
|
+
if (provider === undefined || !vendors.has(provider)) return null;
|
|
286
|
+
if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return `carries forbidden vendor ${provider}`;
|
|
287
|
+
if (candidateId === workerId) return "is the worker runtime itself";
|
|
288
|
+
for (const member of chain) {
|
|
289
|
+
if (effectiveProvider(member) !== provider) continue;
|
|
290
|
+
const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (member), /** @type {{harness: string, model: string}} */ (candidate));
|
|
291
|
+
if (refusal) return `shares vendor ${provider} and ${refusal.message}`;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
};
|
|
273
295
|
}
|
|
274
296
|
|
|
275
297
|
/** @param {RoutingRule} row @returns {string} */
|
|
@@ -281,16 +303,13 @@ function ruleLabel(row) {
|
|
|
281
303
|
/**
|
|
282
304
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
283
305
|
* @param {Record<string, RoutingAvailability>} availability
|
|
284
|
-
* @param {
|
|
306
|
+
* @param {JudgeBar} judgeBar
|
|
285
307
|
* @returns {{id: string, runtime: RoutingRuntime, order: number}[]}
|
|
286
308
|
*/
|
|
287
|
-
function candidateEntries(runtimes, availability,
|
|
309
|
+
function candidateEntries(runtimes, availability, judgeBar) {
|
|
288
310
|
return Object.entries(runtimes)
|
|
289
311
|
.map(([id, runtime], order) => ({ id, runtime, order }))
|
|
290
|
-
.filter(({ id, runtime }) =>
|
|
291
|
-
const provider = effectiveProvider(runtime);
|
|
292
|
-
return (provider === undefined || !forbiddenVendors.has(provider)) && isRuntimeAvailable(availability[id]);
|
|
293
|
-
});
|
|
312
|
+
.filter(({ id, runtime }) => judgeBar(id, runtime) === null && isRuntimeAvailable(availability[id]));
|
|
294
313
|
}
|
|
295
314
|
|
|
296
315
|
/**
|
|
@@ -301,11 +320,11 @@ function candidateEntries(runtimes, availability, forbiddenVendors) {
|
|
|
301
320
|
*
|
|
302
321
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
303
322
|
* @param {Record<string, RoutingAvailability>} availability
|
|
304
|
-
* @param {
|
|
323
|
+
* @param {JudgeBar} judgeBar
|
|
305
324
|
* @returns {string|null}
|
|
306
325
|
*/
|
|
307
|
-
function cheapestAvailable(runtimes, availability,
|
|
308
|
-
return cheapest(candidateEntries(runtimes, availability,
|
|
326
|
+
function cheapestAvailable(runtimes, availability, judgeBar) {
|
|
327
|
+
return cheapest(candidateEntries(runtimes, availability, judgeBar))?.id ?? null;
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
/**
|
|
@@ -317,9 +336,9 @@ function cheapestAvailable(runtimes, availability, forbiddenVendors) {
|
|
|
317
336
|
*
|
|
318
337
|
* @param {Record<string, RoutingRuntime>} runtimes
|
|
319
338
|
* @param {Record<string, RoutingAvailability>} availability
|
|
320
|
-
* @param {
|
|
339
|
+
* @param {JudgeBar} judgeBar
|
|
321
340
|
* @returns {string|null}
|
|
322
341
|
*/
|
|
323
|
-
function strongestAvailable(runtimes, availability,
|
|
324
|
-
return strongest(candidateEntries(runtimes, availability,
|
|
342
|
+
function strongestAvailable(runtimes, availability, judgeBar) {
|
|
343
|
+
return strongest(candidateEntries(runtimes, availability, judgeBar), "")?.id ?? null;
|
|
325
344
|
}
|
package/src/plan/template.mjs
CHANGED
|
@@ -236,6 +236,25 @@ function readFilesForKind(kind, inputs) {
|
|
|
236
236
|
return [/** @type {string} */ (inputs.specPath)];
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The wall clock one planning stage gets, from the reasoning effort of the
|
|
241
|
+
* runtime that runs it. A stage is a single long turn over the whole plan, so
|
|
242
|
+
* the contract default (2400 s) fits a draft at ordinary effort but not a
|
|
243
|
+
* revise at high effort. Measured 2026-09-27: Opus 5.5 at `xhigh` revising
|
|
244
|
+
* a 20-node plan against 13 findings was still reasoning when the 2400 s wall
|
|
245
|
+
* clock ended the stage, and the pipeline, which cannot resume a stage, lost
|
|
246
|
+
* the draft and review it had already paid for.
|
|
247
|
+
*
|
|
248
|
+
* @param {Record<string, unknown>|undefined} runtime
|
|
249
|
+
* @returns {number}
|
|
250
|
+
*/
|
|
251
|
+
export function planningStageTimeoutSec(runtime) {
|
|
252
|
+
const reasoning = runtime?.reasoning;
|
|
253
|
+
if (reasoning === "xhigh" || reasoning === "max") return 7200;
|
|
254
|
+
if (reasoning === "high") return 3600;
|
|
255
|
+
return 2400;
|
|
256
|
+
}
|
|
257
|
+
|
|
239
258
|
/**
|
|
240
259
|
* Build one of the planning pipeline's one-node discovery contracts. Pure:
|
|
241
260
|
* no file is read or written, and no model is invoked. The returned object is
|
|
@@ -273,10 +292,13 @@ export function buildPlanningContract(kind, inputs) {
|
|
|
273
292
|
verification: [],
|
|
274
293
|
};
|
|
275
294
|
|
|
295
|
+
const stageRuntime = runtimeId === undefined ? undefined : /** @type {Record<string, unknown>|undefined} */ (inputs.runtimes[runtimeId]);
|
|
296
|
+
|
|
276
297
|
return {
|
|
277
298
|
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
278
299
|
contractVersion: CONTRACT_VERSION,
|
|
279
300
|
id: `${inputs.campaignId}-plan-${inputs.phase}-${kind}-${inputs.n}`,
|
|
301
|
+
timeoutSec: planningStageTimeoutSec(stageRuntime),
|
|
280
302
|
campaignId: inputs.campaignId,
|
|
281
303
|
goal: inputs.goal ?? `Plan ${kind} for phase ${inputs.phase}`,
|
|
282
304
|
cwd: inputs.cwd ?? ".",
|