bareloop 0.5.1 → 0.7.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 +1129 -1
- package/README.md +40 -13
- package/bareloop.context.md +799 -245
- package/package.json +3 -3
- package/src/bridges.js +571 -0
- package/src/clock.js +233 -0
- package/src/exits.js +8 -2
- package/src/index.js +52 -17
- package/src/job.js +231 -108
- package/src/ladder.js +196 -0
- package/src/ledger.js +55 -3
- package/src/plan.js +334 -20
- package/src/planrun.js +1519 -134
- package/src/ralph.js +373 -34
- package/src/reuse.js +1602 -0
- package/src/root.js +32 -12
- package/src/run.js +129 -391
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/trend.js +405 -0
- package/src/validate.js +35 -250
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +131 -0
- package/types/index.d.ts +7 -6
- package/types/job.d.ts +83 -8
- package/types/ladder.d.ts +86 -0
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +86 -7
- package/types/planrun.d.ts +142 -5
- package/types/ralph.d.ts +143 -7
- package/types/reuse.d.ts +388 -0
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +56 -17
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/trend.d.ts +108 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/ralph.js
CHANGED
|
@@ -4,7 +4,153 @@
|
|
|
4
4
|
// arbiter). Stateless across runs and stdlib-only by design: it must stay too
|
|
5
5
|
// dumb to be gamed. Do not make it smarter.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
// ── the close's child process, run WITHOUT freezing the host (F68) ───────────
|
|
10
|
+
// The close used to run through `spawnSync`, which blocks the host event loop for
|
|
11
|
+
// the child's entire duration: F68 measured 9 loop-freeze records in one green run
|
|
12
|
+
// (worst 74.3s, bracketing a ~65s close exactly) and 4 more bracketing three
|
|
13
|
+
// check-preflights — "the close-runner freezes the loop it reports to". While a
|
|
14
|
+
// close ran, the stall fuse's timers could not fire, the lag sampler recorded a
|
|
15
|
+
// block, and any in-flight instrument shared the freeze.
|
|
16
|
+
//
|
|
17
|
+
// ONLY THE WAIT IS ASYNC. Every observable semantic below is spawnSync's own,
|
|
18
|
+
// replicated field for field, because the close is the arbiter and its reading of
|
|
19
|
+
// the world must not shift by a byte:
|
|
20
|
+
// - the same `{error, status, signal, stdout, stderr}` result shape;
|
|
21
|
+
// - the same 1 MiB PER-STREAM output ceiling, reported the same way spawnSync
|
|
22
|
+
// reports it — an `ENOBUFS` error with the child killed (so an over-long close
|
|
23
|
+
// stays `failed`/broken-close, exactly as before, rather than silently
|
|
24
|
+
// becoming a red on truncated output);
|
|
25
|
+
// - the same deadline behaviour: SIGTERM at `timeoutMs`, surfaced as an
|
|
26
|
+
// `ETIMEDOUT` error (which runClose reads BEFORE any exit code, so the
|
|
27
|
+
// forbidden zone's `timed-out` band is unchanged);
|
|
28
|
+
// - the same stdin: closed immediately, so a close that reads stdin sees EOF
|
|
29
|
+
// instead of hanging on a pipe nobody will ever write to.
|
|
30
|
+
// The one deliberate difference is prose: a spawn fault names itself `spawn …`
|
|
31
|
+
// rather than `spawnSync …` in its message, because that is now what happened.
|
|
32
|
+
// Nothing parses that string (it rides out as an escalation `detail`).
|
|
33
|
+
const CLOSE_MAX_BUFFER = 1024 * 1024; // spawnSync's default maxBuffer, per stream
|
|
34
|
+
const CLOSE_KILL_SIGNAL = 'SIGTERM'; // spawnSync's default killSignal
|
|
35
|
+
// SIGTERM is a REQUEST, and a close is free to refuse it — a runner installing its
|
|
36
|
+
// own handler, a wrapper that traps to clean up. There is no second deadline after
|
|
37
|
+
// the first, so a refused SIGTERM leaves the wait below pending FOREVER: no `close`
|
|
38
|
+
// event ever arrives, and the run's only remaining stop is the out-of-process
|
|
39
|
+
// watchdog. The grace is the escalation — long enough for an honest close to flush
|
|
40
|
+
// and leave on its own terms, then SIGKILL, which nothing can refuse. Verdict
|
|
41
|
+
// semantics are untouched: the FIRST fault already named the outcome, and the kill
|
|
42
|
+
// only enforces it. (spawnSync sends killSignal and then simply keeps waiting; this
|
|
43
|
+
// is the one place the arbiter's clock is deliberately harder than its reference.)
|
|
44
|
+
const CLOSE_KILL_GRACE_MS = 2000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} cmd
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
|
|
50
|
+
* @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
51
|
+
*/
|
|
52
|
+
function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const child = spawn(cmd, args, { env, cwd });
|
|
55
|
+
/** @type {Buffer[]} */ const outChunks = [];
|
|
56
|
+
/** @type {Buffer[]} */ const errChunks = [];
|
|
57
|
+
let outBytes = 0, errBytes = 0;
|
|
58
|
+
/** @type {(Error & {code?: string})|null} */
|
|
59
|
+
let error = null;
|
|
60
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
61
|
+
let timer;
|
|
62
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
63
|
+
let killTimer;
|
|
64
|
+
|
|
65
|
+
// The FIRST fault wins and kills the child, exactly as spawnSync does: a close
|
|
66
|
+
// that blew the buffer and then hit the deadline is one story, not two. The
|
|
67
|
+
// escalation rides inside that same first-fault-wins guard, so a child is asked
|
|
68
|
+
// once and, if it refuses, ended once.
|
|
69
|
+
const fault = (/** @type {string} */ code) => {
|
|
70
|
+
if (error) return;
|
|
71
|
+
error = Object.assign(new Error(`spawn ${cmd} ${code}`), { code, syscall: `spawn ${cmd}`, path: cmd, spawnargs: args });
|
|
72
|
+
try { child.kill(CLOSE_KILL_SIGNAL); } catch { /* already gone — nothing to kill */ }
|
|
73
|
+
// …and SIGKILL if it is still there after the grace. `unref` so this timer can
|
|
74
|
+
// never hold the host's event loop open by itself (F68's whole point) — and it
|
|
75
|
+
// is cleared on `close`, so it cannot fire at a child that already left.
|
|
76
|
+
killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone in the meantime */ } }, CLOSE_KILL_GRACE_MS);
|
|
77
|
+
killTimer.unref();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
child.stdout.on('data', (/** @type {Buffer} */ c) => { outBytes += c.length; outChunks.push(c); if (outBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
|
|
81
|
+
child.stderr.on('data', (/** @type {Buffer} */ c) => { errBytes += c.length; errChunks.push(c); if (errBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
|
|
82
|
+
// A spawn failure (ENOENT, an unreadable cwd) arrives as an event here and as
|
|
83
|
+
// `result.error` in spawnSync; `close` still fires after it, so there is ONE
|
|
84
|
+
// resolution path and the promise can never be left pending.
|
|
85
|
+
child.on('error', (e) => { if (!error) error = e; });
|
|
86
|
+
child.on('close', (status, signal) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
clearTimeout(killTimer);
|
|
89
|
+
resolve({
|
|
90
|
+
error, status, signal,
|
|
91
|
+
// Concat-then-decode, never per-chunk: a multi-byte character straddling a
|
|
92
|
+
// chunk boundary must not become two replacement characters — spawnSync
|
|
93
|
+
// decodes the whole buffer once, and the judged/gapKeep patterns read this.
|
|
94
|
+
stdout: Buffer.concat(outChunks).toString('utf8'),
|
|
95
|
+
stderr: Buffer.concat(errChunks).toString('utf8'),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
if (typeof timeoutMs === 'number' && timeoutMs > 0 && Number.isFinite(timeoutMs)) timer = setTimeout(() => fault('ETIMEDOUT'), timeoutMs);
|
|
99
|
+
// stdin closed at once (spawnSync with no `input` hands the child EOF): a close
|
|
100
|
+
// that reads stdin gets the same EOF instead of blocking on an idle pipe. The
|
|
101
|
+
// error swallow is for the ordinary case where the child has already exited.
|
|
102
|
+
child.stdin?.on('error', () => {});
|
|
103
|
+
child.stdin?.end();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What the close child must NOT be able to read (operator ruling, 2026-07-31:
|
|
109
|
+
* "strip it when unnecessary, minimize unneeded exposure").
|
|
110
|
+
*
|
|
111
|
+
* The close child executes WORKER-AUTHORED CODE. `pytest`, `npm test`, `tsc`
|
|
112
|
+
* run whatever the worker just wrote into the tree, in a child that — before
|
|
113
|
+
* this — inherited the whole operator environment, credentials included. None
|
|
114
|
+
* of git/npm/tsc/pytest/mypy needs an API key to judge a tree, so the exposure
|
|
115
|
+
* bought nothing and is removed. This is EXPOSURE REDUCTION, not a sandbox:
|
|
116
|
+
* see the threat-model note in the PRD (v1.41) and README for what remains.
|
|
117
|
+
*
|
|
118
|
+
* Two rules, deliberately kept BOTH ways round:
|
|
119
|
+
* - `names` / `prefixes` — the knowns. A literal denylist is a chase (every
|
|
120
|
+
* new provider mints a new variable), so it is not load-bearing on its own;
|
|
121
|
+
* it exists for names the shape rule STRUCTURALLY cannot see — either no
|
|
122
|
+
* `_`-bounded credential suffix at all (`AWS_ACCESS_KEY_ID`, `PGPASSWORD`,
|
|
123
|
+
* `MYSQL_PWD`), a `*_KEY` that is not `*_API_KEY` (`SSH_PRIVATE_KEY`), or a
|
|
124
|
+
* BARE spelling with nothing in front of the suffix (`TOKEN`, `SECRET`).
|
|
125
|
+
* Names are matched EXACTLY, which is what keeps connection config out of
|
|
126
|
+
* the strip (`PGHOST`, `SECRETS_DIR`, `TOKENIZERS_PARALLELISM` all survive).
|
|
127
|
+
* - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
|
|
128
|
+
* whose name ends in the credential suffixes goes, whoever minted it and
|
|
129
|
+
* whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
|
|
130
|
+
* so `.test` stays stateless and safe to reuse across every key.
|
|
131
|
+
*
|
|
132
|
+
* It is arbiter territory like the rest of this file: no drafted plan or config
|
|
133
|
+
* can express, widen, or opt out of it.
|
|
134
|
+
* @type {Readonly<{names: readonly string[], prefixes: readonly string[], shape: RegExp}>}
|
|
135
|
+
*/
|
|
136
|
+
export const CLOSE_ENV_DENY = Object.freeze({
|
|
137
|
+
names: Object.freeze([
|
|
138
|
+
// provider keys
|
|
139
|
+
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
|
|
140
|
+
'ANTHROPIC_AUTH_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN',
|
|
141
|
+
// credential spellings the shape rule structurally cannot see, because their
|
|
142
|
+
// names carry no `_`-bounded suffix (operator ruling 2026-07-31, "add", on a
|
|
143
|
+
// delta-review finding that MEASURED every one of these reaching the child).
|
|
144
|
+
// Documented tools' own variables, not invented ones:
|
|
145
|
+
'PGPASSWORD', 'PGPASSFILE', 'PGSSLKEY', // libpq (`PGPASS` is NOT a libpq variable — the FILE is ~/.pgpass, named by PGPASSFILE)
|
|
146
|
+
'MYSQL_PWD', 'REDISCLI_AUTH', // mysql client, redis-cli
|
|
147
|
+
'STRIPE_KEY', 'STRIPE_SECRET_KEY', // `*_KEY` is not `*_API_KEY`: the shape rule misses it
|
|
148
|
+
'SSH_PRIVATE_KEY', 'PRIVATE_KEY', 'SECRET_KEY',
|
|
149
|
+
'TOKEN', 'SECRET', 'API_KEY', 'PASSWORD', 'PASSWD', // the bare spellings; a suffix rule needs a prefix to bound
|
|
150
|
+
]),
|
|
151
|
+
prefixes: Object.freeze(['AWS_']),
|
|
152
|
+
shape: /(_API_KEY|_SECRET|_TOKEN|_PASSWORD|_CREDENTIALS?)$/i,
|
|
153
|
+
});
|
|
8
154
|
|
|
9
155
|
/**
|
|
10
156
|
* Run a close command; its exit code is the verdict (hard green, PRD §4).
|
|
@@ -68,18 +214,26 @@ import { spawnSync } from 'node:child_process';
|
|
|
68
214
|
* (the workdir — the tree it is judging), the exit code the SIGNED spec calls
|
|
69
215
|
* success, the judgment-rendered signal, and the kept-failures pattern. All
|
|
70
216
|
* five are arbiter territory: the drafted workflow config cannot express any.
|
|
71
|
-
* @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
|
|
217
|
+
* @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
|
|
72
218
|
* gap?: string, exitCode?: number, signal?: string, detail?: string,
|
|
73
|
-
* judgedCount?: number|null, unaudited?: boolean}}
|
|
219
|
+
* judgedCount?: number|null, unaudited?: boolean}>}
|
|
74
220
|
*/
|
|
75
|
-
export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
|
|
221
|
+
export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
|
|
76
222
|
const env = { ...process.env };
|
|
77
223
|
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
78
|
-
|
|
224
|
+
// …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
|
|
225
|
+
// stripped, never `process.env` itself: the host still needs its key (bare-agent
|
|
226
|
+
// reads it every round) — only the child that runs worker-authored code is blinded.
|
|
227
|
+
for (const name of Object.keys(env)) {
|
|
228
|
+
if (CLOSE_ENV_DENY.names.includes(name)
|
|
229
|
+
|| CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
|
|
230
|
+
|| CLOSE_ENV_DENY.shape.test(name)) delete env[name];
|
|
231
|
+
}
|
|
232
|
+
const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
|
|
79
233
|
|
|
80
234
|
// ── forbidden zone, before any exit code is believed ──────────────────────
|
|
81
235
|
if (r.error) {
|
|
82
|
-
//
|
|
236
|
+
// The runner reports BOTH "cannot run" and "ran too long" through `error`;
|
|
83
237
|
// they are different human decisions, so they get different names.
|
|
84
238
|
const code = /** @type {NodeJS.ErrnoException} */ (r.error).code;
|
|
85
239
|
if (code === 'ETIMEDOUT') return { verdict: 'timed-out', detail: `close exceeded ${timeoutMs}ms and was killed — it never finished judging` };
|
|
@@ -131,6 +285,56 @@ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, e
|
|
|
131
285
|
};
|
|
132
286
|
}
|
|
133
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Run a STAGED close (PRD v1.28) — an ordered list of named command stages, run
|
|
290
|
+
* in order until one renders a verdict that is not `satisfied`. The return is
|
|
291
|
+
* runClose's own shape, so every consumer (the precheck, ralph's judge, the
|
|
292
|
+
* escalation's CLOSE_FAULTS routing) is unchanged: a staged close is still ONE
|
|
293
|
+
* verdict, not a scoreboard.
|
|
294
|
+
*
|
|
295
|
+
* Two additions, both mechanical information the worker can act on:
|
|
296
|
+
* - `stage` names which stage rendered the verdict. The close's output must
|
|
297
|
+
* never name the CULPRIT FILE (v1.12/F28 — the worker finds that itself), and
|
|
298
|
+
* naming the wall it hit is the opposite of that: it is the mechanical genre
|
|
299
|
+
* that converts (F38/F46), not the answer.
|
|
300
|
+
* - `stages` is the ordered record of what actually ran. A stage after the
|
|
301
|
+
* first red never runs, so a later green can never mask an earlier failure.
|
|
302
|
+
*
|
|
303
|
+
* The same function runs a DERIVED CHECK: a check is its stage's chain
|
|
304
|
+
* (`checkMenu` puts the prerequisites first), so there is one execution path for
|
|
305
|
+
* the close and for the rulers derived from it — never two, which is the F9
|
|
306
|
+
* two-transforms class applied to the arbiter.
|
|
307
|
+
*
|
|
308
|
+
* @param {any[]} stages the stage list (already validated by validateJob)
|
|
309
|
+
* @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
|
|
310
|
+
* @param {{timeoutMs?: number, cwd?: string}} [opts]
|
|
311
|
+
* @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
|
|
312
|
+
*/
|
|
313
|
+
export async function runStages(stages, redact = (s) => s, { timeoutMs, cwd } = {}) {
|
|
314
|
+
const ran = [];
|
|
315
|
+
let last;
|
|
316
|
+
for (const st of stages) {
|
|
317
|
+
// Sequential by construction (F68): stages run one at a time under a single
|
|
318
|
+
// await, so nothing here ever has two closes in flight against one tree.
|
|
319
|
+
const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
|
|
320
|
+
timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
|
|
321
|
+
});
|
|
322
|
+
ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
|
|
323
|
+
last = r;
|
|
324
|
+
if (r.verdict !== 'satisfied') {
|
|
325
|
+
return {
|
|
326
|
+
...r,
|
|
327
|
+
stage: st.name,
|
|
328
|
+
stages: ran,
|
|
329
|
+
// The gap says WHICH wall, then the wall's own output. A worker handed
|
|
330
|
+
// three stages' worth of output cannot tell which one it must satisfy.
|
|
331
|
+
...(r.gap ? { gap: `close stage "${st.name}" failed:\n${r.gap}` } : {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { ...last, stages: ran };
|
|
336
|
+
}
|
|
337
|
+
|
|
134
338
|
// Tail-biased bound: a test runner's useful output (the assertion diff, the
|
|
135
339
|
// failing case name) is at the END; head-only truncation fed the worker the
|
|
136
340
|
// preamble and dropped the cause (N2 queue, F2). Head sample kept so "what ran"
|
|
@@ -219,6 +423,27 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
219
423
|
},
|
|
220
424
|
});
|
|
221
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
|
|
428
|
+
* happens to return". Two governors implement it and they read different signals —
|
|
429
|
+
* the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
|
|
430
|
+
* per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
|
|
431
|
+
* interface here and interprets nothing itself.
|
|
432
|
+
*
|
|
433
|
+
* `record` returns the governor's own reading, which ralph emits verbatim and never
|
|
434
|
+
* inspects; each reading names its `governor` so two instruments under one spine type
|
|
435
|
+
* can never be averaged into one number. `report` is the only shape ralph itself
|
|
436
|
+
* reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
|
|
437
|
+
* category, never the outcome.
|
|
438
|
+
*
|
|
439
|
+
* @typedef {object} LoopGovernor
|
|
440
|
+
* @property {(o: {iteration: number, gap?: string}) => object} record
|
|
441
|
+
* @property {() => boolean} struckOut
|
|
442
|
+
* @property {() => {strikes: number, limit: number, iterations: number, distinctGaps?: number}} report
|
|
443
|
+
* @property {() => string} brief
|
|
444
|
+
* @property {() => {decision: string, options: string[]}} [terminal]
|
|
445
|
+
*/
|
|
446
|
+
|
|
222
447
|
/**
|
|
223
448
|
* The loop: `while close-red and under-cap: run the middle`. Stops at first
|
|
224
449
|
* green (within-run tuning past a visible close is the fit-to-pass surface,
|
|
@@ -244,7 +469,28 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
244
469
|
* zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
|
|
245
470
|
* Injected by run.js only; inexpressible in any config or plan — the agent
|
|
246
471
|
* never authors its judge any more than its close.
|
|
247
|
-
* @param {number} opts.capRuns budget: max middle runs
|
|
472
|
+
* @param {number} [opts.capRuns] budget: max middle runs. Required UNLESS `ladder`
|
|
473
|
+
* is supplied — the two exhaustion rules are alternatives, never both at once.
|
|
474
|
+
* The "unless" is ENFORCED at entry (a param guard that throws), not merely
|
|
475
|
+
* documented: an optional-in-the-contract bound with nothing checking it made a
|
|
476
|
+
* loop that never ran report a governance stop that never happened.
|
|
477
|
+
* @param {LoopGovernor} [opts.ladder]
|
|
478
|
+
* the PROGRESS governor (src/ladder.js) that replaces the fixed count for a plan
|
|
479
|
+
* STEP's micro-loop. With it, the loop is bounded by strikes rather than by a
|
|
480
|
+
* number of runs: iterations float, and money, the wall, the variance meter and
|
|
481
|
+
* the stall fuse keep every bit of their current authority (each still ends the
|
|
482
|
+
* loop through the middle's own throw). Ralph still interprets nothing — it hands
|
|
483
|
+
* the ladder the iteration's gap and reads back a boolean.
|
|
484
|
+
*
|
|
485
|
+
* The CLOSE-FIX loop now supplies one too (PRD v1.46 §4, once its own $0 replay
|
|
486
|
+
* over all 8 archived fix loops came back clean: 0 greens harmed, 1 waste case
|
|
487
|
+
* caught). Its governor reads a different signal — the close's own per-stage
|
|
488
|
+
* graded numbers (src/trend.js), not repeats and writes — which is exactly why
|
|
489
|
+
* this is a SEAM and not a hard-coded rule: ralph still interprets nothing.
|
|
490
|
+
* An optional `terminal()` on the governor overrides the exhaustion prose, because
|
|
491
|
+
* the step ladder's copy offers the human a replan and there is no planner at the
|
|
492
|
+
* close. The category and the outcome are NOT overridable: one exhaustion terminal,
|
|
493
|
+
* two triggers.
|
|
248
494
|
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
249
495
|
* @param {(s: string) => string} [opts.redact] source scrubber for close output
|
|
250
496
|
* (secrets never enter the spine); injected so the shell stays stdlib-only
|
|
@@ -263,8 +509,30 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
263
509
|
* with zero writes, a crash stays what it always was: an instrument stop.
|
|
264
510
|
* @returns {Promise<'green'|'escalated'>}
|
|
265
511
|
*/
|
|
266
|
-
export async function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
|
|
267
|
-
|
|
512
|
+
export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
|
|
513
|
+
// PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
|
|
514
|
+
// thing this shell throws for; a WORKER's bad reach comes back as a refusal
|
|
515
|
+
// RESULT instead, and that rule is about feedback, not about the API contract).
|
|
516
|
+
// `capRuns` became optional in this signature when `ladder` arrived, and nothing
|
|
517
|
+
// enforced the "unless". MEASURED with neither supplied: `1 <= undefined` is
|
|
518
|
+
// false, so the middle ran 0 times and ralph still emitted `cap-halt` and an
|
|
519
|
+
// escalation reading `undefined/undefined runs spent, close still red` — a
|
|
520
|
+
// governance stop narrated for a run that never happened, on the public export.
|
|
521
|
+
// A bound that reports halting a loop it never entered is exactly the
|
|
522
|
+
// blind-instrument class this repo keeps paying for, so the malformed call dies
|
|
523
|
+
// at the door instead of minting a record. The message names BOTH alternatives:
|
|
524
|
+
// either one repairs the call, and naming one would send the caller to the wrong
|
|
525
|
+
// repair.
|
|
526
|
+
if (!ladder && !(Number.isInteger(capRuns) && /** @type {number} */ (capRuns) > 0)) {
|
|
527
|
+
// quoted only for a string, so `"3"` reads as the type error it is while
|
|
528
|
+
// `NaN`/`undefined` read as themselves (JSON.stringify renders both as a lie)
|
|
529
|
+
const got = typeof capRuns === 'string' ? JSON.stringify(capRuns) : String(capRuns);
|
|
530
|
+
throw new Error(`ralph: capRuns must be a positive integer (got ${got}) unless a \`ladder\` governor is supplied — they are alternative exhaustion rules and a loop with neither cannot stop honestly`);
|
|
531
|
+
}
|
|
532
|
+
emit('run-start', {
|
|
533
|
+
...(ladder ? { strikeLimit: ladder.report().limit } : { capRuns }),
|
|
534
|
+
close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' '),
|
|
535
|
+
});
|
|
268
536
|
// The blind spot is NAMED, never hidden: with no judgment-rendered signal this
|
|
269
537
|
// close cannot tell a crash from an honest red (they are byte-identical at the
|
|
270
538
|
// exit-code seam), so the record says so out loud rather than passing the exit
|
|
@@ -274,7 +542,22 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
274
542
|
if (!judged && !judge) emit('close-unaudited', { close: /** @type {string[]} */ (close).join(' '), meaning: 'no judgment-rendered signal declared — a crash-at-load is indistinguishable from an honest red for this close' });
|
|
275
543
|
const verdicts = [];
|
|
276
544
|
let gap;
|
|
277
|
-
|
|
545
|
+
// ONE exhaustion terminal, two triggers — the same three records in the same
|
|
546
|
+
// order either way, so the count path and the ladder path cannot drift into
|
|
547
|
+
// reporting the stop differently. The CATEGORY is `cap-halt` for both, and
|
|
548
|
+
// deliberately: downstream counts escalation categories against an executable
|
|
549
|
+
// excluded-set (ledger.js), and the step loop routes its ONE replan off exactly
|
|
550
|
+
// this name. Only the trigger changed; the taxonomy did not.
|
|
551
|
+
/** @param {number} iteration @param {object} halt @param {object} spend @param {string} decision @param {string[]} options */
|
|
552
|
+
const exhausted = (iteration, halt, spend, decision, options) => {
|
|
553
|
+
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', ...halt });
|
|
554
|
+
emit('escalation', { category: 'cap-halt', decisionReady: true, verdicts, spend, decision, options });
|
|
555
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
556
|
+
return /** @type {'escalated'} */ ('escalated');
|
|
557
|
+
};
|
|
558
|
+
// With a ladder the count is not the bound: the loop runs until the ladder strikes
|
|
559
|
+
// out, or until the middle throws (money, wall, variance, stall — all unchanged).
|
|
560
|
+
for (let iteration = 1; ladder || iteration <= /** @type {number} */ (capRuns); iteration++) {
|
|
278
561
|
emit('iteration-start', { iteration });
|
|
279
562
|
try {
|
|
280
563
|
await middle(iteration, gap);
|
|
@@ -289,6 +572,36 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
289
572
|
['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
|
|
290
573
|
'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
|
|
291
574
|
['retry the run', 'fix the provider binding', 'abandon the task']],
|
|
575
|
+
// A (PRD v1.27) — the METER stopped this attempt before it started: the
|
|
576
|
+
// step had already eaten a declared share of the run with its exits
|
|
577
|
+
// unmoved. Not a fault, not a cap: the caller reads this category and
|
|
578
|
+
// REPLANS, so the message must not say anything broke.
|
|
579
|
+
'step-variance': ['A step consumed a large share of the run with its exits still red — the meter stopped it before another attempt.',
|
|
580
|
+
['let the planner re-allocate what is left (replan)', 'raise the budget and rerun', 'abandon the task']],
|
|
581
|
+
// T/F64 — the run's WALL CLOCK stopped this: either the caller's deadline
|
|
582
|
+
// came back as the provider's own timeout (F64, mid-attempt) or the caller
|
|
583
|
+
// read the clock and declined to START another attempt (W-2, between them).
|
|
584
|
+
// A governance stop either way, not a fault and not a transport casualty:
|
|
585
|
+
// the message must not send a human to debug a socket, and the remedy is the
|
|
586
|
+
// operator's cap, not a retry. The wording says neither "mid-attempt" nor
|
|
587
|
+
// "between attempts", because one entry serves both and a category that
|
|
588
|
+
// narrates the wrong half is worse than one that narrates neither.
|
|
589
|
+
// The two levers are BOTH spec edits (a changed spec hash the runner refuses
|
|
590
|
+
// until it is re-approved), and they point opposite ways — which is why the
|
|
591
|
+
// caller's detail carries the progress trend that says which one fits.
|
|
592
|
+
'wall-halt': ['The run reached its wall-clock cap — time ran out, not capability. Nothing is discarded: the last verdict rendered stands, and the stop is the checkpoint.',
|
|
593
|
+
['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
594
|
+
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
595
|
+
'abandon the task']],
|
|
596
|
+
// F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
|
|
597
|
+
// three times over, and reissuing the call did not recover it. It borrows both
|
|
598
|
+
// siblings' rules. Like `step-variance`, the caller reads this category and
|
|
599
|
+
// REPLANS, so the planner's lever leads. Like `wall-halt`, nothing broke — the
|
|
600
|
+
// socket stayed alive the whole time (that is the measured mechanism), so the
|
|
601
|
+
// message must not send a human to debug transport, and the default's "fix the
|
|
602
|
+
// interpreter" would aim a human at the one component that did nothing wrong.
|
|
603
|
+
'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
|
|
604
|
+
['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
|
|
292
605
|
};
|
|
293
606
|
// Object.hasOwn, not bare lookup: a category named after an
|
|
294
607
|
// Object.prototype member ("toString") would return the inherited
|
|
@@ -311,7 +624,7 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
311
624
|
emit('middle-done', { iteration });
|
|
312
625
|
const v = judge
|
|
313
626
|
? await judge()
|
|
314
|
-
: runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
627
|
+
: await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
315
628
|
// Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
|
|
316
629
|
// still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
|
|
317
630
|
// precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
|
|
@@ -335,30 +648,56 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
335
648
|
gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
|
|
336
649
|
+ `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
|
|
337
650
|
+ 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
|
|
338
|
-
|
|
651
|
+
} else {
|
|
652
|
+
// Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
|
|
653
|
+
// by the outcome's OWN name and never retry — retrying a broken arbiter is
|
|
654
|
+
// the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
|
|
655
|
+
const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
656
|
+
if (fault) {
|
|
657
|
+
emit('escalation', {
|
|
658
|
+
category: fault.category, decisionReady: true, verdicts,
|
|
659
|
+
spend: { runs: iteration, capRuns },
|
|
660
|
+
decision: fault.decision, options: fault.options, detail: v.detail,
|
|
661
|
+
});
|
|
662
|
+
emit('run-end', { outcome: 'escalated', iterations: iteration });
|
|
663
|
+
return 'escalated';
|
|
664
|
+
}
|
|
665
|
+
gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
|
|
339
666
|
}
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
667
|
+
// ── the ladder reads THIS red iteration, whichever gap it ended up with (a
|
|
668
|
+
// worker-crash gap is feedback the attempt really received, so it is a reading
|
|
669
|
+
// like any other). ONE call site, after both branches have settled `gap`: two
|
|
670
|
+
// would let a future branch quietly opt out of the bound.
|
|
671
|
+
if (ladder) {
|
|
672
|
+
const r = ladder.record({ iteration, gap });
|
|
673
|
+
emit('ladder', r);
|
|
674
|
+
if (ladder.struckOut()) {
|
|
675
|
+
const rep = ladder.report();
|
|
676
|
+
// The governor may name its own decision/options; it may NOT name its own
|
|
677
|
+
// category or outcome. A stop is spelled the same either way (`cap-halt`,
|
|
678
|
+
// 'escalated') because downstream counts categories against an executable
|
|
679
|
+
// excluded-set — only the PROSE a human reads is governor-specific.
|
|
680
|
+
const term = typeof ladder.terminal === 'function' ? ladder.terminal() : null;
|
|
681
|
+
return exhausted(iteration,
|
|
682
|
+
{
|
|
683
|
+
strikes: rep.strikes,
|
|
684
|
+
strikeLimit: rep.limit,
|
|
685
|
+
iterations: rep.iterations,
|
|
686
|
+
...(rep.distinctGaps === undefined ? {} : { distinctGaps: rep.distinctGaps }),
|
|
687
|
+
},
|
|
688
|
+
{ runs: iteration, strikes: rep.strikes, strikeLimit: rep.limit },
|
|
689
|
+
term?.decision ?? `${rep.strikes}/${rep.limit} strikes — the step stopped making progress. ${ladder.brief()} Continue, change approach, or stop?`,
|
|
690
|
+
term?.options ?? ['let the planner re-allocate what is left (replan)', 'change approach', 'abandon the task']);
|
|
691
|
+
}
|
|
352
692
|
}
|
|
353
|
-
gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
|
|
354
693
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
694
|
+
// The FIXED-COUNT exhaustion, reached only without a ladder. Both callers inside this
|
|
695
|
+
// package now supply one (the step loop's `createLadder`, the close-fix loop's
|
|
696
|
+
// progress governor), so the only route left is the public export — kept because it IS
|
|
697
|
+
// the public export, and because a `capRuns` bound with no reachable exhaustion would
|
|
698
|
+
// be a bound in name only.
|
|
699
|
+
const spent = /** @type {number} */ (capRuns);
|
|
700
|
+
return exhausted(spent, { capRuns }, { runs: spent, capRuns },
|
|
701
|
+
`${spent}/${spent} runs spent, close still red. Continue, change approach, or stop?`,
|
|
702
|
+
['raise the cap and rerun', 'change the middle/harness', 'abandon the task']);
|
|
364
703
|
}
|