@bridge4dev/runner 0.11.0 → 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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +435 -32
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +171 -23
  35. package/dist/service-unit.d.ts +79 -0
  36. package/dist/service-unit.js +211 -0
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { systemdUserHome } from './paths.js';
5
+ /**
6
+ * The systemd user unit, and the one decision inside it that matters: what to
7
+ * exec.
8
+ *
9
+ * The first version baked `realpathSync(process.argv[1])` — the resolved file
10
+ * inside the installed package directory. That pins the service to a directory
11
+ * that a package RENAME deletes: `@devbridge/runner` → `@bridge4dev/runner` moved
12
+ * the file, the unit kept pointing at the old path, and `systemctl restart`
13
+ * failed with ENOENT. The runner simply never came back and the server went
14
+ * offline until someone logged in — the one outcome an update must never produce.
15
+ *
16
+ * So the unit execs the COMMAND (`<prefix>/bin/devbridge-runner`), which npm
17
+ * re-creates on every install whatever the package is called. A source checkout
18
+ * has no such symlink, and there the resolved script is the honest answer.
19
+ */
20
+ export const SERVICE_NAME = 'devbridge-runner';
21
+ const COMMAND_NAME = 'devbridge-runner';
22
+ /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
23
+ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
24
+ // Invoked through the command itself: that is already the stable path.
25
+ try {
26
+ if (fs.lstatSync(argv1).isSymbolicLink()) {
27
+ return { execStart: path.resolve(argv1), viaCommand: true };
28
+ }
29
+ }
30
+ catch {
31
+ /* fall through to the resolved script */
32
+ }
33
+ let script;
34
+ try {
35
+ script = fs.realpathSync(argv1);
36
+ }
37
+ catch {
38
+ return { execStart: argv1, viaCommand: false };
39
+ }
40
+ // Invoked as the script inside a global install: find the prefix that owns the
41
+ // command. `<prefix>/lib/node_modules/@scope/runner/dist/index.js` → `<prefix>`.
42
+ if (script.split(path.sep).includes('node_modules')) {
43
+ let dir = path.dirname(script);
44
+ for (let i = 0; i < 6; i++) {
45
+ const candidate = path.join(dir, 'bin', COMMAND_NAME);
46
+ if (fs.existsSync(candidate))
47
+ return { execStart: candidate, viaCommand: true };
48
+ const parent = path.dirname(dir);
49
+ if (parent === dir)
50
+ break;
51
+ dir = parent;
52
+ }
53
+ }
54
+ return { execStart: script, viaCommand: false };
55
+ }
56
+ export function unitPath(home = systemdUserHome()) {
57
+ return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
58
+ }
59
+ export function buildUnit(execStart) {
60
+ const target = execStart ?? unitExecTarget().execStart;
61
+ // The command carries a `#!/usr/bin/env node` shebang, so it is exec'd directly;
62
+ // a bare script path needs the interpreter spelled out.
63
+ const command = target.endsWith('.js')
64
+ ? `${process.execPath} ${target} daemon`
65
+ : `${target} daemon`;
66
+ return ([
67
+ '[Unit]',
68
+ 'Description=DevBridge Dev Runner',
69
+ 'After=network-online.target',
70
+ '',
71
+ '[Service]',
72
+ `ExecStart=${command}`,
73
+ 'Restart=always',
74
+ 'RestartSec=5',
75
+ '',
76
+ '[Install]',
77
+ 'WantedBy=default.target',
78
+ ].join('\n') + '\n');
79
+ }
80
+ /**
81
+ * Resource policy for the service, and why it does not live in the unit above.
82
+ *
83
+ * The unit is written ONCE, at install time. Until 0.21.0 it carried
84
+ * `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
85
+ * server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
86
+ *
87
+ * - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
88
+ * agent runs, is a CHILD of this service and therefore inside this cgroup.
89
+ * Three measured `claude` processes are 512/577/646 MB and one workspace
90
+ * `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
91
+ * - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
92
+ * tore down the whole service — and with it every other session on the
93
+ * machine. `Restart=always` then brought the daemon back with an empty
94
+ * session map, which is what the user sees as
95
+ * «Runner reconnected. The session was resumed»;
96
+ * - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
97
+ * what starves the event loop until the gateway's heartbeat gives up on it.
98
+ *
99
+ * So the policy is versioned and shipped as a drop-in instead. A drop-in can
100
+ * RESET a directive the main unit set (`MemoryMax=` with no value clears it),
101
+ * which is the only way to fix the servers that already have the bad numbers
102
+ * baked in — and it never overwrites a unit the operator edited by hand.
103
+ */
104
+ export const LIMITS_VERSION = 2;
105
+ const LIMITS_MARKER = '# devbridge-limits-version:';
106
+ /** `zz-` so it sorts last: an operator's own drop-in should still win. */
107
+ const LIMITS_FILE = 'zz-devbridge-limits.conf';
108
+ export function limitsOverridePath(home = systemdUserHome()) {
109
+ return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service.d`, LIMITS_FILE);
110
+ }
111
+ /**
112
+ * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
113
+ * box unreachable, but never so little that ordinary work is throttled.
114
+ *
115
+ * One core is reserved for the system (sshd, journald, the runner's own event
116
+ * loop). Below four cores there is nothing to reserve without crippling the
117
+ * agents, so no quota is set at all — an unusable dev server is a worse failure
118
+ * than a busy one.
119
+ */
120
+ export function cpuQuotaPercent(cpuCount = os.cpus().length) {
121
+ return cpuCount >= 4 ? (cpuCount - 1) * 100 : null;
122
+ }
123
+ export function buildLimitsOverride(cpuCount = os.cpus().length) {
124
+ const quota = cpuQuotaPercent(cpuCount);
125
+ return ([
126
+ `${LIMITS_MARKER} ${LIMITS_VERSION}`,
127
+ '# Managed by devbridge-runner. Put your own overrides in a file that sorts',
128
+ '# after this one, or edit the unit itself — neither is touched by updates.',
129
+ '',
130
+ '[Unit]',
131
+ // Five fast failures used to leave the service in `failed` and the server
132
+ // offline until someone logged in. A dev runner must always come back;
133
+ // crash loops are surfaced through `lastExit` in hello, not by giving up.
134
+ 'StartLimitIntervalSec=0',
135
+ 'StartLimitBurst=0',
136
+ '',
137
+ '[Service]',
138
+ // The whole point of the change: one child's OOM must not take the fleet.
139
+ 'OOMPolicy=continue',
140
+ // Clears `MemoryMax=2G` from units written before 0.21.0.
141
+ 'MemoryMax=',
142
+ // Soft pressure instead of a hard ceiling: the kernel reclaims and
143
+ // throttles rather than killing, and the machine keeps a fifth of its
144
+ // memory for everything that is not this service.
145
+ 'MemoryHigh=80%',
146
+ // Either a computed quota, or an explicit reset — both of which clear the
147
+ // `CPUQuota=80%` baked into units written before 0.21.0.
148
+ quota === null ? 'CPUQuota=' : `CPUQuota=${quota}%`,
149
+ // Needed for `doctor` and for the memory/CPU figures the runner reports.
150
+ 'MemoryAccounting=yes',
151
+ 'CPUAccounting=yes',
152
+ // An agent running a monorepo build forks a lot; the default is per-user.
153
+ 'TasksMax=8192',
154
+ ].join('\n') + '\n');
155
+ }
156
+ /**
157
+ * Is the shipped resource policy missing or from an older runner?
158
+ *
159
+ * Deliberately version-based rather than content-based: an operator may add
160
+ * their own directives to our file, and re-writing on every start would fight
161
+ * them. Only the version number decides.
162
+ */
163
+ export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome()) {
164
+ let contents;
165
+ try {
166
+ contents = readFile(limitsOverridePath(home));
167
+ }
168
+ catch {
169
+ return true; // never written — every server that predates 0.21.0
170
+ }
171
+ const line = contents.split('\n').find((l) => l.startsWith(LIMITS_MARKER));
172
+ if (!line)
173
+ return true;
174
+ const version = Number.parseInt(line.slice(LIMITS_MARKER.length).trim(), 10);
175
+ return !Number.isFinite(version) || version < LIMITS_VERSION;
176
+ }
177
+ /** Write the drop-in. Returns false when nothing needed doing. */
178
+ export function writeLimitsOverride(force = false, home = systemdUserHome()) {
179
+ if (!force && !limitsOverrideIsOutdated(undefined, home))
180
+ return false;
181
+ const target = limitsOverridePath(home);
182
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
183
+ fs.writeFileSync(target, buildLimitsOverride(), { mode: 0o644 });
184
+ return true;
185
+ }
186
+ /**
187
+ * Does the installed unit point at something that no longer exists?
188
+ *
189
+ * Used after an update: if the package moved (a rename), a unit pinned to the old
190
+ * directory would kill the runner on the very restart the update asks for. Only a
191
+ * missing target counts — a unit the user edited on purpose is left alone.
192
+ */
193
+ export function unitIsBroken(readFile = (p) => fs.readFileSync(p, 'utf8')) {
194
+ let contents;
195
+ try {
196
+ contents = readFile(unitPath());
197
+ }
198
+ catch {
199
+ return false; // no unit of ours — nothing to repair
200
+ }
201
+ const line = contents.split('\n').find((l) => l.startsWith('ExecStart='));
202
+ if (!line)
203
+ return false;
204
+ const parts = line.slice('ExecStart='.length).trim().split(/\s+/);
205
+ // `ExecStart=/usr/bin/node /path/index.js daemon` or `ExecStart=/path/cmd daemon`
206
+ const target = parts[0]?.endsWith('node') ? parts[1] : parts[0];
207
+ if (!target)
208
+ return false;
209
+ return !fs.existsSync(target);
210
+ }
211
+ //# sourceMappingURL=service-unit.js.map
@@ -1,4 +1,5 @@
1
1
  import { JournalStore } from './journal.js';
2
+ import { proposeCommitMessage } from './commit-message.js';
2
3
  import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
3
4
  import type { RunnerWsClient } from './ws-client.js';
4
5
  import type { SessionDescriptor } from './protocol.js';
@@ -30,6 +31,14 @@ export interface SupervisorOptions {
30
31
  * machine's owner decides how much of their machine an agent fleet may take.
31
32
  */
32
33
  maxSessionsLimit?: number;
34
+ /**
35
+ * `[verify] enabled` from the runner's own config (session 14). The machine's
36
+ * owner has the last word on whether project recipes run here at all;
37
+ * `false` means the capability is not announced and no run can be started.
38
+ */
39
+ verifyEnabled?: boolean;
40
+ /** Test seam for the one-shot commit-message run. */
41
+ proposeCommitMessage?: typeof proposeCommitMessage;
33
42
  }
34
43
  export declare class Supervisor {
35
44
  private readonly ws;
@@ -54,10 +63,32 @@ export declare class Supervisor {
54
63
  private readonly repoLocks;
55
64
  /** An update is installing right now — a second one would fight it. */
56
65
  private selfUpdateInFlight;
66
+ /** Session 14: one project-recipe run per machine, and its verdict queue. */
67
+ private readonly verify;
68
+ private readonly verifyReports;
57
69
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
70
+ /**
71
+ * Push every unacked verdict at the API.
72
+ *
73
+ * Called on each reconnect and whenever a run finishes. Sending a report the
74
+ * API already has is harmless — it is keyed by `runId` and stored idempotently
75
+ * — while not sending one is a verdict that never existed.
76
+ */
77
+ private flushVerifyReports;
58
78
  get activeSessionIds(): string[];
59
79
  private onFrame;
60
80
  private startSession;
81
+ /**
82
+ * Where this session is going to work — a worktree of its own, or the project
83
+ * folder itself (session 16).
84
+ *
85
+ * The BRANCH path takes the repo lock because `worktree add` writes into the
86
+ * shared `.git` (registration + prune), exactly like commit/apply/revert. The
87
+ * DIRECT path takes none: it only READS which branch a folder is on, and
88
+ * queueing every session start behind whatever merge happens to be running
89
+ * would be a lock bought for nothing.
90
+ */
91
+ private prepareWorkspace;
61
92
  /**
62
93
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
63
94
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -100,6 +131,23 @@ export declare class Supervisor {
100
131
  */
101
132
  private pauseForBudget;
102
133
  private pumpEvents;
134
+ /**
135
+ * "…and N of them are waiting for an answer from you."
136
+ *
137
+ * An open question pins its slot deliberately (`isParkable`), so a message
138
+ * that blames a running turn sends the user to wait for something that will
139
+ * never happen. Empty when nothing is waiting.
140
+ */
141
+ private waitingForAnswerSuffix;
142
+ /**
143
+ * Close out every ask this session still has open, with a stated cause.
144
+ *
145
+ * Idempotent: the adapter reports its own `question_resolved` when it can, and
146
+ * `forwardEvent` clears the id — so by the time this runs the set is usually
147
+ * already empty. What it catches is the path where the adapter never got the
148
+ * chance, and the alternative there is a card that stays clickable forever.
149
+ */
150
+ private withdrawOpenQuestions;
103
151
  /**
104
152
  * Deliver messages that were held because every slot was taken.
105
153
  *
@@ -140,10 +188,27 @@ export declare class Supervisor {
140
188
  * returns false and the caller tells the user rather than thrashing.
141
189
  */
142
190
  private ensureCapacity;
143
- /** Idle after a finished turn — safe to kill the process and resume later. */
191
+ /**
192
+ * Idle after a finished turn — safe to kill the process and resume later.
193
+ *
194
+ * An open question is the exception (session 12): WAITING_INPUT there does
195
+ * NOT mean "the turn is over", it means the agent's tool call is parked on a
196
+ * human. Parking such a session killed a live turn — and, worse, killed the
197
+ * card the user was about to answer — the moment another session wanted a
198
+ * slot.
199
+ */
144
200
  private isParkable;
145
201
  private park;
146
202
  private forwardEvent;
203
+ /**
204
+ * The dashboard's answer to a parked question (session 12).
205
+ *
206
+ * A miss is reported in the feed rather than swallowed: the three cases that
207
+ * get here — a card from a previous life of the session, a second click, an
208
+ * ask the runner already withdrew — all look identical to the user unless
209
+ * somebody says so.
210
+ */
211
+ private onQuestionAnswer;
147
212
  private onUserMessage;
148
213
  /**
149
214
  * Run delivery work for one session, strictly after whatever is already
@@ -212,5 +277,47 @@ export declare class Supervisor {
212
277
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
213
278
  shutdown(): void;
214
279
  }
280
+ /**
281
+ * The first message the agent gets.
282
+ *
283
+ * Two shapes, and the difference is whether tickets were handed over
284
+ * (session 16, owner's decision):
285
+ *
286
+ * - **No tickets** — exactly what the human typed, and an empty prompt stays
287
+ * empty. That is how you get a session that boots and waits for you to talk
288
+ * first; it is a feature, not an oversight.
289
+ * - **Tickets handed to a TICKET session** — the assignment is always sent and
290
+ * cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
291
+ * instruction, so making the human retype it was ceremony. Anything they
292
+ * typed follows underneath.
293
+ *
294
+ * What is NOT here any more: eight lines about which MCP tools to call and
295
+ * which statuses to move through. That is a standing convention of the project,
296
+ * true on the twentieth turn as much as the first, so it moved to the system
297
+ * prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
298
+ */
215
299
  export declare function composeInitialPrompt(descriptor: SessionDescriptor): string;
300
+ /**
301
+ * Extra system-prompt material: where this session is in git, and how tickets
302
+ * are meant to move (session 13).
303
+ *
304
+ * Facts about THIS SESSION, and nothing else. They exist in no file on disk —
305
+ * which branch the agent is on, that it must not push, where a plan belongs,
306
+ * what to do with the tickets it was given — so somebody has to say them, and
307
+ * that somebody is us.
308
+ *
309
+ * The project's own documentation is deliberately NOT here. Both agents read
310
+ * their own file natively, verified live: Claude picks up `CLAUDE.md` through
311
+ * its memory mechanism (since `settingSources` includes `'project'`), and Codex
312
+ * picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
313
+ * copy on top of that was work we were doing for no one — and when it was
314
+ * switched off for Claude alone it briefly left repositories that carry only
315
+ * `AGENTS.md` with nothing at all, which is precisely the kind of hole a
316
+ * half-measure digs.
317
+ *
318
+ * A repository that wants both agents equipped ships both files, or symlinks
319
+ * one to the other. That is a repository convention and not something a runner
320
+ * should paper over.
321
+ */
322
+ export declare function composeWorkspaceContext(descriptor: SessionDescriptor): string;
216
323
  //# sourceMappingURL=supervisor.d.ts.map