faberun 0.26.0 → 0.26.2
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/launch.mjs +14 -1
- package/src/cli/plan.mjs +7 -1
- package/src/cli.mjs +1 -0
- package/src/engine/live-preflight.mjs +45 -7
- package/src/engine/run-identity.mjs +9 -6
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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/launch.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { readRunNodes } from "../run/node-store.mjs";
|
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
26
|
import { runDirectory } from "../run/paths.mjs";
|
|
27
|
+
import { livePreflightCeilingSec } from "../engine/live-preflight.mjs";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The file a detached child is spawned as. It must be the CLI and not this
|
|
@@ -94,7 +95,7 @@ export function detachArgv(argv, options = {}) {
|
|
|
94
95
|
* @param {number} [timeoutMs]
|
|
95
96
|
* @returns {Promise<BootstrapRecord>}
|
|
96
97
|
*/
|
|
97
|
-
export async function waitForBootstrap(runDir, pid, child = null, timeoutMs =
|
|
98
|
+
export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bootstrapReadyTimeoutMs()) {
|
|
98
99
|
const deadline = Date.now() + timeoutMs;
|
|
99
100
|
const nonce = child?.bootstrapNonce;
|
|
100
101
|
if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
|
|
@@ -157,6 +158,18 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30
|
|
|
157
158
|
cleanupBootstrapAttempts(runDir);
|
|
158
159
|
throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
|
|
159
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The controller answers ready only after the dispatch gate's live preflight,
|
|
163
|
+
* so the launcher waits for that ask on top of its own 30s start-up margin.
|
|
164
|
+
* Measured 2026-09-27: with a fixed 30s wait, a gate asking Opus at `xhigh`
|
|
165
|
+
* made `run --detach` and `plan --detach` report "did not become ready" for
|
|
166
|
+
* controllers that then came up and ran.
|
|
167
|
+
*
|
|
168
|
+
* @returns {number}
|
|
169
|
+
*/
|
|
170
|
+
export function bootstrapReadyTimeoutMs() {
|
|
171
|
+
return 30_000 + livePreflightCeilingSec() * 1000;
|
|
172
|
+
}
|
|
160
173
|
/**
|
|
161
174
|
* @param {string} runDir
|
|
162
175
|
* @param {BootstrapRecord} bootstrap
|
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 },
|
|
@@ -46,7 +46,7 @@ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
|
|
|
46
46
|
* whole is part of the answer.
|
|
47
47
|
*
|
|
48
48
|
* @param {string} contractPath
|
|
49
|
-
* @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
|
|
49
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, persisted?: boolean}} [options]
|
|
50
50
|
* @returns {Promise<ProbeResult[]>}
|
|
51
51
|
*/
|
|
52
52
|
export async function preflightContract(contractPath, options = {}) {
|
|
@@ -70,7 +70,7 @@ export async function preflightContract(contractPath, options = {}) {
|
|
|
70
70
|
* once, so a caller cannot accidentally ask a different question.
|
|
71
71
|
*
|
|
72
72
|
* @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
|
|
73
|
-
* @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
|
|
73
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, cwd?: string}} [options]
|
|
74
74
|
* @returns {Promise<ProbeResult[]>}
|
|
75
75
|
*/
|
|
76
76
|
export async function preflightRuntimes(entries, options = {}) {
|
|
@@ -80,7 +80,7 @@ export async function preflightRuntimes(entries, options = {}) {
|
|
|
80
80
|
));
|
|
81
81
|
if (options.static === true) return staticChecks;
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const configuredTimeoutSec = configuredLivePreflightTimeout(options.liveTimeoutSec);
|
|
84
84
|
let liveRepo;
|
|
85
85
|
try {
|
|
86
86
|
liveRepo = createLivePreflightRepo();
|
|
@@ -95,7 +95,7 @@ export async function preflightRuntimes(entries, options = {}) {
|
|
|
95
95
|
}
|
|
96
96
|
try {
|
|
97
97
|
return await Promise.all(staticChecks.map(async (check, index) => {
|
|
98
|
-
const live = await livePreflight(runtimes[index], liveRepo,
|
|
98
|
+
const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? Math.max(options.minimumTimeoutSec ?? 0, defaultLivePreflightTimeout(runtimes[index])));
|
|
99
99
|
const liveDetail = live.status === "done"
|
|
100
100
|
? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
|
|
101
101
|
: `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
|
|
@@ -116,16 +116,54 @@ export async function preflightRuntimes(entries, options = {}) {
|
|
|
116
116
|
rmSync(liveRepo, { recursive: true, force: true });
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* An explicit budget (option, then environment) applies to every runtime.
|
|
121
|
+
*
|
|
122
|
+
* @param {number|undefined} configured
|
|
123
|
+
* @returns {number|null}
|
|
124
|
+
*/
|
|
125
|
+
function configuredLivePreflightTimeout(configured) {
|
|
121
126
|
const raw = configured ?? (process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC === undefined
|
|
122
|
-
?
|
|
127
|
+
? null
|
|
123
128
|
: Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC));
|
|
129
|
+
if (raw === null) return null;
|
|
124
130
|
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
|
|
125
131
|
throw new TypeError("preflight live timeout must be a positive number of seconds");
|
|
126
132
|
}
|
|
127
133
|
return raw;
|
|
128
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Without an explicit budget, the wait follows the runtime's reasoning effort:
|
|
137
|
+
* a model at high effort thinks before answering even a one-word prompt.
|
|
138
|
+
* Measured 2026-09-26: Opus 5.5 at `xhigh` failed every `faberun plan`
|
|
139
|
+
* preflight at 15 s and passed once the budget was raised, so the planning
|
|
140
|
+
* pipeline was refusing a runtime that was healthy.
|
|
141
|
+
*
|
|
142
|
+
* @param {RuntimeSnapshot} runtime
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
export function defaultLivePreflightTimeout(runtime) {
|
|
146
|
+
// Keep LIVE_PREFLIGHT_CEILING_SEC equal to the largest value returned here.
|
|
147
|
+
const reasoning = /** @type {{reasoning?: unknown}} */ (runtime).reasoning;
|
|
148
|
+
if (reasoning === "xhigh" || reasoning === "max") return 180;
|
|
149
|
+
if (reasoning === "high") return 60;
|
|
150
|
+
return 15;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The longest a live preflight can take without an explicit budget: what a
|
|
154
|
+
* detached launcher must be willing to wait before the controller it spawned
|
|
155
|
+
* can report ready, because the dispatch gate asks before it does.
|
|
156
|
+
*/
|
|
157
|
+
export const LIVE_PREFLIGHT_CEILING_SEC = 180;
|
|
158
|
+
/**
|
|
159
|
+
* The longest one live preflight ask may take under the current environment.
|
|
160
|
+
*
|
|
161
|
+
* @returns {number}
|
|
162
|
+
*/
|
|
163
|
+
export function livePreflightCeilingSec() {
|
|
164
|
+
const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
|
|
165
|
+
return process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : LIVE_PREFLIGHT_CEILING_SEC;
|
|
166
|
+
}
|
|
129
167
|
/** @returns {string} */
|
|
130
168
|
function createLivePreflightRepo() {
|
|
131
169
|
const directory = mkdtempSync(join(tmpdir(), "faberun-preflight-"));
|
|
@@ -507,11 +507,12 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
|
507
507
|
// worker and no judge, and can spend no availability.
|
|
508
508
|
if (blocking === null && launchMayDispatch(runDir)) {
|
|
509
509
|
// measured 2026-09-22: asking four routed runtimes in parallel took about
|
|
510
|
-
// 18s, so
|
|
511
|
-
//
|
|
512
|
-
//
|
|
510
|
+
// 18s, so no runtime is given less than 60s here; a runtime at high
|
|
511
|
+
// reasoning effort gets its longer default (defaultLivePreflightTimeout).
|
|
512
|
+
// FABERUN_PREFLIGHT_TIMEOUT_SEC stays the operator override for every
|
|
513
|
+
// runtime; preflightContract validates it, so only a valid number is lifted.
|
|
513
514
|
const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
|
|
514
|
-
const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override :
|
|
515
|
+
const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : undefined;
|
|
515
516
|
const probes = await livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec);
|
|
516
517
|
const silent = probes.filter((probe) => liveSilenceCause(probe) !== null);
|
|
517
518
|
writeJsonAtomic(join(runDir, "env-preflight.json"), evidence(silent.length === 0, probes));
|
|
@@ -536,6 +537,8 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
|
536
537
|
throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
/** No routed runtime is given less than this by the dispatch gate. */
|
|
541
|
+
const GATE_MINIMUM_PREFLIGHT_SEC = 60;
|
|
539
542
|
/**
|
|
540
543
|
* The live half of the gate for one launch. Every routed runtime either holds
|
|
541
544
|
* a verdict this machine recorded inside its freshness window -- reused, the
|
|
@@ -552,7 +555,7 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
|
552
555
|
* @param {ValidatedContract} contract
|
|
553
556
|
* @param {string} runDir
|
|
554
557
|
* @param {SourceIdentity|undefined} sourceIdentity
|
|
555
|
-
* @param {number} timeoutSec
|
|
558
|
+
* @param {number|undefined} timeoutSec
|
|
556
559
|
* @returns {Promise<ProbeResult[]>}
|
|
557
560
|
*/
|
|
558
561
|
async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec) {
|
|
@@ -628,7 +631,7 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
|
|
|
628
631
|
};
|
|
629
632
|
});
|
|
630
633
|
}
|
|
631
|
-
const probes = await preflightContract(join(runDir, "contract.json"), { liveTimeoutSec: timeoutSec, persisted: true });
|
|
634
|
+
const probes = await preflightContract(join(runDir, "contract.json"), { ...(timeoutSec === undefined ? {} : { liveTimeoutSec: timeoutSec }), minimumTimeoutSec: GATE_MINIMUM_PREFLIGHT_SEC, persisted: true });
|
|
632
635
|
// What this launch bought is durable from here on: every ask that reached a
|
|
633
636
|
// provider -- a refusal included, an answer being an answer -- is recorded
|
|
634
637
|
// under the provider's own identity. Silence and a command that never
|
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
|
}
|