@bridge4dev/runner 0.52.0 → 0.54.0
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 +111 -33
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +14 -26
- package/dist/adapters/types.d.ts +45 -1
- package/dist/adapters/types.js +53 -0
- package/dist/checkpoints.d.ts +62 -0
- package/dist/checkpoints.js +50 -1
- package/dist/git.d.ts +64 -0
- package/dist/git.js +487 -36
- package/dist/gitops.d.ts +5 -0
- package/dist/gitops.js +7 -8
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +190 -40
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +228 -7
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +42 -20
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +232 -10
- package/dist/service-unit.js +372 -43
- package/dist/session-cage.d.ts +297 -0
- package/dist/session-cage.js +755 -0
- package/dist/supervisor.d.ts +156 -3
- package/dist/supervisor.js +351 -32
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT, SESSIONS_SLICE } from './service-unit.js';
|
|
2
|
+
/**
|
|
3
|
+
* What one session may hold. **Proposal Р3 of the plan, awaiting the owner.**
|
|
4
|
+
*
|
|
5
|
+
* `min(2.5 GiB, service ceiling / 2)` — §3 of
|
|
6
|
+
* `docs/plans/active/agent-sessions-host-resources.md`. Both halves matter:
|
|
7
|
+
*
|
|
8
|
+
* - the absolute number is sized against the measurement, not against a round
|
|
9
|
+
* figure. The heaviest ordinary thing a session runs is a workspace
|
|
10
|
+
* `pnpm typecheck`, measured at a 1571 MB peak; three live `claude`
|
|
11
|
+
* processes are 512/577/646 MB. 2.5 GiB leaves ~60 % headroom over the
|
|
12
|
+
* worst measured case, which is the margin between "kills a runaway" and
|
|
13
|
+
* "kills the work";
|
|
14
|
+
* - the half-of-the-service half is what keeps ONE session from being able to
|
|
15
|
+
* fill the ceiling that covers all of them. On this machine the service
|
|
16
|
+
* ceiling is 7680M, so half is 3.75 GiB and the absolute number wins.
|
|
17
|
+
*
|
|
18
|
+
* The service ceiling is read off systemd (`systemctl --user show
|
|
19
|
+
* devbridge-runner -p MemoryMax`) rather than recomputed here, because what
|
|
20
|
+
* protects the machine is what systemd has in force, not what the drop-in on
|
|
21
|
+
* disk says — those two have already drifted apart once (§5.5 of the plan).
|
|
22
|
+
*/
|
|
23
|
+
export declare const SESSION_MEMORY_MAX_ABSOLUTE_BYTES: number;
|
|
24
|
+
/**
|
|
25
|
+
* Processes and threads per session.
|
|
26
|
+
*
|
|
27
|
+
* An agent forks a lot (a monorepo build is hundreds of short-lived processes),
|
|
28
|
+
* so this is not a memory limit in disguise — it is the fork-bomb stop. 512 is
|
|
29
|
+
* far above anything measured on a real session and far below the 8192 the
|
|
30
|
+
* whole service gets.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SESSION_TASKS_MAX = 512;
|
|
33
|
+
/** Prefix of every scope this module creates. The sweeper matches on it. */
|
|
34
|
+
export declare const SESSION_SCOPE_PREFIX = "devbridge-session-";
|
|
35
|
+
/**
|
|
36
|
+
* How the sessions on this machine are contained.
|
|
37
|
+
*
|
|
38
|
+
* `scope` — a cgroup per session with a memory ceiling (this module).
|
|
39
|
+
* `nice-only` — cgroup v2 is not usable here; stage 1a's `nice(2)` is all there
|
|
40
|
+
* is. Correct on cgroup v1/hybrid (Ubuntu 20.04, CentOS 7/8) and
|
|
41
|
+
* under a foreign supervisor.
|
|
42
|
+
* `none` — not even that: the kernel refuses `setpriority` as well.
|
|
43
|
+
*
|
|
44
|
+
* Travels in `hello` as `machine.sessionCage` so the dashboard can SAY that the
|
|
45
|
+
* containment did not take on a given machine, instead of leaving it to be
|
|
46
|
+
* guessed (plan §5.4.3).
|
|
47
|
+
*/
|
|
48
|
+
export type SessionCageMode = 'scope' | 'nice-only' | 'none';
|
|
49
|
+
export interface SessionCageFacts {
|
|
50
|
+
mode: SessionCageMode;
|
|
51
|
+
/** Why not `scope`. Empty string when it is. */
|
|
52
|
+
reason: string;
|
|
53
|
+
/** What one session gets, in bytes. Null unless `mode === 'scope'`. */
|
|
54
|
+
memoryMaxBytes: number | null;
|
|
55
|
+
/** The service ceiling systemd has in force, as measured. Null = infinity. */
|
|
56
|
+
serviceMemoryMaxBytes: number | null;
|
|
57
|
+
/**
|
|
58
|
+
* The ceiling over ALL sessions together, as systemd has it in force on
|
|
59
|
+
* `devbridge-sessions.slice`. Null means there is none.
|
|
60
|
+
*
|
|
61
|
+
* Read rather than assumed, because the live probe proves only the PERSONAL
|
|
62
|
+
* ceiling: a `MemoryMax` on a scope applies whatever the parent slice says. A
|
|
63
|
+
* machine whose `daemon-reload` never happened therefore passed the probe,
|
|
64
|
+
* announced `sessionCage: 'scope'` and `limitsCurrent: true`, and had three
|
|
65
|
+
* sessions of 2.5 GB each over a slice at `MemoryMax=infinity` — with the card
|
|
66
|
+
* saying «Sessions capped» (QA-2026-09-07 MAJOR-3).
|
|
67
|
+
*/
|
|
68
|
+
sessionsSliceMemoryMaxBytes: number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Is `--expand-environment=no` on the command line? systemd ≥ 254 only — see
|
|
71
|
+
* the module header. False is not a downgrade below 254: expansion in
|
|
72
|
+
* `--scope` was off by default there anyway.
|
|
73
|
+
*/
|
|
74
|
+
expandEnvironmentFlag: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The FLOOR the plan's formula does not have, and the reason it needs one.
|
|
78
|
+
*
|
|
79
|
+
* `min(2.5 GiB, ceiling / 2)` is right on a big machine and wrong on a small
|
|
80
|
+
* one: at the 2 GiB ceiling `memoryPolicy` gives the smallest supported box,
|
|
81
|
+
* half is 1 GiB — BELOW the 1571 MB a workspace `pnpm typecheck` was measured
|
|
82
|
+
* at. The cage would then kill honest work on exactly the machines least able
|
|
83
|
+
* to afford a failed run, and it would look like a flaky agent rather than a
|
|
84
|
+
* limit. A containment that fires on correct work is not containment.
|
|
85
|
+
*
|
|
86
|
+
* So the floor is set above the worst measured ordinary peak, not at a round
|
|
87
|
+
* number.
|
|
88
|
+
*
|
|
89
|
+
* It is a floor and not a switch: below ~2.4 GB of RAM the containing ceiling
|
|
90
|
+
* itself drops under this number, and there the last clamp in
|
|
91
|
+
* {@link sessionMemoryMaxBytes} wins and a session is capped below the floor.
|
|
92
|
+
* That is not the cage killing honest work — the slice would have killed the
|
|
93
|
+
* same `pnpm typecheck` a moment later anyway, because it holds the same
|
|
94
|
+
* number — so removing `MemoryMax` from the scope there would buy nothing and
|
|
95
|
+
* cost the one thing it does buy: the runaway dying instead of its neighbours
|
|
96
|
+
* (QA-2026-09-07 MINOR-6, where the earlier wording promised the opposite).
|
|
97
|
+
*/
|
|
98
|
+
export declare const SESSION_MEMORY_MIN_BYTES: number;
|
|
99
|
+
/**
|
|
100
|
+
* `clamp(ceiling / 2, 2 GiB, 2.5 GiB)`, never above the ceiling itself.
|
|
101
|
+
*
|
|
102
|
+
* Three bounds, each for its own reason — see
|
|
103
|
+
* {@link SESSION_MEMORY_MAX_ABSOLUTE_BYTES} for the upper two and
|
|
104
|
+
* {@link SESSION_MEMORY_MIN_BYTES} for the lower one. The last clamp matters
|
|
105
|
+
* on a tiny machine: a per-session number ABOVE the slice's own ceiling is not
|
|
106
|
+
* a limit at all, it is a bigger number that never applies, and printing it in
|
|
107
|
+
* `doctor` would be a straight lie about what is in force.
|
|
108
|
+
*
|
|
109
|
+
* The argument is the ceiling of the cgroup that CONTAINS the session, and
|
|
110
|
+
* since 0.54.0 that is `devbridge-sessions.slice` and not the service — the
|
|
111
|
+
* caller reads the slice and falls back to the service only when systemd has
|
|
112
|
+
* nothing to say about the slice. The two are the same number by construction
|
|
113
|
+
* today; they come apart the moment a drop-in fails to apply or is edited by
|
|
114
|
+
* hand, and then «never more than the slice» has to still be true
|
|
115
|
+
* (QA-2026-09-07 MINOR-5).
|
|
116
|
+
*/
|
|
117
|
+
export declare function sessionMemoryMaxBytes(containingMemoryMaxBytes: number | null): number;
|
|
118
|
+
/**
|
|
119
|
+
* The session id as systemd will accept it.
|
|
120
|
+
*
|
|
121
|
+
* Ids are UUIDs today and arbitrary text tomorrow — they arrive from the API,
|
|
122
|
+
* and `verify` keys its cage by a run id. systemd unit names take only
|
|
123
|
+
* `[A-Za-z0-9:_.-]`, so everything else is folded to `-`. Folding is lossy, and
|
|
124
|
+
* lossy is how two different sessions end up fighting over one scope, so
|
|
125
|
+
* anything that had to be changed or cut also gets eight hex digits of the
|
|
126
|
+
* original — deterministic, so the same session always names the same unit and
|
|
127
|
+
* the sweeper can still recognise it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function sanitizeCageId(id: string): string;
|
|
130
|
+
/**
|
|
131
|
+
* The scope unit for one START of one session.
|
|
132
|
+
*
|
|
133
|
+
* `attempt` is not decoration. A scope the OOM killer took stays loaded in
|
|
134
|
+
* `failed` until something resets it, and `systemd-run --unit=` on a name that
|
|
135
|
+
* is still loaded fails outright — measured: «Unit devbridge-….scope was
|
|
136
|
+
* already loaded or has a fragment file», exit 1, nothing spawned. So a session
|
|
137
|
+
* that is restarted after an OOM (which is exactly when it IS restarted) would
|
|
138
|
+
* be unable to start at all if the name never changed.
|
|
139
|
+
* {@link releaseSessionScope} clears the failed unit and only then lets the
|
|
140
|
+
* counter fall back to 1, so the plain `devbridge-session-<id>.scope` is the
|
|
141
|
+
* normal case and the marker appears only while the old scope is still there.
|
|
142
|
+
*/
|
|
143
|
+
export declare function sessionScopeUnit(id: string, attempt?: number): string;
|
|
144
|
+
/**
|
|
145
|
+
* The systemd release that added `--expand-environment=`.
|
|
146
|
+
*
|
|
147
|
+
* Below it `systemd-run` answers `unrecognized option` and exits 1 — the probe
|
|
148
|
+
* then reads as «the cage did not hold» on a machine whose cgroups are perfect
|
|
149
|
+
* (Ubuntu 22.04 is 249, Debian 12 and RHEL 9 are 252). See the module header for
|
|
150
|
+
* why the flag is not needed there either.
|
|
151
|
+
*/
|
|
152
|
+
export declare const EXPAND_ENVIRONMENT_MIN_SYSTEMD = 254;
|
|
153
|
+
/** What one run of the throwaway scope answered, and why it did not run. */
|
|
154
|
+
export interface CageProbeResult {
|
|
155
|
+
/** `memory.max` as the scope read it back. Null when the scope never ran. */
|
|
156
|
+
memoryMax: string | null;
|
|
157
|
+
/** `systemd-run`'s own first line of complaint. Null when it ran. */
|
|
158
|
+
error: string | null;
|
|
159
|
+
}
|
|
160
|
+
/** Everything about this machine the detector needs, so a test can lie about all of it. */
|
|
161
|
+
export interface CageProbe {
|
|
162
|
+
/** `cgroup2fs`, `tmpfs`, or whatever else is mounted at `/sys/fs/cgroup`. */
|
|
163
|
+
cgroupFsType: () => string | null;
|
|
164
|
+
systemdRunOnPath: () => boolean;
|
|
165
|
+
/** Major version of `systemd-run`, or null when it would not say. */
|
|
166
|
+
systemdRunVersion: () => Promise<number | null>;
|
|
167
|
+
/** `$XDG_RUNTIME_DIR` when `$XDG_RUNTIME_DIR/bus` exists, else null. */
|
|
168
|
+
userBusPath: () => string | null;
|
|
169
|
+
/** Controllers delegated to this user's manager, or null when unreadable. */
|
|
170
|
+
delegatedControllers: () => string[] | null;
|
|
171
|
+
/** The one-shot scope that reads its own `memory.max`, built like a real one. */
|
|
172
|
+
probeMemoryMax: (options: {
|
|
173
|
+
expandEnvironmentFlag: boolean;
|
|
174
|
+
}) => Promise<CageProbeResult>;
|
|
175
|
+
/** `MemoryMax` of the runner service in bytes; null for `infinity`. */
|
|
176
|
+
serviceMemoryMax: () => Promise<number | null>;
|
|
177
|
+
/** `MemoryMax` in force on `devbridge-sessions.slice`; null for `infinity`. */
|
|
178
|
+
sessionsSliceMemoryMax: () => Promise<number | null>;
|
|
179
|
+
/** Can this kernel renice at all — the fallback's own precondition. */
|
|
180
|
+
canRenice: () => boolean;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Variables the child needs to reach the user's systemd, and nothing else.
|
|
184
|
+
*
|
|
185
|
+
* `systemd-run --user` finds the bus through `XDG_RUNTIME_DIR` (sd-bus falls
|
|
186
|
+
* back to `$XDG_RUNTIME_DIR/bus` when `DBUS_SESSION_BUS_ADDRESS` is unset), and
|
|
187
|
+
* that variable is already on both spawn allowlists — so merging this into a
|
|
188
|
+
* session's environment widens nothing. `DBUS_SESSION_BUS_ADDRESS` is passed on
|
|
189
|
+
* only when the daemon really has one of its own, never synthesised.
|
|
190
|
+
*/
|
|
191
|
+
export declare function cageEnv(): Record<string, string>;
|
|
192
|
+
export declare const defaultCageProbe: CageProbe;
|
|
193
|
+
export declare function detectSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
|
|
194
|
+
/**
|
|
195
|
+
* Probe once, at daemon start, and remember the answer.
|
|
196
|
+
*
|
|
197
|
+
* The probe costs a process, so it is not something a spawn can afford to do:
|
|
198
|
+
* three sessions starting at once would mean three throwaway scopes before the
|
|
199
|
+
* first agent got a word out.
|
|
200
|
+
*/
|
|
201
|
+
export declare function initSessionCage(probe?: CageProbe): Promise<SessionCageFacts>;
|
|
202
|
+
/** What the last {@link initSessionCage} found; the safe default before it ran. */
|
|
203
|
+
export declare function sessionCage(): SessionCageFacts;
|
|
204
|
+
export interface CagedSpawn {
|
|
205
|
+
command: string;
|
|
206
|
+
args: string[];
|
|
207
|
+
/**
|
|
208
|
+
* Variables to MERGE into the child's environment (`{...yours, ...cage.env}`).
|
|
209
|
+
* Empty when nothing was wrapped.
|
|
210
|
+
*/
|
|
211
|
+
env: Record<string, string>;
|
|
212
|
+
/** The scope unit this start will live in, or null when nothing was wrapped. */
|
|
213
|
+
unit: string | null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Wrap a command in its session's cage, or hand it back untouched.
|
|
217
|
+
*
|
|
218
|
+
* Untouched is the honest answer on every machine where the cage was not proved
|
|
219
|
+
* to work: `systemd-run` would accept the flags there and apply nothing, and a
|
|
220
|
+
* session that believes it is contained when it is not is worse than one that
|
|
221
|
+
* knows it is not.
|
|
222
|
+
*
|
|
223
|
+
* Pipes, exit codes, signals and stdin EOF behave exactly as with a direct
|
|
224
|
+
* spawn, because `systemd-run --scope` execs into the SAME pid — verified in the
|
|
225
|
+
* spike, including `detached: true` + `process.kill(-pid)` in `verify.ts`
|
|
226
|
+
* (`pgid === child.pid` still holds).
|
|
227
|
+
*/
|
|
228
|
+
export declare function cageSpawn(input: {
|
|
229
|
+
id: string;
|
|
230
|
+
command: string;
|
|
231
|
+
args: string[];
|
|
232
|
+
}): CagedSpawn;
|
|
233
|
+
/**
|
|
234
|
+
* Did this process die in the window before `systemd-run` handed over?
|
|
235
|
+
*
|
|
236
|
+
* Starting a scope took 0.07–2.5 s in the spike, and 2741 ms on a loaded
|
|
237
|
+
* machine. A stop inside that window signals `systemd-run` itself, before the
|
|
238
|
+
* `exec`, and the parent sees `{code: null, signal: 'SIGTERM'}` with nothing on
|
|
239
|
+
* stdout at all. That is «the session never started», not «the agent died
|
|
240
|
+
* silently» — told apart here so no supervisor has to infer it from an empty
|
|
241
|
+
* buffer.
|
|
242
|
+
*/
|
|
243
|
+
export declare function killedBeforeExec(info: {
|
|
244
|
+
code: number | null;
|
|
245
|
+
signal: string | null;
|
|
246
|
+
sawOutput: boolean;
|
|
247
|
+
caged: boolean;
|
|
248
|
+
}): boolean;
|
|
249
|
+
/** So the sweeper and the reader can be tested without a machine under them. */
|
|
250
|
+
export type Systemctl = (args: string[]) => Promise<{
|
|
251
|
+
stdout: string;
|
|
252
|
+
stderr: string;
|
|
253
|
+
}>;
|
|
254
|
+
/**
|
|
255
|
+
* Read why the scope ended, say so, and then let systemd forget it.
|
|
256
|
+
*
|
|
257
|
+
* This is the whole reason `--collect` is not passed. The order is fixed: the
|
|
258
|
+
* process has already exited, `Result` is read, the reason is logged, and only
|
|
259
|
+
* then is the unit reset — because `reset-failed` is what deletes the answer.
|
|
260
|
+
*
|
|
261
|
+
* Failure here is never fatal: a unit that could not be reset is swept at the
|
|
262
|
+
* next daemon start, and the counter in {@link cageSpawn} keeps the session
|
|
263
|
+
* startable in the meantime.
|
|
264
|
+
*/
|
|
265
|
+
export declare function releaseSessionScope(unit: string | null, id?: string, systemctl?: Systemctl): Promise<string | null>;
|
|
266
|
+
/** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
|
|
267
|
+
export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
|
|
268
|
+
export interface SessionScopeInfo {
|
|
269
|
+
unit: string;
|
|
270
|
+
memoryMaxBytes: number | null;
|
|
271
|
+
memoryCurrentBytes: number | null;
|
|
272
|
+
tasksCurrent: number | null;
|
|
273
|
+
result: string | null;
|
|
274
|
+
activeState: string | null;
|
|
275
|
+
}
|
|
276
|
+
/** What `doctor` prints for each caged session. */
|
|
277
|
+
export declare function listSessionScopes(systemctl?: Systemctl): Promise<SessionScopeInfo[]>;
|
|
278
|
+
/**
|
|
279
|
+
* Stop and forget every session scope that has no session behind it.
|
|
280
|
+
*
|
|
281
|
+
* At daemon start that is all of them by definition, and it is the point: a
|
|
282
|
+
* scope outlives a killed daemon carrying the whole process tree with it, which
|
|
283
|
+
* is the shape of the 10 h 51 min `ugrep` of 16.08. Stopping the scope takes the
|
|
284
|
+
* tree, not just the process we happened to know about.
|
|
285
|
+
*
|
|
286
|
+
* `liveIds` exists so the same sweep can run later without killing work in
|
|
287
|
+
* progress; only the CURRENT scope of a live session is spared, because an
|
|
288
|
+
* earlier attempt of the same session is exactly the leftover we are here for.
|
|
289
|
+
*/
|
|
290
|
+
export declare function sweepOrphanSessionScopes(liveIds?: Iterable<string>, systemctl?: Systemctl): Promise<string[]>;
|
|
291
|
+
/**
|
|
292
|
+
* The slice names and the CPU share — re-exported so a caller that reasons about
|
|
293
|
+
* the cage needs one import, while the values themselves stay next to the
|
|
294
|
+
* drop-in that writes them (`service-unit.ts`).
|
|
295
|
+
*/
|
|
296
|
+
export { SESSIONS_SLICE, DEVBRIDGE_SLICE, SESSION_CPU_WEIGHT };
|
|
297
|
+
//# sourceMappingURL=session-cage.d.ts.map
|