@bridge4dev/runner 0.57.0 → 0.58.1

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,760 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { killProcess, runSystemctl } from './cage-authority.js';
4
+ import { log } from './log.js';
5
+ import { scopeCgroupDir } from './session-cage.js';
6
+ const MIB = 1024 * 1024;
7
+ /**
8
+ * Telling «this session is standing still» from «this session is slow» — and
9
+ * doing something about it before the night is over (#398 S2, gotcha §480).
10
+ *
11
+ * Above `MemoryHigh` the kernel does not kill, it puts the allocating thread to
12
+ * sleep on the way back to userspace; with `MemorySwapMax=0` there is nothing to
13
+ * push out, so the penalty is up to 2 s per 256 KB and the process never reaches
14
+ * `MemoryMax` at all — it simply stops. On 09.09.2026 that cost one session an
15
+ * hour of silence on a build that printed nothing but its startup warnings.
16
+ * Ten of the fleet's fifteen caged machines have a session swap of zero, so this
17
+ * is the ordinary case and not the exotic one.
18
+ *
19
+ * The measure is ONE number and it is not the one three earlier drafts reached
20
+ * for.
21
+ *
22
+ * - **Not CPU.** A process wedged in direct reclaim burns system time in bursts:
23
+ * the same stuck process was measured at 3.6 %, 2.9 %, 6.9 %, 18.7 %, 98 %,
24
+ * 53 %, 22 %, 67 % of one core on consecutive seconds. Any threshold over it
25
+ * both misfires and misses.
26
+ * - **Not swap.** The first draft counted growth in `memory.swap.current` and
27
+ * `pgmajfault` as evidence of progress, out of a fear of killing honest work
28
+ * on a machine that swaps. The measurement refuted it: a live session with
29
+ * 232 MB of swap and 1.3 M major faults stood still for 2 h 15 min out of 18.
30
+ * Swap does not separate useful paging from thrashing.
31
+ * - **Pressure does.** `memory.pressure`, line `full`, field `total=`, is
32
+ * cumulative microseconds during which NO task in the cgroup could run for
33
+ * want of memory. Measured on this host: a wedged process, 27.9 s of stall in
34
+ * a 27.5 s window (~100 %); a healthy live session, 10.7 s in six hours
35
+ * (0.05 %); a session thrashing swap, 8093 s in 18 hours with the share close
36
+ * to 1 inside the bad stretches. Three orders of magnitude between honest
37
+ * work and a stall, in one number, on every machine that has PSI.
38
+ *
39
+ * The second condition is a cheap fuse and it is also a measurement: on a live
40
+ * scope of this host `anon` was 308 MB against a `memory.current` of 1033 MB
41
+ * with 9246 brake events — i.e. the brake very often catches page cache, which
42
+ * the kernel takes back with no stall at all. Those episodes must not reach the
43
+ * counter, let alone the deadline.
44
+ */
45
+ /**
46
+ * One detector window.
47
+ *
48
+ * Five seconds, not the watch's thirty: the deadline below is three minutes, and
49
+ * a measure that needs two consecutive windows would spend a third of the
50
+ * deadline deciding at the 30 s cadence. Five seconds of `full` stall is also
51
+ * long enough that a single scheduling hiccup cannot fill it.
52
+ */
53
+ export const STALL_WINDOW_MS = 5_000;
54
+ /**
55
+ * Share of the window spent with nothing in the cgroup able to run, above which
56
+ * the window counts as a standing one.
57
+ *
58
+ * **A fifth, not a half, and the difference was found by measurement.** The plan
59
+ * this module implements said 50 %, taken from a hard stall that read ~100 %.
60
+ * Reproducing the incident itself — a cage with a 64 MiB brake and no swap, a
61
+ * process touching what it allocates — gave four consecutive windows of
62
+ * **0.766, 0.484, 0.443, 0.542** (10.09.2026, this host). Two of the four sit
63
+ * BELOW a half: at that line the very shape this mechanism exists for would
64
+ * trip and clear in turn and never reach its deadline.
65
+ *
66
+ * There is room to move the line because the gap is enormous. Measured on the
67
+ * same host: a healthy live session, 0.0005; honest disk-bound work under the
68
+ * same 64 MiB brake writing 6.3 GB at 739 MB/s, exactly **0.0** with the brake
69
+ * never firing at all. A fifth is four hundred times what healthy work shows and
70
+ * half of what the mildest stalled window did.
71
+ */
72
+ export const STALL_SHARE = 0.2;
73
+ /**
74
+ * …and the share it has to fall BELOW before a running deadline is cleared.
75
+ *
76
+ * Two lines, not one, for the reason the cage watch already had `calmTicks`: a
77
+ * genuine stall is not a flat number, it breathes. One line means one dip
78
+ * cancels three minutes of standing still. Half the trip line, and still two
79
+ * hundred times the 0.0005 healthy work shows.
80
+ */
81
+ export const STALL_SHARE_CLEAR = 0.1;
82
+ /** Consecutive windows over the line before the session counts as standing still. */
83
+ export const STALL_WINDOWS_TO_TRIP = 2;
84
+ /**
85
+ * `(anon + shmem) / memory.current` above which the pressure is about memory the
86
+ * kernel cannot simply drop. Below it the brake is grinding page cache.
87
+ */
88
+ export const STALL_ANON_SHARE = 0.9;
89
+ /**
90
+ * How long a standing session is given before its biggest command is stopped
91
+ * (decision D6 of the plan).
92
+ *
93
+ * Three minutes: long enough for a person who is watching to intervene, short
94
+ * enough that a session which stalls at 02:00 is not still standing at 08:00.
95
+ * Doubled on a machine without PSI, where the fallback measure below is weaker.
96
+ */
97
+ export const STALL_GRACE_MS = 180_000;
98
+ /**
99
+ * A drop of this much in `memory.current` counts as real progress and clears the
100
+ * deadline.
101
+ *
102
+ * A new line of output does NOT: in the incident the build printed startup
103
+ * warnings for seventeen minutes while allocating nothing.
104
+ */
105
+ export const STALL_PROGRESS_DROP_BYTES = 64 * MIB;
106
+ /**
107
+ * How long the detector stays quiet after WE lowered this session's brake.
108
+ *
109
+ * Measured on this host: a process honestly writing a file at 6.4 MB/s was
110
+ * stopped dead the instant its `MemoryHigh` was narrowed on the live scope. Any
111
+ * lowering therefore manufactures exactly the signal this module looks for, and
112
+ * without the mute the mechanism would catch its own edit and stop a command for
113
+ * it. One window plus the deadline, with room to spare.
114
+ */
115
+ export const STALL_MUTE_AFTER_LOWER_MS = 240_000;
116
+ /**
117
+ * Between SIGTERM and SIGKILL for the subtree being stopped.
118
+ *
119
+ * Thirty seconds, not five: neither `pnpm install` nor `docker build` can put
120
+ * its own house in order in five, and half-written state costs the next turn
121
+ * more than the wait costs this one.
122
+ */
123
+ export const STALL_SIGKILL_AFTER_MS = 30_000;
124
+ /**
125
+ * Growth under which the PSI-less fallback still calls a session stuck:
126
+ * `min(1 % of the brake, 16 MiB)` per window.
127
+ */
128
+ export const STALL_FALLBACK_GROWTH_BYTES = 16 * MIB;
129
+ export function freshStallState(unit) {
130
+ return { unit, last: null, hotWindows: 0, stalledSince: null, markBytes: 0, mutedUntil: 0 };
131
+ }
132
+ /**
133
+ * The pure half: five files' text in, a sample out. Null when `memory.current`
134
+ * could not be read, which means the cgroup is gone.
135
+ */
136
+ export function parseStallSample(files, at) {
137
+ const count = (raw) => {
138
+ const trimmed = raw.trim();
139
+ if (!/^\d+$/.test(trimmed))
140
+ return null;
141
+ const value = Number(trimmed);
142
+ return Number.isSafeInteger(value) ? value : null;
143
+ };
144
+ const currentBytes = count(files.current);
145
+ if (currentBytes === null)
146
+ return null;
147
+ const statField = (name) => {
148
+ const match = new RegExp(`^${name} (\\d+)$`, 'm').exec(files.stat);
149
+ return match?.[1] ? Number(match[1]) : 0;
150
+ };
151
+ const event = (name) => {
152
+ const line = files.events.split('\n').find((l) => l.startsWith(`${name} `));
153
+ return line ? (count(line.slice(name.length + 1)) ?? 0) : 0;
154
+ };
155
+ // `full avg10=… avg60=… avg300=… total=12417907`. The averages are the kernel's
156
+ // own smoothing over windows we did not choose; `total` is the raw counter and
157
+ // the only one a window of our own length can be cut out of.
158
+ const full = files.pressure?.split('\n').find((l) => l.startsWith('full '));
159
+ const totalMatch = full ? /(?:^|\s)total=(\d+)/.exec(full) : null;
160
+ const pressureFullUs = totalMatch?.[1] ? Number(totalMatch[1]) : null;
161
+ return {
162
+ pressureFullUs: pressureFullUs !== null && Number.isSafeInteger(pressureFullUs) ? pressureFullUs : null,
163
+ highEvents: event('high'),
164
+ currentBytes,
165
+ anonBytes: statField('anon') + statField('shmem'),
166
+ brakeBytes: files.high.trim() === 'max' ? null : count(files.high),
167
+ at,
168
+ };
169
+ }
170
+ /** Read one sample off a live scope. Null when the cgroup is not there. */
171
+ export function readStallSample(unit, at, readFile = (p) => fs.readFileSync(p, 'utf8')) {
172
+ const dir = scopeCgroupDir(unit);
173
+ if (dir === null)
174
+ return null;
175
+ try {
176
+ let pressure;
177
+ try {
178
+ pressure = readFile(path.join(dir, 'memory.pressure'));
179
+ }
180
+ catch {
181
+ // A kernel without `CONFIG_PSI` has no such file. Not a failure — it
182
+ // decides which measure is used and doubles the deadline, nothing more.
183
+ pressure = undefined;
184
+ }
185
+ return parseStallSample({
186
+ current: readFile(path.join(dir, 'memory.current')),
187
+ high: readFile(path.join(dir, 'memory.high')),
188
+ events: readFile(path.join(dir, 'memory.events')),
189
+ stat: readFile(path.join(dir, 'memory.stat')),
190
+ ...(pressure === undefined ? {} : { pressure }),
191
+ }, at);
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ /**
198
+ * One window of the detector, as a pure function of the previous state.
199
+ *
200
+ * Everything the caller must not get wrong lives here: two windows before the
201
+ * counter trips, the anon fuse, the mute after our own edit, and a reset that
202
+ * only real progress can buy.
203
+ */
204
+ export function stallStep(previous, sample, options = {}) {
205
+ const now = options.now ?? sample.at;
206
+ const graceMs = options.graceMs ?? STALL_GRACE_MS;
207
+ // The nominal window, so a suite can run the whole mechanism in a second
208
+ // without fake timers — which are not an option here: they fire the WS
209
+ // client's liveness watchdog and it terminates the socket as half-dead.
210
+ const nominalWindowMs = options.windowMs ?? STALL_WINDOW_MS;
211
+ const degraded = sample.pressureFullUs === null;
212
+ const clean = (mutedUntil) => ({
213
+ state: {
214
+ ...previous,
215
+ last: sample,
216
+ hotWindows: 0,
217
+ stalledSince: null,
218
+ markBytes: 0,
219
+ mutedUntil,
220
+ },
221
+ stuck: false,
222
+ remainingMs: null,
223
+ expired: false,
224
+ degraded,
225
+ });
226
+ const before = previous.last;
227
+ // A cgroup this detector has not seen, or one whose counters went backwards —
228
+ // which can only be a NEW cgroup behind the same unit name. Start from zero
229
+ // rather than measure a window against a stranger's counter.
230
+ const sameCgroup = before !== null &&
231
+ sample.at > before.at &&
232
+ sample.highEvents >= before.highEvents &&
233
+ (sample.pressureFullUs === null ||
234
+ before.pressureFullUs === null ||
235
+ sample.pressureFullUs >= before.pressureFullUs);
236
+ if (!sameCgroup)
237
+ return clean(previous.mutedUntil);
238
+ if (now < previous.mutedUntil)
239
+ return clean(previous.mutedUntil);
240
+ const windowMs = sample.at - before.at;
241
+ const effectiveGraceEarly = (options.graceMs ?? STALL_GRACE_MS) * (degraded ? 2 : 1);
242
+ // A window shorter than half the nominal one is a tick that fired early; its
243
+ // share would be noise divided by a small number. The old sample is KEPT, so
244
+ // the next window is measured over a full span instead of two short ones —
245
+ // and the deadline, if one is running, keeps running.
246
+ if (windowMs < nominalWindowMs / 2) {
247
+ const running = previous.stalledSince !== null;
248
+ return {
249
+ state: { ...previous, last: before },
250
+ stuck: running,
251
+ remainingMs: running
252
+ ? Math.max(0, effectiveGraceEarly - (now - (previous.stalledSince ?? now)))
253
+ : null,
254
+ expired: false,
255
+ degraded,
256
+ };
257
+ }
258
+ /**
259
+ * Three answers, not two, and the middle one is what the measurement bought.
260
+ *
261
+ * `hot` starts and continues a deadline. `clear` ends one. `warm` — between
262
+ * the two lines — does neither: a stall that dips for one window is still a
263
+ * stall, and a session that has not started standing still does not start now.
264
+ */
265
+ const anonShare = sample.currentBytes > 0 ? sample.anonBytes / sample.currentBytes : 0;
266
+ const anonFuse = anonShare >= STALL_ANON_SHARE;
267
+ /**
268
+ * …and it has to be THIS session's own doing.
269
+ *
270
+ * `memory.pressure` counts every microsecond a task of this cgroup could not
271
+ * run for want of memory — INCLUDING when the reason is an ancestor. Since S3
272
+ * put a collective `MemoryHigh` on the sessions slice, that is no longer
273
+ * hypothetical: a greedy sibling filling the slice makes the kernel throttle
274
+ * everybody, and an innocent session sitting far under its own limits sees a
275
+ * high `full` share with nothing wrong on its side.
276
+ *
277
+ * The independent review of 10.09.2026 reproduced it with real cgroups: a
278
+ * scope holding a steady 430 MB, 1.5 GB under its own brake, below even its
279
+ * own `MemoryLow`, with `memory.events high = 0` for the whole run, showed
280
+ * shares of 0.22–0.42 for 45 consecutive windows — and the mechanism would
281
+ * have stopped its command at 214 s. That is precisely the failure #387 is
282
+ * about, the innocent neighbour, coming back through a new door.
283
+ *
284
+ * `memory.events` `high` is the discriminator, and it is exact: it counts
285
+ * times THIS cgroup's own brake was breached, and it does not move when an
286
+ * ancestor is the one throttling. Measured on this host, a session stalled by
287
+ * its OWN brake moved it by about a thousand per five-second window
288
+ * (4595 → 5785 → 6805 → 7655); the innocent neighbour above moved it not at
289
+ * all.
290
+ */
291
+ /**
292
+ * «This session's own doing», by three signs and not one (#398 S7, B9).
293
+ *
294
+ * `memory.events high` is exact when it fires, and it has two blind spots
295
+ * that the rest of this stage created:
296
+ *
297
+ * 1. **The brake is not always above the hold.** On a tight machine the
298
+ * allocator floors the brake at `max(guarantee, hold × 1.05)`, so an
299
+ * INNOCENT session's own counter ticks on any growth at all. Hence the
300
+ * counter alone is not enough to convict — but it was never the only
301
+ * evidence: the deadline still needs pressure over the line for two
302
+ * windows.
303
+ * 2. **The slice's brake can bind first.** Above a pot of about 22.5 GiB a
304
+ * lone session's brake (`top − band`) sits ABOVE the collective brake on
305
+ * the slice (0.9 × pot), so the kernel throttles at the slice and this
306
+ * counter never moves — the detector switched off in precisely the
307
+ * headline case of the ticket. `nearOwnBrake` and `soleSession` cover it:
308
+ * a session sitting at its own ceiling, or alone in the slice, is not
309
+ * standing in anybody else's queue.
310
+ */
311
+ const nearOwnBrake = sample.brakeBytes !== null && sample.currentBytes >= Math.floor(sample.brakeBytes * 0.95);
312
+ const ownBrakeFired = sample.highEvents > before.highEvents || nearOwnBrake || options.soleSession === true;
313
+ /**
314
+ * A frozen counter no longer cancels a deadline by itself, and it no longer
315
+ * needs a special case to avoid it either (#403).
316
+ *
317
+ * The first attempt at this bug added one: «a still counter means warm while
318
+ * a deadline runs», on the reasoning that a session wedged hard enough to
319
+ * stop allocating looks exactly like an innocent neighbour. It was the right
320
+ * observation and the wrong place — with a real neighbour in the slice the
321
+ * deadline then ran to its end anyway, and the innocent session lost its
322
+ * command. Reproduced in the suite, two sessions and one SIGTERM.
323
+ *
324
+ * `nearOwnBrake` above answers it properly: a session that has stopped
325
+ * allocating because its OWN limit is what stops it is sitting at that limit,
326
+ * which is a fact about the cgroup and not a guess about its intentions. So
327
+ * `ownBrakeFired` is true for it, and «somebody else's brake» keeps meaning
328
+ * exactly what it says.
329
+ */
330
+ let heat;
331
+ if (sample.pressureFullUs !== null && before.pressureFullUs !== null) {
332
+ const share = (sample.pressureFullUs - before.pressureFullUs) / (windowMs * 1000);
333
+ if (!anonFuse || !ownBrakeFired || share < STALL_SHARE_CLEAR)
334
+ heat = 'clear';
335
+ else if (share >= STALL_SHARE)
336
+ heat = 'hot';
337
+ else
338
+ heat = 'warm';
339
+ }
340
+ else {
341
+ // No PSI. The brake fired at least once, the cgroup barely grew, and what it
342
+ // holds is not page cache. Weaker than pressure, hence the doubled grace.
343
+ const growth = sample.currentBytes - before.currentBytes;
344
+ const ceiling = Math.min(sample.brakeBytes === null
345
+ ? STALL_FALLBACK_GROWTH_BYTES
346
+ : Math.floor(sample.brakeBytes * 0.01), STALL_FALLBACK_GROWTH_BYTES);
347
+ if (!anonFuse || growth >= ceiling) {
348
+ // It is allocating, so it is moving. That IS the progress this measure has.
349
+ heat = 'clear';
350
+ }
351
+ else if (sample.highEvents > before.highEvents)
352
+ heat = 'hot';
353
+ else
354
+ heat = 'warm';
355
+ }
356
+ const effectiveGrace = degraded ? graceMs * 2 : graceMs;
357
+ if (heat === 'clear')
358
+ return clean(previous.mutedUntil);
359
+ // Real progress, even while the pressure stays up: the session gave memory back.
360
+ if (previous.stalledSince !== null &&
361
+ previous.markBytes - sample.currentBytes >= STALL_PROGRESS_DROP_BYTES) {
362
+ return clean(previous.mutedUntil);
363
+ }
364
+ // Warm and not already standing: nothing starts here.
365
+ if (heat === 'warm' && previous.stalledSince === null) {
366
+ return {
367
+ state: { ...previous, last: sample, stalledSince: null, markBytes: 0 },
368
+ stuck: false,
369
+ remainingMs: null,
370
+ expired: false,
371
+ degraded,
372
+ };
373
+ }
374
+ const hotWindows = heat === 'hot' ? previous.hotWindows + 1 : previous.hotWindows;
375
+ if (hotWindows < STALL_WINDOWS_TO_TRIP) {
376
+ return {
377
+ state: { ...previous, last: sample, hotWindows, stalledSince: null, markBytes: 0 },
378
+ stuck: false,
379
+ remainingMs: null,
380
+ expired: false,
381
+ degraded,
382
+ };
383
+ }
384
+ const stalledSince = previous.stalledSince ?? now;
385
+ const markBytes = previous.stalledSince === null ? sample.currentBytes : previous.markBytes;
386
+ const elapsed = now - stalledSince;
387
+ return {
388
+ state: { ...previous, last: sample, hotWindows, stalledSince, markBytes },
389
+ stuck: true,
390
+ remainingMs: Math.max(0, effectiveGrace - elapsed),
391
+ expired: elapsed >= effectiveGrace,
392
+ degraded,
393
+ };
394
+ }
395
+ /**
396
+ * Anything whose command line says «MCP server» is the agent's own plumbing.
397
+ *
398
+ * Deliberately generous, because the direction of a mistake matters: a false
399
+ * positive costs us a candidate (we stop something smaller, or nothing), a false
400
+ * negative costs the session its tools mid-turn.
401
+ *
402
+ * **Widened after the independent review of 10.09.2026, which found the first
403
+ * version matched nothing real.** The command lines actually on this machine are
404
+ * `npm exec @playwright/mcp@latest …` and `node …/playwright-mcp --output-dir …`
405
+ * — in the first `mcp` is followed by `@`, in the second preceded by `-`, and
406
+ * the original character classes allowed neither. So the rule is now simply
407
+ * «`mcp` as a word», which does match both.
408
+ *
409
+ * The same review found the mirror-image defect, and it was the worse of the
410
+ * two: the AGENT's own command line contains `--mcp-config …`, so the agent
411
+ * matched, and protection used to be spread from anything that matched — which
412
+ * protected the agent's ENTIRE subtree and made every Claude session return no
413
+ * candidate at all. Protection is no longer spread from a tree root: a root is
414
+ * protected on its own account (it is the agent), and spreading from it would
415
+ * protect the very builds this mechanism exists to stop.
416
+ */
417
+ export const MCP_MARKER = /(^|[^a-z0-9])mcp([^a-z0-9]|$)|modelcontextprotocol/i;
418
+ /**
419
+ * Bytes per page of `/proc/<pid>/statm`, worked out rather than assumed.
420
+ *
421
+ * 4096 is right on x86-64 and wrong on an arm64 kernel built with 16 KB or
422
+ * 64 KB pages — where every size this module reports would be off by a factor of
423
+ * four or sixteen. The ranking would survive (the error is uniform), but the
424
+ * number in the line a person reads would not. `/proc/self/status` gives `VmRSS`
425
+ * in kB and `/proc/self/statm` gives the same figure in pages, so dividing one
426
+ * by the other answers the question with no subprocess and no guess.
427
+ *
428
+ * Measured once per process: the page size cannot change under a running kernel.
429
+ */
430
+ let cachedPageSize = null;
431
+ export function residentPageSize(readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
432
+ if (cachedPageSize !== null)
433
+ return cachedPageSize;
434
+ let size = 4096;
435
+ try {
436
+ const status = readFile(path.join(procDir, 'self', 'status'));
437
+ const rssKb = /^VmRSS:\s+(\d+) kB$/m.exec(status);
438
+ const pages = Number(readFile(path.join(procDir, 'self', 'statm'))
439
+ .trim()
440
+ .split(/\s+/)[1]);
441
+ if (rssKb?.[1] && Number.isSafeInteger(pages) && pages > 0) {
442
+ const derived = Math.round((Number(rssKb[1]) * 1024) / pages);
443
+ // Only a real page size: a power of two between 4 KiB and 64 KiB.
444
+ if (derived >= 4096 && derived <= 65536 && (derived & (derived - 1)) === 0)
445
+ size = derived;
446
+ }
447
+ }
448
+ catch {
449
+ // No `/proc/self`. 4096 is the right guess everywhere this runner runs.
450
+ }
451
+ cachedPageSize = size;
452
+ return size;
453
+ }
454
+ /**
455
+ * Read `/proc` for every process in one session's cgroup.
456
+ *
457
+ * **The exception to the rule in `host-load.ts`**, and the only one: that module
458
+ * states in full what this runner may read for telemetry, and says «no
459
+ * `/proc/<pid>` walk, no `ps`». This is not telemetry — nothing here leaves the
460
+ * machine except one command NAME — and choosing a victim cannot be done from
461
+ * two summary files. The rule and this exception are noted in both places.
462
+ *
463
+ * `ppid` is taken from after the LAST `)` of `/proc/<pid>/stat`, never by field
464
+ * number: a process name may contain spaces and parentheses (`(npm exec
465
+ * @playw)`), which moves every field after it (gotcha §441).
466
+ */
467
+ export function readScopeProcesses(unit, readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
468
+ const dir = scopeCgroupDir(unit);
469
+ if (dir === null)
470
+ return [];
471
+ let listing;
472
+ try {
473
+ listing = readFile(path.join(dir, 'cgroup.procs'));
474
+ }
475
+ catch {
476
+ return [];
477
+ }
478
+ const pageSize = residentPageSize(readFile, procDir);
479
+ const out = [];
480
+ for (const line of listing.split('\n')) {
481
+ const pid = Number(line.trim());
482
+ if (!Number.isSafeInteger(pid) || pid <= 0)
483
+ continue;
484
+ try {
485
+ const stat = readFile(path.join(procDir, String(pid), 'stat'));
486
+ const close = stat.lastIndexOf(')');
487
+ if (close < 0)
488
+ continue;
489
+ const after = stat
490
+ .slice(close + 1)
491
+ .trim()
492
+ .split(/\s+/);
493
+ // After `comm` come `state` then `ppid`; `starttime` is field 22 of the
494
+ // whole line, i.e. index 19 of what follows the closing parenthesis.
495
+ const ppid = Number(after[1]);
496
+ const startedAtTicks = Number(after[19]);
497
+ const statm = readFile(path.join(procDir, String(pid), 'statm'))
498
+ .trim()
499
+ .split(/\s+/);
500
+ const resident = Number(statm[1]);
501
+ const comm = readFile(path.join(procDir, String(pid), 'comm')).trim();
502
+ let cmdline = '';
503
+ try {
504
+ cmdline = readFile(path.join(procDir, String(pid), 'cmdline'));
505
+ }
506
+ catch {
507
+ // A process that ended between the listing and the read. Treated as
508
+ // ordinary work, which only ever makes it a candidate — and a candidate
509
+ // that no longer exists is skipped when the signal is sent.
510
+ }
511
+ if (!Number.isSafeInteger(ppid) || !Number.isSafeInteger(resident))
512
+ continue;
513
+ out.push({
514
+ pid,
515
+ ppid,
516
+ startedAtTicks: Number.isSafeInteger(startedAtTicks) ? startedAtTicks : -1,
517
+ comm,
518
+ rssBytes: resident * pageSize,
519
+ mcp: MCP_MARKER.test(cmdline.replace(/\0/g, ' ')),
520
+ });
521
+ }
522
+ catch {
523
+ // Gone between the listing and the read.
524
+ }
525
+ }
526
+ return out;
527
+ }
528
+ /**
529
+ * The biggest command of this session, defined by the letter and not by «the
530
+ * fattest thing on the machine».
531
+ *
532
+ * Every exclusion below is a measurement or an incident:
533
+ *
534
+ * - **only pids of THIS cgroup.** By RSS the fattest processes on a dev server
535
+ * are `dockerd`, the production API and other people's agents; a candidate
536
+ * chosen machine-wide would stop one of those.
537
+ * - **never the agent.** The agent is the one process in the scope whose parent
538
+ * is OUTSIDE it — `systemd-run --scope` execs into it and the runner spawned
539
+ * it. That is a structural fact and needs no pid handed down through two
540
+ * adapters. Stopping the agent is what the top «Stop» button is for; this
541
+ * mechanism has no right to it (D6).
542
+ * - **never an MCP server**, nor anything under one: those are the agent's
543
+ * tools, not its work.
544
+ * - **never `git`.** It is never the fattest thing, and a killed `git` leaves
545
+ * `.git/index.lock` behind, which then costs the session its restore points
546
+ * and its next turns.
547
+ * - **the whole subtree**, not one pid: over a `tsc` there is a `pnpm`, and
548
+ * over that a `sh`, and killing the leaf leaves the parent to start another.
549
+ */
550
+ export function pickKillCandidate(processes, agentPid = null) {
551
+ if (processes.length === 0)
552
+ return null;
553
+ const byPid = new Map(processes.map((p) => [p.pid, p]));
554
+ const children = new Map();
555
+ for (const p of processes) {
556
+ if (!byPid.has(p.ppid))
557
+ continue;
558
+ const list = children.get(p.ppid);
559
+ if (list)
560
+ list.push(p.pid);
561
+ else
562
+ children.set(p.ppid, [p.pid]);
563
+ }
564
+ // The agent: the root of the tree. On a scope there is exactly one, but a
565
+ // machine can surprise us, so every parentless process is protected.
566
+ const roots = processes.filter((p) => !byPid.has(p.ppid)).map((p) => p.pid);
567
+ const rootComms = new Set(roots.map((pid) => byPid.get(pid)?.comm ?? ''));
568
+ const protectedPids = new Set(roots);
569
+ const spread = (pid) => {
570
+ for (const child of children.get(pid) ?? []) {
571
+ if (protectedPids.has(child))
572
+ continue;
573
+ protectedPids.add(child);
574
+ spread(child);
575
+ }
576
+ };
577
+ for (const p of processes) {
578
+ if (!p.mcp)
579
+ continue;
580
+ protectedPids.add(p.pid);
581
+ /**
582
+ * …but never FROM THE AGENT. See the note on `MCP_MARKER`: the agent's own
583
+ * command line names the MCP config, so spreading from it protected every
584
+ * build the agent ever started.
585
+ *
586
+ * Which one is the agent is now KNOWN when the caller says so (#403).
587
+ * «Every root» was the old answer, and it was too wide by exactly one case:
588
+ * an MCP server whose launcher exited is a root too, and under that answer
589
+ * its own children — a browser, a language server — silently lost the
590
+ * protection they had before S7b. They are the agent's tools either way.
591
+ */
592
+ const isAgent = agentPid === null ? roots.includes(p.pid) : p.pid === agentPid;
593
+ if (!isAgent)
594
+ spread(p.pid);
595
+ }
596
+ const subtree = (pid) => {
597
+ const acc = [pid];
598
+ for (const child of children.get(pid) ?? [])
599
+ acc.push(...subtree(child));
600
+ return acc;
601
+ };
602
+ let best = null;
603
+ for (const p of processes) {
604
+ if (protectedPids.has(p.pid))
605
+ continue;
606
+ // Only tree roots among what is left: a process whose parent is also a
607
+ // candidate is part of that parent's subtree, not a candidate of its own.
608
+ if (byPid.has(p.ppid) && !protectedPids.has(p.ppid))
609
+ continue;
610
+ if (p.comm === 'git')
611
+ continue;
612
+ // A second agent process (a background CLI) is not «a command» either.
613
+ if (rootComms.has(p.comm))
614
+ continue;
615
+ /**
616
+ * `git` is spared, and only `git` — not everything that happens to share a
617
+ * tree with it (#403).
618
+ *
619
+ * Two corrections, in this order. The review of 10.09.2026 found the first:
620
+ * the ordinary shape of a git command from an agent is `bash -lc "git …"`,
621
+ * so the root is `bash` and the `git` underneath it was killed with the
622
+ * tree, leaving `.git/index.lock` behind. The fix for it skipped any
623
+ * subtree CONTAINING a git — and that was too much, because in Claude Code
624
+ * every Bash tool call lives under ONE long-lived shell: a momentary
625
+ * `git status` made the four-gigabyte build in the same tree unkillable,
626
+ * and T2 quietly degraded into T3 «nothing to stop here».
627
+ *
628
+ * So the git processes and their children are removed from the signal list
629
+ * while the rest of the subtree stays a candidate. `comm` is truncated to
630
+ * 15 characters by the kernel, hence the prefix test — it also covers
631
+ * `git-remote-http` and `git-lfs`, which have the same lock to lose.
632
+ */
633
+ const isGit = (pid) => (byPid.get(pid)?.comm ?? '').startsWith('git');
634
+ const spared = new Set();
635
+ const spareTree = (pid) => {
636
+ spared.add(pid);
637
+ for (const child of children.get(pid) ?? [])
638
+ spareTree(child);
639
+ };
640
+ for (const pid of subtree(p.pid))
641
+ if (isGit(pid))
642
+ spareTree(pid);
643
+ const pids = subtree(p.pid).filter((pid) => !spared.has(pid));
644
+ // Nothing but git under here: that is a git command, and it is spared whole.
645
+ if (pids.length === 0)
646
+ continue;
647
+ const rssBytes = pids.reduce((sum, pid) => sum + (byPid.get(pid)?.rssBytes ?? 0), 0);
648
+ // The NAME the person reads is the heaviest process of the subtree, not its
649
+ // root: over a `node` doing the work there is usually a `sh` or a `pnpm`,
650
+ // and «sh» tells nobody anything. Arguments are never taken — they carry
651
+ // keys and tokens.
652
+ const heaviest = pids
653
+ .map((pid) => byPid.get(pid))
654
+ .filter((x) => x !== undefined)
655
+ .sort((a, b) => b.rssBytes - a.rssBytes)[0];
656
+ if (best === null || rssBytes > best.rssBytes) {
657
+ best = {
658
+ rootPid: p.pid,
659
+ pids,
660
+ identities: pids.map((pid) => ({
661
+ pid,
662
+ startedAtTicks: byPid.get(pid)?.startedAtTicks ?? -1,
663
+ })),
664
+ name: heaviest?.comm ?? p.comm,
665
+ rssBytes,
666
+ };
667
+ }
668
+ }
669
+ return best;
670
+ }
671
+ /**
672
+ * Is this pid still the process it was when we decided to stop it?
673
+ *
674
+ * `starttime` never repeats for one pid, so comparing it is the cheap and exact
675
+ * answer. Anything unreadable is «not the same», which is the safe direction: a
676
+ * signal not sent costs a command another thirty seconds, a signal sent to the
677
+ * wrong process costs somebody something we know nothing about.
678
+ */
679
+ export function isSameProcess(identity, readFile = (p) => fs.readFileSync(p, 'utf8'), procDir = '/proc') {
680
+ if (identity.startedAtTicks < 0)
681
+ return false;
682
+ try {
683
+ const stat = readFile(path.join(procDir, String(identity.pid), 'stat'));
684
+ const close = stat.lastIndexOf(')');
685
+ if (close < 0)
686
+ return false;
687
+ const after = stat
688
+ .slice(close + 1)
689
+ .trim()
690
+ .split(/\s+/);
691
+ return Number(after[19]) === identity.startedAtTicks;
692
+ }
693
+ catch {
694
+ return false;
695
+ }
696
+ }
697
+ /**
698
+ * Send one signal to every pid of a subtree. Processes that ended are skipped.
699
+ *
700
+ * When identities are given, each pid is checked against the process it was
701
+ * when the subtree was chosen — see {@link isSameProcess}. The SIGKILL that
702
+ * follows a SIGTERM thirty seconds later is the call that needs it: on a busy
703
+ * machine a pid freed in between can belong to something else by then.
704
+ */
705
+ export function signalSubtree(pids, signal, kill = killProcess, sameProcess = isSameProcess) {
706
+ let sent = 0;
707
+ // Deepest first, so a parent cannot notice a child dying and start another.
708
+ for (const entry of [...pids].reverse()) {
709
+ const pid = typeof entry === 'number' ? entry : entry.pid;
710
+ if (typeof entry !== 'number' && !sameProcess(entry))
711
+ continue;
712
+ try {
713
+ kill(pid, signal);
714
+ sent += 1;
715
+ }
716
+ catch {
717
+ // Already gone, or not ours. Either way there is nothing to stop.
718
+ }
719
+ }
720
+ return sent;
721
+ }
722
+ /**
723
+ * Raise this session's brake on the live scope.
724
+ *
725
+ * `systemctl --user set-property --runtime`, never a write into the cgroup
726
+ * files: a direct write is undone by the next `daemon-reload`, and the runner
727
+ * calls one itself every hour. `--runtime` is not optional either — without it
728
+ * systemd persists the property into `~/.config/systemd/user.control/`, which
729
+ * OUTRANKS the drop-in this runner ships and which `doctor` reports as an
730
+ * emergency override (`index.ts`).
731
+ *
732
+ * Never throws. A failure here must not take down the tick, let alone the
733
+ * daemon: the daemon carries a fatal `unhandledRejection` handler, so one
734
+ * unhandled write error would end every live session on the machine. A
735
+ * `set-property` on a unit that ended between the measurement and the write
736
+ * exits 1 with «Unit … not found» — the ordinary outcome of a race, not an
737
+ * error worth a loud line.
738
+ */
739
+ export async function setScopeProperties(unit, properties, run = (args) => runSystemctl(args)) {
740
+ if (properties.length === 0)
741
+ return true;
742
+ try {
743
+ await run(['set-property', '--runtime', unit, ...properties]);
744
+ return true;
745
+ }
746
+ catch (error) {
747
+ const text = String(error instanceof Error ? error.message : error);
748
+ if (/not found|not loaded/i.test(text)) {
749
+ log.debug('session memory: the scope ended before its limits could be written', { unit });
750
+ }
751
+ else {
752
+ log.warn('session memory: could not write the limits of a live session', {
753
+ unit,
754
+ error: text,
755
+ });
756
+ }
757
+ return false;
758
+ }
759
+ }
760
+ //# sourceMappingURL=session-stall.js.map