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.
@@ -0,0 +1,176 @@
1
+ // Provider factory (PRD item 28, shape (1)): ONE seam mapping a job spec's
2
+ // `provider` name to the bare-agent constructor it drives, the env var
3
+ // carrying its key, and the per-tier model table — including any
4
+ // provider-specific request-key gating. Before this module existed, both
5
+ // runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
6
+ // `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
7
+ // provider would have meant scattering `if (provider === …)` across both.
8
+ //
9
+ // hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
10
+ // provider — "settle on one secondary/replacement for claude … don't
11
+ // pickpocket random models". This table is not a menu of half-tested
12
+ // models; it is exactly the two provider identities the PROVIDERS menu
13
+ // (`src/job.js`) admits.
14
+ //
15
+ // What this module is NOT: it never picks a judge (the judge stays pinned
16
+ // to `anthropic-api`/`JUDGE_MODEL` regardless of the job's worker provider —
17
+ // arbiter territory, PRD item 28), never prices a round (F113: rates
18
+ // passthrough is dead), and never decides a budget. It only constructs the
19
+ // provider objects `bare-agent`'s `Loop` calls.
20
+
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const { AnthropicProvider, OpenAIProvider } = require('bare-agent/providers');
25
+
26
+ /**
27
+ * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
28
+ * runners hardcoded before this factory existed. Any change to these two
29
+ * values is a change to shipped anthropic-api behaviour, not a refactor.
30
+ */
31
+ export const ANTHROPIC_TIER_MODELS = Object.freeze({
32
+ sonnet: 'claude-sonnet-5',
33
+ haiku: 'claude-haiku-4-5-20251001',
34
+ });
35
+
36
+ /**
37
+ * DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
38
+ * ruling above: one secondary provider, not a menu. Both tiers map to the
39
+ * same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
40
+ * (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
41
+ * scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
42
+ * different models — so repeating the one id is the honest representation
43
+ * of a single-model provider, not a second model masquerading as `haiku`.
44
+ * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
45
+ * cheaper tier model, this is where it would earn its own `haiku` id.
46
+ */
47
+ export const OPENAI_TIER_MODELS = Object.freeze({
48
+ sonnet: 'deepseek-chat',
49
+ haiku: 'deepseek-chat',
50
+ });
51
+
52
+ /**
53
+ * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
54
+ * silently IGNORES the modern `max_completion_tokens` key — asked for 64
55
+ * output tokens, it returned 665/608 — and only honours the legacy
56
+ * `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
57
+ * switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
58
+ * bare-agent itself — "the caller declares the dialect"), so THIS table is
59
+ * bareloop's routing of it, keyed per model, never a global default. An
60
+ * output cap that does not bind is a money hazard: a reasoning model can
61
+ * run to its own ceiling on every round, silently, so getting this wrong
62
+ * for even one model defeats every budget that model's tier sets.
63
+ *
64
+ * A model with no entry here gets `{}` — bare-agent's own default
65
+ * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
66
+ */
67
+ const OPENAI_MODEL_OPTIONS = Object.freeze({
68
+ 'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
69
+ });
70
+
71
+ /**
72
+ * @typedef {object} ProviderTableEntry
73
+ * @property {new (options: any) => any} ctor the bare-agent provider class
74
+ * @property {string} envKey the environment variable this provider reads its key from
75
+ * @property {Readonly<Record<string, string>>} tiers tier name -> model id
76
+ * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, baseUrl?}
77
+ */
78
+
79
+ /** @type {Readonly<Record<string, ProviderTableEntry>>} */
80
+ const PROVIDER_TABLE = Object.freeze({
81
+ 'anthropic-api': Object.freeze({
82
+ ctor: AnthropicProvider,
83
+ envKey: 'ANTHROPIC_API_KEY',
84
+ tiers: ANTHROPIC_TIER_MODELS,
85
+ // Anthropic has only ever had one request key for the output cap
86
+ // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
87
+ // backends, which fork on `max_tokens` vs `max_completion_tokens`).
88
+ paramsFor: () => ({}),
89
+ }),
90
+ 'openai-api': Object.freeze({
91
+ ctor: OpenAIProvider,
92
+ envKey: 'OPENAI_API_KEY',
93
+ tiers: OPENAI_TIER_MODELS,
94
+ paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
95
+ }),
96
+ });
97
+
98
+ /**
99
+ * Look up a provider's table entry. Throws a NAMED error on an unrecognized
100
+ * provider — never a silent default (the `resolveRates`-style-substring-
101
+ * match lesson: a forward hazard even at zero observed rounds, MEMORY.md).
102
+ * `src/job.js`'s `PROVIDERS` menu is the one place a spec's `provider`
103
+ * field is validated; this is the runtime construction seam it feeds.
104
+ * @param {string} providerName
105
+ * @returns {ProviderTableEntry}
106
+ */
107
+ export function resolveProvider(providerName) {
108
+ const entry = PROVIDER_TABLE[/** @type {keyof typeof PROVIDER_TABLE} */ (providerName)];
109
+ if (!entry) {
110
+ throw new Error(
111
+ `src/providers.js: unknown provider "${providerName}" — no silent default. `
112
+ + `Known providers: ${Object.keys(PROVIDER_TABLE).join(', ')}`,
113
+ );
114
+ }
115
+ return entry;
116
+ }
117
+
118
+ /**
119
+ * Construct ONE provider instance for `providerName`/`model`, applying
120
+ * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
121
+ * and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
122
+ * 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
123
+ * every provider constructor here defaults it on its own when absent.
124
+ * @param {string} providerName
125
+ * @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
126
+ */
127
+ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
128
+ const entry = resolveProvider(providerName);
129
+ return new entry.ctor({
130
+ exposeErrorBody: true,
131
+ apiKey,
132
+ model,
133
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
134
+ ...entry.paramsFor(model),
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Build the `{provider, providerFor, judgeProvider}` triple both runners
140
+ * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
141
+ * wiring) — ONE seam instead of two copies of the same construction and
142
+ * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
143
+ * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
144
+ * before this factory existed — that decision stays where each runner's own
145
+ * `--model` flag/spec precedence lives, not duplicated in here).
146
+ *
147
+ * The JUDGE stays pinned to `anthropic-api`/`judgeModel` regardless of
148
+ * `providerName` — arbiter territory (PRD item 28: "the judge stays PINNED
149
+ * … regardless of the job's worker provider"). A signed `judge:{provider,
150
+ * model}` field with its own calibration record is PRD item 28 part (2),
151
+ * NOT built here; until it lands, the judge always needs its own
152
+ * `judgeApiKey` (today, always `ANTHROPIC_API_KEY`), independent of which
153
+ * key the worker used.
154
+ * @param {object} args
155
+ * @param {string} args.providerName spec.provider (or the anthropic-api default)
156
+ * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
157
+ * @param {string} args.model the already-resolved worker model id
158
+ * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
159
+ * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
160
+ * @param {string|undefined} args.judgeApiKey the judge's own key (today: `ANTHROPIC_API_KEY`)
161
+ * @param {string} args.judgeModel `JUDGE_MODEL`
162
+ */
163
+ export function buildRunnerProviders({
164
+ providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
165
+ }) {
166
+ const provider = makeProvider(providerName, { apiKey, model, baseUrl });
167
+ /** @type {Record<string, any>} */
168
+ const tierCache = {};
169
+ const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
170
+ tierModels[tier] === model
171
+ ? provider
172
+ : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
173
+ ));
174
+ const judgeProvider = makeProvider('anthropic-api', { apiKey: judgeApiKey, model: judgeModel });
175
+ return { provider, providerFor, judgeProvider };
176
+ }
@@ -0,0 +1,116 @@
1
+ // PRD item 28's parked (a/b) ruling, hamr's verbatim call: "do a/b as a
2
+ // fallback, and verify/validate + no regression." A vendor HTTP 429 is a
3
+ // response — the provider answered, unlike src/transport.js's no-response
4
+ // class — but unlike every other status this repo already declines to retry
5
+ // (4xx/5xx generally, F115's own boundary), a 429 carries the ONE piece of
6
+ // information that turns "retry blindly" into "retry honestly": the vendor's
7
+ // own stated wait. This module is transport.js's SIBLING, not an extension
8
+ // of it — `isTransportFailure` must keep returning FALSE for anything
9
+ // carrying an HTTP status (429 included), and nothing here widens that.
10
+ //
11
+ // This is a FALLBACK, not the primary answer. The primary answer (F139,
12
+ // measured 2026-09-08) is picking a worker model with adequate rate
13
+ // headroom: gpt-4.1 = 30,000 TPM (429s deterministically on our workload,
14
+ // 2 of 2 runs), gpt-4.1-mini = 200,000 TPM, gpt-5-mini/gpt-5 = 500,000 TPM.
15
+ // One bounded retry only softens the failure mode when that primary answer
16
+ // was not followed (or headroom was still exceeded); it does not replace it.
17
+
18
+ /** The fixed retry budget: ONE extra attempt after a 429. Never read from a
19
+ * job spec or CLI flag — raising it is arbiter territory, mirroring
20
+ * transport.js's TRANSPORT_RETRIES exactly (tighten-only doctrine). */
21
+ export const RATE_LIMIT_RETRIES = 1;
22
+
23
+ /** bare-agent's `Retry.call` (if ever used) counts ATTEMPTS, not retries;
24
+ * this module's own hand-rolled ladder (src/planrun.js) does the same for
25
+ * symmetry with TRANSPORT_MAX_ATTEMPTS. */
26
+ export const RATE_LIMIT_MAX_ATTEMPTS = RATE_LIMIT_RETRIES + 1;
27
+
28
+ /** The hard cap on how long we will ever wait for a vendor-stated 429 delay.
29
+ * A vendor asking for longer than this is not honoured — we do not retry at
30
+ * all in that case. Waiting that long is the operator's decision, not ours
31
+ * (arbiter territory: nothing here escalates the wait past what hamr set). */
32
+ export const RATE_LIMIT_MAX_WAIT_MS = 60_000;
33
+
34
+ /** Used only when the vendor's message carries no parseable delay at all. */
35
+ export const RATE_LIMIT_DEFAULT_WAIT_MS = 5_000;
36
+
37
+ /** A parsed delay is the EARLIEST moment the vendor's own window resets, not
38
+ * a safe one to fire on the dot — this margin is added to any PARSED value
39
+ * (never to the default) so the retry lands just after the window, not on
40
+ * its leading edge. Never allowed to push a parsed value over the cap. */
41
+ const PARSED_DELAY_MARGIN_MS = 250;
42
+
43
+ /**
44
+ * Is `err` an HTTP 429 (rate-limited) response? True ONLY for an explicit
45
+ * status of 429 on the error itself. Nothing else qualifies — not message
46
+ * text (a message merely containing "429" is not this), not `retryable`,
47
+ * not any other status. Symmetric with `isTransportFailure`'s own status
48
+ * check (src/transport.js), just reading the opposite branch of it.
49
+ * @param {any} err
50
+ * @returns {boolean}
51
+ */
52
+ export function isRateLimited(err) {
53
+ if (err == null) return false;
54
+ const status = err.status ?? err.statusCode;
55
+ return status === 429;
56
+ }
57
+
58
+ /**
59
+ * The vendor's RAW stated wait, in milliseconds, parsed straight from the
60
+ * error message — no default, no margin, no cap. `null` when nothing in the
61
+ * message matches a known shape. Exported separately from `rateLimitWaitMs`
62
+ * so a caller that wants to report the vendor's own number on the spine
63
+ * (`statedMs`) never has to re-derive it from the margin-adjusted figure.
64
+ *
65
+ * bare-agent does not expose the vendor's `retry-after` /
66
+ * `x-ratelimit-reset-*` response headers on the thrown error (BA-26) — the
67
+ * delay exists only in the vendor's English message text, so this parses
68
+ * that text. Verified real shape (OpenAI, run `8ev2sdkn`, 2026-09-08):
69
+ * "Please try again in 11.996s." This also supports "try again in 1.5
70
+ * seconds", "try again in 20ms", "retry after 30s", and "retry after 30
71
+ * seconds" — case-insensitive.
72
+ * @param {any} err
73
+ * @returns {number|null}
74
+ */
75
+ export function statedWaitMs(err) {
76
+ const message = String(err?.message ?? err ?? '');
77
+ const match = message.match(/(?:try again in|retry after)\s*([\d.]+)\s*(ms|milliseconds?|s|seconds?)?/i);
78
+ if (!match) return null;
79
+
80
+ const value = Number(match[1]);
81
+ if (!Number.isFinite(value) || value < 0) return null;
82
+
83
+ const unit = (match[2] ?? 's').toLowerCase();
84
+ const isMs = unit === 'ms' || unit.startsWith('millisecond');
85
+ return isMs ? value : value * 1000;
86
+ }
87
+
88
+ /**
89
+ * How long to wait before retrying a 429, in milliseconds — or `null`
90
+ * meaning "do not retry at all" (either the parsed wait exceeds
91
+ * `RATE_LIMIT_MAX_WAIT_MS`, in which case waiting that long is the
92
+ * operator's decision, not ours).
93
+ *
94
+ * A parsed value gets `PARSED_DELAY_MARGIN_MS` added (the vendor's stated
95
+ * number is the earliest moment its window resets, not a safe one to fire
96
+ * on), capped so the margin itself can never push a parsed value over
97
+ * `RATE_LIMIT_MAX_WAIT_MS`. When nothing parses, `RATE_LIMIT_DEFAULT_WAIT_MS`
98
+ * is returned as-is, with no margin added (there is nothing to add margin
99
+ * to — it is already a conservative guess, not an earliest-moment reading).
100
+ * @param {any} err
101
+ * @returns {number|null}
102
+ */
103
+ export function rateLimitWaitMs(err) {
104
+ const parsedMs = statedWaitMs(err);
105
+ if (parsedMs === null) {
106
+ return RATE_LIMIT_DEFAULT_WAIT_MS <= RATE_LIMIT_MAX_WAIT_MS ? RATE_LIMIT_DEFAULT_WAIT_MS : null;
107
+ }
108
+
109
+ // The RAW parsed value is what is judged against the cap ("a vendor asking
110
+ // for longer than this is not honoured") — the margin is added only AFTER
111
+ // that gate, and clamped so it can never itself push the result past the
112
+ // cap, but it never turns an already-honoured wait into a refusal.
113
+ if (parsedMs > RATE_LIMIT_MAX_WAIT_MS) return null;
114
+ if (parsedMs === 0) return 0;
115
+ return Math.min(parsedMs + PARSED_DELAY_MARGIN_MS, RATE_LIMIT_MAX_WAIT_MS);
116
+ }
package/src/reuse.js CHANGED
@@ -60,6 +60,7 @@ import { readGrade } from './trend.js';
60
60
  import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
61
61
  import { extractArtifact, priceOf } from './text.js';
62
62
  import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
63
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
63
64
 
64
65
  const require = createRequire(import.meta.url);
65
66
  const { Loop } = require('bare-agent');
@@ -416,7 +417,14 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
416
417
  let result;
417
418
  try {
418
419
  const loop = new Loop({ provider });
419
- result = await loop.run([{ role: 'user', content: prompt }], [], { maxTokens: SELECTION_MAX_TOKENS });
420
+ // F152/PRD 30.4 — the picker's call carried no time bound at all: a live
421
+ // endpoint that accepts and never answers hangs the process forever
422
+ // (measured elsewhere in this repo). This runs BETWEEN bridge tries, not
423
+ // inside any one try's own signed `perTryWallMs`, so there is no wall to
424
+ // derive a tighter number from — the same "no clock here" reasoning
425
+ // `authorscout.js` documents, and the same fallback: the idle-timeout
426
+ // default the run path itself uses when ITS OWN wall is unbounded.
427
+ result = await loop.run([{ role: 'user', content: prompt }], [], { maxTokens: SELECTION_MAX_TOKENS, timeoutMs: PROVIDER_TIMEOUT_MS });
420
428
  } catch (e) {
421
429
  // a throw out of the selection call is TRANSPORT — a casualty, and the run stops on
422
430
  // it rather than quietly drafting cold on a decision that was never made
package/src/run.js CHANGED
@@ -228,6 +228,14 @@ async function primitiveSmoke(workdir) {
228
228
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
229
229
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
230
230
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
231
+ * NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
232
+ * 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
233
+ * function's normal control flow when the process drains with no `job-end` at
234
+ * all (a provider promise that never settled). It never appears as this
235
+ * function's own RETURN value (nothing is left to receive it), only as the
236
+ * spine's terminal record. Non-zero exit, `spendComplete:false` always, and
237
+ * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
238
+ * unknown in-flight state is not a known-safe resume point.
231
239
  */
232
240
  export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
233
241
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
@@ -345,6 +353,15 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
345
353
  // than guessing.
346
354
  verdictType: job.verdictType,
347
355
  ...(typeof provider?.model === 'string' ? { model: provider.model } : {}),
356
+ // PRD item 28 / F156 — WHICH provider actually ran, and against which
357
+ // endpoint. Report-only, but without it an archived spine cannot be
358
+ // segmented by provider at all: every cost, duration and outcome row from
359
+ // the day openai-api joined the menu would pool anthropic and non-anthropic
360
+ // runs into one contaminated aggregate, which is the reading error this repo
361
+ // has already paid for once. `baseUrl` rides along only when the job set one
362
+ // (an absent baseUrl is the vendor's own host, not an unknown).
363
+ provider: typeof job.provider === 'string' ? job.provider : null,
364
+ ...(typeof job.baseUrl === 'string' ? { baseUrl: job.baseUrl } : {}),
348
365
  // F118: replay reads this; report-only. `dirty` is deliberately omitted —
349
366
  // it can't be known without a shell, and null = unknown, never faked.
350
367
  code: (() => { const { version, sha } = codeVersion(); return { version, sha }; })(),
@@ -380,12 +397,46 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
380
397
  : {}),
381
398
  });
382
399
 
400
+ // F140/PRD item 28 (c) — the backstop that fires on the ABSENCE of a
401
+ // `job-end`. A provider promise that never settles (BA-25's class; 0.42.0
402
+ // closed the known drop cases per F141 but the class itself is "an
403
+ // instrument fires on absence", not a specific bug) lets node drain with
404
+ // nothing else holding the event loop, and the process exits with no
405
+ // terminal record at all — no escalation, no outcome, no way to tell a
406
+ // silent drain from an operator kill. `ended` is the one flag every
407
+ // job-end site sets via the local `end()` helper below (never a second
408
+ // spelling of "did we finish" that could fall out of sync with the real
409
+ // emits). One `beforeExit` listener per call, removed in the `finally` so
410
+ // multiple runs in one process (tests included) never leak listeners.
411
+ let ended = false;
412
+ /** @type {(outcome: string, extra?: object) => object} */
413
+ const end = (outcome, extra = {}) => {
414
+ ended = true;
415
+ return emit('job-end', { outcome, ...extra });
416
+ };
417
+ const onBeforeExit = () => {
418
+ if (ended) return;
419
+ // A drained run's in-flight spend is unknowable — the floor already
420
+ // accumulated is honest, an exact-looking total would not be (F6).
421
+ end('runner-drained', {
422
+ ...spend(),
423
+ spendComplete: false,
424
+ detail: 'process drained with no job-end — a provider call never settled (F140)',
425
+ });
426
+ // Never process.exit() (can discard queued stdout) — set the code and
427
+ // let node exit on its own; preserve a more specific code if one is
428
+ // already set.
429
+ process.exitCode = process.exitCode || 1;
430
+ };
431
+ process.once('beforeExit', onBeforeExit);
432
+
433
+ try {
383
434
  // 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
384
435
  const smoke = await primitiveSmoke(workdir);
385
436
  emit('primitive-smoke', smoke);
386
437
  if (!smoke.ok) {
387
438
  emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
388
- emit('job-end', { outcome: 'smoke-red', ...spend() });
439
+ end('smoke-red', spend());
389
440
  return 'smoke-red';
390
441
  }
391
442
 
@@ -426,7 +477,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
426
477
  };
427
478
  const pricingRed = () => {
428
479
  emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
429
- emit('job-end', { outcome: 'pricing-red', ...spend() });
480
+ end('pricing-red', spend());
430
481
  return 'pricing-red';
431
482
  };
432
483
 
@@ -443,7 +494,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
443
494
  });
444
495
  if (unpriced) return pricingRed();
445
496
  if (outcome.startsWith('step-red:')) {
446
- emit('job-end', { outcome: 'step-red', step: outcome.slice('step-red:'.length), ...spend() });
497
+ end('step-red', { step: outcome.slice('step-red:'.length), ...spend() });
447
498
  } else if (outcome === 'provider-red' || outcome === 'step-stalled') {
448
499
  // F44: a transport-throw provider-red never returned a usage figure for the
449
500
  // failed call, so the priced sum is a FLOOR, not the total — spendComplete
@@ -453,10 +504,13 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
453
504
  // the known cost of self-heal — and an unbilled reissue is indistinguishable
454
505
  // from a billed one from here. Reporting the priced sum as exact would be F6
455
506
  // in a self-heal coat.
456
- emit('job-end', { outcome, ...spend(), spendComplete: false });
507
+ end(outcome, { ...spend(), spendComplete: false });
457
508
  } else {
458
- emit('job-end', { outcome, ...spend() });
509
+ end(outcome, spend());
459
510
  }
460
511
  return outcome;
461
512
  }
513
+ } finally {
514
+ process.removeListener('beforeExit', onBeforeExit);
515
+ }
462
516
  }
package/src/transport.js CHANGED
@@ -3,14 +3,18 @@
3
3
  //
4
4
  // A transport failure is fetch THROWING — the request never produced an HTTP
5
5
  // response at all (a TLS fault mid-record, a reset socket, a DNS/network
6
- // failure). That is a DIFFERENT unknown from an HTTP response the provider
7
- // DID send (4xx/5xx/429): a response means the provider answered, so
8
- // bare-agent's own retry policy for those stays exactly what it is — this
9
- // module never widens that. Retrying only the no-response case is the whole
10
- // point: a call that dies mid-read may already have been billed (retry can
11
- // pay twice), so the retry budget stays as tight as hamr authorized it —
12
- // exactly one extra attempt, never configurable upward from a job spec or
13
- // argv (tighten-only doctrine).
6
+ // failure) — OR bare-agent's own BA-25 `guardResponseSettles` rejecting a
7
+ // response cut after headers but before the body completed (a peer
8
+ // `'aborted'`/`'error'`/premature `'close'`), tagged `context.bound:
9
+ // 'transport'` (bare-agent 0.42.0, node_modules/bare-agent/src/provider-http.js).
10
+ // That is a DIFFERENT unknown from an HTTP response the provider DID send
11
+ // (4xx/5xx/429): a response means the provider answered, so bare-agent's own
12
+ // retry policy for those stays exactly what it is — this module never widens
13
+ // that. Retrying only the no-response case is the whole point: a call that
14
+ // dies mid-read may already have been billed (retry can pay twice), so the
15
+ // retry budget stays as tight as hamr authorized it — exactly one extra
16
+ // attempt, never configurable upward from a job spec or argv (tighten-only
17
+ // doctrine).
14
18
  //
15
19
  // Scope: this module wires ONE seam — the worker Loop built in
16
20
  // src/planrun.js's `newLoop` (~line 2239), which is the ONE factory behind
@@ -31,11 +35,17 @@ export const TRANSPORT_MAX_ATTEMPTS = TRANSPORT_RETRIES + 1;
31
35
  * Is `err` a transport-class failure — the request never produced an HTTP
32
36
  * response? True for: a TLS fault mid-record ("bad record mac" and kin), a
33
37
  * reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
34
- * ENETUNREACH — by code on the error itself or on `err.cause`), and
35
- * `fetch failed` wrapping a network `cause`. False for anything carrying an
36
- * HTTP status (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
38
+ * ENETUNREACH — by code on the error itself or on `err.cause`), `fetch
39
+ * failed` wrapping a network `cause`, and — as of bare-agent 0.42.0 (BA-25)
40
+ * — a `ProviderError` whose `err.context.bound === 'transport'`: the
41
+ * response stream ended (`'aborted'`/`'error'`/premature `'close'`) after
42
+ * headers but before the body completed, which bare-agent itself rejects as
43
+ * retryable with no HTTP status. False for anything carrying an HTTP status
44
+ * (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
37
45
  * `err.retryable === false` — those are provider RESPONSES, not transport
38
- * casualties, and bare-agent's own policy governs them unchanged.
46
+ * casualties, and bare-agent's own policy governs them unchanged; both
47
+ * guards run BEFORE the `context.bound` check, so a status or an explicit
48
+ * `retryable:false` wins even if `bound` says 'transport'.
39
49
  * @param {any} err
40
50
  * @returns {boolean}
41
51
  */
@@ -47,6 +57,13 @@ export function isTransportFailure(err) {
47
57
  if (typeof status === 'number') return false;
48
58
  if (err.retryable === false) return false;
49
59
 
60
+ // BA-25 (bare-agent 0.42.0): bare-agent's own transport classification —
61
+ // `guardResponseSettles` tags a body-cut-after-headers rejection with
62
+ // `context.bound: 'transport'`. Only 'transport' qualifies here — a
63
+ // sibling bound like 'idle' (BA-19's deadline-idle guard) already
64
+ // qualifies via its ETIMEDOUT code below, never by bound alone.
65
+ if (err.context && err.context.bound === 'transport') return true;
66
+
50
67
  const codeOf = (e) => (e && typeof e.code === 'string' ? e.code : null);
51
68
  const TRANSPORT_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN']);
52
69
  const code = codeOf(err) ?? codeOf(err.cause);
@@ -91,7 +91,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
91
91
  *
92
92
  * @param {{workdir: string, provider?: any,
93
93
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
94
- * attempts?: number, ceilingUsd?: number|null,
94
+ * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
95
95
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
96
96
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
97
97
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
@@ -99,7 +99,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
99
99
  * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
100
100
  * budgetStop: 'cap-halt'|'pricing-red'|null}>}
101
101
  */
102
- export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, onCall, createLoop, createSurveyor, }: {
102
+ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, callTimeoutMs, onCall, createLoop, createSurveyor, }: {
103
103
  workdir: string;
104
104
  provider?: any;
105
105
  rounds?: number;
@@ -109,6 +109,7 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
109
109
  ctx?: boolean;
110
110
  attempts?: number;
111
111
  ceilingUsd?: number | null;
112
+ callTimeoutMs?: number;
112
113
  onCall?: (call: {
113
114
  label: string;
114
115
  costUsd: number | null;
@@ -205,6 +206,30 @@ export function buildSeedListing({ workdir, seedRef, sourcePaths, testPaths, lis
205
206
  tier: string;
206
207
  };
207
208
  }>;
209
+ /**
210
+ * F152/PRD 30.4 — the scout's per-call IDLE bound. Every `loop.run()` in this
211
+ * module carried NO time bound at all: a live endpoint that accepts and never
212
+ * answers hangs the process forever (measured) — an open socket is an active
213
+ * handle, so node never drains and no backstop can fire.
214
+ *
215
+ * UNLIKE the run path (`callBounds()`, src/planrun.js:1518), this pipeline has
216
+ * NO wall clock to derive a bound FROM — the authoring pipeline is genuinely
217
+ * clockless (`src/authorjob.js`/`src/authorflow.js`/`scripts/run-author.mjs`
218
+ * carry a money ceiling, `ceilingUsd`/`capStop`, and never a `maxWallMs`).
219
+ * Rather than inventing a fresh number, this reuses the EXACT ceiling the run
220
+ * path itself falls back to when ITS OWN wall is unbounded
221
+ * (`PROVIDER_TIMEOUT_MS`, `src/clock.js` — BA-18's idle timeout, already an
222
+ * approved "no wall present" default one layer up): an authoring-time call is
223
+ * the identical situation, one call earlier.
224
+ *
225
+ * It bounds SOCKET INACTIVITY only (reset by every byte, per BA-18) — a call
226
+ * that is merely slow keeps streaming and is untouched; only a truly silent
227
+ * endpoint trips it. TIGHTEN-ONLY, like every cap in this file: this is an
228
+ * operator-set ceiling fixed here, not a param a caller or a signed spec may
229
+ * widen (a defaulted cap is a silent second ceiling, and there is no clock
230
+ * whose narrower reading could ever tighten it further).
231
+ */
232
+ export const AUTHOR_CALL_TIMEOUT_MS: 600000;
208
233
  /** src/planrun.js:42 — the survey's hard round bound. */
209
234
  export const AUTHOR_SCOUT_ROUNDS: 8;
210
235
  /** src/planrun.js:44 — the survey is a PROMPT INGREDIENT for the authoring call. */
@@ -110,7 +110,8 @@ export function guardNames(closeDecl: any, verdictType: string): string[];
110
110
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
111
111
  * humanRuling?: {decision: string, text?: string|null}|null,
112
112
  * judgeLoop?: ((o: {system: string}) => any)|null,
113
- * onJudgeCost?: ((c: any) => void)|null}} [opts]
113
+ * onJudgeCost?: ((c: any) => void)|null,
114
+ * callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
114
115
  * @returns {Promise<any>}
115
116
  */
116
117
  export function runDeclaredStages(stages: any[], redact?: (s: string) => string, opts?: {
@@ -129,6 +130,10 @@ export function runDeclaredStages(stages: any[], redact?: (s: string) => string,
129
130
  system: string;
130
131
  }) => any) | null;
131
132
  onJudgeCost?: ((c: any) => void) | null;
133
+ callBounds?: (() => {
134
+ timeoutMs?: number;
135
+ deadlineMs?: number;
136
+ }) | null;
132
137
  }): Promise<any>;
133
138
  /**
134
139
  * The trend reader's input for ONE close verdict, whichever executor produced
package/types/job.d.ts CHANGED
@@ -207,6 +207,10 @@ export const CADENCE_UNITS: readonly string[];
207
207
  * subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
208
208
  * billed), so it is a DISTINCT condition: rows never pool with anthropic-api
209
209
  * rows on the cost axis, and budgets sized for one do not transfer. */
210
+ /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
211
+ * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
212
+ * `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
213
+ * models); the factory's tier table is where that lives, not this menu. */
210
214
  export const PROVIDERS: readonly string[];
211
215
  /** V3 environment label: declared keys only — every field is a lineage-key
212
216
  * candidate at N3. `provider` is part of the key by definition (top-level,
package/types/judged.d.ts CHANGED
@@ -130,12 +130,13 @@ export function locatePrompt(card: any): string;
130
130
  * On every route out, red or clean, `onCost` fires exactly once with the honest
131
131
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
132
132
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
133
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void}} o
133
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
134
+ * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
134
135
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
135
136
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
136
137
  * raw: ReturnType<typeof scrubRaw>}>}
137
138
  */
138
- export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt, onCost }: {
139
+ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt, onCost, callBounds, }: {
139
140
  artifactText: string;
140
141
  card: any;
141
142
  loopFactory: (o: {
@@ -148,6 +149,10 @@ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt,
148
149
  costUsd: number | null;
149
150
  unpricedRounds: number;
150
151
  }) => void;
152
+ callBounds?: {
153
+ timeoutMs?: number;
154
+ deadlineMs?: number;
155
+ };
151
156
  }): Promise<{
152
157
  ok: boolean;
153
158
  facts: any | null;
package/types/kinds.d.ts CHANGED
@@ -539,6 +539,11 @@ export type Red = {
539
539
  * funded honestly: this executor cannot reach the run's ledger, so every locate
540
540
  * call's cost is reported OUT through it, once per call, on every route including
541
541
  * the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
542
+ * `callBounds` is F152/PRD 30.4's per-call TIME bound, read fresh before every
543
+ * locate attempt — the same derivation the run's own worker rounds carry
544
+ * (`callBounds()`, src/planrun.js). Absent is the honest unbounded default:
545
+ * an adopter or test that wires no clock through behaves exactly as before
546
+ * this field existed.
542
547
  * `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
543
548
  * toolchain per stage (a suite is the slow one) and a whole read is one opaque
544
549
  * await, so the shell gets told as each stage lands. It decides nothing, it is
@@ -571,6 +576,10 @@ export type Ctx = {
571
576
  costUsd: number | null;
572
577
  unpricedRounds: number;
573
578
  }) => void;
579
+ callBounds?: () => {
580
+ timeoutMs?: number;
581
+ deadlineMs?: number;
582
+ };
574
583
  onStage?: (row: {
575
584
  stage: string | null;
576
585
  kind: string | null;