@bridge4dev/runner 0.57.0 → 0.58.2

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,7 +5,9 @@ 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
+ import { type ScopeHold, type ScopeMemoryStatus, type SliceLimits } from './session-cage.js';
9
+ import { type AllocatorKnobs } from './session-allocator.js';
10
+ import { type ScopeProcess, type StallSample } from './session-stall.js';
9
11
  import { createCheckpoint } from './checkpoints.js';
10
12
  import type { RunnerWsClient } from './ws-client.js';
11
13
  import type { SessionDescriptor } from './protocol.js';
@@ -165,8 +167,49 @@ export interface SupervisorOptions {
165
167
  */
166
168
  sessionScopeUnitOf?: (id: string) => string | null;
167
169
  readScopeMemoryStatus?: (unit: string) => ScopeMemoryStatus | null;
168
- /** How long over the brake before the second, «do something» warning. A test seam. */
169
- cageBrakeLongMs?: number;
170
+ /**
171
+ * #398 S1: the orphan sweep, so a test can prove the tick calls it — and that
172
+ * it never calls it twice at once. Production sweeps for real.
173
+ */
174
+ sweepOrphanSessionScopes?: (liveIds: Iterable<string>) => Promise<string[]>;
175
+ /**
176
+ * #398 S2: the stall mechanism's four doors to the machine.
177
+ *
178
+ * All four are seams for the same reason the cage's two are: putting a real
179
+ * session into a real stall needs a real kernel refusing real memory, and no
180
+ * suite can arrange that. Production reads `/sys` and `/proc` and talks to
181
+ * systemd.
182
+ */
183
+ readStallSample?: (unit: string, at: number) => StallSample | null;
184
+ readScopeProcesses?: (unit: string) => ScopeProcess[];
185
+ setScopeProperties?: (unit: string, properties: string[]) => Promise<boolean>;
186
+ signalSubtree?: (pids: Array<number | {
187
+ pid: number;
188
+ startedAtTicks: number;
189
+ }>, signal: NodeJS.Signals) => number;
190
+ /** The detector's cadence, ms. Real one is 5 s — no suite waits that out. */
191
+ stallSampleMs?: number;
192
+ /** How long a standing session is given. Real one is 3 minutes (D6). */
193
+ stallGraceMs?: number;
194
+ /**
195
+ * #398 S6: what the machine's owner asked for in `config.toml`.
196
+ *
197
+ * Read once in `cmdDaemon` and passed down, which is this package's idiom for
198
+ * config — nothing outside `index.ts` imports `config.js`, so a module that
199
+ * read the file itself would be the first exception and the first place for
200
+ * two answers about one setting to appear.
201
+ */
202
+ memoryKnobs?: AllocatorKnobs;
203
+ /** `report-only` leaves the deadline running and stops nothing (#398 S6). */
204
+ memoryStallAction?: 'stop-command' | 'report-only';
205
+ /**
206
+ * #398 S3: the allocator's two readings of the machine, so a test can put a
207
+ * session on a crowded or an empty box without one. Production reads cgroupfs.
208
+ */
209
+ readSliceLimits?: () => SliceLimits | null;
210
+ readScopeHold?: (unit: string) => ScopeHold | null;
211
+ /** #398 S4: the heartbeat for the memory frame. Real one is a minute. */
212
+ sessionLimitsHeartbeatMs?: number;
170
213
  }
171
214
  export declare class Supervisor {
172
215
  private readonly ws;
@@ -245,23 +288,6 @@ export declare class Supervisor {
245
288
  private readonly verify;
246
289
  private readonly verifyReports;
247
290
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
248
- /**
249
- * How long a session may sit over its brake before the feed says more than
250
- * «slower» (#387, step 3). Five minutes is a starting point, not a
251
- * measurement: an honest `pnpm typecheck` overshoots for tens of seconds, a
252
- * runaway for ever, and the number that tells them apart on real machines is
253
- * still to be read off `memory.events`. Sized so the first warning is never
254
- * the only one a night-time run leaves behind.
255
- */
256
- private static readonly CAGE_BRAKE_LONG_MS;
257
- /**
258
- * Calm ticks before the feed says the braking is over.
259
- *
260
- * `memory.high` holds usage AT the line, so a single sample under it means
261
- * nothing. Two (a minute at the 30 s cadence) is the difference between an
262
- * episode that ended and a build that breathed.
263
- */
264
- private static readonly CAGE_CALM_TICKS_TO_RELEASE;
265
291
  /** How often the agent versions are re-derived. See the constructor. */
266
292
  private static readonly AGENT_VERSIONS_INTERVAL_MS;
267
293
  private readonly agentVersionsTimer;
@@ -413,6 +439,13 @@ export declare class Supervisor {
413
439
  /** The heartbeat window actually used — the constant, or a test's own. */
414
440
  private readonly hostLoadHeartbeatMs;
415
441
  private readonly hostLoadTimer;
442
+ /** #398 S2: the stall detector's clock, and the deadline it enforces. */
443
+ private readonly stallTimer;
444
+ private readonly stallGraceMs;
445
+ private readonly stallSampleMs;
446
+ private readonly limitsHeartbeatMs;
447
+ /** A stall tick is in flight — the next one waits rather than overlaps. */
448
+ private watchingStalls;
416
449
  /** The last measurement the API actually took from us, or `null` for «nothing yet». */
417
450
  private lastPublishedHostLoad;
418
451
  /** When that frame went out, by this machine's clock. `0` = never. */
@@ -443,28 +476,185 @@ export declare class Supervisor {
443
476
  * it had one.
444
477
  */
445
478
  /**
446
- * Tell the person what the kernel is doing to each caged session (#387).
479
+ * Tell the person when the KERNEL killed something inside a caged session.
480
+ *
481
+ * One thing, since #398 S2 — `oom_kill` moved: the kernel stopped a process
482
+ * inside the session. With `OOMPolicy=continue` the session itself is still
483
+ * here, and the person has to be told that the command that was running is
484
+ * probably what died.
447
485
  *
448
- * Three things the cgroup can say, each with its own line in the feed:
486
+ * **The three braking lines this tick used to write are gone** (decision D8 of
487
+ * `session-memory-fair-share.md`): «has hit its memory share», «has been over
488
+ * its memory share for 5 min», and the all-clear. Two reasons, and the second
489
+ * is the decisive one:
449
490
  *
450
- * - the brake engaged (`memory.current` over `memory.high`): the session is
451
- * being slowed down, not killed. Said once per episode, and again when it
452
- * has lasted `CAGE_BRAKE_LONG_MS` — that second line is the one that
453
- * suggests an action, because by then «slower» may mean «stuck»;
454
- * - the brake released: said once, so a person who saw the warning knows the
455
- * wait is over without reading the numbers;
456
- * - `oom_kill` moved: the kernel killed a process inside the session. With
457
- * `OOMPolicy=continue` the session itself is still here, and the person
458
- * has to be told that the command that was running is probably what died.
491
+ * - they were state, not events. Being over the brake is a condition that
492
+ * comes and goes; its place is the activity strip under the session and the
493
+ * machine card, not the history a person scrolls back through;
494
+ * - they disagreed with the mechanism that replaced them. The deadline in
495
+ * `session-stall.ts` runs out after three minutes, so the five-minute line
496
+ * could never have been printed at all.
459
497
  *
460
- * Until this tick existed the runner knew all three and wrote them to its own
461
- * log only; the feed said «exited with code 143» and nothing about memory.
498
+ * What a person sees instead: nothing while the machine copes, and exactly one
499
+ * line when a command was actually stopped.
462
500
  *
463
501
  * A parked session has no process and no cgroup, and is skipped without a
464
502
  * read. A session whose cgroup cannot be read (no cage on this machine, or
465
503
  * the scope already gone) is skipped the same way: the watch never guesses.
466
504
  */
467
505
  private watchSessionCages;
506
+ /** A sweep is in flight — see {@link sweepOrphanCages}. */
507
+ private sweepingCages;
508
+ /**
509
+ * Stop every session scope with nobody behind it, on the watch tick (#398 S1).
510
+ *
511
+ * The sweep itself spares every LIVE cage by its own register, sessions and
512
+ * `verify` runs alike (`session-cage.ts`), so what is passed here is belt and
513
+ * braces rather than the guard — but passing it says at the call site which
514
+ * ids this supervisor believes are alive, and a divergence between the two
515
+ * would be a bug worth seeing in a test.
516
+ *
517
+ * Re-entrancy matters more than it looks: the sweep is a `systemctl` call and
518
+ * the bus was measured at 2.7 s under load, so on a machine where the tick is
519
+ * 30 s and the bus is slow two sweeps could otherwise overlap and each would
520
+ * see the other's half-stopped units.
521
+ *
522
+ * Never fatal, and deliberately silent on failure: the daemon carries a fatal
523
+ * `unhandledRejection` handler, and losing every session because a bus call
524
+ * failed would be a far worse trade than an orphan living one more tick.
525
+ */
526
+ private sweepOrphanCages;
527
+ /**
528
+ * The stall mechanism, once per window (#398 S2, D6, gotcha §480).
529
+ *
530
+ * The order is the whole design, and each step exists because the one before
531
+ * it was not enough:
532
+ *
533
+ * 1. **T0 — add.** If the machine has memory free, the brake goes up and the
534
+ * session carries on. Nothing is written to the feed: the mechanism
535
+ * worked, nothing changed for the person, the build is still going (D8).
536
+ * 2. **T1 — the deadline.** Three minutes, announced as state (the strip
537
+ * under the session says how long is left) and never as a line in the
538
+ * history. This is the window in which a person can step in without
539
+ * paying for it with an entry they will scroll past for ever.
540
+ * 3. **T2 — stop the biggest command.** Not the session, not the agent: the
541
+ * command. The agent stays alive, sees its command stopped, is told why,
542
+ * and answers the person itself. Waiting for a human instead is what cost
543
+ * the incident its hour — at night nobody comes.
544
+ * 4. **T3 — nothing to stop.** When all that is left in the cage is the agent
545
+ * and its tools, this mechanism has no right to any of them. State again,
546
+ * no line: the person decides with «Stop» or «Pause».
547
+ *
548
+ * Never throws out of here. The daemon carries a fatal `unhandledRejection`
549
+ * handler, so one unhandled error would end every session on the machine.
550
+ */
551
+ private watchSessionStalls;
552
+ private stepOneStall;
553
+ /**
554
+ * Ask the allocator for more, now, because this session has stopped moving (T0).
555
+ *
556
+ * Since S3 there is no separate ladder here: `MemoryHigh` has exactly TWO
557
+ * writers, and they are the same code — the allocator on its tick and the
558
+ * allocator on this signal. Two independent writers of one number is how a
559
+ * limit ends up being whatever the last tick happened to think.
560
+ *
561
+ * The allocator needs no special case for a stall: a standing session's `hold`
562
+ * is by definition all the way up against its brake, so `hold + free` asks for
563
+ * everything the machine has spare. If there is nothing spare, nothing moves,
564
+ * and the deadline below is the answer instead.
565
+ */
566
+ private addMemoryTo;
567
+ /** T2 and T3 — stop the biggest command of this session, or nothing at all. */
568
+ private stopBiggestCommand;
569
+ /**
570
+ * Say something to the agent on the runner's own initiative, without stepping
571
+ * on a card the person has not answered (#398 S7, B7).
572
+ *
573
+ * `session.send()` looks like a neutral channel and is not one: with a
574
+ * question open, `ClaudeSession.send` answers it «discuss» and the API
575
+ * records `question_resolved {source: 'user'}` — a decision attributed to a
576
+ * person who never made it — while `CodexSession.send` declines a held plan
577
+ * the same way. Every other initiative of this supervisor already checks for
578
+ * an open card; the memory mechanism was the one that did not.
579
+ *
580
+ * Held rather than dropped: the agent is parked on the card, so it cannot run
581
+ * anything until the person answers anyway, and once they do the note is the
582
+ * first thing it reads.
583
+ */
584
+ private tellAgent;
585
+ /** Deliver what waited for a card to close — see {@link tellAgent}. */
586
+ private flushPendingAgentNote;
587
+ /** How many sessions are running in a cage right now (#398 S7, B9). */
588
+ private liveCagedSessions;
589
+ /** SIGKILL for a subtree that did not take SIGTERM within the grace. */
590
+ private escalatePendingKill;
591
+ /** An allocator pass is in flight — the next one waits rather than overlaps. */
592
+ private allocating;
593
+ /** The last pass's machine-wide numbers, for the card and the session banner. */
594
+ private lastAllocation;
595
+ /**
596
+ * The machine the last allocation was computed for, kept so the card's own
597
+ * number can be derived without measuring the machine a SECOND time (#403).
598
+ */
599
+ private lastAllocatorMachine;
600
+ /** The last frame the API actually took from us, or null for «nothing yet». */
601
+ private lastPublishedLimits;
602
+ /** When that frame went out, by this machine's clock. `0` = never. */
603
+ private lastLimitsSentAt;
604
+ /**
605
+ * Decide, and write, how much memory each live session may use (#398 S3).
606
+ *
607
+ * Runs on the 30 s watch tick and on the stall detector's signal, and nowhere
608
+ * else: `MemoryHigh` must have exactly one implementation of «what should this
609
+ * be», or the two writers disagree and the limit becomes whichever ran last.
610
+ *
611
+ * Everything that could be wrong about the machine is read fresh here rather
612
+ * than taken from the daemon-start probe: the pot moves when the hourly
613
+ * re-measure rewrites the slice, the collective brake decides whether
614
+ * overselling is even legal, and a neighbouring application growing is
615
+ * precisely the case #398 was opened about.
616
+ */
617
+ private runAllocator;
618
+ /** The machine as the allocator needs to see it, from live readings only. */
619
+ private allocatorMachine;
620
+ /**
621
+ * What a session STARTING right now should be given (#398 S3, work 9).
622
+ *
623
+ * The allocator is asked to compute as though this session already existed,
624
+ * so a session born at 03:00 gets the number its first tick will confirm
625
+ * rather than the one the daemon measured whenever it last started.
626
+ */
627
+ private ladderForNewSession;
628
+ /**
629
+ * Tell the API how this machine is dividing its memory — when it moved (#398 S4).
630
+ *
631
+ * Built from the pass the allocator has just finished rather than measured
632
+ * again: two readings of a moving machine taken a moment apart would let the
633
+ * card and the scope disagree about the same session, and «the card said 4 GB
634
+ * and the cgroup said 2» is the exact shape of the bug that made
635
+ * `limitsCurrent` untrustworthy once already.
636
+ *
637
+ * Recorded ONLY when the socket took it, same as `publishHostLoad`: a frame
638
+ * dropped by a dead socket must not be remembered as sent.
639
+ */
640
+ /**
641
+ * The number on the machine card: what ONE session gets when the machine is
642
+ * free (decision D4 of `runner-cage-authority.md`, 10.09.2026).
643
+ *
644
+ * The card used to carry the ceiling a newcomer would get RIGHT NOW, from the
645
+ * current crowd — which is honest per second and useless per person: on a
646
+ * 12 GiB machine with two sessions of 3 GiB it read ~6 GiB, it moved whenever
647
+ * a neighbour started or stopped, and the sentence beside it said «up to X on
648
+ * an idle machine», which is a different number entirely. A card is read to
649
+ * answer «what does this machine give a session», and that question has a
650
+ * still answer.
651
+ *
652
+ * Computed, not measured: the pot and the owner's knobs are all it takes, so
653
+ * this makes no second reading of a moving machine — the one the shape of
654
+ * `publishSessionLimits` warns against in its own header.
655
+ */
656
+ private idleSessionCeiling;
657
+ private publishSessionLimits;
468
658
  private publishHostLoad;
469
659
  private onFrame;
470
660
  private startSession;