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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for the `judgeIndependence` contract field (R20): the opt-in
|
|
3
|
+
* that lets a same-vendor judge review a node an operator with a single
|
|
4
|
+
* provider could otherwise never gate. Split from `contract/index.mjs`,
|
|
5
|
+
* mirroring `judges.mjs` (R18), because the tier comparison and its refusal
|
|
6
|
+
* belong with the field that turns them on, not scattered through the
|
|
7
|
+
* vendor-conflict check that calls them.
|
|
8
|
+
*/
|
|
9
|
+
import { declaredModelTier } from "../harnesses/model-tiers.mjs";
|
|
10
|
+
|
|
11
|
+
/** The only value `judgeIndependence` accepts, on the contract or the machine config. */
|
|
12
|
+
export const SAME_VENDOR_REVIEW_MODE = "same-vendor";
|
|
13
|
+
|
|
14
|
+
/** The label every human-facing surface uses to mark a node reviewed under the mode. */
|
|
15
|
+
export const SAME_PROVIDER_REVIEW_LABEL = "same-provider review";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} value
|
|
19
|
+
* @param {string} label
|
|
20
|
+
* @returns {"same-vendor"|undefined}
|
|
21
|
+
*/
|
|
22
|
+
export function validateJudgeIndependence(value, label) {
|
|
23
|
+
if (value === undefined) return undefined;
|
|
24
|
+
if (value !== SAME_VENDOR_REVIEW_MODE) throw new TypeError(`${label} must be "${SAME_VENDOR_REVIEW_MODE}"`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Same-vendor mode's own admissibility rule for a worker/judge pair that
|
|
30
|
+
* share a vendor: a judge model absent from the declared tier table can never
|
|
31
|
+
* judge in this mode, and a judge tier below the worker's is refused, naming
|
|
32
|
+
* both. `null` means the pair is admissible.
|
|
33
|
+
*
|
|
34
|
+
* @param {{harness: string, model: string}} workerRuntime
|
|
35
|
+
* @param {{harness: string, model: string}} judgeRuntime
|
|
36
|
+
* @returns {{code: string, message: string}|null}
|
|
37
|
+
*/
|
|
38
|
+
export function sameVendorTierRefusal(workerRuntime, judgeRuntime) {
|
|
39
|
+
const judgeTier = declaredModelTier(judgeRuntime.harness, judgeRuntime.model);
|
|
40
|
+
if (judgeTier === null) {
|
|
41
|
+
return {
|
|
42
|
+
code: "same_vendor_judge_no_tier",
|
|
43
|
+
message: `judge model ${judgeRuntime.model} declares no tier and cannot judge in same-vendor mode`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const workerTier = declaredModelTier(workerRuntime.harness, workerRuntime.model);
|
|
47
|
+
if (workerTier !== null && judgeTier < workerTier) {
|
|
48
|
+
return {
|
|
49
|
+
code: "same_vendor_judge_tier_too_low",
|
|
50
|
+
message: `judge tier ${judgeTier} (${judgeRuntime.model}) is below worker tier ${workerTier} (${workerRuntime.model})`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a worker runtime that actually ran and a judge runtime that
|
|
58
|
+
* actually ran are a same-provider pairing: the mode is opted in and the two
|
|
59
|
+
* share a vendor. Contract validation already refused every reachable
|
|
60
|
+
* worker/judge pairing (primary or fallback) that fails the tier rule, so
|
|
61
|
+
* this is a plain vendor comparison at dispatch time, not a second
|
|
62
|
+
* enforcement of it.
|
|
63
|
+
*
|
|
64
|
+
* @param {{judgeIndependence?: string}} contract
|
|
65
|
+
* @param {{vendor?: string}|null|undefined} workerRuntime
|
|
66
|
+
* @param {{vendor?: string}|null|undefined} judgeRuntime
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isSameProviderReviewPair(contract, workerRuntime, judgeRuntime) {
|
|
70
|
+
if (contract.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return false;
|
|
71
|
+
const workerVendor = workerRuntime?.vendor;
|
|
72
|
+
const judgeVendor = judgeRuntime?.vendor;
|
|
73
|
+
return Boolean(workerVendor) && workerVendor === judgeVendor;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether `workerRuntimeId` or its one declared fallback hop could share
|
|
78
|
+
* `judgeVendor`: the structural "this pairing might land same-provider"
|
|
79
|
+
* fact a plan-time surface (the Campaign Brief) can read before any node has
|
|
80
|
+
* run, mirroring the one-hop rule `engine/failover.mjs` enforces at dispatch.
|
|
81
|
+
* A run-time surface reading a completed node's snapshot should prefer the
|
|
82
|
+
* dynamic fact instead (`isSameProviderReviewPair` against the runtime that
|
|
83
|
+
* actually ran).
|
|
84
|
+
*
|
|
85
|
+
* @param {Record<string, {vendor?: string, fallback?: string}>} runtimes
|
|
86
|
+
* @param {string} workerRuntimeId
|
|
87
|
+
* @param {string|null|undefined} judgeVendor
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function sharesVendorThroughFallback(runtimes, workerRuntimeId, judgeVendor) {
|
|
91
|
+
if (!judgeVendor) return false;
|
|
92
|
+
const seen = new Set([workerRuntimeId]);
|
|
93
|
+
let currentId = workerRuntimeId;
|
|
94
|
+
while (currentId !== undefined) {
|
|
95
|
+
if (runtimes[currentId]?.vendor === judgeVendor) return true;
|
|
96
|
+
const nextId = runtimes[currentId]?.fallback;
|
|
97
|
+
if (nextId === undefined || seen.has(nextId)) break;
|
|
98
|
+
seen.add(nextId);
|
|
99
|
+
currentId = nextId;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A gated node whose worker and judge share a vendor cannot produce an
|
|
106
|
+
* independent review -- the same vendor grading its own output is not a
|
|
107
|
+
* gate, so this is rejected outright unless `judgeIndependence` opts the
|
|
108
|
+
* contract into same-vendor mode, in which case a judge tier at or above the
|
|
109
|
+
* worker's admits the pair and marks the node `sameProviderReview` for every
|
|
110
|
+
* human-facing surface to read back. The worker's declared fallback chain is
|
|
111
|
+
* checked the same way, since it is statically known which runtime a worker
|
|
112
|
+
* failover lands on; the symmetric case -- the judge's own fallback landing
|
|
113
|
+
* on the worker's vendor -- depends on which worker runtime actually ran and
|
|
114
|
+
* is refused at execution instead (node.mjs, `judge_fallback_vendor_conflict`).
|
|
115
|
+
*
|
|
116
|
+
* Mutates and returns `nodes` in place; called once per `validateContract`.
|
|
117
|
+
*
|
|
118
|
+
* @param {{gate: {enabled: boolean, runtime?: string}, runtime?: string, sameProviderReview: boolean}[]} nodes
|
|
119
|
+
* @param {Record<string, {vendor?: string, fallback?: string, harness: string, model: string}>} runtimes
|
|
120
|
+
* @param {{worker?: string, judge?: string}} defaults
|
|
121
|
+
* @param {"same-vendor"|undefined} judgeIndependence
|
|
122
|
+
* @returns {{gate: {enabled: boolean, runtime?: string}, runtime?: string, sameProviderReview: boolean}[]}
|
|
123
|
+
*/
|
|
124
|
+
export function markSameProviderReviewNodes(nodes, runtimes, defaults, judgeIndependence) {
|
|
125
|
+
for (const [index, node] of nodes.entries()) {
|
|
126
|
+
if (!node.gate.enabled) continue;
|
|
127
|
+
const workerRuntimeId = node.runtime ?? defaults.worker;
|
|
128
|
+
const judgeRuntimeId = node.gate.runtime ?? defaults.judge;
|
|
129
|
+
if (!workerRuntimeId || !judgeRuntimeId) continue;
|
|
130
|
+
const workerRuntime = runtimes[workerRuntimeId];
|
|
131
|
+
const judgeRuntime = runtimes[judgeRuntimeId];
|
|
132
|
+
const workerVendor = workerRuntime.vendor;
|
|
133
|
+
const judgeVendor = judgeRuntime.vendor;
|
|
134
|
+
if (workerVendor === judgeVendor) {
|
|
135
|
+
if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
|
|
136
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}`);
|
|
137
|
+
}
|
|
138
|
+
const refusal = sameVendorTierRefusal(workerRuntime, judgeRuntime);
|
|
139
|
+
if (refusal) {
|
|
140
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}: ${refusal.message}`);
|
|
141
|
+
}
|
|
142
|
+
nodes[index] = { ...node, sameProviderReview: true };
|
|
143
|
+
}
|
|
144
|
+
const seenFallbacks = new Set([workerRuntimeId]);
|
|
145
|
+
let fallbackId = runtimes[workerRuntimeId].fallback;
|
|
146
|
+
while (fallbackId !== undefined) {
|
|
147
|
+
if (seenFallbacks.has(fallbackId)) {
|
|
148
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback chain cycles back to ${fallbackId}`);
|
|
149
|
+
}
|
|
150
|
+
seenFallbacks.add(fallbackId);
|
|
151
|
+
const fallbackVendor = runtimes[fallbackId].vendor;
|
|
152
|
+
if (fallbackVendor === judgeVendor) {
|
|
153
|
+
if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
|
|
154
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}`);
|
|
155
|
+
}
|
|
156
|
+
const fallbackRefusal = sameVendorTierRefusal(runtimes[fallbackId], judgeRuntime);
|
|
157
|
+
if (fallbackRefusal) {
|
|
158
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}: ${fallbackRefusal.message}`);
|
|
159
|
+
}
|
|
160
|
+
// The fallback is only reachable if it actually runs, but the tier
|
|
161
|
+
// rule above already admitted it -- so the node is marked here too,
|
|
162
|
+
// not only when the primary matches (the plan-time "could this land
|
|
163
|
+
// same-provider" fact every human-facing surface reads back).
|
|
164
|
+
nodes[index] = { ...nodes[index], sameProviderReview: true };
|
|
165
|
+
}
|
|
166
|
+
fallbackId = runtimes[fallbackId].fallback;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return nodes;
|
|
170
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for the `judges` contract field (R18): a static, ordered list of
|
|
3
|
+
* runtime ids the engine reads before falling back to the machine default or
|
|
4
|
+
* the single strongest cross-vendor candidate. Split from `contract/index.mjs`,
|
|
5
|
+
* which is already near this repository's 800-line ceiling.
|
|
6
|
+
*/
|
|
7
|
+
import { requireId } from "./assert.mjs";
|
|
8
|
+
import { requireRuntime } from "./runtime.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("./runtime.mjs").ValidatedRuntime} ValidatedRuntime */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {unknown} value
|
|
14
|
+
* @param {Record<string, ValidatedRuntime>} runtimes
|
|
15
|
+
* @param {string} label
|
|
16
|
+
* @returns {string[]|undefined}
|
|
17
|
+
*/
|
|
18
|
+
export function validateJudgeList(value, runtimes, label) {
|
|
19
|
+
if (value === undefined) return undefined;
|
|
20
|
+
if (!Array.isArray(value) || value.length === 0) throw new TypeError(`${label} must be a non-empty array of runtime ids`);
|
|
21
|
+
value.forEach((id, index) => {
|
|
22
|
+
requireId(id, `${label}[${index}]`);
|
|
23
|
+
requireRuntime(runtimes, id, `${label}[${index}]`);
|
|
24
|
+
});
|
|
25
|
+
if (new Set(value).size !== value.length) throw new TypeError(`${label} must not repeat a runtime id`);
|
|
26
|
+
return /** @type {string[]} */ (value);
|
|
27
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical provider a runtime actually talks to, as opposed to its
|
|
3
|
+
* free-text `vendor` label. Split from runtime.mjs because both the contract
|
|
4
|
+
* validator and every worker-against-judge vendor comparison across `plan/`,
|
|
5
|
+
* `engine/`, `harnesses/` and `cli/` need it, and none of those layers should
|
|
6
|
+
* import the runtime schema validator just to reach it.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_HARNESS_VENDORS } from "../harnesses/index.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {{harness?: string, model?: string, config?: Record<string, unknown>}} ProviderInput */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The five canonical providers a runtime can derive to. A runtime whose
|
|
14
|
+
* harness names no default and whose model matches no family below derives
|
|
15
|
+
* none of these — `replay` and `exec-jsonl` never do, since either stands in
|
|
16
|
+
* for whatever the recording or the exec'd binary actually is.
|
|
17
|
+
*
|
|
18
|
+
* @type {ReadonlyArray<"openai"|"anthropic"|"zhipu"|"deepseek"|"google">}
|
|
19
|
+
*/
|
|
20
|
+
const CANONICAL_PROVIDERS = Object.freeze(["openai", "anthropic", "zhipu", "deepseek", "google"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Model-id family prefixes, checked before the harness default so an
|
|
24
|
+
* `agy` runtime reselling `claude-sonnet-4-6` derives `anthropic`, not `agy`'s
|
|
25
|
+
* own default of `google`.
|
|
26
|
+
*
|
|
27
|
+
* @type {ReadonlyArray<{pattern: RegExp, provider: string}>}
|
|
28
|
+
*/
|
|
29
|
+
const MODEL_FAMILY_PROVIDERS = Object.freeze([
|
|
30
|
+
{ pattern: /^claude-|^opus\b|^sonnet\b|^fable\b|^haiku\b/iu, provider: "anthropic" },
|
|
31
|
+
{ pattern: /^gpt-|^o\d/iu, provider: "openai" },
|
|
32
|
+
{ pattern: /^gemini-/iu, provider: "google" },
|
|
33
|
+
{ pattern: /^glm-/iu, provider: "zhipu" },
|
|
34
|
+
{ pattern: /^deepseek-/iu, provider: "deepseek" },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The harnesses whose runtime always stands in for an arbitrary provider: the
|
|
39
|
+
* recording (`replay`) or the exec'd binary (`exec-jsonl`) decide, never this
|
|
40
|
+
* module.
|
|
41
|
+
*/
|
|
42
|
+
const NO_CANONICAL_PROVIDER_HARNESSES = new Set(["replay", "exec-jsonl"]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The provider a runtime declares a route to, read from the config key its
|
|
46
|
+
* own harness uses for that: codex's `config.model_provider` (for example
|
|
47
|
+
* `deepseek`) and dsh's `config.provider` (`deepseek-official` names
|
|
48
|
+
* `deepseek`). A route naming none of the five canonical providers is not a
|
|
49
|
+
* route this function recognizes, so derivation falls through to the model's
|
|
50
|
+
* family and then the harness default.
|
|
51
|
+
*
|
|
52
|
+
* @param {ProviderInput} runtime
|
|
53
|
+
* @returns {string|null}
|
|
54
|
+
*/
|
|
55
|
+
function routedProvider(runtime) {
|
|
56
|
+
const config = runtime.config;
|
|
57
|
+
if (!config || typeof config !== "object") return null;
|
|
58
|
+
const route = runtime.harness === "codex"
|
|
59
|
+
? config.model_provider
|
|
60
|
+
: runtime.harness === "dsh"
|
|
61
|
+
? config.provider
|
|
62
|
+
: undefined;
|
|
63
|
+
if (typeof route !== "string" || route.length === 0) return null;
|
|
64
|
+
const lowered = route.toLowerCase();
|
|
65
|
+
return CANONICAL_PROVIDERS.find((provider) => lowered.includes(provider)) ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The provider a runtime actually talks to, derived in order from (1) the
|
|
70
|
+
* route it declares to another provider, (2) its model id's family, and (3)
|
|
71
|
+
* its harness's own default. `null` means none of the three named one — the
|
|
72
|
+
* two test harnesses always land here, and any other harness does too when
|
|
73
|
+
* its model matches no known family and its harness names no default.
|
|
74
|
+
*
|
|
75
|
+
* @param {ProviderInput} runtime
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
export function canonicalProvider(runtime) {
|
|
79
|
+
if (!runtime || typeof runtime.harness !== "string") return null;
|
|
80
|
+
if (NO_CANONICAL_PROVIDER_HARNESSES.has(runtime.harness)) return null;
|
|
81
|
+
const routed = routedProvider(runtime);
|
|
82
|
+
if (routed) return routed;
|
|
83
|
+
const model = typeof runtime.model === "string" ? runtime.model : "";
|
|
84
|
+
const family = MODEL_FAMILY_PROVIDERS.find((entry) => entry.pattern.test(model));
|
|
85
|
+
if (family) return family.provider;
|
|
86
|
+
return /** @type {Record<string, string>} */ (DEFAULT_HARNESS_VENDORS)[runtime.harness] ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The provider identity a worker-against-judge comparison actually compares:
|
|
91
|
+
* the canonical provider when one is derivable, the declared `vendor`
|
|
92
|
+
* otherwise. Every routing and failover site that used to compare `.vendor`
|
|
93
|
+
* directly compares this instead, so a contradicting label (R18) cannot
|
|
94
|
+
* survive `validateRuntime` and reach one of them, and a runtime that
|
|
95
|
+
* bypasses validation (a raw fixture, a discovery-catalogue definition) still
|
|
96
|
+
* compares consistently rather than by accident.
|
|
97
|
+
*
|
|
98
|
+
* @param {ProviderInput & {vendor?: string}} runtime
|
|
99
|
+
* @returns {string|undefined}
|
|
100
|
+
*/
|
|
101
|
+
export function effectiveProvider(runtime) {
|
|
102
|
+
return canonicalProvider(runtime) ?? runtime.vendor;
|
|
103
|
+
}
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
|
|
13
13
|
import { composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
14
14
|
import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
15
|
+
import { canonicalProvider } from "./provider.mjs";
|
|
15
16
|
import { stableJson } from "../util.mjs";
|
|
16
17
|
/** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
|
|
17
18
|
/** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
@@ -98,8 +99,24 @@ export function validateRuntime(id, runtime) {
|
|
|
98
99
|
assertObject(runtime, `runtime ${id}`);
|
|
99
100
|
rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
|
|
100
101
|
validateRuntimeValues(runtime, `runtime ${id}`, runtime.harness === "exec-jsonl");
|
|
101
|
-
const
|
|
102
|
-
|
|
102
|
+
const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
|
|
103
|
+
// The canonical provider derived from the route, the model's family, or the
|
|
104
|
+
// harness default outranks a declared `vendor`: a label that contradicts it
|
|
105
|
+
// is refused (R18), and the runtime's vendor becomes the derived provider
|
|
106
|
+
// rather than whatever text the contract declared. `replay` and `exec-jsonl`
|
|
107
|
+
// derive none — for those the declared vendor stands, as it always has.
|
|
108
|
+
const derived = canonicalProvider(typedRuntime);
|
|
109
|
+
const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
|
|
110
|
+
let vendor;
|
|
111
|
+
if (derived) {
|
|
112
|
+
if (declared !== undefined && declared !== derived) {
|
|
113
|
+
throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
|
|
114
|
+
}
|
|
115
|
+
vendor = derived;
|
|
116
|
+
} else {
|
|
117
|
+
vendor = resolveVendor(typedRuntime);
|
|
118
|
+
if (!vendor) throw new TypeError(`runtime ${id} has no resolvable vendor`);
|
|
119
|
+
}
|
|
103
120
|
const stallTimeoutSec = runtime.stallTimeoutSec ?? HARNESS_STALL_TIMEOUT_SEC[/** @type {string} */ (runtime.harness)];
|
|
104
121
|
return /** @type {ValidatedRuntime} */ ({
|
|
105
122
|
...runtime,
|
|
@@ -130,6 +130,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
130
130
|
"attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
131
131
|
"costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
|
|
132
132
|
"scopeFindings", "verificationArtifacts", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
|
|
133
|
+
"sameProviderReview",
|
|
133
134
|
]), "node snapshot");
|
|
134
135
|
validateMetadata(value, "node snapshot");
|
|
135
136
|
requireId(value.id, "node snapshot.id");
|
|
@@ -148,6 +149,11 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
148
149
|
if (value.review !== undefined && !REVIEW_MODES.has(/** @type {string} */ (value.review))) {
|
|
149
150
|
throw new TypeError("node snapshot.review is invalid");
|
|
150
151
|
}
|
|
152
|
+
// R20: whether this attempt's judge was admitted under same-vendor mode,
|
|
153
|
+
// stamped by `startJudge` from the contract's own `sameProviderReview`.
|
|
154
|
+
if (value.sameProviderReview !== undefined && typeof value.sameProviderReview !== "boolean") {
|
|
155
|
+
throw new TypeError("node snapshot.sameProviderReview must be a boolean");
|
|
156
|
+
}
|
|
151
157
|
requirePacketHash(value.packetHash, "node snapshot.packetHash");
|
|
152
158
|
validateSourceIdentity(value.sourceIdentity, "node snapshot.sourceIdentity", { kind: "node" });
|
|
153
159
|
if (value.integratedHead !== undefined && value.integratedHead !== null) boundedString(value.integratedHead, "node snapshot.integratedHead", 256);
|
|
@@ -460,13 +466,36 @@ function validateTierExhaustion(value, label) {
|
|
|
460
466
|
if (candidate.exhaustedUntil !== null) requireTimestamp(candidate.exhaustedUntil, `${label}.candidates[${index}].exhaustedUntil`);
|
|
461
467
|
}
|
|
462
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* The durable evidence of one node's ordered judge-list selection (R18): the
|
|
471
|
+
* list it read, the entry it chose (null once every entry is skipped), and
|
|
472
|
+
* why each earlier one was -- appended to, never replaced, across a run's
|
|
473
|
+
* hop-by-hop fallback, so an entry already skipped for cause stays skipped.
|
|
474
|
+
*
|
|
475
|
+
* @param {unknown} value
|
|
476
|
+
* @param {string} label
|
|
477
|
+
*/
|
|
478
|
+
function validateJudgeListState(value, label) {
|
|
479
|
+
assertObject(value, label);
|
|
480
|
+
rejectUnknown(value, new Set(["list", "chosen", "skipped"]), label);
|
|
481
|
+
if (!Array.isArray(value.list) || value.list.length === 0) throw new TypeError(`${label}.list must be a non-empty array`);
|
|
482
|
+
value.list.forEach((id, index) => requireId(id, `${label}.list[${index}]`));
|
|
483
|
+
if (value.chosen !== null) requireId(value.chosen, `${label}.chosen`);
|
|
484
|
+
if (!Array.isArray(value.skipped)) throw new TypeError(`${label}.skipped must be an array`);
|
|
485
|
+
for (const [index, entry] of value.skipped.entries()) {
|
|
486
|
+
assertObject(entry, `${label}.skipped[${index}]`);
|
|
487
|
+
rejectUnknown(entry, new Set(["id", "reason"]), `${label}.skipped[${index}]`);
|
|
488
|
+
requireId(entry.id, `${label}.skipped[${index}].id`);
|
|
489
|
+
boundedString(entry.reason, `${label}.skipped[${index}].reason`, 512);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
463
492
|
/**
|
|
464
493
|
* @param {unknown} value
|
|
465
494
|
* @param {string} label
|
|
466
495
|
*/
|
|
467
496
|
function validateRoutingState(value, label) {
|
|
468
497
|
assertObject(value, label);
|
|
469
|
-
rejectUnknown(value, new Set(["history", "currentOverride", "assignments", "availability", "tierExhaustion", "tierExhaustionCycle"]), label);
|
|
498
|
+
rejectUnknown(value, new Set(["history", "currentOverride", "assignments", "availability", "tierExhaustion", "tierExhaustionCycle", "judgeList"]), label);
|
|
470
499
|
if (!Array.isArray(value.history) || value.history.length > MAX_ROUTING_HISTORY) {
|
|
471
500
|
throw new TypeError(`${label}.history must be an array with at most ${MAX_ROUTING_HISTORY} items`);
|
|
472
501
|
}
|
|
@@ -476,6 +505,7 @@ function validateRoutingState(value, label) {
|
|
|
476
505
|
if (value.currentOverride !== null) validateRoutingEntry(value.currentOverride, `${label}.currentOverride`, true);
|
|
477
506
|
if (value.tierExhaustion !== undefined) validateTierExhaustion(value.tierExhaustion, `${label}.tierExhaustion`);
|
|
478
507
|
if (value.tierExhaustionCycle !== undefined) nonNegativeInteger(value.tierExhaustionCycle, `${label}.tierExhaustionCycle`);
|
|
508
|
+
if (value.judgeList !== undefined) validateJudgeListState(value.judgeList, `${label}.judgeList`);
|
|
479
509
|
if (value.assignments !== undefined) {
|
|
480
510
|
assertObject(value.assignments, `${label}.assignments`);
|
|
481
511
|
rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge"]), `${label}.assignments`);
|
|
@@ -11,11 +11,14 @@ import { PARKED } from "./prompts.mjs";
|
|
|
11
11
|
import { composeAssignments, discoverRuntimes } from "./runtime-discovery.mjs";
|
|
12
12
|
import { readUserConfig } from "../host/config.mjs";
|
|
13
13
|
import { transition } from "./state.mjs";
|
|
14
|
+
import { isHumanStepPending } from "../contract/human-step.mjs";
|
|
15
|
+
import { initialJudgeListState, resolveJudgeList } from "./judge-list.mjs";
|
|
14
16
|
|
|
15
17
|
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
16
18
|
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
17
19
|
/** @typedef {import("./runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
18
20
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
21
|
+
/** @typedef {import("../contract/index.mjs").JudgeListState} JudgeListState */
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Resolve role assignments once at run creation. Discovery is used only for
|
|
@@ -30,7 +33,7 @@ import { transition } from "./state.mjs";
|
|
|
30
33
|
* observables); it widens when a reader needs the record durably.
|
|
31
34
|
*
|
|
32
35
|
* @param {ValidatedContract} contract
|
|
33
|
-
* @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>}>}
|
|
36
|
+
* @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>, judgeListStates: Record<string, JudgeListState|undefined>}>}
|
|
34
37
|
*/
|
|
35
38
|
export async function runtimeAssignments(contract) {
|
|
36
39
|
const needsComposition = contract.nodes.some((node) =>
|
|
@@ -38,7 +41,27 @@ export async function runtimeAssignments(contract) {
|
|
|
38
41
|
|| (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
|
|
39
42
|
const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
|
|
40
43
|
const config = readUserConfig(process.env);
|
|
41
|
-
const
|
|
44
|
+
const list = resolveJudgeList(contract, config);
|
|
45
|
+
/** @type {Record<string, JudgeListState|undefined>} */
|
|
46
|
+
const judgeListStates = {};
|
|
47
|
+
// R18: an omitted judge reads the ordered list, contract over machine
|
|
48
|
+
// config, before `composeAssignments`' own single `config.judge` preference
|
|
49
|
+
// and strongest-candidate default. The callback records the evidence
|
|
50
|
+
// (`judgeListStates`) as a side effect and returns `undefined` only for a
|
|
51
|
+
// node the list does not govern (its gate is disabled), which falls through
|
|
52
|
+
// to those candidates unchanged; once the list governs, its pick is final --
|
|
53
|
+
// the whole state, so an exhausted list's thrown error can still name every
|
|
54
|
+
// skip, not just `chosen` (`null` when every entry was skipped), which
|
|
55
|
+
// `composeAssignments` must not fall through either.
|
|
56
|
+
const listJudge = list
|
|
57
|
+
? (/** @type {{id: string, gate: {enabled: boolean}}} */ node, /** @type {string} */ workerId) => {
|
|
58
|
+
if (!node.gate.enabled) return undefined;
|
|
59
|
+
const judgeListState = initialJudgeListState(contract, list, workerId);
|
|
60
|
+
judgeListStates[node.id] = judgeListState;
|
|
61
|
+
return judgeListState;
|
|
62
|
+
}
|
|
63
|
+
: undefined;
|
|
64
|
+
const assignments = composeAssignments(contract, availability, { config, listJudge });
|
|
42
65
|
/** @type {Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>} */
|
|
43
66
|
const decisions = {};
|
|
44
67
|
return {
|
|
@@ -54,20 +77,28 @@ export async function runtimeAssignments(contract) {
|
|
|
54
77
|
const workerSource = node?.runtime !== undefined
|
|
55
78
|
? "node runtime"
|
|
56
79
|
: contract.runtimeDefaults?.worker !== undefined ? "runtimeDefaults.worker" : null;
|
|
80
|
+
// A list state exists only when the callback actually ran for this node
|
|
81
|
+
// (composedJudge). An exhausted list (chosen null) never reaches this
|
|
82
|
+
// point at all: composeAssignments throws before this map runs rather
|
|
83
|
+
// than falling through to the discovery ranking.
|
|
84
|
+
const fromList = judgeListStates[nodeId]?.chosen === assignment.judge;
|
|
57
85
|
decisions[nodeId] = {
|
|
58
86
|
worker: {
|
|
59
87
|
strategy: composedWorker ? "cost" : workerSource !== null ? "declared" : null,
|
|
60
88
|
reason: composedWorker ? "discovery: cheapest available runtime" : /** @type {string} */ (workerSource),
|
|
61
89
|
},
|
|
62
90
|
judge: {
|
|
63
|
-
strategy: composedJudge ? "priority" : judgeSource !== null ? "declared" : null,
|
|
64
|
-
reason: composedJudge
|
|
91
|
+
strategy: composedJudge ? (fromList ? "judge-list" : "priority") : judgeSource !== null ? "declared" : null,
|
|
92
|
+
reason: composedJudge
|
|
93
|
+
? (fromList ? `judge list: chose ${assignment.judge}` : "discovery: strongest available runtime")
|
|
94
|
+
: /** @type {string} */ (judgeSource ?? "no judge required"),
|
|
65
95
|
},
|
|
66
96
|
};
|
|
67
97
|
return [nodeId, { ...assignment, composedWorker, composedJudge }];
|
|
68
98
|
})),
|
|
69
99
|
decisions,
|
|
70
100
|
availability,
|
|
101
|
+
judgeListStates,
|
|
71
102
|
};
|
|
72
103
|
}
|
|
73
104
|
/**
|
|
@@ -85,6 +116,11 @@ export function blockDependents(contract, runDir, states, lock) {
|
|
|
85
116
|
// automatic retry is still unspent is re-opened to `pending` before this
|
|
86
117
|
// runs, so the dependants stay `pending`/`phase: "waiting"` until it parks.
|
|
87
118
|
const blockedBy = node.dependsOn.filter((id) => PARKED.has(states.get(id)?.status ?? ""));
|
|
88
|
-
|
|
119
|
+
// A dependant of a pending human step (R16) is waiting, not failed: the
|
|
120
|
+
// code stays `dependency_failed` so `resume --answer` re-opens it, and the
|
|
121
|
+
// message says what it waits for.
|
|
122
|
+
const waiting = blockedBy.every((id) => isHumanStepPending(/** @type {NodeSnapshot} */ (states.get(id))));
|
|
123
|
+
const message = waiting ? `waiting for human step ${blockedBy.join(", ")}` : `blocked by ${blockedBy.join(", ")}`;
|
|
124
|
+
if (blockedBy.length) transition(runDir, state, "blocked", { phase: "dependency", blockedBy, error: { code: "dependency_failed", message } }, lock);
|
|
89
125
|
}
|
|
90
126
|
}
|
package/src/engine/backoff.mjs
CHANGED
|
@@ -362,9 +362,10 @@ export function upsertTierExhaustionCandidate(existing, role, runtimeId, exhaust
|
|
|
362
362
|
* @param {Transition} schedule
|
|
363
363
|
* @param {number} [now] epoch ms the backoff window is measured from
|
|
364
364
|
* @param {string|null} [exhaustedUntil] the reset instant the exhausted candidate announced, if any
|
|
365
|
-
* @
|
|
365
|
+
* @param {{judgeListState?: import("../contract/index.mjs").JudgeListState}} [options] R18: a judge-list hop already resolved by the impure caller (`engine/judge-list.mjs`'s `nextListJudge` reads the durable refusal and usage-window stores, which this decision module never touches); its `chosen` -- possibly null, once the list is exhausted -- replaces both the declared `runtime.fallback` edge and the dynamic-tier candidate a list-driven judge would otherwise get from `nextSynthesizedRuntime`/`nextSameTierRuntime`
|
|
366
|
+
* @returns {{blocked: RouteError|null, nextRuntime: string, ruleIndex: number|undefined, revision: number, hop: number, backoffSec: number, backoffUntil: string, composed: boolean, tierExhaustion: TierExhaustion|null, judgeList?: import("../contract/index.mjs").JudgeListState}}
|
|
366
367
|
*/
|
|
367
|
-
export function planRoute(contract, node, state, role, error, current, schedule, now = Date.now(), exhaustedUntil = null) {
|
|
368
|
+
export function planRoute(contract, node, state, role, error, current, schedule, now = Date.now(), exhaustedUntil = null, options = {}) {
|
|
368
369
|
const revision = state.revisions ?? 0;
|
|
369
370
|
// Tier routing is scoped by generation, not revision: an invocation counts
|
|
370
371
|
// as attempted here only when it ran in the current tier-exhaustion
|
|
@@ -394,17 +395,33 @@ export function planRoute(contract, node, state, role, error, current, schedule,
|
|
|
394
395
|
// declared" — nextSynthesizedRuntime's own attempted-filter would otherwise
|
|
395
396
|
// make that distinction unreachable.
|
|
396
397
|
const declaredCandidate = synthesizedChain(contract, role, current)[0] ?? null;
|
|
397
|
-
|
|
398
|
-
|
|
398
|
+
// R18: once a list governs this hop (`options.judgeListState` is supplied),
|
|
399
|
+
// its precomputed pick *replaces* the declared `runtime.fallback` edge
|
|
400
|
+
// rather than following it -- the list is itself the ordering, and a list
|
|
401
|
+
// entry that happens to declare its own `fallback` must not hop there
|
|
402
|
+
// instead of the list's next eligible entry. Only when no list governs does
|
|
403
|
+
// the declared edge, then the dynamic-tier candidate, apply.
|
|
404
|
+
const judgeListState = options.judgeListState;
|
|
405
|
+
const listGoverned = judgeListState !== undefined;
|
|
406
|
+
const fallback = judgeListState !== undefined
|
|
407
|
+
? judgeListState.chosen
|
|
408
|
+
: nextSynthesizedRuntime(contract, role, current, attempted) ?? (dynamicComposed ? nextSameTierRuntime(contract, routing, role, current, attempted) : null);
|
|
399
409
|
const hop = nextHop(state, role, revision, schedule);
|
|
400
410
|
const nextRuntime = schedule.kind === "reset" ? current : fallback ?? current;
|
|
411
|
+
// A list-governed hop is bounded by the list's own length (instructions
|
|
412
|
+
// R18 §4), never the one-hop cap that applies to a declared or dynamic-tier
|
|
413
|
+
// fallback, so it is exempted from the hop-cap check below exactly as a
|
|
414
|
+
// dynamic-tier fallback already is.
|
|
415
|
+
const noHopCap = dynamicComposed || listGoverned;
|
|
401
416
|
const blocked = schedule.kind === "reset"
|
|
402
417
|
? null
|
|
403
418
|
: fallback === null
|
|
404
|
-
?
|
|
405
|
-
? { code: "
|
|
406
|
-
:
|
|
407
|
-
|
|
419
|
+
? listGoverned
|
|
420
|
+
? { code: "judge_list_exhausted", message: `no eligible entry remains in the judge list for ${role}` }
|
|
421
|
+
: declaredCandidate !== null && attempted.has(declaredCandidate)
|
|
422
|
+
? { code: "provider_failover_cycle", message: `runtime ${declaredCandidate} was already attempted in ${role} revision ${revision}` }
|
|
423
|
+
: { code: dynamicComposed ? "runtime_tier_exhausted" : error.code, message: dynamicComposed ? `no available runtime remains in tier ${String(contract.runtimes[current]?.tier ?? "unknown")} for ${role}` : error.message }
|
|
424
|
+
: hop > 1 && !noHopCap
|
|
408
425
|
? { code: "provider_failover_hop_cap", message: "provider failover exceeded the one-hop cap" }
|
|
409
426
|
: null;
|
|
410
427
|
const backoffSec = schedule.kind === "reset"
|
|
@@ -417,7 +434,7 @@ export function planRoute(contract, node, state, role, error, current, schedule,
|
|
|
417
434
|
const tierExhaustion = dynamicComposed
|
|
418
435
|
? upsertTierExhaustionCandidate(state.routing?.tierExhaustion, role, current, exhaustedUntil)
|
|
419
436
|
: null;
|
|
420
|
-
return { blocked, nextRuntime, ruleIndex: undefined, revision, hop, backoffSec, backoffUntil, composed: composedAssignment, tierExhaustion };
|
|
437
|
+
return { blocked, nextRuntime, ruleIndex: undefined, revision, hop, backoffSec, backoffUntil, composed: composedAssignment, tierExhaustion, judgeList: options.judgeListState };
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
/**
|
|
@@ -464,6 +481,10 @@ export function buildRouting(state, { role, error, current, plan, schedule, usag
|
|
|
464
481
|
history: [...(state.routing?.history ?? []), { ...shared, runtime: current, status, errorCode }].slice(-MAX_ROUTING_HISTORY),
|
|
465
482
|
currentOverride: override,
|
|
466
483
|
...(plan.tierExhaustion ? { tierExhaustion: plan.tierExhaustion } : {}),
|
|
484
|
+
// Unlike tierExhaustion, an untouched judgeList is not deleted above: a
|
|
485
|
+
// worker exhaustion round in the same call never resolved one, and the
|
|
486
|
+
// node's own list evidence must survive it unchanged.
|
|
487
|
+
...(plan.judgeList !== undefined ? { judgeList: plan.judgeList } : {}),
|
|
467
488
|
},
|
|
468
489
|
override,
|
|
469
490
|
errorCode,
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -37,7 +37,8 @@ import { mkdirSync, statSync } from "node:fs";
|
|
|
37
37
|
import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult, writesWorkspace } from "../harnesses/index.mjs";
|
|
38
38
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
39
39
|
import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
|
|
40
|
-
import {
|
|
40
|
+
import { isSameProviderReviewPair } from "../contract/judge-independence.mjs";
|
|
41
|
+
import { previousAttemptRuntimeId, routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
|
|
41
42
|
import { fingerprintRuntime, forceFreshSession, phaseInvocationPlan } from "./phase-session.mjs";
|
|
42
43
|
|
|
43
44
|
/** @typedef {import("./phase-session.mjs").SessionPolicy} SessionPolicy */
|
|
@@ -495,6 +496,22 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
495
496
|
/** @type {import("../contract/index.mjs").VerificationState|null} */ (state.verification),
|
|
496
497
|
);
|
|
497
498
|
state.review = reviewMode(node.gate);
|
|
499
|
+
// The judge candidate this attempt would actually use, and the worker
|
|
500
|
+
// runtime read off the durable invocation record rather than
|
|
501
|
+
// `state.runtime`: on the first judge dispatch of an attempt `state.runtime`
|
|
502
|
+
// still holds the worker, but `startJudge` overwrites it with the judge's
|
|
503
|
+
// own runtime below, and a bounded re-ask (review.mjs) or a judge-failure
|
|
504
|
+
// retry (settle-judge.mjs) then calls `startJudge` again with the judge
|
|
505
|
+
// already in `state.runtime` -- comparing against that would compare the
|
|
506
|
+
// judge to itself and always mark the node. The invocation record survives
|
|
507
|
+
// every such re-dispatch, so the mark reflects the pairing that ran,
|
|
508
|
+
// fallback included -- not `node.sameProviderReview`, which only says the
|
|
509
|
+
// pairing was admitted under same-vendor mode at contract validation,
|
|
510
|
+
// primary or fallback alike.
|
|
511
|
+
const judgeCandidate = routeRuntimeForState(contract, node, state, "judge");
|
|
512
|
+
const workerRuntimeId = previousAttemptRuntimeId(state, "worker");
|
|
513
|
+
const workerRuntime = workerRuntimeId ? contract.runtimes[workerRuntimeId] : state.runtime;
|
|
514
|
+
state.sameProviderReview = isSameProviderReviewPair(contract, workerRuntime, judgeCandidate);
|
|
498
515
|
if (verdict.verdict === "fail") return { kind: "rejected", verdict };
|
|
499
516
|
// `skipWhen` is checked before the ordinary judgment rule so a green-and-small
|
|
500
517
|
// change settles mechanically even when a Definition of Done item carries
|
|
@@ -502,7 +519,7 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
502
519
|
// and a gate whose review mode is `none` is skipped there exactly as before.
|
|
503
520
|
if (judgeSkippedByScope(node, state)) return { kind: "settle", gate: verdict };
|
|
504
521
|
if (!judgeRequired(node)) return { kind: "settle", gate: verdict };
|
|
505
|
-
const runtime =
|
|
522
|
+
const runtime = judgeCandidate;
|
|
506
523
|
const paths = logPaths(runDir, node.id, "judge", state.attempt);
|
|
507
524
|
state.phase = "judge";
|
|
508
525
|
state.runtime = runtime;
|