@bridge4dev/runner 0.53.0 → 0.55.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,508 @@
1
+ import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
2
+ /**
3
+ * The honest share of one session — where the BRAKE starts, not where it dies.
4
+ *
5
+ * Until #387 this was `MemoryMax`, the kill line. It is the same number and the
6
+ * same reasoning, moved one step down the ladder: above it the kernel reclaims
7
+ * and throttles (`MemoryHigh`), and the kill line is {@link sessionMemoryMaxBytes}.
8
+ *
9
+ * `min(2.5 GiB, service ceiling / 2)` — §3 of
10
+ * `docs/plans/shipped/agent-sessions-host-resources.md`. Both halves matter:
11
+ *
12
+ * - the absolute number is sized against the measurement, not against a round
13
+ * figure. The heaviest ordinary thing a session runs is a workspace
14
+ * `pnpm typecheck`, measured at a 1571 MB peak; three live `claude`
15
+ * processes are 512/577/646 MB. 2.5 GiB leaves ~60 % headroom over the
16
+ * worst measured case, which is the margin between "kills a runaway" and
17
+ * "kills the work";
18
+ * - the half-of-the-service half is what keeps ONE session from being able to
19
+ * fill the ceiling that covers all of them. On this machine the service
20
+ * ceiling is 7680M, so half is 3.75 GiB and the absolute number wins.
21
+ *
22
+ * The service ceiling is read off systemd (`systemctl --user show
23
+ * devbridge-runner -p MemoryMax`) rather than recomputed here, because what
24
+ * protects the machine is what systemd has in force, not what the drop-in on
25
+ * disk says — those two have already drifted apart once (§5.5 of the plan).
26
+ */
27
+ export declare const SESSION_MEMORY_HIGH_ABSOLUTE_BYTES: number;
28
+ /**
29
+ * Processes and threads per session.
30
+ *
31
+ * An agent forks a lot (a monorepo build is hundreds of short-lived processes),
32
+ * so this is not a memory limit in disguise — it is the fork-bomb stop. 512 is
33
+ * far above anything measured on a real session and far below the 8192 the
34
+ * whole service gets.
35
+ */
36
+ export declare const SESSION_TASKS_MAX = 512;
37
+ /** Prefix of every scope this module creates. The sweeper matches on it. */
38
+ export declare const SESSION_SCOPE_PREFIX = "devbridge-session-";
39
+ /**
40
+ * How the sessions on this machine are contained.
41
+ *
42
+ * `scope` — a cgroup per session with a memory ceiling (this module).
43
+ * `nice-only` — cgroup v2 is not usable here; stage 1a's `nice(2)` is all there
44
+ * is. Correct on cgroup v1/hybrid (Ubuntu 20.04, CentOS 7/8) and
45
+ * under a foreign supervisor.
46
+ * `none` — not even that: the kernel refuses `setpriority` as well.
47
+ *
48
+ * Travels in `hello` as `machine.sessionCage` so the dashboard can SAY that the
49
+ * containment did not take on a given machine, instead of leaving it to be
50
+ * guessed (plan §5.4.3).
51
+ */
52
+ export type SessionCageMode = 'scope' | 'nice-only' | 'none';
53
+ export interface SessionCageFacts {
54
+ mode: SessionCageMode;
55
+ /** Why not `scope`. Empty string when it is. */
56
+ reason: string;
57
+ /**
58
+ * The brake — `MemoryHigh` on the scope: past it the kernel reclaims and slows
59
+ * the session down instead of killing anything. Null unless `mode === 'scope'`.
60
+ */
61
+ memoryHighBytes: number | null;
62
+ /**
63
+ * The wall — `MemoryMax` on the scope, far above the brake. Reaching it costs
64
+ * the session its hungriest process (and, without `oomContinue`, the session).
65
+ * Null unless `mode === 'scope'`.
66
+ */
67
+ memoryMaxBytes: number | null;
68
+ /** Swap one session may use, bytes. 0 = none. Null unless `mode === 'scope'`. */
69
+ swapMaxBytes: number | null;
70
+ /**
71
+ * `OOMPolicy=continue` is on the scope: a kill inside it takes one process,
72
+ * not the session. False on a systemd whose scopes do not take the setting
73
+ * (Ubuntu 22.04 is 249, and there the whole scope still stops on any kill —
74
+ * which is why the wall has to be far away on every machine, not only here).
75
+ */
76
+ oomContinue: boolean;
77
+ /** The service ceiling systemd has in force, as measured. Null = infinity. */
78
+ serviceMemoryMaxBytes: number | null;
79
+ /**
80
+ * The ceiling over ALL sessions together, as systemd has it in force on
81
+ * `devbridge-sessions.slice`. Null means there is none.
82
+ *
83
+ * Read rather than assumed, because the live probe proves only the PERSONAL
84
+ * ceiling: a `MemoryMax` on a scope applies whatever the parent slice says. A
85
+ * machine whose `daemon-reload` never happened therefore passed the probe,
86
+ * announced `sessionCage: 'scope'` and `limitsCurrent: true`, and had three
87
+ * sessions of 2.5 GB each over a slice at `MemoryMax=infinity` — with the card
88
+ * saying «Sessions capped» (QA-2026-09-07 MAJOR-3).
89
+ */
90
+ sessionsSliceMemoryMaxBytes: number | null;
91
+ /**
92
+ * Is `--expand-environment=no` on the command line? systemd ≥ 254 only — see
93
+ * the module header. False is not a downgrade below 254: expansion in
94
+ * `--scope` was off by default there anyway.
95
+ */
96
+ expandEnvironmentFlag: boolean;
97
+ }
98
+ /**
99
+ * The FLOOR the plan's formula does not have, and the reason it needs one.
100
+ *
101
+ * `min(2.5 GiB, ceiling / 2)` is right on a big machine and wrong on a small
102
+ * one: at the 2 GiB ceiling `memoryPolicy` gives the smallest supported box,
103
+ * half is 1 GiB — BELOW the 1571 MB a workspace `pnpm typecheck` was measured
104
+ * at. The cage would then kill honest work on exactly the machines least able
105
+ * to afford a failed run, and it would look like a flaky agent rather than a
106
+ * limit. A containment that fires on correct work is not containment.
107
+ *
108
+ * So the floor is set above the worst measured ordinary peak, not at a round
109
+ * number.
110
+ *
111
+ * It is a floor and not a switch: below ~2.4 GB of RAM the containing ceiling
112
+ * itself drops under this number, and there the last clamp in
113
+ * {@link sessionMemoryMaxBytes} wins and a session is capped below the floor.
114
+ * That is not the cage killing honest work — the slice would have killed the
115
+ * same `pnpm typecheck` a moment later anyway, because it holds the same
116
+ * number — so removing `MemoryMax` from the scope there would buy nothing and
117
+ * cost the one thing it does buy: the runaway dying instead of its neighbours
118
+ * (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
119
+ */
120
+ export declare const SESSION_MEMORY_HIGH_MIN_BYTES: number;
121
+ /**
122
+ * The wall, when nothing on the machine can say where it should be.
123
+ *
124
+ * Every real path reads the wall off systemd (the slice, else the service) or
125
+ * measures it (`memoryPolicy`). This is the last resort for a machine that gave
126
+ * neither — twice the honest share, so that even blind the wall sits above the
127
+ * brake by a margin an honest build fits into, and a runaway still meets one.
128
+ */
129
+ export declare const SESSION_MEMORY_WALL_FALLBACK_FACTOR = 2;
130
+ /**
131
+ * The pool is cut into this many honest shares. Three, because that is what
132
+ * `maxSessions` defaults to on a dev server, so three sessions can sit at their
133
+ * brakes without the pool itself overflowing — and one runaway then meets its
134
+ * own wall (`ceiling − share`) while a neighbour on its share still fits.
135
+ */
136
+ export declare const SESSION_SHARE_DIVISOR = 3;
137
+ /**
138
+ * On a machine too small to leave a share of room, the brake is this fraction
139
+ * of the wall. The same 0.8 `memoryPolicy` puts between the service's own
140
+ * `MemoryHigh` and `MemoryMax`, for the same reason: a band, however narrow,
141
+ * beats a bare kill line.
142
+ */
143
+ export declare const SESSION_BRAKE_OF_WALL = 0.8;
144
+ /**
145
+ * `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
146
+ *
147
+ * Three bounds, each for its own reason — see
148
+ * {@link SESSION_MEMORY_MAX_ABSOLUTE_BYTES} for the upper two and
149
+ * {@link SESSION_MEMORY_MIN_BYTES} for the lower one. The last clamp matters
150
+ * on a tiny machine: a per-session number ABOVE the slice's own ceiling is not
151
+ * a limit at all, it is a bigger number that never applies, and printing it in
152
+ * `doctor` would be a straight lie about what is in force.
153
+ *
154
+ * The argument is the ceiling of the cgroup that CONTAINS the session, and
155
+ * since 0.54.0 that is `devbridge-sessions.slice` and not the service — the
156
+ * caller reads the slice and falls back to the service only when systemd has
157
+ * nothing to say about the slice. The two are the same number by construction
158
+ * today; they come apart the moment a drop-in fails to apply or is edited by
159
+ * hand, and then «never more than the slice» has to still be true
160
+ * (QA-2026-09-07 MINOR-5).
161
+ */
162
+ export declare function sessionMemoryHighBytes(containingMemoryMaxBytes: number | null): number;
163
+ /** The brake and the wall, decided together — see {@link sessionMemoryLadder}. */
164
+ export interface SessionMemoryLadder {
165
+ highBytes: number;
166
+ maxBytes: number;
167
+ }
168
+ /**
169
+ * The two numbers of the cage, computed in ONE place because each is wrong
170
+ * without the other (QA of #387 found both halves broken separately).
171
+ *
172
+ * ```
173
+ * brake = max(containing / 3, 2 GiB) capped by the containing ceiling
174
+ * wall = containing − brake never at or below the brake
175
+ * ```
176
+ *
177
+ * **Why a third, and no upper cap.** The owner's second note on #387: «ночью
178
+ * машина свободна (~9 ГБ), а сессии всё равно нельзя выйти за 2.5 ГБ… граница
179
+ * должна смотреть на то, сколько реально свободно». A fixed 2.5 GiB was right
180
+ * as a KILL line and is wrong as a brake: on a big machine it throttles honest
181
+ * work that the machine could have absorbed. A third of the pool is the largest
182
+ * share that still lets three sessions sit at their brakes inside the ceiling,
183
+ * which is what `maxSessions` defaults to. The 2 GiB floor stays — it is sized
184
+ * over the 1571 MB measured peak of a workspace `pnpm typecheck`, and a dev
185
+ * server that cannot run one of those is not a dev server.
186
+ *
187
+ * **Why the wall is `containing − brake` and not the ceiling itself.** 0.55.0's
188
+ * first shape put the wall AT the slice ceiling, and that is not a per-session
189
+ * wall at all: the kernel charges a leaf and every ancestor, so with any
190
+ * neighbour the SLICE overflows first, and then the victim is chosen across the
191
+ * whole slice. Measured on this host (a 300 MB slice, two 250 MB scopes): the
192
+ * scope that crept up survived to the end, and the **neighbour holding 200 MB
193
+ * under its own wall was killed** — slice `max 19, oom 1, oom_kill 1`, victim
194
+ * `max 0, oom 0, oom_kill 1`. Leaving one honest share of room under the
195
+ * ceiling is what makes the runaway meet its OWN wall first while a neighbour
196
+ * on its share still fits.
197
+ *
198
+ * **The tiny machine.** When the ceiling is so low that `containing − brake`
199
+ * would land at or under the brake, there is no room for isolation at all: the
200
+ * wall becomes the ceiling and the BRAKE is lowered to 80 % of it, so that
201
+ * there is still a braking band (`memoryPolicy` uses the same 0.8 for the
202
+ * service). Without this, a 4 GB machine got `MemoryHigh == MemoryMax` — the
203
+ * 0.54.0 kill line back in place, with the card claiming a brake.
204
+ */
205
+ export declare function sessionMemoryLadder(containingMemoryMaxBytes: number | null, measuredCeilingBytes?: number | null): SessionMemoryLadder;
206
+ /**
207
+ * The wall — `MemoryMax` on the scope — and why it is the WHOLE containing
208
+ * ceiling and not a fraction of it (#387).
209
+ *
210
+ * The owner's rule: the wall is protection against a runaway, not the norm of
211
+ * work. Where the norm lives is {@link sessionMemoryHighBytes}; the wall has to
212
+ * be far enough above it that honest work never touches it, and «far» on a dev
213
+ * server means «what the machine can actually spare» — which is exactly the
214
+ * number the slice ceiling already is (`memoryPolicy`: `MemAvailable` plus what
215
+ * we hold, minus a reserve). Giving a session less than that is the 2.5 GiB
216
+ * mistake with a different number.
217
+ *
218
+ * With `OOMPolicy=continue` on the scope, reaching the wall costs a runaway its
219
+ * fattest process and nothing else; the neighbours are protected by the slice,
220
+ * which holds the same number over all of them together.
221
+ *
222
+ * `containing` is what systemd has in force on the slice (else the service);
223
+ * `measured` is what the policy would write there; the factor is the last
224
+ * resort. Never below the brake — a wall under the brake is a kill line again.
225
+ */
226
+ export declare function sessionMemoryMaxBytes(containingMemoryMaxBytes: number | null, measuredCeilingBytes?: number | null): number;
227
+ /**
228
+ * Swap one session may push into — the reason the brake slows instead of stalls.
229
+ *
230
+ * Half of what the slice may use, the same «no single session takes the
231
+ * collective allowance» rule as the memory share. When systemd reports no
232
+ * bound on the slice (the drop-in never landed, MAJOR-3 shape) the share is cut
233
+ * from the machine's `SwapTotal` directly, with the same fraction the slice
234
+ * would have had, so the per-scope line is a real bound on its own. A machine
235
+ * with no swap gets 0, which is exactly 0.54.0's line — and exactly right:
236
+ * there is nothing to share.
237
+ */
238
+ export declare function sessionSwapMaxBytes(sliceSwapMaxBytes: number | null, hostSwapTotalBytes: number | null): number;
239
+ /**
240
+ * The session id as systemd will accept it.
241
+ *
242
+ * Ids are UUIDs today and arbitrary text tomorrow — they arrive from the API,
243
+ * and `verify` keys its cage by a run id. systemd unit names take only
244
+ * `[A-Za-z0-9:_.-]`, so everything else is folded to `-`. Folding is lossy, and
245
+ * lossy is how two different sessions end up fighting over one scope, so
246
+ * anything that had to be changed or cut also gets eight hex digits of the
247
+ * original — deterministic, so the same session always names the same unit and
248
+ * the sweeper can still recognise it.
249
+ */
250
+ export declare function sanitizeCageId(id: string): string;
251
+ /**
252
+ * The scope unit for one START of one session.
253
+ *
254
+ * `attempt` is not decoration. A scope the OOM killer took stays loaded in
255
+ * `failed` until something resets it, and `systemd-run --unit=` on a name that
256
+ * is still loaded fails outright — measured: «Unit devbridge-….scope was
257
+ * already loaded or has a fragment file», exit 1, nothing spawned. So a session
258
+ * that is restarted after an OOM (which is exactly when it IS restarted) would
259
+ * be unable to start at all if the name never changed.
260
+ * {@link releaseSessionScope} clears the failed unit and only then lets the
261
+ * counter fall back to 1, so the plain `devbridge-session-<id>.scope` is the
262
+ * normal case and the marker appears only while the old scope is still there.
263
+ */
264
+ export declare function sessionScopeUnit(id: string, attempt?: number): string;
265
+ /**
266
+ * The systemd release that added `--expand-environment=`.
267
+ *
268
+ * Below it `systemd-run` answers `unrecognized option` and exits 1 — the probe
269
+ * then reads as «the cage did not hold» on a machine whose cgroups are perfect
270
+ * (Ubuntu 22.04 is 249, Debian 12 and RHEL 9 are 252). See the module header for
271
+ * why the flag is not needed there either.
272
+ */
273
+ export declare const EXPAND_ENVIRONMENT_MIN_SYSTEMD = 254;
274
+ /** What one run of the throwaway scope answered, and why it did not run. */
275
+ export interface CageProbeResult {
276
+ /** `memory.max` as the scope read it back. Null when the scope never ran. */
277
+ memoryMax: string | null;
278
+ /** `systemd-run`'s own first line of complaint. Null when it ran. */
279
+ error: string | null;
280
+ }
281
+ /** Everything about this machine the detector needs, so a test can lie about all of it. */
282
+ export interface CageProbe {
283
+ /** `cgroup2fs`, `tmpfs`, or whatever else is mounted at `/sys/fs/cgroup`. */
284
+ cgroupFsType: () => string | null;
285
+ systemdRunOnPath: () => boolean;
286
+ /** Major version of `systemd-run`, or null when it would not say. */
287
+ systemdRunVersion: () => Promise<number | null>;
288
+ /** `$XDG_RUNTIME_DIR` when `$XDG_RUNTIME_DIR/bus` exists, else null. */
289
+ userBusPath: () => string | null;
290
+ /** Controllers delegated to this user's manager, or null when unreadable. */
291
+ delegatedControllers: () => string[] | null;
292
+ /** The one-shot scope that reads its own `memory.max`, built like a real one. */
293
+ probeMemoryMax: (options: {
294
+ expandEnvironmentFlag: boolean;
295
+ oomPolicyFlag: boolean;
296
+ }) => Promise<CageProbeResult>;
297
+ /** `MemoryMax` of the runner service in bytes; null for `infinity`. */
298
+ serviceMemoryMax: () => Promise<number | null>;
299
+ /** `MemoryMax` in force on `devbridge-sessions.slice`; null for `infinity`. */
300
+ sessionsSliceMemoryMax: () => Promise<number | null>;
301
+ /** `MemorySwapMax` in force on the slice; 0 is a real answer, null = `infinity`/unknown. */
302
+ sessionsSliceSwapMax: () => Promise<number | null>;
303
+ /** `SwapTotal` of the machine, or null where `/proc/meminfo` will not say. */
304
+ hostSwapTotalBytes: () => number | null;
305
+ /** The ceiling `memoryPolicy` would write today, or null on an unmeasurable machine. */
306
+ machineCeilingBytes: () => number | null;
307
+ /** Can this kernel renice at all — the fallback's own precondition. */
308
+ canRenice: () => boolean;
309
+ }
310
+ /**
311
+ * Variables the child needs to reach the user's systemd, and nothing else.
312
+ *
313
+ * `systemd-run --user` finds the bus through `XDG_RUNTIME_DIR` (sd-bus falls
314
+ * back to `$XDG_RUNTIME_DIR/bus` when `DBUS_SESSION_BUS_ADDRESS` is unset), and
315
+ * that variable is already on both spawn allowlists — so merging this into a
316
+ * session's environment widens nothing. `DBUS_SESSION_BUS_ADDRESS` is passed on
317
+ * only when the daemon really has one of its own, never synthesised.
318
+ */
319
+ export declare function cageEnv(): Record<string, string>;
320
+ export declare const defaultCageProbe: CageProbe;
321
+ export declare function detectSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
322
+ /**
323
+ * Did `systemd-run` refuse the command line BECAUSE of `OOMPolicy=`?
324
+ *
325
+ * systemd's wording for a property a unit type does not take is «Unknown
326
+ * assignment: OOMPolicy=continue» (`bus_append_unit_property_assignment`); a
327
+ * refusal that does not name the property is some other machine's problem and
328
+ * must not be retried into a cage with a weaker policy.
329
+ */
330
+ export declare function refusesOomPolicy(error: string): boolean;
331
+ /**
332
+ * Probe once, at daemon start, and remember the answer.
333
+ *
334
+ * The probe costs a process, so it is not something a spawn can afford to do:
335
+ * three sessions starting at once would mean three throwaway scopes before the
336
+ * first agent got a word out.
337
+ */
338
+ export declare function initSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
339
+ /** What the last {@link initSessionCage} found; the safe default before it ran. */
340
+ export declare function sessionCage(): SessionCageFacts;
341
+ export interface CagedSpawn {
342
+ command: string;
343
+ args: string[];
344
+ /**
345
+ * Variables to MERGE into the child's environment (`{...yours, ...cage.env}`).
346
+ * Empty when nothing was wrapped.
347
+ */
348
+ env: Record<string, string>;
349
+ /** The scope unit this start will live in, or null when nothing was wrapped. */
350
+ unit: string | null;
351
+ }
352
+ /** The scope a live caged id runs in, or null when it is not caged (or gone). */
353
+ export declare function sessionScopeUnitOf(id: string): string | null;
354
+ /**
355
+ * Wrap a command in its session's cage, or hand it back untouched.
356
+ *
357
+ * Untouched is the honest answer on every machine where the cage was not proved
358
+ * to work: `systemd-run` would accept the flags there and apply nothing, and a
359
+ * session that believes it is contained when it is not is worse than one that
360
+ * knows it is not.
361
+ *
362
+ * Pipes, exit codes, signals and stdin EOF behave exactly as with a direct
363
+ * spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
364
+ * spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
365
+ * (`pgid === child.pid` still holds).
366
+ */
367
+ export declare function cageSpawn(input: {
368
+ id: string;
369
+ command: string;
370
+ args: string[];
371
+ }): CagedSpawn;
372
+ /**
373
+ * Did this process die in the window before `systemd-run` handed over?
374
+ *
375
+ * Starting a scope took 0.07–2.5 s in the spike, and 2741 ms on a loaded
376
+ * machine. A stop inside that window signals `systemd-run` itself, before the
377
+ * `exec`, and the parent sees `{code: null, signal: 'SIGTERM'}` with nothing on
378
+ * stdout at all. That is «the session never started», not «the agent died
379
+ * silently» — told apart here so no supervisor has to infer it from an empty
380
+ * buffer.
381
+ */
382
+ export declare function killedBeforeExec(info: {
383
+ code: number | null;
384
+ signal: string | null;
385
+ sawOutput: boolean;
386
+ caged: boolean;
387
+ }): boolean;
388
+ /** The counters of one session's cgroup, read straight off the filesystem. */
389
+ export interface ScopeMemoryStatus {
390
+ /** `memory.current`. */
391
+ currentBytes: number;
392
+ /** `memory.high` — null for `max`. */
393
+ highBytes: number | null;
394
+ /** `memory.max` — null for `max`. */
395
+ maxBytes: number | null;
396
+ /** `memory.swap.current`, 0 where the file is missing. */
397
+ swapCurrentBytes: number;
398
+ /** `memory.events` `high`: how many times the brake engaged. Cumulative. */
399
+ highEvents: number;
400
+ /** `memory.events` `oom_kill`: processes the kernel killed in here. Cumulative. */
401
+ oomKills: number;
402
+ /**
403
+ * `memory.events` `oom`: how many times THIS cgroup's own limit was reached
404
+ * and an allocation was about to fail.
405
+ *
406
+ * The discriminator between «this session hit its wall» and «the pool over
407
+ * all sessions ran out and the kernel picked a victim anywhere in it».
408
+ * Measured on this host: the innocent neighbour that was killed read
409
+ * `max 0, oom 0, oom_kill 1`, while the slice read `max 19, oom 1,
410
+ * oom_kill 1`. Without it every kill was reported to the session it landed
411
+ * on as «you went over your ceiling», which for a neighbour is false.
412
+ */
413
+ ownLimitOom: number;
414
+ }
415
+ /**
416
+ * The pure half of {@link readScopeMemoryStatus}: the four files' text in, the
417
+ * status out. `memory.swap.current` is optional (no swap controller, cgroup v2
418
+ * without swap accounting); the rest are not.
419
+ */
420
+ export declare function parseScopeMemoryStatus(files: {
421
+ current: string;
422
+ high: string;
423
+ max: string;
424
+ events: string;
425
+ swapCurrent?: string;
426
+ }): ScopeMemoryStatus | null;
427
+ /**
428
+ * What one live session's cgroup holds and has been through. Null when the
429
+ * cgroup is not there (the scope ended, or this machine has no cage).
430
+ *
431
+ * Filesystem, not `systemctl show`: this runs on the supervisor's 30 s tick for
432
+ * every live session, and the bus is the thing that took 2.7 s under load.
433
+ */
434
+ export declare function readScopeMemoryStatus(unit: string, readFile?: (p: string) => string): ScopeMemoryStatus | null;
435
+ export declare function markScopeOomKillsSeen(id: string, oomKills: number): void;
436
+ /**
437
+ * Snapshot the cgroup's verdict before systemd can take it away.
438
+ *
439
+ * Exported for the test seam only (`readStatus`): the real caller passes
440
+ * nothing and reads the live cgroup.
441
+ */
442
+ export declare function rememberDeath(id: string, unit: string, readStatus?: (unit: string) => ScopeMemoryStatus | null): void;
443
+ /**
444
+ * One sentence for the error the person reads, when the kernel had a hand in
445
+ * this death — or null when it had not. Consumed: a session restarted after an
446
+ * OOM must not carry the old sentence into its next, unrelated failure.
447
+ *
448
+ * The text that reached people in 0.54.0 was «exited with code 143» once and
449
+ * «terminated by signal SIGKILL» the next time, for the same cause, and neither
450
+ * said the word memory. This is that word.
451
+ */
452
+ export declare function explainMemoryDeath(id: string): string | null;
453
+ /** So the sweeper and the reader can be tested without a machine under them. */
454
+ export type Systemctl = (args: string[]) => Promise<{
455
+ stdout: string;
456
+ stderr: string;
457
+ }>;
458
+ /**
459
+ * Read why the scope ended, say so, and then let systemd forget it.
460
+ *
461
+ * This is the whole reason `--collect` is not passed. The order is fixed: the
462
+ * process has already exited, `Result` is read, the reason is logged, and only
463
+ * then is the unit reset — because `reset-failed` is what deletes the answer.
464
+ *
465
+ * Failure here is never fatal: a unit that could not be reset is swept at the
466
+ * next daemon start, and the counter in {@link cageSpawn} keeps the session
467
+ * startable in the meantime.
468
+ */
469
+ export declare function releaseSessionScope(unit: string | null, id?: string, systemctl?: Systemctl): Promise<string | null>;
470
+ /**
471
+ * The sentence for a death, once the verdict is in. Null when the kernel had no
472
+ * hand in it. Waits for the release of this session's scope, but never longer
473
+ * than `capMs`: an answer that arrives after the person has read the error is
474
+ * worth nothing, and a hung `systemctl` must not hold a failing session open.
475
+ */
476
+ export declare function memoryDeathSentence(id: string, capMs?: number): Promise<string | null>;
477
+ /** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
478
+ export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
479
+ export interface SessionScopeInfo {
480
+ unit: string;
481
+ memoryMaxBytes: number | null;
482
+ memoryCurrentBytes: number | null;
483
+ tasksCurrent: number | null;
484
+ result: string | null;
485
+ activeState: string | null;
486
+ }
487
+ /** What `doctor` prints for each caged session. */
488
+ export declare function listSessionScopes(systemctl?: Systemctl): Promise<SessionScopeInfo[]>;
489
+ /**
490
+ * Stop and forget every session scope that has no session behind it.
491
+ *
492
+ * At daemon start that is all of them by definition, and it is the point: a
493
+ * scope outlives a killed daemon carrying the whole process tree with it, which
494
+ * is the shape of the 10 h 51 min `ugrep` of 16.08. Stopping the scope takes the
495
+ * tree, not just the process we happened to know about.
496
+ *
497
+ * `liveIds` exists so the same sweep can run later without killing work in
498
+ * progress; only the CURRENT scope of a live session is spared, because an
499
+ * earlier attempt of the same session is exactly the leftover we are here for.
500
+ */
501
+ export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl): Promise<string[]>;
502
+ /**
503
+ * The slice names and the CPU share — re-exported so a caller that reasons about
504
+ * the cage needs one import, while the values themselves stay next to the
505
+ * drop-in that writes them (`service-unit.ts`).
506
+ */
507
+ export { SESSIONS_SLICE, DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT };
508
+ //# sourceMappingURL=session-cage.d.ts.map