bareloop 0.6.0 → 0.8.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 +690 -0
- package/README.md +30 -11
- package/bareloop.context.md +658 -38
- package/package.json +2 -2
- package/src/bridges.js +571 -0
- package/src/clock.js +57 -5
- package/src/exits.js +8 -2
- package/src/index.js +29 -0
- package/src/job.js +43 -6
- package/src/ladder.js +196 -0
- package/src/ledger.js +84 -9
- package/src/plan.js +106 -107
- package/src/planrun.js +1040 -168
- package/src/ralph.js +156 -29
- package/src/reuse.js +1665 -0
- package/src/run.js +92 -6
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/trend.js +405 -0
- package/src/validate.js +121 -0
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +25 -3
- package/types/exits.d.ts +14 -0
- package/types/index.d.ts +3 -0
- package/types/job.d.ts +13 -2
- package/types/ladder.d.ts +86 -0
- package/types/plan.d.ts +41 -41
- package/types/planrun.d.ts +208 -9
- package/types/ralph.d.ts +98 -4
- package/types/reuse.d.ts +392 -0
- package/types/run.d.ts +63 -3
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/trend.d.ts +108 -0
- package/types/validate.d.ts +50 -0
package/src/clock.js
CHANGED
|
@@ -68,6 +68,14 @@ export const MIN_CALL_TIMEOUT_MS = 30_000;
|
|
|
68
68
|
* it as transport. */
|
|
69
69
|
export const TIMEOUT_CODE = 'ETIMEDOUT';
|
|
70
70
|
|
|
71
|
+
/** bare-agent's rejection code when its TOTAL-duration deadline trips (BA-19,
|
|
72
|
+
* `provider-http.js` → `applyRequestDeadline`). A DIFFERENT code from the idle
|
|
73
|
+
* bound's on purpose — upstream splits them precisely so a consumer routing
|
|
74
|
+
* governance stops apart from transport casualties can tell which timer fired. Like
|
|
75
|
+
* `TIMEOUT_CODE` it carries no `category`, so every default-to-transport catch site
|
|
76
|
+
* would file it as a casualty. */
|
|
77
|
+
export const DEADLINE_CODE = 'EDEADLINE';
|
|
78
|
+
|
|
71
79
|
/**
|
|
72
80
|
* F64 — is this throw the run's OWN deadline coming back dressed as a provider
|
|
73
81
|
* error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
|
|
@@ -90,12 +98,20 @@ export const TIMEOUT_CODE = 'ETIMEDOUT';
|
|
|
90
98
|
* the same (raise `maxWallMs`), and the alternative — calling a governance stop
|
|
91
99
|
* transport on the chance that it was — is the failure this closes.
|
|
92
100
|
*
|
|
101
|
+
* BOTH derived bounds land here, because both are derived from the same wall:
|
|
102
|
+
* BA-18's idle timeout (`callTimeoutMs`) and BA-19's total-duration deadline
|
|
103
|
+
* (`callDeadlineMs`). Adding the second CODE does not widen the rule — the
|
|
104
|
+
* discriminator is still `clock.expired()`, never the code. That matters for the
|
|
105
|
+
* deadline specifically: a provider someone else constructed with its own
|
|
106
|
+
* `deadlineMs` could throw this code on a run with time left, and that is transport,
|
|
107
|
+
* exactly as it was before.
|
|
108
|
+
*
|
|
93
109
|
* @param {any} err the throw as caught
|
|
94
110
|
* @param {Clock} clock the run's clock
|
|
95
111
|
* @returns {boolean}
|
|
96
112
|
*/
|
|
97
113
|
export function isWallTimeout(err, clock) {
|
|
98
|
-
return !!err && err.code === TIMEOUT_CODE && clock.expired();
|
|
114
|
+
return !!err && (err.code === TIMEOUT_CODE || err.code === DEADLINE_CODE) && clock.expired();
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
/**
|
|
@@ -108,21 +124,37 @@ export function isWallTimeout(err, clock) {
|
|
|
108
124
|
* @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
|
|
109
125
|
* @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
|
|
110
126
|
* @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
|
|
127
|
+
* @property {() => number|null} callDeadlineMs the derived per-call TOTAL-duration ceiling; `null` when unbounded
|
|
111
128
|
* @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
|
|
112
129
|
*/
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
132
|
* Start a wall clock for one run.
|
|
116
|
-
* @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
|
|
133
|
+
* @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number, priorElapsedMs?: number }} [opts]
|
|
117
134
|
* `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
|
|
118
135
|
* run. `closeStages`: how many stages the job's close runs (W5) — each one gets
|
|
119
136
|
* the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
|
|
120
137
|
* decoration. Defaults to 1, the single-predicate close. `now`: injected for
|
|
121
|
-
* tests — the default is the real clock.
|
|
138
|
+
* tests — the default is the real clock. `priorElapsedMs`: time this attempt's
|
|
139
|
+
* PREDECESSOR already consumed (see below).
|
|
122
140
|
* @returns {Clock}
|
|
123
141
|
*/
|
|
124
|
-
export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now() } = {}) {
|
|
125
|
-
|
|
142
|
+
export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
|
|
143
|
+
// RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
|
|
144
|
+
// mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
|
|
145
|
+
// because "a budget ceiling folds in prior spend so re-invoking cannot silently
|
|
146
|
+
// widen it". The fold happens HERE rather than by editing `maxWallMs`, and that
|
|
147
|
+
// is the whole point: the cap the operator signed stays the number reported and
|
|
148
|
+
// the number hashed (a rewritten cap is a new spec version needing a new
|
|
149
|
+
// signature), while the clock simply starts already partly spent. A fresh
|
|
150
|
+
// allotment on every kill would let a resumed run outlive the worst case the
|
|
151
|
+
// operator signed, one kill at a time.
|
|
152
|
+
//
|
|
153
|
+
// Belted like `maxWallMs` above: a non-finite, negative or garbage fold is 0. A
|
|
154
|
+
// NaN here would make every comparison false and silently unbound the run, which
|
|
155
|
+
// is the inverse of what the fold exists to do.
|
|
156
|
+
const prior = typeof priorElapsedMs === 'number' && Number.isFinite(priorElapsedMs) && priorElapsedMs > 0 ? priorElapsedMs : 0;
|
|
157
|
+
const startedAt = now() - prior;
|
|
126
158
|
// A cap is only a cap if it is a usable positive number. validateJob already
|
|
127
159
|
// reds anything else, so this is the belt (a caller that hand-builds a spec
|
|
128
160
|
// must not be able to turn a garbage value into a silent unbounded run OR a
|
|
@@ -162,6 +194,26 @@ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Dat
|
|
|
162
194
|
// would restore the ~2h OS-TCP hang on the last round of a tight budget.
|
|
163
195
|
return Math.min(providerDefaultMs, Math.max(MIN_CALL_TIMEOUT_MS, remainingMs()));
|
|
164
196
|
},
|
|
197
|
+
// BA-19 (F72 park D) — the TOTAL-duration ceiling beside the idle one. The idle
|
|
198
|
+
// bound resets on every byte, so a response that trickles forever never trips it
|
|
199
|
+
// (F66's 274-minute call, and F67's 81.5-minute freeze happened above it); this
|
|
200
|
+
// one is a plain timer that no byte resets.
|
|
201
|
+
//
|
|
202
|
+
// Three properties, each a rule this repo already paid for:
|
|
203
|
+
// - NULL when unbounded. An operator who set no wall gets no ceiling — a
|
|
204
|
+
// defaulted cap is a silent second ceiling (F45, and the reason `maxWallMs`
|
|
205
|
+
// has no default). The caller must OMIT the knob on null, not pass a number.
|
|
206
|
+
// - REMAINING, read at call time. A constant would bound the last call by the
|
|
207
|
+
// first call's budget, which is the thing `callTimeoutMs` already refuses.
|
|
208
|
+
// - NOT clamped to the provider default. That upper clamp exists on the idle
|
|
209
|
+
// bound because a value above the provider's own default is inert; a
|
|
210
|
+
// total-duration ceiling above it is the entire point of BA-19.
|
|
211
|
+
// The lower floor is shared with `callTimeoutMs` and for the same two reasons: a
|
|
212
|
+
// bound certain to trip manufactures a stop out of arithmetic, and `0` DISABLES
|
|
213
|
+
// the bound upstream (`if (!(deadlineMs > 0)) return`) — the exact inverse of the
|
|
214
|
+
// intent. A trip past the floor is still honest: the wall has expired by then, so
|
|
215
|
+
// `isWallTimeout` reads it as the governance stop it is.
|
|
216
|
+
callDeadlineMs: () => (cap === null ? null : Math.max(MIN_CALL_TIMEOUT_MS, remainingMs())),
|
|
165
217
|
report: (closeTimeoutMs) => ({
|
|
166
218
|
bounded,
|
|
167
219
|
requestedMs: cap,
|
package/src/exits.js
CHANGED
|
@@ -33,8 +33,14 @@ import { globToPrefix } from './validate.js';
|
|
|
33
33
|
* `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
|
|
34
34
|
* feeds the worker (F28 reintroduced). This cap is a defensive backstop above
|
|
35
35
|
* boundGap's own envelope: a normal bounded gap passes through intact, and only
|
|
36
|
-
* a pathological seam that returned an unbounded gap would ever be trimmed.
|
|
37
|
-
|
|
36
|
+
* a pathological seam that returned an unbounded gap would ever be trimmed.
|
|
37
|
+
*
|
|
38
|
+
* Exported for the ONE reason `boundGap`/`GAP_KEEP_TRIM_MARKER` are (src/ralph.js):
|
|
39
|
+
* the replan brief re-bounds this same artifact one seam later (`closeGapBlock`), and
|
|
40
|
+
* a second hand-spelled ceiling there would be a second answer to "how big may a
|
|
41
|
+
* check gap be" — the two would drift on exactly the property that matters, whether
|
|
42
|
+
* the `not ok`/`FAILED` names survive. */
|
|
43
|
+
export const CHECK_GAP_MAX = 12000;
|
|
38
44
|
|
|
39
45
|
/** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
|
|
40
46
|
* `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
|
package/src/index.js
CHANGED
|
@@ -32,4 +32,33 @@ export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEP
|
|
|
32
32
|
export { snapshotScope, evalExits } from './exits.js';
|
|
33
33
|
export { runPlan } from './planrun.js';
|
|
34
34
|
export { runJob } from './run.js';
|
|
35
|
+
// Layer 3 — the REUSE registry (bridge-v1) and D3's display half. The registry is a
|
|
36
|
+
// directory of plain files at an OPERATOR-supplied path (never a default location), the
|
|
37
|
+
// load gate is the code half of D2's split, and `renderListing`/`selectionPrompt` are
|
|
38
|
+
// pure text: they send nothing, parse nothing and decide nothing. The selection CALL and
|
|
39
|
+
// the pin/shortlist/force-cold flow are the adopter's, which is why the pieces they need
|
|
40
|
+
// are exported rather than kept internal to the runner.
|
|
41
|
+
export {
|
|
42
|
+
BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate,
|
|
43
|
+
mintBridge, appendGreen, appendRed,
|
|
44
|
+
loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists,
|
|
45
|
+
} from './bridges.js';
|
|
46
|
+
export { renderListing, selectionPrompt } from './selection.js';
|
|
47
|
+
// Layer 3 modules 4+5 — the D7 envelope and the reuse runner. `runReuse` composes
|
|
48
|
+
// `runJob` under an operator-signed envelope (three explicit numbers, tighten-only) and
|
|
49
|
+
// is the only thing in the library that WRITES the registry — minting is a graded
|
|
50
|
+
// green's privilege (R1), never a caller's. `validateEnvelope`/`resolveTrySpec` are
|
|
51
|
+
// exported because an operator runner has to show the resolved per-try spec's HASH at
|
|
52
|
+
// its approval gate: a tightened envelope is a new spec version, and the human signs it.
|
|
53
|
+
// `resolveReuse`/`reuseSpecHash` are the pair that gate must actually PRINT: the try
|
|
54
|
+
// count multiplies the worst case, so a signature over the per-try spec alone leaves it
|
|
55
|
+
// unsigned — a runner showing `jobSpecHash(resolveTrySpec(...))` prints the same hash for
|
|
56
|
+
// 0 tries and for 9.
|
|
57
|
+
// Module C (resume after a kill) exports its two operator-side halves for the same
|
|
58
|
+
// reason: `readResume` is what turns a dead run's spine back into the state a resume
|
|
59
|
+
// continues from (which tries completed, which one restarts, on what remainder), and
|
|
60
|
+
// `resumeTreeGate` is the ruling that a resumed patient is continued dirty and never
|
|
61
|
+
// reset. Both are read by a runner BEFORE the approval gate, so neither can live behind
|
|
62
|
+
// `runReuse`'s own entry.
|
|
63
|
+
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
|
|
35
64
|
export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
|
package/src/job.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// judgment call. Minting policy is product doctrine, not job-authorable.
|
|
15
15
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
|
-
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
17
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
|
|
18
18
|
|
|
19
19
|
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
20
20
|
// they are read at call time, so a mutable export would let adopter code
|
|
@@ -123,7 +123,12 @@ const CLOSE_FIELDS = {
|
|
|
123
123
|
};
|
|
124
124
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
125
125
|
|
|
126
|
-
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb`
|
|
126
|
+
/** @typedef {{code: string, path: string, detail?: string, verb?: string, lib?: string}} Red — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
|
|
127
|
+
* `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
|
|
128
|
+
* (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
|
|
129
|
+
* against the worker-surface package, a locked VERDICT type is demand against
|
|
130
|
+
* bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
|
|
131
|
+
* filed as an upstream bug (the BA-2 misattribution class). */
|
|
127
132
|
|
|
128
133
|
/**
|
|
129
134
|
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
@@ -278,6 +283,21 @@ function validateClose(close, at, red) {
|
|
|
278
283
|
return true;
|
|
279
284
|
}
|
|
280
285
|
|
|
286
|
+
/**
|
|
287
|
+
* The F49 reject's OPERATOR-side detail. ONE spelling for both regex fields —
|
|
288
|
+
* they differ only in which untimed evaluator the pattern reaches — so the two
|
|
289
|
+
* reds can never drift into saying different things about the same hazard.
|
|
290
|
+
*
|
|
291
|
+
* The second sentence is the part an operator cannot derive: on the agent side
|
|
292
|
+
* F49 is self-DoS, but these two patterns are compiled and run in BARELOOP'S OWN
|
|
293
|
+
* PROCESS, where the hang blocks the event loop the in-process stall fuse lives
|
|
294
|
+
* on (F67 — a guard sharing the fate of the process it guards). The run then
|
|
295
|
+
* dies to the outside watchdog and reads as a model stall, so without this red a
|
|
296
|
+
* bad regex in a SIGNED spec is diagnosed as a provider problem.
|
|
297
|
+
* @param {string} evaluator the consumer that would run it, named concretely
|
|
298
|
+
*/
|
|
299
|
+
const redosDetail = (evaluator) => `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang ${evaluator}, untimed (F49). It runs in bareloop's own process, so the hang blocks the event loop the stall fuse lives on (F67) and the run dies to the outside watchdog looking like a model stall; rewrite without a repeated group inside a repeat`;
|
|
300
|
+
|
|
281
301
|
/**
|
|
282
302
|
* The predicate BODY contract — cmd/expect/judged/gapKeep. Shared verbatim by
|
|
283
303
|
* the predicate close and the Layer 2 named checks (decision 1: a check runs
|
|
@@ -330,6 +350,13 @@ function predicateBody(o, at, red) {
|
|
|
330
350
|
// the fake green this floor exists to catch). Alternation stays
|
|
331
351
|
// fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
|
|
332
352
|
else if (groups > 1) red('invalid-value', `${at}.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
|
|
353
|
+
// F49, operator side: `runClose` execs this against the close's whole
|
|
354
|
+
// stdout+stderr with no timeout. Gated on `groups >= 0` — that is the
|
|
355
|
+
// COMPILED flag in disguise (the catch sets -1), and the scan documents
|
|
356
|
+
// that it assumes syntactically valid input.
|
|
357
|
+
if (groups >= 0 && hasNestedQuantifier(j.pattern)) {
|
|
358
|
+
red('invalid-value', `${at}.judged.pattern`, redosDetail('the close reader (runClose)'));
|
|
359
|
+
}
|
|
333
360
|
}
|
|
334
361
|
if (!Number.isInteger(j.min) || j.min < 1) {
|
|
335
362
|
red('invalid-value', `${at}.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
|
|
@@ -348,8 +375,15 @@ function predicateBody(o, at, red) {
|
|
|
348
375
|
if (o.gapKeep !== undefined) {
|
|
349
376
|
if (!isNonEmptyString(o.gapKeep)) red('invalid-value', `${at}.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
|
|
350
377
|
else {
|
|
351
|
-
|
|
378
|
+
let compiled = false;
|
|
379
|
+
try { new RegExp(o.gapKeep, 'm'); compiled = true; }
|
|
352
380
|
catch { red('invalid-value', `${at}.gapKeep`, 'must compile as a RegExp'); }
|
|
381
|
+
// F49, operator side and the measured one: `boundGap` runs this per LINE
|
|
382
|
+
// over the whole close stream, untimed. Measured: `(a+)+$` against a
|
|
383
|
+
// 40-char line does not finish in 20s.
|
|
384
|
+
if (compiled && hasNestedQuantifier(o.gapKeep)) {
|
|
385
|
+
red('invalid-value', `${at}.gapKeep`, redosDetail('the gap bound (boundGap), once per line of close output'));
|
|
386
|
+
}
|
|
353
387
|
}
|
|
354
388
|
}
|
|
355
389
|
}
|
|
@@ -486,8 +520,9 @@ function validatePlanShape(spec, red, reds) {
|
|
|
486
520
|
else if (LOCKED_VERDICTS.includes(spec.verdictType)) {
|
|
487
521
|
// declared-but-locked (disclosure ≠ admission, the tool-menu pattern):
|
|
488
522
|
// the type rides as a structured field — the ledger keys admission demand
|
|
489
|
-
// on it, never on prose
|
|
490
|
-
|
|
523
|
+
// on it, never on prose. `lib: 'bareloop'`: the refused catalogue is OURS
|
|
524
|
+
// (VERDICT_TYPES), so this demand is never an upstream ask.
|
|
525
|
+
reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, lib: 'bareloop', detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
|
|
491
526
|
demanded = CLASS_BY_VERDICT[spec.verdictType];
|
|
492
527
|
} else if (!VERDICT_TYPES.includes(spec.verdictType)) {
|
|
493
528
|
red('invalid-value', 'verdictType', `menu: ${VERDICT_TYPES.join('|')} — an unknown type is a typo, never a request`);
|
|
@@ -529,7 +564,9 @@ function validatePlanShape(spec, red, reds) {
|
|
|
529
564
|
red('invalid-value', 'tools', `non-empty unique subset of ${TOOL_MENU.join('|')}`);
|
|
530
565
|
} else {
|
|
531
566
|
for (const t of spec.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
532
|
-
|
|
567
|
+
// `lib: 'bare-agent'`: a locked TOOL verb is demand against the
|
|
568
|
+
// worker-surface package that would have to expose it (the original case)
|
|
569
|
+
reds.push({ code: 'request-red', path: 'tools', verb: t, lib: 'bare-agent', detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
|
|
533
570
|
}
|
|
534
571
|
const unknown = spec.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
535
572
|
if (unknown.length) red('invalid-value', 'tools', `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
package/src/ladder.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The step ladder's governor — PROGRESS, not a count (design 2026-08-03).
|
|
2
|
+
//
|
|
3
|
+
// MEASURED reason this exists. The step loop was bounded by a fixed number of
|
|
4
|
+
// check-iterations (`capRuns`, 4 in the operator's runner). Two real spines show
|
|
5
|
+
// what that cost: u-msd916dh went 30 → 22 → 17 → 11 scope errors across its
|
|
6
|
+
// iterations and was cut with $1.30 and ~6 minutes still unspent; u-mscxuziw went
|
|
7
|
+
// 13 → 1 and was cut the same way. The count could not tell converging work from
|
|
8
|
+
// thrash, so it stopped both at the same place. And the one lever the plan could
|
|
9
|
+
// pull — a step's `attempts` field — could only ever TIGHTEN below the shell's
|
|
10
|
+
// number, so the correct heal was inexpressible: drafters measurably tightened to
|
|
11
|
+
// 3, then to 2 on replan.
|
|
12
|
+
//
|
|
13
|
+
// The replacement asks the question the count could not: **is this iteration
|
|
14
|
+
// making progress?** Two signals, read per RED iteration:
|
|
15
|
+
//
|
|
16
|
+
// gap-new the failing exits' text, normalized and hashed, against every gap
|
|
17
|
+
// hash seen so far in this step's ladder. A SEEN-SET, never last-only:
|
|
18
|
+
// a real A→B→A oscillator exists in the archive and last-only reads it
|
|
19
|
+
// as progress.
|
|
20
|
+
// wrote did the worker make real write-class actions THIS iteration — read
|
|
21
|
+
// from the gate audit's own record COUNT (the F32 instrument:
|
|
22
|
+
// run-scoped, allow-decision, write AND edit). Never git status, never
|
|
23
|
+
// a tree diff. It must be a COUNT and not the path SET, because a plan
|
|
24
|
+
// step rewrites its one target every attempt — the set is constant
|
|
25
|
+
// after iteration 1 and would read every later iteration as idle (the
|
|
26
|
+
// same Finding-3 trap Layer R documents one module over).
|
|
27
|
+
//
|
|
28
|
+
// A **strike** is a red iteration that is a repeat OR wrote nothing. Strikes are
|
|
29
|
+
// STICKY — never reset, never repaid by a good iteration in between — because the
|
|
30
|
+
// question the ceiling answers is "has this step spent enough of the run failing
|
|
31
|
+
// to make progress", and a counter that a single good iteration zeroes can be held
|
|
32
|
+
// open forever by alternating.
|
|
33
|
+
//
|
|
34
|
+
// What still bounds a converging step, so this cannot run away: the wallet (the
|
|
35
|
+
// worker's Gate carries the run's remaining budget), the run's wall clock (W-2, at
|
|
36
|
+
// the head of every attempt), the variance meter (A), and the stall fuse. Those
|
|
37
|
+
// are unchanged and each still ends the ladder on its own authority. This module
|
|
38
|
+
// replaces exactly ONE of the old bounds: exhaustion-by-count.
|
|
39
|
+
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The strike ceiling. Shell-owned, exactly where the fixed count was: the runner
|
|
44
|
+
* sets it, the agent cannot express it, and it is arbiter territory — a step's own
|
|
45
|
+
* plan may not tighten it and may not raise it. Two is hamr's number.
|
|
46
|
+
*/
|
|
47
|
+
export const STRIKE_LIMIT = 2;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The volatile bytes, stripped before hashing. COMPARISON-ONLY: the gap the worker
|
|
51
|
+
* sees is never rewritten (the `normalizeRedLine` rule, one module over).
|
|
52
|
+
*
|
|
53
|
+
* Every entry is a byte a re-run changes while the failure does not. Without them
|
|
54
|
+
* a `node --test` gap is never byte-stable (its duration stamps move every run),
|
|
55
|
+
* so every repeat would read as a novel gap and the repeat signal would be dead —
|
|
56
|
+
* an instrument that cannot see the variable.
|
|
57
|
+
* @type {[RegExp, string][]}
|
|
58
|
+
*/
|
|
59
|
+
const VOLATILE = [
|
|
60
|
+
// TAP's own timing line, dropped WHOLE: its value is the only thing on it
|
|
61
|
+
[/^[ \t]*#?[ \t]*duration_ms[ \t]*[:=].*$/gm, ''],
|
|
62
|
+
// ISO-8601 stamps, wherever they appear in a close's output
|
|
63
|
+
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>'],
|
|
64
|
+
// any millisecond figure (`(1.738ms)`, `took 12 ms`)
|
|
65
|
+
[/\d+(?:\.\d+)?[ \t]*ms\b/g, '<ms>'],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a gap for comparison. Deliberately NARROW: only bytes a re-run changes
|
|
70
|
+
* on its own are removed. Error COUNTS, file names and line numbers all survive —
|
|
71
|
+
* a trajectory like "30 errors" → "22 errors" must read as two different gaps, and
|
|
72
|
+
* a normalizer that scrubbed numbers would turn the converging case this module
|
|
73
|
+
* exists to protect into an instant strike.
|
|
74
|
+
* @param {string} text @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeGap(text) {
|
|
77
|
+
let s = String(text ?? '');
|
|
78
|
+
for (const [re, to] of VOLATILE) s = s.replace(re, to);
|
|
79
|
+
// runs of spaces/tabs collapse (a re-run can re-pad a column); newlines do NOT —
|
|
80
|
+
// line structure is real content
|
|
81
|
+
return s.replace(/[ \t]+/g, ' ').replace(/[ \t]+$/gm, '').trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The comparable key for a gap: the normalized text, hashed. Hashed rather than
|
|
86
|
+
* kept whole because the seen-set lives for the whole step and a close's output
|
|
87
|
+
* can be tens of KB — and because the set holds no content, nothing here can leak
|
|
88
|
+
* model or file bytes into a record.
|
|
89
|
+
* @param {string} text @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function gapKey(text) {
|
|
92
|
+
return createHash('sha256').update(normalizeGap(text), 'utf8').digest('hex');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build one ladder. ONE PER STEP: the seen-set and the strike count are the step's
|
|
97
|
+
* own history, and a ladder shared across steps would strike a fresh step for a gap
|
|
98
|
+
* an earlier one had already seen.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} opts
|
|
101
|
+
* @param {number} [opts.limit] strikes that end the step (default `STRIKE_LIMIT`)
|
|
102
|
+
* @param {() => number} opts.writeCount the CUMULATIVE count of the worker's
|
|
103
|
+
* allow-decision write/edit records so far this step (planrun reads it off the
|
|
104
|
+
* gate audit). Required, and a throw when it is missing rather than a silent
|
|
105
|
+
* degradation to repeats-only: a strike rule running on one of its two signals
|
|
106
|
+
* with nothing on the record saying so is the blind-instrument class, and this
|
|
107
|
+
* is the BA-4 param-guard class where a throw is the right answer.
|
|
108
|
+
*/
|
|
109
|
+
export function createLadder({ limit = STRIKE_LIMIT, writeCount }) {
|
|
110
|
+
if (typeof writeCount !== 'function') {
|
|
111
|
+
throw new TypeError('createLadder requires a writeCount seam — a strike rule with one signal wired is prose, not a bound');
|
|
112
|
+
}
|
|
113
|
+
/** @type {Map<string, number>} gap key → the iteration it was FIRST seen at */
|
|
114
|
+
const firstSeen = new Map();
|
|
115
|
+
/** @type {{at: number, firstAt: number}[]} */
|
|
116
|
+
const repeats = [];
|
|
117
|
+
/** @type {number[]} iterations in which the worker wrote nothing */
|
|
118
|
+
const idle = [];
|
|
119
|
+
let strikes = 0;
|
|
120
|
+
let iterations = 0;
|
|
121
|
+
let priorWrites = 0;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
/**
|
|
125
|
+
* Read one RED iteration. Called by ralph after the judge has rendered a
|
|
126
|
+
* non-green, non-fault verdict — a green never reaches here, so every reading
|
|
127
|
+
* is a failing iteration by construction.
|
|
128
|
+
* @param {{iteration: number, gap?: string}} o
|
|
129
|
+
* @returns {{governor: string, iteration: number, strike: boolean, strikes: number,
|
|
130
|
+
* limit: number, wrote: boolean, repeatOf: number|null, distinctGaps: number}}
|
|
131
|
+
*/
|
|
132
|
+
record({ iteration, gap }) {
|
|
133
|
+
iterations = iteration;
|
|
134
|
+
const key = gapKey(gap ?? '');
|
|
135
|
+
const firstAt = firstSeen.get(key);
|
|
136
|
+
const repeated = firstAt !== undefined;
|
|
137
|
+
if (repeated) repeats.push({ at: iteration, firstAt: /** @type {number} */ (firstAt) });
|
|
138
|
+
else firstSeen.set(key, iteration);
|
|
139
|
+
|
|
140
|
+
// the DELTA, not the total: the audit is append-only across the whole step
|
|
141
|
+
const total = writeCount();
|
|
142
|
+
const wrote = total > priorWrites;
|
|
143
|
+
priorWrites = total;
|
|
144
|
+
if (!wrote) idle.push(iteration);
|
|
145
|
+
|
|
146
|
+
const strike = repeated || !wrote;
|
|
147
|
+
if (strike) strikes += 1; // sticky: nothing anywhere decrements this
|
|
148
|
+
return {
|
|
149
|
+
// WHICH governor produced this reading. Two of them emit under the one
|
|
150
|
+
// `ladder` spine type now (this one, and the close-fix loop's per-stage trend
|
|
151
|
+
// reader), and their fields differ — a reader that cannot tell them apart
|
|
152
|
+
// would average two different instruments into one number.
|
|
153
|
+
governor: 'step-ladder',
|
|
154
|
+
iteration, strike, strikes, limit, wrote,
|
|
155
|
+
repeatOf: repeated ? /** @type {number} */ (firstAt) : null,
|
|
156
|
+
distinctGaps: firstSeen.size,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
/** has the step run out of strikes? */
|
|
160
|
+
struckOut: () => strikes >= limit,
|
|
161
|
+
/** the ladder's own books — counts and iteration numbers, never gap content */
|
|
162
|
+
report: () => ({
|
|
163
|
+
iterations, strikes, limit, distinctGaps: firstSeen.size,
|
|
164
|
+
repeats: repeats.map((r) => ({ ...r })),
|
|
165
|
+
idle: [...idle],
|
|
166
|
+
}),
|
|
167
|
+
/**
|
|
168
|
+
* The mechanism, in one sentence, for the replan brief. It is the only channel
|
|
169
|
+
* the redrafting planner adapts to, so it states WHICH shape ended the step and
|
|
170
|
+
* the evidence for it — never a label on its own, and never a culprit file
|
|
171
|
+
* beyond what the close itself reported (F28): the inputs here are iteration
|
|
172
|
+
* numbers and counts, so naming one is inexpressible.
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
brief() {
|
|
176
|
+
const parts = [];
|
|
177
|
+
if (repeats.length) {
|
|
178
|
+
const last = repeats[repeats.length - 1];
|
|
179
|
+
parts.push(`it repeated itself — the exit output at iteration ${last.at} had already been seen at iteration ${last.firstAt}`);
|
|
180
|
+
}
|
|
181
|
+
if (idle.length) parts.push(`it stalled — no file was written in iteration(s) ${idle.join(', ')}`);
|
|
182
|
+
const trajectory = `${firstSeen.size} distinct exit output(s) over ${iterations} iteration(s)`;
|
|
183
|
+
// BOTH signals gate it, because "converging" is a claim about the WORK and a
|
|
184
|
+
// moving exit output is only half the evidence for one. Gated on repeats alone
|
|
185
|
+
// it fired on an idle strike-out — "no file was written in iteration(s) 1, 2"
|
|
186
|
+
// and "the step was converging" in the same paragraph, handed to the one
|
|
187
|
+
// channel the redrafting planner adapts to. A step that wrote nothing was not
|
|
188
|
+
// converging whatever its exit bytes did (a close re-run moves its own).
|
|
189
|
+
const converging = repeats.length === 0 && idle.length === 0
|
|
190
|
+
? ' Every attempt moved the exit output, so the step was converging when it struck out.'
|
|
191
|
+
: '';
|
|
192
|
+
return `The step ladder ended it on ${strikes} strike(s) of ${limit}: ${parts.join('; ')}. `
|
|
193
|
+
+ `Gap trajectory: ${trajectory}.${converging}`;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/ledger.js
CHANGED
|
@@ -79,6 +79,33 @@ const EXCLUDED_ESCALATIONS = new Set([
|
|
|
79
79
|
'smoke-red', // already counted via primitive-smoke
|
|
80
80
|
'hitl-close', // by design: a human is the close
|
|
81
81
|
'close-unsupported', // honest refusal, by design
|
|
82
|
+
// Layer 3 (D2 split): the bridge load gate refused a wrong-KIND recipe at the door.
|
|
83
|
+
// That is the mechanism WORKING — every recipe from a different day is a candidate for
|
|
84
|
+
// refusal, and the run stopped having spent nothing. Filing it upstream would aim a bug
|
|
85
|
+
// report at a library for our own gate doing its job (the step-stalled lesson).
|
|
86
|
+
'recipe-stale',
|
|
87
|
+
// Layer 3 (D7/D3) — the reuse runner's own three stops. Each is the ENVELOPE or the
|
|
88
|
+
// OPERATOR speaking, never a library failing: `reuse-exhausted` is the pre-authorized
|
|
89
|
+
// number of tries being spent (the money/time story, exactly like cap-halt);
|
|
90
|
+
// `selection-refused` is the model declining a pinned workflow, which D3 requires it to
|
|
91
|
+
// be able to do; `selection-red` is a model answer that was not the one JSON object it
|
|
92
|
+
// was asked for — a behaviour, not a transport fault (a real transport fault out of the
|
|
93
|
+
// selection call is classified as `provider-red` and is NOT excluded).
|
|
94
|
+
'reuse-exhausted',
|
|
95
|
+
'selection-refused',
|
|
96
|
+
'selection-red',
|
|
97
|
+
// Layer 3 (module C) — a RESUME that refuses to continue: the seed was signed under a
|
|
98
|
+
// different envelope, or the workflow the killed run was part-way through is no longer
|
|
99
|
+
// in the registry. Both are the operator's own state speaking (a stale signature, an
|
|
100
|
+
// edited registry), and both stop having run nothing.
|
|
101
|
+
'resume-red',
|
|
102
|
+
'registry-red', // the operator-supplied registry path does not exist — a typo, not a bug
|
|
103
|
+
'envelope-red', // the envelope does not compose with the signed spec — operator input
|
|
104
|
+
// the reuse runner also STOPS on the three answers no further try could change, and
|
|
105
|
+
// escalates each under its own name. Both are operator-input stories: an unsigned spec
|
|
106
|
+
// version and a spec that will not validate. (`smoke-red` is already excluded above.)
|
|
107
|
+
'unapproved-spec',
|
|
108
|
+
'job-red',
|
|
82
109
|
'close-timeout', // close-verdict reds: the arbiter's own named terminals
|
|
83
110
|
'close-killed', // (F17) — operator/environment stories, never a suite lib
|
|
84
111
|
'close-crashed',
|
|
@@ -90,9 +117,15 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
|
|
|
90
117
|
'runtime-red': (o) => `${o.lib}: \`${o.verb}\` threw at runtime — ${o.detail}`,
|
|
91
118
|
'provider-red': (o) => `bare-agent: the provider path failed — ${o.detail}`,
|
|
92
119
|
'pricing-red': (o) => `bare-agent: a provider result carried no priced cost (unpriced is never free, F6) — ${o.detail}`,
|
|
93
|
-
|
|
120
|
+
// same rule as request-red below (capability-gap is its cap-halted form): the
|
|
121
|
+
// ask is seeded at the STAMPED target, so a bareloop-catalogue refusal cannot
|
|
122
|
+
// render as "bare-agent: …" — fixing the occurrence's lib alone would leave the
|
|
123
|
+
// misattribution intact in the one field a human actually files from
|
|
124
|
+
'capability-gap': (o) => `${o.lib}: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
|
|
94
125
|
'broken-close': (o) => `job owner: the close itself cannot run — ${o.detail}`,
|
|
95
|
-
|
|
126
|
+
// the target is the STAMPED lib, never a hardcoded package: a bareloop-catalogue
|
|
127
|
+
// refusal (a locked verdict type) must not seed an ask that reads "bare-agent: …"
|
|
128
|
+
'request-red': (o) => `${o.lib}: admission — locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
|
|
96
129
|
'retention-red': (o) => `litectx: on-green retention failed — ${o.detail}`,
|
|
97
130
|
'config-red': (o) => `bareloop: drafting friction at ${o.verb} — ${o.detail}`,
|
|
98
131
|
});
|
|
@@ -133,9 +166,20 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
|
133
166
|
// a repeated signature indicts the drafting prompt/schema, i.e. bareloop
|
|
134
167
|
add(ev, 'config-red', 'bareloop', String(ev.path ?? 'config').split('.')[0] || 'config', `${ev.code} at ${ev.path}: ${ev.detail ?? ''}`);
|
|
135
168
|
} else if (ev.type === 'job-red' && ev.code === 'request-red') {
|
|
136
|
-
// the structured
|
|
137
|
-
// for spines written before the field existed
|
|
138
|
-
|
|
169
|
+
// the structured fields win; the prose-quoted verb stays as the fallback
|
|
170
|
+
// for spines written before the field existed, and the hardcoded
|
|
171
|
+
// 'bare-agent' lib stays as the fallback for the same reason — it was the
|
|
172
|
+
// only territory `request-red` had when the field did not exist.
|
|
173
|
+
//
|
|
174
|
+
// One code, TWO territories: a locked TOOL verb is demand against the
|
|
175
|
+
// worker-surface package; a locked VERDICT type (and, when D13 lands, a
|
|
176
|
+
// genre refusal) is demand against bareloop's OWN catalogue and must never
|
|
177
|
+
// seed an upstream ask. The lib is stamped at the emit site (src/job.js)
|
|
178
|
+
// per the typed-lib rule — deriving it here from the code or the path is
|
|
179
|
+
// exactly the BA-2 misattribution class. A new bareloop-territory
|
|
180
|
+
// request-red therefore routes correctly by stamping its own lib, with no
|
|
181
|
+
// change to this branch.
|
|
182
|
+
add(ev, 'request-red', ev.lib ?? 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
|
|
139
183
|
} else if (ev.type === 'escalation') {
|
|
140
184
|
if (ev.category === 'broken-close') {
|
|
141
185
|
add(ev, 'broken-close', 'consumer', 'close', ev.detail);
|
|
@@ -184,10 +228,41 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
|
184
228
|
|
|
185
229
|
if (capHalted) {
|
|
186
230
|
// capability-gap: the run hit its cap in a spine that also asked for a
|
|
187
|
-
// locked verb — the strongest admission evidence there is (design table)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
231
|
+
// locked verb — the strongest admission evidence there is (design table).
|
|
232
|
+
// STILL DORMANT, exactly as the module header says: a request-red is a
|
|
233
|
+
// job-validation red, validateJob returns not-ok on any red and run.js
|
|
234
|
+
// returns on a validation red, so a request-red and a cap-halt cannot share
|
|
235
|
+
// one spine today. What follows is a latent-consistency fix at a documented
|
|
236
|
+
// dormant seam, not a live bug fix.
|
|
237
|
+
//
|
|
238
|
+
// The territory comes from the OCCURRENCE, never from a hardcoded package.
|
|
239
|
+
// capability-gap inherits request-red's two territories: a locked TOOL verb
|
|
240
|
+
// is demand against the worker-surface package, a locked VERDICT type is
|
|
241
|
+
// demand against bareloop's OWN catalogue. The request-red rows above
|
|
242
|
+
// already carry the lib stamped at their emit site (src/job.js), so reading
|
|
243
|
+
// it here is the typed-lib rule; re-hardcoding 'bare-agent' would seed an
|
|
244
|
+
// upstream ask for our own refusal — the BA-2 misattribution class. The
|
|
245
|
+
// sibling request-red site was fixed on this branch and this one was missed;
|
|
246
|
+
// a fix landing at one of two identical sites is the class already paid for
|
|
247
|
+
// once here (the ripgrep install that landed in ci.yml but not publish.yml).
|
|
248
|
+
//
|
|
249
|
+
// The dedup key is (verb, lib), not verb alone. The ledger's own occurrence
|
|
250
|
+
// key already carries lib, so folding on the verb would merge two
|
|
251
|
+
// territories into one row and attribute it to whichever lib the Set
|
|
252
|
+
// happened to hold first — an arbitrary target, which IS the defect. One
|
|
253
|
+
// gap per (territory, verb) costs nothing and each carries an honest one.
|
|
254
|
+
// The key is JSON, not a joined string: a delimiter that can appear in
|
|
255
|
+
// either half makes ("a-b","c") and ("a","b-c") the same key, and picking
|
|
256
|
+
// an "impossible" delimiter is a guess about names neither of these two
|
|
257
|
+
// vocabularies has promised to keep.
|
|
258
|
+
const halt = events.find((e) => e.type === 'cap-halt');
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
// .filter() snapshots before add() appends, so the loop cannot feed itself
|
|
261
|
+
for (const o of occs.filter((x) => x.class === 'request-red')) {
|
|
262
|
+
const dedup = JSON.stringify([o.lib, o.verb]);
|
|
263
|
+
if (seen.has(dedup)) continue;
|
|
264
|
+
seen.add(dedup);
|
|
265
|
+
add(halt, 'capability-gap', o.lib, o.verb, `locked "${o.verb}" requested and the run cap-halted`);
|
|
191
266
|
}
|
|
192
267
|
}
|
|
193
268
|
return occs;
|