@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/config.d.ts
CHANGED
|
@@ -41,11 +41,79 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
41
41
|
auth?: "link" | "own" | undefined;
|
|
42
42
|
}>>;
|
|
43
43
|
limits: z.ZodOptional<z.ZodObject<{
|
|
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.ZodOptional<z.ZodNumber>;
|
|
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.ZodOptional<z.ZodBoolean>;
|
|
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.ZodOptional<z.ZodNumber>;
|
|
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.ZodOptional<z.ZodNumber>;
|
|
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.ZodOptional<z.ZodNumber>;
|
|
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.ZodOptional<z.ZodEnum<["stop-command", "report-only"]>>;
|
|
45
103
|
}, "strip", z.ZodTypeAny, {
|
|
46
|
-
max_sessions
|
|
104
|
+
max_sessions?: number | undefined;
|
|
105
|
+
adaptive_memory?: boolean | undefined;
|
|
106
|
+
session_memory_min?: number | undefined;
|
|
107
|
+
session_memory_max?: number | undefined;
|
|
108
|
+
memory_stall_grace_sec?: number | undefined;
|
|
109
|
+
memory_stall_action?: "stop-command" | "report-only" | undefined;
|
|
47
110
|
}, {
|
|
48
|
-
max_sessions
|
|
111
|
+
max_sessions?: number | undefined;
|
|
112
|
+
adaptive_memory?: boolean | undefined;
|
|
113
|
+
session_memory_min?: number | undefined;
|
|
114
|
+
session_memory_max?: number | undefined;
|
|
115
|
+
memory_stall_grace_sec?: number | undefined;
|
|
116
|
+
memory_stall_action?: "stop-command" | "report-only" | undefined;
|
|
49
117
|
}>>;
|
|
50
118
|
/**
|
|
51
119
|
* Session 14: the machine owner's veto over running project recipes.
|
|
@@ -126,7 +194,12 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
126
194
|
auth: "link" | "own";
|
|
127
195
|
} | undefined;
|
|
128
196
|
limits?: {
|
|
129
|
-
max_sessions
|
|
197
|
+
max_sessions?: number | undefined;
|
|
198
|
+
adaptive_memory?: boolean | undefined;
|
|
199
|
+
session_memory_min?: number | undefined;
|
|
200
|
+
session_memory_max?: number | undefined;
|
|
201
|
+
memory_stall_grace_sec?: number | undefined;
|
|
202
|
+
memory_stall_action?: "stop-command" | "report-only" | undefined;
|
|
130
203
|
} | undefined;
|
|
131
204
|
verify?: {
|
|
132
205
|
enabled: boolean;
|
|
@@ -155,7 +228,12 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
155
228
|
auth?: "link" | "own" | undefined;
|
|
156
229
|
} | undefined;
|
|
157
230
|
limits?: {
|
|
158
|
-
max_sessions
|
|
231
|
+
max_sessions?: number | undefined;
|
|
232
|
+
adaptive_memory?: boolean | undefined;
|
|
233
|
+
session_memory_min?: number | undefined;
|
|
234
|
+
session_memory_max?: number | undefined;
|
|
235
|
+
memory_stall_grace_sec?: number | undefined;
|
|
236
|
+
memory_stall_action?: "stop-command" | "report-only" | undefined;
|
|
159
237
|
} | undefined;
|
|
160
238
|
verify?: {
|
|
161
239
|
enabled?: boolean | undefined;
|
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
|