@bridge4dev/runner 0.54.0 → 0.55.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.
@@ -5,6 +5,7 @@ import { installAgent } from './agent-install.js';
5
5
  import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
6
  import { type AgentVersionsMeasurement } from './agent-versions.js';
7
7
  import { type HostLoadFrame } from './host-load.js';
8
+ import { type ScopeMemoryStatus } from './session-cage.js';
8
9
  import type { RunnerWsClient } from './ws-client.js';
9
10
  import type { SessionDescriptor } from './protocol.js';
10
11
  import type { AgentAdapter } from './adapters/types.js';
@@ -146,6 +147,14 @@ export interface SupervisorOptions {
146
147
  * still reports» would be an untested promise. Never set in production.
147
148
  */
148
149
  hostLoadHeartbeatMs?: number;
150
+ /**
151
+ * #387: the memory watch's two readers, so a test can put a caged session
152
+ * over its brake without a cgroup under it. Production reads the cage module.
153
+ */
154
+ sessionScopeUnitOf?: (id: string) => string | null;
155
+ readScopeMemoryStatus?: (unit: string) => ScopeMemoryStatus | null;
156
+ /** How long over the brake before the second, «do something» warning. A test seam. */
157
+ cageBrakeLongMs?: number;
149
158
  }
150
159
  export declare class Supervisor {
151
160
  private readonly ws;
@@ -222,6 +231,23 @@ export declare class Supervisor {
222
231
  private readonly verify;
223
232
  private readonly verifyReports;
224
233
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
234
+ /**
235
+ * How long a session may sit over its brake before the feed says more than
236
+ * «slower» (#387, step 3). Five minutes is a starting point, not a
237
+ * measurement: an honest `pnpm typecheck` overshoots for tens of seconds, a
238
+ * runaway for ever, and the number that tells them apart on real machines is
239
+ * still to be read off `memory.events`. Sized so the first warning is never
240
+ * the only one a night-time run leaves behind.
241
+ */
242
+ private static readonly CAGE_BRAKE_LONG_MS;
243
+ /**
244
+ * Calm ticks before the feed says the braking is over.
245
+ *
246
+ * `memory.high` holds usage AT the line, so a single sample under it means
247
+ * nothing. Two (a minute at the 30 s cadence) is the difference between an
248
+ * episode that ended and a build that breathed.
249
+ */
250
+ private static readonly CAGE_CALM_TICKS_TO_RELEASE;
225
251
  /** How often the agent versions are re-derived. See the constructor. */
226
252
  private static readonly AGENT_VERSIONS_INTERVAL_MS;
227
253
  private readonly agentVersionsTimer;
@@ -402,6 +428,29 @@ export declare class Supervisor {
402
428
  * reconnect the API would have no measurement at all while this side believed
403
429
  * it had one.
404
430
  */
431
+ /**
432
+ * Tell the person what the kernel is doing to each caged session (#387).
433
+ *
434
+ * Three things the cgroup can say, each with its own line in the feed:
435
+ *
436
+ * - the brake engaged (`memory.current` over `memory.high`): the session is
437
+ * being slowed down, not killed. Said once per episode, and again when it
438
+ * has lasted `CAGE_BRAKE_LONG_MS` — that second line is the one that
439
+ * suggests an action, because by then «slower» may mean «stuck»;
440
+ * - the brake released: said once, so a person who saw the warning knows the
441
+ * wait is over without reading the numbers;
442
+ * - `oom_kill` moved: the kernel killed a process inside the session. With
443
+ * `OOMPolicy=continue` the session itself is still here, and the person
444
+ * has to be told that the command that was running is probably what died.
445
+ *
446
+ * Until this tick existed the runner knew all three and wrote them to its own
447
+ * log only; the feed said «exited with code 143» and nothing about memory.
448
+ *
449
+ * A parked session has no process and no cgroup, and is skipped without a
450
+ * read. A session whose cgroup cannot be read (no cage on this machine, or
451
+ * the scope already gone) is skipped the same way: the watch never guesses.
452
+ */
453
+ private watchSessionCages;
405
454
  private publishHostLoad;
406
455
  private onFrame;
407
456
  private startSession;
@@ -26,6 +26,7 @@ import { agentByDbValue } from './agent-registry.js';
26
26
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
27
27
  import { rememberWorkspacePath } from './environment.js';
28
28
  import { hostLoadChangedEnough, hostLoadHeartbeatDue, readHostLoad, HOST_LOAD_HEARTBEAT_MS, HOST_LOAD_SAMPLE_INTERVAL_MS, } from './host-load.js';
29
+ import { markScopeOomKillsSeen, readScopeMemoryStatus, sessionScopeUnitOf, } from './session-cage.js';
29
30
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
30
31
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
31
32
  import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
@@ -223,9 +224,31 @@ export class Supervisor {
223
224
  * actually moved (or the heartbeat came due).
224
225
  */
225
226
  this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
226
- this.hostLoadTimer = setInterval(() => this.publishHostLoad(), opts.hostLoadSampleMs ?? HOST_LOAD_SAMPLE_INTERVAL_MS);
227
+ this.hostLoadTimer = setInterval(() => {
228
+ this.publishHostLoad();
229
+ // Same cadence, same reason: what a session's cgroup is going through is
230
+ // something that happens to it, and only a tick can notice (#387).
231
+ this.watchSessionCages();
232
+ }, opts.hostLoadSampleMs ?? HOST_LOAD_SAMPLE_INTERVAL_MS);
227
233
  this.hostLoadTimer.unref?.();
228
234
  }
235
+ /**
236
+ * How long a session may sit over its brake before the feed says more than
237
+ * «slower» (#387, step 3). Five minutes is a starting point, not a
238
+ * measurement: an honest `pnpm typecheck` overshoots for tens of seconds, a
239
+ * runaway for ever, and the number that tells them apart on real machines is
240
+ * still to be read off `memory.events`. Sized so the first warning is never
241
+ * the only one a night-time run leaves behind.
242
+ */
243
+ static CAGE_BRAKE_LONG_MS = 5 * 60_000;
244
+ /**
245
+ * Calm ticks before the feed says the braking is over.
246
+ *
247
+ * `memory.high` holds usage AT the line, so a single sample under it means
248
+ * nothing. Two (a minute at the 30 s cadence) is the difference between an
249
+ * episode that ended and a build that breathed.
250
+ */
251
+ static CAGE_CALM_TICKS_TO_RELEASE = 2;
229
252
  /** How often the agent versions are re-derived. See the constructor. */
230
253
  static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
231
254
  agentVersionsTimer;
@@ -620,6 +643,154 @@ export class Supervisor {
620
643
  * reconnect the API would have no measurement at all while this side believed
621
644
  * it had one.
622
645
  */
646
+ /**
647
+ * Tell the person what the kernel is doing to each caged session (#387).
648
+ *
649
+ * Three things the cgroup can say, each with its own line in the feed:
650
+ *
651
+ * - the brake engaged (`memory.current` over `memory.high`): the session is
652
+ * being slowed down, not killed. Said once per episode, and again when it
653
+ * has lasted `CAGE_BRAKE_LONG_MS` — that second line is the one that
654
+ * suggests an action, because by then «slower» may mean «stuck»;
655
+ * - the brake released: said once, so a person who saw the warning knows the
656
+ * wait is over without reading the numbers;
657
+ * - `oom_kill` moved: the kernel killed a process inside the session. With
658
+ * `OOMPolicy=continue` the session itself is still here, and the person
659
+ * has to be told that the command that was running is probably what died.
660
+ *
661
+ * Until this tick existed the runner knew all three and wrote them to its own
662
+ * log only; the feed said «exited with code 143» and nothing about memory.
663
+ *
664
+ * A parked session has no process and no cgroup, and is skipped without a
665
+ * read. A session whose cgroup cannot be read (no cage on this machine, or
666
+ * the scope already gone) is skipped the same way: the watch never guesses.
667
+ */
668
+ watchSessionCages() {
669
+ const unitOf = this.opts.sessionScopeUnitOf ?? sessionScopeUnitOf;
670
+ const read = this.opts.readScopeMemoryStatus ?? readScopeMemoryStatus;
671
+ const longMs = this.opts.cageBrakeLongMs ?? Supervisor.CAGE_BRAKE_LONG_MS;
672
+ const now = Date.now();
673
+ const mb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
674
+ for (const running of this.sessions.values()) {
675
+ if (running.session === null)
676
+ continue;
677
+ const unit = unitOf(running.descriptor.id);
678
+ if (unit === null)
679
+ continue;
680
+ const status = read(unit);
681
+ if (status === null)
682
+ continue;
683
+ const seen = running.cageWatch;
684
+ /**
685
+ * A cgroup this watch has not seen before starts from zero.
686
+ *
687
+ * Two ways that happens, and the second is why the counters are checked
688
+ * as well as the name: a session relaunched after its scope was released
689
+ * gets the SAME unit name back (`sessionScopeUnit` falls back to the
690
+ * plain name once the failed unit is reset), so «same name» does not mean
691
+ * «same cgroup». Counters that went backwards can only be a new one —
692
+ * without this the episode of the dead process carried over, and the
693
+ * feed opened with «memory is back under the share» for a process that
694
+ * had never been over it (#387 QA).
695
+ */
696
+ const carriedOver = seen !== undefined &&
697
+ seen.unit === unit &&
698
+ status.oomKills >= seen.oomKills &&
699
+ status.highEvents >= seen.highEvents &&
700
+ status.ownLimitOom >= seen.ownOom;
701
+ const base = carriedOver
702
+ ? seen
703
+ : {
704
+ unit,
705
+ oomKills: 0,
706
+ ownOom: 0,
707
+ highEvents: 0,
708
+ brakedSince: null,
709
+ warnedLong: false,
710
+ calmTicks: 0,
711
+ };
712
+ if (status.oomKills > base.oomKills) {
713
+ // WHOSE limit was hit decides the sentence. `oom` moves only in the
714
+ // cgroup whose own ceiling was reached; a kill with our counter still
715
+ // where it was came from the slice over ALL sessions, and telling this
716
+ // session it went over its ceiling would be false (measured on this
717
+ // host: the killed neighbour read `max 0, oom 0, oom_kill 1`).
718
+ const ownWall = status.ownLimitOom > base.ownOom;
719
+ const killed = status.oomKills - base.oomKills === 1
720
+ ? 'a process'
721
+ : `${status.oomKills - base.oomKills} processes`;
722
+ this.sendEvent(running, 'notice', {
723
+ level: 'warn',
724
+ text: ownWall
725
+ ? `This session went over its memory ceiling${status.maxBytes === null ? '' : ` of ${mb(status.maxBytes)}`} and the kernel killed ${killed} inside it. ` +
726
+ 'The session itself is still running; the command that was in flight most likely died with it — check its output before going on.'
727
+ : `The machine ran out of memory for agent sessions — they share one ceiling — and the kernel killed ${killed} in this session. ` +
728
+ `This session was using ${mb(status.currentBytes)}${status.maxBytes === null ? '' : ` of its own ${mb(status.maxBytes)}`}, so it was not necessarily the greedy one. ` +
729
+ 'It is still running; the command that was in flight most likely died with it.',
730
+ });
731
+ // So a death minutes later is not blamed on a kill the feed already carries.
732
+ markScopeOomKillsSeen(running.descriptor.id, status.oomKills);
733
+ }
734
+ /**
735
+ * «Being braked» is the EVENT counter first, the level second.
736
+ *
737
+ * `memory.high` works by holding usage at the line: a 30 s sample of
738
+ * `current > high` lands over it and under it in turn, which produced a
739
+ * warning and an all-clear on alternate ticks for one long build. The
740
+ * `high` counter only ever moves when the kernel actually throttled this
741
+ * cgroup, so a delta is the honest answer to «is it being slowed down
742
+ * right now», and the level is kept as the answer for a session that is
743
+ * sitting over the line without allocating.
744
+ */
745
+ const throttled = status.highEvents > base.highEvents ||
746
+ (status.highBytes !== null && status.currentBytes > status.highBytes);
747
+ let { brakedSince, warnedLong, calmTicks } = base;
748
+ if (throttled) {
749
+ calmTicks = 0;
750
+ if (brakedSince === null) {
751
+ brakedSince = now;
752
+ warnedLong = false;
753
+ this.sendEvent(running, 'notice', {
754
+ level: 'warn',
755
+ text: `This session is over its memory share (${mb(status.highBytes ?? 0)}, using ${mb(status.currentBytes)}), so the kernel is slowing it down instead of killing it. ` +
756
+ 'Heavy commands will finish slower until memory is freed.',
757
+ });
758
+ }
759
+ else if (!warnedLong && now - brakedSince >= longMs) {
760
+ warnedLong = true;
761
+ this.sendEvent(running, 'notice', {
762
+ level: 'warn',
763
+ text: `Still over its memory share after ${Math.round(longMs / 60_000)} min (using ${mb(status.currentBytes)} of a ${mb(status.highBytes ?? 0)} share). ` +
764
+ `If the current command is stuck rather than slow, stop it or pause the session.${status.maxBytes === null
765
+ ? ''
766
+ : ` The hard ceiling, where a process would be killed, is ${mb(status.maxBytes)}.`}`,
767
+ });
768
+ }
769
+ }
770
+ else if (brakedSince !== null) {
771
+ // One calm sample is noise — see above. Two in a row is an episode that
772
+ // really ended, and only then is the all-clear worth a line in the feed.
773
+ calmTicks += 1;
774
+ if (calmTicks >= Supervisor.CAGE_CALM_TICKS_TO_RELEASE) {
775
+ brakedSince = null;
776
+ warnedLong = false;
777
+ this.sendEvent(running, 'notice', {
778
+ level: 'info',
779
+ text: `Memory is back under the session's share (${mb(status.highBytes ?? 0)}); it runs at full speed again.`,
780
+ });
781
+ }
782
+ }
783
+ running.cageWatch = {
784
+ unit,
785
+ oomKills: status.oomKills,
786
+ ownOom: status.ownLimitOom,
787
+ highEvents: status.highEvents,
788
+ brakedSince,
789
+ warnedLong,
790
+ calmTicks,
791
+ };
792
+ }
793
+ }
623
794
  publishHostLoad() {
624
795
  const sample = (this.opts.readHostLoad ?? readHostLoad)();
625
796
  if (!sample)
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.54.0";
1
+ export declare const RUNNER_VERSION = "0.55.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.54.0';
2
+ export const RUNNER_VERSION = '0.55.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.54.0",
3
+ "version": "0.55.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",