bareloop 0.22.0 → 0.23.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/src/job.js CHANGED
@@ -133,7 +133,11 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
133
133
  * subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
134
134
  * billed), so it is a DISTINCT condition: rows never pool with anthropic-api
135
135
  * rows on the cost axis, and budgets sized for one do not transfer. */
136
- export const PROVIDERS = Object.freeze(['anthropic-api', 'clipipe-subscription']);
136
+ /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
137
+ * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
138
+ * `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
139
+ * models); the factory's tier table is where that lives, not this menu. */
140
+ export const PROVIDERS = Object.freeze(['anthropic-api', 'openai-api', 'clipipe-subscription']);
137
141
  /** V3 environment label: declared keys only — every field is a lineage-key
138
142
  * candidate at N3. `provider` is part of the key by definition (top-level,
139
143
  * not duplicated here). */
@@ -141,7 +145,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
141
145
  // `steps` stays in the field list ONLY so a retired spec reds by name
142
146
  // (`shape-retired`) instead of falling through to a generic unknown-field —
143
147
  // the operator gets told what happened, not just that something is wrong.
144
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
148
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
145
149
  /** the four-field plan shape's core (decision 5) — presence of any of these
146
150
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
147
151
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
@@ -267,6 +271,37 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
267
271
  red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
268
272
  }
269
273
 
274
+ // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
275
+ // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
276
+ // the signed hash like `model` above. This validator has no network
277
+ // access, so it checks SHAPE only: `https://` for a real endpoint, or
278
+ // `http://` restricted to a loopback host (a local OpenAI-compatible
279
+ // server, the only legitimate reason to skip TLS) — and never a
280
+ // credential embedded IN the URL itself. A URL travels into logs and
281
+ // spine records far more casually than a header ever would, and secrets
282
+ // never enter the tree/spine (hard line #3) — a bareUrl carrying
283
+ // `user:pass@host` is exactly that leak, just spelled differently.
284
+ if (spec.baseUrl !== undefined) {
285
+ if (!isNonEmptyString(spec.baseUrl)) {
286
+ red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
287
+ } else {
288
+ /** @type {URL|null} */
289
+ let u = null;
290
+ try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
291
+ if (!u) {
292
+ red('invalid-value', 'baseUrl', 'must be a parseable URL');
293
+ } else if (u.username !== '' || u.password !== '') {
294
+ red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
295
+ } else if (u.protocol === 'https:') {
296
+ // admitted
297
+ } else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
298
+ // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
299
+ } else {
300
+ red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
301
+ }
302
+ }
303
+ }
304
+
270
305
  // 3. the outer write fence — operator law (interview decision #4), same
271
306
  // containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
272
307
  // transforms would let a fence validate green and mean something else).
package/src/judged.js CHANGED
@@ -565,6 +565,43 @@ export function validateFacts(facts) {
565
565
 
566
566
  // ── THE LOCATE PROMPT ───────────────────────────────────────────────────────
567
567
 
568
+ /**
569
+ * THE ONE SHAPE INVENTORY (PRD 30.8), stated ONCE and never twice. It drives
570
+ * BOTH the LOCATE prompt's prose ("declared with ...") AND the completeness
571
+ * detector's regex — the same standing rule the secret-shape scrubber
572
+ * follows (one inventory drives detection, never two spellings that can
573
+ * drift). A shape added here rides both sides in the same commit; a shape
574
+ * added to only a hand-typed prompt string or only a hand-typed regex is
575
+ * exactly the blind-instrument class this repo keeps paying for.
576
+ *
577
+ * Two BASE shapes, each carrying its own independent `export`/`async`
578
+ * modifiers (the modifiers sit in different POSITIONS in the two shapes —
579
+ * `async function foo(` vs `const foo = async () =>` — so they cannot be
580
+ * factored into one shared prefix):
581
+ * - `function name(...)` — optionally `export`, optionally `async`,
582
+ * in either order before `function`.
583
+ * - `const name = (...) =>` — optionally `export` before `const`,
584
+ * optionally `async` after `=`.
585
+ *
586
+ * Restricted to column-0 lines only, in both the prompt's prose and the
587
+ * detector: a nested function or a function expression returned from one is
588
+ * indented, and excluding it is the SAME cure PROMPT_HEAD already states
589
+ * (the POC's nested-function cure).
590
+ * @type {ReadonlyArray<{prose: string, match: (line: string) => string|null}>}
591
+ */
592
+ const FN_SHAPES = Object.freeze([
593
+ {
594
+ prose: '`function name(...)` (optionally `async`, optionally `export`, in either order)',
595
+ match: (/** @type {string} */ line) =>
596
+ line.match(/^(?:(?:export|async)\s+){0,2}function\s+([A-Za-z_$][\w$]*)\s*\(/)?.[1] ?? null,
597
+ },
598
+ {
599
+ prose: '`const name = (...) =>` (optionally `export` before `const`, optionally `async` after `=`)',
600
+ match: (/** @type {string} */ line) =>
601
+ line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/)?.[1] ?? null,
602
+ },
603
+ ]);
604
+
568
605
  /** The spine every card shares. Both POC cures live here rather than on a rule,
569
606
  * because both are properties of ENUMERATING functions and not of any one
570
607
  * question about them: the nested-function clause (the POC counted a returned
@@ -573,8 +610,7 @@ export function validateFacts(facts) {
573
610
  const PROMPT_HEAD =
574
611
  'You LOCATE FACTS in a source file. You never decide whether anything passes, and you never judge — '
575
612
  + 'another system does that.\n'
576
- + 'For EVERY top-level function in the FILE (declared with `function name(...)`, `const name = (...) =>`, '
577
- + 'or `export function name(...)`), report:\n'
613
+ + `For EVERY top-level function in the FILE (declared with ${FN_SHAPES.map((s) => s.prose).join(', or ')}), report:\n`
578
614
  + 'Count ONLY functions declared at the top level of the module — never a function nested inside '
579
615
  + 'another function, and never a function expression returned from one.\n'
580
616
  + ' "name": the function name\n'
@@ -596,6 +632,43 @@ export function locatePrompt(card) {
596
632
  return `${PROMPT_HEAD}${asks}\n${PROMPT_TAIL}`;
597
633
  }
598
634
 
635
+ /**
636
+ * THE COMPLETENESS MIRROR of `PROMPT_HEAD`'s own contract (PRD 30.8): which
637
+ * top-level function declarations the artifact ACTUALLY holds, read off the
638
+ * SAME `FN_SHAPES` inventory the prompt's prose is built from, restricted to
639
+ * column-0 lines.
640
+ *
641
+ * This is a HEURISTIC, never a parser, and it is used for exactly one thing:
642
+ * telling `decide()` when LOCATE's `functions` list is short of what the file
643
+ * holds. It never grades a rule and it never adds a fact `decide()` acts on
644
+ * beyond "this name exists at the top level" — the missing function's rule
645
+ * violations, if any, stay unknown, which is exactly why the miss is UNSURE
646
+ * and not a manufactured red on a specific rule.
647
+ *
648
+ * TWO DIRECTIONS, both handled but not symmetrically by design:
649
+ * - a shape THIS inventory does not enumerate is a FALSE NEGATIVE — the
650
+ * check stays silent about that function, same as it stays silent with
651
+ * no `artifactText` at all. Widening `FN_SHAPES` closes the hole; nothing
652
+ * here manufactures a claim about a shape it was never told to look for.
653
+ * - a column-0 line that only LOOKS like a declaration (e.g. inside a
654
+ * template literal) is a FALSE POSITIVE and reds a real judged close —
655
+ * that is the SAFE side of the fail-safe tiebreak (unsure is red), and it
656
+ * is never silently swallowed the way a false negative would be.
657
+ * @param {string} artifactText @returns {string[]}
658
+ */
659
+ function topLevelFunctionNames(artifactText) {
660
+ /** @type {string[]} */
661
+ const names = [];
662
+ for (const line of artifactText.split('\n')) {
663
+ if (/^\s/.test(line)) continue; // indented: nested, or a returned function expression — never top level
664
+ for (const shape of FN_SHAPES) {
665
+ const name = shape.match(line);
666
+ if (name) { names.push(name); break; }
667
+ }
668
+ }
669
+ return names;
670
+ }
671
+
599
672
  /** the default toolless seam — one spelling of how this repo drives a judge.
600
673
  * A `Loop`, NOT `provider.generate()`: the POC measured that a bare provider
601
674
  * result carries no `costUsd` at all, and under F6 an unpriced call is a red,
@@ -628,12 +701,23 @@ export const defaultJudgeLoop = ({ provider, system }) => {
628
701
  * On every route out, red or clean, `onCost` fires exactly once with the honest
629
702
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
630
703
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
631
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void}} o
704
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
705
+ * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
632
706
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
633
707
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
634
708
  * raw: ReturnType<typeof scrubRaw>}>}
635
709
  */
636
- export async function runLocate({ artifactText, card, loopFactory, maxTokens = JUDGE_MAX_TOKENS, attempt = 1, onCost = () => {} }) {
710
+ export async function runLocate({
711
+ artifactText, card, loopFactory, maxTokens = JUDGE_MAX_TOKENS, attempt = 1, onCost = () => {},
712
+ // F152/PRD 30.4 — the SAME per-call TIME bounds the worker's own rounds carry
713
+ // (`callBounds()`, src/planrun.js:1518), threaded through by the caller that
714
+ // owns the clock. Defaults to `{}` (unbounded) so every existing caller and
715
+ // test is unchanged: an adopter that never wires a clock through gets the same
716
+ // behaviour as before this field existed. A live endpoint that accepts and
717
+ // never answers otherwise HANGS the process forever (measured, F152) — an open
718
+ // socket is an active handle, so node never drains and no backstop can fire.
719
+ callBounds = {},
720
+ }) {
637
721
  // the param-guard class: a caller's own broken input THROWS. These are
638
722
  // programmer errors with no safe direction to degrade toward — a locate over
639
723
  // an absent artifact would grade an empty string and report it as facts.
@@ -662,7 +746,7 @@ export async function runLocate({ artifactText, card, loopFactory, maxTokens = J
662
746
  let r = null;
663
747
  try {
664
748
  const loop = loopFactory({ system });
665
- r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens });
749
+ r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens, ...callBounds });
666
750
  } catch (e) {
667
751
  return out({ axis: LOCATE_AXES.PROVIDER, detail: `the locate call failed: ${String(/** @type {any} */ (e)?.message ?? e)}` }, null, null, false, false);
668
752
  }
@@ -743,6 +827,25 @@ export function decide(facts, card, { artifactText = null } = {}) {
743
827
  return unsure('locate found nothing in the artifact — unsure, and unsure is red');
744
828
  }
745
829
 
830
+ // COMPLETENESS (PRD 30.8): a locate emission can be well-formed AND non-empty
831
+ // AND still have silently DROPPED a top-level function the artifact actually
832
+ // holds — every route above this one only asks "is `fns` usable", never "is
833
+ // `fns` the WHOLE set". Graded rules only ever see what LOCATE reported, so
834
+ // an omission never surfaces as a rule red; it surfaces as a PASS on the
835
+ // subset, which is the fail-safe direction inverted. With `artifactText` in
836
+ // hand (every real caller has it — see the note below), the omission is
837
+ // checkable and is unsure, and unsure is red, same as every other route in.
838
+ // Without it, this says nothing it cannot know, same as the quote checks.
839
+ if (typeof artifactText === 'string') {
840
+ const present = topLevelFunctionNames(artifactText);
841
+ const reported = new Set(fns.map((f) => String(f?.name ?? '')));
842
+ const missing = present.filter((n) => !reported.has(n));
843
+ if (missing.length > 0) {
844
+ return unsure(`locate omitted top-level function(s) the artifact contains: ${missing.join(', ')} — a pass on `
845
+ + 'the reported subset is not a pass on the file, and unsure is red');
846
+ }
847
+ }
848
+
746
849
  const lines = typeof artifactText === 'string'
747
850
  ? new Set(artifactText.split('\n').map((l) => l.trim()).filter(Boolean))
748
851
  : null;
package/src/kinds.js CHANGED
@@ -260,6 +260,7 @@ export function mechanicalStages(stages) {
260
260
  * judgeLoop?: (o: {system: string}) => any,
261
261
  * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
262
262
  * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
263
+ * callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
263
264
  * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
264
265
  * The arbiter's half of a stage run. Every field here is operator/runner
265
266
  * territory: a declaration parameterises kinds, never the contracts.
@@ -275,6 +276,11 @@ export function mechanicalStages(stages) {
275
276
  * funded honestly: this executor cannot reach the run's ledger, so every locate
276
277
  * call's cost is reported OUT through it, once per call, on every route including
277
278
  * the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
279
+ * `callBounds` is F152/PRD 30.4's per-call TIME bound, read fresh before every
280
+ * locate attempt — the same derivation the run's own worker rounds carry
281
+ * (`callBounds()`, src/planrun.js). Absent is the honest unbounded default:
282
+ * an adopter or test that wires no clock through behaves exactly as before
283
+ * this field existed.
278
284
  * `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
279
285
  * toolchain per stage (a suite is the slow one) and a whole read is one opaque
280
286
  * await, so the shell gets told as each stage lands. It decides nothing, it is
@@ -1781,6 +1787,12 @@ async function runJudgedFloor(stage, ctx) {
1781
1787
  card: p.card,
1782
1788
  loopFactory,
1783
1789
  attempt,
1790
+ // F152/PRD 30.4 — read fresh per attempt, the same way `planrun.js`'s own
1791
+ // worker rounds re-read `callBounds()` at every call site rather than
1792
+ // once: the remaining time shrinks between attempts. Absent (no clock
1793
+ // wired through) is `{}`, the honest unbounded default `runLocate` itself
1794
+ // already carries.
1795
+ callBounds: typeof ctx.callBounds === 'function' ? ctx.callBounds() : {},
1784
1796
  onCost: (c) => {
1785
1797
  if (typeof ctx.onJudgeCost !== 'function') return;
1786
1798
  ctx.onJudgeCost({
package/src/planrun.js CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  } from './kinds.js';
44
44
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
45
45
  import { TRANSPORT_MAX_ATTEMPTS, isTransportFailure } from './transport.js';
46
+ import { isRateLimited, rateLimitWaitMs, statedWaitMs } from './ratelimit.js';
47
+ import { setTimeout as sleepMs } from 'node:timers/promises';
46
48
  import { checkCloseAbsolutePaths, checkCloseByteSignature, checkStageByteSignature, checkCloseDirRequired } from './close-integrity.js';
47
49
  import { resolveCloseTimeoutMs, closeTimeoutBanner, TIMING_PREFLIGHT_CEILING_MS } from './closetimeout.js';
48
50
 
@@ -72,21 +74,44 @@ const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
72
74
  const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn: isTransportFailure, timeout: 0 });
73
75
 
74
76
  /**
75
- * Wrap a Loop provider's `generate` so a single transport-class throw (fetch
76
- * itself throwing — no HTTP response was ever produced) gets ONE retry via
77
- * `transportRetry`, and the outcome is reported once the retry is settled.
78
- * Every other property of `baseProvider` (`.name`, `.model`, `.ownsCycle`,
79
- * `.policy`) passes through unchanged — bare-agent's Loop reads those
80
- * directly (src/loop.js).
77
+ * Wrap a Loop provider's `generate` with TWO independent, one-shot retry
78
+ * ladders: F115's transport-class retry (unchanged), and PRD item 28's
79
+ * parked (a/b) ruling — hamr's verbatim call: "do a/b as a fallback, and
80
+ * verify/validate + no regression" — a bounded retry on an HTTP 429 that
81
+ * honours the vendor's OWN stated wait (src/ratelimit.js). Every other
82
+ * property of `baseProvider` (`.name`, `.model`, `.ownsCycle`, `.policy`)
83
+ * passes through unchanged — bare-agent's Loop reads those directly
84
+ * (src/loop.js).
81
85
  *
82
- * F64 carve-out: a wall-DERIVED timeout (bare-agent's own `ETIMEDOUT`/
83
- * `EDEADLINE`, thrown because `clock.callTimeoutMs()` told the provider to
84
- * cut the call — `isWallTimeout`, src/clock.js) is doctrine's governance
85
- * stop, never a transport casualty, and retrying it would both mis-spend a
86
- * round the operator's clock already ended and corrupt `categorize`'s own
87
- * classification (which reads the SAME error a moment later). So the
88
- * per-call `retryOn` here is `isTransportFailure(err) && !isWallTimeout(err,
89
- * clock)` — the clock's verdict always wins over the transport shape.
86
+ * The two budgets are independent — one transport retry AND one rate-limit
87
+ * retry may both occur across a single `generate()` call's lifetime, but
88
+ * each at most once. This is a FALLBACK, not the primary answer: the
89
+ * primary answer (F139) is picking a worker model with adequate rate
90
+ * headroom; this softens the failure mode when that was not enough, it does
91
+ * not replace it.
92
+ *
93
+ * F64 carve-out (both ladders): a wall-DERIVED timeout (bare-agent's own
94
+ * `ETIMEDOUT`/`EDEADLINE`, thrown because `clock.callTimeoutMs()` told the
95
+ * provider to cut the call — `isWallTimeout`, src/clock.js) is doctrine's
96
+ * governance stop, never a casualty to retry, and retrying it would both
97
+ * mis-spend a round the operator's clock already ended and corrupt
98
+ * `categorize`'s own classification (which reads the SAME error a moment
99
+ * later). The wall always wins over either retry shape: before EITHER retry,
100
+ * `isWallTimeout` must be false, and before sleeping for a rate-limit retry,
101
+ * the run must have MORE time left than the wait would cost
102
+ * (`clock.remainingMs() > waitMs`) — a wait that would run past the
103
+ * deadline is not honoured; the run ends on the operator's clock instead of
104
+ * sleeping past it.
105
+ *
106
+ * `spendComplete` note (the one place the two ladders differ): a transport
107
+ * throw's first attempt may already have been billed (fetch failing after
108
+ * the request left the socket), so F115 floors `spendComplete` for the rest
109
+ * of the run regardless of `recovered` (src/run.js reads the `transport-retry`
110
+ * record's presence for this). A 429, in contrast, is a REFUSAL — the vendor
111
+ * rejected the request before processing it — so nothing can have been
112
+ * billed, and a `rate-limit-retry` record does NOT floor `spendComplete`
113
+ * (src/run.js only watches for `transport-retry`; this is a stated
114
+ * assumption, not independently measured against a real provider).
90
115
  *
91
116
  * Scope note: this wraps the ONE seam that builds every worker Loop
92
117
  * (scout/drafter/step-worker/fix-worker, planrun.js `newLoop` ~line 2240) —
@@ -103,38 +128,110 @@ const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn:
103
128
  * @param {(s: string) => string} scrub
104
129
  * @param {import('./clock.js').Clock} clock
105
130
  */
106
- function withTransportRetry(baseProvider, phase, emit, scrub, clock) {
131
+ // F146/F149 (PRD item 30.5) — every provider construction site in this repo now
132
+ // passes `exposeErrorBody: true` (bare-agent's providers), which attaches the
133
+ // parsed upstream error body as `err.body` on an HTTP throw. The body often
134
+ // carries the ONE sentence that names what actually broke — the category and
135
+ // HTTP status alone never do (F146: Qwen's `HTTP 400` said nothing on its own;
136
+ // its body said "System message must be at the beginning"). This was PARKED
137
+ // (2026-09-05 design note) because a raw body can echo the API key straight
138
+ // back — so it goes through `redactSecrets`, the ONE secret shape inventory
139
+ // (`SECRET_PATTERNS`/`scanSecrets`/`redactSecrets`, `src/validate.js`),
140
+ // REGARDLESS of whatever `scrub` a caller has in scope, and is bounded at
141
+ // `BODY_MAX` chars (600 — bounded like every other detail this file emits:
142
+ // `CHECK_GAP_MAX`, `SCOUT_BLOB_MAX`) so one oversized body can never blow a
143
+ // record open. Only this redacted, capped STRING ever rides an emit — the raw
144
+ // `e.body` object itself must never reach a spine record.
145
+ const BODY_MAX = 600;
146
+ const BODY_MARKER = ' — body: ';
147
+ /** @param {any} e @returns {string} '' when there is no body, else ' — body: <redacted, ≤BODY_MAX chars>' */
148
+ const bodySuffix = (e) => {
149
+ if (e?.body === undefined) return '';
150
+ let raw;
151
+ try { raw = JSON.stringify(e.body); } catch { raw = String(e.body); }
152
+ return `${BODY_MARKER}${redactSecrets(raw).slice(0, BODY_MAX)}`;
153
+ };
154
+
155
+ function withProviderRetries(baseProvider, phase, emit, scrub, clock) {
107
156
  return new Proxy(baseProvider, {
108
157
  get(target, prop, receiver) {
109
158
  if (prop !== 'generate') return Reflect.get(target, prop, receiver);
110
159
  return async (...args) => {
111
- let attempts = 0;
112
- let firstErr = null;
113
- const fn = async () => {
114
- attempts += 1;
160
+ // Each budget is consumed at most once across the WHOLE call,
161
+ // however many times the two ladders alternate.
162
+ let transportBudget = 1;
163
+ let rateLimitBudget = 1;
164
+
165
+ /**
166
+ * One "transport-protected" attempt: calls `target.generate` through
167
+ * the shared `transportRetry` instance, which itself may retry once
168
+ * for a transport-class throw — but only while `transportBudget`
169
+ * is still available, so a SECOND pass through this function (after
170
+ * a rate-limit sleep) cannot spend a transport retry twice.
171
+ */
172
+ const attemptWithTransport = async () => {
173
+ let calls = 0;
174
+ /** @type {any} */
175
+ let firstErr = null;
176
+ const fn = async () => {
177
+ calls += 1;
178
+ try {
179
+ return await target.generate(...args);
180
+ } catch (err) {
181
+ if (calls === 1) firstErr = err;
182
+ throw err;
183
+ }
184
+ };
115
185
  try {
116
- return await target.generate(...args);
186
+ const result = await transportRetry.call(fn, {
187
+ retryOn: (err) => transportBudget > 0 && isTransportFailure(err) && !isWallTimeout(err, clock),
188
+ });
189
+ if (calls > 1) {
190
+ transportBudget -= 1;
191
+ const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200) + bodySuffix(firstErr);
192
+ emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered: true });
193
+ }
194
+ return result;
117
195
  } catch (err) {
118
- if (attempts === 1) firstErr = err;
196
+ if (calls > 1) {
197
+ transportBudget -= 1;
198
+ const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200) + bodySuffix(firstErr);
199
+ emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered: false });
200
+ }
119
201
  throw err;
120
202
  }
121
203
  };
122
- // `recovered` is knowable only once the retried attempt has settled
123
- // (succeeded or exhausted the budget) — emit exactly once, here,
124
- // after the outcome is known, never speculatively before it.
125
- const report = (recovered) => {
126
- if (attempts <= 1) return; // no retry happened — nothing to report
127
- const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200);
128
- emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered });
204
+
205
+ /** The whole call, recursing (at most once) through a rate-limit sleep. */
206
+ const attempt = async () => {
207
+ try {
208
+ return await attemptWithTransport();
209
+ } catch (err) {
210
+ if (rateLimitBudget <= 0 || !isRateLimited(err) || isWallTimeout(err, clock)) throw err;
211
+
212
+ const waitMs = rateLimitWaitMs(err);
213
+ const remainingMs = clock.remainingMs();
214
+ // The wall always wins: no retry when nothing parses to an
215
+ // honourable wait, and none when the run has no more time left
216
+ // than the wait itself would cost.
217
+ if (waitMs == null || (remainingMs !== Infinity && remainingMs <= waitMs)) throw err;
218
+
219
+ rateLimitBudget -= 1;
220
+ const stated = statedWaitMs(err);
221
+ const message = String(err?.message ?? err ?? '').slice(0, 200) + bodySuffix(err);
222
+ await sleepMs(waitMs, undefined, { ref: false });
223
+ try {
224
+ const result = await attempt();
225
+ emit('rate-limit-retry', { phase, attempt: 1, statedMs: stated, waitedMs: waitMs, error: scrub(message), recovered: true });
226
+ return result;
227
+ } catch (err2) {
228
+ emit('rate-limit-retry', { phase, attempt: 1, statedMs: stated, waitedMs: waitMs, error: scrub(message), recovered: false });
229
+ throw err2;
230
+ }
231
+ }
129
232
  };
130
- try {
131
- const result = await transportRetry.call(fn, { retryOn: (err) => isTransportFailure(err) && !isWallTimeout(err, clock) });
132
- report(true);
133
- return result;
134
- } catch (err) {
135
- report(false);
136
- throw err;
137
- }
233
+
234
+ return attempt();
138
235
  };
139
236
  },
140
237
  });
@@ -1432,6 +1529,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1432
1529
  * transport-debugging option list for a run that simply ran out of time. A
1433
1530
  * thrower that named its own category keeps it (the standing typed-attribution
1434
1531
  * rule); only an UNNAMED throw is classified here.
1532
+ *
1533
+ * F146/F149 — this is also the ONE place a categorized error's `.message` gets
1534
+ * the redacted upstream body appended (`bodySuffix`, above). Every downstream
1535
+ * reader of a categorized error's message — `relay()`'s `detail` here, AND
1536
+ * ralph.js's own mid-step dumb-passthrough catch (`detail: String(e.message ||
1537
+ * e)`) reading the SAME error `mkWorker`'s `ask`/`askFrom` threw after routing
1538
+ * it through this function — gets the sentence for free, without ralph.js
1539
+ * importing anything (it stays stdlib-only, PRD §4: fixed, deliberately dumb).
1540
+ * `relay()` re-categorizes an error its own `ask`/`askFrom` already ran through
1541
+ * here, so this must be IDEMPOTENT: `BODY_MARKER` guards a second pass over the
1542
+ * same error from doubling the suffix.
1435
1543
  * @param {any} e
1436
1544
  * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
1437
1545
  * plus the category as a plain string (so a caller reading it never has to
@@ -1442,6 +1550,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1442
1550
  const category = e instanceof HaltError ? 'cap-halt'
1443
1551
  : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
1444
1552
  err.category = category;
1553
+ if (typeof err.message === 'string' && !err.message.includes(BODY_MARKER)) {
1554
+ const suffix = bodySuffix(err);
1555
+ if (suffix) err.message += suffix;
1556
+ }
1445
1557
  return { err, category };
1446
1558
  };
1447
1559
 
@@ -1521,6 +1633,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1521
1633
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1522
1634
  // the same F6 pricing halt rather than being laundered into $0.
1523
1635
  judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1636
+ // F152/PRD 30.4 — the judge's locate call carried NO time bound at all: a
1637
+ // live endpoint that accepts and never answers hung the process forever
1638
+ // (measured), because an open socket is an active handle and no backstop
1639
+ // can fire on it. The SAME derivation every worker round already uses,
1640
+ // handed through as the FUNCTION (not a snapshot) so a retried locate call
1641
+ // re-reads the remaining time rather than reusing the first attempt's.
1642
+ callBounds,
1524
1643
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1525
1644
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1526
1645
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
@@ -2456,7 +2575,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2456
2575
  // re-spends a subscription turn, not a metered fetch) and is out of scope.
2457
2576
  const loopProvider = native
2458
2577
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
2459
- : withTransportRetry(workerProvider ?? provider, phase, emit, scrub, clock);
2578
+ : withProviderRetries(workerProvider ?? provider, phase, emit, scrub, clock);
2460
2579
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
2461
2580
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
2462
2581
  // so a connection that stays alive while producing nothing is invisible to it:
@@ -2498,7 +2617,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2498
2617
  const newLoop = (gen) => {
2499
2618
  /** @type {any} */
2500
2619
  let self = null;
2501
- /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
2620
+ /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string, stopReason?: string|null}} arg */
2502
2621
  const metered = async (arg) => {
2503
2622
  // The meter fires for EVERY generation, live or abandoned. An orphaned round
2504
2623
  // was really billed, and spend that no instrument sees is the one thing this
@@ -2519,6 +2638,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2519
2638
  ...rateSourceFields(arg),
2520
2639
  tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
2521
2640
  usage: usageOf(arg?.usage),
2641
+ // PRD item 28 — BA-13: bare-agent's Loop surfaces the round's own neutral
2642
+ // stop reason ('end_turn'|'max_tokens'|'tool_use'|'refusal'|…) on EVERY
2643
+ // onLlmResult call, forwarded verbatim. REPORT-ONLY (nothing here acts on
2644
+ // it — the Loop itself already refuses to execute a 'max_tokens' round's
2645
+ // tool calls, BA-6); null when bare-agent didn't say (pre-BA-13, or a
2646
+ // non-string value), never invented — the same honesty rule every other
2647
+ // provenance field on this record already follows.
2648
+ stopReason: typeof arg?.stopReason === 'string' ? arg.stopReason : null,
2522
2649
  });
2523
2650
  // Everything BELOW here belongs to the live call only. A superseded call must
2524
2651
  // not beat the watch (that is the whole watchdog), must not spend the step's
@@ -2571,7 +2698,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2571
2698
  */
2572
2699
  const askFrom = async (msgs, prompt) => {
2573
2700
  try {
2574
- return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2701
+ // `msgs` is a prior `loop.run()`'s returned transcript, which already has
2702
+ // the Loop's `system` PREPENDED (bare-agent Loop.run, loop.js ~482-486).
2703
+ // The new Loop built below is constructed with the same `system` and would
2704
+ // prepend it again, leaving two identical system messages at index 0/1.
2705
+ // Real OpenAI tolerates that; vLLM-class OpenAI-compatible backends 400 on
2706
+ // it ("System message must be at the beginning") — captured evidence:
2707
+ // bareloop-patients/spines-poc-openai/qwen-failing-request-x760ei99.json.
2708
+ // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2709
+ // message before re-running for exactly this reason; do the same here.
2710
+ const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2711
+ return await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2575
2712
  } catch (e) {
2576
2713
  throw categorize(e).err;
2577
2714
  }
@@ -2618,6 +2755,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2618
2755
  * worker does, so this path needs the same split or it is the blinder route),
2619
2756
  * and transport. */
2620
2757
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
2758
+ // categorize(e) mutates e.message in place (guarded, idempotent) with the
2759
+ // redacted body suffix — never re-append it here, or a caller whose thrown
2760
+ // error already went through categorize() once (mkWorker's ask/askFrom)
2761
+ // would double it.
2621
2762
  const { category } = categorize(e);
2622
2763
  const detail = String(e?.message ?? e);
2623
2764
  if (category === 'cap-halt') {