@bridge4dev/runner 0.54.0 → 0.55.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.
@@ -1,9 +1,13 @@
1
1
  import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
2
2
  /**
3
- * What one session may hold. **Proposal Р3 of the plan, awaiting the owner.**
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}.
4
8
  *
5
9
  * `min(2.5 GiB, service ceiling / 2)` — §3 of
6
- * `docs/plans/active/agent-sessions-host-resources.md`. Both halves matter:
10
+ * `docs/plans/shipped/agent-sessions-host-resources.md`. Both halves matter:
7
11
  *
8
12
  * - the absolute number is sized against the measurement, not against a round
9
13
  * figure. The heaviest ordinary thing a session runs is a workspace
@@ -20,7 +24,7 @@ import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-u
20
24
  * protects the machine is what systemd has in force, not what the drop-in on
21
25
  * disk says — those two have already drifted apart once (§5.5 of the plan).
22
26
  */
23
- export declare const SESSION_MEMORY_MAX_ABSOLUTE_BYTES: number;
27
+ export declare const SESSION_MEMORY_HIGH_ABSOLUTE_BYTES: number;
24
28
  /**
25
29
  * Processes and threads per session.
26
30
  *
@@ -50,8 +54,26 @@ export interface SessionCageFacts {
50
54
  mode: SessionCageMode;
51
55
  /** Why not `scope`. Empty string when it is. */
52
56
  reason: string;
53
- /** What one session gets, in bytes. Null unless `mode === 'scope'`. */
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
+ */
54
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;
55
77
  /** The service ceiling systemd has in force, as measured. Null = infinity. */
56
78
  serviceMemoryMaxBytes: number | null;
57
79
  /**
@@ -95,7 +117,30 @@ export interface SessionCageFacts {
95
117
  * cost the one thing it does buy: the runaway dying instead of its neighbours
96
118
  * (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
97
119
  */
98
- export declare const SESSION_MEMORY_MIN_BYTES: number;
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;
99
144
  /**
100
145
  * `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
101
146
  *
@@ -114,7 +159,83 @@ export declare const SESSION_MEMORY_MIN_BYTES: number;
114
159
  * hand, and then «never more than the slice» has to still be true
115
160
  * (QA-2026-09-07 MINOR-5).
116
161
  */
117
- export declare function sessionMemoryMaxBytes(containingMemoryMaxBytes: number | null): number;
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;
118
239
  /**
119
240
  * The session id as systemd will accept it.
120
241
  *
@@ -171,11 +292,18 @@ export interface CageProbe {
171
292
  /** The one-shot scope that reads its own `memory.max`, built like a real one. */
172
293
  probeMemoryMax: (options: {
173
294
  expandEnvironmentFlag: boolean;
295
+ oomPolicyFlag: boolean;
174
296
  }) => Promise<CageProbeResult>;
175
297
  /** `MemoryMax` of the runner service in bytes; null for `infinity`. */
176
298
  serviceMemoryMax: () => Promise<number | null>;
177
299
  /** `MemoryMax` in force on `devbridge-sessions.slice`; null for `infinity`. */
178
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;
179
307
  /** Can this kernel renice at all — the fallback's own precondition. */
180
308
  canRenice: () => boolean;
181
309
  }
@@ -191,6 +319,15 @@ export interface CageProbe {
191
319
  export declare function cageEnv(): Record<string, string>;
192
320
  export declare const defaultCageProbe: CageProbe;
193
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;
194
331
  /**
195
332
  * Probe once, at daemon start, and remember the answer.
196
333
  *
@@ -212,6 +349,8 @@ export interface CagedSpawn {
212
349
  /** The scope unit this start will live in, or null when nothing was wrapped. */
213
350
  unit: string | null;
214
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;
215
354
  /**
216
355
  * Wrap a command in its session's cage, or hand it back untouched.
217
356
  *
@@ -246,6 +385,77 @@ export declare function killedBeforeExec(info: {
246
385
  sawOutput: boolean;
247
386
  caged: boolean;
248
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
+ * «a process in it was stopped» / «3 processes in it were stopped», so the verb
438
+ * agrees. Shared with the supervisor's feed notices, which count the same
439
+ * thing and must not word it differently.
440
+ */
441
+ export declare function stoppedProcesses(count: number, where?: 'it' | 'this one'): string;
442
+ /**
443
+ * Snapshot the cgroup's verdict before systemd can take it away.
444
+ *
445
+ * Exported for the test seam only (`readStatus`): the real caller passes
446
+ * nothing and reads the live cgroup.
447
+ */
448
+ export declare function rememberDeath(id: string, unit: string, readStatus?: (unit: string) => ScopeMemoryStatus | null): void;
449
+ /**
450
+ * One sentence for the error the person reads, when the kernel had a hand in
451
+ * this death — or null when it had not. Consumed: a session restarted after an
452
+ * OOM must not carry the old sentence into its next, unrelated failure.
453
+ *
454
+ * The text that reached people in 0.54.0 was «exited with code 143» once and
455
+ * «terminated by signal SIGKILL» the next time, for the same cause, and neither
456
+ * said the word memory. This is that word.
457
+ */
458
+ export declare function explainMemoryDeath(id: string): string | null;
249
459
  /** So the sweeper and the reader can be tested without a machine under them. */
250
460
  export type Systemctl = (args: string[]) => Promise<{
251
461
  stdout: string;
@@ -263,6 +473,13 @@ export type Systemctl = (args: string[]) => Promise<{
263
473
  * startable in the meantime.
264
474
  */
265
475
  export declare function releaseSessionScope(unit: string | null, id?: string, systemctl?: Systemctl): Promise<string | null>;
476
+ /**
477
+ * The sentence for a death, once the verdict is in. Null when the kernel had no
478
+ * hand in it. Waits for the release of this session's scope, but never longer
479
+ * than `capMs`: an answer that arrives after the person has read the error is
480
+ * worth nothing, and a hung `systemctl` must not hold a failing session open.
481
+ */
482
+ export declare function memoryDeathSentence(id: string, capMs?: number): Promise<string | null>;
266
483
  /** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
267
484
  export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
268
485
  export interface SessionScopeInfo {