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/CHANGELOG.md +292 -0
- package/README.md +1 -1
- package/bareloop.context.md +177 -24
- package/package.json +3 -3
- package/src/authorflow.js +7 -2
- package/src/authorjob.js +15 -1
- package/src/authorscout.js +83 -9
- package/src/bundle.js +40 -46
- package/src/calibrate.js +10 -0
- package/src/cli.js +52 -19
- package/src/close-integrity.js +325 -0
- package/src/closetimeout.js +165 -0
- package/src/declaredclose.js +9 -2
- package/src/index.js +14 -0
- package/src/job.js +84 -5
- package/src/judged.js +108 -5
- package/src/kinds.js +12 -0
- package/src/plan.js +5 -1
- package/src/planrun.js +392 -46
- package/src/providers.js +176 -0
- package/src/ralph.js +40 -7
- package/src/ratelimit.js +116 -0
- package/src/reuse.js +12 -2
- package/src/reviewdoor.js +26 -6
- package/src/run.js +83 -8
- package/src/transport.js +29 -12
- package/src/validate.js +59 -0
- package/types/authorscout.d.ts +27 -2
- package/types/close-integrity.d.ts +196 -0
- package/types/closetimeout.d.ts +126 -0
- package/types/declaredclose.d.ts +6 -1
- package/types/index.d.ts +1 -0
- package/types/job.d.ts +4 -0
- package/types/judged.d.ts +7 -2
- package/types/kinds.d.ts +9 -0
- package/types/planrun.d.ts +17 -2
- package/types/providers.d.ts +104 -0
- package/types/ralph.d.ts +19 -5
- package/types/ratelimit.d.ts +59 -0
- package/types/reuse.d.ts +2 -0
- package/types/reviewdoor.d.ts +3 -1
- package/types/run.d.ts +33 -2
- package/types/transport.d.ts +10 -4
- package/types/validate.d.ts +48 -0
package/src/providers.js
ADDED
|
@@ -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
|
+
}
|
package/src/ralph.js
CHANGED
|
@@ -208,17 +208,24 @@ export const CLOSE_ENV_DENY = Object.freeze({
|
|
|
208
208
|
*
|
|
209
209
|
* @param {string[]} close argv, e.g. ['node', '--test', 'close/']
|
|
210
210
|
* @param {(s: string) => string} [redact] source scrubber; identity by default
|
|
211
|
-
* @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string }} [opts]
|
|
211
|
+
* @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string, closeDir?: string|null }} [opts]
|
|
212
212
|
* close wall-clock cap (operator-set via the runner, never the config's — the
|
|
213
213
|
* agent must not author its arbiter's clock), the directory the close runs in
|
|
214
214
|
* (the workdir — the tree it is judging), the exit code the SIGNED spec calls
|
|
215
215
|
* success, the judgment-rendered signal, and the kept-failures pattern. All
|
|
216
216
|
* five are arbiter territory: the drafted workflow config cannot express any.
|
|
217
|
+
* `closeDir` (PRD item 27/M3 Part B) is the close's OWN books directory —
|
|
218
|
+
* pristine copies, calibration thresholds, logs — deliberately outside the
|
|
219
|
+
* patient tree (the worker must never read the arbiter's books). Handed to
|
|
220
|
+
* the child as `BARELOOP_CLOSE_DIR` (`CLOSE_DIR_ENV_VAR`,
|
|
221
|
+
* src/close-integrity.js); a close script that needs one and gets none
|
|
222
|
+
* instrument-stops itself, by the script's own contract — this seam only
|
|
223
|
+
* ever THREADS the value, it never invents one.
|
|
217
224
|
* @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
|
|
218
225
|
* gap?: string, exitCode?: number, signal?: string, detail?: string,
|
|
219
226
|
* judgedCount?: number|null, unaudited?: boolean}>}
|
|
220
227
|
*/
|
|
221
|
-
export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
|
|
228
|
+
export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep, closeDir = null } = {}) {
|
|
222
229
|
const env = { ...process.env };
|
|
223
230
|
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
224
231
|
// …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
|
|
@@ -229,6 +236,13 @@ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000,
|
|
|
229
236
|
|| CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
|
|
230
237
|
|| CLOSE_ENV_DENY.shape.test(name)) delete env[name];
|
|
231
238
|
}
|
|
239
|
+
// PRD item 27/M3 Part B — a `BARELOOP_` name, deliberately not matched by
|
|
240
|
+
// any CLOSE_ENV_DENY rule above (checked: no name/prefix/suffix entry there
|
|
241
|
+
// touches it), set AFTER the strip loop so it can never be stripped by a
|
|
242
|
+
// future denylist edit either. Only set when the runner actually has one —
|
|
243
|
+
// an empty/absent `closeDir` leaves the child's env exactly as it was
|
|
244
|
+
// before this rung, so a script that never reads the variable is unaffected.
|
|
245
|
+
if (typeof closeDir === 'string' && closeDir.length > 0) env.BARELOOP_CLOSE_DIR = closeDir;
|
|
232
246
|
const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
|
|
233
247
|
|
|
234
248
|
// ── forbidden zone, before any exit code is believed ──────────────────────
|
|
@@ -322,17 +336,17 @@ export const stageGap = (name, gap) => `close stage "${name}" failed:\n${gap}`;
|
|
|
322
336
|
*
|
|
323
337
|
* @param {any[]} stages the stage list (already validated by validateJob)
|
|
324
338
|
* @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
|
|
325
|
-
* @param {{timeoutMs?: number, cwd?: string}} [opts]
|
|
339
|
+
* @param {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} [opts]
|
|
326
340
|
* @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
|
|
327
341
|
*/
|
|
328
|
-
export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string}} */ { timeoutMs, cwd } = {}) {
|
|
342
|
+
export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} */ { timeoutMs, cwd, closeDir } = {}) {
|
|
329
343
|
const ran = [];
|
|
330
344
|
let last;
|
|
331
345
|
for (const st of stages) {
|
|
332
346
|
// Sequential by construction (F68): stages run one at a time under a single
|
|
333
347
|
// await, so nothing here ever has two closes in flight against one tree.
|
|
334
348
|
const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
|
|
335
|
-
timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
|
|
349
|
+
timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep, closeDir,
|
|
336
350
|
});
|
|
337
351
|
ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
|
|
338
352
|
last = r;
|
|
@@ -443,6 +457,21 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
443
457
|
decision: 'The close exited without judging anything — it crashed rather than failed. Its exit code is NOT a verdict, in either direction.',
|
|
444
458
|
options: ['fix what makes the close crash at startup', 'fix the close argv', 'lower the declared judgment floor if the suite legitimately shrank', 'abandon the task'],
|
|
445
459
|
},
|
|
460
|
+
// PRD item 27/M2 (`docs/product/CLOSE-INTEGRITY-BUILD.md`) — a close
|
|
461
|
+
// script's bytes no longer match what was signed. Distinct from every row
|
|
462
|
+
// above: those are read off HOW THE CHILD PROCESS TERMINATED, and this one
|
|
463
|
+
// is decided BEFORE the process is even spawned (`runCloseStages`,
|
|
464
|
+
// `src/planrun.js`, re-verifies before every close run and short-circuits
|
|
465
|
+
// to this verdict on a mismatch, the same forbidden-zone shape as a
|
|
466
|
+
// crash). Never `close-red` (a judged "no" from a working close) and never
|
|
467
|
+
// `close-crashed` (an instrument fault the close itself hit) — this is a
|
|
468
|
+
// THIRD thing: the close that ran would not have been the one the operator
|
|
469
|
+
// approved.
|
|
470
|
+
'close-tampered': {
|
|
471
|
+
category: 'close-tampered',
|
|
472
|
+
decision: "A close script's bytes no longer match its signed sha256 — it would run something other than what was approved, so no verdict from it can be trusted.",
|
|
473
|
+
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'],
|
|
474
|
+
},
|
|
446
475
|
});
|
|
447
476
|
|
|
448
477
|
/**
|
|
@@ -529,9 +558,13 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
529
558
|
* it to the gate audit's allow-decision writes. Injected like `redact` so the shell
|
|
530
559
|
* stays stdlib-only and dumb; consulted ONLY at a crashed verdict. With no seam, or
|
|
531
560
|
* with zero writes, a crash stays what it always was: an instrument stop.
|
|
561
|
+
* @param {string|null} [opts.closeDir] the close's own books directory
|
|
562
|
+
* (PRD item 27/M3 Part B), forwarded to the raw `close` path's `runClose`
|
|
563
|
+
* call unchanged; a caller supplying `judge` instead already closed over
|
|
564
|
+
* its own `closeDir` and this param does nothing for it.
|
|
532
565
|
* @returns {Promise<'green'|'escalated'>}
|
|
533
566
|
*/
|
|
534
|
-
export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
|
|
567
|
+
export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir }) {
|
|
535
568
|
// PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
|
|
536
569
|
// thing this shell throws for; a WORKER's bad reach comes back as a refusal
|
|
537
570
|
// RESULT instead, and that rule is about feedback, not about the API contract).
|
|
@@ -660,7 +693,7 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
|
|
|
660
693
|
emit('middle-done', { iteration });
|
|
661
694
|
const v = judge
|
|
662
695
|
? await judge()
|
|
663
|
-
: await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
696
|
+
: await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep, closeDir });
|
|
664
697
|
// Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
|
|
665
698
|
// still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
|
|
666
699
|
// precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
|
package/src/ratelimit.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
@@ -1353,6 +1361,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
|
|
|
1353
1361
|
* @param {number} [opts.capRuns] forwarded
|
|
1354
1362
|
* @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
|
|
1355
1363
|
* @param {number} [opts.closeTimeoutMs] forwarded
|
|
1364
|
+
* @param {string} [opts.closeDir] forwarded (PRD item 27/M3 Part B)
|
|
1356
1365
|
* @param {boolean} [opts.layerRoot] forwarded
|
|
1357
1366
|
* @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
|
|
1358
1367
|
* @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
|
|
@@ -1371,7 +1380,7 @@ export async function runReuse(opts) {
|
|
|
1371
1380
|
const {
|
|
1372
1381
|
job, approvals, registryDir, patient, workdir, provider, selectionProvider,
|
|
1373
1382
|
emit, ask, pinned = null, shortlist = null, forceCold = false,
|
|
1374
|
-
providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
|
|
1383
|
+
providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, closeDir, layerRoot, readShim,
|
|
1375
1384
|
runJob = shippedRunJob, now = () => Date.now(),
|
|
1376
1385
|
} = opts;
|
|
1377
1386
|
const runid = opts.runid ?? Date.now().toString(36);
|
|
@@ -1538,6 +1547,7 @@ export async function runReuse(opts) {
|
|
|
1538
1547
|
...(capRuns !== undefined ? { capRuns } : {}),
|
|
1539
1548
|
...(strikeLimit !== undefined ? { strikeLimit } : {}),
|
|
1540
1549
|
...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
|
|
1550
|
+
...(closeDir !== undefined ? { closeDir } : {}),
|
|
1541
1551
|
...(layerRoot !== undefined ? { layerRoot } : {}),
|
|
1542
1552
|
...(readShim !== undefined ? { readShim } : {}),
|
|
1543
1553
|
// the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
|
package/src/reviewdoor.js
CHANGED
|
@@ -38,6 +38,7 @@ import { isDeclaredClose, runDeclaredStages, HITL_DECISION_RED } from './declare
|
|
|
38
38
|
import { runStages } from './ralph.js';
|
|
39
39
|
import { redactSecrets } from './validate.js';
|
|
40
40
|
import { applyDoorDecision, PAUSE_TTL_MS } from './reuse.js';
|
|
41
|
+
import { checkStageByteSignature } from './close-integrity.js';
|
|
41
42
|
|
|
42
43
|
/** the door's own record, off the run's spine — the LAST one, because a run that
|
|
43
44
|
* ends at a door writes exactly one and a reader that guesses which is which is a
|
|
@@ -92,6 +93,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
|
|
|
92
93
|
* @param {string} opts.decision one of the three doors
|
|
93
94
|
* @param {string|null} [opts.text] the rerun's words — the gap, and refused when empty
|
|
94
95
|
* @param {number} [opts.closeTimeoutMs] the per-stage bound for the accept's re-run
|
|
96
|
+
* @param {string|null} [opts.closeDir] the close's own books directory (PRD item 27/M3 Part B), forwarded to the accept's re-run
|
|
95
97
|
* @param {string|null} [opts.registryDir] the workflow registry, when the runner keeps one
|
|
96
98
|
* @param {string|null} [opts.name] the workflow this run wrote its row against
|
|
97
99
|
* @param {string|null} [opts.runid] the RUN's id, exactly as the bridge row records it
|
|
@@ -104,7 +106,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
|
|
|
104
106
|
* options: string[], ttlMs: number, note: string|null}>}
|
|
105
107
|
*/
|
|
106
108
|
export async function answerReviewDoor({
|
|
107
|
-
job, workdir, events, decision, text = null, closeTimeoutMs,
|
|
109
|
+
job, workdir, events, decision, text = null, closeTimeoutMs, closeDir = null,
|
|
108
110
|
registryDir = null, name = null, runid = null, at = null,
|
|
109
111
|
now = Date.now, ttlMs = PAUSE_TTL_MS, emit = null,
|
|
110
112
|
}) {
|
|
@@ -149,7 +151,7 @@ export async function answerReviewDoor({
|
|
|
149
151
|
// this is the only branch that runs anything, and it runs the close's own
|
|
150
152
|
// mechanical stages — never a second, weaker spelling of "is it still green".
|
|
151
153
|
if (out.decision === 'accept') {
|
|
152
|
-
const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs });
|
|
154
|
+
const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir });
|
|
153
155
|
out.mechanical = proof.reading;
|
|
154
156
|
if (!proof.ok) {
|
|
155
157
|
out.reds = red('door-accept-red', proof.detail, proof.reading.stage ? `stage.${proof.reading.stage}` : 'close');
|
|
@@ -200,16 +202,24 @@ export async function answerReviewDoor({
|
|
|
200
202
|
|
|
201
203
|
/**
|
|
202
204
|
* The accept's re-proof: the close's MECHANICAL stages, run against the tree as it
|
|
203
|
-
* stands now, through the SAME executors the run itself was judged by
|
|
205
|
+
* stands now, through the SAME executors the run itself was judged by — the
|
|
206
|
+
* scripts' own bytes are verified against their signed sha256 FIRST (F135).
|
|
204
207
|
*
|
|
205
208
|
* The declared path measures against the run's OWN seed (`close-decl.seedRef` off
|
|
206
209
|
* the spine), never a fresh one: re-basing the baseline onto today's HEAD would
|
|
207
210
|
* compare the tree to itself and turn "nothing has changed since the run" into a
|
|
208
211
|
* green for a `files-changed` stage that means the opposite. A run whose seed is
|
|
209
212
|
* not on its spine is refused rather than guessed at.
|
|
210
|
-
*
|
|
213
|
+
*
|
|
214
|
+
* F135 — the script BYTES are verified (`checkStageByteSignature`, PRD item 27/M2)
|
|
215
|
+
* BEFORE any stage runs, exactly the re-verify every other close execution in this
|
|
216
|
+
* runner already goes through (`runCloseStages`, `src/planrun.js`). Without it, a
|
|
217
|
+
* close script swapped on disk after the run ended but before the signer's accept
|
|
218
|
+
* would be re-run unchecked — a mismatch here never reaches the executor, and
|
|
219
|
+
* reads the same `close-tampered` shape the run-start/mid-run checks already use.
|
|
220
|
+
* @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number, closeDir?: string|null}} o
|
|
211
221
|
*/
|
|
212
|
-
async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
|
|
222
|
+
async function proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir = null }) {
|
|
213
223
|
const stages = mechanicalStages(closeStagesOf(job) ?? []);
|
|
214
224
|
if (!stages.length) {
|
|
215
225
|
// Honest, and named: a close with no mechanical stage has nothing a machine
|
|
@@ -217,6 +227,16 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
|
|
|
217
227
|
// dressed as a re-run.
|
|
218
228
|
return { ok: true, detail: '', reading: { ran: 0, verdict: null, stage: null, stages: [], note: 'this close has no mechanical stage — there is nothing a machine can re-prove, so the accept rests on the person' } };
|
|
219
229
|
}
|
|
230
|
+
const sig = checkStageByteSignature(stages, workdir);
|
|
231
|
+
if (!sig.ok) {
|
|
232
|
+
const r = sig.reds[0];
|
|
233
|
+
const detail = sig.reds.map((rr) => `${rr.stage}: expected ${rr.expected.slice(0, 12)}… got ${rr.actual ? `${rr.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
reading: { ran: 0, verdict: 'close-tampered', stage: r?.stage ?? null, stages: [] },
|
|
237
|
+
detail: `the close script's bytes no longer match its signed sha256 (close-tampered) — the accept is refused rather than re-run against a script nobody vouched for. ${detail}`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
220
240
|
/** @type {any} */
|
|
221
241
|
let v;
|
|
222
242
|
if (isDeclaredClose(job)) {
|
|
@@ -226,7 +246,7 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
|
|
|
226
246
|
}
|
|
227
247
|
v = await runDeclaredStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, seedRef });
|
|
228
248
|
} else {
|
|
229
|
-
v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir });
|
|
249
|
+
v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, closeDir });
|
|
230
250
|
}
|
|
231
251
|
const reading = {
|
|
232
252
|
ran: stages.length,
|