bareloop 0.5.1 → 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/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
+ }