@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.
package/dist/config.js CHANGED
@@ -41,7 +41,65 @@ const ConfigSchema = z.object({
41
41
  // processes and worktrees may exist at once.
42
42
  limits: z
43
43
  .object({
44
- max_sessions: z.number().int().min(1).max(64),
44
+ /**
45
+ * OPTIONAL since 0.58.0, and the change is a bug fix rather than a
46
+ * loosening.
47
+ *
48
+ * `loadConfig()` uses `.parse`, not `safeParse`, and a ZodError from it
49
+ * reaches `main().catch` and exits 1 — under systemd that is a restart
50
+ * loop. So while this field was required, a machine owner who opened
51
+ * `config.toml` to add any OTHER key under `[limits]` and did not happen
52
+ * to have this one would have taken their dev server down, and `doctor`
53
+ * could not have told them why: it dies on the same parse.
54
+ *
55
+ * The default is unchanged — absence means «the API's number decides»,
56
+ * which is what `get maxSessions()` already did.
57
+ */
58
+ max_sessions: z.number().int().min(1).max(64).optional(),
59
+ /**
60
+ * #398 S6: the emergency switch back to the pre-0.58.0 behaviour.
61
+ *
62
+ * `false` puts every session on the fixed `max(pot / 3, 2 GiB)` share
63
+ * that shipped in 0.55.0, computed once at daemon start and never moved.
64
+ * It exists because this formula travels to dev servers nobody here has
65
+ * ever seen, and the two previous memory rules were both reasonable on
66
+ * the author's machine and destructive somewhere else. A machine owner
67
+ * who has to get work done tonight needs a way back that does not involve
68
+ * downgrading the runner.
69
+ */
70
+ adaptive_memory: z.boolean().optional(),
71
+ /**
72
+ * The guarantee, in megabytes — what a session on this machine will not
73
+ * have taken away.
74
+ *
75
+ * The default is 2048, sized over the 1571 MB peak measured for a
76
+ * workspace `pnpm typecheck`. Lower it on a machine that runs lighter
77
+ * work and wants more sessions; raise it on one that runs heavier.
78
+ */
79
+ session_memory_min: z.number().int().min(256).max(262144).optional(),
80
+ /**
81
+ * A ceiling on the ceiling, in megabytes — the most any single session
82
+ * may be allowed to grow to, whatever the machine has free.
83
+ *
84
+ * For the owner who wants agents to leave room for something the runner
85
+ * cannot see (a build they run by hand, a database that spikes).
86
+ */
87
+ session_memory_max: z.number().int().min(256).max(1048576).optional(),
88
+ /**
89
+ * How long a session that has stopped moving is given before its biggest
90
+ * command is stopped, in seconds. Default 180 (decision D6).
91
+ */
92
+ memory_stall_grace_sec: z.number().int().min(30).max(3600).optional(),
93
+ /**
94
+ * What happens when that time runs out: `stop-command` (the default) or
95
+ * `report-only`.
96
+ *
97
+ * `report-only` is for the owner who would rather have a session stand
98
+ * still than have a command stopped under it. It changes nothing else —
99
+ * the deadline still runs and the strip still shows it, so the person can
100
+ * act; only DevBridge stops acting on their behalf.
101
+ */
102
+ memory_stall_action: z.enum(['stop-command', 'report-only']).optional(),
45
103
  })
46
104
  .optional(),
47
105
  /**
@@ -0,0 +1,43 @@
1
+ /**
2
+ * One daemon per user manager, and the reason is the sweep (#403).
3
+ *
4
+ * `cmdDaemon` stops every session scope it does not know about, and at start-up
5
+ * it knows about none — that is the point: a scope outliving a killed daemon
6
+ * carries a whole process tree with it. But the same line, run by a SECOND
7
+ * daemon on a machine where the first one is working, ends every live session
8
+ * the first one is supervising. `pnpm --filter @bridge4dev/runner dev` beside a
9
+ * paired runner is enough, and no amount of care inside the test suite touches
10
+ * that path.
11
+ *
12
+ * The lock is in `/run/user/<uid>` and not in the state directory on purpose:
13
+ * `DEVBRIDGE_RUNNER_HOME` moves the state directory (the test contour uses it),
14
+ * and two daemons with different state directories still share one systemd user
15
+ * bus — which is the thing being protected.
16
+ */
17
+ export interface DaemonLock {
18
+ /** Where the lock lives, for the message a person reads. */
19
+ path: string;
20
+ /** Give it up — on shutdown, or when the daemon is done with it. */
21
+ release: () => void;
22
+ }
23
+ export interface HeldByAnother {
24
+ heldBy: number;
25
+ path: string;
26
+ }
27
+ /** The lock file for this user's manager. */
28
+ export declare function daemonLockPath(): string;
29
+ /**
30
+ * Take the lock, or say who has it.
31
+ *
32
+ * A stale lock — the pid in it is gone — is taken over silently: a daemon that
33
+ * was killed with `SIGKILL` cannot clean up after itself, and refusing to start
34
+ * after a crash would be a worse failure than the one being prevented.
35
+ */
36
+ export declare function acquireDaemonLock(io?: {
37
+ lockPath?: () => string;
38
+ isAlive?: (pid: number) => boolean;
39
+ now?: () => number;
40
+ }): DaemonLock | HeldByAnother;
41
+ /** True when this is a refusal rather than a lock. */
42
+ export declare function isHeldByAnother(result: DaemonLock | HeldByAnother): result is HeldByAnother;
43
+ //# sourceMappingURL=daemon-lock.d.ts.map
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runnerIdentity, systemdUserEnv } from './environment.js';
4
+ import { log } from './log.js';
5
+ import { isPidAlive } from './status-file.js';
6
+ /** The lock file for this user's manager. */
7
+ export function daemonLockPath() {
8
+ const runtimeDir = systemdUserEnv()['XDG_RUNTIME_DIR'];
9
+ const uid = runnerIdentity().uid;
10
+ const dir = runtimeDir ?? path.join('/run/user', String(uid));
11
+ return path.join(dir, 'devbridge-runner.daemon.lock');
12
+ }
13
+ /**
14
+ * Take the lock, or say who has it.
15
+ *
16
+ * A stale lock — the pid in it is gone — is taken over silently: a daemon that
17
+ * was killed with `SIGKILL` cannot clean up after itself, and refusing to start
18
+ * after a crash would be a worse failure than the one being prevented.
19
+ */
20
+ export function acquireDaemonLock(io = {}) {
21
+ const file = (io.lockPath ?? daemonLockPath)();
22
+ const alive = io.isAlive ?? isPidAlive;
23
+ const write = () => {
24
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, at: (io.now ?? Date.now)() }), {
25
+ flag: 'w',
26
+ });
27
+ };
28
+ /**
29
+ * The directory first and on its own, because its `EEXIST` means the opposite
30
+ * of the one below: «the folder is already there», which is the ordinary
31
+ * case. Mixed into the same `try`, it was read as «somebody holds the lock».
32
+ */
33
+ try {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ }
36
+ catch (error) {
37
+ if (error.code !== 'EEXIST') {
38
+ log.warn('daemon: could not take the single-daemon lock, starting without it', {
39
+ path: file,
40
+ error: String(error instanceof Error ? error.message : error),
41
+ });
42
+ return { path: file, release: () => { } };
43
+ }
44
+ }
45
+ for (let attempt = 0; attempt < 2; attempt += 1) {
46
+ try {
47
+ // `wx` fails when the file exists — that is the whole exclusion.
48
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, at: (io.now ?? Date.now)() }), {
49
+ flag: 'wx',
50
+ });
51
+ return { path: file, release: () => releaseLockFile(file) };
52
+ }
53
+ catch (error) {
54
+ if (error.code !== 'EEXIST') {
55
+ /**
56
+ * The runtime directory is not there, or is not writable. Not a reason
57
+ * to refuse to start: a machine with no `/run/user/<uid>` has no user
58
+ * manager either, so it has no session scopes to protect.
59
+ */
60
+ log.warn('daemon: could not take the single-daemon lock, starting without it', {
61
+ path: file,
62
+ error: String(error instanceof Error ? error.message : error),
63
+ });
64
+ return { path: file, release: () => { } };
65
+ }
66
+ const holder = readHolder(file);
67
+ if (holder !== null && holder !== process.pid && alive(holder)) {
68
+ return { heldBy: holder, path: file };
69
+ }
70
+ // Stale (the holder is gone) or ours from a previous life: take it over.
71
+ try {
72
+ fs.rmSync(file, { force: true });
73
+ }
74
+ catch {
75
+ // Somebody else got there first; the next attempt will find out whose.
76
+ }
77
+ if (attempt === 1) {
78
+ write();
79
+ return { path: file, release: () => releaseLockFile(file) };
80
+ }
81
+ }
82
+ }
83
+ return { path: file, release: () => releaseLockFile(file) };
84
+ }
85
+ /** True when this is a refusal rather than a lock. */
86
+ export function isHeldByAnother(result) {
87
+ return 'heldBy' in result;
88
+ }
89
+ function readHolder(file) {
90
+ try {
91
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
92
+ return typeof raw.pid === 'number' && raw.pid > 0 ? raw.pid : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function releaseLockFile(file) {
99
+ try {
100
+ if (readHolder(file) === process.pid)
101
+ fs.rmSync(file, { force: true });
102
+ }
103
+ catch {
104
+ // Gone already, or never ours. Nothing to undo.
105
+ }
106
+ }
107
+ //# sourceMappingURL=daemon-lock.js.map
@@ -12,6 +12,15 @@
12
12
  * both world-readable, neither containing a path, a command line, an
13
13
  * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
14
14
  *
15
+ * **One module is allowed past that line, and it is named here so the rule and
16
+ * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
17
+ * processes of ONE session's own cgroup, in order to choose which command to
18
+ * stop when that session has run out of memory (#398 S2). It is not telemetry —
19
+ * nothing it reads is reported anywhere, and the single thing that leaves the
20
+ * machine is a command NAME with no arguments, because arguments carry keys and
21
+ * tokens. Choosing a victim cannot be done from two summary files, and the
22
+ * alternative was killing the session instead of its command.
23
+ *
15
24
  * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
16
25
  * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
17
26
  * the source of truth, exactly like `levels.ts` mirrors the level thresholds
package/dist/host-load.js CHANGED
@@ -15,6 +15,15 @@ import path from 'node:path';
15
15
  * both world-readable, neither containing a path, a command line, an
16
16
  * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
17
17
  *
18
+ * **One module is allowed past that line, and it is named here so the rule and
19
+ * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
20
+ * processes of ONE session's own cgroup, in order to choose which command to
21
+ * stop when that session has run out of memory (#398 S2). It is not telemetry —
22
+ * nothing it reads is reported anywhere, and the single thing that leaves the
23
+ * machine is a command NAME with no arguments, because arguments carry keys and
24
+ * tokens. Choosing a victim cannot be done from two summary files, and the
25
+ * alternative was killing the session instead of its command.
26
+ *
18
27
  * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
19
28
  * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
20
29
  * the source of truth, exactly like `levels.ts` mirrors the level thresholds
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@ import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
11
  import { sessionClaudePath } from './agent-binary.js';
12
+ import { claimCageAuthority, runSystemctl } from './cage-authority.js';
13
+ import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
12
14
  import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
13
15
  import { log } from './log.js';
14
16
  import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
@@ -17,12 +19,14 @@ import { Supervisor } from './supervisor.js';
17
19
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
18
20
  import { RunnerWsClient } from './ws-client.js';
19
21
  import { RUNNER_VERSION } from './version.js';
20
- import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, sessionsSliceOverridePath, unitExecTarget, unitPath, writeLimitsOverride, DEVBRIDGE_SLICE, LIMITS_VERSION, SESSION_CPU_WEIGHT, SESSIONS_SLICE, } from './service-unit.js';
21
- import { initSessionCage, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
22
+ import { SESSION_GUARANTEE_BYTES } from './session-allocator.js';
23
+ import { STALL_GRACE_MS } from './session-stall.js';
24
+ import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, sessionsSliceOverridePath, unitExecTarget, unitPath, writeLimitsOverride, DEVBRIDGE_SLICE, LIMITS_VERSION, SESSION_CPU_WEIGHT, SESSIONS_SLICE, readSwapTotalBytes, } from './service-unit.js';
25
+ import { defaultCageProbe, initSessionCage, readSliceLimits, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
22
26
  import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
23
27
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
24
28
  import { agentAuthStatuses } from './auth-relay.js';
25
- import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
29
+ import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, } from './environment.js';
26
30
  import { mcpConfigDir } from './paths.js';
27
31
  import { readMemoryFactsFromSystemd } from './systemd-memory.js';
28
32
  const execFileAsync = promisify(execFile);
@@ -536,6 +540,11 @@ async function cmdPair(args) {
536
540
  // Probed here as well as at daemon start, so the very first record of this
537
541
  // server already carries the truth about its containment instead of the safe
538
542
  // default. Costs one throwaway process, once.
543
+ //
544
+ // `probe` and not `daemon` (#403): pairing may run its own throwaway unit and
545
+ // clean it up, and it has no business anywhere near a session's cage — there
546
+ // may be a daemon running sessions on this machine right now.
547
+ claimCageAuthority('probe');
539
548
  await initSessionCage();
540
549
  const capabilities = runnerCapabilities(apiUrl);
541
550
  const response = await fetch(`${apiUrl}/api/v1/dev/servers/claim`, {
@@ -607,7 +616,91 @@ const RESTART_DELAY_MS = 1_500;
607
616
  * a runner that refuses to start because it could not improve its own limits is
608
617
  * strictly worse than one that starts without the improvement.
609
618
  */
610
- async function repairResourceLimits() {
619
+ /**
620
+ * The machine as the cage last measured it — see {@link reMeasureMachine}.
621
+ */
622
+ let lastMachineFingerprint = null;
623
+ /**
624
+ * Re-measure the machine on the hourly tick, and re-probe the cage when it has
625
+ * actually changed (#398 S3, work 11).
626
+ *
627
+ * `initSessionCage()` probes the machine EXACTLY ONCE, at daemon start, and
628
+ * remembers the answer; the hourly `repairResourceLimits` rewrites the slice's
629
+ * drop-in from fresh facts but never touches that cache. A live example, on the
630
+ * day of the incident: a swap file appeared on vmi3219930, and every session
631
+ * there went on being told `MemorySwapMax=0` — so the brake kept stopping dead
632
+ * instead of slowing down, on the one machine that had by then acquired
633
+ * somewhere to push pages. Until this, such a machine was cured only by
634
+ * restarting the runner.
635
+ *
636
+ * The probe costs a throwaway scope, so it is not done unconditionally: it runs
637
+ * only when the machine's fingerprint moved. New numbers reach LIVE sessions
638
+ * through the allocator's own tick, which reads the slice off cgroupfs and
639
+ * writes what has changed.
640
+ *
641
+ * The card is deliberately NOT promised here: `capabilities` are assembled once
642
+ * in `cmdDaemon` and travel only inside `hello`, so there is nothing to reissue
643
+ * them with short of a reconnect.
644
+ */
645
+ async function reMeasureMachine(maxSessions) {
646
+ await repairResourceLimits(maxSessions);
647
+ try {
648
+ const slice = readSliceLimits();
649
+ const fingerprint = JSON.stringify({
650
+ swap: readSwapTotalBytes() ?? 0,
651
+ pot: slice?.potBytes ?? null,
652
+ brake: slice?.collectiveBrakeBytes ?? null,
653
+ });
654
+ if (lastMachineFingerprint === null) {
655
+ lastMachineFingerprint = fingerprint;
656
+ return;
657
+ }
658
+ if (fingerprint === lastMachineFingerprint)
659
+ return;
660
+ log.warn('daemon: the machine changed under us — re-measuring the session cage', {
661
+ was: lastMachineFingerprint,
662
+ now: fingerprint,
663
+ });
664
+ /**
665
+ * A re-probe that comes back WORSE does not take the cage away.
666
+ *
667
+ * `initSessionCage` replaces what the daemon knows, and its live probe is a
668
+ * `systemd-run` — one transient failure (a busy bus, a momentary
669
+ * `Failed to connect`) would answer `nice-only`, and from that moment every
670
+ * new session on that machine would start with no cage at all, until
671
+ * somebody restarted the runner. An hourly job must not be able to do that.
672
+ * Found by the independent review of 10.09.2026.
673
+ *
674
+ * Losing the cage for real is possible — someone remounts cgroups, the user
675
+ * bus goes — and it is not silent: the line below says so, and the next
676
+ * daemon start settles it.
677
+ */
678
+ // `keepCageIfWorse`: an hourly job must not be able to uncage a healthy
679
+ // machine on one transient `systemd-run` failure — see `initSessionCage`.
680
+ const facts = await initSessionCage(defaultCageProbe, { keepCageIfWorse: true });
681
+ /**
682
+ * Remembered only when the measurement actually SUCCEEDED (#398 S7, B4).
683
+ *
684
+ * The fingerprint used to be stored before the probe, so one transient
685
+ * failure — the very failure `keepCageIfWorse` exists to survive — left the
686
+ * next hour seeing an unchanged fingerprint and skipping the re-probe
687
+ * forever. The cage was kept, and the FACTS behind it stayed yesterday's:
688
+ * `swapMaxBytes` above all, which is the vmi3219930 bug this work was
689
+ * written for. Cured only by restarting the runner, which is what the whole
690
+ * job was supposed to stop needing.
691
+ */
692
+ if (facts.mode === 'scope')
693
+ lastMachineFingerprint = fingerprint;
694
+ }
695
+ catch (error) {
696
+ // Never fatal: a runner that dies because it could not re-measure is worse
697
+ // than one that keeps yesterday's numbers for another hour.
698
+ log.warn('daemon: could not re-measure the machine', {
699
+ error: String(error instanceof Error ? error.message : error),
700
+ });
701
+ }
702
+ }
703
+ async function repairResourceLimits(maxSessions) {
611
704
  try {
612
705
  const { facts, sessionsUsageBytes } = await readMemoryFactsFromSystemd();
613
706
  if (facts === null) {
@@ -628,17 +721,14 @@ async function repairResourceLimits() {
628
721
  totalMB: Math.round(facts.totalBytes / 1048576),
629
722
  });
630
723
  }
631
- if (!writeLimitsOverride(false, undefined, facts, sessionsUsageBytes))
724
+ if (!writeLimitsOverride(false, undefined, facts, sessionsUsageBytes, maxSessions))
632
725
  return;
633
726
  log.warn('daemon: resource limits drop-in written — reloading systemd', {
634
727
  path: limitsOverridePath(),
635
728
  version: LIMITS_VERSION,
636
729
  ...memoryPolicy(facts),
637
730
  });
638
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
639
- timeout: 15_000,
640
- env: systemdUserEnv(),
641
- });
731
+ await runSystemctl(['daemon-reload'], { timeout: 15_000 });
642
732
  // Deliberately no restart: `daemon-reload` alone is enough for these
643
733
  // directives — verified live twice, in both directions: 2026-07-30 removing a
644
734
  // ceiling (MemoryMax 2G→infinity, OOMPolicy stop→continue) and 2026-08-12
@@ -757,13 +847,51 @@ async function cmdDaemon() {
757
847
  const config = requireConfig();
758
848
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
759
849
  sweepOrphanedMcpConfigs();
850
+ /**
851
+ * One daemon per user manager, checked BEFORE the sweep below (#403).
852
+ *
853
+ * The sweep stops every session scope this process does not know about, and
854
+ * at start-up that is all of them. A second daemon — `pnpm dev` of this
855
+ * package beside a paired runner, or a hand-started `devbridge-runner daemon`
856
+ * — therefore ends every live session the first one is supervising, and no
857
+ * guard inside the test suite covers that path.
858
+ *
859
+ * The escape hatch is deliberate and narrow: `DEVBRIDGE_ALLOW_SECOND_DAEMON=1`
860
+ * starts a second daemon with the `probe` grade, so it can run and be
861
+ * developed against but cannot touch anybody's cage.
862
+ */
863
+ const lock = acquireDaemonLock();
864
+ const second = isHeldByAnother(lock);
865
+ if (second) {
866
+ if (process.env['DEVBRIDGE_ALLOW_SECOND_DAEMON'] !== '1') {
867
+ fail(`another devbridge-runner daemon is already running on this user (pid ${lock.heldBy}). ` +
868
+ "Two daemons stop each other's sessions — see the lock at " +
869
+ `${lock.path}. Set DEVBRIDGE_ALLOW_SECOND_DAEMON=1 to start one that cannot touch cages.`);
870
+ }
871
+ log.warn('daemon: a second daemon, started without the right to act on session cages', {
872
+ heldBy: lock.heldBy,
873
+ });
874
+ }
875
+ else {
876
+ process.once('exit', () => lock.release());
877
+ }
878
+ /**
879
+ * The daemon is the ONE process on this machine allowed to act on session
880
+ * cages (#403), and it says so here, once, before the first sweep.
881
+ *
882
+ * Everything below — the sweep, the drop-ins, the probe, and later every
883
+ * limit written to a live scope — goes through `cage-authority.ts` and would
884
+ * be refused without this line. A test process cannot reach it at all: the
885
+ * claim throws there rather than granting anything.
886
+ */
887
+ claimCageAuthority(second ? 'probe' : 'daemon');
760
888
  // Nothing of ours is running yet, so every `devbridge-session-*.scope` on this
761
889
  // machine belongs to a process that is gone. Stopping the scope takes the
762
890
  // whole tree under it — which is the answer to the `ugrep` that outlived its
763
891
  // session by 10 h 51 min on 16.08, and to every scope the OOM killer left in
764
892
  // `failed` (a name systemd will otherwise refuse to reuse).
765
893
  await sweepOrphanSessionScopes();
766
- await repairResourceLimits();
894
+ await repairResourceLimits(config.limits?.max_sessions);
767
895
  // After the drop-ins are on disk and reloaded, never before: the probe below
768
896
  // creates `devbridge-sessions.slice`, and a slice first loaded without its
769
897
  // policy would hold no ceiling until the next daemon-reload.
@@ -785,6 +913,29 @@ async function cmdDaemon() {
785
913
  },
786
914
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
787
915
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
916
+ /**
917
+ * #398 S6: the machine owner's memory knobs, read once here and passed
918
+ * down. Nothing outside this file imports `config.js`, and a module that
919
+ * read the file itself would be the first place for two answers about one
920
+ * setting to appear.
921
+ */
922
+ memoryKnobs: {
923
+ ...(config.limits?.adaptive_memory === undefined
924
+ ? {}
925
+ : { adaptive: config.limits.adaptive_memory }),
926
+ ...(config.limits?.session_memory_min === undefined
927
+ ? {}
928
+ : { guaranteeBytes: config.limits.session_memory_min * 1024 * 1024 }),
929
+ ...(config.limits?.session_memory_max === undefined
930
+ ? {}
931
+ : { sessionMaxBytes: config.limits.session_memory_max * 1024 * 1024 }),
932
+ },
933
+ ...(config.limits?.memory_stall_grace_sec === undefined
934
+ ? {}
935
+ : { stallGraceMs: config.limits.memory_stall_grace_sec * 1000 }),
936
+ ...(config.limits?.memory_stall_action === undefined
937
+ ? {}
938
+ : { memoryStallAction: config.limits.memory_stall_action }),
788
939
  // The same veto the capability list honours — announced AND enforced, so a
789
940
  // frame from an API that has not noticed still cannot start a run.
790
941
  verifyEnabled: config.verify?.enabled !== false,
@@ -836,7 +987,7 @@ async function cmdDaemon() {
836
987
  pruneTimer.unref();
837
988
  // Re-measure the machine — see `LIMITS_RECHECK_MS`. Writes nothing in the
838
989
  // normal case, so this is a file read and some arithmetic once an hour.
839
- const limitsTimer = setInterval(() => void repairResourceLimits(), LIMITS_RECHECK_MS);
990
+ const limitsTimer = setInterval(() => void reMeasureMachine(config.limits?.max_sessions), LIMITS_RECHECK_MS);
840
991
  limitsTimer.unref();
841
992
  const shutdown = (signal) => {
842
993
  log.info(`daemon: ${signal} received, shutting down`);
@@ -931,6 +1082,9 @@ async function cmdInstallService() {
931
1082
  requireConfig(); // fail early if not paired
932
1083
  if (process.platform !== 'linux')
933
1084
  fail('install-service supports Linux/systemd only');
1085
+ // Installing the service writes the runner's own unit and starts it; it never
1086
+ // touches a session's cage, so `install` and not `daemon` (#403).
1087
+ claimCageAuthority('install');
934
1088
  const target = unitPath();
935
1089
  fs.mkdirSync(path.dirname(target), { recursive: true });
936
1090
  const exec = unitExecTarget();
@@ -979,10 +1133,8 @@ async function cmdInstallService() {
979
1133
  print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
980
1134
  }
981
1135
  try {
982
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
983
- await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner'], {
984
- env: systemdUserEnv(),
985
- });
1136
+ await runSystemctl(['daemon-reload']);
1137
+ await runSystemctl(['enable', '--now', 'devbridge-runner']);
986
1138
  print('Service enabled and started (systemctl --user).');
987
1139
  }
988
1140
  catch (error) {
@@ -1008,7 +1160,9 @@ function printCheck(check) {
1008
1160
  /** One property of the user service, or null when systemd cannot answer. */
1009
1161
  async function systemctlProperty(name) {
1010
1162
  try {
1011
- const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', name, '--value'], { timeout: 10_000, env: systemdUserEnv() });
1163
+ const { stdout } = await runSystemctl(['show', 'devbridge-runner', '-p', name, '--value'], {
1164
+ timeout: 10_000,
1165
+ });
1012
1166
  const value = stdout.trim();
1013
1167
  return value.length > 0 ? value : null;
1014
1168
  }
@@ -1378,6 +1532,9 @@ async function reportAgentReadiness(paths, fix) {
1378
1532
  }
1379
1533
  async function cmdDoctor(args) {
1380
1534
  const fix = args.includes('--fix');
1535
+ // Reading needs no right at all; `--fix` writes the runner's own drop-in and
1536
+ // reloads systemd, which is the `install` grade and nothing above it (#403).
1537
+ claimCageAuthority(fix ? 'install' : 'probe');
1381
1538
  const config = loadConfig();
1382
1539
  print(`devbridge-runner ${RUNNER_VERSION}`);
1383
1540
  print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
@@ -1397,6 +1554,52 @@ async function cmdDoctor(args) {
1397
1554
  // build, so this is the honest ceiling regardless of what the dashboard says.
1398
1555
  const advised = Math.max(1, Math.floor(os.totalmem() / 1024 / 1024 / 1536));
1399
1556
  print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
1557
+ /**
1558
+ * #398 S6. Every memory number `doctor` printed before this block came off
1559
+ * systemd or off `os` — it never said a word about `[limits]` in the machine
1560
+ * owner's own `config.toml`, so «I set a guarantee and nothing changed» had no
1561
+ * answer anywhere on the machine. Each line names the value in force AND
1562
+ * where it came from.
1563
+ */
1564
+ print('');
1565
+ print('Session memory (#398)');
1566
+ {
1567
+ const limits = loadConfig()?.limits;
1568
+ const shipped = (value) => `${value} (shipped default)`;
1569
+ const chosen = (value, unit) => `${value}${unit} (config.toml)`;
1570
+ print(` adaptive ${limits?.adaptive_memory === false
1571
+ ? 'OFF (config.toml) — sessions get the fixed pre-0.58.0 third'
1572
+ : 'on (shipped default) — the share follows what is actually free'}`);
1573
+ print(` guarantee ${limits?.session_memory_min === undefined
1574
+ ? shipped(Math.round(SESSION_GUARANTEE_BYTES / 1024 / 1024)) + ' MB'
1575
+ : chosen(limits.session_memory_min, ' MB')}`);
1576
+ print(` session ceiling ${limits?.session_memory_max === undefined
1577
+ ? 'whatever the machine has free (shipped default)'
1578
+ : chosen(limits.session_memory_max, ' MB')}`);
1579
+ print(` stall grace ${limits?.memory_stall_grace_sec === undefined
1580
+ ? shipped(Math.round(STALL_GRACE_MS / 1000)) + ' s'
1581
+ : chosen(limits.memory_stall_grace_sec, ' s')}`);
1582
+ print(` when it stalls ${limits?.memory_stall_action === 'report-only'
1583
+ ? 'report only (config.toml) — DevBridge stops nothing'
1584
+ : 'stop the biggest command (shipped default)'}`);
1585
+ print(` seats ${limits?.max_sessions ?? 'the API decides (no max_sessions set)'}`);
1586
+ const slice = readSliceLimits();
1587
+ /**
1588
+ * Named precisely, because «MISSING» alone sent people looking for a fault.
1589
+ *
1590
+ * The brake arrives with `LIMITS_VERSION` 6, and a machine whose runner has
1591
+ * not restarted since the update simply does not have it yet — that is a
1592
+ * pending update, not a broken machine. Found by the independent review of
1593
+ * 10.09.2026.
1594
+ */
1595
+ const brakeInForce = slice?.collectiveBrakeBytes ?? null;
1596
+ const dropInIsOld = limitsOverrideIsOutdated();
1597
+ print(` collective brake ${brakeInForce === null
1598
+ ? dropInIsOld
1599
+ ? 'not applied yet — the drop-in on this machine is older than this runner; `doctor --fix` writes it'
1600
+ : 'MISSING — run `devbridge-runner doctor --fix`; until then sessions get a smaller share each'
1601
+ : `${Math.round(brakeInForce / 1024 / 1024)} MB in force on the slice`}`);
1602
+ }
1400
1603
  print('');
1401
1604
  print('Service limits');
1402
1605
  const { facts: memFacts, sessionsUsageBytes: memSessionsUsage } = await readMemoryFactsFromSystemd();
@@ -1476,8 +1679,7 @@ async function cmdDoctor(args) {
1476
1679
  }
1477
1680
  let effective;
1478
1681
  try {
1479
- const { stdout } = await execFileAsync('systemctl', [
1480
- '--user',
1682
+ const { stdout } = await runSystemctl([
1481
1683
  'show',
1482
1684
  'devbridge-runner',
1483
1685
  '-p',
@@ -1490,7 +1692,7 @@ async function cmdDoctor(args) {
1490
1692
  'OOMPolicy',
1491
1693
  '-p',
1492
1694
  'NRestarts',
1493
- ], { env: systemdUserEnv() });
1695
+ ]);
1494
1696
  effective = stdout.trim().split('\n').filter(Boolean);
1495
1697
  }
1496
1698
  catch {
@@ -1629,7 +1831,7 @@ async function cmdDoctor(args) {
1629
1831
  }
1630
1832
  }
1631
1833
  try {
1632
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
1834
+ await runSystemctl(['daemon-reload']);
1633
1835
  print('systemctl --user daemon-reload — done.');
1634
1836
  print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
1635
1837
  }
package/dist/policy.d.ts CHANGED
@@ -71,6 +71,14 @@ export interface PolicyContext extends AgentGitPolicy {
71
71
  * than this release — and there the answer stays what it has always been.
72
72
  */
73
73
  agentAutoCommit?: boolean;
74
+ /**
75
+ * This session's memory ceiling in bytes, or `undefined` on a machine with no
76
+ * cage (#398 S5).
77
+ *
78
+ * Passed in rather than read here, so the pure policy stays pure and a test
79
+ * can put a session on a machine of any size.
80
+ */
81
+ sessionMemoryMaxBytes?: number;
74
82
  /**
75
83
  * Absolute path of the project's own prompt file, when this session was given
76
84
  * one (session 17).
@@ -226,5 +234,6 @@ export declare function evaluateRecipeCommand(command: string, ctx?: RecipeComma
226
234
  * workspace trust unchanged, which is exactly the old behaviour.
227
235
  */
228
236
  export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
237
+ export declare function requestedHeapBytes(command: string): number | null;
229
238
  export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
230
239
  //# sourceMappingURL=policy.d.ts.map