bareloop 0.32.0 → 0.33.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.
@@ -0,0 +1,296 @@
1
+ // The OUTSIDE watchdog (F67) — the only stop that survives a frozen run.
2
+ //
3
+ // MEASURED reason it exists. U run ms3jh76q spent 81.5 minutes emitting nothing,
4
+ // then died `read ETIMEDOUT` — the kernel giving up on a socket the process never
5
+ // read. The in-process stall fuse (F66) did not fire once, and it is not broken:
6
+ // firing was reproduced against that run's exact shape (four rounds, then
7
+ // silence). It never RAN, because a timer in the same event loop as whatever
8
+ // froze that loop shares its fate. Every guard bareloop had lived inside the run:
9
+ //
10
+ // bare-agent's timeoutMs idle SOCKET timer — resets on bytes, saw nothing wrong
11
+ // the wall clock read BETWEEN rounds — no round ever arrived
12
+ // the stall fuse (F66) a setTimeout — cannot fire on a blocked event loop
13
+ //
14
+ // So this one is a separate process and shares nothing with the run. It reads one
15
+ // file's mtime and calls kill(2). That is the whole design.
16
+ //
17
+ // It is deliberately DUMB, and each omission is load-bearing:
18
+ // - it parses no JSON (a malformed spine must not blind the guard)
19
+ // - it imports nothing from src/ (no shared code means no shared failure)
20
+ // - it holds no model of what the run is doing (nothing to get out of sync)
21
+ // - it reads bytes, not meaning (cost is one stat(2) per poll)
22
+ //
23
+ // TWO triggers, ONE reconciled decision (hamr's ruling, 2026-07-30: "the kill from
24
+ // outside should check for activity/bytes or other markers for activity, not a
25
+ // silent kill"):
26
+ // STALE no progress for longer than the worst LEGAL silence → the run is wedged
27
+ // DEAD past the deadline (wall + stages × close timeout) AND the spine flat for
28
+ // the whole dead window → the process is producing nothing, and its own
29
+ // deadline has already funded every legal silence
30
+ // Past the deadline but still ACTIVE is NOT a kill any more. The in-process fuses
31
+ // and the money cap own bounding a run that is alive; this guard owns processes
32
+ // that are dead. A live run is never killed from outside purely by the clock —
33
+ // that is the point of the change, and the previous behaviour could destroy a
34
+ // verdict mid-close. Overshoot is reported LOUD on every poll instead. The full
35
+ // decision table sits above the decision code.
36
+ //
37
+ // The kill is RECORDED next to the spine, and it is never silent: before the
38
+ // signal goes out this process says which deadline passed, which marker it
39
+ // checked, its last value and age, and why it judged the process dead. A run
40
+ // stopped by the arbiter must never read as a mystery crash — that is the
41
+ // distinction between a governance stop and a casualty, and it decides whether the
42
+ // row is evidence or noise (F45/F48).
43
+ //
44
+ // Usage:
45
+ // node src/u-watchdog.mjs --spine <path> --pid <n>
46
+ // [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N]
47
+ import { statSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
48
+
49
+ const arg = (/** @type {string} */ n, /** @type {string|null} */ dflt = null) => {
50
+ const i = process.argv.indexOf(`--${n}`);
51
+ return i === -1 ? dflt : (process.argv[i + 1] ?? dflt);
52
+ };
53
+ // A flag the operator PASSED is never quietly replaced by a default. An ABSENT flag
54
+ // takes its default (`--wall-ms` omitted is the legal unbounded choice, and run-u
55
+ // omits it on a spec with no `maxWallMs`); a flag that is present and unreadable —
56
+ // `0`, a negative, `undefined`, `soon` — is an operator error and stops this process
57
+ // LOUD. Silently defaulting it disarms a trigger while the caller's own banner still
58
+ // claims the bound, which is the guard reading zero and saying nothing: the
59
+ // blind-instrument class, and W-2's case besides (a wall remainder of nothing is a
60
+ // decision, not an unbounded launch).
61
+ const num = (/** @type {string} */ n, /** @type {number|null} */ dflt) => {
62
+ const v = arg(n);
63
+ if (v === null) return dflt;
64
+ const x = Number(v);
65
+ if (!Number.isFinite(x) || x <= 0) {
66
+ console.error(`[u-watchdog] REFUSED: --${n} ${JSON.stringify(v)} is not a positive number of milliseconds. It is NOT defaulted — a defaulted bound disarms this guard's trigger while the run's banner still advertises one. Pass a real remainder, or omit the flag to take the default deliberately (an omitted --wall-ms is the unbounded choice, and the STALE trigger stays armed either way).`);
67
+ process.exit(2);
68
+ }
69
+ return x;
70
+ };
71
+
72
+ const spine = arg('spine');
73
+ const pid = Number(arg('pid'));
74
+ if (!spine || !Number.isInteger(pid) || pid <= 0) {
75
+ console.error('usage: u-watchdog.mjs --spine <path> --pid <n> [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N]');
76
+ process.exit(2);
77
+ }
78
+ // The watched pid must be this process's PARENT. kill(pid, 0) answers "is SOME
79
+ // process holding this pid" — after a SIGKILL/OOM the kernel can recycle the
80
+ // run's pid onto a stranger, and an armed guard would SIGTERM/SIGKILL it and
81
+ // leave a marker blaming the run. The parent link cannot be recycled: run-u
82
+ // spawns this guard as its direct child, and `process.ppid` flips to init
83
+ // within one poll of the parent dying — verified live, including under SIGKILL.
84
+ // A guard pointed at a non-parent refuses LOUD at startup (exit 2, no marker):
85
+ // an armed guard aimed at a stranger is worse than no guard, and a silent no-op
86
+ // is the blind-instrument class.
87
+ if (process.ppid !== pid) {
88
+ console.error(`[u-watchdog] REFUSED: --pid ${pid} is not this process's parent (ppid ${process.ppid}) — the liveness check is the parent link, which pid reuse cannot forge`);
89
+ process.exit(2);
90
+ }
91
+ // Defaults are DERIVED, not chosen: the stale window is twice the in-process fuse
92
+ // (F66, hamr's 5 min), so the inner guard always gets its three tries first and
93
+ // this one only speaks when the inner one could not. It stays a flag because a
94
+ // threshold is arbiter territory — a number the agent picks off its own sample is
95
+ // fitted, not set.
96
+ const staleMs = num('stale-ms', 600_000) ?? 600_000;
97
+ const wallMs = num('wall-ms', null);
98
+ // The wall's grace covers the CLOSE, which legitimately runs after the last round
99
+ // and is the longest legal silence a healthy run produces. It is sized the same way
100
+ // the run's own clock sizes its enforced deadline (src/clock.js: maxWallMs +
101
+ // closeStages × closeTimeoutMs) and the same way the stale window is sized —
102
+ // STAGES × the close timeout, because a staged close (PRD v1.28) gives EVERY stage
103
+ // the full timeout. The 900_000 default is ONE stage: the single-predicate floor,
104
+ // not a number any staged job should run on. A caller with a staged close passes
105
+ // its own (scripts/run-u.mjs does; a 4-stage close needs 60min, not 15min, and the
106
+ // stale window already got this arithmetic under F67 while the grace did not).
107
+ const graceMs = num('grace-ms', 900_000) ?? 900_000;
108
+ // How long the spine must be flat, PAST the deadline, before the process is judged
109
+ // dead. Derived, not chosen: it is the in-process stall fuse's own window (F66,
110
+ // hamr's 5 minutes). Past the deadline the grace has ALREADY paid
111
+ // for every legal close stage, so there is no legal silence left to protect — one
112
+ // fuse window is patience, not budget. Never above the stale window, which would
113
+ // make it inert.
114
+ const deadMs = Math.min(num('dead-ms', 300_000) ?? 300_000, staleMs);
115
+ const pollMs = num('poll-ms', 5_000) ?? 5_000;
116
+ // How long SIGTERM gets before SIGKILL. A flag, not a constant, for the same reason
117
+ // every other window here is one — and because a test cannot exercise the escalation
118
+ // against a ten-minute wait. The DEFAULT is unchanged (10s): long enough for a live run
119
+ // to flush its spine, short enough that a run swallowing the polite signal cannot
120
+ // outlive its own guard.
121
+ const termGraceMs = num('term-grace-ms', 10_000) ?? 10_000;
122
+
123
+ const startedAt = Date.now();
124
+ const iso = (/** @type {number} */ ms) => new Date(ms).toISOString();
125
+ // Liveness is the PARENT LINK, not kill(pid, 0): once the parent dies this
126
+ // process is reparented and ppid stops matching — reuse-proof where a pid probe
127
+ // is not (see the startup refusal above).
128
+ const alive = () => process.ppid === pid;
129
+
130
+ // ── THE ACTIVITY MARKER ─────────────────────────────────────────────────────────
131
+ // "Is it dead" is answered by EVIDENCE, never by the clock alone. ONE marker:
132
+ //
133
+ // spine PROGRESS. Bytes reaching the run's own append-only log. Growth here is
134
+ // the run doing the job. One stat(2): size OR mtime moving counts.
135
+ //
136
+ // ONE on purpose. A second, CPU-liveness marker (utime+stime+cutime+cstime from
137
+ // /proc/<pid>/stat) was built here and then MEASURED broken in BOTH directions, so
138
+ // it is deleted rather than kept as decoration:
139
+ // - it read DEAD on a live run. A close runs its suite in a CHILD, and a child's
140
+ // ticks are credited to the parent only when the parent REAPS it — so the
141
+ // busiest minutes of a multi-minute close show a parent whose own ticks never
142
+ // move.
143
+ // - it read ALIVE on a dead one. run-u's event-loop lag sampler wakes this very
144
+ // process every second, moving its ticks roughly every 3.3s; at the production
145
+ // constants (deadMs 300_000 against pollMs 5_000) the marker could therefore
146
+ // never go flat, and the deadline kill could effectively never fire. The kill
147
+ // tests only passed at --dead-ms 500 — three orders of magnitude below what
148
+ // production runs on.
149
+ // An instrument that cannot see the variable makes every reading unusable, and a
150
+ // veto that is always ON is not a safety, it is a disarmed trigger. hamr's ruling,
151
+ // 2026-07-30: "keep what is simpler/available" — the spine, which is the marker
152
+ // that actually distinguishes progress from silence.
153
+
154
+ /** spine size+mtime in one stat(2). A missing file reads as the watchdog's own
155
+ * start: a run that wedges BEFORE its first event is the most stalled run there
156
+ * is, and treating "no file" as "no news is good news" would blind the guard to
157
+ * exactly that case. */
158
+ const readSpine = () => { try { const st = statSync(spine); return { mtimeMs: st.mtimeMs, size: st.size }; } catch { return { mtimeMs: startedAt, size: -1 }; } };
159
+
160
+ let spineSeen = readSpine();
161
+ let spineMovedAt = spineSeen.mtimeMs;
162
+
163
+ /** advance the marker's last-movement stamp. Called once per poll, before any
164
+ * decision reads it. */
165
+ function sampleMarkers(/** @type {number} */ now) {
166
+ const st = readSpine();
167
+ if (st.size !== spineSeen.size) spineMovedAt = now;
168
+ if (st.mtimeMs > spineMovedAt) spineMovedAt = st.mtimeMs;
169
+ spineSeen = st;
170
+ }
171
+ /** what every log line and every kill record says about the marker: its value and
172
+ * its age. Kept under a `markers` key — the record is a read-once forensic
173
+ * artifact, and a reader comparing an old kill to a new one should see the shape
174
+ * hold and the CPU entry simply be gone. */
175
+ const markerReport = (/** @type {number} */ now) => ({
176
+ spine: { path: spine, bytes: spineSeen.size < 0 ? 'no file yet' : spineSeen.size, lastMovedAt: iso(spineMovedAt), coldMs: now - spineMovedAt },
177
+ });
178
+ const spineColdMs = (/** @type {number} */ now) => now - spineMovedAt;
179
+
180
+ function stop(/** @type {string} */ reason, /** @type {string} */ judgement, /** @type {object} */ detail) {
181
+ const now = Date.now();
182
+ const markers = markerReport(now);
183
+ const record = {
184
+ watchdog: 'u-watchdog', reason, killed: true, pid, spine,
185
+ // Was the target still THERE when the decision was taken (F72 park B — the checks
186
+ // that ran before the signal belong in the record, not only in the reasoning). It
187
+ // is the parent link, not `kill(pid, 0)`: a pid probe answers "is SOME process
188
+ // holding this pid" and cannot survive pid reuse (see the startup refusal). A
189
+ // `false` here says the run died on its own between the last poll and this line —
190
+ // a different event from stopping a running process, and the record must be able
191
+ // to say which one happened.
192
+ pidAlive: alive(),
193
+ at: iso(now),
194
+ startedAt: iso(startedAt),
195
+ elapsedMs: now - startedAt,
196
+ // Every bound this guard was ARMED with, so the decision is reproducible from the
197
+ // record alone — including the two that shape what happens next (`pollMs`, which
198
+ // says how many flat observations the age above represents, and `termGraceMs`,
199
+ // which says how long the run had to die politely).
200
+ staleMs, wallMs, graceMs, deadMs, pollMs, termGraceMs,
201
+ lastEventAt: iso(spineMovedAt),
202
+ judgement, markers,
203
+ ...detail,
204
+ };
205
+ // Write the verdict BEFORE the kill: if this process dies mid-stop, the record of why
206
+ // must already be on disk. A kill with no explanation is the failure mode this whole
207
+ // file exists to prevent.
208
+ //
209
+ // TEMP + RENAME, because the record is read by a script that runs after a crash
210
+ // (run-u.mjs / run-reuse.mjs both `JSON.parse` it): rename is atomic within a
211
+ // directory, so a reader can only ever see the whole file or no file — never the
212
+ // half-written one a direct write leaves if this process is killed mid-write.
213
+ //
214
+ // And it is WRAPPED, because a guard that can be disarmed through its own logging is
215
+ // not a guard (F70: the instrument carrying the failure mode it exists to catch). The
216
+ // kill below runs whether or not this succeeded; a failure is named on stderr so a
217
+ // missing marker is never read as "the guard never fired".
218
+ const reportPath = `${spine}.watchdog.json`;
219
+ const tmpPath = `${reportPath}.tmp`;
220
+ try {
221
+ writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
222
+ renameSync(tmpPath, reportPath);
223
+ } catch (e) {
224
+ console.error(`[u-watchdog] REPORT WRITE FAILED (${reportPath}): ${e?.message ?? e} — the kill still proceeds; this stderr IS the record now`);
225
+ try { unlinkSync(tmpPath); } catch { /* nothing to clean, or unwritable too */ }
226
+ }
227
+ // NEVER a silent kill (hamr, 2026-07-30). Three lines, in the order a reader
228
+ // needs them: what fired, which bound it was measured against, and the exact
229
+ // marker evidence that says the process is dead rather than slow.
230
+ console.error(`[u-watchdog] KILL ${reason}: pid ${pid} judged DEAD — ${judgement}`);
231
+ console.error(`[u-watchdog] deadline: ${JSON.stringify({ elapsedMs: record.elapsedMs, wallMs, graceMs, staleMs, deadMs, ...detail })}`);
232
+ console.error(`[u-watchdog] marker checked: ${JSON.stringify(markers)}`);
233
+ console.error(`[u-watchdog] SIGTERM -> pid ${pid}; SIGKILL in ${termGraceMs}ms if it is still there`);
234
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
235
+ // SIGTERM lets the run flush its spine. A wedged event loop cannot run a signal
236
+ // handler, so SIGKILL is the one that actually lands — but only after giving the
237
+ // recoverable case its chance. A run that INSTALLS a handler and swallows the signal
238
+ // reaches the same place: the polite signal is an opportunity, never a veto.
239
+ setTimeout(() => {
240
+ if (alive()) {
241
+ console.error(`[u-watchdog] SIGKILL -> pid ${pid}: it survived the ${termGraceMs}ms grace`);
242
+ try { process.kill(pid, 'SIGKILL'); } catch { /* raced */ }
243
+ }
244
+ process.exit(1);
245
+ }, termGraceMs);
246
+ }
247
+
248
+ console.error(`[u-watchdog] watching pid ${pid} — marker [spine], stale ${Math.round(staleMs / 1000)}s${wallMs ? `, wall ${Math.round(wallMs / 1000)}s (+${Math.round(graceMs / 1000)}s grace, then ${Math.round(deadMs / 1000)}s of a flat spine)` : ', no wall'}`);
249
+
250
+ // ── THE DECISION ────────────────────────────────────────────────────────────────
251
+ // One table, evaluated every poll after the marker is sampled. "Past deadline"
252
+ // means elapsed >= wallMs + graceMs, where the grace is stages × close timeout.
253
+ //
254
+ // past deadline | spine (progress) | action
255
+ // --------------+-------------------+--------------------------------------------
256
+ // any | cold >= staleMs | KILL 'stale'
257
+ // no | cold < staleMs | quiet — the run is inside its worst legal
258
+ // | | silence
259
+ // yes | cold < deadMs | LOUD, no kill (still progressing)
260
+ // yes | cold >= deadMs | KILL 'wall-dead'
261
+ //
262
+ // Two things this table says on purpose:
263
+ // 1. Only BYTES hold a kill off. Nothing else is consulted — in particular not
264
+ // CPU, which was measured unable to tell a working process from a spun-out
265
+ // one and, worse, unable to go flat at all at the production constants (see
266
+ // THE ACTIVITY MARKER above). A hard-frozen event loop burns 100% CPU forever
267
+ // and emits nothing; it is F67's own case, and it must die on both triggers.
268
+ // 2. Past the deadline with the spine still moving, NOTHING is killed. The
269
+ // in-process fuses (F66), the wall clock and the money cap own bounding a run
270
+ // that is alive; this guard owns processes that are dead. It says so loudly
271
+ // every poll rather than going quiet — the overshoot is still a fact the
272
+ // operator must see, it is just not this process's to end.
273
+ const tick = setInterval(() => {
274
+ if (!alive()) { clearInterval(tick); process.exit(0); } // the run finished on its own
275
+ const now = Date.now();
276
+ sampleMarkers(now);
277
+ const spineCold = spineColdMs(now);
278
+ const elapsed = now - startedAt;
279
+
280
+ if (spineCold >= staleMs) {
281
+ clearInterval(tick);
282
+ stop('stale', `the spine has not grown for ${Math.round(spineCold / 1000)}s, past the ${Math.round(staleMs / 1000)}s window sized above the worst LEGAL silence (a full staged close) — the run is producing nothing, whatever it is or is not burning CPU on`, { coldMs: spineCold });
283
+ return;
284
+ }
285
+ if (wallMs === null || elapsed < wallMs + graceMs) return;
286
+
287
+ const overMs = elapsed - (wallMs + graceMs);
288
+ if (spineCold < deadMs) {
289
+ // LOUD, every poll. hamr: the outside kill checks for activity/bytes, not the
290
+ // clock alone — so an overrun that is still working gets named, not executed.
291
+ console.error(`[u-watchdog] PAST DEADLINE by ${Math.round(overMs / 1000)}s (wall ${Math.round(wallMs / 1000)}s + ${Math.round(graceMs / 1000)}s close grace) — NOT killing: still ACTIVE on spine (grew ${Math.round(spineCold / 1000)}s ago, ${spineSeen.size < 0 ? 'no file yet' : `${spineSeen.size} bytes`}). This guard stops DEAD processes; a live run is bounded by its own fuses and its money cap.`);
292
+ return;
293
+ }
294
+ clearInterval(tick);
295
+ stop('wall-dead', `${Math.round(overMs / 1000)}s past the deadline (wall ${Math.round(wallMs / 1000)}s + ${Math.round(graceMs / 1000)}s close grace, which has already funded every legal close stage) AND the spine flat for at least ${Math.round(deadMs / 1000)}s — no bytes are reaching the run's own log, so there is nothing left that could still be producing a verdict`, { requestedMs: wallMs, enforcedMs: wallMs + graceMs, pastDeadlineMs: overMs });
296
+ }, pollMs);