@bridge4dev/runner 0.13.1 → 0.22.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.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -14,8 +15,20 @@ import { Supervisor } from './supervisor.js';
|
|
|
14
15
|
import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
|
|
15
16
|
import { RunnerWsClient } from './ws-client.js';
|
|
16
17
|
import { RUNNER_VERSION } from './version.js';
|
|
17
|
-
import { buildUnit, unitExecTarget, unitPath } from './service-unit.js';
|
|
18
|
+
import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
|
|
19
|
+
import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
|
|
20
|
+
import { mcpConfigDir } from './paths.js';
|
|
18
21
|
const execFileAsync = promisify(execFile);
|
|
22
|
+
/**
|
|
23
|
+
* Identity of this process, and the note the previous one left.
|
|
24
|
+
*
|
|
25
|
+
* Read once at module load, before anything can connect: `takeLastExit()`
|
|
26
|
+
* CONSUMES the note, so it must happen exactly once per process — reading it
|
|
27
|
+
* per reconnect would report the same death forever (QA-112).
|
|
28
|
+
*/
|
|
29
|
+
const INSTANCE_ID = crypto.randomUUID();
|
|
30
|
+
const STARTED_AT = new Date().toISOString();
|
|
31
|
+
const LAST_EXIT = takeLastExit();
|
|
19
32
|
function print(line) {
|
|
20
33
|
process.stdout.write(line + '\n');
|
|
21
34
|
}
|
|
@@ -49,6 +62,21 @@ function installedAgents() {
|
|
|
49
62
|
function selfUpdatable() {
|
|
50
63
|
return resolveInstalledPackageDir() !== null && isSupervisedProcess();
|
|
51
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Why this runner cannot replace itself — so the card can SAY it.
|
|
67
|
+
*
|
|
68
|
+
* Both halves look identical from the dashboard (no button), and "why does this
|
|
69
|
+
* machine have a button and that one doesn't" is exactly the question the page
|
|
70
|
+
* should not leave hanging. Reported as a string rather than inferred, because
|
|
71
|
+
* only the runner knows how it was started.
|
|
72
|
+
*/
|
|
73
|
+
function selfUpdateBlockedReason() {
|
|
74
|
+
if (resolveInstalledPackageDir() === null)
|
|
75
|
+
return 'source-checkout';
|
|
76
|
+
if (!isSupervisedProcess())
|
|
77
|
+
return 'unsupervised';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
52
80
|
function hasExecutable(name) {
|
|
53
81
|
const dirs = (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean);
|
|
54
82
|
return dirs.some((dir) => {
|
|
@@ -68,7 +96,12 @@ function hasExecutable(name) {
|
|
|
68
96
|
* command it does not know would just hang until the gateway timeout.
|
|
69
97
|
*/
|
|
70
98
|
function runnerCapabilities() {
|
|
71
|
-
const
|
|
99
|
+
const config = loadConfig();
|
|
100
|
+
const localLimit = config?.limits?.max_sessions;
|
|
101
|
+
// Session 14: the machine owner's veto. Default on — the safety of the
|
|
102
|
+
// feature is that a DevBridge manager approves every command first — but the
|
|
103
|
+
// person who owns the server gets the last word on whether it exists here.
|
|
104
|
+
const verifyEnabled = config?.verify?.enabled !== false;
|
|
72
105
|
return {
|
|
73
106
|
agents: installedAgents(),
|
|
74
107
|
git: true,
|
|
@@ -83,7 +116,9 @@ function runnerCapabilities() {
|
|
|
83
116
|
* installed package under a supervisor — a source checkout or a hand-started
|
|
84
117
|
* daemon says so here, so the button never appears where it cannot work.
|
|
85
118
|
*/
|
|
86
|
-
...(selfUpdatable()
|
|
119
|
+
...(selfUpdatable()
|
|
120
|
+
? { selfUpdate: true }
|
|
121
|
+
: { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
|
|
87
122
|
/**
|
|
88
123
|
* A stricter ceiling set on the machine itself (layer 1). Reported so the
|
|
89
124
|
* dashboard can explain why raising the number there changed nothing.
|
|
@@ -99,8 +134,144 @@ function runnerCapabilities() {
|
|
|
99
134
|
* History tab rather than showing a tab that answers "unknown command".
|
|
100
135
|
*/
|
|
101
136
|
gitHistory: true,
|
|
137
|
+
/**
|
|
138
|
+
* Session 12: parks `AskUserQuestion` and understands the `question_answer`
|
|
139
|
+
* frame. The API refuses to accept an answer without it — otherwise the
|
|
140
|
+
* frame would leave, the socket would report success, and the runner would
|
|
141
|
+
* drop it: the silent failure this whole session exists to remove.
|
|
142
|
+
*/
|
|
143
|
+
questionCards: true,
|
|
144
|
+
/**
|
|
145
|
+
* Session 13: git is a workplace, not a one-way door.
|
|
146
|
+
*
|
|
147
|
+
* Understands `branchPlan` (create vs continue, with a pinned fork point),
|
|
148
|
+
* answers `git_branches` / `update_from_base` / `git_push`, and reports
|
|
149
|
+
* ahead/behind/drift in `git_status`. One flag rather than four: they ship
|
|
150
|
+
* together and the panel needs all of them to say anything true.
|
|
151
|
+
*/
|
|
152
|
+
gitWorkspace: true,
|
|
153
|
+
/**
|
|
154
|
+
* Session 14: the graph became a repository viewer.
|
|
155
|
+
*
|
|
156
|
+
* `git_refs`, a `git_log` that takes an explicit ref set, typed
|
|
157
|
+
* decorations, the committer in both formats, and `workspace_state`. One
|
|
158
|
+
* flag rather than five: the graph needs all of them at once to draw
|
|
159
|
+
* anything the previous version could not.
|
|
160
|
+
*/
|
|
161
|
+
gitRefs: true,
|
|
162
|
+
/**
|
|
163
|
+
* Session 14: reads `.devbridge/project.json`. Announced even when
|
|
164
|
+
* verification is switched off — «this machine will not run recipes» and
|
|
165
|
+
* «this project has no recipe» are different sentences and the card has to
|
|
166
|
+
* be able to say both.
|
|
167
|
+
*/
|
|
168
|
+
projectRecipe: true,
|
|
169
|
+
/**
|
|
170
|
+
* Session 14: can execute an APPROVED recipe and report a verdict.
|
|
171
|
+
*
|
|
172
|
+
* Withheld when the machine's owner set `[verify] enabled = false`, and
|
|
173
|
+
* withheld means the dashboard draws no card at all — a control that is
|
|
174
|
+
* switched off must not look like a control that is broken.
|
|
175
|
+
*/
|
|
176
|
+
...(verifyEnabled ? { verifyRuns: true } : { verifyBlocked: 'disabled-by-config' }),
|
|
177
|
+
/** Session 14: a second, detached worktree driven only by `preview`. */
|
|
178
|
+
...(verifyEnabled ? { previewWorktrees: true } : {}),
|
|
179
|
+
/** Session 14: one-shot agent-written commit messages. */
|
|
180
|
+
commitMessages: true,
|
|
181
|
+
/**
|
|
182
|
+
* Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
|
|
183
|
+
* the panel can say what is blocking an Apply instead of offering a button
|
|
184
|
+
* that the runner will refuse.
|
|
185
|
+
*/
|
|
186
|
+
workspaceDirtyReporting: true,
|
|
187
|
+
/**
|
|
188
|
+
* Session 15: reads `agentAutoCommit` off the workspace and honours it on
|
|
189
|
+
* every tool call rather than at launch.
|
|
190
|
+
*
|
|
191
|
+
* Its own flag now. It used to ride on `workspaceStash`, which was fine
|
|
192
|
+
* while the two shipped together and wrong the moment one of them was
|
|
193
|
+
* deleted — the settings card would have started calling every up-to-date
|
|
194
|
+
* runner incapable of the setting it honours.
|
|
195
|
+
*/
|
|
196
|
+
agentAutoCommit: true,
|
|
197
|
+
/**
|
|
198
|
+
* Session 16: git is git.
|
|
199
|
+
*
|
|
200
|
+
* `git_stage` / `git_unstage` / `git_discard` / `git_pull` /
|
|
201
|
+
* `git_merge_abort`, a `git_status` that reports the working tree the way
|
|
202
|
+
* `git status` does (both columns, upstream, ahead/behind, merge in
|
|
203
|
+
* progress), a `git_diff` that can show either side of the index, a
|
|
204
|
+
* `git_commit` that can commit just what was staged, and `workMode:
|
|
205
|
+
* DIRECT` — a session that works in the project folder itself.
|
|
206
|
+
*
|
|
207
|
+
* One flag for all of it because the Source Control panel needs all of it:
|
|
208
|
+
* a stage button next to a status that cannot say what is staged would be a
|
|
209
|
+
* control with nothing to control.
|
|
210
|
+
*/
|
|
211
|
+
sourceControl: true,
|
|
212
|
+
/**
|
|
213
|
+
* Session 17 (#113): `agent_tasks` — the live set of subagents, background
|
|
214
|
+
* shells and dynamic workflows the agent is running beside the
|
|
215
|
+
* conversation, published as a level signal with REPLACE semantics.
|
|
216
|
+
*
|
|
217
|
+
* Nothing gates on it yet — the tray simply stays empty on a runner that
|
|
218
|
+
* sends no `agent_tasks`, which is the right behaviour either way. It is
|
|
219
|
+
* advertised now because a capability has to be on the wire BEFORE anything
|
|
220
|
+
* can gate on it: the runner ships to machines on its own schedule, and a
|
|
221
|
+
* flag added at the same time as its first reader is a flag that reads
|
|
222
|
+
* `false` on every server that has not been updated yet.
|
|
223
|
+
*/
|
|
224
|
+
agentTasks: true,
|
|
225
|
+
/**
|
|
226
|
+
* Session 18 (QA-112): the facts DevBridge needs to stop guessing.
|
|
227
|
+
*
|
|
228
|
+
* `maxSessions` was a number out of thin air (default 3) while this machine
|
|
229
|
+
* silently decided whether three agents fit. Three measured `claude`
|
|
230
|
+
* processes are ~1.7 GB and one workspace typecheck peaks at 1.5 GB, so on a
|
|
231
|
+
* small box the answer is no — and the only symptom used to be a dead runner.
|
|
232
|
+
* Reported so the dashboard can recommend a ceiling and explain a refusal.
|
|
233
|
+
*/
|
|
234
|
+
machine: {
|
|
235
|
+
cpuCount: os.cpus().length,
|
|
236
|
+
memTotalBytes: os.totalmem(),
|
|
237
|
+
memAvailableBytes: os.freemem(),
|
|
238
|
+
limitsVersion: LIMITS_VERSION,
|
|
239
|
+
limitsCurrent: !limitsOverrideIsOutdated(),
|
|
240
|
+
},
|
|
241
|
+
/**
|
|
242
|
+
* Identity of THIS process, so the API can tell a network blink from a
|
|
243
|
+
* restart. Without it `dev-runner: connected activeSessions: 3` reads the
|
|
244
|
+
* same either way, which is why the incident's six drops could not be
|
|
245
|
+
* classified (QA-112 MAJOR-4).
|
|
246
|
+
*/
|
|
247
|
+
instanceId: INSTANCE_ID,
|
|
248
|
+
startedAt: STARTED_AT,
|
|
249
|
+
/**
|
|
250
|
+
* How the PREVIOUS process died, consumed once. Absent on a clean start.
|
|
251
|
+
* `kind: 'oom'` is inferred from the cgroup counter — see `crash-note.ts`.
|
|
252
|
+
*/
|
|
253
|
+
...(LAST_EXIT ? { lastExit: LAST_EXIT } : {}),
|
|
102
254
|
/** Commands beyond the stage-A/B set. */
|
|
103
|
-
commands: [
|
|
255
|
+
commands: [
|
|
256
|
+
'purge_session',
|
|
257
|
+
'git_log',
|
|
258
|
+
'git_show',
|
|
259
|
+
'git_branches',
|
|
260
|
+
'update_from_base',
|
|
261
|
+
'git_push',
|
|
262
|
+
'git_refs',
|
|
263
|
+
'workspace_state',
|
|
264
|
+
'git_stage',
|
|
265
|
+
'git_unstage',
|
|
266
|
+
'git_discard',
|
|
267
|
+
'git_pull',
|
|
268
|
+
'git_merge_abort',
|
|
269
|
+
'recipe_state',
|
|
270
|
+
'propose_commit_message',
|
|
271
|
+
...(verifyEnabled
|
|
272
|
+
? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
|
|
273
|
+
: []),
|
|
274
|
+
],
|
|
104
275
|
};
|
|
105
276
|
}
|
|
106
277
|
// ─── pair ────────────────────────────────────────────────────────────
|
|
@@ -165,9 +336,80 @@ function bootstrapCodex(config) {
|
|
|
165
336
|
}
|
|
166
337
|
/** Long enough for the `command_result` frame to leave the socket. */
|
|
167
338
|
const RESTART_DELAY_MS = 1_500;
|
|
339
|
+
/**
|
|
340
|
+
* Bring this machine's resource policy up to date, on every daemon start.
|
|
341
|
+
*
|
|
342
|
+
* `self-update` also does this, and that is NOT enough: the update is executed by
|
|
343
|
+
* the process being replaced, so the code that writes the drop-in only exists in
|
|
344
|
+
* the build being INSTALLED. Updating 0.20.1 → 0.21.0 therefore left the bad
|
|
345
|
+
* `MemoryMax=2G` in place and the runner reported `limitsCurrent: false` on its
|
|
346
|
+
* first hello — caught live on vmi3328440, 2026-07-30 03:35 UTC.
|
|
347
|
+
*
|
|
348
|
+
* A migration that only runs during an update can never fix the version that
|
|
349
|
+
* introduces it. Startup is the place that always belongs to the new build.
|
|
350
|
+
*
|
|
351
|
+
* Idempotent by version marker, so a normal restart writes nothing. Never fatal:
|
|
352
|
+
* a runner that refuses to start because it could not improve its own limits is
|
|
353
|
+
* strictly worse than one that starts without the improvement.
|
|
354
|
+
*/
|
|
355
|
+
async function repairResourceLimits() {
|
|
356
|
+
try {
|
|
357
|
+
if (!writeLimitsOverride())
|
|
358
|
+
return;
|
|
359
|
+
log.warn('daemon: resource limits drop-in written — reloading systemd', {
|
|
360
|
+
path: limitsOverridePath(),
|
|
361
|
+
version: LIMITS_VERSION,
|
|
362
|
+
});
|
|
363
|
+
await execFileAsync('systemctl', ['--user', 'daemon-reload'], { timeout: 15_000 });
|
|
364
|
+
// Deliberately no restart: `daemon-reload` alone is enough for these
|
|
365
|
+
// directives (verified live — OOMPolicy went stop→continue and MemoryMax
|
|
366
|
+
// 2G→infinity with the PID unchanged), and restarting here would park every
|
|
367
|
+
// live session to apply a setting that is already in force.
|
|
368
|
+
log.info('daemon: resource limits applied without a restart');
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
log.error('daemon: could not apply the resource limits drop-in', {
|
|
372
|
+
error: String(error instanceof Error ? error.message : error),
|
|
373
|
+
hint: 'run `devbridge-runner doctor --fix`',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Ticket #119. `ClaudeAdapter.stop()` removes each session's MCP config file,
|
|
379
|
+
* but a kill -9, an OOM or a machine reboot leaves it behind — a live project
|
|
380
|
+
* key sitting on disk with nothing left that will ever clean it up. Nothing is
|
|
381
|
+
* running yet at this point in the boot, so every file here is by definition an
|
|
382
|
+
* orphan of a previous process.
|
|
383
|
+
*/
|
|
384
|
+
function sweepOrphanedMcpConfigs() {
|
|
385
|
+
const dir = mcpConfigDir();
|
|
386
|
+
let entries;
|
|
387
|
+
try {
|
|
388
|
+
entries = fs.readdirSync(dir);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return; // never created — the normal case on a fresh install
|
|
392
|
+
}
|
|
393
|
+
let removed = 0;
|
|
394
|
+
for (const name of entries) {
|
|
395
|
+
if (!name.startsWith('devbridge-mcp.') || !name.endsWith('.json'))
|
|
396
|
+
continue;
|
|
397
|
+
try {
|
|
398
|
+
fs.unlinkSync(path.join(dir, name));
|
|
399
|
+
removed += 1;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// Leave it; a warning per file would be noise.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (removed > 0)
|
|
406
|
+
log.warn('daemon: removed orphaned MCP config files', { count: removed });
|
|
407
|
+
}
|
|
168
408
|
async function cmdDaemon() {
|
|
169
409
|
const config = requireConfig();
|
|
170
410
|
log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
|
|
411
|
+
sweepOrphanedMcpConfigs();
|
|
412
|
+
await repairResourceLimits();
|
|
171
413
|
const agents = installedAgents();
|
|
172
414
|
const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
|
|
173
415
|
const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
|
|
@@ -178,6 +420,9 @@ async function cmdDaemon() {
|
|
|
178
420
|
},
|
|
179
421
|
...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
|
|
180
422
|
...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
|
|
423
|
+
// The same veto the capability list honours — announced AND enforced, so a
|
|
424
|
+
// frame from an API that has not noticed still cannot start a run.
|
|
425
|
+
verifyEnabled: config.verify?.enabled !== false,
|
|
181
426
|
apiUrl: config.api.url,
|
|
182
427
|
// Used to fetch the files a user attaches to a message (session 10) — the
|
|
183
428
|
// same token the WS connection authenticates with, never passed onwards.
|
|
@@ -231,6 +476,37 @@ async function cmdDaemon() {
|
|
|
231
476
|
};
|
|
232
477
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
233
478
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
479
|
+
/**
|
|
480
|
+
* A death must leave a note. Until 0.21.0 the only handlers here were the two
|
|
481
|
+
* signals above, so an unhandled error exited the process silently and the
|
|
482
|
+
* only trace anywhere was systemd's `Scheduled restart job` — which is why the
|
|
483
|
+
* 2026-07-30 incident could not be classified as OOM-vs-crash after the fact
|
|
484
|
+
* (QA-112 MAJOR-4).
|
|
485
|
+
*
|
|
486
|
+
* `crashFile` is read by the next process and reported in `hello`, so the
|
|
487
|
+
* cause survives the restart and reaches the dashboard.
|
|
488
|
+
*/
|
|
489
|
+
const noteFatal = (kind, error) => {
|
|
490
|
+
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
491
|
+
log.error(`daemon: ${kind} — exiting for a restart`, { detail: detail.slice(0, 2000) });
|
|
492
|
+
try {
|
|
493
|
+
recordCrash({
|
|
494
|
+
kind,
|
|
495
|
+
detail: detail.slice(0, 2000),
|
|
496
|
+
at: new Date().toISOString(),
|
|
497
|
+
version: RUNNER_VERSION,
|
|
498
|
+
activeSessionIds: supervisor.activeSessionIds,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
/* the log line above is already on disk via journald */
|
|
503
|
+
}
|
|
504
|
+
// Not a graceful supervisor.shutdown(): the process state is by definition
|
|
505
|
+
// untrustworthy here, and systemd `Restart=always` is the recovery path.
|
|
506
|
+
process.exit(1);
|
|
507
|
+
};
|
|
508
|
+
process.on('uncaughtException', (error) => noteFatal('uncaughtException', error));
|
|
509
|
+
process.on('unhandledRejection', (reason) => noteFatal('unhandledRejection', reason));
|
|
234
510
|
ws.start();
|
|
235
511
|
updateStatus();
|
|
236
512
|
// Keep the process alive forever; ws timers drive everything.
|
|
@@ -289,6 +565,11 @@ async function cmdInstallService() {
|
|
|
289
565
|
const exec = unitExecTarget();
|
|
290
566
|
fs.writeFileSync(target, buildUnit(exec.execStart));
|
|
291
567
|
print(`Wrote ${target}`);
|
|
568
|
+
// Resource policy is a versioned drop-in, not part of the unit — see
|
|
569
|
+
// `buildLimitsOverride`. Forced here: a fresh install must have it even if a
|
|
570
|
+
// file from an older runner is already sitting there.
|
|
571
|
+
writeLimitsOverride(true);
|
|
572
|
+
print(`Wrote ${limitsOverridePath()}`);
|
|
292
573
|
if (!exec.viaCommand) {
|
|
293
574
|
// Worth saying out loud: a unit pinned to a file inside the package directory
|
|
294
575
|
// breaks if that directory ever moves (which a package rename does).
|
|
@@ -312,6 +593,120 @@ async function cmdInstallService() {
|
|
|
312
593
|
}
|
|
313
594
|
print('Verify with: devbridge-runner status');
|
|
314
595
|
}
|
|
596
|
+
// ─── doctor ──────────────────────────────────────────────────────────
|
|
597
|
+
/**
|
|
598
|
+
* The one command that answers «is this machine set up to run the sessions it
|
|
599
|
+
* says it can run».
|
|
600
|
+
*
|
|
601
|
+
* It exists because of what the 2026-07-30 incident cost to diagnose: the
|
|
602
|
+
* runner's own systemd limits were the cause, they were invisible from
|
|
603
|
+
* DevBridge, and the machine could not be reached. Everything printed here was
|
|
604
|
+
* needed to reach that answer and none of it was available in one place.
|
|
605
|
+
*/
|
|
606
|
+
async function cmdDoctor(args) {
|
|
607
|
+
const fix = args.includes('--fix');
|
|
608
|
+
const config = loadConfig();
|
|
609
|
+
print(`devbridge-runner ${RUNNER_VERSION}`);
|
|
610
|
+
print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
|
|
611
|
+
const cpuCount = os.cpus().length;
|
|
612
|
+
const quota = cpuQuotaPercent(cpuCount);
|
|
613
|
+
print('');
|
|
614
|
+
print('Machine');
|
|
615
|
+
print(` cpus ${cpuCount}`);
|
|
616
|
+
print(` memory total ${Math.round(os.totalmem() / 1024 / 1024)} MB`);
|
|
617
|
+
print(` memory free ${Math.round(os.freemem() / 1024 / 1024)} MB`);
|
|
618
|
+
// The number that matters: sessions cost ~1.5 GB each once an agent starts a
|
|
619
|
+
// build, so this is the honest ceiling regardless of what the dashboard says.
|
|
620
|
+
const advised = Math.max(1, Math.floor(os.totalmem() / 1024 / 1024 / 1536));
|
|
621
|
+
print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
|
|
622
|
+
print('');
|
|
623
|
+
print('Service limits');
|
|
624
|
+
const outdated = limitsOverrideIsOutdated();
|
|
625
|
+
print(` drop-in ${limitsOverridePath()}`);
|
|
626
|
+
print(` version ${outdated ? `MISSING or OLD (want ${LIMITS_VERSION})` : LIMITS_VERSION}`);
|
|
627
|
+
print(` cpu quota ${quota === null ? 'none (fewer than 4 cpus)' : `${quota}%`}`);
|
|
628
|
+
let effective;
|
|
629
|
+
try {
|
|
630
|
+
const { stdout } = await execFileAsync('systemctl', [
|
|
631
|
+
'--user',
|
|
632
|
+
'show',
|
|
633
|
+
'devbridge-runner',
|
|
634
|
+
'-p',
|
|
635
|
+
'CPUQuotaPerSecUSec',
|
|
636
|
+
'-p',
|
|
637
|
+
'MemoryMax',
|
|
638
|
+
'-p',
|
|
639
|
+
'MemoryHigh',
|
|
640
|
+
'-p',
|
|
641
|
+
'OOMPolicy',
|
|
642
|
+
'-p',
|
|
643
|
+
'NRestarts',
|
|
644
|
+
]);
|
|
645
|
+
effective = stdout.trim().split('\n').filter(Boolean);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
effective = ['(systemctl --user unavailable — is this a source checkout?)'];
|
|
649
|
+
}
|
|
650
|
+
print('');
|
|
651
|
+
print('Effective (systemd)');
|
|
652
|
+
for (const line of effective)
|
|
653
|
+
print(` ${line}`);
|
|
654
|
+
// The two settings that turned one agent's OOM into a dead server.
|
|
655
|
+
const bad = effective.filter((l) => (l.startsWith('OOMPolicy=') && !l.endsWith('=continue')) ||
|
|
656
|
+
(l.startsWith('MemoryMax=') && !l.endsWith('=infinity')) ||
|
|
657
|
+
(l.startsWith('CPUQuotaPerSecUSec=') && l.endsWith('=800ms')));
|
|
658
|
+
if (bad.length > 0) {
|
|
659
|
+
print('');
|
|
660
|
+
print('PROBLEM — these limits kill every session on this machine when one agent runs out of memory:');
|
|
661
|
+
for (const line of bad)
|
|
662
|
+
print(` ${line}`);
|
|
663
|
+
}
|
|
664
|
+
// `systemctl set-property` (the zero-downtime emergency fix) persists into
|
|
665
|
+
// `~/.config/systemd/user.control/`, which OUTRANKS the drop-in we ship. Left
|
|
666
|
+
// in place it silently pins the numbers from whatever the emergency was, on a
|
|
667
|
+
// machine whose core count may since have changed — so say so.
|
|
668
|
+
const controlDir = path.join(os.homedir(), '.config', 'systemd', 'user.control', 'devbridge-runner.service.d');
|
|
669
|
+
try {
|
|
670
|
+
const files = fs.readdirSync(controlDir).filter((f) => f.endsWith('.conf'));
|
|
671
|
+
if (files.length > 0) {
|
|
672
|
+
print('');
|
|
673
|
+
print(`Emergency overrides present (these WIN over the shipped policy): ${controlDir}`);
|
|
674
|
+
for (const file of files)
|
|
675
|
+
print(` ${file}`);
|
|
676
|
+
print(' Remove with: systemctl --user revert devbridge-runner');
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* no emergency overrides — the normal case */
|
|
681
|
+
}
|
|
682
|
+
const oomKills = readOomKills();
|
|
683
|
+
if (oomKills !== null) {
|
|
684
|
+
print('');
|
|
685
|
+
print(`OOM kills in this service's cgroup (cumulative): ${oomKills}`);
|
|
686
|
+
}
|
|
687
|
+
if (LAST_EXIT) {
|
|
688
|
+
print(`Previous process exit: ${LAST_EXIT.kind}${LAST_EXIT.at ? ` at ${LAST_EXIT.at}` : ''}`);
|
|
689
|
+
}
|
|
690
|
+
if (!fix) {
|
|
691
|
+
if (outdated || bad.length > 0) {
|
|
692
|
+
print('');
|
|
693
|
+
print('Run `devbridge-runner doctor --fix` to write the drop-in, then restart the service.');
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
writeLimitsOverride(true);
|
|
699
|
+
print('');
|
|
700
|
+
print(`Wrote ${limitsOverridePath()}`);
|
|
701
|
+
try {
|
|
702
|
+
await execFileAsync('systemctl', ['--user', 'daemon-reload']);
|
|
703
|
+
print('systemctl --user daemon-reload — done.');
|
|
704
|
+
print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
fail(`daemon-reload failed: ${String(error instanceof Error ? error.message : error)}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
315
710
|
// ─── set-token ───────────────────────────────────────────────────────
|
|
316
711
|
function cmdSetToken(args) {
|
|
317
712
|
const token = args[0];
|
|
@@ -330,6 +725,7 @@ Usage:
|
|
|
330
725
|
devbridge-runner install-service install + start systemd user service
|
|
331
726
|
devbridge-runner status connection status (exit 0 = connected)
|
|
332
727
|
devbridge-runner set-token <dbr_token> store a rotated runner token
|
|
728
|
+
devbridge-runner doctor [--fix] report (and repair) resource limits
|
|
333
729
|
`;
|
|
334
730
|
async function main() {
|
|
335
731
|
const [command, ...args] = process.argv.slice(2);
|
|
@@ -344,6 +740,8 @@ async function main() {
|
|
|
344
740
|
return cmdInstallService();
|
|
345
741
|
case 'set-token':
|
|
346
742
|
return cmdSetToken(args);
|
|
743
|
+
case 'doctor':
|
|
744
|
+
return cmdDoctor(args);
|
|
347
745
|
case '--version':
|
|
348
746
|
case 'version':
|
|
349
747
|
print(RUNNER_VERSION);
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
export declare function configDir(): string;
|
|
2
2
|
export declare function stateDir(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Home for systemd USER units. Honours `DEVBRIDGE_RUNNER_HOME` like everything
|
|
5
|
+
* else here: `service-unit.ts` used to reach for `os.homedir()` directly, and the
|
|
6
|
+
* moment it started WRITING (the 0.21.0 limits drop-in) a test run began
|
|
7
|
+
* reconfiguring the developer's own service.
|
|
8
|
+
*/
|
|
9
|
+
export declare function systemdUserHome(): string;
|
|
3
10
|
export declare function configFilePath(): string;
|
|
4
11
|
export declare function statusFilePath(): string;
|
|
5
12
|
export declare function journalDir(): string;
|
|
6
13
|
export declare function worktreesDir(): string;
|
|
14
|
+
/** Session 14: the single preview checkout per repository. */
|
|
15
|
+
export declare function previewsDir(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Per-session MCP config files (ticket #119).
|
|
18
|
+
*
|
|
19
|
+
* The Agent SDK serialises its `mcpServers` option straight into the CLI's
|
|
20
|
+
* argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`), which put the
|
|
21
|
+
* project's `dbk_…` key in `/proc/<pid>/cmdline` — world-readable, and how the
|
|
22
|
+
* key was found in the first place. The CLI also accepts a PATH there
|
|
23
|
+
* (`--mcp-config <configs...>`: "Load MCP servers from JSON files or strings"),
|
|
24
|
+
* so we write the same JSON to a 0600 file and pass the path instead.
|
|
25
|
+
*
|
|
26
|
+
* The basename is deliberately distinctive rather than just `<sessionId>.json`:
|
|
27
|
+
* `policy.ts` matches it by name in BOTH secret lists, so the guard holds
|
|
28
|
+
* wherever the file ends up — including under a `DEVBRIDGE_RUNNER_HOME`
|
|
29
|
+
* override, where the path carries no `devbridge-runner` segment.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mcpConfigDir(): string;
|
|
32
|
+
export declare function mcpConfigPath(sessionId: string): string;
|
|
7
33
|
//# sourceMappingURL=paths.d.ts.map
|
package/dist/paths.js
CHANGED
|
@@ -18,6 +18,15 @@ export function stateDir() {
|
|
|
18
18
|
const xdg = process.env['XDG_STATE_HOME'] ?? path.join(os.homedir(), '.local', 'state');
|
|
19
19
|
return path.join(xdg, 'devbridge-runner');
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Home for systemd USER units. Honours `DEVBRIDGE_RUNNER_HOME` like everything
|
|
23
|
+
* else here: `service-unit.ts` used to reach for `os.homedir()` directly, and the
|
|
24
|
+
* moment it started WRITING (the 0.21.0 limits drop-in) a test run began
|
|
25
|
+
* reconfiguring the developer's own service.
|
|
26
|
+
*/
|
|
27
|
+
export function systemdUserHome() {
|
|
28
|
+
return baseDir() ?? os.homedir();
|
|
29
|
+
}
|
|
21
30
|
export function configFilePath() {
|
|
22
31
|
return path.join(configDir(), 'config.toml');
|
|
23
32
|
}
|
|
@@ -30,4 +39,29 @@ export function journalDir() {
|
|
|
30
39
|
export function worktreesDir() {
|
|
31
40
|
return path.join(stateDir(), 'worktrees');
|
|
32
41
|
}
|
|
42
|
+
/** Session 14: the single preview checkout per repository. */
|
|
43
|
+
export function previewsDir() {
|
|
44
|
+
return path.join(stateDir(), 'previews');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Per-session MCP config files (ticket #119).
|
|
48
|
+
*
|
|
49
|
+
* The Agent SDK serialises its `mcpServers` option straight into the CLI's
|
|
50
|
+
* argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`), which put the
|
|
51
|
+
* project's `dbk_…` key in `/proc/<pid>/cmdline` — world-readable, and how the
|
|
52
|
+
* key was found in the first place. The CLI also accepts a PATH there
|
|
53
|
+
* (`--mcp-config <configs...>`: "Load MCP servers from JSON files or strings"),
|
|
54
|
+
* so we write the same JSON to a 0600 file and pass the path instead.
|
|
55
|
+
*
|
|
56
|
+
* The basename is deliberately distinctive rather than just `<sessionId>.json`:
|
|
57
|
+
* `policy.ts` matches it by name in BOTH secret lists, so the guard holds
|
|
58
|
+
* wherever the file ends up — including under a `DEVBRIDGE_RUNNER_HOME`
|
|
59
|
+
* override, where the path carries no `devbridge-runner` segment.
|
|
60
|
+
*/
|
|
61
|
+
export function mcpConfigDir() {
|
|
62
|
+
return path.join(stateDir(), 'mcp');
|
|
63
|
+
}
|
|
64
|
+
export function mcpConfigPath(sessionId) {
|
|
65
|
+
return path.join(mcpConfigDir(), `devbridge-mcp.${sessionId}.json`);
|
|
66
|
+
}
|
|
33
67
|
//# sourceMappingURL=paths.js.map
|
package/dist/policy.d.ts
CHANGED
|
@@ -3,6 +3,19 @@ export interface PolicyContext {
|
|
|
3
3
|
trustMode: TrustMode;
|
|
4
4
|
/** The session worktree — the only place the agent may write. */
|
|
5
5
|
worktreePath: string;
|
|
6
|
+
/**
|
|
7
|
+
* May the agent commit on its own? (session 15)
|
|
8
|
+
*
|
|
9
|
+
* `false` is the project setting «I review and commit myself». It was a
|
|
10
|
+
* column, an API field and a switch in the workspace form since session 14 —
|
|
11
|
+
* and nothing ever sent it anywhere, so the switch moved a value that no code
|
|
12
|
+
* read. A control that does nothing is worse than a missing one: somebody
|
|
13
|
+
* turns it off and believes the agent stopped committing.
|
|
14
|
+
*
|
|
15
|
+
* `undefined` means the API did not say, which is every runner-API pair older
|
|
16
|
+
* than this release — and there the answer stays what it has always been.
|
|
17
|
+
*/
|
|
18
|
+
agentAutoCommit?: boolean;
|
|
6
19
|
}
|
|
7
20
|
export interface PolicyDecision {
|
|
8
21
|
decision: 'allow' | 'deny' | 'ask';
|
|
@@ -13,5 +26,55 @@ export declare function maskString(value: string): string;
|
|
|
13
26
|
export declare function maskSecrets<T>(value: T): T;
|
|
14
27
|
export declare function isSecretPath(p: string): boolean;
|
|
15
28
|
export declare function isInsideWorktree(p: string, worktreePath: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Anything inside a `.git` directory (session 16, QA-110 B2).
|
|
31
|
+
*
|
|
32
|
+
* This became load-bearing the day DIRECT mode made the repository ROOT the
|
|
33
|
+
* agent's confinement. In a linked worktree the real `.git` lives elsewhere, so
|
|
34
|
+
* «outside the worktree» happened to cover it; in the project folder it sits
|
|
35
|
+
* right there, one level down, and «inside the worktree» happened to allow it.
|
|
36
|
+
*
|
|
37
|
+
* A file in `.git/hooks/` is not data — it is code git runs on the next commit,
|
|
38
|
+
* outside every rule on the Bash denylist, including the `git clean` and
|
|
39
|
+
* `git reset --hard` this same session added to it. `.git/config` is worse: it
|
|
40
|
+
* can set `core.fsmonitor` or a credential helper and run a command on the very
|
|
41
|
+
* next git invocation. Neither is ever ordinary agent work.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isGitInternalPath(p: string): boolean;
|
|
44
|
+
export interface RecipeCommandContext {
|
|
45
|
+
/**
|
|
46
|
+
* The docker compose project this preview owns, from `preview.project` in the
|
|
47
|
+
* recipe. Present only for the preview `stop` command.
|
|
48
|
+
*/
|
|
49
|
+
dockerProject?: string;
|
|
50
|
+
/** True only for the recipe's own `preview.stop` command. */
|
|
51
|
+
isPreviewStop?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface RecipeCommandDecision {
|
|
54
|
+
allowed: boolean;
|
|
55
|
+
reason?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Layer 1 for a command that came out of an APPROVED project recipe.
|
|
59
|
+
*
|
|
60
|
+
* The semantics, fixed here rather than left to be discovered: **an approved
|
|
61
|
+
* recipe does not ask, but it cannot get past the denylist.** Both halves
|
|
62
|
+
* matter and neither is what `evaluateToolUse` would do on its own.
|
|
63
|
+
*
|
|
64
|
+
* - It does not ask, because there is nobody to ask: a build runs for minutes
|
|
65
|
+
* with no agent turn around it, and the human already read every command on
|
|
66
|
+
* the approval screen. Running it through the strict-mode branch would turn
|
|
67
|
+
* `docker compose up --build` — which is not on any safe list — into a
|
|
68
|
+
* permission card nobody can answer.
|
|
69
|
+
* - It cannot get past the denylist, because approval is a statement about a
|
|
70
|
+
* build, not a grant of `sudo`, of the firewall, or of the machine's power
|
|
71
|
+
* switch.
|
|
72
|
+
*
|
|
73
|
+
* One denial is liftable, and only one: `docker … down`. A preview that cannot
|
|
74
|
+
* tear its own stack down leaks containers forever, so a recipe that NAMES its
|
|
75
|
+
* compose project may run `down` on that project in its `stop` command — and
|
|
76
|
+
* nowhere else. Without the name, the refusal stands and says why.
|
|
77
|
+
*/
|
|
78
|
+
export declare function evaluateRecipeCommand(command: string, ctx?: RecipeCommandContext): RecipeCommandDecision;
|
|
16
79
|
export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
|
|
17
80
|
//# sourceMappingURL=policy.d.ts.map
|