@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/adapters/claude.js +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- 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 +71 -1
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/regex-guard.js +6 -6
- 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 +223 -33
- package/dist/supervisor.js +845 -94
- 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/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
|
-
|
|
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
|
package/dist/host-load.d.ts
CHANGED
|
@@ -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 {
|
|
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
|