faberun 0.10.0 → 0.11.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/src/contract/task-packet.mjs +12 -3
- package/src/engine/dispatch.mjs +17 -0
- package/src/engine/lifecycle.mjs +31 -60
- package/src/engine/settle-judge.mjs +141 -0
- package/src/harnesses/protocol.mjs +46 -10
- package/src/host/preflight.mjs +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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": {
|
|
@@ -27,6 +27,15 @@ const PROMPT_MAX_BYTES = 64 * 1024;
|
|
|
27
27
|
*/
|
|
28
28
|
const VERIFICATION_PARAGRAPH = "The controller runs every command below after you report; its recorded results are the proof of this node. Running a command yourself is optional and only for one that finishes in seconds and spawns no long-lived process. Keep output bounded (pipe through `| tail -n 200`). Never wait on a background job, never run the whole test suite, and never run tests that start and terminate other processes.";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The `## Required output` schema every mode states: literally every key
|
|
32
|
+
* `validateWorkerResult` requires, so a worker never loses a valid result to
|
|
33
|
+
* a field this paragraph failed to name. What belongs in `artifacts` (if
|
|
34
|
+
* anything) is this packet's own content contract, stated in its own
|
|
35
|
+
* `instructions`, not repeated or guessed here.
|
|
36
|
+
*/
|
|
37
|
+
const REQUIRED_OUTPUT_SCHEMA = 'Return exactly one JSON object, with no markdown or prose, matching this shape: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. status is "done" when the node is complete or "blocked_context" when required context is missing; summary is prose for a human reader; verification and artifacts are string arrays, sent as [] when this node ran no command or has nothing to put there; missingContext lists exactly what is absent, non-empty only for blocked_context. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.';
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* `validateRelativePath`'s answer when a path is absent and the caller asked to
|
|
32
41
|
* defer the missing-path verdict rather than throw it. Only contract loading
|
|
@@ -128,7 +137,7 @@ export function renderWorkerPrompt(packet, nodeId) {
|
|
|
128
137
|
...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
|
|
129
138
|
"",
|
|
130
139
|
"## Required output",
|
|
131
|
-
|
|
140
|
+
REQUIRED_OUTPUT_SCHEMA,
|
|
132
141
|
];
|
|
133
142
|
const prompt = `${lines.join("\n")}\n`;
|
|
134
143
|
if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) {
|
|
@@ -432,7 +441,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
|
|
|
432
441
|
...bulletOrNone(packet.nonGoals),
|
|
433
442
|
"",
|
|
434
443
|
"## Required output",
|
|
435
|
-
|
|
444
|
+
`${REQUIRED_OUTPUT_SCHEMA} Put structured findings meant to inform this packet's own instructions in \`output\` instead (a JSON object, at most 65536 bytes).`,
|
|
436
445
|
"",
|
|
437
446
|
"## Verification",
|
|
438
447
|
VERIFICATION_PARAGRAPH,
|
|
@@ -481,7 +490,7 @@ function renderAutonomousPrompt(packet, nodeId) {
|
|
|
481
490
|
...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
|
|
482
491
|
"",
|
|
483
492
|
"## Required output",
|
|
484
|
-
|
|
493
|
+
REQUIRED_OUTPUT_SCHEMA,
|
|
485
494
|
];
|
|
486
495
|
const prompt = `${lines.join("\n")}\n`;
|
|
487
496
|
if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -668,6 +668,19 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
668
668
|
error.code = "judge_prompt_too_large";
|
|
669
669
|
throw error;
|
|
670
670
|
}
|
|
671
|
+
// Captured at the last possible instant before the judge can touch
|
|
672
|
+
// anything, so the settlement pass's comparison proves what the judge
|
|
673
|
+
// itself wrote rather than racing whatever ran just before dispatch. A
|
|
674
|
+
// capture failure must not block dispatch -- the write check is a
|
|
675
|
+
// controller invariant on top of whatever the judge does, not a
|
|
676
|
+
// precondition for running it -- so it degrades to unchecked instead of
|
|
677
|
+
// to a refusal.
|
|
678
|
+
let judgeBaseline;
|
|
679
|
+
try {
|
|
680
|
+
judgeBaseline = captureWorkspaceSnapshot(workspace);
|
|
681
|
+
} catch {
|
|
682
|
+
judgeBaseline = null;
|
|
683
|
+
}
|
|
671
684
|
const job = startProcess({
|
|
672
685
|
contract, node, state, runtime, workspace,
|
|
673
686
|
prompt: phasePlan.prompt,
|
|
@@ -678,6 +691,10 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
678
691
|
}),
|
|
679
692
|
onInvocation: (invocation, currentJob) => {
|
|
680
693
|
stampInvocation(invocation, contract, node, runtime, state, runDir, "judge", phasePlan.mode, phasePlan.continuationId);
|
|
694
|
+
// Reusing the worker phase's own scratch field: a job is never both a
|
|
695
|
+
// worker and a judge, and this field carries no persisted shape of
|
|
696
|
+
// its own that a judge borrowing it would have to match.
|
|
697
|
+
currentJob.scopeBaseline = judgeBaseline;
|
|
681
698
|
persistInvocation(runDir, state, invocation, currentJob, lock);
|
|
682
699
|
persistInvocationIntent(runDir, invocation, {
|
|
683
700
|
nodeId: node.id,
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -17,17 +17,8 @@ import {
|
|
|
17
17
|
SETTLED,
|
|
18
18
|
} from "./prompts.mjs";
|
|
19
19
|
import {
|
|
20
|
-
judgeReaskOutstanding,
|
|
21
|
-
} from "./judge-gate.mjs";
|
|
22
|
-
import {
|
|
23
|
-
judgeVerdictEvidence,
|
|
24
|
-
} from "../contract/review-modes.mjs";
|
|
25
|
-
import {
|
|
26
|
-
JUDGE_MAX_FAILURES,
|
|
27
|
-
applyJudgeProtocolFailure,
|
|
28
20
|
applyJudgeResult,
|
|
29
21
|
applyJudgeRound,
|
|
30
|
-
settleUnavailableJudge,
|
|
31
22
|
} from "./review.mjs";
|
|
32
23
|
import { routeRuntimeForState, routingBackoffActive, runtimeSnapshot } from "./failover.mjs";
|
|
33
24
|
import {
|
|
@@ -65,6 +56,7 @@ import { startJudge, startResultMaterialization } from "./dispatch.mjs";
|
|
|
65
56
|
import { raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
66
57
|
import { applyRejection, applyVerificationFailure } from "./settle.mjs";
|
|
67
58
|
import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
59
|
+
import { judgeWorkspaceWriteViolation, settleJudgeRound } from "./settle-judge.mjs";
|
|
68
60
|
|
|
69
61
|
/** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
|
|
70
62
|
/** @typedef {import("./backoff.mjs").Transition} Transition */
|
|
@@ -80,7 +72,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
|
80
72
|
/** @typedef {import("../contract/index.mjs").GateResult} GateResult */
|
|
81
73
|
/** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
|
|
82
74
|
/** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
|
|
83
|
-
/** @typedef {import("../repo/workspace.mjs").WorkspaceSnapshot} WorkspaceSnapshot */
|
|
84
75
|
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
85
76
|
/** @typedef {ReturnType<typeof acquireLock>} LockHandle */
|
|
86
77
|
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
@@ -90,7 +81,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
|
90
81
|
/** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
|
|
91
82
|
/** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
|
|
92
83
|
/** @typedef {import("../contract/verification.mjs").VerificationResult} VerificationResult */
|
|
93
|
-
/** @typedef {import("../repo/workspace.mjs").ScopeComparison} ScopeComparison */
|
|
94
84
|
/** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
|
|
95
85
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
96
86
|
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
@@ -277,7 +267,7 @@ export function autoRetryParkedNodes(contract, runDir, states, lock, previouslyP
|
|
|
277
267
|
*
|
|
278
268
|
* @param {NodeSnapshot} state
|
|
279
269
|
*/
|
|
280
|
-
function clearTierExhaustion(state) {
|
|
270
|
+
export function clearTierExhaustion(state) {
|
|
281
271
|
if (!state.routing || state.routing.tierExhaustion === undefined) return;
|
|
282
272
|
const routing = { ...state.routing };
|
|
283
273
|
delete routing.tierExhaustion;
|
|
@@ -393,6 +383,25 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
393
383
|
appendUsageRecord(runDir, state.invocations.find((invocation) => invocation.id === job.invocation.id));
|
|
394
384
|
state.usage = invocationUsage(state);
|
|
395
385
|
state.costUsd = invocationCost(state);
|
|
386
|
+
// Checked before any other branch can act on how this invocation closed --
|
|
387
|
+
// a done verdict, a provider failure, a bounded re-dispatch, or exhaustion
|
|
388
|
+
// -- so a judge that wrote into its own workspace is caught here rather
|
|
389
|
+
// than laundered through a re-dispatch whose fresh baseline would already
|
|
390
|
+
// contain the write (the whole point of TECH-SPEC's write check: blocked
|
|
391
|
+
// outright, never re-asked). A comparison that cannot even be completed --
|
|
392
|
+
// an edited ignore source, a symlink escaping the tree, too many entries --
|
|
393
|
+
// is the same violation as an ordinary write, never silence: the worker
|
|
394
|
+
// path (`engine/scope.mjs`'s `checkWorkerScope`) already fails closed on
|
|
395
|
+
// exactly the same throw.
|
|
396
|
+
if (job.phase === "judge") {
|
|
397
|
+
const violation = judgeWorkspaceWriteViolation(job);
|
|
398
|
+
if (violation) {
|
|
399
|
+
clearTierExhaustion(state);
|
|
400
|
+
transition(runDir, state, "blocked", { phase: "judge", result: state.result, usage: state.usage, error: { code: "judge_protocol", message: violation.message } }, lock);
|
|
401
|
+
await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
396
405
|
// A closed worker whose canonical result file is valid and whose scope
|
|
397
406
|
// passed is completed work, no matter what the provider envelope or the
|
|
398
407
|
// exit code said. The durable file was read above, before the scope gate,
|
|
@@ -425,55 +434,17 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
425
434
|
continue;
|
|
426
435
|
}
|
|
427
436
|
// A judge provider that failed outright (its turn died, its tool host was
|
|
428
|
-
// gone) is a provider failure, never a verdict
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// warmed, and spends none of the one re-dispatch counted below.
|
|
438
|
-
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
439
|
-
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
440
|
-
clearTierExhaustion(state);
|
|
441
|
-
// The provider died on the bounded re-ask itself, so the one permitted
|
|
442
|
-
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
443
|
-
// third judge invocation behind a fresh failure count.
|
|
444
|
-
// The provider died on the bounded re-ask itself, so the one permitted
|
|
445
|
-
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
446
|
-
// third judge invocation behind a fresh failure count.
|
|
447
|
-
if (judgeReaskOutstanding(state)) {
|
|
448
|
-
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
451
|
-
state.judgeFailures = (state.judgeFailures ?? 0) + 1;
|
|
452
|
-
if (state.judgeFailures < JUDGE_MAX_FAILURES) {
|
|
453
|
-
writeNode(runDir, state, lock);
|
|
454
|
-
await applyJudgeRound(await startJudge(contract, job.node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
455
|
-
contract, job.node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
await settleUnavailableJudge(contract, job.node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
459
|
-
continue;
|
|
460
|
-
} // Whatever else this invocation produced, it is not exactly one usable
|
|
461
|
-
// verdict: no verdict at all, several of them in separate agent messages,
|
|
462
|
-
// an unparseable one, a stream cut off before its terminal envelope, or a
|
|
463
|
-
// phase killed on its wall clock. One bounded re-ask, then the review mode
|
|
464
|
-
// decides — advisory completes, blocking enters attention with the work
|
|
465
|
-
// preserved so a retry in place can re-judge it.
|
|
437
|
+
// gone) is a provider failure, never a verdict, and a verdict that arrived
|
|
438
|
+
// but is not exactly one usable one (none at all, several of them, an
|
|
439
|
+
// unparseable one, a stream cut off before its terminal envelope, or a
|
|
440
|
+
// phase killed on its wall clock) is a protocol defect rather than a
|
|
441
|
+
// pass. Both are settled by `settleJudgeRound`, moved out of this file
|
|
442
|
+
// for the same reason `engine/settle.mjs` was: this file and
|
|
443
|
+
// `test/engine/judge.test.mjs` both sit on the 800-line ceiling
|
|
444
|
+
// `test/repo/source-shape.test.mjs` enforces. The write check above has
|
|
445
|
+
// already run, so nothing here can adopt or launder a judge's own write.
|
|
466
446
|
if (job.phase === "judge") {
|
|
467
|
-
|
|
468
|
-
if (!evidence.ok) {
|
|
469
|
-
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
470
|
-
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
471
|
-
clearTierExhaustion(state);
|
|
472
|
-
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, evidence.reason);
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
clearTierExhaustion(state);
|
|
476
|
-
await applyJudgeResult(contract, job.node, state, evidence.result, runDir, lock, running, states, campaignPath);
|
|
447
|
+
await settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion });
|
|
477
448
|
continue;
|
|
478
449
|
}
|
|
479
450
|
// An empty final message is a missing worker result, not a no-op worker:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge branch of `finalizeClosedJobs`, and the workspace comparison its
|
|
3
|
+
* fail-closed write check depends on. Split out of `lifecycle.mjs` for the
|
|
4
|
+
* same reason `engine/settle.mjs` was: `lifecycle.mjs` and
|
|
5
|
+
* `test/engine/judge.test.mjs` both sit on the 800-line ceiling
|
|
6
|
+
* `test/repo/source-shape.test.mjs` enforces.
|
|
7
|
+
*
|
|
8
|
+
* `settleJudgeRound` runs only after `lifecycle.mjs` has already called
|
|
9
|
+
* `judgeWorkspaceWriteViolation` and found nothing: the check has to run
|
|
10
|
+
* before any branch here (a failed provider, a bounded re-dispatch, or a
|
|
11
|
+
* verdict) can adopt or launder a judge's own write, so it cannot live inside
|
|
12
|
+
* this function without reintroducing the escape it closes.
|
|
13
|
+
*
|
|
14
|
+
* `clearTierExhaustion` and `handleProviderExhaustion` are `lifecycle.mjs`'s
|
|
15
|
+
* own -- importing them back from there would recreate the exact
|
|
16
|
+
* `lifecycle.mjs` <-> `review.mjs` cycle `test/repo/source-shape.test.mjs`
|
|
17
|
+
* once caught and `AGENTS.md` records as fixed, so the caller passes them in
|
|
18
|
+
* instead.
|
|
19
|
+
*/
|
|
20
|
+
import { judgeReaskOutstanding } from "./judge-gate.mjs";
|
|
21
|
+
import { judgeVerdictEvidence } from "../contract/review-modes.mjs";
|
|
22
|
+
import {
|
|
23
|
+
JUDGE_MAX_FAILURES,
|
|
24
|
+
applyJudgeProtocolFailure,
|
|
25
|
+
applyJudgeResult,
|
|
26
|
+
applyJudgeRound,
|
|
27
|
+
settleUnavailableJudge,
|
|
28
|
+
} from "./review.mjs";
|
|
29
|
+
import { networkTransition } from "./backoff.mjs";
|
|
30
|
+
import { startJudge } from "./dispatch.mjs";
|
|
31
|
+
import { writeNode } from "./state.mjs";
|
|
32
|
+
import { compareWorkspaceSnapshot } from "../repo/workspace.mjs";
|
|
33
|
+
import { errorCode, errorMessage, excerpt } from "../util.mjs";
|
|
34
|
+
|
|
35
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
36
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
37
|
+
/** @typedef {import("./process.mjs").Job} Job */
|
|
38
|
+
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
39
|
+
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
40
|
+
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
41
|
+
/** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a closed judge job wrote into its own workspace, or a workspace
|
|
45
|
+
* comparison error that means the same thing: either is a violation, never
|
|
46
|
+
* silence. `compareWorkspaceSnapshot` throws `snapshot_ignore_changed` when
|
|
47
|
+
* the judge edits an ignore source, `snapshot_symlink_escape` when it creates
|
|
48
|
+
* a symlink out of the tree, and `snapshot_too_large` when it adds enough
|
|
49
|
+
* entries -- all ordinary judge writes that must fail closed exactly like the
|
|
50
|
+
* worker path (`engine/scope.mjs`'s `checkWorkerScope`), never vanish into a
|
|
51
|
+
* "no writes" result. `job.scopeBaseline` is null only when the capture at
|
|
52
|
+
* dispatch time itself failed -- `startJudge` degrades to unchecked rather
|
|
53
|
+
* than refuse to run the judge at all -- and that is the one case with
|
|
54
|
+
* nothing to compare, not a violation.
|
|
55
|
+
*
|
|
56
|
+
* @param {Job} job
|
|
57
|
+
* @returns {{message: string}|null}
|
|
58
|
+
*/
|
|
59
|
+
export function judgeWorkspaceWriteViolation(job) {
|
|
60
|
+
const baseline = job.scopeBaseline;
|
|
61
|
+
if (!baseline) return null;
|
|
62
|
+
try {
|
|
63
|
+
const { unexpectedPaths } = compareWorkspaceSnapshot(/** @type {import("../repo/workspace.mjs").WorkspaceSnapshot} */ (baseline), job.cwd);
|
|
64
|
+
if (!unexpectedPaths.length) return null;
|
|
65
|
+
return { message: excerpt(`judge wrote into its own workspace (${unexpectedPaths.length}): ${unexpectedPaths.slice(0, 8).join(", ")}`) ?? "judge wrote into its own workspace" };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { message: excerpt(`judge workspace comparison failed (${errorCode(error) ?? "scope_snapshot_invalid"}): ${errorMessage(error)}`) ?? "judge workspace comparison failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Settle a closed judge invocation whose write check already passed: a
|
|
73
|
+
* provider that failed outright gets one bounded re-dispatch, then review-mode
|
|
74
|
+
* settlement; anything else that is not exactly one usable verdict (none at
|
|
75
|
+
* all, several of them, an unparseable one, a stream cut off before its
|
|
76
|
+
* terminal envelope, or a phase killed on its wall clock) takes the same
|
|
77
|
+
* bounded re-ask; a clean verdict is applied.
|
|
78
|
+
*
|
|
79
|
+
* @param {ValidatedContract} contract
|
|
80
|
+
* @param {Job} job
|
|
81
|
+
* @param {NodeSnapshot} state
|
|
82
|
+
* @param {string} runDir
|
|
83
|
+
* @param {Map<string, Job>} running
|
|
84
|
+
* @param {LockHandle} lock
|
|
85
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
86
|
+
* @param {string} campaignPath
|
|
87
|
+
* @param {PricedEnvelope} envelope
|
|
88
|
+
* @param {{clearTierExhaustion: (state: NodeSnapshot) => void, handleProviderExhaustion: typeof import("./lifecycle.mjs").handleProviderExhaustion}} hooks
|
|
89
|
+
* `lifecycle.mjs`'s own tier-exhaustion clear and provider-exhaustion router, passed in rather than imported back.
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
export async function settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion }) {
|
|
93
|
+
const node = job.node;
|
|
94
|
+
// A judge provider that failed outright (its turn died, its tool host was
|
|
95
|
+
// gone) is a provider failure, never a verdict: the gate cannot adopt a
|
|
96
|
+
// result the judge could not ground in inspection. Re-dispatch the judge
|
|
97
|
+
// once on the same routing, then settle by review mode so a judge failure
|
|
98
|
+
// is surfaced, never silently settled. A stream that never reached its
|
|
99
|
+
// terminal envelope is a protocol defect instead and takes the bounded
|
|
100
|
+
// re-ask below.
|
|
101
|
+
if (envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
|
|
102
|
+
// A judge that lost its socket is not an unavailable judge. It buys the
|
|
103
|
+
// same bounded network waits a worker does, on the runtime it already
|
|
104
|
+
// warmed, and spends none of the one re-dispatch counted below.
|
|
105
|
+
const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
|
|
106
|
+
if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
|
|
107
|
+
clearTierExhaustion(state);
|
|
108
|
+
// The provider died on the bounded re-ask itself, so the one permitted
|
|
109
|
+
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
110
|
+
// third judge invocation behind a fresh failure count.
|
|
111
|
+
if (judgeReaskOutstanding(state)) {
|
|
112
|
+
await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
state.judgeFailures = (state.judgeFailures ?? 0) + 1;
|
|
116
|
+
if (state.judgeFailures < JUDGE_MAX_FAILURES) {
|
|
117
|
+
writeNode(runDir, state, lock);
|
|
118
|
+
await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
119
|
+
contract, node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await settleUnavailableJudge(contract, node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Whatever else this invocation produced, it is not exactly one usable
|
|
126
|
+
// verdict: no verdict at all, several of them in separate agent messages,
|
|
127
|
+
// an unparseable one, a stream cut off before its terminal envelope, or a
|
|
128
|
+
// phase killed on its wall clock. One bounded re-ask, then the review mode
|
|
129
|
+
// decides — advisory completes, blocking enters attention with the work
|
|
130
|
+
// preserved so a retry in place can re-judge it.
|
|
131
|
+
const evidence = judgeVerdictEvidence(envelope);
|
|
132
|
+
if (!evidence.ok) {
|
|
133
|
+
const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
|
|
134
|
+
if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
|
|
135
|
+
clearTierExhaustion(state);
|
|
136
|
+
await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, evidence.reason);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
clearTierExhaustion(state);
|
|
140
|
+
await applyJudgeResult(contract, node, state, evidence.result, runDir, lock, running, states, campaignPath);
|
|
141
|
+
}
|
|
@@ -498,6 +498,50 @@ export function isVerdictCandidate(text) {
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* A judge's final message can carry its whole verdict on one line with no
|
|
503
|
+
* newline before the JSON: `Confirmed. Now write the verdict JSON.{"verdict":
|
|
504
|
+
* "fail", ...}`, measured 2026-09-17 at 5662 characters. A line-boundary-only
|
|
505
|
+
* suffix scan never finds it, extractJson returns null, isVerdictCandidate
|
|
506
|
+
* returns false, and a correct, evidenced verdict is discarded as
|
|
507
|
+
* `judge_unavailable`. The candidate starts tried here are every `{` and `[`
|
|
508
|
+
* offset, not just line starts, so a JSON object glued directly to prose is
|
|
509
|
+
* still reachable.
|
|
510
|
+
*/
|
|
511
|
+
const SUFFIX_CANDIDATE_LIMIT = 200;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The last parseable suffix of `trimmed`, trying every `{`/`[` offset from the
|
|
515
|
+
* end backwards. Bounded to the last `SUFFIX_CANDIDATE_LIMIT` offsets: without
|
|
516
|
+
* a cap this is one `JSON.parse` per bracket character, quadratic on a
|
|
517
|
+
* pathological message built mostly of brackets. 200 is comfortably above the
|
|
518
|
+
* bracket count of any real verdict object (the measured 5662-character
|
|
519
|
+
* message resolves on one of its first few tries, since its outermost `{` is
|
|
520
|
+
* also its first) and keeps every call to at most 200 parses regardless of
|
|
521
|
+
* message length.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} trimmed
|
|
524
|
+
* @returns {string|null}
|
|
525
|
+
*/
|
|
526
|
+
function suffixJsonCandidate(trimmed) {
|
|
527
|
+
const offsets = [];
|
|
528
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
529
|
+
const char = trimmed[index];
|
|
530
|
+
if (char === "{" || char === "[") offsets.push(index);
|
|
531
|
+
}
|
|
532
|
+
const floor = Math.max(0, offsets.length - SUFFIX_CANDIDATE_LIMIT);
|
|
533
|
+
for (let index = offsets.length - 1; index >= floor; index -= 1) {
|
|
534
|
+
const candidate = trimmed.slice(offsets[index]);
|
|
535
|
+
try {
|
|
536
|
+
JSON.parse(candidate);
|
|
537
|
+
return candidate;
|
|
538
|
+
} catch {
|
|
539
|
+
// This suffix is not JSON; keep trying earlier bracket offsets.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
501
545
|
/**
|
|
502
546
|
* Extract a JSON value from a provider response that may carry prose, taking
|
|
503
547
|
* the last parseable suffix or fenced JSON block.
|
|
@@ -514,16 +558,8 @@ export function extractJson(value) {
|
|
|
514
558
|
} catch {
|
|
515
559
|
// Not JSON as a whole: fall through to the suffix and fenced-block scans below.
|
|
516
560
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
const candidate = lines.slice(index).join("\n").trim();
|
|
520
|
-
try {
|
|
521
|
-
JSON.parse(candidate);
|
|
522
|
-
return candidate;
|
|
523
|
-
} catch {
|
|
524
|
-
// This suffix is not JSON; keep trying earlier line boundaries.
|
|
525
|
-
}
|
|
526
|
-
}
|
|
561
|
+
const suffix = suffixJsonCandidate(trimmed);
|
|
562
|
+
if (suffix !== null) return suffix;
|
|
527
563
|
const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
|
|
528
564
|
for (const block of blocks.reverse()) {
|
|
529
565
|
const candidate = block[1].trim();
|
package/src/host/preflight.mjs
CHANGED
|
@@ -254,6 +254,22 @@ export function declaredVerificationCommands(contract) {
|
|
|
254
254
|
return [...commands.values()];
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Environment names removed before spawning a timing candidate. The probe
|
|
259
|
+
* only measures how long a command takes; a subtraction of a named few, not
|
|
260
|
+
* an allowlist, so PATH, HOME and every ordinary variable the command needs
|
|
261
|
+
* to run at all still pass through unchanged.
|
|
262
|
+
*/
|
|
263
|
+
const SIDE_EFFECT_ENV_KEYS = [
|
|
264
|
+
NOTIFY_BIN_ENV, // a measurement must not notify a human
|
|
265
|
+
"FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
|
|
266
|
+
"FABERUN_CLAUDE_BIN", // same, for the claude harness
|
|
267
|
+
"FABERUN_AGY_BIN", // same, for the agy harness
|
|
268
|
+
"FABERUN_DSH_BIN", // same, for the dsh harness
|
|
269
|
+
"FABERUN_ZCODE_BIN", // same, for the zcode harness
|
|
270
|
+
"FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
|
|
271
|
+
];
|
|
272
|
+
|
|
257
273
|
/**
|
|
258
274
|
* Run every declared verification command once and report what it actually
|
|
259
275
|
* costs against the timeout the contract gives it.
|
|
@@ -275,6 +291,8 @@ export function declaredVerificationCommands(contract) {
|
|
|
275
291
|
export function timeVerificationCommands(contract, probes = {}) {
|
|
276
292
|
const now = probes.now ?? (() => Date.now());
|
|
277
293
|
const run = probes.run ?? spawnSync;
|
|
294
|
+
const env = { ...process.env };
|
|
295
|
+
for (const key of SIDE_EFFECT_ENV_KEYS) delete env[key];
|
|
278
296
|
return declaredVerificationCommands(contract).map((command) => {
|
|
279
297
|
const label = command.argv.join(" ");
|
|
280
298
|
const name = `verification timing · ${label}`;
|
|
@@ -288,6 +306,7 @@ export function timeVerificationCommands(contract, probes = {}) {
|
|
|
288
306
|
timeout: ceilingSec * 1_000,
|
|
289
307
|
stdio: "ignore",
|
|
290
308
|
encoding: "utf8",
|
|
309
|
+
env,
|
|
291
310
|
});
|
|
292
311
|
const seconds = (now() - startedAt) / 1_000;
|
|
293
312
|
const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
|