@bridge4dev/runner 0.56.0 → 0.58.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.
@@ -94,6 +94,14 @@ export interface SessionCageFacts {
94
94
  * `--scope` was off by default there anyway.
95
95
  */
96
96
  expandEnvironmentFlag: boolean;
97
+ /**
98
+ * Does this systemd take `MemoryLow=` on a scope? (#398 S3)
99
+ *
100
+ * False means the machine keeps its cage and loses only the guarantees — the
101
+ * brake and the wall are unaffected. Probed rather than assumed, because a
102
+ * `-p` systemd refuses makes `systemd-run` spawn nothing at all.
103
+ */
104
+ memoryLowFlag: boolean;
97
105
  }
98
106
  /**
99
107
  * The FLOOR the plan's formula does not have, and the reason it needs one.
@@ -118,6 +126,36 @@ export interface SessionCageFacts {
118
126
  * (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
119
127
  */
120
128
  export declare const SESSION_MEMORY_HIGH_MIN_BYTES: number;
129
+ /**
130
+ * The gap between the brake and the wall, and never less than this (#398 S2).
131
+ *
132
+ * The brake is where the kernel starts reclaiming; the wall is where it kills.
133
+ * With the two at the same number there is no brake at all — that is the 0.54.0
134
+ * kill line, and #387 is the whole story of what it costs. So every path that
135
+ * computes the pair keeps at least a band between them, and the band is also
136
+ * the step by which the stall detector may raise a brake.
137
+ *
138
+ * 256 MiB is sized against what one `tsc` of this monorepo holds (~400 MB
139
+ * measured) divided by the overshoot a build makes in one 5 s window: enough
140
+ * room for the allocation that was refused, not enough to hide a runaway.
141
+ *
142
+ * {@link sessionMemoryBandBytes} widens it on the machines where reaching the
143
+ * wall costs the whole session rather than one process — see there.
144
+ */
145
+ export declare const SESSION_MEMORY_BAND_BYTES: number;
146
+ /**
147
+ * The band on THIS machine.
148
+ *
149
+ * Where the probe found no `OOMPolicy=continue` (systemd 249 and friends), a
150
+ * kill inside the scope stops the whole scope: the session dies, not one
151
+ * process. The band there is not a nicety but the only warning the session
152
+ * gets, so it is a quarter of the brake and never under 512 MiB.
153
+ *
154
+ * Two of the fleet's fifteen caged machines answer `sessionOomContinue: false`
155
+ * (`search aigain`, `trizailab`, measured 10.09.2026), so this is not a
156
+ * hypothetical branch.
157
+ */
158
+ export declare function sessionMemoryBandBytes(brakeBytes: number, oomContinue: boolean): number;
121
159
  /**
122
160
  * The wall, when nothing on the machine can say where it should be.
123
161
  *
@@ -293,6 +331,7 @@ export interface CageProbe {
293
331
  probeMemoryMax: (options: {
294
332
  expandEnvironmentFlag: boolean;
295
333
  oomPolicyFlag: boolean;
334
+ memoryLowFlag: boolean;
296
335
  }) => Promise<CageProbeResult>;
297
336
  /** `MemoryMax` of the runner service in bytes; null for `infinity`. */
298
337
  serviceMemoryMax: () => Promise<number | null>;
@@ -328,6 +367,8 @@ export declare function detectSessionCage(probe?: CageProbe): Promise<SessionCag
328
367
  * must not be retried into a cage with a weaker policy.
329
368
  */
330
369
  export declare function refusesOomPolicy(error: string): boolean;
370
+ /** The same question for `MemoryLow=`, and the same wording from systemd. */
371
+ export declare function refusesMemoryLow(error: string): boolean;
331
372
  /**
332
373
  * Probe once, at daemon start, and remember the answer.
333
374
  *
@@ -335,7 +376,9 @@ export declare function refusesOomPolicy(error: string): boolean;
335
376
  * three sessions starting at once would mean three throwaway scopes before the
336
377
  * first agent got a word out.
337
378
  */
338
- export declare function initSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
379
+ export declare function initSessionCage(probe?: CageProbe, options?: {
380
+ keepCageIfWorse?: boolean;
381
+ }): Promise<SessionCageFacts>;
339
382
  /** What the last {@link initSessionCage} found; the safe default before it ran. */
340
383
  export declare function sessionCage(): SessionCageFacts;
341
384
  export interface CagedSpawn {
@@ -351,6 +394,10 @@ export interface CagedSpawn {
351
394
  }
352
395
  /** The scope a live caged id runs in, or null when it is not caged (or gone). */
353
396
  export declare function sessionScopeUnitOf(id: string): string | null;
397
+ /** Remember which process is the agent of this session. */
398
+ export declare function noteSessionAgentPid(id: string, pid: number | undefined): void;
399
+ /** The agent's pid for a live session, or null when nothing has said. */
400
+ export declare function sessionAgentPid(id: string): number | null;
354
401
  /**
355
402
  * Wrap a command in its session's cage, or hand it back untouched.
356
403
  *
@@ -363,12 +410,30 @@ export declare function sessionScopeUnitOf(id: string): string | null;
363
410
  * spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
364
411
  * spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
365
412
  * (`pgid === child.pid` still holds).
413
+ *
414
+ * Untouched is also the answer for a process that has no right to put units on
415
+ * this machine (#403). It is the mildest of the doors in `cage-authority.ts`:
416
+ * the command still runs, it simply runs uncaged, which is exactly what happens
417
+ * on every machine without cgroups.
366
418
  */
367
419
  export declare function cageSpawn(input: {
368
420
  id: string;
369
421
  command: string;
370
422
  args: string[];
371
423
  }): CagedSpawn;
424
+ /**
425
+ * The command line itself, with no question of who is allowed to run it.
426
+ *
427
+ * Split out from {@link cageSpawn} so the shape of the line stays testable: the
428
+ * suite proves what `systemd-run` would be handed, and the door above proves
429
+ * who may hand it. One function could not do both — a test process legitimately
430
+ * needs the first answer and must never get the second.
431
+ */
432
+ export declare function buildCagedSpawn(input: {
433
+ id: string;
434
+ command: string;
435
+ args: string[];
436
+ }): CagedSpawn;
372
437
  /**
373
438
  * Did this process die in the window before `systemd-run` handed over?
374
439
  *
@@ -424,6 +489,14 @@ export declare function parseScopeMemoryStatus(files: {
424
489
  events: string;
425
490
  swapCurrent?: string;
426
491
  }): ScopeMemoryStatus | null;
492
+ /**
493
+ * Where a session scope's cgroup lives, or null off a user manager.
494
+ *
495
+ * Exported for `session-stall.ts`, which reads three more files out of the same
496
+ * directory (`memory.pressure`, `memory.stat`, `cgroup.procs`). One resolver,
497
+ * so the two modules can never disagree about which cgroup a session is in.
498
+ */
499
+ export declare function scopeCgroupDir(unit: string): string | null;
427
500
  /**
428
501
  * What one live session's cgroup holds and has been through. Null when the
429
502
  * cgroup is not there (the scope ended, or this machine has no cage).
@@ -432,6 +505,122 @@ export declare function parseScopeMemoryStatus(files: {
432
505
  * every live session, and the bus is the thing that took 2.7 s under load.
433
506
  */
434
507
  export declare function readScopeMemoryStatus(unit: string, readFile?: (p: string) => string): ScopeMemoryStatus | null;
508
+ /** What one session's cgroup holds, split into «can be given back» and «cannot». */
509
+ export interface ScopeHold {
510
+ /** `memory.current`. */
511
+ currentBytes: number;
512
+ /** `memory.current − (file − shmem)` — the part a lower limit cannot reclaim. */
513
+ holdBytes: number;
514
+ }
515
+ /**
516
+ * What a live session holds, for the allocator (#398 S3).
517
+ *
518
+ * `readCgroupMemory` rather than a second parser: it already takes page cache
519
+ * out and puts `shmem` back, which is the difference between «cache we can give
520
+ * back» and «memory something would have to be killed for», and getting that
521
+ * wrong inflates every number by whatever the machine happened to have cached
522
+ * (measured at +19 % on this host).
523
+ */
524
+ export declare function readScopeHold(unit: string): ScopeHold | null;
525
+ /** What systemd has in force over ALL sessions together, read live off cgroupfs. */
526
+ export interface SliceLimits {
527
+ /** `memory.max` of the slice — the pot. Null for `max`. */
528
+ potBytes: number | null;
529
+ /**
530
+ * `memory.high` of the slice — the COLLECTIVE brake, and the invariant the
531
+ * allocator's overselling rests on. Null for `max`, which means there is none.
532
+ */
533
+ collectiveBrakeBytes: number | null;
534
+ /**
535
+ * `memory.low` of the slice — is the GUARANTEE in force at all (#398 S7, B3)?
536
+ *
537
+ * The card and the agent's prompt both promise every session two gigabytes
538
+ * that will not be reclaimed, and nothing on this machine ever checked that
539
+ * the promise was written: measured on vmi3201430 on 10.09.2026, this file
540
+ * held `0` while the chip said «2.0 GB guaranteed». Zero is not «unknown» —
541
+ * it is the kernel's way of saying there is no protection here.
542
+ */
543
+ guaranteeBytes: number | null;
544
+ }
545
+ /**
546
+ * The slice's own two limits, from the filesystem rather than the bus.
547
+ *
548
+ * Live on purpose. What the probe found at daemon start is a snapshot, and the
549
+ * pot moves: the hourly `repairResourceLimits` rewrites it from a fresh
550
+ * measurement, and a machine whose neighbours grew or shrank gets a different
551
+ * number. Reading two small files on the tick costs nothing and cannot be stale;
552
+ * a `systemctl show` would cost a bus call measured at 2.7 s under load.
553
+ */
554
+ export declare function readSliceLimits(readFile?: (p: string) => string): SliceLimits | null;
555
+ /**
556
+ * Where `devbridge-sessions.slice` lives on this machine.
557
+ *
558
+ * Two ways of answering, and the second is what makes this usable from a
559
+ * command the operator typed. The first is the reader's own cgroup, which is
560
+ * exact whenever the reader is under the user manager. The second builds the
561
+ * path from the user id, exactly as `userManagerCgroupControllers()` already
562
+ * does — a `systemd-run` scope, a login session and a bare shell all get the
563
+ * right answer that way, and the daemon gets the same one either way.
564
+ */
565
+ export declare function sessionsSliceCgroupDir(io?: {
566
+ selfCgroup?: () => string | null;
567
+ exists?: (p: string) => boolean;
568
+ /** The uid of THIS process — injected so a test does not depend on who runs it. */
569
+ selfUid?: () => number;
570
+ ownerUid?: () => number;
571
+ }): string | null;
572
+ /**
573
+ * The three numbers a session's scope should be started with, when something
574
+ * knows better than the daemon-start snapshot (#398 S3, work 9).
575
+ *
576
+ * Registered by the supervisor, which owns the allocator. Without it every new
577
+ * session would spend its first half-minute on the old `pot / 3` formula and
578
+ * then jump to the new one on the first tick — visible, unexplainable, and
579
+ * exactly the kind of thing that gets reported as a bug.
580
+ *
581
+ * A plain module-level hook rather than an import, because the allocator imports
582
+ * THIS module and the cycle would be real.
583
+ */
584
+ export interface LiveLadder {
585
+ highBytes: number;
586
+ maxBytes: number;
587
+ swapBytes: number;
588
+ guaranteedBytes: number;
589
+ }
590
+ export declare function setLiveLadderSource(source: ((id: string) => LiveLadder | null) | null): void;
591
+ /**
592
+ * The two numbers this session may be TOLD about (#398 S5).
593
+ *
594
+ * The guarantee and the ceiling, and deliberately not the live share. The share
595
+ * is recomputed every 30 s, so any value of it put into an environment variable
596
+ * or into a system prompt is stale within half a minute — and a number the agent
597
+ * believes and acts on after it has stopped being true is worse than no number.
598
+ * The guarantee is a constant of the formula and the ceiling barely moves.
599
+ *
600
+ * Null on a machine with no cage: there is nothing to promise there, and «нет
601
+ * cgroup — держим ноль» (#257) means we say nothing rather than invent a limit.
602
+ */
603
+ export declare function sessionMemoryFor(id: string): {
604
+ guaranteedBytes: number;
605
+ maxBytes: number;
606
+ swapBytes: number;
607
+ } | null;
608
+ /**
609
+ * What the agent is told, and how — one text for both CLIs (#398 S5).
610
+ *
611
+ * A shared builder rather than two literals for the reason `DIRECT_BRANCH_RULE`
612
+ * in `adapters/types.ts` is shared: the two system-prompt appends are hand-kept
613
+ * copies of one another, and a third place for them to diverge was not worth
614
+ * having.
615
+ *
616
+ * The live share is named as something the agent CANNOT know, on purpose. Half
617
+ * of the incident this whole plan comes from was an agent raising its own heap
618
+ * twice — to 4 GB, then to 6 GB, against a wall of 4296 MB — because nothing
619
+ * had ever told it there was a wall.
620
+ */
621
+ export declare function sessionMemoryEnv(id: string): Record<string, string>;
622
+ /** The sentence for the system prompt, or null on a machine with no cage. */
623
+ export declare function sessionMemoryPromptLine(id: string): string | null;
435
624
  export declare function markScopeOomKillsSeen(id: string, oomKills: number): void;
436
625
  /**
437
626
  * «a process in it was stopped» / «3 processes in it were stopped», so the verb
@@ -503,8 +692,46 @@ export declare function listSessionScopes(systemctl?: Systemctl): Promise<Sessio
503
692
  * `liveIds` exists so the same sweep can run later without killing work in
504
693
  * progress; only the CURRENT scope of a live session is spared, because an
505
694
  * earlier attempt of the same session is exactly the leftover we are here for.
695
+ *
696
+ * **Everything in {@link liveUnits} is spared too, whatever the caller passed.**
697
+ * A session is not the only thing that puts a scope under this prefix: a
698
+ * `verify` run cages itself as `verify-<runId>` (`verify.ts`), and a caller
699
+ * that hands over its list of SESSIONS would have the sweep stop a check that
700
+ * is running — a new bug in place of the old one. `liveUnits` is filled by
701
+ * `cageSpawn` and emptied by `releaseSessionScope`, so it is the full register
702
+ * of live cages regardless of who opened them; at daemon start it is empty by
703
+ * construction, which is exactly why the sweep there still takes everything.
704
+ */
705
+ /**
706
+ * Who, if anybody, is alive inside a cage — asked of the kernel rather than of
707
+ * this process's own memory (#403).
708
+ *
709
+ * The sweep used to decide by `liveUnits`, which is a register of what THIS
710
+ * process started. In the daemon that is right; in any other process it is
711
+ * empty by construction, and «not in my register» then means «the whole
712
+ * machine». That inversion is what stopped three people's sessions on
713
+ * 10.09.2026.
714
+ *
715
+ * The cgroup answers the question properly, and it answers the same for
716
+ * everybody:
717
+ *
718
+ * - `empty` — no processes at all: nothing to stop, only a name to
719
+ * free (`reset-failed`).
720
+ * - `orphaned` — processes whose parent is gone (`PPid` 1 or 0, or a
721
+ * parent that no longer exists). This is the shape the
722
+ * sweep exists for: a scope that outlived the daemon
723
+ * that started it, carrying a `ugrep` for 10 h 51 min.
724
+ * - `owned` — a live parent outside the cage is supervising them.
725
+ * Somebody's session, and not ours to end.
726
+ * - `unknown` — `/proc` would not say. Left alone, loudly.
506
727
  */
507
- export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl): Promise<string[]>;
728
+ export type ScopeLiveness = 'empty' | 'orphaned' | 'owned' | 'unknown';
729
+ export declare function scopeOwnerLiveness(unit: string, io?: {
730
+ readFile?: (p: string) => string;
731
+ cgroupDir?: (unit: string) => string | null;
732
+ isAlive?: (pid: number) => boolean;
733
+ }): ScopeLiveness;
734
+ export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl, liveness?: (unit: string) => ScopeLiveness): Promise<string[]>;
508
735
  /**
509
736
  * The slice names and the CPU share — re-exported so a caller that reasons about
510
737
  * the cage needs one import, while the values themselves stay next to the