@bridge4dev/runner 0.53.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,297 @@
1
+ import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
2
+ /**
3
+ * What one session may hold. **Proposal Р3 of the plan, awaiting the owner.**
4
+ *
5
+ * `min(2.5 GiB, service ceiling / 2)` — §3 of
6
+ * `docs/plans/active/agent-sessions-host-resources.md`. Both halves matter:
7
+ *
8
+ * - the absolute number is sized against the measurement, not against a round
9
+ * figure. The heaviest ordinary thing a session runs is a workspace
10
+ * `pnpm typecheck`, measured at a 1571 MB peak; three live `claude`
11
+ * processes are 512/577/646 MB. 2.5 GiB leaves ~60 % headroom over the
12
+ * worst measured case, which is the margin between "kills a runaway" and
13
+ * "kills the work";
14
+ * - the half-of-the-service half is what keeps ONE session from being able to
15
+ * fill the ceiling that covers all of them. On this machine the service
16
+ * ceiling is 7680M, so half is 3.75 GiB and the absolute number wins.
17
+ *
18
+ * The service ceiling is read off systemd (`systemctl --user show
19
+ * devbridge-runner -p MemoryMax`) rather than recomputed here, because what
20
+ * protects the machine is what systemd has in force, not what the drop-in on
21
+ * disk says — those two have already drifted apart once (§5.5 of the plan).
22
+ */
23
+ export declare const SESSION_MEMORY_MAX_ABSOLUTE_BYTES: number;
24
+ /**
25
+ * Processes and threads per session.
26
+ *
27
+ * An agent forks a lot (a monorepo build is hundreds of short-lived processes),
28
+ * so this is not a memory limit in disguise — it is the fork-bomb stop. 512 is
29
+ * far above anything measured on a real session and far below the 8192 the
30
+ * whole service gets.
31
+ */
32
+ export declare const SESSION_TASKS_MAX = 512;
33
+ /** Prefix of every scope this module creates. The sweeper matches on it. */
34
+ export declare const SESSION_SCOPE_PREFIX = "devbridge-session-";
35
+ /**
36
+ * How the sessions on this machine are contained.
37
+ *
38
+ * `scope` — a cgroup per session with a memory ceiling (this module).
39
+ * `nice-only` — cgroup v2 is not usable here; stage 1a's `nice(2)` is all there
40
+ * is. Correct on cgroup v1/hybrid (Ubuntu 20.04, CentOS 7/8) and
41
+ * under a foreign supervisor.
42
+ * `none` — not even that: the kernel refuses `setpriority` as well.
43
+ *
44
+ * Travels in `hello` as `machine.sessionCage` so the dashboard can SAY that the
45
+ * containment did not take on a given machine, instead of leaving it to be
46
+ * guessed (plan §5.4.3).
47
+ */
48
+ export type SessionCageMode = 'scope' | 'nice-only' | 'none';
49
+ export interface SessionCageFacts {
50
+ mode: SessionCageMode;
51
+ /** Why not `scope`. Empty string when it is. */
52
+ reason: string;
53
+ /** What one session gets, in bytes. Null unless `mode === 'scope'`. */
54
+ memoryMaxBytes: number | null;
55
+ /** The service ceiling systemd has in force, as measured. Null = infinity. */
56
+ serviceMemoryMaxBytes: number | null;
57
+ /**
58
+ * The ceiling over ALL sessions together, as systemd has it in force on
59
+ * `devbridge-sessions.slice`. Null means there is none.
60
+ *
61
+ * Read rather than assumed, because the live probe proves only the PERSONAL
62
+ * ceiling: a `MemoryMax` on a scope applies whatever the parent slice says. A
63
+ * machine whose `daemon-reload` never happened therefore passed the probe,
64
+ * announced `sessionCage: 'scope'` and `limitsCurrent: true`, and had three
65
+ * sessions of 2.5 GB each over a slice at `MemoryMax=infinity` — with the card
66
+ * saying «Sessions capped» (QA-2026-09-07 MAJOR-3).
67
+ */
68
+ sessionsSliceMemoryMaxBytes: number | null;
69
+ /**
70
+ * Is `--expand-environment=no` on the command line? systemd ≥ 254 only — see
71
+ * the module header. False is not a downgrade below 254: expansion in
72
+ * `--scope` was off by default there anyway.
73
+ */
74
+ expandEnvironmentFlag: boolean;
75
+ }
76
+ /**
77
+ * The FLOOR the plan's formula does not have, and the reason it needs one.
78
+ *
79
+ * `min(2.5 GiB, ceiling / 2)` is right on a big machine and wrong on a small
80
+ * one: at the 2 GiB ceiling `memoryPolicy` gives the smallest supported box,
81
+ * half is 1 GiB — BELOW the 1571 MB a workspace `pnpm typecheck` was measured
82
+ * at. The cage would then kill honest work on exactly the machines least able
83
+ * to afford a failed run, and it would look like a flaky agent rather than a
84
+ * limit. A containment that fires on correct work is not containment.
85
+ *
86
+ * So the floor is set above the worst measured ordinary peak, not at a round
87
+ * number.
88
+ *
89
+ * It is a floor and not a switch: below ~2.4 GB of RAM the containing ceiling
90
+ * itself drops under this number, and there the last clamp in
91
+ * {@link sessionMemoryMaxBytes} wins and a session is capped below the floor.
92
+ * That is not the cage killing honest work — the slice would have killed the
93
+ * same `pnpm typecheck` a moment later anyway, because it holds the same
94
+ * number — so removing `MemoryMax` from the scope there would buy nothing and
95
+ * cost the one thing it does buy: the runaway dying instead of its neighbours
96
+ * (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
97
+ */
98
+ export declare const SESSION_MEMORY_MIN_BYTES: number;
99
+ /**
100
+ * `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
101
+ *
102
+ * Three bounds, each for its own reason — see
103
+ * {@link SESSION_MEMORY_MAX_ABSOLUTE_BYTES} for the upper two and
104
+ * {@link SESSION_MEMORY_MIN_BYTES} for the lower one. The last clamp matters
105
+ * on a tiny machine: a per-session number ABOVE the slice's own ceiling is not
106
+ * a limit at all, it is a bigger number that never applies, and printing it in
107
+ * `doctor` would be a straight lie about what is in force.
108
+ *
109
+ * The argument is the ceiling of the cgroup that CONTAINS the session, and
110
+ * since 0.54.0 that is `devbridge-sessions.slice` and not the service — the
111
+ * caller reads the slice and falls back to the service only when systemd has
112
+ * nothing to say about the slice. The two are the same number by construction
113
+ * today; they come apart the moment a drop-in fails to apply or is edited by
114
+ * hand, and then «never more than the slice» has to still be true
115
+ * (QA-2026-09-07 MINOR-5).
116
+ */
117
+ export declare function sessionMemoryMaxBytes(containingMemoryMaxBytes: number | null): number;
118
+ /**
119
+ * The session id as systemd will accept it.
120
+ *
121
+ * Ids are UUIDs today and arbitrary text tomorrow — they arrive from the API,
122
+ * and `verify` keys its cage by a run id. systemd unit names take only
123
+ * `[A-Za-z0-9:_.-]`, so everything else is folded to `-`. Folding is lossy, and
124
+ * lossy is how two different sessions end up fighting over one scope, so
125
+ * anything that had to be changed or cut also gets eight hex digits of the
126
+ * original — deterministic, so the same session always names the same unit and
127
+ * the sweeper can still recognise it.
128
+ */
129
+ export declare function sanitizeCageId(id: string): string;
130
+ /**
131
+ * The scope unit for one START of one session.
132
+ *
133
+ * `attempt` is not decoration. A scope the OOM killer took stays loaded in
134
+ * `failed` until something resets it, and `systemd-run --unit=` on a name that
135
+ * is still loaded fails outright — measured: «Unit devbridge-….scope was
136
+ * already loaded or has a fragment file», exit 1, nothing spawned. So a session
137
+ * that is restarted after an OOM (which is exactly when it IS restarted) would
138
+ * be unable to start at all if the name never changed.
139
+ * {@link releaseSessionScope} clears the failed unit and only then lets the
140
+ * counter fall back to 1, so the plain `devbridge-session-<id>.scope` is the
141
+ * normal case and the marker appears only while the old scope is still there.
142
+ */
143
+ export declare function sessionScopeUnit(id: string, attempt?: number): string;
144
+ /**
145
+ * The systemd release that added `--expand-environment=`.
146
+ *
147
+ * Below it `systemd-run` answers `unrecognized option` and exits 1 — the probe
148
+ * then reads as «the cage did not hold» on a machine whose cgroups are perfect
149
+ * (Ubuntu 22.04 is 249, Debian 12 and RHEL 9 are 252). See the module header for
150
+ * why the flag is not needed there either.
151
+ */
152
+ export declare const EXPAND_ENVIRONMENT_MIN_SYSTEMD = 254;
153
+ /** What one run of the throwaway scope answered, and why it did not run. */
154
+ export interface CageProbeResult {
155
+ /** `memory.max` as the scope read it back. Null when the scope never ran. */
156
+ memoryMax: string | null;
157
+ /** `systemd-run`'s own first line of complaint. Null when it ran. */
158
+ error: string | null;
159
+ }
160
+ /** Everything about this machine the detector needs, so a test can lie about all of it. */
161
+ export interface CageProbe {
162
+ /** `cgroup2fs`, `tmpfs`, or whatever else is mounted at `/sys/fs/cgroup`. */
163
+ cgroupFsType: () => string | null;
164
+ systemdRunOnPath: () => boolean;
165
+ /** Major version of `systemd-run`, or null when it would not say. */
166
+ systemdRunVersion: () => Promise<number | null>;
167
+ /** `$XDG_RUNTIME_DIR` when `$XDG_RUNTIME_DIR/bus` exists, else null. */
168
+ userBusPath: () => string | null;
169
+ /** Controllers delegated to this user's manager, or null when unreadable. */
170
+ delegatedControllers: () => string[] | null;
171
+ /** The one-shot scope that reads its own `memory.max`, built like a real one. */
172
+ probeMemoryMax: (options: {
173
+ expandEnvironmentFlag: boolean;
174
+ }) => Promise<CageProbeResult>;
175
+ /** `MemoryMax` of the runner service in bytes; null for `infinity`. */
176
+ serviceMemoryMax: () => Promise<number | null>;
177
+ /** `MemoryMax` in force on `devbridge-sessions.slice`; null for `infinity`. */
178
+ sessionsSliceMemoryMax: () => Promise<number | null>;
179
+ /** Can this kernel renice at all — the fallback's own precondition. */
180
+ canRenice: () => boolean;
181
+ }
182
+ /**
183
+ * Variables the child needs to reach the user's systemd, and nothing else.
184
+ *
185
+ * `systemd-run --user` finds the bus through `XDG_RUNTIME_DIR` (sd-bus falls
186
+ * back to `$XDG_RUNTIME_DIR/bus` when `DBUS_SESSION_BUS_ADDRESS` is unset), and
187
+ * that variable is already on both spawn allowlists — so merging this into a
188
+ * session's environment widens nothing. `DBUS_SESSION_BUS_ADDRESS` is passed on
189
+ * only when the daemon really has one of its own, never synthesised.
190
+ */
191
+ export declare function cageEnv(): Record<string, string>;
192
+ export declare const defaultCageProbe: CageProbe;
193
+ export declare function detectSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
194
+ /**
195
+ * Probe once, at daemon start, and remember the answer.
196
+ *
197
+ * The probe costs a process, so it is not something a spawn can afford to do:
198
+ * three sessions starting at once would mean three throwaway scopes before the
199
+ * first agent got a word out.
200
+ */
201
+ export declare function initSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
202
+ /** What the last {@link initSessionCage} found; the safe default before it ran. */
203
+ export declare function sessionCage(): SessionCageFacts;
204
+ export interface CagedSpawn {
205
+ command: string;
206
+ args: string[];
207
+ /**
208
+ * Variables to MERGE into the child's environment (`{...yours, ...cage.env}`).
209
+ * Empty when nothing was wrapped.
210
+ */
211
+ env: Record<string, string>;
212
+ /** The scope unit this start will live in, or null when nothing was wrapped. */
213
+ unit: string | null;
214
+ }
215
+ /**
216
+ * Wrap a command in its session's cage, or hand it back untouched.
217
+ *
218
+ * Untouched is the honest answer on every machine where the cage was not proved
219
+ * to work: `systemd-run` would accept the flags there and apply nothing, and a
220
+ * session that believes it is contained when it is not is worse than one that
221
+ * knows it is not.
222
+ *
223
+ * Pipes, exit codes, signals and stdin EOF behave exactly as with a direct
224
+ * spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
225
+ * spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
226
+ * (`pgid === child.pid` still holds).
227
+ */
228
+ export declare function cageSpawn(input: {
229
+ id: string;
230
+ command: string;
231
+ args: string[];
232
+ }): CagedSpawn;
233
+ /**
234
+ * Did this process die in the window before `systemd-run` handed over?
235
+ *
236
+ * Starting a scope took 0.07–2.5 s in the spike, and 2741 ms on a loaded
237
+ * machine. A stop inside that window signals `systemd-run` itself, before the
238
+ * `exec`, and the parent sees `{code: null, signal: 'SIGTERM'}` with nothing on
239
+ * stdout at all. That is «the session never started», not «the agent died
240
+ * silently» — told apart here so no supervisor has to infer it from an empty
241
+ * buffer.
242
+ */
243
+ export declare function killedBeforeExec(info: {
244
+ code: number | null;
245
+ signal: string | null;
246
+ sawOutput: boolean;
247
+ caged: boolean;
248
+ }): boolean;
249
+ /** So the sweeper and the reader can be tested without a machine under them. */
250
+ export type Systemctl = (args: string[]) => Promise<{
251
+ stdout: string;
252
+ stderr: string;
253
+ }>;
254
+ /**
255
+ * Read why the scope ended, say so, and then let systemd forget it.
256
+ *
257
+ * This is the whole reason `--collect` is not passed. The order is fixed: the
258
+ * process has already exited, `Result` is read, the reason is logged, and only
259
+ * then is the unit reset — because `reset-failed` is what deletes the answer.
260
+ *
261
+ * Failure here is never fatal: a unit that could not be reset is swept at the
262
+ * next daemon start, and the counter in {@link cageSpawn} keeps the session
263
+ * startable in the meantime.
264
+ */
265
+ export declare function releaseSessionScope(unit: string | null, id?: string, systemctl?: Systemctl): Promise<string | null>;
266
+ /** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
267
+ export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
268
+ export interface SessionScopeInfo {
269
+ unit: string;
270
+ memoryMaxBytes: number | null;
271
+ memoryCurrentBytes: number | null;
272
+ tasksCurrent: number | null;
273
+ result: string | null;
274
+ activeState: string | null;
275
+ }
276
+ /** What `doctor` prints for each caged session. */
277
+ export declare function listSessionScopes(systemctl?: Systemctl): Promise<SessionScopeInfo[]>;
278
+ /**
279
+ * Stop and forget every session scope that has no session behind it.
280
+ *
281
+ * At daemon start that is all of them by definition, and it is the point: a
282
+ * scope outlives a killed daemon carrying the whole process tree with it, which
283
+ * is the shape of the 10 h 51 min `ugrep` of 16.08. Stopping the scope takes the
284
+ * tree, not just the process we happened to know about.
285
+ *
286
+ * `liveIds` exists so the same sweep can run later without killing work in
287
+ * progress; only the CURRENT scope of a live session is spared, because an
288
+ * earlier attempt of the same session is exactly the leftover we are here for.
289
+ */
290
+ export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl): Promise<string[]>;
291
+ /**
292
+ * The slice names and the CPU share — re-exported so a caller that reasons about
293
+ * the cage needs one import, while the values themselves stay next to the
294
+ * drop-in that writes them (`service-unit.ts`).
295
+ */
296
+ export { SESSIONS_SLICE, DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT };
297
+ //# sourceMappingURL=session-cage.d.ts.map