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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.10.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
- 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
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
- 'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository. Put structured findings meant to inform that packet in `output` (a JSON object, at most 65536 bytes); prose belongs in `summary`.',
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
- 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
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`);
@@ -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,
@@ -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: the gate cannot adopt a
429
- // result the judge could not ground in inspection. Re-dispatch the judge
430
- // once on the same routing, then settle by review mode so a judge failure
431
- // is surfaced, never silently settled. A stream that never reached its
432
- // terminal envelope is a protocol defect instead and takes the bounded
433
- // re-ask below.
434
- if (job.phase === "judge" && envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
435
- // A judge that lost its socket is not an unavailable judge. It buys the
436
- // same bounded network waits a worker does, on the runtime it already
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
- const evidence = judgeVerdictEvidence(envelope);
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 lines = trimmed.split(/\r?\n/u);
518
- for (let index = lines.length - 1; index > 0; index -= 1) {
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();
@@ -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`;