bareloop 0.21.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/planrun.js CHANGED
@@ -43,6 +43,10 @@ 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';
48
+ import { checkCloseAbsolutePaths, checkCloseByteSignature, checkStageByteSignature, checkCloseDirRequired } from './close-integrity.js';
49
+ import { resolveCloseTimeoutMs, closeTimeoutBanner, TIMING_PREFLIGHT_CEILING_MS } from './closetimeout.js';
46
50
 
47
51
  const require = createRequire(import.meta.url);
48
52
  const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
@@ -70,21 +74,44 @@ const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
70
74
  const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn: isTransportFailure, timeout: 0 });
71
75
 
72
76
  /**
73
- * Wrap a Loop provider's `generate` so a single transport-class throw (fetch
74
- * itself throwing — no HTTP response was ever produced) gets ONE retry via
75
- * `transportRetry`, and the outcome is reported once the retry is settled.
76
- * Every other property of `baseProvider` (`.name`, `.model`, `.ownsCycle`,
77
- * `.policy`) passes through unchanged — bare-agent's Loop reads those
78
- * 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).
79
85
  *
80
- * F64 carve-out: a wall-DERIVED timeout (bare-agent's own `ETIMEDOUT`/
81
- * `EDEADLINE`, thrown because `clock.callTimeoutMs()` told the provider to
82
- * cut the call — `isWallTimeout`, src/clock.js) is doctrine's governance
83
- * stop, never a transport casualty, and retrying it would both mis-spend a
84
- * round the operator's clock already ended and corrupt `categorize`'s own
85
- * classification (which reads the SAME error a moment later). So the
86
- * per-call `retryOn` here is `isTransportFailure(err) && !isWallTimeout(err,
87
- * 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).
88
115
  *
89
116
  * Scope note: this wraps the ONE seam that builds every worker Loop
90
117
  * (scout/drafter/step-worker/fix-worker, planrun.js `newLoop` ~line 2240) —
@@ -101,38 +128,110 @@ const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn:
101
128
  * @param {(s: string) => string} scrub
102
129
  * @param {import('./clock.js').Clock} clock
103
130
  */
104
- 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) {
105
156
  return new Proxy(baseProvider, {
106
157
  get(target, prop, receiver) {
107
158
  if (prop !== 'generate') return Reflect.get(target, prop, receiver);
108
159
  return async (...args) => {
109
- let attempts = 0;
110
- let firstErr = null;
111
- const fn = async () => {
112
- 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
+ };
113
185
  try {
114
- 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;
115
195
  } catch (err) {
116
- 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
+ }
117
201
  throw err;
118
202
  }
119
203
  };
120
- // `recovered` is knowable only once the retried attempt has settled
121
- // (succeeded or exhausted the budget) — emit exactly once, here,
122
- // after the outcome is known, never speculatively before it.
123
- const report = (recovered) => {
124
- if (attempts <= 1) return; // no retry happened — nothing to report
125
- const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200);
126
- 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
+ }
127
232
  };
128
- try {
129
- const result = await transportRetry.call(fn, { retryOn: (err) => isTransportFailure(err) && !isWallTimeout(err, clock) });
130
- report(true);
131
- return result;
132
- } catch (err) {
133
- report(false);
134
- throw err;
135
- }
233
+
234
+ return attempt();
136
235
  };
137
236
  },
138
237
  });
@@ -640,7 +739,14 @@ ${scoutBlob || '(no scout notes)'}`;
640
739
  * ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
641
740
  * the runner sets it, the plan cannot express it, and no step may tighten or raise
642
741
  * it.
643
- * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
742
+ * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory).
743
+ * OPTIONAL as of PRD item 27/M3: absent, this run autosets it from a $0 seed
744
+ * timing pass (or the spec's own signed `closeTimeoutMs` override) — an
745
+ * explicit value here still wins outright (backward compatibility/test
746
+ * control only, F133: `scripts/run-u.mjs`/`src/cli.js` never pass this).
747
+ * @param {string|null} [opts.closeDir] the close's own books directory (PRD
748
+ * item 27/M3 Part B), forwarded to every close invocation as
749
+ * `BARELOOP_CLOSE_DIR`
644
750
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
645
751
  * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md):
646
752
  * `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
@@ -811,12 +917,18 @@ ${scoutBlob || '(no scout notes)'}`;
811
917
  * precisely the state the person rejected), and the words reach the PLANNER as new
812
918
  * authoring. It is never a `humanRuling` — that answers a close's human STAGE, and
813
919
  * a green-class job has none. Empty words are refused at the same seam every door is.
920
+ * @param {boolean} [opts.resumable=true] PRD item 27(c)/F130 — does THIS runner
921
+ * support `--resume`? Forwarded verbatim from `runJob`; `run-u.mjs` leaves it at
922
+ * the default (byte-identical to before this param existed), the bundle CLI
923
+ * passes `false` so the three `--resume`-naming readouts below (`MONEY_OPTIONS`,
924
+ * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
925
+ * in v1" instead of naming a flag that would fail if typed.
814
926
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
815
927
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
816
928
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
817
929
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
818
930
  */
819
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
931
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
820
932
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
821
933
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
822
934
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -916,6 +1028,159 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
916
1028
  return 'close-unsupported';
917
1029
  }
918
1030
 
1031
+ // ── close-absolute-path (PRD item 27(c), F129's general fix): the SAME
1032
+ // detector `src/bundle.js` already applies at export time, now applied for
1033
+ // EVERY job at run start, before the close-first precheck and before any
1034
+ // provider call. A close script whose CONTENT bakes in an absolute path
1035
+ // that exists on disk judges the wrong tree by construction (F129) — the
1036
+ // spec's own `cmd` field naming the script is the address and stays legal;
1037
+ // only the script's bytes are judged. `readCloseScripts`/
1038
+ // `checkCloseAbsolutePaths` live in `src/close-integrity.js` so this and
1039
+ // `exportBundle` never drift into two spellings of the same rule.
1040
+ {
1041
+ const integrity = checkCloseAbsolutePaths(job, workdir);
1042
+ if (!integrity.ok) {
1043
+ const detail = integrity.reds.map((r) => `${r.stage}: "${r.literal}"`).join('; ');
1044
+ emit('escalation', {
1045
+ category: 'close-absolute-path', decisionReady: true,
1046
+ decision: 'A close script bakes in an absolute path that exists on disk — it would judge the wrong tree by construction (F129), so nothing was run and nothing was spent.',
1047
+ options: ['fix the close script to read the cwd it is given, never a baked-in path', 'abandon the task'],
1048
+ detail,
1049
+ });
1050
+ return 'close-absolute-path';
1051
+ }
1052
+ }
1053
+
1054
+ // ── close-tampered (PRD item 27/M2, N4's own general fix): the job's
1055
+ // signature covers a close STAGE's byte-exact CONTENT (`close[].sha256`),
1056
+ // not only the `cmd` path naming it — `jobSpecHash` already covers this
1057
+ // field with no hash-function change (it sits inside the signed spec), so
1058
+ // the only thing left to verify is that the SCRIPT ON DISK still is what
1059
+ // was signed. Same seam as `close-absolute-path` immediately above (run
1060
+ // start, $0, before the close-first precheck and before any provider
1061
+ // call) and the same detector module — `checkCloseByteSignature` reuses
1062
+ // `readCloseScripts`, so this and the runtime re-verify inside
1063
+ // `runCloseStages` below can never drift into two spellings of "which
1064
+ // bytes were signed".
1065
+ {
1066
+ const sig = checkCloseByteSignature(job, workdir);
1067
+ if (!sig.ok) {
1068
+ const detail = sig.reds.map((r) => `${r.stage}: expected ${r.expected.slice(0, 12)}… got ${r.actual ? `${r.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
1069
+ emit('escalation', {
1070
+ category: 'close-tampered', decisionReady: true,
1071
+ decision: "A close script's bytes no longer match its signed sha256 — it would run something other than what was approved, so nothing was run and nothing was spent.",
1072
+ options: ['restore the close script to its signed bytes', 're-sign the close if the change is intentional (a new spec hash needs re-approval)', 'abandon the task'],
1073
+ detail,
1074
+ });
1075
+ return 'close-tampered';
1076
+ }
1077
+ }
1078
+
1079
+ // ── close-dir-required (PRD item 27/M3 Part B): a close script that reads
1080
+ // its own books directory (`BARELOOP_CLOSE_DIR`) and gets none would run
1081
+ // silently against `undefined` — refuse at the same $0 seam as the two
1082
+ // checks immediately above, before any tokens spend.
1083
+ {
1084
+ const dirCheck = checkCloseDirRequired(job, workdir, closeDir);
1085
+ if (!dirCheck.ok) {
1086
+ const detail = dirCheck.reds.map((r) => `${r.stage}: ${r.path}`).join('; ');
1087
+ emit('escalation', {
1088
+ category: 'close-dir-required', decisionReady: true,
1089
+ decision: 'A close script reads BARELOOP_CLOSE_DIR (its own books directory, deliberately outside the patient tree) but the runner was given none — nothing was run and nothing was spent.',
1090
+ options: ['run this job through a runner that sets closeDir (run-u.mjs, or the bundle CLI)', 'abandon the task'],
1091
+ detail,
1092
+ });
1093
+ return 'close-dir-required';
1094
+ }
1095
+ }
1096
+
1097
+ // ── close timeout: autoset + signed override (PRD item 27/M3). hamr,
1098
+ // 2026-09-06: "both … autoset and can be override, same like api pricing".
1099
+ // Constants (`CLOSE_TIMEOUT_FLOOR_MS`, `CLOSE_TIMEOUT_K`) are hamr's own
1100
+ // arbiter constants (2026-09-07) — never agent- or adopter-settable.
1101
+ //
1102
+ // Precedence, all $0, before any provider call:
1103
+ // 1. an EXPLICIT `closeTimeoutMs` runtime option (the pre-M3 shell/test
1104
+ // knob) wins outright — kept for backward compatibility and for tests
1105
+ // that force a specific close-timeout scenario; it is never the signed
1106
+ // spec field and prints its own honest word rather than "signed
1107
+ // override". F133 (run mtqwmb9l): this used to also be how
1108
+ // `scripts/run-u.mjs` fed its OWN pre-resolved ceiling through, which
1109
+ // printed the number correctly here but under the wrong word
1110
+ // ("explicit runner override") beside run-u's own already-correct
1111
+ // "estimated"/"signed override" line — two banners for one number,
1112
+ // the second one lying about its source. Fixed by making this the
1113
+ // ONLY caller of `closeTimeoutMs` an operator/runner is meant to use:
1114
+ // `run-u.mjs` no longer resolves-and-passes it at all (see its own
1115
+ // comment); this tier is test/back-compat-only again.
1116
+ // 2. the SIGNED spec field `job.closeTimeoutMs` — the operator's own
1117
+ // number, which may legally sit above OR below the autoset estimate
1118
+ // (F113's rates-passthrough shape).
1119
+ // 3. AUTOSET — a $0 timing pass runs every stage once (ignoring verdicts;
1120
+ // first-red-wins stays for the real precheck below), under the
1121
+ // generous provisional ceiling `TIMING_PREFLIGHT_CEILING_MS` so a
1122
+ // genuinely hung stage still ends. `max(FLOOR, K × slowest)` becomes
1123
+ // the ceiling every close run in this call uses, and a stage that
1124
+ // timed out DURING THE TIMING PASS is `close-timing-red` — the close
1125
+ // cannot run on this machine, and the provider was never called.
1126
+ //
1127
+ // The pass is SKIPPED whenever an override already exists (case 1 or 2):
1128
+ // running every stage a second time just to throw the reading away would
1129
+ // waste real machine time before any provider call, and — measured risk —
1130
+ // a close stage a test or an operator deliberately made slow/hanging would
1131
+ // then block for up to 10 minutes for no purpose. "Guesstimate only when no
1132
+ // rate is given" is the same doctrine F113 already applies to pricing.
1133
+ {
1134
+ const explicitOverride = typeof closeTimeoutMs === 'number' && Number.isFinite(closeTimeoutMs) ? closeTimeoutMs : null;
1135
+ if (explicitOverride !== null) {
1136
+ // A caller-passed runtime option wins outright and skips the timing
1137
+ // pass entirely — this is the pre-M3 shell/test knob (backward
1138
+ // compatibility only, F133: no production runner passes this anymore —
1139
+ // `scripts/run-u.mjs` and `src/cli.js` both leave this call site
1140
+ // nothing to autoset from but the autoset/signed-override tiers below).
1141
+ closeTimeoutMs = explicitOverride;
1142
+ emit('close-timing', { perStage: [], slowestMs: null, slowestName: null, ceilingMs: closeTimeoutMs, source: 'explicit' });
1143
+ console.log(closeTimeoutBanner({ ceilingMs: closeTimeoutMs, source: 'explicit' }));
1144
+ } else {
1145
+ const resolved = await resolveCloseTimeoutMs({ job, stages: stagedClose, cwd: workdir, redact: scrub });
1146
+ if (resolved.timedOut) {
1147
+ // `timedOut` is only ever true alongside a real `timing` reading
1148
+ // (`resolveCloseTimeoutMs` never sets one without the other) — the
1149
+ // cast states that correlation for the checker, which cannot infer
1150
+ // it across the two independent optional fields on its own.
1151
+ const timing = /** @type {NonNullable<typeof resolved.timing>} */ (resolved.timing);
1152
+ const detail = timing.perStage.filter((s) => s.timedOut).map((s) => s.name).join(', ');
1153
+ emit('close-timing', { perStage: timing.perStage, slowestMs: timing.slowestMs, slowestName: timing.slowestName, ceilingMs: null, source: 'estimated' });
1154
+ emit('escalation', {
1155
+ category: 'close-timing-red', decisionReady: true,
1156
+ decision: `A close stage did not finish within the timing preflight's own ceiling (${TIMING_PREFLIGHT_CEILING_MS}ms) — the close cannot run on this machine in a boundable time, so nothing was run and nothing was spent.`,
1157
+ options: ['investigate why the stage hangs (infra/network/resource issue)', 'sign an explicit closeTimeoutMs override once the real duration is known', 'abandon the task'],
1158
+ detail,
1159
+ });
1160
+ return 'close-timing-red';
1161
+ }
1162
+ closeTimeoutMs = /** @type {number} */ (resolved.closeTimeoutMs);
1163
+ emit('close-timing', { ...(resolved.timing ?? { perStage: [], slowestMs: null, slowestName: null, anyTimedOut: false }), ceilingMs: closeTimeoutMs, source: resolved.source });
1164
+ console.log(closeTimeoutBanner({ ceilingMs: closeTimeoutMs, source: resolved.source, slowestMs: resolved.timing?.slowestMs, slowestName: resolved.timing?.slowestName }));
1165
+ }
1166
+ }
1167
+
1168
+ // ── the wall must be able to fund at least one close under the EFFECTIVE
1169
+ // ceiling just computed (PRD item 27/M3): `job.maxWallMs`'s own static
1170
+ // floor (`MIN_WALL_MS`, src/job.js) assumed the library's un-autoset
1171
+ // default — with autoset, the real per-stage ceiling is only known here, at
1172
+ // run start, so the honest shape is a run-start check, never a silent
1173
+ // clamp (a budget under one close cannot fund its own close, verbatim the
1174
+ // reasoning `maxWallMs`'s own validator red already states).
1175
+ if (typeof job.maxWallMs === 'number' && job.maxWallMs < closeTimeoutMs) {
1176
+ emit('escalation', {
1177
+ category: 'wall-under-close-timeout', decisionReady: true,
1178
+ decision: `maxWallMs (${job.maxWallMs}ms) is under the effective close timeout (${closeTimeoutMs}ms) — a budget under one close cannot fund its own close, so nothing was run and nothing was spent.`,
1179
+ options: ['raise maxWallMs to at least the printed close timeout and rerun (a spec edit, so the new hash needs re-approval)', 'sign a lower closeTimeoutMs override if the measured duration allows it (same re-approval)', 'abandon the task'],
1180
+ });
1181
+ return 'wall-under-close-timeout';
1182
+ }
1183
+
919
1184
  // ── THE SIGNER'S ANSWER (N4 §1.4), read before anything costs anything.
920
1185
  //
921
1186
  // Three gates, and each refuses rather than guesses. A decision that is not one
@@ -1264,6 +1529,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1264
1529
  * transport-debugging option list for a run that simply ran out of time. A
1265
1530
  * thrower that named its own category keeps it (the standing typed-attribution
1266
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.
1267
1543
  * @param {any} e
1268
1544
  * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
1269
1545
  * plus the category as a plain string (so a caller reading it never has to
@@ -1274,6 +1550,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1274
1550
  const category = e instanceof HaltError ? 'cap-halt'
1275
1551
  : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
1276
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
+ }
1277
1557
  return { err, category };
1278
1558
  };
1279
1559
 
@@ -1300,6 +1580,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1300
1580
  'revise the goal/spec so the work fits the time (same re-approval)',
1301
1581
  'abandon the task',
1302
1582
  ];
1583
+ /** PRD item 27(c)/F130 — the honest escalation tail. `run-u.mjs` supports
1584
+ * `--resume`; the exported bundle CLI does not (v1) and would print a flag
1585
+ * that fails if typed. `resumable` (default `true`, `run-u`'s existing
1586
+ * lines unchanged) picks which sentence names the resume lever. */
1587
+ const resumeToCapNote = resumable
1588
+ ? 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)'
1589
+ : 'resume is `run-u`-only in v1 — top up budgetUsd and re-fire the bundle from the start (a spec edit, so the new hash needs re-approval)';
1303
1590
  /** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
1304
1591
  * money cut is the same KIND of stop: the run is out of an operator-owned
1305
1592
  * allowance, not out of capability, and the last verdict rendered stands. hamr's
@@ -1310,12 +1597,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1310
1597
  * hash, so raising it is a new spec version somebody signs — the library only ever
1311
1598
  * names the lever, and never adjusts a budget itself (the permanent hard line). */
1312
1599
  const MONEY_OPTIONS = [
1313
- 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
1600
+ resumeToCapNote,
1314
1601
  'revise the goal/spec so the work fits the budget (same re-approval)',
1315
1602
  'abandon the task',
1316
1603
  ];
1317
1604
 
1318
- const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
1605
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, closeDir };
1319
1606
  /** D8 — the DECLARED close's seed, filled in below before anything runs.
1320
1607
  * `null` for a command close, which has no baseline to measure against. */
1321
1608
  /** @type {{seedRef: string}|null} */
@@ -1329,7 +1616,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1329
1616
  * The command path is byte-identical to what it was: same call, same options.
1330
1617
  * @param {any[]} stages
1331
1618
  */
1332
- const runCloseStages = (stages) => (declared
1619
+ const runCloseStagesInner = (stages) => (declared
1333
1620
  ? runDeclaredStages(stages, scrub, {
1334
1621
  ...closeOpts,
1335
1622
  seedRef: /** @type {any} */ (declaredCtx).seedRef,
@@ -1346,6 +1633,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1346
1633
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1347
1634
  // the same F6 pricing halt rather than being laundered into $0.
1348
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,
1349
1643
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1350
1644
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1351
1645
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
@@ -1359,6 +1653,34 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1359
1653
  }),
1360
1654
  })
1361
1655
  : runStages(stages, scrub, closeOpts));
1656
+ /**
1657
+ * PRD item 27/M2 — the re-verify that runs before EVERY close run, precheck
1658
+ * included: `runCloseStagesInner` is the ONE seam every close execution in
1659
+ * this runner goes through (the precheck's `judgeClose`, the preflight
1660
+ * loop, the check-passes seam, and the close-fix loop via its own `judge`
1661
+ * seam), so wrapping it here covers all of them without threading a
1662
+ * verify call through each call site separately — the same reasoning
1663
+ * `checkCloseAbsolutePaths`'s run-start placement already relies on (W4:
1664
+ * "the staging itself lives in ONE place").
1665
+ *
1666
+ * A mismatch never reaches the executor: it returns a `close-tampered`
1667
+ * verdict in the SAME shape `runClose`/`runStages` render (`{verdict,
1668
+ * stage, detail}`), so every downstream reader that already keys off
1669
+ * `Object.hasOwn(CLOSE_FAULTS, v.verdict)` (the precheck, the preflight
1670
+ * loop, the post-fix read, and ralph's own judge-fault routing) treats it
1671
+ * exactly like `crashed`/`timed-out`/`killed` — a forbidden-zone outcome,
1672
+ * never retried, never fed back as a gap.
1673
+ * @param {any[]} stages
1674
+ */
1675
+ const runCloseStages = async (stages) => {
1676
+ const sig = checkStageByteSignature(stages, workdir);
1677
+ if (!sig.ok) {
1678
+ const r = sig.reds[0];
1679
+ const detail = sig.reds.map((rr) => `${rr.stage}: expected ${rr.expected.slice(0, 12)}… got ${rr.actual ? `${rr.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
1680
+ return { verdict: 'close-tampered', stage: r?.stage, detail };
1681
+ }
1682
+ return runCloseStagesInner(stages);
1683
+ };
1362
1684
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
1363
1685
  let closeStage;
1364
1686
  /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
@@ -2253,7 +2575,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2253
2575
  // re-spends a subscription turn, not a metered fetch) and is out of scope.
2254
2576
  const loopProvider = native
2255
2577
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
2256
- : withTransportRetry(workerProvider ?? provider, phase, emit, scrub, clock);
2578
+ : withProviderRetries(workerProvider ?? provider, phase, emit, scrub, clock);
2257
2579
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
2258
2580
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
2259
2581
  // so a connection that stays alive while producing nothing is invisible to it:
@@ -2295,7 +2617,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2295
2617
  const newLoop = (gen) => {
2296
2618
  /** @type {any} */
2297
2619
  let self = null;
2298
- /** @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 */
2299
2621
  const metered = async (arg) => {
2300
2622
  // The meter fires for EVERY generation, live or abandoned. An orphaned round
2301
2623
  // was really billed, and spend that no instrument sees is the one thing this
@@ -2316,6 +2638,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2316
2638
  ...rateSourceFields(arg),
2317
2639
  tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
2318
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,
2319
2649
  });
2320
2650
  // Everything BELOW here belongs to the live call only. A superseded call must
2321
2651
  // not beat the watch (that is the whole watchdog), must not spend the step's
@@ -2368,7 +2698,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2368
2698
  */
2369
2699
  const askFrom = async (msgs, prompt) => {
2370
2700
  try {
2371
- 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() }));
2372
2712
  } catch (e) {
2373
2713
  throw categorize(e).err;
2374
2714
  }
@@ -2415,6 +2755,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2415
2755
  * worker does, so this path needs the same split or it is the blinder route),
2416
2756
  * and transport. */
2417
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.
2418
2762
  const { category } = categorize(e);
2419
2763
  const detail = String(e?.message ?? e);
2420
2764
  if (category === 'cap-halt') {
@@ -3462,7 +3806,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3462
3806
  : `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
3463
3807
  options: [
3464
3808
  'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
3465
- 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
3809
+ resumable
3810
+ ? 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging'
3811
+ : 'resume is `run-u`-only in v1 — top up budgetUsd and re-fire the bundle from the start, if the trend above says it was still converging',
3466
3812
  'abandon the task',
3467
3813
  ],
3468
3814
  };
@@ -3576,7 +3922,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3576
3922
  // is not passed — it survives only inside the governor, as the bound for the
3577
3923
  // case the trend instrument is blind.
3578
3924
  middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
3579
- closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
3925
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir,
3580
3926
  });
3581
3927
  } catch (e) {
3582
3928
  planExecuted();