@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.
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { findClaudeCli, USE_BUNDLED_CLAUDE } from './agent-binary.js';
7
+ import { systemdUserEnv } from './environment.js';
7
8
  import { log } from './log.js';
8
9
  import { stateDir } from './paths.js';
9
10
  import { RUNNER_VERSION } from './version.js';
@@ -330,6 +331,8 @@ async function installGlobal(exec, source, prefix) {
330
331
  * npm needs PATH/HOME and a writable cache; everything else is stripped, both to
331
332
  * keep provider credentials out of a child process that touches the network and
332
333
  * to stop a stray `npm_config_*` from redirecting the install.
334
+ *
335
+ * NOT for `systemctl` — see `systemctlEnv()` below.
333
336
  */
334
337
  function npmEnv() {
335
338
  const env = {
@@ -344,6 +347,44 @@ function npmEnv() {
344
347
  env['XDG_CACHE_HOME'] = process.env['XDG_CACHE_HOME'];
345
348
  return env;
346
349
  }
350
+ /**
351
+ * What `systemctl --user` needs, and nothing more.
352
+ *
353
+ * `npmEnv()` was used here too, and that was the whole defect (plan §5.5): it
354
+ * strips `XDG_RUNTIME_DIR`, which is how the client finds this user's D-Bus
355
+ * socket. Without it systemd prints «Failed to connect to bus: No medium found»
356
+ * and exits **0** — so every self-update reported a `daemon-reload` that never
357
+ * happened, and the memory ceiling written into the drop-in a line earlier never
358
+ * came into effect. In the file 7680M, in effect 6.0G, and no error anywhere.
359
+ *
360
+ * The two environments are merged rather than swapped, because each one answers
361
+ * a question the other does not:
362
+ *
363
+ * - `systemdUserEnv()` alone would work, and is what the rest of the runner
364
+ * passes to `systemctl`. But it starts from the full `process.env`, and in
365
+ * the daemon that includes the agent provider keys. This module deliberately
366
+ * hands no child a credential it has no use for (there is a test for exactly
367
+ * that), and `systemctl` has no use for one.
368
+ * - `npmEnv()` alone is the bug.
369
+ *
370
+ * So: the minimal shape of `npmEnv()` — PATH to find the binary, HOME for
371
+ * completeness — plus the two bus variables `systemdUserEnv()` resolves. The
372
+ * `npm_config_*` half is left out; it means nothing to systemctl.
373
+ */
374
+ function systemctlEnv() {
375
+ const resolved = systemdUserEnv();
376
+ const env = {
377
+ PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin',
378
+ HOME: process.env['HOME'] ?? '',
379
+ };
380
+ const runtimeDir = resolved['XDG_RUNTIME_DIR'];
381
+ if (runtimeDir)
382
+ env['XDG_RUNTIME_DIR'] = runtimeDir;
383
+ const busAddress = resolved['DBUS_SESSION_BUS_ADDRESS'];
384
+ if (busAddress)
385
+ env['DBUS_SESSION_BUS_ADDRESS'] = busAddress;
386
+ return env;
387
+ }
347
388
  export async function selfUpdate(options) {
348
389
  const exec = options.exec ??
349
390
  ((file, args, opts) => execFileAsync(file, args, { timeout: opts.timeout, env: opts.env, maxBuffer: 4_000_000 }));
@@ -501,7 +542,7 @@ export async function selfUpdate(options) {
501
542
  fs.writeFileSync(unitPath(), buildUnit(installed.command));
502
543
  await exec('systemctl', ['--user', 'daemon-reload'], {
503
544
  timeout: VERIFY_TIMEOUT_MS,
504
- env: npmEnv(),
545
+ env: systemctlEnv(),
505
546
  });
506
547
  log.warn('self-update: the service unit pointed at the previous location — rewritten', {
507
548
  execStart: installed.command,
@@ -525,7 +566,7 @@ export async function selfUpdate(options) {
525
566
  if ((options.writeLimits ?? writeLimitsOverride)()) {
526
567
  await exec('systemctl', ['--user', 'daemon-reload'], {
527
568
  timeout: VERIFY_TIMEOUT_MS,
528
- env: npmEnv(),
569
+ env: systemctlEnv(),
529
570
  });
530
571
  log.warn('self-update: resource limits drop-in written', {
531
572
  path: limitsOverridePath(),
@@ -57,8 +57,45 @@ 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 = 3;
60
+ export declare const LIMITS_VERSION = 4;
61
+ /**
62
+ * Where the agent sessions live once they have a cage of their own.
63
+ *
64
+ * A `systemd-run --scope` is a SIBLING of the service, not a child of it: a
65
+ * session started that way leaves the service's `MemoryMax`, `CPUQuota`,
66
+ * `OOMPolicy=continue` and `KillMode=control-group` behind entirely. So the
67
+ * collective ceiling has to move with them, onto the slice — otherwise the cage
68
+ * per session would arrive at the price of the ceiling over all of them.
69
+ *
70
+ * The dash is systemd's hierarchy separator: `devbridge-sessions.slice` is a
71
+ * child of `devbridge.slice`, which is where the CPU share for everything the
72
+ * agents run is set.
73
+ */
74
+ export declare const SESSIONS_SLICE = "devbridge-sessions.slice";
75
+ export declare const DEVBRIDGE_SLICE = "devbridge.slice";
76
+ /**
77
+ * Half the default weight, on `devbridge.slice` and on every session scope.
78
+ *
79
+ * Lives here rather than in `session-cage.ts` because the number has to be the
80
+ * SAME in both places — the slice sets the share of the agents against the
81
+ * daemon, the scope sets one session's share against another's — and a
82
+ * duplicated literal is how those two drift apart. Why 50 and what it replaces:
83
+ * `buildDevbridgeSliceOverride` below.
84
+ */
85
+ export declare const SESSION_CPU_WEIGHT = 50;
61
86
  export declare function limitsOverridePath(home?: string): string;
87
+ /**
88
+ * Drop-in path for a slice unit that has no unit FILE at all.
89
+ *
90
+ * systemd synthesises `devbridge-sessions.slice` the first time something asks
91
+ * for it, and it reads drop-ins for the synthesised unit exactly as for a real
92
+ * one — verified on this machine: a `[Slice] MemoryMax=64M` drop-in with no
93
+ * fragment gave `MemoryMax=67108864` on the live slice. So there is no unit file
94
+ * to write and no unit file to keep in sync; the policy is the drop-in.
95
+ */
96
+ export declare function sliceOverridePath(slice: string, home?: string): string;
97
+ export declare function sessionsSliceOverridePath(home?: string): string;
98
+ export declare function devbridgeSliceOverridePath(home?: string): string;
62
99
  /**
63
100
  * What the memory policy needs to know about this machine. Read once and passed
64
101
  * in, so the policy itself is a pure function that a test can drive with the
@@ -76,6 +113,43 @@ export interface MemoryFacts {
76
113
  * as the floor under the ceiling; see `memoryPolicy`.
77
114
  */
78
115
  ownUsageBytes: number;
116
+ /**
117
+ * What a LOWER ceiling could not reclaim its way out of — the number the
118
+ * ceiling has to clear, and not the same question as the one above.
119
+ *
120
+ * Equal to `ownUsageBytes` whenever the split is known, which is every path
121
+ * that can read the cgroup's own `memory.stat`. It differs only where the
122
+ * split is unknown — systemd answered `MemoryCurrent` but the files behind it
123
+ * could not be read — and there it is the FULL reading: a ceiling written
124
+ * under an unknown cgroup must assume none of it can be given back.
125
+ *
126
+ * Page cache is deliberately NOT part of it when the split IS known: lowering
127
+ * `MemoryMax` under clean file pages makes the kernel reclaim them, not kill
128
+ * anything, so counting them would inflate every ceiling by whatever the
129
+ * machine happened to have cached (measured: +87 % on this host) — and the
130
+ * floor is applied AFTER the 85 %-of-total cap, so that inflation walks the
131
+ * ceiling straight past the cap this policy exists to enforce.
132
+ */
133
+ ownFloorBytes?: number;
134
+ /**
135
+ * The same reading for `devbridge-sessions.slice`, and the reason it is a
136
+ * separate number rather than part of the one above.
137
+ *
138
+ * Since 0.54.0 the agents live in that slice, NOT in the service's cgroup, and
139
+ * the ceiling this policy computes is written to both units. So both cgroups
140
+ * are invisible to `MemAvailable` and both have to survive the write — a
141
+ * policy that measures only the daemon computes a ceiling from a machine it
142
+ * cannot see and then applies it to one it can kill (QA-2026-09-07 BLOCKER-1:
143
+ * the hourly re-measure wrote 2 GiB onto a slice holding 6 GiB, with
144
+ * `MemorySwapMax=0`, which is every session on the machine).
145
+ *
146
+ * 0 is the honest answer on a machine where the cage never took: there the
147
+ * sessions are still inside the service's cgroup and `ownUsageBytes` already
148
+ * counts them.
149
+ */
150
+ sessionsUsageBytes: number;
151
+ /** The same distinction as {@link MemoryFacts.ownFloorBytes}, for the slice. */
152
+ sessionsFloorBytes?: number;
79
153
  /** Seconds since boot. Below `BOOT_SETTLE_SEC` the measurement is a lie. */
80
154
  uptimeSec: number;
81
155
  }
@@ -102,6 +176,8 @@ export interface MemoryPolicy {
102
176
  * produces a ceiling that protects nothing.
103
177
  */
104
178
  export declare const BOOT_SETTLE_SEC = 600;
179
+ /** What the ceiling about to be written has to clear, in either cgroup. */
180
+ export declare function managedUsageFloorBytes(facts: MemoryFacts): number;
105
181
  /**
106
182
  * The two numbers, and the incident that decides them.
107
183
  *
@@ -122,8 +198,10 @@ export declare const BOOT_SETTLE_SEC = 600;
122
198
  *
123
199
  * So the percentage has to be of what the machine can SPARE, not of what it has:
124
200
  *
125
- * headroom = MemAvailable + our own usage (ours is added back, or every
126
- * rewrite would walk the ceiling down by what we already hold)
201
+ * headroom = MemAvailable + everything WE hold (ours is added back, or every
202
+ * rewrite would walk the ceiling down by what we already hold —
203
+ * and since 0.54.0 «ours» is the daemon's cgroup PLUS the sessions
204
+ * slice, because the agents moved out of the daemon's one)
127
205
  * MemoryMax = headroom − reserve
128
206
  * MemoryHigh = 80 % of MemoryMax (reclaim and throttle first, kill last)
129
207
  *
@@ -143,6 +221,7 @@ export declare const BOOT_SETTLE_SEC = 600;
143
221
  * machine, and a ceiling of «everything» is the bug this function exists to fix.
144
222
  */
145
223
  export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: number): MemoryPolicy;
224
+ export declare function readOwnCgroupMemory(): CgroupMemory | null;
146
225
  /**
147
226
  * What the cgroup holds that `MemAvailable` has NOT already counted.
148
227
  *
@@ -154,17 +233,92 @@ export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: numbe
154
233
  * precisely when memory is tightest. Subtracting `file` keeps the part we really
155
234
  * do hold and cannot give back on demand.
156
235
  */
236
+ export interface CgroupMemory {
237
+ /** Everything the cgroup holds, page cache included. */
238
+ currentBytes: number;
239
+ /**
240
+ * The part of it a lower ceiling could not reclaim its way out of, or null
241
+ * when only the total is known — systemd answered `MemoryCurrent` but the
242
+ * cgroup's own files could not be read. Null is «unknown», never «zero»:
243
+ * the two lead to different ceilings, and only one of them is safe.
244
+ */
245
+ unreclaimableBytes: number | null;
246
+ }
247
+ export declare function readCgroupMemory(dir: string): CgroupMemory | null;
157
248
  export declare function readCgroupUnreclaimable(dir: string): number | null;
249
+ /**
250
+ * Where a slice unit's cgroup lives, worked out from the cgroup we are in.
251
+ *
252
+ * systemd's dash rule spells the hierarchy out: `devbridge-sessions.slice` sits
253
+ * inside `devbridge.slice`, which sits directly under the user manager's own
254
+ * cgroup — verified on this host with a throwaway `--slice=dbqa-probe-sub.slice`,
255
+ * which landed in `user@0.service/dbqa.slice/dbqa-probe.slice/dbqa-probe-sub.slice`.
256
+ *
257
+ * The user manager's cgroup is found rather than assembled: the runner runs as
258
+ * root and as a dedicated user (`user@0.service`, `user@1001.service`), and the
259
+ * same guess-the-path mistake that `readOwnCgroupUsage` avoids would silently
260
+ * return 0 here — which is exactly the blindness BLOCKER-1 was.
261
+ *
262
+ * Null means «there is no user manager above us», and then there is no
263
+ * `--user` slice for the sessions to be in either.
264
+ */
265
+ export declare function sliceCgroupPath(selfCgroup: string, slice: string): string | null;
266
+ /**
267
+ * What the agents are holding right now, outside the daemon's own cgroup — or
268
+ * null where this machine cannot say.
269
+ *
270
+ * Read from the filesystem rather than through `systemctl show`, because this is
271
+ * the hourly path inside the daemon and it has to stay synchronous — the same
272
+ * reason `readOwnCgroupUsage` reads `/proc`. Callers that are not the daemon can
273
+ * pass the number in; see {@link readMemoryFacts}.
274
+ *
275
+ * A missing directory is 0 and not «unknown»: systemd removes the cgroup of an
276
+ * empty slice, so «no directory» means «no session is holding anything». The two
277
+ * are kept apart because they now lead to opposite decisions — 0 lets a blind
278
+ * ceiling be written onto the slice, «unknown» forbids it (see
279
+ * {@link buildSessionsSliceOverride}).
280
+ */
281
+ export declare function readSessionsSliceMemory(): CgroupMemory | null;
282
+ /**
283
+ * The same tri-state, read out of `systemctl show` instead of the filesystem —
284
+ * the authoritative source for the paths a person types (`doctor --fix`,
285
+ * `install-service`), which run outside the daemon's cgroup.
286
+ *
287
+ * `[not set]` is the ambiguous answer and the reason `activeState` is asked for
288
+ * as well: systemd prints it for a slice that has no cgroup (nothing has ever
289
+ * run there) AND for one whose accounting is off, and those two must not lead to
290
+ * the same decision. Only «systemd loaded the unit and it is not even active» is
291
+ * a positive statement that nothing can be killed by what we write; everything
292
+ * else is «unknown», which writes no ceiling at all.
293
+ *
294
+ * A failed `systemctl` call is null on both counts — including the 10-second
295
+ * timeout, which fires exactly on the overloaded machine this policy protects.
296
+ */
297
+ export declare function parseSliceUsage(memoryCurrent: string | null, activeState: string | null): number | null;
298
+ /**
299
+ * The slice's number for the callers that take one, and «unknown» kept apart
300
+ * from «empty»: an unreadable live slice must never be replaced with zero.
301
+ *
302
+ * The FLOOR reading, not the headroom one — this feeds a ceiling that has to
303
+ * clear what the slice holds, and where the split is unknown the whole reading
304
+ * has to be assumed unreclaimable.
305
+ */
306
+ export declare function readSessionsSliceUsageOrNull(): number | null;
158
307
  /**
159
308
  * Everything `memoryPolicy` needs, straight off this machine.
160
309
  *
161
- * `ownUsageBytes` is passed in by callers that are not the daemon — `doctor` and
162
- * `install-service` run in the operator's own cgroup and cannot read the
163
- * service's usage from `/proc/self`. Returns null when the service's usage is
164
- * unknowable, because guessing 0 there is the one dangerous direction: it removes
165
- * the floor that stops a live session from being killed on `daemon-reload`.
310
+ * Both readings are passed in by callers that are not the daemon — `doctor` and
311
+ * `install-service` run in the operator's own `session-N.scope` and cannot read
312
+ * the service's usage from `/proc/self`; they ask systemd instead
313
+ * (`readMemoryFactsFromSystemd`). Returns null when either cgroup is unknowable,
314
+ * because guessing there is the one dangerous direction: it removes the floor
315
+ * that stops a live session from being killed on the next `daemon-reload`.
316
+ *
317
+ * The default reads both from the filesystem, which is right for the daemon:
318
+ * its own cgroup through `/proc/self`, and the sessions slice through the user
319
+ * manager's cgroup, which sits above the daemon and the CLI alike.
166
320
  */
167
- export declare function readMemoryFacts(ownUsageBytes?: number | null): MemoryFacts | null;
321
+ export declare function readMemoryFacts(own?: CgroupMemory | null, sessions?: CgroupMemory | null): MemoryFacts | null;
168
322
  /**
169
323
  * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
170
324
  * box unreachable, but never so little that ordinary work is throttled.
@@ -176,6 +330,74 @@ export declare function readMemoryFacts(ownUsageBytes?: number | null): MemoryFa
176
330
  */
177
331
  export declare function cpuQuotaPercent(cpuCount?: number): number | null;
178
332
  export declare function buildLimitsOverride(cpuCount?: number, facts?: MemoryFacts | null): string;
333
+ /**
334
+ * The ceiling over ALL sessions, on the slice they were moved into.
335
+ *
336
+ * Same number as the service's, and deliberately so — but it is a COPY, not a
337
+ * move, and that is deliberate too. The plan asked for the ceiling to be carried
338
+ * across; shrinking the service's to «what the daemon alone needs» would be
339
+ * correct only on a machine where the cage actually took. On cgroup v1, without
340
+ * a user bus, under a foreign supervisor — every `nice-only` machine — the
341
+ * sessions are still CHILDREN of the service, and a service ceiling sized for
342
+ * the daemon would cap all of them at a few hundred MB. This file is written
343
+ * before anything has probed which of the two machines this is, so the safe
344
+ * shape is the same number twice: on a caged machine the slice is the ceiling
345
+ * that binds, on an uncaged one the service is, and neither machine is ever
346
+ * left with a ceiling that is too small for what is under it.
347
+ *
348
+ * The price is that a caged machine formally permits `service + slice`. It is
349
+ * not the guarantee `memoryPolicy` computes, and it is written down here rather
350
+ * than glossed over (QA-2026-09-07 MINOR-4).
351
+ *
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.
360
+ *
361
+ * `sessionsUsageBytes` is the door that used to lead around all of the above.
362
+ * `facts` is null whenever the SERVICE's `MemoryCurrent` is unreadable — a
363
+ * stopped service, or a runner under a foreign supervisor — and this file then
364
+ * fell back to a flat `MemoryMax=55%`. But the sessions are SIBLINGS of the
365
+ * service, not its children: they survive `systemctl --user stop
366
+ * devbridge-runner`, so «the service is not running» says nothing at all about
367
+ * what the slice is holding, and `doctor --fix` on such a machine wrote 55 % of
368
+ * total onto a live slice and then called `daemon-reload` — the collective kill
369
+ * of QA-2026-09-07 BLOCKER-1 arriving through a door with no policy behind it.
370
+ *
371
+ * So the slice's own usage is read separately, and the promise made on
372
+ * `buildLimitsOverride` («the floor belongs to the policy, so every caller gets
373
+ * it and none can opt out») holds on this path too:
374
+ * - a number → the ceiling clears it by the same 1.25 the policy uses, and
375
+ * never drops below what one agent needs;
376
+ * - 0 → nothing is running there, so the blind fraction can kill
377
+ * nothing and stays;
378
+ * - null → this machine could not say, and NO ceiling is written at all.
379
+ * Leaving whatever is in force in force is strictly better than
380
+ * applying an unfounded number to a cgroup that may be full: the
381
+ * daemon rewrites the file with a measured ceiling the moment it
382
+ * can measure one (the drift check treats a file with no
383
+ * `MemoryMax` as outdated).
384
+ */
385
+ export declare function buildSessionsSliceOverride(facts?: MemoryFacts | null, sessionsUsageBytes?: number | null): string;
386
+ /**
387
+ * The CPU share of everything the agents run, against the daemon's own.
388
+ *
389
+ * `nice(2)` orders tasks INSIDE one cgroup. The moment a session gets a scope of
390
+ * its own it is no longer inside the service's cgroup, and the split between the
391
+ * two is decided by `cpu.weight` — which is 100 everywhere by default,
392
+ * `app.slice` (where the service lives) included. Without this file the cage
393
+ * would silently undo stage 1a and hand back the failure of 16.08: the daemon
394
+ * starved by its own children, four missed heartbeats, the server Offline and
395
+ * 504 on every session. 50 against 100 leaves the daemon two thirds.
396
+ *
397
+ * `process-priority.ts` stays exactly as it is: it is what protects the daemon
398
+ * on cgroup v1 and on every machine where the cage does not apply.
399
+ */
400
+ export declare function buildDevbridgeSliceOverride(): string;
179
401
  /**
180
402
  * Is the shipped resource policy missing or from an older runner?
181
403
  *
@@ -195,7 +417,7 @@ export declare function limitsOverrideIsOutdated(readFile?: (p: string) => strin
195
417
  * arrive at the same number — otherwise a write whose floor was binding would be
196
418
  * seen as drifted on the very next call and rewritten forever.
197
419
  */
198
- export declare function writeLimitsOverride(force?: boolean, home?: string, facts?: MemoryFacts | null): boolean;
420
+ export declare function writeLimitsOverride(force?: boolean, home?: string, facts?: MemoryFacts | null, sessionsUsageBytes?: number | null): boolean;
199
421
  /**
200
422
  * Does the installed unit point at something that no longer exists?
201
423
  *