@bridge4dev/runner 0.52.0 → 0.54.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,755 @@
1
+ import { execFile } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { runnerIdentity, systemdUserEnv } from './environment.js';
8
+ import { log } from './log.js';
9
+ import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
10
+ const execFileAsync = promisify(execFile);
11
+ /**
12
+ * A cgroup of its own for every agent session — the memory ceiling that
13
+ * `process-priority.ts` cannot give.
14
+ *
15
+ * Stage 1a (`process-priority.ts`) orders the CPU and nothing else. Memory is
16
+ * the mechanism behind three of the four incidents on other people's machines:
17
+ * one session allocates until the machine swaps itself to a standstill, and the
18
+ * only thing that ever stopped it was the ceiling on the WHOLE service — which
19
+ * kills whichever child the kernel picks, not the one that misbehaved. This
20
+ * module puts each session in `devbridge-session-<id>.scope` under
21
+ * `devbridge-sessions.slice`, so the session that runs out of memory is the
22
+ * session that dies, and its neighbours never notice.
23
+ *
24
+ * Every line below is a finding of the spike of 2026-09-07 (plan
25
+ * `agent-sessions-host-resources.md` §5.4.1), not a preference. The ones that
26
+ * cost the most to learn:
27
+ *
28
+ * - `MemorySwapMax=0` is what MAKES the cage. Without it there is none. A
29
+ * process under `MemoryMax=200M` allocated 2 GB and drained the host's
30
+ * entire swap (`memory.events` read `max 0, oom_kill 0`, swap 2047/2047 MB)
31
+ * — the exact incident this whole plan exists to prevent, reproduced by the
32
+ * supposed fix. `MemoryMax` bounds RESIDENT memory; the rest goes to swap,
33
+ * and swap belongs to the machine, not to the cgroup.
34
+ * - no `MemoryHigh`. With `MemoryHigh` under `MemoryMax` the kernel throttles
35
+ * allocations with delays instead of killing: 60 seconds in, the runaway had
36
+ * 100 MB and was still alive — a session that hangs rather than dies, which
37
+ * is the worse of the two. With `MemoryMax` + `MemorySwapMax=0` alone it was
38
+ * exit 137 in 380 ms and `Result=oom-kill`.
39
+ * - no `--collect`. A scope killed by the OOM killer stays behind in `failed`,
40
+ * and `systemctl --user show <unit> -p Result` is the ONLY place the cause
41
+ * can be read: `Result=oom-kill`. `--collect` deletes the unit the instant it
42
+ * dies and takes the reason with it. Hence the order in
43
+ * {@link releaseSessionScope}: wait for the exit, read `Result`, log it, THEN
44
+ * `reset-failed`.
45
+ * - `--expand-environment=no`, but ONLY on systemd ≥ 254. The arguments carry
46
+ * the agent's prompt, and prompts contain `$`; without this flag a future
47
+ * systemd would substitute variables into the user's own words. systemd 254
48
+ * is where the switch was added, and it is also where its own NEWS says
49
+ * expansion «defaults to enabled for all execution types except `--scope`,
50
+ * where it defaults to off … for backward compatibility reasons». So on
51
+ * Ubuntu 22.04 (249), Debian 12 and RHEL 9 (252) the flag is both rejected
52
+ * (`unrecognized option`, exit 1, nothing spawned) and unnecessary. Passing
53
+ * it unconditionally cost those machines the cage entirely
54
+ * (QA-2026-09-07 MAJOR-2).
55
+ * - `--pty` is not compatible with `--scope` ("--pty/--pipe is not compatible
56
+ * in timer or --scope mode."), and does not need to be: the sign-in relay's
57
+ * `script -qec` runs INSIDE the scope byte for byte the same. `auth-relay.ts`
58
+ * is untouched by this module.
59
+ * - `CPUWeight` is set on the scope AND on `devbridge.slice`
60
+ * (`service-unit.ts`). The moment sessions leave the service's own cgroup,
61
+ * `nice(2)` stops protecting the daemon from its own children — nice orders
62
+ * tasks WITHIN one cgroup, and across cgroups `cpu.weight` decides, which is
63
+ * 100 everywhere by default. A cage without that drop-in would quietly undo
64
+ * stage 1a and bring back 16.08: daemon starved, four missed pongs, server
65
+ * Offline, 504 on every session.
66
+ *
67
+ * Rejected in the spike: `Delegate=yes` on the service plus the runner sorting
68
+ * PIDs into child cgroups itself. cgroup v2's "no internal processes" rule
69
+ * answers `Device or resource busy` on `cgroup.subtree_control`, the way around
70
+ * it moves the daemon into a sub-cgroup and needs a unit change and a RESTART
71
+ * (which kills every live session), and the spawn-then-move order lost the race
72
+ * anyway — the runaway was 100–150 MB in before it ever reached the cage.
73
+ */
74
+ const MIB = 1024 * 1024;
75
+ const GIB = 1024 * MIB;
76
+ /**
77
+ * What one session may hold. **Proposal Р3 of the plan, awaiting the owner.**
78
+ *
79
+ * `min(2.5 GiB, service ceiling / 2)` — §3 of
80
+ * `docs/plans/active/agent-sessions-host-resources.md`. Both halves matter:
81
+ *
82
+ * - the absolute number is sized against the measurement, not against a round
83
+ * figure. The heaviest ordinary thing a session runs is a workspace
84
+ * `pnpm typecheck`, measured at a 1571 MB peak; three live `claude`
85
+ * processes are 512/577/646 MB. 2.5 GiB leaves ~60 % headroom over the
86
+ * worst measured case, which is the margin between "kills a runaway" and
87
+ * "kills the work";
88
+ * - the half-of-the-service half is what keeps ONE session from being able to
89
+ * fill the ceiling that covers all of them. On this machine the service
90
+ * ceiling is 7680M, so half is 3.75 GiB and the absolute number wins.
91
+ *
92
+ * The service ceiling is read off systemd (`systemctl --user show
93
+ * devbridge-runner -p MemoryMax`) rather than recomputed here, because what
94
+ * protects the machine is what systemd has in force, not what the drop-in on
95
+ * disk says — those two have already drifted apart once (§5.5 of the plan).
96
+ */
97
+ export const SESSION_MEMORY_MAX_ABSOLUTE_BYTES = Math.round(2.5 * GIB);
98
+ /**
99
+ * Processes and threads per session.
100
+ *
101
+ * An agent forks a lot (a monorepo build is hundreds of short-lived processes),
102
+ * so this is not a memory limit in disguise — it is the fork-bomb stop. 512 is
103
+ * far above anything measured on a real session and far below the 8192 the
104
+ * whole service gets.
105
+ */
106
+ export const SESSION_TASKS_MAX = 512;
107
+ /** Prefix of every scope this module creates. The sweeper matches on it. */
108
+ export const SESSION_SCOPE_PREFIX = 'devbridge-session-';
109
+ /**
110
+ * The FLOOR the plan's formula does not have, and the reason it needs one.
111
+ *
112
+ * `min(2.5 GiB, ceiling / 2)` is right on a big machine and wrong on a small
113
+ * one: at the 2 GiB ceiling `memoryPolicy` gives the smallest supported box,
114
+ * half is 1 GiB — BELOW the 1571 MB a workspace `pnpm typecheck` was measured
115
+ * at. The cage would then kill honest work on exactly the machines least able
116
+ * to afford a failed run, and it would look like a flaky agent rather than a
117
+ * limit. A containment that fires on correct work is not containment.
118
+ *
119
+ * So the floor is set above the worst measured ordinary peak, not at a round
120
+ * number.
121
+ *
122
+ * It is a floor and not a switch: below ~2.4 GB of RAM the containing ceiling
123
+ * itself drops under this number, and there the last clamp in
124
+ * {@link sessionMemoryMaxBytes} wins and a session is capped below the floor.
125
+ * That is not the cage killing honest work — the slice would have killed the
126
+ * same `pnpm typecheck` a moment later anyway, because it holds the same
127
+ * number — so removing `MemoryMax` from the scope there would buy nothing and
128
+ * cost the one thing it does buy: the runaway dying instead of its neighbours
129
+ * (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
130
+ */
131
+ export const SESSION_MEMORY_MIN_BYTES = 2 * GIB;
132
+ /**
133
+ * `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
134
+ *
135
+ * Three bounds, each for its own reason — see
136
+ * {@link SESSION_MEMORY_MAX_ABSOLUTE_BYTES} for the upper two and
137
+ * {@link SESSION_MEMORY_MIN_BYTES} for the lower one. The last clamp matters
138
+ * on a tiny machine: a per-session number ABOVE the slice's own ceiling is not
139
+ * a limit at all, it is a bigger number that never applies, and printing it in
140
+ * `doctor` would be a straight lie about what is in force.
141
+ *
142
+ * The argument is the ceiling of the cgroup that CONTAINS the session, and
143
+ * since 0.54.0 that is `devbridge-sessions.slice` and not the service — the
144
+ * caller reads the slice and falls back to the service only when systemd has
145
+ * nothing to say about the slice. The two are the same number by construction
146
+ * today; they come apart the moment a drop-in fails to apply or is edited by
147
+ * hand, and then «never more than the slice» has to still be true
148
+ * (QA-2026-09-07 MINOR-5).
149
+ */
150
+ export function sessionMemoryMaxBytes(containingMemoryMaxBytes) {
151
+ if (containingMemoryMaxBytes === null || !Number.isFinite(containingMemoryMaxBytes)) {
152
+ return SESSION_MEMORY_MAX_ABSOLUTE_BYTES;
153
+ }
154
+ const half = Math.floor(containingMemoryMaxBytes / 2);
155
+ const clamped = Math.min(SESSION_MEMORY_MAX_ABSOLUTE_BYTES, Math.max(SESSION_MEMORY_MIN_BYTES, half));
156
+ // Never promise a session more than the cgroup that contains every session.
157
+ return Math.max(1, Math.min(clamped, Math.floor(containingMemoryMaxBytes)));
158
+ }
159
+ // ─── unit names ──────────────────────────────────────────────────────
160
+ /**
161
+ * Room for the id inside a unit name. systemd's own limit is 255 bytes for the
162
+ * whole name; the prefix, the `.scope` suffix, a restart marker and a hash all
163
+ * come out of the same budget, so the id is cut well short of it.
164
+ */
165
+ const MAX_ID_IN_UNIT = 180;
166
+ /**
167
+ * The session id as systemd will accept it.
168
+ *
169
+ * Ids are UUIDs today and arbitrary text tomorrow — they arrive from the API,
170
+ * and `verify` keys its cage by a run id. systemd unit names take only
171
+ * `[A-Za-z0-9:_.-]`, so everything else is folded to `-`. Folding is lossy, and
172
+ * lossy is how two different sessions end up fighting over one scope, so
173
+ * anything that had to be changed or cut also gets eight hex digits of the
174
+ * original — deterministic, so the same session always names the same unit and
175
+ * the sweeper can still recognise it.
176
+ */
177
+ export function sanitizeCageId(id) {
178
+ const folded = id.replace(/[^A-Za-z0-9:_.-]/g, '-').replace(/^[.-]+/, '');
179
+ const cut = folded.slice(0, MAX_ID_IN_UNIT);
180
+ if (cut === id && cut.length > 0)
181
+ return cut;
182
+ const digest = crypto.createHash('sha256').update(id).digest('hex').slice(0, 8);
183
+ return `${cut || 'session'}-${digest}`;
184
+ }
185
+ /**
186
+ * The scope unit for one START of one session.
187
+ *
188
+ * `attempt` is not decoration. A scope the OOM killer took stays loaded in
189
+ * `failed` until something resets it, and `systemd-run --unit=` on a name that
190
+ * is still loaded fails outright — measured: «Unit devbridge-….scope was
191
+ * already loaded or has a fragment file», exit 1, nothing spawned. So a session
192
+ * that is restarted after an OOM (which is exactly when it IS restarted) would
193
+ * be unable to start at all if the name never changed.
194
+ * {@link releaseSessionScope} clears the failed unit and only then lets the
195
+ * counter fall back to 1, so the plain `devbridge-session-<id>.scope` is the
196
+ * normal case and the marker appears only while the old scope is still there.
197
+ */
198
+ export function sessionScopeUnit(id, attempt = 1) {
199
+ const base = `${SESSION_SCOPE_PREFIX}${sanitizeCageId(id)}`;
200
+ return attempt <= 1 ? `${base}.scope` : `${base}-r${attempt}.scope`;
201
+ }
202
+ // ─── capability detection ────────────────────────────────────────────
203
+ /** cgroup v2 unified, from `statfs` — the number behind `stat -fc %T`. */
204
+ const CGROUP2_SUPER_MAGIC = 0x63677270;
205
+ const TMPFS_MAGIC = 0x01021994;
206
+ /** What the live probe must read back out of its own cgroup: 64 MiB, exactly. */
207
+ const PROBE_MEMORY_MAX = 64 * MIB;
208
+ /**
209
+ * The systemd release that added `--expand-environment=`.
210
+ *
211
+ * Below it `systemd-run` answers `unrecognized option` and exits 1 — the probe
212
+ * then reads as «the cage did not hold» on a machine whose cgroups are perfect
213
+ * (Ubuntu 22.04 is 249, Debian 12 and RHEL 9 are 252). See the module header for
214
+ * why the flag is not needed there either.
215
+ */
216
+ export const EXPAND_ENVIRONMENT_MIN_SYSTEMD = 254;
217
+ function statfsType(target) {
218
+ try {
219
+ const type = fs.statfsSync(target).type;
220
+ if (type === CGROUP2_SUPER_MAGIC)
221
+ return 'cgroup2fs';
222
+ if (type === TMPFS_MAGIC)
223
+ return 'tmpfs';
224
+ return `0x${type.toString(16)}`;
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ function onPath(name) {
231
+ const dirs = (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean);
232
+ return dirs.some((dir) => {
233
+ try {
234
+ fs.accessSync(path.join(dir, name), fs.constants.X_OK);
235
+ return true;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ });
241
+ }
242
+ /**
243
+ * Variables the child needs to reach the user's systemd, and nothing else.
244
+ *
245
+ * `systemd-run --user` finds the bus through `XDG_RUNTIME_DIR` (sd-bus falls
246
+ * back to `$XDG_RUNTIME_DIR/bus` when `DBUS_SESSION_BUS_ADDRESS` is unset), and
247
+ * that variable is already on both spawn allowlists — so merging this into a
248
+ * session's environment widens nothing. `DBUS_SESSION_BUS_ADDRESS` is passed on
249
+ * only when the daemon really has one of its own, never synthesised.
250
+ */
251
+ export function cageEnv() {
252
+ const out = {};
253
+ const dir = systemdUserEnv()['XDG_RUNTIME_DIR'];
254
+ if (dir)
255
+ out['XDG_RUNTIME_DIR'] = dir;
256
+ const bus = process.env['DBUS_SESSION_BUS_ADDRESS'];
257
+ if (bus)
258
+ out['DBUS_SESSION_BUS_ADDRESS'] = bus;
259
+ return out;
260
+ }
261
+ function userManagerCgroupControllers() {
262
+ const uid = runnerIdentity().uid;
263
+ if (uid < 0)
264
+ return null;
265
+ const file = path.join('/sys/fs/cgroup/user.slice', `user-${uid}.slice`, `user@${uid}.service`, 'cgroup.controllers');
266
+ try {
267
+ return fs.readFileSync(file, 'utf8').trim().split(/\s+/).filter(Boolean);
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ /**
274
+ * The only honest check there is: build a real cage and ask it what it holds.
275
+ *
276
+ * Reading `cgroup.controllers` and `systemd-run --version` proves the parts are
277
+ * on the machine, not that they work: under a foreign supervisor the files look
278
+ * perfect and the limit is silently never applied. So the probe starts a
279
+ * throwaway scope with `MemoryMax=64M` whose entire job is to `cat` its own
280
+ * `memory.max`, and the answer has to be `67108864` on the nose.
281
+ *
282
+ * `--collect` IS right here, and only here: this scope's cause of death is of no
283
+ * interest, and a probe that left units behind on every daemon start would be a
284
+ * litter machine.
285
+ */
286
+ async function runLiveProbe(options) {
287
+ const unit = `devbridge-cage-probe-${process.pid}`;
288
+ try {
289
+ const { stdout } = await execFileAsync('systemd-run', [
290
+ '--user',
291
+ '--scope',
292
+ '--quiet',
293
+ '--collect',
294
+ `--unit=${unit}`,
295
+ `--slice=${SESSIONS_SLICE}`,
296
+ // Exactly the switches `cageSpawn` will use, this one included: a probe
297
+ // that tests a different command line proves nothing about the real one.
298
+ ...(options.expandEnvironmentFlag ? ['--expand-environment=no'] : []),
299
+ '-p',
300
+ `MemoryMax=${PROBE_MEMORY_MAX}`,
301
+ '-p',
302
+ 'MemorySwapMax=0',
303
+ '-p',
304
+ 'TasksMax=16',
305
+ '-p',
306
+ `CPUWeight=${SESSION_CPU_WEIGHT}`,
307
+ '--',
308
+ '/bin/sh',
309
+ '-c',
310
+ 'cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max',
311
+ ],
312
+ // Generous: the same call took 0.07–2.5 s on an idle machine and 2741 ms
313
+ // under load. A probe that times out costs the machine its cage, so the
314
+ // budget is sized for the worst measurement, not the typical one.
315
+ { timeout: 20_000, env: { ...process.env, ...cageEnv() } });
316
+ return { memoryMax: stdout.trim(), error: null };
317
+ }
318
+ catch (error) {
319
+ // Kept, not swallowed. «Did not run» and «ran and did not hold» are
320
+ // different machines and different repairs, and one sentence about
321
+ // `memory.max` for both sent people to fix cgroups that were fine.
322
+ return { memoryMax: null, error: execErrorText(error) };
323
+ }
324
+ }
325
+ /**
326
+ * The first line `systemd-run` complained with, short enough for a card.
327
+ *
328
+ * `stderr` before `message`: node's own message is `Command failed: systemd-run
329
+ * …` with the whole command line in it, while stderr is the one sentence that
330
+ * names the cause (`unrecognized option '--expand-environment=no'`).
331
+ */
332
+ function execErrorText(error) {
333
+ const own = typeof error === 'object' && error !== null ? { ...error } : {};
334
+ const stderr = typeof own['stderr'] === 'string' ? own['stderr'] : '';
335
+ const text = stderr.trim() || (error instanceof Error ? error.message : String(error));
336
+ const first = text.split('\n').find((line) => line.trim().length > 0) ?? '';
337
+ return first.trim().slice(0, 200);
338
+ }
339
+ /**
340
+ * Which `systemd-run` this is. `systemd 255 (255.4-1ubuntu8.17)` → 255.
341
+ *
342
+ * The version decides one flag and nothing else, so an unreadable answer is not
343
+ * a refusal: it drops the flag, which is what every systemd below 254 does with
344
+ * `--scope` anyway.
345
+ */
346
+ async function readSystemdRunVersion() {
347
+ try {
348
+ const { stdout } = await execFileAsync('systemd-run', ['--version'], { timeout: 10_000 });
349
+ const match = /^systemd\s+(\d+)/m.exec(stdout);
350
+ const major = match?.[1] ? Number(match[1]) : Number.NaN;
351
+ return Number.isFinite(major) ? major : null;
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ }
357
+ async function showMemoryMax(unit) {
358
+ try {
359
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', unit, '-p', 'MemoryMax', '--value'], { timeout: 10_000, env: systemdUserEnv() });
360
+ const value = Number(stdout.trim());
361
+ return Number.isFinite(value) && value > 0 ? value : null;
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ export const defaultCageProbe = {
368
+ cgroupFsType: () => statfsType('/sys/fs/cgroup'),
369
+ systemdRunOnPath: () => onPath('systemd-run'),
370
+ userBusPath: () => {
371
+ const dir = systemdUserEnv()['XDG_RUNTIME_DIR'];
372
+ if (!dir)
373
+ return null;
374
+ return fs.existsSync(path.join(dir, 'bus')) ? dir : null;
375
+ },
376
+ delegatedControllers: userManagerCgroupControllers,
377
+ systemdRunVersion: readSystemdRunVersion,
378
+ probeMemoryMax: runLiveProbe,
379
+ serviceMemoryMax: () => showMemoryMax('devbridge-runner'),
380
+ sessionsSliceMemoryMax: () => showMemoryMax(SESSIONS_SLICE),
381
+ canRenice: () => {
382
+ try {
383
+ // A no-op that still asks the kernel the question: setting our own
384
+ // priority to the value it already has. Allowed for every user, so a
385
+ // failure here means the syscall itself is refused (seccomp, a locked-down
386
+ // container) — which is the one case where even the fallback is a lie.
387
+ os.setPriority(0, os.getPriority(0));
388
+ return true;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ },
394
+ };
395
+ export async function detectSessionCage(probe = defaultCageProbe) {
396
+ const fallback = (reason) => ({
397
+ mode: probe.canRenice() ? 'nice-only' : 'none',
398
+ reason,
399
+ memoryMaxBytes: null,
400
+ serviceMemoryMaxBytes: null,
401
+ sessionsSliceMemoryMaxBytes: null,
402
+ expandEnvironmentFlag: false,
403
+ });
404
+ const fsType = probe.cgroupFsType();
405
+ // Not «contains cgroup2» — exactly cgroup2fs. `tmpfs` at this path is cgroup
406
+ // v1 or the hybrid layout, where `systemd-run --scope` accepts every `-p` and
407
+ // applies none of them.
408
+ if (fsType !== 'cgroup2fs') {
409
+ return fallback(`/sys/fs/cgroup is ${fsType ?? 'unreadable'}, not cgroup2fs (cgroup v1 or hybrid)`);
410
+ }
411
+ if (!probe.systemdRunOnPath())
412
+ return fallback('systemd-run is not on PATH');
413
+ if (!probe.userBusPath()) {
414
+ return fallback('no user systemd bus ($XDG_RUNTIME_DIR/bus) — started outside a user session?');
415
+ }
416
+ const controllers = probe.delegatedControllers();
417
+ // Unreadable is NOT a refusal: the path is only right for a systemd user
418
+ // manager, and the live probe below is the authority in every layout. A file
419
+ // we can read and which says «no memory» is a different matter — that is the
420
+ // delegation this cage is built on, missing.
421
+ if (controllers && !controllers.includes('memory')) {
422
+ return fallback(`the user manager has no delegated memory controller (${controllers.join(' ') || 'none'})`);
423
+ }
424
+ // Decided once, here, and carried in the facts: the probe and every later
425
+ // spawn have to build the SAME command line, or the thing that was proved to
426
+ // work is not the thing that runs.
427
+ const systemdVersion = await probe.systemdRunVersion();
428
+ const expandEnvironmentFlag = systemdVersion !== null && systemdVersion >= EXPAND_ENVIRONMENT_MIN_SYSTEMD;
429
+ const probed = await probe.probeMemoryMax({ expandEnvironmentFlag });
430
+ if (probed.error !== null) {
431
+ // Not «the cage did not hold» — nothing was ever caged. Blaming `memory.max`
432
+ // for a refused command line sent people to fix delegation that was fine.
433
+ return fallback(`the test cage would not start: ${probed.error}`);
434
+ }
435
+ if (probed.memoryMax !== String(PROBE_MEMORY_MAX)) {
436
+ return fallback(`the test cage did not hold: memory.max read back as ${probed.memoryMax ?? 'nothing'}, wanted ${PROBE_MEMORY_MAX}`);
437
+ }
438
+ const serviceMemoryMaxBytes = await probe.serviceMemoryMax();
439
+ const sessionsSliceMemoryMaxBytes = await probe.sessionsSliceMemoryMax();
440
+ return {
441
+ mode: 'scope',
442
+ reason: '',
443
+ // The slice is what actually contains the session; the service is the
444
+ // fallback for a systemd that would not answer about the slice at all.
445
+ memoryMaxBytes: sessionMemoryMaxBytes(sessionsSliceMemoryMaxBytes ?? serviceMemoryMaxBytes),
446
+ serviceMemoryMaxBytes,
447
+ sessionsSliceMemoryMaxBytes,
448
+ expandEnvironmentFlag,
449
+ };
450
+ }
451
+ /**
452
+ * Before the detector has run, nothing is wrapped.
453
+ *
454
+ * `nice-only` rather than `none` because stage 1a is unconditional — the renice
455
+ * happens at every spawn point whatever this module says. The value only ever
456
+ * appears where the daemon has not started (a `verify` from the command line, a
457
+ * test), and it means «no scope», which is the safe answer.
458
+ */
459
+ const UNPROBED = {
460
+ mode: 'nice-only',
461
+ reason: 'not probed yet',
462
+ memoryMaxBytes: null,
463
+ serviceMemoryMaxBytes: null,
464
+ sessionsSliceMemoryMaxBytes: null,
465
+ expandEnvironmentFlag: false,
466
+ };
467
+ let detected = null;
468
+ /**
469
+ * Probe once, at daemon start, and remember the answer.
470
+ *
471
+ * The probe costs a process, so it is not something a spawn can afford to do:
472
+ * three sessions starting at once would mean three throwaway scopes before the
473
+ * first agent got a word out.
474
+ */
475
+ export async function initSessionCage(probe = defaultCageProbe) {
476
+ detected = await detectSessionCage(probe);
477
+ if (detected.mode === 'scope') {
478
+ log.info('session cage: each session gets its own cgroup', {
479
+ slice: SESSIONS_SLICE,
480
+ memoryMaxMB: Math.round((detected.memoryMaxBytes ?? 0) / MIB),
481
+ serviceMemoryMaxMB: detected.serviceMemoryMaxBytes
482
+ ? Math.round(detected.serviceMemoryMaxBytes / MIB)
483
+ : null,
484
+ sessionsSliceMemoryMaxMB: detected.sessionsSliceMemoryMaxBytes
485
+ ? Math.round(detected.sessionsSliceMemoryMaxBytes / MIB)
486
+ : null,
487
+ });
488
+ if (detected.sessionsSliceMemoryMaxBytes === null) {
489
+ // The half of the containment the probe cannot see. Loud, because from
490
+ // here on the machine looks caged and one session at a time is: what is
491
+ // missing is the ceiling over all of them together.
492
+ log.warn('session cage: no collective ceiling on the sessions slice', {
493
+ slice: SESSIONS_SLICE,
494
+ why: 'the drop-in has not been applied — systemd reports MemoryMax=infinity',
495
+ hint: 'run `devbridge-runner doctor --fix`',
496
+ });
497
+ }
498
+ }
499
+ else {
500
+ // Warn, not error. A machine without the cage is the machine we had
501
+ // yesterday; what must never happen is that it looks like the machine we
502
+ // wanted and quietly is not (plan §5.4.2).
503
+ log.warn('session cage: not available — falling back to priority only', {
504
+ mode: detected.mode,
505
+ reason: detected.reason,
506
+ });
507
+ }
508
+ return detected;
509
+ }
510
+ /** What the last {@link initSessionCage} found; the safe default before it ran. */
511
+ export function sessionCage() {
512
+ return detected ?? UNPROBED;
513
+ }
514
+ /**
515
+ * Live scope per cage id, so a restart cannot collide with a scope that the OOM
516
+ * killer left behind. Cleared by {@link releaseSessionScope} once the old unit
517
+ * is provably gone — see {@link sessionScopeUnit}.
518
+ */
519
+ const attempts = new Map();
520
+ /**
521
+ * Wrap a command in its session's cage, or hand it back untouched.
522
+ *
523
+ * Untouched is the honest answer on every machine where the cage was not proved
524
+ * to work: `systemd-run` would accept the flags there and apply nothing, and a
525
+ * session that believes it is contained when it is not is worse than one that
526
+ * knows it is not.
527
+ *
528
+ * Pipes, exit codes, signals and stdin EOF behave exactly as with a direct
529
+ * spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
530
+ * spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
531
+ * (`pgid === child.pid` still holds).
532
+ */
533
+ export function cageSpawn(input) {
534
+ const facts = sessionCage();
535
+ if (facts.mode !== 'scope' || facts.memoryMaxBytes === null) {
536
+ return { command: input.command, args: input.args, env: {}, unit: null };
537
+ }
538
+ const attempt = (attempts.get(input.id) ?? 0) + 1;
539
+ attempts.set(input.id, attempt);
540
+ const unit = sessionScopeUnit(input.id, attempt);
541
+ return {
542
+ command: 'systemd-run',
543
+ args: [
544
+ '--user',
545
+ '--scope',
546
+ '--quiet',
547
+ `--unit=${unit}`,
548
+ `--slice=${SESSIONS_SLICE}`,
549
+ // The agent's prompt travels in these arguments and prompts contain `$`.
550
+ // Only where systemd knows the switch — below 254 it is a hard error and
551
+ // `--scope` does no expansion anyway (module header, MAJOR-2).
552
+ ...(facts.expandEnvironmentFlag ? ['--expand-environment=no'] : []),
553
+ '-p',
554
+ `MemoryMax=${facts.memoryMaxBytes}`,
555
+ // THE line. Without it there is no cage at all: `MemoryMax` bounds
556
+ // resident memory and lets the rest fall into the host's swap, which is
557
+ // how a 200 MB cage allocated 2 GB and took the machine's swap with it.
558
+ // Never remove, never make conditional.
559
+ '-p',
560
+ 'MemorySwapMax=0',
561
+ '-p',
562
+ `TasksMax=${SESSION_TASKS_MAX}`,
563
+ '-p',
564
+ `CPUWeight=${SESSION_CPU_WEIGHT}`,
565
+ '--',
566
+ input.command,
567
+ ...input.args,
568
+ ],
569
+ env: cageEnv(),
570
+ unit,
571
+ };
572
+ }
573
+ /**
574
+ * Did this process die in the window before `systemd-run` handed over?
575
+ *
576
+ * Starting a scope took 0.07–2.5 s in the spike, and 2741 ms on a loaded
577
+ * machine. A stop inside that window signals `systemd-run` itself, before the
578
+ * `exec`, and the parent sees `{code: null, signal: 'SIGTERM'}` with nothing on
579
+ * stdout at all. That is «the session never started», not «the agent died
580
+ * silently» — told apart here so no supervisor has to infer it from an empty
581
+ * buffer.
582
+ */
583
+ export function killedBeforeExec(info) {
584
+ return info.caged && !info.sawOutput && info.code === null && info.signal === 'SIGTERM';
585
+ }
586
+ const realSystemctl = async (args) => {
587
+ const { stdout, stderr } = await execFileAsync('systemctl', ['--user', ...args], {
588
+ timeout: 15_000,
589
+ env: systemdUserEnv(),
590
+ });
591
+ return { stdout, stderr };
592
+ };
593
+ function showValue(stdout, property) {
594
+ const line = stdout
595
+ .split('\n')
596
+ .map((l) => l.trim())
597
+ .find((l) => l.startsWith(`${property}=`));
598
+ return line ? line.slice(property.length + 1) : null;
599
+ }
600
+ /**
601
+ * Read why the scope ended, say so, and then let systemd forget it.
602
+ *
603
+ * This is the whole reason `--collect` is not passed. The order is fixed: the
604
+ * process has already exited, `Result` is read, the reason is logged, and only
605
+ * then is the unit reset — because `reset-failed` is what deletes the answer.
606
+ *
607
+ * Failure here is never fatal: a unit that could not be reset is swept at the
608
+ * next daemon start, and the counter in {@link cageSpawn} keeps the session
609
+ * startable in the meantime.
610
+ */
611
+ export async function releaseSessionScope(unit, id, systemctl = realSystemctl) {
612
+ if (!unit)
613
+ return null;
614
+ let result = null;
615
+ try {
616
+ const { stdout } = await systemctl(['show', unit, '-p', 'Result']);
617
+ result = showValue(stdout, 'Result');
618
+ }
619
+ catch {
620
+ // Already collected — a clean exit takes its scope with it.
621
+ }
622
+ if (result === 'oom-kill') {
623
+ log.warn('session cage: the kernel killed this session for going over its memory ceiling', {
624
+ unit,
625
+ memoryMaxMB: Math.round((sessionCage().memoryMaxBytes ?? 0) / MIB),
626
+ });
627
+ }
628
+ let forgotten = false;
629
+ try {
630
+ await systemctl(['reset-failed', unit]);
631
+ forgotten = true;
632
+ }
633
+ catch {
634
+ // Nothing to reset is the normal case — but so is «the bus was not there»,
635
+ // and those two look identical from here.
636
+ }
637
+ // Only when the unit is provably gone. Clearing the counter after a FAILED
638
+ // `reset-failed` hands the next start of this session the same unit name,
639
+ // which systemd answers with «was already loaded or has a fragment file» and
640
+ // spawns nothing — the exact failure the counter exists to prevent
641
+ // (QA-2026-09-07 MINOR-11).
642
+ if (id !== undefined && forgotten)
643
+ attempts.delete(id);
644
+ return result;
645
+ }
646
+ /** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
647
+ export async function listSessionScopeUnits(systemctl = realSystemctl) {
648
+ let stdout;
649
+ try {
650
+ ({ stdout } = await systemctl([
651
+ 'list-units',
652
+ '--all',
653
+ '--plain',
654
+ '--no-legend',
655
+ `${SESSION_SCOPE_PREFIX}*.scope`,
656
+ ]));
657
+ }
658
+ catch {
659
+ return [];
660
+ }
661
+ return stdout
662
+ .split('\n')
663
+ .map((line) => line.trim().split(/\s+/)[0] ?? '')
664
+ .filter((unit) => unit.startsWith(SESSION_SCOPE_PREFIX) && unit.endsWith('.scope'));
665
+ }
666
+ /** What `doctor` prints for each caged session. */
667
+ export async function listSessionScopes(systemctl = realSystemctl) {
668
+ const units = await listSessionScopeUnits(systemctl);
669
+ const out = [];
670
+ for (const unit of units) {
671
+ let stdout = '';
672
+ try {
673
+ ({ stdout } = await systemctl([
674
+ 'show',
675
+ unit,
676
+ '-p',
677
+ 'MemoryMax',
678
+ '-p',
679
+ 'MemoryCurrent',
680
+ '-p',
681
+ 'TasksCurrent',
682
+ '-p',
683
+ 'Result',
684
+ '-p',
685
+ 'ActiveState',
686
+ ]));
687
+ }
688
+ catch {
689
+ // Gone between the list and the read — report the name and nothing else.
690
+ }
691
+ const num = (property) => {
692
+ const value = Number(showValue(stdout, property));
693
+ return Number.isFinite(value) ? value : null;
694
+ };
695
+ out.push({
696
+ unit,
697
+ memoryMaxBytes: num('MemoryMax'),
698
+ memoryCurrentBytes: num('MemoryCurrent'),
699
+ tasksCurrent: num('TasksCurrent'),
700
+ result: showValue(stdout, 'Result'),
701
+ activeState: showValue(stdout, 'ActiveState'),
702
+ });
703
+ }
704
+ return out;
705
+ }
706
+ /**
707
+ * Stop and forget every session scope that has no session behind it.
708
+ *
709
+ * At daemon start that is all of them by definition, and it is the point: a
710
+ * scope outlives a killed daemon carrying the whole process tree with it, which
711
+ * is the shape of the 10 h 51 min `ugrep` of 16.08. Stopping the scope takes the
712
+ * tree, not just the process we happened to know about.
713
+ *
714
+ * `liveIds` exists so the same sweep can run later without killing work in
715
+ * progress; only the CURRENT scope of a live session is spared, because an
716
+ * earlier attempt of the same session is exactly the leftover we are here for.
717
+ */
718
+ export async function sweepOrphanSessionScopes(liveIds = [], systemctl = realSystemctl) {
719
+ const spared = new Set();
720
+ for (const id of liveIds)
721
+ spared.add(sessionScopeUnit(id, attempts.get(id) ?? 1));
722
+ const removed = [];
723
+ for (const unit of await listSessionScopeUnits(systemctl)) {
724
+ if (spared.has(unit))
725
+ continue;
726
+ try {
727
+ await systemctl(['stop', unit]);
728
+ }
729
+ catch {
730
+ // A failed scope has nothing to stop; `reset-failed` below is the part
731
+ // that matters for it.
732
+ }
733
+ try {
734
+ await systemctl(['reset-failed', unit]);
735
+ }
736
+ catch {
737
+ // Already gone.
738
+ }
739
+ removed.push(unit);
740
+ }
741
+ if (removed.length > 0) {
742
+ log.warn('session cage: removed scopes left behind by a previous run', {
743
+ count: removed.length,
744
+ units: removed.slice(0, 10),
745
+ });
746
+ }
747
+ return removed;
748
+ }
749
+ /**
750
+ * The slice names and the CPU share — re-exported so a caller that reasons about
751
+ * the cage needs one import, while the values themselves stay next to the
752
+ * drop-in that writes them (`service-unit.ts`).
753
+ */
754
+ export { SESSIONS_SLICE, DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT };
755
+ //# sourceMappingURL=session-cage.js.map