@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.
- package/dist/adapters/claude.js +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- package/dist/adapters/types.js +14 -0
- package/dist/cage-authority.d.ts +118 -0
- package/dist/cage-authority.js +241 -0
- package/dist/config.d.ts +83 -5
- package/dist/config.js +59 -1
- package/dist/daemon-lock.d.ts +43 -0
- package/dist/daemon-lock.js +107 -0
- package/dist/host-load.d.ts +9 -0
- package/dist/host-load.js +9 -0
- package/dist/index.js +222 -20
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +68 -0
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/self-update.js +22 -1
- package/dist/service-unit.d.ts +35 -3
- package/dist/service-unit.js +82 -5
- package/dist/session-allocator.d.ts +259 -0
- package/dist/session-allocator.js +492 -0
- package/dist/session-cage.d.ts +229 -2
- package/dist/session-cage.js +590 -40
- package/dist/session-limits.d.ts +71 -0
- package/dist/session-limits.js +93 -0
- package/dist/session-stall.d.ts +353 -0
- package/dist/session-stall.js +760 -0
- package/dist/supervisor.d.ts +235 -33
- package/dist/supervisor.js +1178 -265
- package/dist/systemd-memory.js +2 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 {
|
|
21
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
983
|
-
await
|
|
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
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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
|
package/dist/policy.js
CHANGED
|
@@ -1173,6 +1173,46 @@ export function effectiveTrust(trustMode, mode) {
|
|
|
1173
1173
|
// ask / plan — at most NORMAL, never AUTO.
|
|
1174
1174
|
return trustMode === 'AUTO' ? 'NORMAL' : trustMode;
|
|
1175
1175
|
}
|
|
1176
|
+
/**
|
|
1177
|
+
* The largest Node heap this command line asks for, in bytes — or null when it
|
|
1178
|
+
* asks for none.
|
|
1179
|
+
*
|
|
1180
|
+
* Both spellings systemd-era Node accepts (`--max-old-space-size=N` and
|
|
1181
|
+
* `--max_old_space_size N`), anywhere on the line: the flag arrives through
|
|
1182
|
+
* `NODE_OPTIONS=`, through `node --…`, and through a `pnpm` script that passes
|
|
1183
|
+
* it on, and it is the NUMBER that matters, not who wrote it.
|
|
1184
|
+
*
|
|
1185
|
+
* The unit is megabytes, which is V8's own unit for the flag — a plain number,
|
|
1186
|
+
* never a suffix.
|
|
1187
|
+
*/
|
|
1188
|
+
/**
|
|
1189
|
+
* Commands that RUN node, as opposed to commands that merely mention it.
|
|
1190
|
+
*
|
|
1191
|
+
* The gate reads the heap flag out of a shell line, and a shell line is not one
|
|
1192
|
+
* command: `sed -i 's/--max-old-space-size=8192/…=3000/' package.json` contains
|
|
1193
|
+
* the flag and asks Node for nothing at all. Denying that was worse than a
|
|
1194
|
+
* nuisance — LOWERING the flag is exactly what the refusal tells the agent to
|
|
1195
|
+
* do, so the gate forbade its own remedy (#398 S7, B10).
|
|
1196
|
+
*/
|
|
1197
|
+
const NODE_RUNNERS = /(^|[\s;&|(])(sudo\s+)?(env\s+)?([A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(node|nodejs|npm|npx|pnpm|yarn|bun|deno|tsx|ts-node|vitest|jest)([\s;&|)]|$)/;
|
|
1198
|
+
export function requestedHeapBytes(command) {
|
|
1199
|
+
let largest = null;
|
|
1200
|
+
for (const segment of commandSegments(command)) {
|
|
1201
|
+
// `NODE_OPTIONS=` carries the flag into whatever the segment starts, so it
|
|
1202
|
+
// counts on its own; otherwise the segment has to actually start a runtime.
|
|
1203
|
+
if (!/NODE_OPTIONS\s*=/.test(segment) && !NODE_RUNNERS.test(segment))
|
|
1204
|
+
continue;
|
|
1205
|
+
for (const match of segment.matchAll(/--?max[-_]old[-_]space[-_]size(?:\s*=\s*|\s+)(\d+)/gi)) {
|
|
1206
|
+
const mb = Number(match[1]);
|
|
1207
|
+
if (!Number.isSafeInteger(mb) || mb <= 0)
|
|
1208
|
+
continue;
|
|
1209
|
+
const bytes = mb * 1024 * 1024;
|
|
1210
|
+
if (largest === null || bytes > largest)
|
|
1211
|
+
largest = bytes;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return largest;
|
|
1215
|
+
}
|
|
1176
1216
|
export function evaluateToolUse(toolName, input, ctx) {
|
|
1177
1217
|
// Read ONCE, here, and never `ctx.trustMode` again below: the branches that
|
|
1178
1218
|
// follow are the whole of layer 1, and a single one still reading the raw
|
|
@@ -1227,6 +1267,34 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
1227
1267
|
}
|
|
1228
1268
|
if (gitAsk)
|
|
1229
1269
|
return gitAsk;
|
|
1270
|
+
/**
|
|
1271
|
+
* A heap the cage cannot honour (#398 S5).
|
|
1272
|
+
*
|
|
1273
|
+
* Same position as everything above it and for the same reason: under AUTO
|
|
1274
|
+
* the Bash branch returns `allow` two lines down, and a rule placed below
|
|
1275
|
+
* that line is dead in the one mode most sessions run in. That mistake cost
|
|
1276
|
+
* a whole release once (session 13).
|
|
1277
|
+
*
|
|
1278
|
+
* From the incident this plan comes from: the agent raised its own heap to
|
|
1279
|
+
* 4 GB, then to 6 GB, against a wall of 4296 MB, and each time made things
|
|
1280
|
+
* worse — the ceiling is enforced OUTSIDE the process, so asking for more
|
|
1281
|
+
* only means being stopped sooner. The reason is written for the model to
|
|
1282
|
+
* read: it names the number and the way out.
|
|
1283
|
+
*/
|
|
1284
|
+
if (ctx.sessionMemoryMaxBytes !== undefined && ctx.sessionMemoryMaxBytes > 0) {
|
|
1285
|
+
for (const variant of [command, dequote(command)]) {
|
|
1286
|
+
const asked = requestedHeapBytes(variant);
|
|
1287
|
+
if (asked !== null && asked > ctx.sessionMemoryMaxBytes) {
|
|
1288
|
+
const mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
1289
|
+
return {
|
|
1290
|
+
decision: 'deny',
|
|
1291
|
+
reason: `this command asks Node for a ${mb(asked)} MB heap, and this session cannot exceed ` +
|
|
1292
|
+
`${mb(ctx.sessionMemoryMaxBytes)} MB — the ceiling is enforced outside the process, so a ` +
|
|
1293
|
+
'bigger heap only means being stopped sooner. Lower --max-old-space-size, or split the work up',
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1230
1298
|
if (trust === 'STRICT')
|
|
1231
1299
|
return { decision: 'ask', reason: 'strict mode' };
|
|
1232
1300
|
if (trust === 'AUTO')
|