bareloop 0.5.0 → 0.6.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 +746 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -234
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +356 -20
- package/src/planrun.js +978 -98
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +107 -7
- package/types/planrun.d.ts +51 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -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/stall.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The stall watchdog (F66) — the only instrument that fires on the ABSENCE of ROUNDS.
|
|
2
|
+
//
|
|
3
|
+
// MEASURED reason this exists. bare-agent's `timeoutMs` is an IDLE-SOCKET timeout:
|
|
4
|
+
// `applyRequestTimeout` is one `req.setTimeout(...)` in provider-http.js, and Node
|
|
5
|
+
// resets that timer on every byte. Its own docstring is explicit — "the timer
|
|
6
|
+
// resets on activity, so a slow-but-streaming response is not killed". On U run
|
|
7
|
+
// ms3197n8 the connection stayed alive for 274 minutes with ZERO rounds emitted,
|
|
8
|
+
// then died `read ECONNRESET`. bare-agent's timer never fired; the run spent
|
|
9
|
+
// 287.3min against a 45min wall and $3.23 for nothing. The bound was correctly
|
|
10
|
+
// derived and correctly forwarded (loop.js:732 passes options per provider call) —
|
|
11
|
+
// it just watches bytes, and bytes kept flowing while the run made no progress.
|
|
12
|
+
//
|
|
13
|
+
// So the heartbeat here is A COMPLETED ROUND, not a byte. That swap IS the fix,
|
|
14
|
+
// and it is the one signal bare-agent cannot see from inside the HTTP request.
|
|
15
|
+
//
|
|
16
|
+
// SELF-HEAL, not a stop (hamr's ruling): "the goal is always self heal and killing
|
|
17
|
+
// and coming back is not an option — what would we tell the user, it timed out, do
|
|
18
|
+
// something?!" A stall abandons the call and REISSUES it; the operator never hears
|
|
19
|
+
// about it. Only after `maxStalls` does the watch give up, and even then it does
|
|
20
|
+
// not end the run — it throws `step-stalled`, which the step loop reads as a
|
|
21
|
+
// replan trigger. The run adapts; it does not surrender.
|
|
22
|
+
//
|
|
23
|
+
// What still bounds this, so self-heal cannot run away: the wallet is unchanged (a
|
|
24
|
+
// reissue spends from the same signed budget), the wall clock still ends the run,
|
|
25
|
+
// and the step's round bound is untouched. The one honest cost is that an
|
|
26
|
+
// abandoned call MAY already have been billed, so a reissue can pay twice — that
|
|
27
|
+
// is capped by the wallet, and unknown spend is reported as unknown, never zero
|
|
28
|
+
// (F6/F44). It is also exactly why `Retry` was left unwired; self-heal is the
|
|
29
|
+
// ruling that overrides it.
|
|
30
|
+
//
|
|
31
|
+
// L4 — "the wall clock still ends the run" was the half-true half of that. The
|
|
32
|
+
// clock is consulted where a ROUND completes (planrun's `metered`) and after a step
|
|
33
|
+
// returns; during a stall no round completes and no step returns, so nothing read it
|
|
34
|
+
// between the wall passing and the next reissue. A worker stalling at its wall could
|
|
35
|
+
// therefore be reissued for up to maxStalls × stallMs of calls the run had already
|
|
36
|
+
// declined to authorize. So the watch takes the wall as a callback and consults it
|
|
37
|
+
// BEFORE reissuing: past the deadline it does not self-heal, it gives up. That is
|
|
38
|
+
// not a retreat from hamr's ruling — self-heal is what a run does with time left,
|
|
39
|
+
// and there is none.
|
|
40
|
+
//
|
|
41
|
+
// Timers are injected so the whole mechanism is deterministically testable — a
|
|
42
|
+
// watchdog validated with real sleeps is a flake generator, and one that is never
|
|
43
|
+
// watched failing is not validated at all.
|
|
44
|
+
|
|
45
|
+
/** hamr's number: no completed round for five minutes is a stall. */
|
|
46
|
+
export const STALL_MS = 300_000;
|
|
47
|
+
/** hamr's number: three stalls, then stop reissuing and replan. The count is
|
|
48
|
+
* CUMULATIVE over the watch's whole life and `watch()` never resets it — planrun
|
|
49
|
+
* builds one watch per WORKER, so the ceiling is spent across the STEP, not per
|
|
50
|
+
* call: two stalls on one call and one on the next give up just the same. That
|
|
51
|
+
* is deliberate (a step that keeps hanging is a step to replan, not a call to
|
|
52
|
+
* retry forever), and it is why nothing here says "on one call". */
|
|
53
|
+
export const MAX_STALLS = 3;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A stall gave up. Carries the category the step loop's replan trigger reads and
|
|
57
|
+
* the `lib` attribution stamped AT THE THROW SITE (never sniffed from prose).
|
|
58
|
+
*/
|
|
59
|
+
export class StallError extends Error {
|
|
60
|
+
/** @param {string} message @param {number} stalls */
|
|
61
|
+
constructor(message, stalls) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'StallError';
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
this.category = 'step-stalled';
|
|
66
|
+
/** @type {string} */
|
|
67
|
+
this.lib = 'bare-agent';
|
|
68
|
+
/** @type {number} */
|
|
69
|
+
this.stalls = stalls;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} StallWatch
|
|
75
|
+
* @property {<T>(call: (gen: number) => Promise<T>) => Promise<T>} watch run `call`,
|
|
76
|
+
* reissuing it whenever it goes `stallMs` without a beat; rejects `StallError` past
|
|
77
|
+
* `maxStalls`. Each issue is handed its GENERATION — the token every callback that
|
|
78
|
+
* call installs must carry back, so a corpse's callbacks cannot touch the live call.
|
|
79
|
+
* @property {(gen: number) => void} beat a round completed on generation `gen` — reset
|
|
80
|
+
* the window. From any other generation it is inert.
|
|
81
|
+
* @property {(gen: number) => boolean} isCurrent is `gen` still the call this watch is
|
|
82
|
+
* waiting on? The caller reads it to withhold everything else a dead call would do.
|
|
83
|
+
* @property {() => number} stalls how many stalls this watch has absorbed
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start a stall watchdog.
|
|
88
|
+
* @param {{ stallMs?: number, maxStalls?: number, onStall?: (n: number) => void,
|
|
89
|
+
* expired?: () => boolean,
|
|
90
|
+
* setTimer?: (fn: () => void, ms: number) => any,
|
|
91
|
+
* clearTimer?: (id: any) => void }} [opts]
|
|
92
|
+
* `onStall` is announced for EVERY stall including the last — a trim or a
|
|
93
|
+
* recovery that happens silently is indistinguishable from one that never
|
|
94
|
+
* happened (F28's rule, applied to time).
|
|
95
|
+
* `expired` is the run's WALL, read at the one moment this module decides to
|
|
96
|
+
* spend again (L4). Omitted, the watch behaves exactly as it did before the wall
|
|
97
|
+
* existed — a caller with no clock reissues on the same rules.
|
|
98
|
+
* @returns {StallWatch}
|
|
99
|
+
*/
|
|
100
|
+
export function createStallWatch({
|
|
101
|
+
stallMs = STALL_MS,
|
|
102
|
+
maxStalls = MAX_STALLS,
|
|
103
|
+
onStall = () => {},
|
|
104
|
+
expired = () => false,
|
|
105
|
+
setTimer = setTimeout,
|
|
106
|
+
clearTimer = clearTimeout,
|
|
107
|
+
} = {}) {
|
|
108
|
+
let stalls = 0;
|
|
109
|
+
/** @type {any} */
|
|
110
|
+
let timerId = null;
|
|
111
|
+
/** the generation of the call currently being watched: NOTHING from an ABANDONED
|
|
112
|
+
* call may touch the one that replaced it — not its trip, not its late settlement,
|
|
113
|
+
* and not its beats. The corpse keeps streaming (ms3197n8's socket lived 274min
|
|
114
|
+
* after we would have moved on), so every callback it installed keeps firing; the
|
|
115
|
+
* generation is the token that tells those callbacks apart from the live call's.
|
|
116
|
+
* It is handed to each issued call and carried back through `beat`/`isCurrent`. */
|
|
117
|
+
let generation = 0;
|
|
118
|
+
let settled = true;
|
|
119
|
+
/** @type {((v: any) => void) | null} */
|
|
120
|
+
let resolve = null;
|
|
121
|
+
/** @type {((e: any) => void) | null} */
|
|
122
|
+
let reject = null;
|
|
123
|
+
/** @type {((gen: number) => Promise<any>) | null} */
|
|
124
|
+
let theCall = null;
|
|
125
|
+
|
|
126
|
+
const disarm = () => {
|
|
127
|
+
if (timerId !== null) { clearTimer(timerId); timerId = null; }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** @param {number} gen */
|
|
131
|
+
function arm(gen) {
|
|
132
|
+
disarm();
|
|
133
|
+
timerId = setTimer(() => {
|
|
134
|
+
timerId = null;
|
|
135
|
+
if (settled || gen !== generation) return; // a trip from a superseded call
|
|
136
|
+
trip();
|
|
137
|
+
}, stallMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function trip() {
|
|
141
|
+
stalls += 1;
|
|
142
|
+
onStall(stalls);
|
|
143
|
+
generation += 1; // everything from the abandoned call is now inert
|
|
144
|
+
// L4 — the WALL, read before the decision to spend again, and read FIRST. A
|
|
145
|
+
// reissue past the deadline is money and time the run has already declined to
|
|
146
|
+
// authorize, and it is the only spend on this path nothing else can catch: no
|
|
147
|
+
// round completes during a stall, so `metered`'s clock check never fires, and no
|
|
148
|
+
// step returns, so the step loop's never does either.
|
|
149
|
+
//
|
|
150
|
+
// It outranks the `maxStalls` ceiling deliberately, and the category is the
|
|
151
|
+
// reason. `step-stalled` is a REPLAN TRIGGER (planrun's third trigger): it says
|
|
152
|
+
// "the model stopped answering, re-allocate what is left" and buys a fresh
|
|
153
|
+
// drafting round. On an expired wall there is nothing left to re-allocate, so
|
|
154
|
+
// that route would spend a draft on a run that is over and hand the operator
|
|
155
|
+
// "retry the run / check provider status" for a stop whose only remedy is
|
|
156
|
+
// `maxWallMs`. `wall-halt` is the truthful reading — the wall bound the run, not
|
|
157
|
+
// the model — and it is the SAME ruling clock.js already made for the F64
|
|
158
|
+
// ambiguity: a call that dies on a run whose wall has also passed reads as a
|
|
159
|
+
// wall-halt, because the run is out of time either way and the remedy is
|
|
160
|
+
// identical. No new category, no new routing: every consumer (ralph's
|
|
161
|
+
// DECISIONS, the step loop's terminal, `relay`, the ledger's exclusions) already
|
|
162
|
+
// handles `wall-halt`, and none of them replans on it.
|
|
163
|
+
if (expired()) {
|
|
164
|
+
settled = true;
|
|
165
|
+
disarm();
|
|
166
|
+
// Not a StallError: that class IS the `step-stalled` category, and a stall
|
|
167
|
+
// count is what happened on the way here, not what stopped it. No `lib`
|
|
168
|
+
// either — the wall is bareloop's own governance instrument, and stamping an
|
|
169
|
+
// upstream package would stand a fake bug of ours where real evidence goes.
|
|
170
|
+
const err = /** @type {any} */ (new Error(
|
|
171
|
+
`the run's wall-clock cap passed while this call was stalled (${stalls} stall${stalls === 1 ? '' : 's'}) — not reissuing`,
|
|
172
|
+
));
|
|
173
|
+
err.category = 'wall-halt';
|
|
174
|
+
err.stalls = stalls;
|
|
175
|
+
reject?.(err);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (stalls >= maxStalls) {
|
|
179
|
+
settled = true;
|
|
180
|
+
disarm();
|
|
181
|
+
reject?.(new StallError(
|
|
182
|
+
`no round completed for ${Math.round(stallMs / 1000)}s, ${stalls} times in this step — not reissuing again`,
|
|
183
|
+
stalls,
|
|
184
|
+
));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
issue(); // self-heal: abandon the hung call, start a fresh one
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function issue() {
|
|
191
|
+
const gen = generation;
|
|
192
|
+
arm(gen);
|
|
193
|
+
let p;
|
|
194
|
+
try {
|
|
195
|
+
// the call is TOLD its generation: the callbacks it wires (metering, the round
|
|
196
|
+
// bound, the wall cut) outlive the call itself, and this is the only token that
|
|
197
|
+
// lets them ask "am I still the live one?".
|
|
198
|
+
p = /** @type {(g: number) => Promise<any>} */ (theCall)(gen);
|
|
199
|
+
} catch (e) { // a synchronous throw is a real failure, not a stall
|
|
200
|
+
settled = true;
|
|
201
|
+
disarm();
|
|
202
|
+
reject?.(e);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
p.then(
|
|
206
|
+
(v) => {
|
|
207
|
+
if (settled || gen !== generation) return; // an abandoned call finally answered
|
|
208
|
+
settled = true;
|
|
209
|
+
disarm();
|
|
210
|
+
resolve?.(v);
|
|
211
|
+
},
|
|
212
|
+
(e) => {
|
|
213
|
+
// The abandoned socket dies LATER — ms3197n8's did, at +274min. Nobody is
|
|
214
|
+
// listening any more, and an unhandled rejection here would take the whole
|
|
215
|
+
// run down. Swallowing it is the point, not an oversight.
|
|
216
|
+
if (settled || gen !== generation) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
disarm();
|
|
219
|
+
reject?.(e);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @type {<T>(call: (gen: number) => Promise<T>) => Promise<T>} */
|
|
225
|
+
function watch(call) {
|
|
226
|
+
theCall = call;
|
|
227
|
+
settled = false;
|
|
228
|
+
generation += 1;
|
|
229
|
+
return new Promise((res, rej) => {
|
|
230
|
+
resolve = res;
|
|
231
|
+
reject = rej;
|
|
232
|
+
issue();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
stalls: () => stalls,
|
|
238
|
+
// A beat is SCOPED TO ITS CALL. Rounds do arrive from a call we abandoned, and
|
|
239
|
+
// re-arming on one is not a harmless late timer: it re-arms the watch that is
|
|
240
|
+
// timing the REPLACEMENT, so the corpse feeds the watchdog and the live call can
|
|
241
|
+
// hang for the rest of the run without ever tripping. A beat after the watch has
|
|
242
|
+
// settled is inert for the same reason.
|
|
243
|
+
/** @param {number} gen the generation `watch` handed the call that produced this round */
|
|
244
|
+
beat(gen) {
|
|
245
|
+
if (settled || gen !== generation) return;
|
|
246
|
+
arm(generation);
|
|
247
|
+
},
|
|
248
|
+
/** @param {number} gen @returns {boolean} */
|
|
249
|
+
isCurrent: (gen) => !settled && gen === generation,
|
|
250
|
+
watch,
|
|
251
|
+
};
|
|
252
|
+
}
|