@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 { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
4
4
  import { installAgent } from './agent-install.js';
5
5
  import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
6
  import { type AgentVersionsMeasurement } from './agent-versions.js';
7
+ import { type HostLoadFrame } from './host-load.js';
7
8
  import type { RunnerWsClient } from './ws-client.js';
8
9
  import type { SessionDescriptor } from './protocol.js';
9
10
  import type { AgentAdapter } from './adapters/types.js';
@@ -122,6 +123,29 @@ export interface SupervisorOptions {
122
123
  * in production.
123
124
  */
124
125
  stopSettleMs?: number;
126
+ /**
127
+ * Test seam for the host-load measurement (§5.3).
128
+ *
129
+ * A seam because the real one reads THIS machine's `/proc`, and a test that
130
+ * had to arrange the kernel into «load 12.4, swap 98 % used» would not be
131
+ * written — so the rule this frame exists for (send only what moved) would
132
+ * go unverified on the one machine shape that matters.
133
+ */
134
+ readHostLoad?: () => HostLoadFrame | null;
135
+ /**
136
+ * How often `/proc` is sampled, in ms — a test seam over
137
+ * `HOST_LOAD_SAMPLE_INTERVAL_MS`, for the same reason `agentCleanupMs`
138
+ * exists: the real cadence is 30 s and this suite runs on real timers (fake
139
+ * ones fire the WS client's liveness watchdog and kill the socket). Never
140
+ * set in production.
141
+ */
142
+ hostLoadSampleMs?: number;
143
+ /**
144
+ * The heartbeat window, in ms — a test seam over `HOST_LOAD_HEARTBEAT_MS`.
145
+ * The real one is five minutes; without this seam «an unchanged machine
146
+ * still reports» would be an untested promise. Never set in production.
147
+ */
148
+ hostLoadHeartbeatMs?: number;
125
149
  }
126
150
  export declare class Supervisor {
127
151
  private readonly ws;
@@ -346,6 +370,39 @@ export declare class Supervisor {
346
370
  * it had room. Both were caught by the independent QA review of this change.
347
371
  */
348
372
  private publishSlots;
373
+ /** The heartbeat window actually used — the constant, or a test's own. */
374
+ private readonly hostLoadHeartbeatMs;
375
+ private readonly hostLoadTimer;
376
+ /** The last measurement the API actually took from us, or `null` for «nothing yet». */
377
+ private lastPublishedHostLoad;
378
+ /** When that frame went out, by this machine's clock. `0` = never. */
379
+ private lastHostLoadSentAt;
380
+ /**
381
+ * Tell the API what this machine's load looks like — when it is worth telling.
382
+ *
383
+ * Three ways a frame goes out, and each covers a hole the others leave:
384
+ *
385
+ * - the value MOVED past a threshold (`hostLoadChangedEnough`) — the reason
386
+ * the frame exists, and the only one that makes the card timely;
387
+ * - the heartbeat came due — an idle machine still has to say «still here,
388
+ * still idle», because the API expires the measurement after two minutes
389
+ * and silence would otherwise turn «nothing is happening» into «we have no
390
+ * idea» on a perfectly healthy card;
391
+ * - nothing was ever sent on this connection (`hello_ack` clears the memory)
392
+ * — a fresh socket knows nothing about what the last one was told.
393
+ *
394
+ * `null` from the sampler is a complete answer: not Linux, `/proc` masked, a
395
+ * kernel too old for `MemAvailable`. Nothing is sent and nothing is logged —
396
+ * a machine that cannot measure itself leaves the card saying «no data»,
397
+ * which is exactly what is true.
398
+ *
399
+ * Recorded ONLY when the socket took it, same as `publishSlots`: a frame
400
+ * dropped by a dead socket must not be remembered as sent, or a machine whose
401
+ * load never moves again would go silent until the heartbeat — and on a
402
+ * reconnect the API would have no measurement at all while this side believed
403
+ * it had one.
404
+ */
405
+ private publishHostLoad;
349
406
  private onFrame;
350
407
  private startSession;
351
408
  /**
@@ -25,6 +25,7 @@ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
25
25
  import { agentByDbValue } from './agent-registry.js';
26
26
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
27
27
  import { rememberWorkspacePath } from './environment.js';
28
+ import { hostLoadChangedEnough, hostLoadHeartbeatDue, readHostLoad, HOST_LOAD_HEARTBEAT_MS, HOST_LOAD_SAMPLE_INTERVAL_MS, } from './host-load.js';
28
29
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
29
30
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
30
31
  import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
@@ -209,6 +210,21 @@ export class Supervisor {
209
210
  this.agentCleanupFirstTimer.unref?.();
210
211
  this.agentCleanupTimer = setInterval(() => this.sweepOldAgentVersions(), cleanupEvery);
211
212
  this.agentCleanupTimer.unref?.();
213
+ /**
214
+ * The machine's own load, sampled on a timer for the reason the seat report
215
+ * is: nothing on this side is an EVENT. Load is not something the runner
216
+ * does, it is something that happens to it — a `pnpm build` a person
217
+ * started over ssh, a neighbour container, this machine's own three agents
218
+ * — and there is no call site to hook. A tick that re-reads the truth is
219
+ * the only shape that cannot be forgotten.
220
+ *
221
+ * Nearly free, and quiet by default: two small files are read, and
222
+ * `publishHostLoad` returns without touching the socket unless the number
223
+ * actually moved (or the heartbeat came due).
224
+ */
225
+ this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
226
+ this.hostLoadTimer = setInterval(() => this.publishHostLoad(), opts.hostLoadSampleMs ?? HOST_LOAD_SAMPLE_INTERVAL_MS);
227
+ this.hostLoadTimer.unref?.();
212
228
  }
213
229
  /** How often the agent versions are re-derived. See the constructor. */
214
230
  static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
@@ -572,6 +588,53 @@ export class Supervisor {
572
588
  this.lastPublishedSlots = fingerprint;
573
589
  }
574
590
  }
591
+ /** The heartbeat window actually used — the constant, or a test's own. */
592
+ hostLoadHeartbeatMs;
593
+ hostLoadTimer;
594
+ /** The last measurement the API actually took from us, or `null` for «nothing yet». */
595
+ lastPublishedHostLoad = null;
596
+ /** When that frame went out, by this machine's clock. `0` = never. */
597
+ lastHostLoadSentAt = 0;
598
+ /**
599
+ * Tell the API what this machine's load looks like — when it is worth telling.
600
+ *
601
+ * Three ways a frame goes out, and each covers a hole the others leave:
602
+ *
603
+ * - the value MOVED past a threshold (`hostLoadChangedEnough`) — the reason
604
+ * the frame exists, and the only one that makes the card timely;
605
+ * - the heartbeat came due — an idle machine still has to say «still here,
606
+ * still idle», because the API expires the measurement after two minutes
607
+ * and silence would otherwise turn «nothing is happening» into «we have no
608
+ * idea» on a perfectly healthy card;
609
+ * - nothing was ever sent on this connection (`hello_ack` clears the memory)
610
+ * — a fresh socket knows nothing about what the last one was told.
611
+ *
612
+ * `null` from the sampler is a complete answer: not Linux, `/proc` masked, a
613
+ * kernel too old for `MemAvailable`. Nothing is sent and nothing is logged —
614
+ * a machine that cannot measure itself leaves the card saying «no data»,
615
+ * which is exactly what is true.
616
+ *
617
+ * Recorded ONLY when the socket took it, same as `publishSlots`: a frame
618
+ * dropped by a dead socket must not be remembered as sent, or a machine whose
619
+ * load never moves again would go silent until the heartbeat — and on a
620
+ * reconnect the API would have no measurement at all while this side believed
621
+ * it had one.
622
+ */
623
+ publishHostLoad() {
624
+ const sample = (this.opts.readHostLoad ?? readHostLoad)();
625
+ if (!sample)
626
+ return;
627
+ const now = Date.now();
628
+ const sinceLastSent = now - this.lastHostLoadSentAt;
629
+ // A backwards clock step is «due», not «early» — see `hostLoadHeartbeatDue`.
630
+ const heartbeatDue = hostLoadHeartbeatDue(sinceLastSent, this.hostLoadHeartbeatMs);
631
+ if (!heartbeatDue && !hostLoadChangedEnough(this.lastPublishedHostLoad, sample))
632
+ return;
633
+ if (this.ws.send({ type: 'host_load', ...sample })) {
634
+ this.lastPublishedHostLoad = sample;
635
+ this.lastHostLoadSentAt = now;
636
+ }
637
+ }
575
638
  async onFrame(frame) {
576
639
  switch (frame.type) {
577
640
  case 'hello_ack':
@@ -580,9 +643,17 @@ export class Supervisor {
580
643
  // because it is only true while the socket is. Forget what we told the
581
644
  // old one so the first tick after this reconnect actually sends.
582
645
  this.lastPublishedSlots = '';
646
+ // Same rule for the load: the API keeps the measurement in Redis beside
647
+ // the socket, with a TTL shorter than our heartbeat, so a machine that
648
+ // has just come back has no load on its card at all. Forget what the
649
+ // last connection was told and say it again immediately — a card that
650
+ // is right only after five minutes of silence is not right.
651
+ this.lastPublishedHostLoad = null;
652
+ this.lastHostLoadSentAt = 0;
583
653
  this.setMaxSessions(frame.maxSessions);
584
654
  await this.reconcile(frame.sessions);
585
655
  this.publishSlots();
656
+ this.publishHostLoad();
586
657
  // A build that finished while the socket was down has its verdict
587
658
  // sitting on disk. This is the moment it can be delivered.
588
659
  this.flushVerifyReports();
@@ -5805,6 +5876,7 @@ export class Supervisor {
5805
5876
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
5806
5877
  shutdown() {
5807
5878
  clearInterval(this.slotsTimer);
5879
+ clearInterval(this.hostLoadTimer);
5808
5880
  clearInterval(this.agentVersionsTimer);
5809
5881
  clearTimeout(this.agentCleanupFirstTimer);
5810
5882
  clearInterval(this.agentCleanupTimer);
@@ -0,0 +1,35 @@
1
+ import { type CgroupMemory, type MemoryFacts } from './service-unit.js';
2
+ /**
3
+ * Read the unit's group, never the CLI's own `/proc/self` group. A missing
4
+ * reading is not an empty service: a timed-out user bus can belong to a busy
5
+ * machine. Raw `memory.current` deliberately survives beside the split, so the
6
+ * safety floor never depends on an estimate of how much can be reclaimed.
7
+ */
8
+ export declare function unitMemoryReading(output: string, cgroupRoot?: string): CgroupMemory | null;
9
+ /**
10
+ * Both units, measured through systemd, with the filesystem underneath.
11
+ *
12
+ * One helper so `doctor`, `doctor --fix`, `install-service` and the daemon's
13
+ * hourly re-measure cannot drift apart in what they measure — which is how the
14
+ * CLI once computed a ceiling 34 % away from the daemon's and the two rewrote
15
+ * the file forever.
16
+ *
17
+ * The fallback is not a second opinion, it is the same reading taken another
18
+ * way, and it is safe for every caller precisely because each reader
19
+ * self-identifies: `readOwnCgroupMemory` hands back null for any process that
20
+ * is not the service itself, so the daemon measures itself when the bus is
21
+ * unhappy and a CLI never mistakes its own `session-N.scope` for the service.
22
+ * Without it, one slow `systemctl` meant the daemon wrote no policy at all —
23
+ * for an hour, on the overloaded machine this policy exists to protect.
24
+ */
25
+ export declare function readMemoryFactsFromSystemd(): Promise<MemoryReadings>;
26
+ export interface MemoryReadings {
27
+ facts: MemoryFacts | null;
28
+ sessionsUsageBytes: number | null;
29
+ }
30
+ /** The half of the above that has no bus in it, so it can be tested. */
31
+ export declare function memoryReadings(service: CgroupMemory | null, sessions: CgroupMemory | null, fromFilesystem?: {
32
+ own: () => CgroupMemory | null;
33
+ sessions: () => CgroupMemory | null;
34
+ }): MemoryReadings;
35
+ //# sourceMappingURL=systemd-memory.d.ts.map
@@ -0,0 +1,115 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { systemdUserEnv } from './environment.js';
6
+ import { readCgroupMemory, readMemoryFacts, readOwnCgroupMemory, readSessionsSliceMemory, SERVICE_NAME, SESSIONS_SLICE, } from './service-unit.js';
7
+ const execFileAsync = promisify(execFile);
8
+ function bytes(raw) {
9
+ if (raw === undefined || !/^\d+$/.test(raw.trim()))
10
+ return null;
11
+ const value = Number(raw);
12
+ // systemd prints UINT64_MAX for «not set» on some versions, which is above
13
+ // `MAX_SAFE_INTEGER` and would otherwise arrive as a plausible byte count.
14
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
15
+ }
16
+ /**
17
+ * Read the unit's group, never the CLI's own `/proc/self` group. A missing
18
+ * reading is not an empty service: a timed-out user bus can belong to a busy
19
+ * machine. Raw `memory.current` deliberately survives beside the split, so the
20
+ * safety floor never depends on an estimate of how much can be reclaimed.
21
+ */
22
+ export function unitMemoryReading(output, cgroupRoot = '/sys/fs/cgroup') {
23
+ const properties = new Map(output.split('\n').map((line) => {
24
+ const separator = line.indexOf('=');
25
+ return [line.slice(0, separator), line.slice(separator + 1).trim()];
26
+ }));
27
+ // An empty answer is not a fact about the unit: everything below reads meaning
28
+ // into what systemd said, so there has to have been an answer first. A dead
29
+ // bus prints nothing and stays «unknown». `ControlGroup` is not required to be
30
+ // PRESENT — only to be empty where it decides emptiness below — so a systemd
31
+ // that does not print it leaves «unknown» too, and the filesystem reader
32
+ // behind this one covers that machine.
33
+ if (!properties.has('ActiveState'))
34
+ return null;
35
+ const current = bytes(properties.get('MemoryCurrent'));
36
+ const group = properties.get('ControlGroup');
37
+ if (group !== undefined && group.startsWith('/') && group !== '/') {
38
+ const root = path.resolve(cgroupRoot);
39
+ const directory = path.resolve(root, `.${group}`);
40
+ if (directory.startsWith(`${root}${path.sep}`)) {
41
+ try {
42
+ const measured = readCgroupMemory(directory);
43
+ if (measured !== null)
44
+ return measured;
45
+ const raw = bytes(fs.readFileSync(path.join(directory, 'memory.current'), 'utf8'));
46
+ // Without `memory.stat` the split between «cache we can give back» and
47
+ // «memory that has to be killed for» is unknown, and null says so: the
48
+ // floor then assumes all of it, the headroom none of it.
49
+ if (raw !== null)
50
+ return { currentBytes: raw, unreclaimableBytes: null };
51
+ }
52
+ catch {
53
+ // Unsupported controller, removed cgroup or unreadable file: unknown.
54
+ }
55
+ }
56
+ }
57
+ if (current !== null)
58
+ return { currentBytes: current, unreclaimableBytes: null };
59
+ // A unit with NO cgroup holds nothing a new ceiling could kill, whatever its
60
+ // ActiveState says — and that is the whole point of not asking for
61
+ // `inactive` here. `failed` and `activating (auto-restart)` report exactly
62
+ // the shape `inactive` does (verified on systemd 255: `ControlGroup=`,
63
+ // `MemoryCurrent=[not set]`), and those are precisely the states a machine is
64
+ // in when someone runs `install.sh --repair` or `doctor --fix` on it. Reading
65
+ // them as «unknown» turned the cure into a refusal on the only machines that
66
+ // need it.
67
+ return group === '' ? { currentBytes: 0, unreclaimableBytes: 0 } : null;
68
+ }
69
+ async function readUnitUsage(unit) {
70
+ try {
71
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', unit, '-p', 'MemoryCurrent', '-p', 'ActiveState', '-p', 'ControlGroup'], { timeout: 10_000, env: systemdUserEnv() });
72
+ return unitMemoryReading(stdout);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * Both units, measured through systemd, with the filesystem underneath.
80
+ *
81
+ * One helper so `doctor`, `doctor --fix`, `install-service` and the daemon's
82
+ * hourly re-measure cannot drift apart in what they measure — which is how the
83
+ * CLI once computed a ceiling 34 % away from the daemon's and the two rewrote
84
+ * the file forever.
85
+ *
86
+ * The fallback is not a second opinion, it is the same reading taken another
87
+ * way, and it is safe for every caller precisely because each reader
88
+ * self-identifies: `readOwnCgroupMemory` hands back null for any process that
89
+ * is not the service itself, so the daemon measures itself when the bus is
90
+ * unhappy and a CLI never mistakes its own `session-N.scope` for the service.
91
+ * Without it, one slow `systemctl` meant the daemon wrote no policy at all —
92
+ * for an hour, on the overloaded machine this policy exists to protect.
93
+ */
94
+ export async function readMemoryFactsFromSystemd() {
95
+ const [service, sessions] = await Promise.all([
96
+ readUnitUsage(SERVICE_NAME),
97
+ readUnitUsage(SESSIONS_SLICE),
98
+ ]);
99
+ return memoryReadings(service, sessions);
100
+ }
101
+ /** The half of the above that has no bus in it, so it can be tested. */
102
+ export function memoryReadings(service, sessions, fromFilesystem = { own: readOwnCgroupMemory, sessions: readSessionsSliceMemory }) {
103
+ const serviceMemory = service ?? fromFilesystem.own();
104
+ const sessionsMemory = sessions ?? fromFilesystem.sessions();
105
+ return {
106
+ facts: readMemoryFacts(serviceMemory, sessionsMemory),
107
+ // The floor reading, and «unknown» kept apart from «empty»: this number is
108
+ // the only thing standing between a live slice and the ceiling written by
109
+ // a command typed on a machine whose service cannot be measured.
110
+ sessionsUsageBytes: sessionsMemory === null
111
+ ? null
112
+ : (sessionsMemory.unreclaimableBytes ?? sessionsMemory.currentBytes),
113
+ };
114
+ }
115
+ //# sourceMappingURL=systemd-memory.js.map
package/dist/verify.js CHANGED
@@ -5,6 +5,8 @@ import { promisify } from 'node:util';
5
5
  import { log } from './log.js';
6
6
  import { maskString } from './policy.js';
7
7
  import { evaluateRecipeCommand } from './policy.js';
8
+ import { lowerPriority } from './process-priority.js';
9
+ import { cageSpawn, releaseSessionScope } from './session-cage.js';
8
10
  import { recipeFingerprint } from './recipe.js';
9
11
  import { StringDecoder } from 'node:string_decoder';
10
12
  import { stateDir } from './paths.js';
@@ -301,6 +303,7 @@ export class VerifyRunner {
301
303
  logPath,
302
304
  logBytes: 0,
303
305
  child: null,
306
+ scopeUnit: null,
304
307
  cancelled: false,
305
308
  branch: input.branch,
306
309
  commitSha: input.commitSha,
@@ -540,6 +543,11 @@ export class VerifyRunner {
540
543
  settled = true;
541
544
  clearTimeout(timer);
542
545
  run.child = null;
546
+ // Read why the scope ended and let systemd forget it, in that order —
547
+ // a scope the OOM killer took stays in `failed` and would refuse the
548
+ // very same unit name to the next step (`session-cage.ts`).
549
+ void releaseSessionScope(run.scopeUnit, `verify-${run.runId}`);
550
+ run.scopeUnit = null;
543
551
  resolve({
544
552
  step: run.currentStep ?? '',
545
553
  durationMs: this.now() - startedAt,
@@ -547,14 +555,26 @@ export class VerifyRunner {
547
555
  ...(timedOut ? { timedOut: true } : {}),
548
556
  });
549
557
  };
558
+ // A verification step is the heaviest thing on the machine that has an id
559
+ // of its own, so it gets a cage of its own too — keyed by the run rather
560
+ // than by a session, because that is the identity a verify run has. The
561
+ // scope survives `detached: true`: `systemd-run --scope` execs in the
562
+ // same pid, so `setsid` still makes the child its own group leader and
563
+ // `process.kill(-pid)` below still reaches the compilers.
564
+ const caged = cageSpawn({
565
+ id: `verify-${run.runId}`,
566
+ command: '/bin/sh',
567
+ args: ['-c', step.run],
568
+ });
569
+ run.scopeUnit = caged.unit;
550
570
  let child;
551
571
  try {
552
572
  // `detached` so the whole process tree gets the signal: a build script
553
573
  // is a shell that spawns compilers, and killing only the shell leaves
554
574
  // them running with the disk and the CPU.
555
- child = spawn('/bin/sh', ['-c', step.run], {
575
+ child = spawn(caged.command, caged.args, {
556
576
  cwd: run.cwd,
557
- env: buildEnv(step.env),
577
+ env: { ...buildEnv(step.env), ...caged.env },
558
578
  detached: true,
559
579
  stdio: ['ignore', 'pipe', 'pipe'],
560
580
  });
@@ -565,6 +585,12 @@ export class VerifyRunner {
565
585
  return;
566
586
  }
567
587
  run.child = child;
588
+ // A recipe step is the heaviest thing the runner starts on its own — a
589
+ // full build or a full test run — and the one with nobody waiting on a
590
+ // keystroke. It goes behind the daemon and level with the agent sessions;
591
+ // the whole process group inherits it, which is the point, because
592
+ // `detached` means the compilers are down there and not here.
593
+ lowerPriority(child.pid);
568
594
  // A StringDecoder per stream, not `buffer.toString('utf8')` per chunk.
569
595
  //
570
596
  // A pipe read ends wherever the kernel filled the buffer, which is
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.53.0";
1
+ export declare const RUNNER_VERSION = "0.54.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.53.0';
2
+ export const RUNNER_VERSION = '0.54.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",