@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.
@@ -6,7 +6,7 @@ import { AsyncQueue } from '../async-queue.js';
6
6
  import { log } from '../log.js';
7
7
  import { mcpConfigPath } from '../paths.js';
8
8
  import { lowerPriority } from '../process-priority.js';
9
- import { cageSpawn, releaseSessionScope } from '../session-cage.js';
9
+ import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-cage.js';
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
@@ -2444,9 +2444,17 @@ class ClaudeSession {
2444
2444
  // appended it to as well.
2445
2445
  const message = maskString(String(error instanceof Error ? error.message : error) + this.stderrSuffix());
2446
2446
  const code = errorCode(message);
2447
+ // #387: «exited with code 143» and «terminated by signal SIGKILL» were the
2448
+ // two faces of one cause — the kernel's OOM killer inside this session's
2449
+ // cgroup — and neither said so. The cage remembers the kill counter at
2450
+ // exit; when it moved, the sentence about memory rides on the error.
2451
+ // Awaited, not read: on a systemd that stops the whole scope the cgroup
2452
+ // is already gone when the process's `exit` fires, and the only surviving
2453
+ // record is `Result=oom-kill`, which the release is still fetching.
2454
+ const memory = await memoryDeathSentence(this.spec.sessionId);
2447
2455
  this.emit({
2448
2456
  type: 'error',
2449
- message: classifyRunError(message),
2457
+ message: memory ? `${classifyRunError(message)} ${memory}` : classifyRunError(message),
2450
2458
  ...(code ? { code } : {}),
2451
2459
  // #373: this catch is the CLI process falling over under the read loop —
2452
2460
  // the only place in this adapter where that is what happened. Our own
@@ -1,6 +1,7 @@
1
1
  import { AsyncQueue } from '../async-queue.js';
2
2
  import { log } from '../log.js';
3
3
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
4
+ import { memoryDeathSentence } from '../session-cage.js';
4
5
  import { RUNNER_VERSION } from '../version.js';
5
6
  import { repairCodexAuth } from './codex-home.js';
6
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
@@ -1866,8 +1867,36 @@ class CodexSession {
1866
1867
  processGone: true,
1867
1868
  });
1868
1869
  }
1870
+ if (!this.stopped)
1871
+ this.sayIfMemoryKilledIt();
1869
1872
  this.finish();
1870
1873
  }
1874
+ /**
1875
+ * «codex app-server exited (code null)» is the same sentence whether the CLI
1876
+ * crashed or the kernel took it for memory, and #387 is the ticket about
1877
+ * exactly that silence. The cage knows which it was; here it reaches the
1878
+ * person.
1879
+ *
1880
+ * A follow-up `notice` rather than a longer error message, because both death
1881
+ * paths in this adapter are synchronous by design (one is a notification
1882
+ * handler, the other a catch that closes the output queue on the next line),
1883
+ * and the verdict needs an await — `Result=oom-kill` is read after the exit.
1884
+ */
1885
+ sayIfMemoryKilledIt() {
1886
+ if (this.memoryVerdictAsked)
1887
+ return;
1888
+ this.memoryVerdictAsked = true;
1889
+ void memoryDeathSentence(this.spec.sessionId)
1890
+ .then((sentence) => {
1891
+ if (sentence)
1892
+ this.emit({ type: 'notice', level: 'warn', text: sentence });
1893
+ })
1894
+ .catch(() => {
1895
+ // A verdict we could not fetch is no verdict; the error text stands.
1896
+ });
1897
+ }
1898
+ /** One verdict per process — both death paths can fire for the same exit. */
1899
+ memoryVerdictAsked = false;
1871
1900
  // ─── Capabilities ──────────────────────────────────────────────────
1872
1901
  refreshCapabilities() {
1873
1902
  if (this.capabilitiesInFlight || this.stopped)
@@ -2120,6 +2149,10 @@ class CodexSession {
2120
2149
  return code;
2121
2150
  }
2122
2151
  this.emit({ type: 'error', message: `Codex session error: ${message.slice(0, 1_000)}` });
2152
+ // The generic door: a pending request rejected because the app-server died
2153
+ // (`failAll`). If the kernel is why it died, say so (#387).
2154
+ if (/app-server exited|SIGKILL/i.test(message))
2155
+ this.sayIfMemoryKilledIt();
2123
2156
  return null;
2124
2157
  }
2125
2158
  /**
package/dist/index.js CHANGED
@@ -423,9 +423,27 @@ function runnerCapabilities(apiUrlOverride) {
423
423
  // Why not `scope`, in one sentence, for the card to show under it.
424
424
  // Empty string when the cage is on.
425
425
  sessionCageReason: sessionCage().reason,
426
- // What ONE session may hold, in bytes. Null when there is no cage —
427
- // then the only ceiling is the service's, over all of them together.
426
+ /**
427
+ * The ladder one session lives under (#387), in bytes. Null when there is
428
+ * no cage — then the only ceiling is the service's, over all of them.
429
+ *
430
+ * `High` is the brake: past it the kernel slows the session down instead
431
+ * of killing. `Max` is the wall, far above — the machine's measured
432
+ * headroom, where only a runaway ever arrives. Runners up to 0.54.0 sent
433
+ * `Max` alone, and it was the kill line at 2.5 GiB; a card that sees no
434
+ * `High` is looking at one of those.
435
+ */
436
+ sessionMemoryHighBytes: sessionCage().memoryHighBytes,
428
437
  sessionMemoryMaxBytes: sessionCage().memoryMaxBytes,
438
+ /** Swap one session may push into while braked. 0 on a machine without swap. */
439
+ sessionSwapMaxBytes: sessionCage().swapMaxBytes,
440
+ /**
441
+ * Whether reaching the wall costs the session ONE process (`OOMPolicy=
442
+ * continue` on the scope) or is left to that systemd's own default —
443
+ * older systemds do not take the setting on a scope at all. The card
444
+ * words the wall by it.
445
+ */
446
+ sessionOomContinue: sessionCage().oomContinue,
429
447
  /**
430
448
  * The ceiling over ALL sessions, as systemd has it in force — not as the
431
449
  * drop-in on disk says.
@@ -1425,7 +1443,10 @@ async function cmdDoctor(args) {
1425
1443
  print(` ${devbridgeSliceOverridePath()}`);
1426
1444
  if (cage.mode === 'scope') {
1427
1445
  const mb = (bytes) => bytes === null ? 'infinity' : `${Math.round(bytes / 1024 / 1024)} MB`;
1428
- print(` per session MemoryMax ${mb(cage.memoryMaxBytes)}, MemorySwapMax 0, TasksMax ${SESSION_TASKS_MAX}`);
1446
+ print(` per session share ${mb(cage.memoryHighBytes)} (MemoryHigh — allocations are throttled above it), ceiling ${mb(cage.memoryMaxBytes)} (MemoryMax), swap ${mb(cage.swapMaxBytes)} (MemorySwapMax), TasksMax ${SESSION_TASKS_MAX}`);
1447
+ print(` at the ceiling ${cage.oomContinue
1448
+ ? 'OOMPolicy=continue — the kernel stops the largest process in the session'
1449
+ : 'this systemd does not take OOMPolicy on a scope, so its own default applies — on systemd 253+ that stops the whole session'}`);
1429
1450
  // What systemd has IN FORCE on the slice, beside the paths of the files
1430
1451
  // that were supposed to put it there. «В файле 7680M, действует 6.0G» is the
1431
1452
  // lesson of §5.5, and the drop-in of a slice can fail to land in exactly the
@@ -1436,7 +1457,7 @@ async function cmdDoctor(args) {
1436
1457
  print(' NOT CAPPED — the drop-in above has not been applied.');
1437
1458
  print(` Apply with: ${systemctlHint('daemon-reload')}`);
1438
1459
  }
1439
- print(` service ceiling ${mb(cage.serviceMemoryMaxBytes)} (the per-session figure is half of the containing ceiling, capped)`);
1460
+ print(` service ceiling ${mb(cage.serviceMemoryMaxBytes)} (a session's share is a third of the containing ceiling, its own ceiling is that minus one share)`);
1440
1461
  print(` expand-env flag ${cage.expandEnvironmentFlag ? 'passed (systemd ≥ 254)' : 'not passed — this systemd does not know it, and --scope does not expand anyway'}`);
1441
1462
  }
1442
1463
  else {
@@ -57,7 +57,7 @@ export declare function buildUnit(execStart?: string, nodeBinary?: string): stri
57
57
  * which is the only way to fix the servers that already have the bad numbers
58
58
  * baked in — and it never overwrites a unit the operator edited by hand.
59
59
  */
60
- export declare const LIMITS_VERSION = 4;
60
+ export declare const LIMITS_VERSION = 5;
61
61
  /**
62
62
  * Where the agent sessions live once they have a cage of their own.
63
63
  *
@@ -152,10 +152,29 @@ export interface MemoryFacts {
152
152
  sessionsFloorBytes?: number;
153
153
  /** Seconds since boot. Below `BOOT_SETTLE_SEC` the measurement is a lie. */
154
154
  uptimeSec: number;
155
+ /**
156
+ * `SwapTotal` — what the sessions' swap share is cut from (#387). Absent or 0
157
+ * means a machine without swap, and then the share is 0: the brake on such a
158
+ * machine can only drop page cache, and the wall is what stops a runaway.
159
+ */
160
+ swapTotalBytes?: number;
155
161
  }
162
+ /**
163
+ * How much of the machine's swap ALL sessions together may use (#387).
164
+ *
165
+ * Half, not all: swap belongs to the machine (the spike's 200 MB cage drained
166
+ * the host's 2 GB and the box hung), so the other half stays with the neighbours
167
+ * and the kernel. Each session gets half of this again (`sessionSwapMaxBytes`).
168
+ * On the 2 GB swap this machine has today that is 512 MB per session — enough
169
+ * for a build's overshoot, not for a runaway; the owner's note on #387 is that
170
+ * the swap itself has to grow for the brake to have real room.
171
+ */
172
+ export declare const SESSIONS_SWAP_SHARE = 0.5;
156
173
  export interface MemoryPolicy {
157
174
  maxBytes: number;
158
175
  highBytes: number;
176
+ /** `MemorySwapMax` for the sessions slice: `SESSIONS_SWAP_SHARE` of `SwapTotal`. */
177
+ swapMaxBytes: number;
159
178
  /** false = the conservative static pair, because the machine was still booting. */
160
179
  measured: boolean;
161
180
  /**
@@ -221,6 +240,19 @@ export declare function managedUsageFloorBytes(facts: MemoryFacts): number;
221
240
  * machine, and a ceiling of «everything» is the bug this function exists to fix.
222
241
  */
223
242
  export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: number): MemoryPolicy;
243
+ /** `SwapTotal` of this machine, or null where `/proc/meminfo` will not say. */
244
+ export declare function readSwapTotalBytes(): number | null;
245
+ /**
246
+ * What our own cgroup currently holds.
247
+ *
248
+ * Read through `/proc/self/cgroup` rather than assembling the path from the
249
+ * service name: the runner runs as root and as a dedicated user, under
250
+ * `user@0.service` and under `user@1001.service`, and guessing that path wrong
251
+ * silently returns 0 — which would quietly shrink the ceiling by whatever we are
252
+ * already using. Returns 0 on cgroup v1 or in a container without the file, which
253
+ * is the safe direction: a slightly lower ceiling, never a higher one.
254
+ */
255
+ export declare function readSelfCgroup(): string | null;
224
256
  export declare function readOwnCgroupMemory(): CgroupMemory | null;
225
257
  /**
226
258
  * What the cgroup holds that `MemAvailable` has NOT already counted.
@@ -349,14 +381,18 @@ export declare function buildLimitsOverride(cpuCount?: number, facts?: MemoryFac
349
381
  * not the guarantee `memoryPolicy` computes, and it is written down here rather
350
382
  * than glossed over (QA-2026-09-07 MINOR-4).
351
383
  *
352
- * `MemorySwapMax=0` for the same reason it is on every scope: a ceiling on
353
- * resident memory alone is not a ceiling, it is a swap pump (a 200 MB cage
354
- * allocated 2 GB and drained the host's swap during the spike).
355
- *
356
- * No `MemoryHigh` here either. Soft pressure on the slice would throttle every
357
- * session on the machine to keep one runaway alive a little longer — the exact
358
- * trade the spike measured and rejected: 60 seconds of delays instead of a
359
- * 380 ms honest death.
384
+ * `MemorySwapMax` is a BOUND on the slice for the same reason it is on every
385
+ * scope: a ceiling on resident memory alone is not a ceiling, it is a swap pump
386
+ * (a 200 MB cage allocated 2 GB and drained the host's swap during the spike).
387
+ * Since #387 the bound is a share of the machine's swap rather than 0
388
+ * (`SESSIONS_SWAP_SHARE`), because each session now carries a `MemoryHigh`
389
+ * brake, and a brake with no swap under it stalls the session instead of
390
+ * slowing it. The unmeasured branch still writes 0: no measurement, no swap to
391
+ * hand out.
392
+ *
393
+ * No `MemoryHigh` here. The brake belongs on each SCOPE (`session-cage.ts`):
394
+ * soft pressure on the slice would throttle every session on the machine to
395
+ * keep one runaway alive a little longer.
360
396
  *
361
397
  * `sessionsUsageBytes` is the door that used to lead around all of the above.
362
398
  * `facts` is null whenever the SERVICE's `MemoryCurrent` is unreadable — a
@@ -129,7 +129,7 @@ export function buildUnit(execStart, nodeBinary = process.execPath) {
129
129
  * which is the only way to fix the servers that already have the bad numbers
130
130
  * baked in — and it never overwrites a unit the operator edited by hand.
131
131
  */
132
- export const LIMITS_VERSION = 4;
132
+ export const LIMITS_VERSION = 5;
133
133
  const LIMITS_MARKER = '# devbridge-limits-version:';
134
134
  /** `zz-` so it sorts last: an operator's own drop-in should still win. */
135
135
  const LIMITS_FILE = 'zz-devbridge-limits.conf';
@@ -181,6 +181,17 @@ export function devbridgeSliceOverridePath(home = systemdUserHome()) {
181
181
  }
182
182
  const MIB = 1024 * 1024;
183
183
  const GIB = 1024 * MIB;
184
+ /**
185
+ * How much of the machine's swap ALL sessions together may use (#387).
186
+ *
187
+ * Half, not all: swap belongs to the machine (the spike's 200 MB cage drained
188
+ * the host's 2 GB and the box hung), so the other half stays with the neighbours
189
+ * and the kernel. Each session gets half of this again (`sessionSwapMaxBytes`).
190
+ * On the 2 GB swap this machine has today that is 512 MB per session — enough
191
+ * for a build's overshoot, not for a runaway; the owner's note on #387 is that
192
+ * the swap itself has to grow for the brake to have real room.
193
+ */
194
+ export const SESSIONS_SWAP_SHARE = 0.5;
184
195
  /**
185
196
  * How long after boot `MemAvailable` starts telling the truth.
186
197
  *
@@ -268,9 +279,16 @@ const CEILING_FLOOR_BYTES = 2 * GIB;
268
279
  */
269
280
  export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(facts)) {
270
281
  const { totalBytes, availableBytes, ownUsageBytes, sessionsUsageBytes, uptimeSec } = facts;
282
+ const swapMaxBytes = Math.floor(Math.max(0, facts.swapTotalBytes ?? 0) * SESSIONS_SWAP_SHARE);
271
283
  const withFloor = (maxBytes, measured, starved = false) => {
272
284
  const ceiling = Math.floor(Math.max(maxBytes, minCeilingBytes));
273
- return { maxBytes: ceiling, highBytes: Math.floor(ceiling * 0.8), measured, starved };
285
+ return {
286
+ maxBytes: ceiling,
287
+ highBytes: Math.floor(ceiling * 0.8),
288
+ swapMaxBytes,
289
+ measured,
290
+ starved,
291
+ };
274
292
  };
275
293
  // Still booting: the neighbours have not claimed their memory yet, so measuring
276
294
  // now would hand back almost the whole machine. Take a fraction of TOTAL
@@ -311,8 +329,8 @@ function clamp(value, low, high) {
311
329
  function asMiB(bytes) {
312
330
  return `${Math.max(1, Math.floor(bytes / MIB))}M`;
313
331
  }
314
- /** `MemTotal`/`MemAvailable` in bytes, or null where there is no `/proc`. */
315
- function readMemInfo() {
332
+ /** One `kB` field of `/proc/meminfo`, in bytes; null when absent or unreadable. */
333
+ function memInfoField(name) {
316
334
  let text;
317
335
  try {
318
336
  text = fs.readFileSync('/proc/meminfo', 'utf8');
@@ -320,13 +338,20 @@ function readMemInfo() {
320
338
  catch {
321
339
  return null;
322
340
  }
323
- const field = (name) => {
324
- const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, 'm').exec(text);
325
- return match?.[1] ? Number(match[1]) * 1024 : null;
326
- };
327
- const totalBytes = field('MemTotal');
328
- const availableBytes = field('MemAvailable');
329
- return totalBytes && availableBytes ? { totalBytes, availableBytes } : null;
341
+ const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, 'm').exec(text);
342
+ return match?.[1] ? Number(match[1]) * 1024 : null;
343
+ }
344
+ /** `MemTotal`/`MemAvailable`/`SwapTotal` in bytes, or null where there is no `/proc`. */
345
+ function readMemInfo() {
346
+ const totalBytes = memInfoField('MemTotal');
347
+ const availableBytes = memInfoField('MemAvailable');
348
+ // 0 is a real answer here (no swap), unlike the two above.
349
+ const swapTotalBytes = memInfoField('SwapTotal') ?? 0;
350
+ return totalBytes && availableBytes ? { totalBytes, availableBytes, swapTotalBytes } : null;
351
+ }
352
+ /** `SwapTotal` of this machine, or null where `/proc/meminfo` will not say. */
353
+ export function readSwapTotalBytes() {
354
+ return memInfoField('SwapTotal');
330
355
  }
331
356
  /**
332
357
  * What our own cgroup currently holds.
@@ -338,7 +363,7 @@ function readMemInfo() {
338
363
  * already using. Returns 0 on cgroup v1 or in a container without the file, which
339
364
  * is the safe direction: a slightly lower ceiling, never a higher one.
340
365
  */
341
- function readSelfCgroup() {
366
+ export function readSelfCgroup() {
342
367
  try {
343
368
  const line = fs
344
369
  .readFileSync('/proc/self/cgroup', 'utf8')
@@ -620,14 +645,18 @@ const MANAGED_HEADER = [
620
645
  * not the guarantee `memoryPolicy` computes, and it is written down here rather
621
646
  * than glossed over (QA-2026-09-07 MINOR-4).
622
647
  *
623
- * `MemorySwapMax=0` for the same reason it is on every scope: a ceiling on
624
- * resident memory alone is not a ceiling, it is a swap pump (a 200 MB cage
625
- * allocated 2 GB and drained the host's swap during the spike).
648
+ * `MemorySwapMax` is a BOUND on the slice for the same reason it is on every
649
+ * scope: a ceiling on resident memory alone is not a ceiling, it is a swap pump
650
+ * (a 200 MB cage allocated 2 GB and drained the host's swap during the spike).
651
+ * Since #387 the bound is a share of the machine's swap rather than 0
652
+ * (`SESSIONS_SWAP_SHARE`), because each session now carries a `MemoryHigh`
653
+ * brake, and a brake with no swap under it stalls the session instead of
654
+ * slowing it. The unmeasured branch still writes 0: no measurement, no swap to
655
+ * hand out.
626
656
  *
627
- * No `MemoryHigh` here either. Soft pressure on the slice would throttle every
628
- * session on the machine to keep one runaway alive a little longer — the exact
629
- * trade the spike measured and rejected: 60 seconds of delays instead of a
630
- * 380 ms honest death.
657
+ * No `MemoryHigh` here. The brake belongs on each SCOPE (`session-cage.ts`):
658
+ * soft pressure on the slice would throttle every session on the machine to
659
+ * keep one runaway alive a little longer.
631
660
  *
632
661
  * `sessionsUsageBytes` is the door that used to lead around all of the above.
633
662
  * `facts` is null whenever the SERVICE's `MemoryCurrent` is unreadable — a
@@ -664,8 +693,8 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
664
693
  `MemoryMax=${asMiB(memory.maxBytes)}`,
665
694
  ]
666
695
  : unmeasuredSliceCeiling(sessionsUsageBytes)),
667
- // The line the cage is built on. See `session-cage.ts`.
668
- 'MemorySwapMax=0',
696
+ // The line the cage is built on. See `session-cage.ts` and the note above.
697
+ `MemorySwapMax=${memory && memory.swapMaxBytes > 0 ? asMiB(memory.swapMaxBytes) : '0'}`,
669
698
  'TasksMax=8192',
670
699
  'MemoryAccounting=yes',
671
700
  'TasksAccounting=yes',
@@ -773,6 +802,14 @@ function fileIsOutdated(readFile, target, facts) {
773
802
  */
774
803
  const CEILING_DRIFT_TOLERANCE = 0.15;
775
804
  function memoryCeilingHasDrifted(contents, facts) {
805
+ // The swap share drifts for its own reason, and on its own schedule: an
806
+ // operator who enlarges the machine's swapfile (the one lever that makes the
807
+ // brake a brake rather than a freeze) would otherwise keep the old, smaller
808
+ // share until the next `LIMITS_VERSION` — and each session would keep getting
809
+ // half of it (#387 QA). Zero to non-zero and back is always drift; above that
810
+ // the same tolerance as the ceiling.
811
+ if (swapShareHasDrifted(contents, facts))
812
+ return true;
776
813
  // Never re-measure a machine that is still booting: the static pair it is
777
814
  // holding is deliberate, and «drift» against a lie is not drift.
778
815
  if (!memoryPolicy(facts).measured)
@@ -795,6 +832,19 @@ function memoryCeilingHasDrifted(contents, facts) {
795
832
  const wanted = memoryPolicy(facts).maxBytes;
796
833
  return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
797
834
  }
835
+ function swapShareHasDrifted(contents, facts) {
836
+ const written = [...contents.matchAll(/^MemorySwapMax=(\d+)([KMG]?)$/gm)].at(-1);
837
+ // Not written at all: only the slice file carries this line, and the service
838
+ // file legitimately has none. Silence is not drift.
839
+ if (!written?.[1])
840
+ return false;
841
+ const scale = { '': 1, K: 1024, M: MIB, G: 1024 * MIB }[written[2] ?? ''] ?? MIB;
842
+ const writtenBytes = Number(written[1]) * scale;
843
+ const wanted = memoryPolicy(facts).swapMaxBytes;
844
+ if (writtenBytes === 0 || wanted === 0)
845
+ return writtenBytes !== wanted;
846
+ return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
847
+ }
798
848
  /**
799
849
  * Write the drop-in. Returns false when nothing needed doing.
800
850
  *