faberun 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +30 -26
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +19 -16
- package/src/engine/resume.mjs +46 -9
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
package/src/cli/plan.mjs
CHANGED
|
@@ -15,8 +15,11 @@ import { validateFinalVerification, validateSharedVerification } from "../contra
|
|
|
15
15
|
import { colorLevel, statusToken } from "./brand.mjs";
|
|
16
16
|
import { delay } from "../util.mjs";
|
|
17
17
|
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
18
|
+
import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
|
|
19
|
+
import { resolveReviewerList } from "../plan/reviewer.mjs";
|
|
18
20
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
19
21
|
import { readCampaign } from "../campaign/record.mjs";
|
|
22
|
+
import { readUserConfig } from "../host/config.mjs";
|
|
20
23
|
|
|
21
24
|
/** How often a foreground `plan` polls a launched stage's run directory. */
|
|
22
25
|
const DEFAULT_POLL_MS = 1_000;
|
|
@@ -56,6 +59,21 @@ export function parseRuntimeDefaults(value) {
|
|
|
56
59
|
return result;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `--reviewers <a,b>`: the planner's own ordered reviewer list (R19),
|
|
64
|
+
* separate from `--runtime-defaults judge=` and from the frozen contract's
|
|
65
|
+
* R18 judge list even when an operator names the same runtime id in both.
|
|
66
|
+
*
|
|
67
|
+
* @param {string|undefined} value
|
|
68
|
+
* @returns {string[]|undefined}
|
|
69
|
+
*/
|
|
70
|
+
export function parseReviewerList(value) {
|
|
71
|
+
if (value === undefined) return undefined;
|
|
72
|
+
const ids = value.split(",").map((id) => id.trim()).filter(Boolean);
|
|
73
|
+
if (ids.length === 0) throw new Error("--reviewers needs at least one runtime id");
|
|
74
|
+
return ids;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
/**
|
|
60
78
|
* @param {unknown} value
|
|
61
79
|
* @returns {number}
|
|
@@ -126,10 +144,11 @@ export function loadVerificationSuites(path) {
|
|
|
126
144
|
|
|
127
145
|
/**
|
|
128
146
|
* @param {string} target
|
|
129
|
-
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, detach?: boolean, json?: boolean}} values
|
|
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
|
|
130
148
|
* @returns {Promise<void>}
|
|
131
149
|
*/
|
|
132
150
|
export async function planCli(target, values) {
|
|
151
|
+
if (typeof values.resolve === "string" && values.resolve) return await resolvePlanCli(values);
|
|
133
152
|
const specPath = resolve(target);
|
|
134
153
|
if (typeof values.campaign !== "string" || !values.campaign) throw new Error("plan requires --campaign <id>");
|
|
135
154
|
const campaignId = values.campaign;
|
|
@@ -137,6 +156,9 @@ export async function planCli(target, values) {
|
|
|
137
156
|
const reviewRounds = reviewRoundsOf(values["review-rounds"]);
|
|
138
157
|
const approveBelow = /** @type {"standard"|"high"|"none"|undefined} */ (values["approve-below"]);
|
|
139
158
|
const runtimeDefaults = parseRuntimeDefaults(values["runtime-defaults"]);
|
|
159
|
+
// R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
|
|
160
|
+
// `--reviewers` wins over the machine's own `config.reviewers` default.
|
|
161
|
+
const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
|
|
140
162
|
const runtimes = typeof values.runtimes === "string" && values.runtimes
|
|
141
163
|
? loadRuntimesCatalogue(values.runtimes)
|
|
142
164
|
: DISCOVERY_RUNTIME_DEFINITIONS;
|
|
@@ -149,6 +171,7 @@ export async function planCli(target, values) {
|
|
|
149
171
|
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
150
172
|
if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
|
|
151
173
|
if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
|
|
174
|
+
if (values.reviewers !== undefined) argv.push("--reviewers", values.reviewers);
|
|
152
175
|
if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
|
|
153
176
|
if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
|
|
154
177
|
if (packageMode !== "implementation") argv.push("--package", packageMode);
|
|
@@ -194,6 +217,7 @@ export async function planCli(target, values) {
|
|
|
194
217
|
reviewRounds,
|
|
195
218
|
approveBelow,
|
|
196
219
|
runtimeDefaults,
|
|
220
|
+
reviewers,
|
|
197
221
|
runtimes,
|
|
198
222
|
verification,
|
|
199
223
|
targetedFix: values["targeted-fix"] === true,
|
|
@@ -222,14 +246,62 @@ export async function planCli(target, values) {
|
|
|
222
246
|
return;
|
|
223
247
|
}
|
|
224
248
|
if (result.status === "contested") {
|
|
225
|
-
|
|
226
|
-
process.exitCode = 1;
|
|
249
|
+
renderContested(campaignId, phase, result);
|
|
227
250
|
return;
|
|
228
251
|
}
|
|
229
252
|
for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
|
|
230
253
|
process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
|
|
231
254
|
}
|
|
232
255
|
|
|
256
|
+
/**
|
|
257
|
+
* `faberun plan --resolve <plan-dir> --answer <finding-id>=accept|reject:<reason>`
|
|
258
|
+
* (R9): apply the operator's decisions to a contested plan's open critical
|
|
259
|
+
* findings and resume straight to sizing/routing/freeze, without redrafting
|
|
260
|
+
* or asking a provider anything. `--campaign`/`--phase`/the spec positional
|
|
261
|
+
* are not needed here — everything this needs is the resume context the
|
|
262
|
+
* contest already wrote to `plan.json` (`src/plan/contest.mjs`).
|
|
263
|
+
*
|
|
264
|
+
* @param {{resolve?: string, answer?: string[], json?: boolean}} values
|
|
265
|
+
* @returns {Promise<void>}
|
|
266
|
+
*/
|
|
267
|
+
async function resolvePlanCli(values) {
|
|
268
|
+
const plansDir = resolve(/** @type {string} */ (values.resolve));
|
|
269
|
+
const rawAnswers = values.answer ?? [];
|
|
270
|
+
if (rawAnswers.length === 0) throw new Error("--resolve requires at least one --answer <finding-id>=accept or --answer <finding-id>=reject:<reason>");
|
|
271
|
+
const answers = parseAnswerFlags(rawAnswers);
|
|
272
|
+
const result = await resolvePlanningPipeline({ plansDir, cwd: process.cwd(), answers });
|
|
273
|
+
if (values.json === true) {
|
|
274
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (result.status === "contested") {
|
|
278
|
+
renderContested(undefined, undefined, result);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
|
|
282
|
+
process.stdout.write(`[plan] resolved · frozen · approved ${result.approved} · ${result.contractPath}\n`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A contested result's console rendering: the summary line, then every
|
|
287
|
+
* critical finding with its id, the node or requirement it concerns, and
|
|
288
|
+
* what would resolve it (R9's own words for what the output must list), and
|
|
289
|
+
* the exact command to answer it.
|
|
290
|
+
*
|
|
291
|
+
* @param {string|undefined} campaignId
|
|
292
|
+
* @param {string|undefined} phase
|
|
293
|
+
* @param {import("../plan/contest.mjs").ContestedPipelineResult} result
|
|
294
|
+
* @returns {void}
|
|
295
|
+
*/
|
|
296
|
+
function renderContested(campaignId, phase, result) {
|
|
297
|
+
const criticalFindings = result.findings.filter((finding) => finding.severity === "critical");
|
|
298
|
+
const label = campaignId ? `${campaignId} phase ${phase} ` : "";
|
|
299
|
+
process.stdout.write(`[plan] ${label}contested after ${result.round} round(s) · ${criticalFindings.length} critical finding(s) · ${result.planPath}\n`);
|
|
300
|
+
for (const finding of criticalFindings) process.stdout.write(` - ${finding.id} (${finding.nodeId}): ${finding.text}\n`);
|
|
301
|
+
process.stdout.write(`[plan] resolve with: faberun plan --resolve ${result.plansDir} --answer <finding-id>=accept or --answer <finding-id>=reject:<reason>\n`);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
233
305
|
/**
|
|
234
306
|
* Where a detached planning run records why it never came up. It sits beside
|
|
235
307
|
* the phase's durable plan artifacts rather than in the disposable scratch
|
package/src/cli/setup.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
discoverRuntimes,
|
|
27
27
|
strongest,
|
|
28
28
|
} from "../engine/runtime-discovery.mjs";
|
|
29
|
+
import { effectiveProvider } from "../contract/provider.mjs";
|
|
29
30
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
30
31
|
import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
|
|
31
32
|
import { packageVersion } from "../host/package.mjs";
|
|
@@ -46,6 +47,8 @@ import { discoverSkillTargets, registerSkills } from "./skills.mjs";
|
|
|
46
47
|
* @property {string} [harnesses]
|
|
47
48
|
* @property {string} [worker]
|
|
48
49
|
* @property {string} [judge]
|
|
50
|
+
* @property {string} [judges] comma-separated ordered runtime ids for the machine's R18 judge-list default; unset keeps whatever the existing config already declared
|
|
51
|
+
* @property {string} [reviewers] comma-separated ordered runtime ids for the machine's R19 reviewer-list default (`faberun plan --reviewers`); unset keeps whatever the existing config already declared
|
|
49
52
|
* @property {boolean} [skill] whether to register the faberun skill (default true)
|
|
50
53
|
* @property {boolean} [json]
|
|
51
54
|
* @property {NodeJS.ProcessEnv} [env]
|
|
@@ -166,11 +169,15 @@ export async function setupCommand(options = {}) {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
const selectedJudges = splitHarnesses(options.judges ?? "").length ? splitHarnesses(options.judges ?? "") : kept.judges;
|
|
173
|
+
const selectedReviewers = splitHarnesses(options.reviewers ?? "").length ? splitHarnesses(options.reviewers ?? "") : kept.reviewers;
|
|
169
174
|
const config = {
|
|
170
175
|
schemaVersion: /** @type {1} */ (1),
|
|
171
176
|
harnesses: selectedHarnesses,
|
|
172
177
|
worker: selectedWorker,
|
|
173
178
|
judge: selectedJudge,
|
|
179
|
+
...(selectedJudges.length ? { judges: selectedJudges } : {}),
|
|
180
|
+
...(selectedReviewers.length ? { reviewers: selectedReviewers } : {}),
|
|
174
181
|
updatedAt: new Date().toISOString(),
|
|
175
182
|
};
|
|
176
183
|
writeUserConfig(env, config);
|
|
@@ -270,10 +277,10 @@ function missingEnvKeys(runtime, env) {
|
|
|
270
277
|
*
|
|
271
278
|
* @param {UserConfig|null} existing
|
|
272
279
|
* @param {Record<string, RuntimeAvailability>} availability
|
|
273
|
-
* @returns {{harnesses: string[], worker: string, judge: string}}
|
|
280
|
+
* @returns {{harnesses: string[], worker: string, judge: string, judges: string[], reviewers: string[]}}
|
|
274
281
|
*/
|
|
275
282
|
export function mergeExistingConfig(existing, availability) {
|
|
276
|
-
if (!existing) return { harnesses: [], worker: "", judge: "" };
|
|
283
|
+
if (!existing) return { harnesses: [], worker: "", judge: "", judges: [], reviewers: [] };
|
|
277
284
|
const availableHarnesses = new Set(
|
|
278
285
|
Object.entries(DISCOVERY_RUNTIME_DEFINITIONS)
|
|
279
286
|
.filter(([id]) => availability[id]?.available === true)
|
|
@@ -286,6 +293,12 @@ export function mergeExistingConfig(existing, availability) {
|
|
|
286
293
|
harnesses: existing.harnesses.filter((harness) => availableHarnesses.has(harness)),
|
|
287
294
|
worker: existing.worker && candidateIds.has(existing.worker) ? existing.worker : "",
|
|
288
295
|
judge: existing.judge && candidateIds.has(existing.judge) ? existing.judge : "",
|
|
296
|
+
// The judge and reviewer lists are static, operator-declared orderings
|
|
297
|
+
// (D9, D11): unlike the single worker/judge default, an entry discovery
|
|
298
|
+
// cannot currently reach is not dropped, since each list's own selection
|
|
299
|
+
// already skips a refused entry at every read.
|
|
300
|
+
judges: Array.isArray(existing.judges) ? existing.judges : [],
|
|
301
|
+
reviewers: Array.isArray(existing.reviewers) ? existing.reviewers : [],
|
|
289
302
|
};
|
|
290
303
|
}
|
|
291
304
|
|
|
@@ -314,7 +327,9 @@ function keptJudgeDefault(kept, workerId, candidates) {
|
|
|
314
327
|
* @returns {string}
|
|
315
328
|
*/
|
|
316
329
|
function defaultJudge(workerId, candidates) {
|
|
317
|
-
const
|
|
330
|
+
const worker = DISCOVERY_RUNTIME_DEFINITIONS[workerId];
|
|
331
|
+
if (worker === undefined) return "";
|
|
332
|
+
const vendor = effectiveProvider(worker);
|
|
318
333
|
if (vendor === undefined) return "";
|
|
319
334
|
return strongest(candidates, vendor)?.id ?? "";
|
|
320
335
|
}
|
|
@@ -328,8 +343,10 @@ function defaultJudge(workerId, candidates) {
|
|
|
328
343
|
* @returns {boolean}
|
|
329
344
|
*/
|
|
330
345
|
function crossVendor(judgeId, workerId) {
|
|
331
|
-
const
|
|
332
|
-
const
|
|
346
|
+
const judge = DISCOVERY_RUNTIME_DEFINITIONS[judgeId];
|
|
347
|
+
const worker = DISCOVERY_RUNTIME_DEFINITIONS[workerId];
|
|
348
|
+
const judgeVendor = judge && effectiveProvider(judge);
|
|
349
|
+
const workerVendor = worker && effectiveProvider(worker);
|
|
333
350
|
return Boolean(judgeVendor && workerVendor && judgeVendor !== workerVendor);
|
|
334
351
|
}
|
|
335
352
|
|
package/src/cli.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
writeTextAtomic,
|
|
24
24
|
} from "./run/store.mjs";
|
|
25
25
|
import { runDirectory, runsRoot } from "./run/paths.mjs";
|
|
26
|
+
import { pruneIntegratedRunWorktrees } from "./repo/worktree-prune.mjs";
|
|
26
27
|
import { migrateRunState } from "./run/migrate.mjs";
|
|
27
28
|
import {
|
|
28
29
|
acquire as acquireLock,
|
|
@@ -112,6 +113,7 @@ export const COMMAND_OPTIONS = {
|
|
|
112
113
|
update: { check: { type: "boolean" }, json: { type: "boolean" } },
|
|
113
114
|
project: { from: { type: "string" } },
|
|
114
115
|
migrate: { cwd: { type: "string" } },
|
|
116
|
+
prune: { cwd: { type: "string" }, parked: { type: "boolean" }, json: { type: "boolean" } },
|
|
115
117
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
116
118
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
117
119
|
metrics: METRICS_OPTIONS,
|
|
@@ -121,11 +123,14 @@ export const COMMAND_OPTIONS = {
|
|
|
121
123
|
"review-rounds": { type: "string" },
|
|
122
124
|
"approve-below": { type: "string" },
|
|
123
125
|
"runtime-defaults": { type: "string" },
|
|
126
|
+
reviewers: { type: "string" },
|
|
124
127
|
runtimes: { type: "string" },
|
|
125
128
|
verification: { type: "string" },
|
|
126
129
|
package: { type: "string" },
|
|
127
130
|
"targeted-fix": { type: "boolean" },
|
|
128
131
|
detach: { type: "boolean" },
|
|
132
|
+
resolve: { type: "string" },
|
|
133
|
+
answer: { type: "string", multiple: true },
|
|
129
134
|
json: { type: "boolean" },
|
|
130
135
|
},
|
|
131
136
|
};
|
|
@@ -156,7 +161,13 @@ function parseCli(argv, quiet = false) {
|
|
|
156
161
|
if (command === "setup" && parsed.positionals.length !== 0) return null;
|
|
157
162
|
if (command === "init" && parsed.positionals.length !== 0) return null;
|
|
158
163
|
if (command === "migrate" && parsed.positionals.length !== 0) return null;
|
|
159
|
-
if (command
|
|
164
|
+
if (command === "prune" && parsed.positionals.length !== 0) return null;
|
|
165
|
+
// `plan --resolve <plan-dir>` (R9) takes no spec positional: everything a
|
|
166
|
+
// fresh `plan` reads from it and its flags is already on the contested
|
|
167
|
+
// plan.json the run it resumes wrote.
|
|
168
|
+
if (command === "plan" && typeof parsed.values.resolve === "string" && parsed.values.resolve !== "") {
|
|
169
|
+
if (parsed.positionals.length !== 0) return null;
|
|
170
|
+
} else if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && command !== "prune" && parsed.positionals.length !== 1) return null;
|
|
160
171
|
return {
|
|
161
172
|
command,
|
|
162
173
|
target: parsed.positionals[0],
|
|
@@ -310,6 +321,13 @@ async function main(argv) {
|
|
|
310
321
|
process.stdout.write(`[migrate] ${result.legacy} -> ${result.target} · ${runs}, ${campaigns}\n`);
|
|
311
322
|
return;
|
|
312
323
|
}
|
|
324
|
+
if (command === "prune") {
|
|
325
|
+
const cwd = resolve(typeof values.cwd === "string" ? values.cwd : ".");
|
|
326
|
+
const pruned = pruneIntegratedRunWorktrees(cwd, runsRoot(cwd), { parked: values.parked === true });
|
|
327
|
+
if (values.json === true) process.stdout.write(`${JSON.stringify({ pruned }, null, 2)}\n`);
|
|
328
|
+
else process.stdout.write(`${prunedLine(pruned)}\n`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
313
331
|
if (!target) { usage(); return; }
|
|
314
332
|
if (command === "project") {
|
|
315
333
|
const record = reassociateProject(faberunHome(process.env), target, {
|
|
@@ -334,6 +352,12 @@ async function main(argv) {
|
|
|
334
352
|
const contractFromCwd = relative(contract.cwd, absolute);
|
|
335
353
|
const contractIgnore = contractFromCwd && !contractFromCwd.startsWith("..") && !isAbsolute(contractFromCwd) ? [contractFromCwd] : [];
|
|
336
354
|
assertLaunchBaseClean(contract.cwd, baseRef, { ignorePaths: contractIgnore });
|
|
355
|
+
// Every launch releases the worktrees of runs that have already landed,
|
|
356
|
+
// so a long campaign does not carry them until `campaign close`.
|
|
357
|
+
if (!hasDetachedBootstrapNonce()) {
|
|
358
|
+
const pruned = pruneIntegratedRunWorktrees(contract.cwd, runsRoot(contract.cwd));
|
|
359
|
+
if (pruned.length) process.stdout.write(`${prunedLine(pruned)}\n`);
|
|
360
|
+
}
|
|
337
361
|
if (values.detach === true) {
|
|
338
362
|
if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
|
|
339
363
|
for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
@@ -487,6 +511,16 @@ function advisoryToken() {
|
|
|
487
511
|
return statusToken("warn", colorLevel(process.env, process.stdout.isTTY));
|
|
488
512
|
}
|
|
489
513
|
|
|
514
|
+
/**
|
|
515
|
+
* @param {import("./repo/worktree-prune.mjs").PrunedRun[]} pruned
|
|
516
|
+
* @returns {string}
|
|
517
|
+
*/
|
|
518
|
+
function prunedLine(pruned) {
|
|
519
|
+
if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
|
|
520
|
+
const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
|
|
521
|
+
return `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
|
|
522
|
+
}
|
|
523
|
+
|
|
490
524
|
/**
|
|
491
525
|
* The foreground launch command is the only moment an operator is present, so
|
|
492
526
|
* it is where the no-transport warning belongs. A detached controller's stdio
|
|
@@ -14,7 +14,7 @@ import { rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {unknown} value
|
|
16
16
|
* @param {string} label
|
|
17
|
-
* @param {{verificationCount?: number, recordableCount?: number}} [options]
|
|
17
|
+
* @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} [options]
|
|
18
18
|
* @returns {DefinitionOfDoneItem[]}
|
|
19
19
|
*/
|
|
20
20
|
export function validateDefinitionOfDone(value, label, options = {}) {
|
|
@@ -80,7 +80,7 @@ export function judgmentReasonWarnings(items, index) {
|
|
|
80
80
|
/**
|
|
81
81
|
* @param {unknown} value
|
|
82
82
|
* @param {string} label
|
|
83
|
-
* @param {{verificationCount?: number, recordableCount?: number}} options
|
|
83
|
+
* @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
|
|
84
84
|
* @returns {DefinitionOfDoneProof}
|
|
85
85
|
*/
|
|
86
86
|
function validateProof(value, label, options) {
|
|
@@ -100,20 +100,60 @@ function validateProof(value, label, options) {
|
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
102
|
* A `verification` proof reuses a recorded controller verification result by
|
|
103
|
-
* position
|
|
104
|
-
*
|
|
103
|
+
* position. It is authored as either the zero-based index or, when the
|
|
104
|
+
* node's verification commands are known to the caller (`options.commands`),
|
|
105
|
+
* the exact text of one of them -- the shape a planning model actually wrote
|
|
106
|
+
* (measured in `choose-the-judges` R5). A text ref is resolved to its index
|
|
107
|
+
* and normalized the same way a numeric ref is, so nothing downstream ever
|
|
108
|
+
* compares command strings again.
|
|
105
109
|
*
|
|
106
110
|
* @param {unknown} value
|
|
107
111
|
* @param {string} label
|
|
108
|
-
* @param {{verificationCount?: number, recordableCount?: number}} options
|
|
112
|
+
* @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
|
|
109
113
|
* @returns {string}
|
|
110
114
|
*/
|
|
111
115
|
function validateVerificationRef(value, label, options) {
|
|
116
|
+
if (typeof value === "string" && options.commands !== undefined && !isIndexLike(value)) {
|
|
117
|
+
return resolveTextRef(value, label, options);
|
|
118
|
+
}
|
|
112
119
|
const text = typeof value === "number" ? String(value) : value;
|
|
113
|
-
if (
|
|
114
|
-
throw new TypeError(`${label}.ref must be the zero-based index of a verification command`);
|
|
120
|
+
if (!isIndexLike(text)) {
|
|
121
|
+
throw new TypeError(`${label}.ref must be the zero-based index of a verification command, or the exact text of one`);
|
|
122
|
+
}
|
|
123
|
+
return checkedIndex(Number.parseInt(/** @type {string} */ (text).trim(), 10), label, options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {unknown} text @returns {boolean} */
|
|
127
|
+
function isIndexLike(text) {
|
|
128
|
+
return typeof text === "string" && /^\d+$/u.test(text.trim());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} value
|
|
133
|
+
* @param {string} label
|
|
134
|
+
* @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
function resolveTextRef(value, label, options) {
|
|
138
|
+
const commands = /** @type {{argv: string[]}[]} */ (options.commands);
|
|
139
|
+
const index = commands.findIndex((command) => command.argv.join(" ") === value);
|
|
140
|
+
if (index === -1) {
|
|
141
|
+
const node = options.nodeId ?? "this node";
|
|
142
|
+
const existing = commands.length === 0
|
|
143
|
+
? "(no verification commands)"
|
|
144
|
+
: commands.map((command, position) => `${position}: ${command.argv.join(" ")}`).join("\n");
|
|
145
|
+
throw new TypeError(`${label}.ref "${value}" names no verification command of ${node}; its verification commands are:\n${existing}`);
|
|
115
146
|
}
|
|
116
|
-
|
|
147
|
+
return checkedIndex(index, label, options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {number} index
|
|
152
|
+
* @param {string} label
|
|
153
|
+
* @param {{verificationCount?: number, recordableCount?: number}} options
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function checkedIndex(index, label, options) {
|
|
117
157
|
if (typeof options.verificationCount === "number" && index >= options.verificationCount) {
|
|
118
158
|
throw new TypeError(`${label}.ref ${index} names no verification command: the packet declares ${options.verificationCount}`);
|
|
119
159
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for a node's `humanStep` field (R16): the operator-only step and
|
|
3
|
+
* its command a requirement's `constraints` declared (`plan/human-step.mjs`
|
|
4
|
+
* detects it; `plan/freeze.mjs` stamps it onto the node that carries the
|
|
5
|
+
* requirement). Split from `contract/index.mjs`, mirroring
|
|
6
|
+
* `judge-independence.mjs`, because the shape that turns a node into a stop
|
|
7
|
+
* belongs with the field that declares it, not the general node-parsing loop.
|
|
8
|
+
*/
|
|
9
|
+
import { assertObject, boundedString, rejectUnknown } from "./assert.mjs";
|
|
10
|
+
|
|
11
|
+
/** The fields a node's `humanStep` declaration carries. */
|
|
12
|
+
const HUMAN_STEP_FIELDS = new Set(["step", "command"]);
|
|
13
|
+
|
|
14
|
+
/** Bound on each of `step` and `command`, matching the operator-answer ceiling this stop is paired with (`engine/retry.mjs`'s `Previous attempt` section). */
|
|
15
|
+
const HUMAN_STEP_MAX_BYTES = 4096;
|
|
16
|
+
|
|
17
|
+
/** The error code a `humanStep` node stops with once its dependencies are done, never dispatched to a provider. */
|
|
18
|
+
export const HUMAN_STEP_ERROR_CODE = "human_step_pending";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @param {string} label
|
|
23
|
+
* @returns {{step: string, command: string}|undefined}
|
|
24
|
+
*/
|
|
25
|
+
export function validateHumanStep(value, label) {
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
assertObject(value, label);
|
|
28
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
29
|
+
rejectUnknown(record, HUMAN_STEP_FIELDS, label);
|
|
30
|
+
boundedString(record.step, `${label}.step`, HUMAN_STEP_MAX_BYTES);
|
|
31
|
+
boundedString(record.command, `${label}.command`, HUMAN_STEP_MAX_BYTES);
|
|
32
|
+
return { step: /** @type {string} */ (record.step), command: /** @type {string} */ (record.command) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The attention message naming the step and its command: `state.error.message`
|
|
37
|
+
* carries this verbatim, so every existing attention surface (the final
|
|
38
|
+
* report, the run's stdout line, a resume's attention print) names both
|
|
39
|
+
* without reading the contract a second time.
|
|
40
|
+
*
|
|
41
|
+
* @param {{step: string, command: string}} humanStep
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function humanStepAttentionMessage(humanStep) {
|
|
45
|
+
return `${humanStep.step} — run \`${humanStep.command}\``;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{status?: string, error?: {code?: string}|null}} state
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isHumanStepPending(state) {
|
|
53
|
+
return state.status === "blocked" && state.error?.code === HUMAN_STEP_ERROR_CODE;
|
|
54
|
+
}
|
package/src/contract/index.mjs
CHANGED
|
@@ -14,6 +14,9 @@ import { stableJson } from "../util.mjs";
|
|
|
14
14
|
import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
15
15
|
import { validateMetadata } from "./schema-version.mjs";
|
|
16
16
|
import { assertRuntimeExecutesCommands, judgeWriteWarnings, requireRuntime, validateRuntime } from "./runtime.mjs";
|
|
17
|
+
import { validateJudgeList } from "./judges.mjs";
|
|
18
|
+
import { markSameProviderReviewNodes, validateJudgeIndependence } from "./judge-independence.mjs";
|
|
19
|
+
import { validateHumanStep } from "./human-step.mjs";
|
|
17
20
|
import { validateSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
21
|
import { commandCoverageWarnings, ignoreSourceWriteWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
19
22
|
import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
|
|
@@ -33,13 +36,14 @@ const WRITE_FILE_LINE_WARN_MARGIN = 100;
|
|
|
33
36
|
const CONTRACT_FIELDS = new Set([
|
|
34
37
|
"schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
|
|
35
38
|
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec", "maxTurns", "phaseSessionReuse",
|
|
36
|
-
"runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory",
|
|
39
|
+
"runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory", "judges",
|
|
40
|
+
"judgeIndependence",
|
|
37
41
|
]);
|
|
38
42
|
const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
|
|
39
43
|
const NODE_FIELDS = new Set([
|
|
40
44
|
"id", "type", "phase", "requirementIds", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
41
45
|
"definitionOfDone", "gate", "timeoutSec", "maxTurns",
|
|
42
|
-
"requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy",
|
|
46
|
+
"requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy", "sameProviderReview", "humanStep",
|
|
43
47
|
]);
|
|
44
48
|
const REPLAY_POLICIES = new Set(["safe", "reconcile", "never"]);
|
|
45
49
|
/**
|
|
@@ -68,9 +72,9 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
68
72
|
|
|
69
73
|
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
70
74
|
|
|
71
|
-
/** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
75
|
+
/** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never", sameProviderReview: boolean, humanStep?: {step: string, command: string}}} ValidatedNode */
|
|
72
76
|
|
|
73
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
77
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
74
78
|
/** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
|
|
75
79
|
|
|
76
80
|
/** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
|
|
@@ -95,10 +99,12 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
95
99
|
/** @typedef {{available: boolean, exhaustedUntil: string|null, reason: string}} RuntimeAvailability */
|
|
96
100
|
/** @typedef {{runtimeId: string, exhaustedUntil: string|null}} TierExhaustionCandidate */
|
|
97
101
|
/** @typedef {{role: "worker"|"judge", candidates: TierExhaustionCandidate[]}} TierExhaustion */
|
|
98
|
-
/** @typedef {{
|
|
102
|
+
/** @typedef {{id: string, reason: string}} JudgeSkip */
|
|
103
|
+
/** @typedef {{list: string[], chosen: string|null, skipped: JudgeSkip[]}} JudgeListState */
|
|
104
|
+
/** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number, judgeList?: JudgeListState}} RoutingState */
|
|
99
105
|
/** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
|
|
100
106
|
/** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
|
|
101
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
107
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null, sameProviderReview?: boolean}} NodeSnapshot */
|
|
102
108
|
/** @typedef {{path: string, sha: string}} ControllerIdentity */
|
|
103
109
|
/** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
|
|
104
110
|
/** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
|
|
@@ -160,6 +166,8 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
160
166
|
rejectUnknown(defaults, DEFAULTS_FIELDS, "contract.runtimeDefaults");
|
|
161
167
|
if (defaults.worker !== undefined) requireRuntime(runtimes, defaults.worker, "runtimeDefaults.worker");
|
|
162
168
|
if (defaults.judge !== undefined) requireRuntime(runtimes, defaults.judge, "runtimeDefaults.judge");
|
|
169
|
+
const judges = validateJudgeList(raw.judges, runtimes, "contract.judges");
|
|
170
|
+
const judgeIndependence = validateJudgeIndependence(raw.judgeIndependence, "contract.judgeIndependence");
|
|
163
171
|
|
|
164
172
|
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
|
|
165
173
|
throw new TypeError("contract.nodes must be a non-empty array");
|
|
@@ -244,6 +252,7 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
244
252
|
? undefined
|
|
245
253
|
: positiveInteger(node.maxTurns, `nodes[${index}].maxTurns`);
|
|
246
254
|
const replayPolicy = validateReplayPolicy(node.replayPolicy, `nodes[${index}]`);
|
|
255
|
+
const humanStep = validateHumanStep(node.humanStep, `nodes[${index}].humanStep`);
|
|
247
256
|
return /** @type {ValidatedNode} */ ({
|
|
248
257
|
...node,
|
|
249
258
|
dependsOn,
|
|
@@ -257,6 +266,11 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
257
266
|
timeoutSec,
|
|
258
267
|
maxTurns,
|
|
259
268
|
replayPolicy,
|
|
269
|
+
// Flipped true below, once the worker/judge vendor check has run for
|
|
270
|
+
// every node: same-vendor mode (R20) is only known admissible after
|
|
271
|
+
// that pass reads the pair's declared tiers.
|
|
272
|
+
sameProviderReview: false,
|
|
273
|
+
...(humanStep === undefined ? {} : { humanStep }),
|
|
260
274
|
});
|
|
261
275
|
});
|
|
262
276
|
|
|
@@ -290,38 +304,9 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
290
304
|
}
|
|
291
305
|
}
|
|
292
306
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
// The worker's declared fallback chain is checked the same way, since it is
|
|
297
|
-
// statically known which runtime a worker failover lands on; the symmetric
|
|
298
|
-
// case — the judge's own fallback landing on the worker's vendor — depends
|
|
299
|
-
// on which worker runtime actually ran and is refused at execution instead
|
|
300
|
-
// (node.mjs, `judge_fallback_vendor_conflict`).
|
|
301
|
-
for (const [index, node] of nodes.entries()) {
|
|
302
|
-
if (!node.gate.enabled) continue;
|
|
303
|
-
const workerRuntimeId = node.runtime ?? defaults.worker;
|
|
304
|
-
const judgeRuntimeId = node.gate.runtime ?? defaults.judge;
|
|
305
|
-
if (!workerRuntimeId || !judgeRuntimeId) continue;
|
|
306
|
-
const workerVendor = runtimes[/** @type {string} */ (workerRuntimeId)].vendor;
|
|
307
|
-
const judgeVendor = runtimes[/** @type {string} */ (judgeRuntimeId)].vendor;
|
|
308
|
-
if (workerVendor === judgeVendor) {
|
|
309
|
-
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}`);
|
|
310
|
-
}
|
|
311
|
-
const seenFallbacks = new Set([/** @type {string} */ (workerRuntimeId)]);
|
|
312
|
-
let fallbackId = runtimes[/** @type {string} */ (workerRuntimeId)].fallback;
|
|
313
|
-
while (fallbackId !== undefined) {
|
|
314
|
-
if (seenFallbacks.has(fallbackId)) {
|
|
315
|
-
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback chain cycles back to ${fallbackId}`);
|
|
316
|
-
}
|
|
317
|
-
seenFallbacks.add(fallbackId);
|
|
318
|
-
const fallbackVendor = runtimes[fallbackId].vendor;
|
|
319
|
-
if (fallbackVendor === judgeVendor) {
|
|
320
|
-
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}`);
|
|
321
|
-
}
|
|
322
|
-
fallbackId = runtimes[fallbackId].fallback;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
307
|
+
// See `markSameProviderReviewNodes` for the vendor-conflict refusal and
|
|
308
|
+
// R20's same-vendor opt-in that relaxes it.
|
|
309
|
+
markSameProviderReviewNodes(nodes, runtimes, defaults, judgeIndependence);
|
|
325
310
|
|
|
326
311
|
// Every worker prompt tells the worker to run its packet verification.
|
|
327
312
|
// Refuse a statically known permission mode that makes that instruction
|
|
@@ -403,6 +388,8 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
403
388
|
cwd,
|
|
404
389
|
runtimes,
|
|
405
390
|
runtimeDefaults: /** @type {{worker?: string, judge?: string}} */ (defaults),
|
|
391
|
+
judges,
|
|
392
|
+
judgeIndependence,
|
|
406
393
|
nodes,
|
|
407
394
|
maxParallel: validateMaxParallel(raw.maxParallel ?? 1),
|
|
408
395
|
pollIntervalMs: positiveInteger(raw.pollIntervalMs ?? 1_000, "contract.pollIntervalMs"),
|