bare-agent 0.30.0 → 0.32.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/README.md +2 -2
- package/bareagent.context.md +11 -2
- package/package.json +1 -1
- package/src/evaluator.d.ts +3 -0
- package/src/evaluator.js +27 -1
- package/src/provider-clipipe-tools.d.ts +121 -0
- package/src/provider-clipipe-tools.js +271 -0
- package/src/provider-clipipe.d.ts +53 -2
- package/src/provider-clipipe.js +112 -20
- package/src/recurse.d.ts +76 -4
- package/src/recurse.js +392 -36
package/src/recurse.js
CHANGED
|
@@ -238,6 +238,13 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
238
238
|
* NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
|
|
239
239
|
* editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
|
|
240
240
|
* got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
|
|
241
|
+
* **Broken sensor ≠ failing model (BA-15):** a sensor that THROWS (non-Halt) or returns a MALFORMED verdict
|
|
242
|
+
* (anything but `{pass: boolean}` or a valid tri-state `status`) is a faulty ARBITER — the loop stops at the
|
|
243
|
+
* FIRST broken close (never retries against it) and returns a labeled `{incomplete, blocker:'broken-sensor'}`
|
|
244
|
+
* (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
|
|
245
|
+
* sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
|
|
246
|
+
* model-generated checks in an isolated child process WITH A TIMEOUT — a sensor that hangs forever hangs the
|
|
247
|
+
* leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
|
|
241
248
|
* **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
|
|
242
249
|
* surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
|
|
243
250
|
* STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
|
|
@@ -306,8 +313,17 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
306
313
|
* @property {Verdict|null} verdict
|
|
307
314
|
* @property {boolean} incomplete
|
|
308
315
|
* @property {boolean} halted
|
|
309
|
-
* @property {string} [blocker] -
|
|
310
|
-
* short-circuited a consecutive-policy-deny
|
|
316
|
+
* @property {string} [blocker] - Set when this node stopped for a specific non-model reason (mirrors
|
|
317
|
+
* `RecurseResult.blocker`): `'governance-deny'` (BA-11) — its Loop short-circuited a consecutive-policy-deny
|
|
318
|
+
* spin; `'broken-sensor'` (BA-15) — the caller's `refineLeaf.sensor` threw or returned a malformed verdict;
|
|
319
|
+
* `'broken-verifier'` (BA-15) — the caller's `opts.evaluate` did (the default Evaluator path is never labeled).
|
|
320
|
+
* @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did (threw with
|
|
321
|
+
* which message, or which malformed shape it returned) — the actionable half of the label.
|
|
322
|
+
* @property {{blocker: string, blockerDetail?: string, blockerTask?: string}} [blockerFrom] - (BA-15) a
|
|
323
|
+
* DESCENDANT's blocker, surfaced here so an aggregating node still reports the fault upward. Deliberately
|
|
324
|
+
* SEPARATE from this node's own `blocker` (which means "THIS node's arbiter/Loop broke"): stamping a
|
|
325
|
+
* descendant's label onto every ancestor made the receipts tree accuse nodes whose sensor never ran, and
|
|
326
|
+
* re-labelled a parent `governance-deny` when only one child was denied. `blockerTask` names the culprit.
|
|
311
327
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
312
328
|
* @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
|
|
313
329
|
* this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
|
|
@@ -341,6 +357,18 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
341
357
|
* @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
342
358
|
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
343
359
|
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
360
|
+
* `'broken-sensor'` (BA-15): the caller's `refineLeaf.sensor` threw or returned a malformed verdict — the
|
|
361
|
+
* ARBITER is faulty, not the model; fix the sensor and re-run (`receipts.blockerDetail` says what it did).
|
|
362
|
+
* `'broken-verifier'` (BA-15): same fault class at the verify slot — the caller's `opts.evaluate` threw
|
|
363
|
+
* (non-Halt) or returned a malformed verdict; the default Evaluator path is never labeled (its failures are
|
|
364
|
+
* provider-class faults). For both `broken-*` blockers `best` preserves the model's last non-empty output
|
|
365
|
+
* (BA-5) — the arbiter broke, so treat it as best-effort work rather than a graded pass.
|
|
366
|
+
* @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did — the
|
|
367
|
+
* ACTIONABLE half of the label, surfaced on the result (not only in `receipts`) so a caller branching on
|
|
368
|
+
* `blocker` can report the cause without walking the receipts tree.
|
|
369
|
+
* @property {string} [blockerTask] - (BA-15) when the blocker was INHERITED from a descendant in a nested run:
|
|
370
|
+
* which sub-task actually broke. Without it a nested failure reports "a sensor broke" with no way to find
|
|
371
|
+
* which one.
|
|
344
372
|
* @property {RecurseNode} receipts - The audit node for this call (RC-10).
|
|
345
373
|
*/
|
|
346
374
|
|
|
@@ -586,16 +614,16 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
586
614
|
const missingSlices = node.spawned.filter(c => c.incomplete).map(c => c.task);
|
|
587
615
|
if (missingSlices.length > 0) {
|
|
588
616
|
node.incomplete = true;
|
|
589
|
-
|
|
617
|
+
// BA-15: carry a child's blocker up, so a nested broken sensor still names itself at the top.
|
|
618
|
+
return incompleteWithBlocker(node, result, { missingSlices });
|
|
590
619
|
}
|
|
591
620
|
|
|
592
621
|
// Verify: a SEPARATE-context judge, never the generator grading itself. Runs when a contract is given, the
|
|
593
622
|
// caller supplied a verifier, OR the task is critical (the forced-verify safety rail).
|
|
594
623
|
const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
|
|
595
624
|
if (wantVerify) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
return { result, verdict, receipts: node };
|
|
625
|
+
// `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
|
|
626
|
+
return await verifyOrBlock(task, result, ctx, opts, node);
|
|
599
627
|
}
|
|
600
628
|
|
|
601
629
|
return { result, verdict: null, receipts: node };
|
|
@@ -603,12 +631,221 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
603
631
|
if (err instanceof HaltError) {
|
|
604
632
|
node.halted = true;
|
|
605
633
|
node.incomplete = true;
|
|
606
|
-
|
|
634
|
+
// BA-15: a halt AFTER the children ran (e.g. mid-synthesize) must still name a child's broken sensor —
|
|
635
|
+
// otherwise the caller reads a governance problem where their own arbiter crashed.
|
|
636
|
+
return incompleteWithBlocker(node, result);
|
|
607
637
|
}
|
|
608
638
|
throw err;
|
|
609
639
|
}
|
|
610
640
|
}
|
|
611
641
|
|
|
642
|
+
/** Valid tri-state `Verdict.status` values a caller arbiter (sensor/verifier) may return in lieu of a boolean `pass`. */
|
|
643
|
+
const SENSOR_STATUS = new Set(['satisfied', 'needs_revision', 'failed']);
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* BA-15 — a TYPED signal that a caller arbiter (the `refineLeaf.sensor` or the `opts.evaluate` verifier)
|
|
647
|
+
* broke (threw non-Halt, or returned a malformed verdict). Classified by `instanceof` + `.tag`, NOT by
|
|
648
|
+
* re-parsing an `Error.message` prefix — so an intermediate layer that rewords the message (the exact class
|
|
649
|
+
* of the prior loop.js HaltError-wrapping bug) or a Loop/provider error whose text happens to begin
|
|
650
|
+
* `broken-sensor: ` can never be mis-labeled. Module-local: it is always thrown AND caught inside recurse.js
|
|
651
|
+
* (never propagates to a caller), so it needs no `errors.js` entry or public export.
|
|
652
|
+
*/
|
|
653
|
+
class BrokenArbiterError extends Error {
|
|
654
|
+
/**
|
|
655
|
+
* @param {'broken-sensor'|'broken-verifier'} tag - which seam broke.
|
|
656
|
+
* @param {string} detail - what the arbiter did (surfaced as `receipts.blockerDetail`).
|
|
657
|
+
* @param {{cause?: any}} [options] - `cause` preserves the original error (stack/type) for debugging.
|
|
658
|
+
*/
|
|
659
|
+
constructor(tag, detail, options = {}) {
|
|
660
|
+
super(`${tag}: ${detail}`, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
661
|
+
this.name = 'BrokenArbiterError';
|
|
662
|
+
this.tag = tag;
|
|
663
|
+
this.detail = detail;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* BA-15 — validate a caller arbiter's return at a close seam (the `refineLeaf.sensor` AND the caller
|
|
669
|
+
* `opts.evaluate` verifier). A verdict is well-formed iff it is an object carrying a boolean `pass` OR a
|
|
670
|
+
* valid tri-state `status` (the two shapes `refine`/callers branch on). Returns `null` when well-formed,
|
|
671
|
+
* else a short description of the malformation ("named, never coerced" — a garbage verdict otherwise reads
|
|
672
|
+
* as pass:false with critique:null at the sensor seam, or rides a converged-shaped return at the verify slot).
|
|
673
|
+
* @param {any} v
|
|
674
|
+
* @returns {string|null}
|
|
675
|
+
*/
|
|
676
|
+
function verdictShapeFault(v) {
|
|
677
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
678
|
+
return `returned ${v === null ? 'null' : Array.isArray(v) ? 'an array' : `a ${typeof v}`}`;
|
|
679
|
+
}
|
|
680
|
+
// A PRESENT `pass` counts whatever its type (`1`/`0`/`'yes'` are a long-standing yes-no convention, and
|
|
681
|
+
// `refine` has always branched on its TRUTHINESS). BA-15 exists to catch a verdict carrying NO usable
|
|
682
|
+
// signal — not to reject one that answers clearly in a different dialect: demanding a strict boolean
|
|
683
|
+
// silently flipped previously-CONVERGING adopter sensors to a permanent first-attempt block.
|
|
684
|
+
if (v.pass != null || SENSOR_STATUS.has(v.status)) return null;
|
|
685
|
+
const keys = Object.keys(v).slice(0, 5).join(', ');
|
|
686
|
+
return `returned an object with neither a usable \`pass\` nor a valid \`status\` (keys: ${keys || 'none'})`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Upper bound on a `blockerDetail` fragment — it rides into receipts and, via a wired gate, onto disk. */
|
|
690
|
+
const DETAIL_MAX = 200;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* BA-15 — a readable one-liner for ANYTHING thrown by a caller arbiter. `String(err)` on a non-Error throw
|
|
694
|
+
* (a test harness rejecting with a raw `{code:'ENOENT', path}` result is the common case) yields the useless
|
|
695
|
+
* `[object Object]`, defeating `blockerDetail`'s entire purpose: naming what broke so the operator can fix it.
|
|
696
|
+
* @param {any} err
|
|
697
|
+
* @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
function describeThrown(err) {
|
|
700
|
+
const clamp = (s) => (s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}… (truncated)` : s);
|
|
701
|
+
if (err instanceof Error && typeof err.message === 'string' && err.message) return clamp(err.message);
|
|
702
|
+
if (err === null || typeof err !== 'object') return clamp(String(err));
|
|
703
|
+
// Deliberately NOT a whole-object dump. A thrown non-Error is typically a spawn/exec RESULT, which routinely
|
|
704
|
+
// carries a full stdout buffer and an env snapshot — and `blockerDetail` rides into `receipts`, which a wired
|
|
705
|
+
// gate serializes VERBATIM into a plaintext audit log (the F16/BA-1 lesson: never let caller data of unknown
|
|
706
|
+
// shape reach the audit unfiltered). Take only the conventional diagnostic fields, clamped.
|
|
707
|
+
try {
|
|
708
|
+
const picked = ['name', 'code', 'errno', 'syscall', 'path', 'status', 'signal', 'message']
|
|
709
|
+
.filter(k => typeof err[k] === 'string' || typeof err[k] === 'number')
|
|
710
|
+
.map(k => `${k}=${String(err[k])}`)
|
|
711
|
+
.join(' ');
|
|
712
|
+
if (picked) return clamp(picked);
|
|
713
|
+
} catch { /* a throwing accessor / Proxy trap — fall through to the type tag */ }
|
|
714
|
+
return Object.prototype.toString.call(err);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* BA-15 — run a caller arbiter (the `refineLeaf.sensor` or the `opts.evaluate` verifier) and NAME a broken one
|
|
719
|
+
* rather than let it launder. A non-`HaltError` throw or a malformed return (per {@link verdictShapeFault}) is
|
|
720
|
+
* re-thrown as a typed {@link BrokenArbiterError} the call sites classify by `instanceof` + `.tag` (NEVER by
|
|
721
|
+
* re-parsing the message text); a `HaltError` passes straight through (clean
|
|
722
|
+
* governance exit, BA-2). One helper so the sensor and verifier seams stay byte-identical (same guidance
|
|
723
|
+
* string, same fault taxonomy) — a divergence between them was the copy-paste risk this replaces.
|
|
724
|
+
* A verdict that carries a valid `status` but no boolean `pass` is NORMALIZED to add `pass = status ===
|
|
725
|
+
* 'satisfied'` (the same derivation as `evaluator.js`): the advertised `{status}` contract must actually
|
|
726
|
+
* WORK — `refine.js` stops the leaf on `verdict.pass`, so a bare `{status:'satisfied'}` would otherwise never
|
|
727
|
+
* satisfy, burn every iteration, and report `passed:false` (a satisfied close mislabeled as non-recovery).
|
|
728
|
+
* @template {{pass?: boolean, status?: string}} T
|
|
729
|
+
* @param {'broken-sensor'|'broken-verifier'} tag - the fault channel, carried on the thrown error's `.tag`.
|
|
730
|
+
* @param {() => (T | Promise<T>)} call - invokes the arbiter (already bound to its result/ctx args).
|
|
731
|
+
* @returns {Promise<T>} the arbiter's well-formed verdict (with `pass` derived from `status` when absent).
|
|
732
|
+
*/
|
|
733
|
+
async function runArbiter(tag, call) {
|
|
734
|
+
const who = tag === 'broken-sensor' ? 'sensor' : 'evaluate';
|
|
735
|
+
const shape = `a ${who === 'sensor' ? 'sensor' : 'verifier'} must return {pass} or {status: 'satisfied'|'needs_revision'|'failed'}`;
|
|
736
|
+
/** A throw from the arbiter's own body (sync or async rejection) — "the sensor threw". */
|
|
737
|
+
const threw = (err) => {
|
|
738
|
+
if (err instanceof HaltError || err instanceof BrokenArbiterError) return err;
|
|
739
|
+
// `cause` keeps the original error (stack + type) reachable for debugging; `detail` stays the short label.
|
|
740
|
+
return new BrokenArbiterError(tag, `${who} threw: ${describeThrown(err)}`, { cause: err });
|
|
741
|
+
};
|
|
742
|
+
/** A throw from READING the value the arbiter returned — "the verdict is unreadable", a different fault. */
|
|
743
|
+
const unreadable = (err) => {
|
|
744
|
+
if (err instanceof HaltError || err instanceof BrokenArbiterError) return err;
|
|
745
|
+
return new BrokenArbiterError(tag, `${who} returned a verdict whose properties could not be read: ${describeThrown(err)} — ${shape}`, { cause: err });
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// Typed `any` deliberately: this block probes an UNTRUSTED caller return (it may be a promise, a plain
|
|
749
|
+
// verdict, or a hostile Proxy), so the generic `T | Promise<T>` narrowing does not apply until it is settled.
|
|
750
|
+
/** @type {any} */
|
|
751
|
+
let raw;
|
|
752
|
+
try {
|
|
753
|
+
raw = call();
|
|
754
|
+
} catch (err) { throw threw(err); }
|
|
755
|
+
|
|
756
|
+
// `await`ing directly would conflate two different faults: the await PROBES `.then` on the returned value,
|
|
757
|
+
// so an accessor-backed/Proxy verdict throws during the await and gets reported as "the sensor threw" —
|
|
758
|
+
// sending the operator hunting a `throw` in a sensor that returned perfectly normally. Probe the thenable
|
|
759
|
+
// separately (a throw HERE is the returned value being unreadable) and only then await (a rejection THERE
|
|
760
|
+
// is genuinely the arbiter's body failing).
|
|
761
|
+
let thenable;
|
|
762
|
+
try {
|
|
763
|
+
thenable = raw !== null && (typeof raw === 'object' || typeof raw === 'function') && typeof raw.then === 'function';
|
|
764
|
+
} catch (err) { throw unreadable(err); }
|
|
765
|
+
|
|
766
|
+
/** @type {any} */
|
|
767
|
+
let v = raw;
|
|
768
|
+
if (thenable) {
|
|
769
|
+
try {
|
|
770
|
+
v = await raw;
|
|
771
|
+
} catch (err) { throw threw(err); }
|
|
772
|
+
}
|
|
773
|
+
// The SHAPE INSPECTION is guarded SEPARATELY: reading `.status`/`.pass`/`Object.keys` on a returned Proxy or
|
|
774
|
+
// accessor-backed object can itself throw. Unguarded that escapes untyped (the very uncaught crash this seam
|
|
775
|
+
// prevents, one step later) — but folding it into the call's own catch is also wrong: it reports "the sensor
|
|
776
|
+
// threw" for a sensor that RETURNED NORMALLY, sending the operator hunting a `throw` that does not exist.
|
|
777
|
+
// The fault is in the returned VALUE, so it is named as such.
|
|
778
|
+
try {
|
|
779
|
+
const fault = verdictShapeFault(v);
|
|
780
|
+
if (fault) throw new BrokenArbiterError(tag, `${who} ${fault} — ${shape}`);
|
|
781
|
+
// Derive `pass` from a status-only verdict so the advertised `{status}` shape actually gates `refine`, which
|
|
782
|
+
// branches on `verdict.pass`. NEVER mutate the caller's object — and never flatten it either: an object
|
|
783
|
+
// spread copies OWN enumerable properties only, so a class-instance verdict whose `status`/`critique` are
|
|
784
|
+
// PROTOTYPE getters passes the shape check above and then comes out the other side with those fields
|
|
785
|
+
// ERASED (critique lost ⇒ every retry re-sends the plain task with zero feedback — the exact burn BA-15
|
|
786
|
+
// exists to prevent). Copy descriptors onto the SAME prototype so accessor-backed fields survive.
|
|
787
|
+
if (v.pass == null && typeof v.status === 'string') {
|
|
788
|
+
const copy = Object.create(Object.getPrototypeOf(v), Object.getOwnPropertyDescriptors(v));
|
|
789
|
+
Object.defineProperty(copy, 'pass', {
|
|
790
|
+
value: v.status === 'satisfied', writable: true, enumerable: true, configurable: true,
|
|
791
|
+
});
|
|
792
|
+
return /** @type {T} */ (copy);
|
|
793
|
+
}
|
|
794
|
+
return v;
|
|
795
|
+
} catch (err) { throw unreadable(err); }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* BA-15 — surface a CHILD's blocker at the PARENT. A nested tree aggregates a dead child into
|
|
800
|
+
* `{incomplete, missingSlices}`; without this the child's `broken-sensor` (or `governance-deny`) label is
|
|
801
|
+
* dropped at the first parent, so a top-level caller branching on `result.blocker` sees nothing and debugs
|
|
802
|
+
* the model instead of its own sensor — the exact laundering BA-15 exists to close, reintroduced one level up.
|
|
803
|
+
* `broken-sensor` wins over `governance-deny`: it names a fault in the CALLER's own code, which is both more
|
|
804
|
+
* actionable and cheaper to fix than a gate decision.
|
|
805
|
+
*
|
|
806
|
+
* `blockerTask` names WHICH descendant broke. Without it a nested run reports "a sensor broke" with no way to
|
|
807
|
+
* find which one — half a fix. (It is also why the label is NOT stamped onto the parent's own `blocker`; see
|
|
808
|
+
* {@link incompleteWithBlocker}.)
|
|
809
|
+
*
|
|
810
|
+
* `'broken-verifier'` is deliberately NOT matched here: `forChild` strips `evaluate`, so a child never runs a
|
|
811
|
+
* caller verifier and no child node can carry that label. Matching it would be unreachable code asserting a
|
|
812
|
+
* capability that does not exist.
|
|
813
|
+
* @param {RecurseNode[]} spawned - this node's child receipts.
|
|
814
|
+
* @returns {{blocker: string, blockerDetail?: string, blockerTask?: string}|null}
|
|
815
|
+
*/
|
|
816
|
+
function inheritedBlocker(spawned) {
|
|
817
|
+
const pick = spawned.find(c => c.incomplete && c.blocker === 'broken-sensor')
|
|
818
|
+
|| spawned.find(c => c.incomplete && c.blocker);
|
|
819
|
+
if (!pick || !pick.blocker) return null;
|
|
820
|
+
return {
|
|
821
|
+
blocker: pick.blocker,
|
|
822
|
+
...(pick.blockerDetail && { blockerDetail: pick.blockerDetail }),
|
|
823
|
+
...(pick.task && { blockerTask: pick.task }),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* BA-15 — the ONE shared "this node is incomplete because a descendant was" return. Used by every aggregating
|
|
829
|
+
* path (worker / partition / fanout) on BOTH its `missingSlices` branch AND its `HaltError` catch: the halt
|
|
830
|
+
* branches originally skipped the inherit entirely, so a gate tripping AFTER a broken-sensor child (e.g. mid-
|
|
831
|
+
* synthesize) dropped the label and the caller read a governance problem where their own sensor had crashed.
|
|
832
|
+
*
|
|
833
|
+
* The inherited label rides the RETURNED result (the caller-facing API — that surfacing is the whole point)
|
|
834
|
+
* but is recorded on the node as `blockerFrom`, NOT by overwriting `node.blocker`. `node.blocker` means "THIS
|
|
835
|
+
* node's own arbiter/Loop broke"; stamping a descendant's label onto every ancestor made the receipts tree
|
|
836
|
+
* accuse nodes whose sensor never ran, and re-labelled a parent `governance-deny` when only one child was
|
|
837
|
+
* denied — pointing the operator at the wrong node to re-gate.
|
|
838
|
+
* @param {RecurseNode} node
|
|
839
|
+
* @param {any} best
|
|
840
|
+
* @param {{missingSlices?: string[]}} [extra] - extra fields for the non-halt aggregation branch.
|
|
841
|
+
* @returns {RecurseResult}
|
|
842
|
+
*/
|
|
843
|
+
function incompleteWithBlocker(node, best, extra = {}) {
|
|
844
|
+
const inherited = inheritedBlocker(node.spawned);
|
|
845
|
+
if (inherited) node.blockerFrom = inherited;
|
|
846
|
+
return /** @type {RecurseResult} */ ({ incomplete: true, best, ...extra, ...(inherited || {}), receipts: node });
|
|
847
|
+
}
|
|
848
|
+
|
|
612
849
|
/**
|
|
613
850
|
* BA-8 leaf-refine — run a DEFINITE leaf as a bounded generate→sense→regenerate loop (relayfact F17). Reuses the
|
|
614
851
|
* existing `refine.js` primitive (the Outcomes iterate→grade→revise port): each attempt is a FRESH leaf Loop
|
|
@@ -665,6 +902,11 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
665
902
|
// a dropped attempt is stored as `null` ("provider default"). Indexed by iteration (refine calls once each).
|
|
666
903
|
/** @type {(number|null)[]} */
|
|
667
904
|
const effectiveTemps = [];
|
|
905
|
+
// BA-15/BA-5: the last attempt's text, kept OUTSIDE refine so a broken-sensor stop can still preserve the
|
|
906
|
+
// model's work — when the ARBITER breaks, the work was never judged; destroying it would punish the model
|
|
907
|
+
// for the caller's fault (refine's own history is lost on the throw).
|
|
908
|
+
/** @type {string|null} */
|
|
909
|
+
let lastAttemptText = null;
|
|
668
910
|
// One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
|
|
669
911
|
// iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
|
|
670
912
|
const attempt = async ({ iteration, critique, history }) => {
|
|
@@ -700,15 +942,29 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
700
942
|
const dropped = /** @type {{temperatureDropped?: boolean}} */ (out).temperatureDropped;
|
|
701
943
|
effectiveTemps[iteration] = dropped ? null : temperature;
|
|
702
944
|
accrueTokens(out.metrics ? out.metrics.tokens : null);
|
|
945
|
+
// BA-5: capture BEFORE the throws. The Loop returns its last non-empty text on EVERY terminating path
|
|
946
|
+
// (halt/deny/truncation/refusal), so a throw placed above this line would discard the text of the very
|
|
947
|
+
// attempt that terminated — leaving `best:null` on a FIRST-attempt halt, the exact loss the catch-branch
|
|
948
|
+
// preservation exists to prevent. (Caught by review: the earlier tests only halted on attempt 2, where a
|
|
949
|
+
// prior clean attempt had already populated this.)
|
|
950
|
+
lastAttemptText = out.text || lastAttemptText;
|
|
703
951
|
if (typeof out.error === 'string' && out.error.startsWith('halt:')) throw new HaltError('refine-leaf attempt halted', { rule: out.error.slice('halt:'.length) });
|
|
704
952
|
if (out.error) throw new Error(out.error); // a non-halt worker fault → honest incomplete
|
|
705
953
|
return out.text;
|
|
706
954
|
};
|
|
707
955
|
|
|
956
|
+
// BA-15: the sensor call is WRAPPED (via runArbiter) so a broken arbiter is NAMED, never coerced. A non-Halt
|
|
957
|
+
// throw (the caller's test runner crashed — ENOENT, syntax error in the harness) and a malformed return are
|
|
958
|
+
// the same fault class: "didn't judge", which must never collapse into "judged-and-failed" (the model's
|
|
959
|
+
// fault) or a bare {incomplete} (indistinguishable from a provider death). The throw stops refine at the
|
|
960
|
+
// FIRST broken close — retrying against a broken arbiter burns every remaining attempt for nothing (each
|
|
961
|
+
// retry would carry critique:null, i.e. the plain task again). HaltError passes through (governance, BA-2).
|
|
962
|
+
const evaluate = (result, c) => runArbiter('broken-sensor', () => sensor(result, { task, context: opts.context, contract: c.contract }));
|
|
963
|
+
|
|
708
964
|
try {
|
|
709
965
|
const outcome = await refine({
|
|
710
966
|
attempt,
|
|
711
|
-
evaluate
|
|
967
|
+
evaluate,
|
|
712
968
|
contract: typeof opts.contract === 'string' ? opts.contract : undefined,
|
|
713
969
|
maxIterations,
|
|
714
970
|
});
|
|
@@ -722,9 +978,8 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
722
978
|
// Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
|
|
723
979
|
const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
|
|
724
980
|
if (wantVerify) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
return { result, verdict, receipts: node };
|
|
981
|
+
// `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
|
|
982
|
+
return await verifyOrBlock(task, result, ctx, opts, node);
|
|
728
983
|
}
|
|
729
984
|
// No rubric layer ⇒ the sensor's final verdict IS the node verdict (a non-pass is surfaced, not hidden).
|
|
730
985
|
node.verdict = outcome.verdict || null;
|
|
@@ -733,23 +988,43 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
733
988
|
node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
|
|
734
989
|
// The refineLeaf receipt must ride EVERY terminating path, not just the clean one (same invariant as BA-10's
|
|
735
990
|
// `temperatureDropped`): a leaf that ran attempts then halted/faulted still spent tokens and may have engaged
|
|
736
|
-
// the buffer. `effectiveTemps[iteration]` is set BEFORE each attempt's throw, so it reflects every attempt
|
|
737
|
-
//
|
|
738
|
-
|
|
991
|
+
// the buffer. `effectiveTemps[iteration]` is set BEFORE each attempt's throw, so it reflects every attempt made.
|
|
992
|
+
// NOT unconditional: the refine loop may have COMPLETED (receipt already written, possibly `passed:true`) and
|
|
993
|
+
// the throw come from the verify layer below it — clobbering that to `passed:false` would report a sensor that
|
|
994
|
+
// never closed when it did, misattributing a verify-slot halt to a non-converging leaf.
|
|
995
|
+
if (!node.refineLeaf) {
|
|
996
|
+
node.refineLeaf = { iterations: effectiveTemps.length, passed: false, temperatures: effectiveTemps.slice(), rejectedBuffer: bufferUsed };
|
|
997
|
+
}
|
|
998
|
+
// BA-5: EVERY terminating branch preserves the model's last non-empty attempt (`lastAttemptText`), never
|
|
999
|
+
// `null` — matching the plain-worker path (`best: out.text || null`) and the "bounds PRESERVE work"
|
|
1000
|
+
// invariant. A refine leaf is the sole cross-attempt text channel in a ralph-style retry; dropping it on a
|
|
1001
|
+
// halt/deny/fault (as this path did pre-BA-15) loses attempt N's only bridge to attempt N+1.
|
|
739
1002
|
if (err instanceof HaltError) {
|
|
740
1003
|
node.halted = true;
|
|
741
1004
|
node.incomplete = true;
|
|
742
|
-
return { incomplete: true, best:
|
|
1005
|
+
return { incomplete: true, best: lastAttemptText, receipts: node };
|
|
743
1006
|
}
|
|
744
1007
|
// BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
|
|
745
1008
|
// denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
|
|
746
1009
|
if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
|
|
747
1010
|
node.incomplete = true;
|
|
748
1011
|
node.blocker = 'governance-deny';
|
|
749
|
-
return { incomplete: true, best:
|
|
1012
|
+
return { incomplete: true, best: lastAttemptText, blocker: 'governance-deny', receipts: node };
|
|
1013
|
+
}
|
|
1014
|
+
// BA-15: the caller's SENSOR broke — a faulty arbiter, not a model failure. Named (like BA-11's
|
|
1015
|
+
// governance-deny) so the caller fixes the sensor instead of debugging the model; `best` preserves the
|
|
1016
|
+
// model's last non-empty attempt (BA-5) — the arbiter, not the model, is at fault.
|
|
1017
|
+
if (err instanceof BrokenArbiterError && err.tag === 'broken-sensor') {
|
|
1018
|
+
node.incomplete = true;
|
|
1019
|
+
node.blocker = 'broken-sensor';
|
|
1020
|
+
node.blockerDetail = err.detail;
|
|
1021
|
+
// `blockerDetail` rides the RESULT too, not just receipts: it is the actionable half of the label (WHAT
|
|
1022
|
+
// the sensor did), and a parent reading a child's return — or a caller branching on the documented
|
|
1023
|
+
// `{blocker}` shape — should not have to walk the receipts tree to get it.
|
|
1024
|
+
return { incomplete: true, best: lastAttemptText, blocker: 'broken-sensor', blockerDetail: err.detail, receipts: node };
|
|
750
1025
|
}
|
|
751
1026
|
node.incomplete = true;
|
|
752
|
-
return { incomplete: true, best:
|
|
1027
|
+
return { incomplete: true, best: lastAttemptText, receipts: node };
|
|
753
1028
|
}
|
|
754
1029
|
}
|
|
755
1030
|
|
|
@@ -800,6 +1075,10 @@ async function recurseScan(task, ctx, opts, state) {
|
|
|
800
1075
|
};
|
|
801
1076
|
}
|
|
802
1077
|
|
|
1078
|
+
// BA-5: hoisted so a halt thrown BELOW the scan (e.g. from the verify slot) still returns the finished,
|
|
1079
|
+
// code-counted result as `best` instead of destroying it — re-running a scan re-pays every window judge call.
|
|
1080
|
+
/** @type {{count: number, matchedIds: string[]}|null} */
|
|
1081
|
+
let scanResult = null;
|
|
803
1082
|
try {
|
|
804
1083
|
const scan = await scanCount(task, corpus, {
|
|
805
1084
|
provider,
|
|
@@ -812,6 +1091,7 @@ async function recurseScan(task, ctx, opts, state) {
|
|
|
812
1091
|
node.scan = { window: scan.window, passes: scan.passes, scanned: scan.scanned, matched: scan.count };
|
|
813
1092
|
// Structured, CODE-counted result — the count is authoritative; matchedIds carry the evidence (RC-10).
|
|
814
1093
|
const result = { count: scan.count, matchedIds: scan.matchedIds };
|
|
1094
|
+
scanResult = result;
|
|
815
1095
|
|
|
816
1096
|
// RC-9: a dead window means we did NOT see every slice → the count is a floor, not the answer. Report it
|
|
817
1097
|
// incomplete with the partial as `best`, never a clean pass over a hole.
|
|
@@ -824,16 +1104,21 @@ async function recurseScan(task, ctx, opts, state) {
|
|
|
824
1104
|
// structured count against the goal/contract (an isolated grader, never the scanner itself).
|
|
825
1105
|
const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
|
|
826
1106
|
if (wantVerify) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
return { result, verdict, receipts: node };
|
|
1107
|
+
// `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
|
|
1108
|
+
return await verifyOrBlock(task, result, ctx, opts, node);
|
|
830
1109
|
}
|
|
831
1110
|
return { result, verdict: null, receipts: node };
|
|
832
1111
|
} catch (err) {
|
|
833
1112
|
if (err instanceof HaltError) {
|
|
834
1113
|
node.halted = true;
|
|
835
1114
|
node.incomplete = true;
|
|
836
|
-
|
|
1115
|
+
// BA-5, PARTIAL by construction: a halt from the VERIFY slot lands here with the scan already finished,
|
|
1116
|
+
// so the counted result is returned instead of null (it previously survived only in receipts, forcing a
|
|
1117
|
+
// full re-scan). A halt DURING `scanCount` — the likelier case, since a token cap trips after many window
|
|
1118
|
+
// judge calls — still yields `null`: `scanCount` throws without surfacing the windows it did judge, so
|
|
1119
|
+
// there is nothing here to preserve. Fixing that needs `scanCount` to return its partial union on halt
|
|
1120
|
+
// (a retrieval-side change, not this seam's); tracked as a known limit rather than papered over here.
|
|
1121
|
+
return { incomplete: true, best: scanResult, receipts: node };
|
|
837
1122
|
}
|
|
838
1123
|
throw err;
|
|
839
1124
|
}
|
|
@@ -891,6 +1176,8 @@ async function recursePartition(task, ctx, opts, state) {
|
|
|
891
1176
|
// (never `undefined`) across every dispatch path.
|
|
892
1177
|
|
|
893
1178
|
const childResults = [];
|
|
1179
|
+
/** @type {{count: number, matchedIds: string[]}|null} */
|
|
1180
|
+
let partitionResult = null;
|
|
894
1181
|
try {
|
|
895
1182
|
// 2b) Pre-wave checkpoint — width (the cost) is now known. A governance HaltError halts BEFORE any worker
|
|
896
1183
|
// spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
|
|
@@ -935,25 +1222,30 @@ async function recursePartition(task, ctx, opts, state) {
|
|
|
935
1222
|
if (child.incomplete) missingSlices.push(label);
|
|
936
1223
|
}
|
|
937
1224
|
const result = { count: matched.size, matchedIds: [...matched] };
|
|
1225
|
+
partitionResult = result; // BA-5: a halt below this (e.g. verify) returns the full result, not a count-only rebuild
|
|
938
1226
|
node.partition.matched = matched.size;
|
|
939
1227
|
|
|
940
1228
|
if (missingSlices.length > 0) {
|
|
941
1229
|
node.incomplete = true;
|
|
942
|
-
|
|
1230
|
+
// BA-15: carry a child's blocker up (see inheritedBlocker).
|
|
1231
|
+
return incompleteWithBlocker(node, result, { missingSlices });
|
|
943
1232
|
}
|
|
944
1233
|
const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
|
|
945
1234
|
if (wantVerify) {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
return { result, verdict, receipts: node };
|
|
1235
|
+
// `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
|
|
1236
|
+
return await verifyOrBlock(task, result, ctx, opts, node);
|
|
949
1237
|
}
|
|
950
1238
|
return { result, verdict: null, receipts: node };
|
|
951
1239
|
} catch (err) {
|
|
952
1240
|
if (err instanceof HaltError) {
|
|
953
1241
|
node.halted = true;
|
|
954
1242
|
node.incomplete = true;
|
|
955
|
-
|
|
956
|
-
|
|
1243
|
+
// Prefer the finished result (a halt from the verify slot lands here with it already computed); else
|
|
1244
|
+
// rebuild a best-effort count from whatever slices did return (BA-5, never a bare null).
|
|
1245
|
+
const best = partitionResult
|
|
1246
|
+
|| (childResults.length ? { count: new Set(childResults.flatMap((v) => (v && Array.isArray(v.matchedIds) ? v.matchedIds : []))).size } : null);
|
|
1247
|
+
// BA-15: a halt must not swallow a child's broken-sensor label (see incompleteWithBlocker).
|
|
1248
|
+
return incompleteWithBlocker(node, best);
|
|
957
1249
|
}
|
|
958
1250
|
throw err;
|
|
959
1251
|
}
|
|
@@ -987,6 +1279,11 @@ async function recurseFanout(task, ctx, opts, state) {
|
|
|
987
1279
|
|
|
988
1280
|
const childResults = [];
|
|
989
1281
|
const contract = typeof opts.contract === 'string' ? opts.contract : null;
|
|
1282
|
+
// BA-5: declared OUTSIDE the try so a halt from the verify slot (which lands in the catch below, with the
|
|
1283
|
+
// reduce already computed and PAID FOR) returns the finished reduce instead of a lossy re-join of the raw
|
|
1284
|
+
// child strings — a different TYPE from the documented reduce output, and an 'merge' strategy's LLM call
|
|
1285
|
+
// thrown away. Mirrors `scanResult`/`partitionResult` on the sibling paths.
|
|
1286
|
+
let result;
|
|
990
1287
|
|
|
991
1288
|
try {
|
|
992
1289
|
// 1) Decompose into exactly `count` independent parallel steps (the NB-2 Planner seam). A non-Halt planner
|
|
@@ -1057,7 +1354,6 @@ async function recurseFanout(task, ctx, opts, state) {
|
|
|
1057
1354
|
// 4) NB-3 reduce over the slice results. Unlike Family A there is no parent closing turn, so we ALWAYS
|
|
1058
1355
|
// reduce: a `synthesize` FUNCTION is the deterministic code-reduce (§9.1); a string runs the built-in
|
|
1059
1356
|
// reducer; unset defaults to lossless `'concat'`. (`childResults` always has `count` entries.)
|
|
1060
|
-
let result;
|
|
1061
1357
|
if (typeof opts.synthesize === 'function') {
|
|
1062
1358
|
result = await opts.synthesize({ task, text: null, results: childResults, children: node.spawned, ctx });
|
|
1063
1359
|
} else {
|
|
@@ -1077,24 +1373,27 @@ async function recurseFanout(task, ctx, opts, state) {
|
|
|
1077
1373
|
// 5) Honest completeness (RC-9): any missing slice → incomplete, with the partial reduce as `best`.
|
|
1078
1374
|
if (missingSlices.length > 0) {
|
|
1079
1375
|
node.incomplete = true;
|
|
1080
|
-
|
|
1376
|
+
// BA-15: carry a child's blocker up (see inheritedBlocker).
|
|
1377
|
+
return incompleteWithBlocker(node, result, { missingSlices });
|
|
1081
1378
|
}
|
|
1082
1379
|
|
|
1083
1380
|
// 6) Verify (RC-7): forced for critical, or when a contract/override is supplied.
|
|
1084
1381
|
const wantVerify = critical || contract != null || typeof opts.evaluate === 'function';
|
|
1085
1382
|
if (wantVerify) {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
return { result, verdict, receipts: node };
|
|
1383
|
+
// `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
|
|
1384
|
+
return await verifyOrBlock(task, result, ctx, opts, node);
|
|
1089
1385
|
}
|
|
1090
1386
|
return { result, verdict: null, receipts: node };
|
|
1091
1387
|
} catch (err) {
|
|
1092
1388
|
if (err instanceof HaltError) {
|
|
1093
1389
|
node.halted = true;
|
|
1094
1390
|
node.incomplete = true;
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1391
|
+
// BA-5: prefer the FINISHED reduce when the halt landed after it (the verify slot); only fall back to a
|
|
1392
|
+
// lossless join of the raw slices when the halt tripped before/during the reduce itself. Never a bare null.
|
|
1393
|
+
const rejoined = childResults.length ? childResults.filter(v => v !== '').join('\n\n') : null;
|
|
1394
|
+
const best = result !== undefined ? result : (rejoined || null);
|
|
1395
|
+
// BA-15: a halt must not swallow a child's broken-sensor label (see incompleteWithBlocker).
|
|
1396
|
+
return incompleteWithBlocker(node, best);
|
|
1098
1397
|
}
|
|
1099
1398
|
throw err;
|
|
1100
1399
|
}
|
|
@@ -1161,6 +1460,16 @@ function buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults) {
|
|
|
1161
1460
|
// silently dropped or faked. The same declared value is collected for the NB-3 reducer.
|
|
1162
1461
|
const value = child.incomplete ? (child.best == null ? '' : child.best) : (child.result == null ? '' : child.result);
|
|
1163
1462
|
childResults.push(value);
|
|
1463
|
+
// BA-15: a child blocked by a BROKEN ARBITER must not read to the parent model as a generic failure.
|
|
1464
|
+
// Collapsed into a bare `[incomplete]`, the parent cannot tell "the model failed" from "the judge is
|
|
1465
|
+
// broken", so it re-spawns the identical subtask against the identical broken sensor — re-running a
|
|
1466
|
+
// full leaf attempt each time, up to the Loop's round limit. That is BA-15's own spend-burn ("retrying
|
|
1467
|
+
// against a broken arbiter carries zero feedback") reintroduced one level up, so the tool result says
|
|
1468
|
+
// so explicitly and tells the model not to retry.
|
|
1469
|
+
if (child.incomplete && child.blocker === 'broken-sensor') {
|
|
1470
|
+
const where = child.blockerTask ? ` (in sub-task: ${child.blockerTask})` : '';
|
|
1471
|
+
return `[blocked: broken-sensor] The CHECK that judges this subtask is itself broken${where} — ${child.blockerDetail || 'no detail available'}. Retrying will hit the same broken check: do NOT re-delegate this subtask; report it as blocked. Partial output: ${String(value)}`.trim();
|
|
1472
|
+
}
|
|
1164
1473
|
if (child.incomplete) return `[incomplete] ${String(value)}`.trim();
|
|
1165
1474
|
return String(value);
|
|
1166
1475
|
},
|
|
@@ -1184,7 +1493,19 @@ function verify(task, result, ctx, opts) {
|
|
|
1184
1493
|
// and an agentic critic needs the path to exercise the artifact. A caller `evaluate` gets the RAW task (it owns
|
|
1185
1494
|
// its own context); only the default isolated grader is contextualized.
|
|
1186
1495
|
if (typeof opts.evaluate === 'function') {
|
|
1187
|
-
|
|
1496
|
+
// BA-15 (verifier seam): the CALLER-supplied verifier is wrapped exactly like the refineLeaf sensor — a
|
|
1497
|
+
// non-Halt throw or a malformed return is a faulty ARBITER, tagged so the call sites label it (pre-fix a
|
|
1498
|
+
// throw crashed the whole run on the plain-worker path / laundered to a bare {incomplete} under refineLeaf,
|
|
1499
|
+
// and a garbage verdict rode a CONVERGED-shaped {result, verdict} out). The default Evaluator path below is
|
|
1500
|
+
// NOT wrapped: it constructs well-formed Verdicts by design, and its failures are provider-class faults.
|
|
1501
|
+
// Capture the narrowed reference in a const: `typeof opts.evaluate === 'function'` does NOT survive into the
|
|
1502
|
+
// nested async closure (TS re-widens `opts.evaluate` to possibly-undefined there → TS2722/TS18048).
|
|
1503
|
+
// BOUND to `opts`, because a bare `const evaluate = opts.evaluate` DETACHES the method: the call used to be
|
|
1504
|
+
// `opts.evaluate(...)` (receiver `opts`), and a caller passing a class method (`evaluate: grader.check`)
|
|
1505
|
+
// would suddenly get `this === undefined` and throw on its first `this.x` read — a working (if degraded)
|
|
1506
|
+
// integration flipped to a hard `broken-verifier` by an unrelated typecheck fix.
|
|
1507
|
+
const evaluate = opts.evaluate.bind(opts);
|
|
1508
|
+
return runArbiter('broken-verifier', () => evaluate(result, { contract, task }));
|
|
1188
1509
|
}
|
|
1189
1510
|
const provider = ctx.provider || opts.provider;
|
|
1190
1511
|
const evaluator = new Evaluator({ provider });
|
|
@@ -1199,4 +1520,39 @@ function verify(task, result, ctx, opts) {
|
|
|
1199
1520
|
);
|
|
1200
1521
|
}
|
|
1201
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* BA-15 (verifier seam) — run the verify slot, converting a `BrokenArbiterError` into a LABELED
|
|
1525
|
+
* `{ incomplete, blocker:'broken-verifier' }` return with `best` preserving the result the arbiter failed to
|
|
1526
|
+
* judge (BA-5: the work exists — best-effort, not a graded pass). One helper so all five dispatch paths (worker /
|
|
1527
|
+
* refineLeaf / scan / partition / fanout) get identical semantics. Anything else (HaltError, a default-
|
|
1528
|
+
* Evaluator provider fault) rethrows to the caller's own catch, exactly as before.
|
|
1529
|
+
*
|
|
1530
|
+
* MUST be called as `return await verifyOrBlock(...)` from inside each caller's `try` — a bare
|
|
1531
|
+
* `return verifyOrBlock(...)` returns the promise and exits the `try` before it settles, so a verifier
|
|
1532
|
+
* `HaltError` would escape the caller's own catch instead of landing as a clean `{ incomplete, halted }`
|
|
1533
|
+
* (proven by `poc/ba15-broken-sensor.mjs` [E4]).
|
|
1534
|
+
* @param {string} task
|
|
1535
|
+
* @param {any} result
|
|
1536
|
+
* @param {RecurseCtx} ctx
|
|
1537
|
+
* @param {RecurseOptions} opts
|
|
1538
|
+
* @param {RecurseNode} node
|
|
1539
|
+
* @returns {Promise<RecurseResult>}
|
|
1540
|
+
*/
|
|
1541
|
+
async function verifyOrBlock(task, result, ctx, opts, node) {
|
|
1542
|
+
try {
|
|
1543
|
+
const verdict = await verify(task, result, ctx, opts);
|
|
1544
|
+
node.verdict = verdict;
|
|
1545
|
+
return { result, verdict, receipts: node };
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
// Only a typed BrokenArbiterError is a caller-verifier fault; everything else (HaltError, a default-
|
|
1548
|
+
// Evaluator provider fault) rethrows to the caller's own catch. Classified by type, never by message text.
|
|
1549
|
+
if (!(err instanceof BrokenArbiterError)) throw err;
|
|
1550
|
+
node.incomplete = true;
|
|
1551
|
+
node.blocker = 'broken-verifier';
|
|
1552
|
+
node.blockerDetail = err.detail;
|
|
1553
|
+
// `blockerDetail` rides the RESULT too (see the sensor-side sibling) — the actionable half of the label.
|
|
1554
|
+
return { incomplete: true, best: result, blocker: 'broken-verifier', blockerDetail: err.detail, receipts: node };
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1202
1558
|
module.exports = { recurse };
|