@bridge4dev/runner 0.45.1 → 0.47.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.
@@ -45,6 +45,12 @@ export interface SelfUpdateOptions {
45
45
  packageDir?: string | null;
46
46
  /** Test seam: is this process supervised (defaults to autodetect). */
47
47
  supervised?: boolean;
48
+ /**
49
+ * Test seam for C4: whether this build runs sessions on the Claude binary
50
+ * bundled with it. Both branches must be provable — the `false` one is what
51
+ * stops every future runner update from rolling itself back.
52
+ */
53
+ bundledClaude?: boolean;
48
54
  /**
49
55
  * Test seam for the resource-limits drop-in. Injected rather than called
50
56
  * directly because the real one writes into `$HOME` — a test that exercised
@@ -134,4 +140,12 @@ export declare function manualUpdateCommand(tarballUrl: string, options?: {
134
140
  packageDir?: string | null;
135
141
  }): string;
136
142
  export declare function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome>;
143
+ /**
144
+ * The same reading of a failed child process, for `agent-install.ts`.
145
+ *
146
+ * Exported rather than copied: installing an agent CLI shells out to the very
147
+ * same npm, and a second implementation would drift into reporting the ERESOLVE
148
+ * wall this one exists to suppress.
149
+ */
150
+ export declare function describeCommandFailure(error: unknown): string;
137
151
  //# sourceMappingURL=self-update.d.ts.map
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { findClaudeCli } from './agent-binary.js';
6
+ import { findClaudeCli, USE_BUNDLED_CLAUDE } from './agent-binary.js';
7
7
  import { log } from './log.js';
8
8
  import { stateDir } from './paths.js';
9
9
  import { RUNNER_VERSION } from './version.js';
@@ -237,18 +237,32 @@ function installArgs(source, prefix) {
237
237
  // npm cheerfully installs a SECOND copy somewhere the service does not exec,
238
238
  // reports success, and the runner restarts on the old version forever.
239
239
  //
240
- // `--include=optional` is npm's default and is stated anyway (ticket #225):
241
- // the Claude CLI ships as an OPTIONAL platform package, and a single
242
- // `omit=optional` inherited from an `.npmrc`, an environment variable or a CI
243
- // habit turns an update into a runner that cannot start a single Claude
244
- // session — silently, because for npm a failed optional dependency is not a
245
- // failure at all. The flag makes this deployment's intent explicit rather
246
- // than dependent on whatever configuration the machine happens to carry.
240
+ // `--omit=optional` since 0.47.0, and it used to be its exact opposite.
241
+ //
242
+ // Until C3 the Claude CLI a session ran on WAS an optional platform package of
243
+ // the Agent SDK inside this package, so `--include=optional` was stated
244
+ // explicitly (ticket #225): a single `omit=optional` inherited from an
245
+ // `.npmrc` produced a runner that could not start one Claude session, and npm
246
+ // called that a success. Sessions now run on the system `claude` — installed,
247
+ // measured and updated like any other agent — so the 215 MB binary in here is
248
+ // no longer used by anything, and downloading it on every update would be a
249
+ // tax on every dev server for a file nothing opens.
250
+ //
251
+ // Read this beside `npmInstallArgs` in `agent-install.ts`, which says
252
+ // `--include=optional` and is not a contradiction: DIFFERENT packages. The
253
+ // runner no longer wants Claude's platform binary; `@openai/codex` keeps its
254
+ // own 320 MB executable in exactly such an optional package, and installing
255
+ // Codex without it yields a shim that cannot run — with npm exiting 0 either
256
+ // way (гоча #297, and #415 for the Codex half).
257
+ //
258
+ // C4 is what makes this safe: the bundled-binary check below is conditional on
259
+ // `USE_BUNDLED_CLAUDE`, so an update that no longer ships the binary is not
260
+ // read as a broken install and rolled back on every single machine, forever.
247
261
  return [
248
262
  'install',
249
263
  '-g',
250
264
  '--ignore-scripts',
251
- '--include=optional',
265
+ '--omit=optional',
252
266
  '--loglevel=error',
253
267
  ...(prefix ? ['--prefix', prefix] : []),
254
268
  source,
@@ -269,7 +283,7 @@ export function manualUpdateCommand(tarballUrl, options = {}) {
269
283
  const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
270
284
  const user = options.user ?? os.userInfo().username;
271
285
  const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
272
- const install = ['npm install -g --ignore-scripts --include=optional --loglevel=error']
286
+ const install = ['npm install -g --ignore-scripts --omit=optional --loglevel=error']
273
287
  .concat(prefix ? [`--prefix ${prefix}`] : [])
274
288
  .concat([tarballUrl])
275
289
  .join(' ');
@@ -367,6 +381,7 @@ export async function selfUpdate(options) {
367
381
  }
368
382
  // Derived once and threaded through every npm call below.
369
383
  const prefix = installPrefixFor(packageDir);
384
+ const usesBundledClaude = options.bundledClaude ?? USE_BUNDLED_CLAUDE;
370
385
  // Pack the current version FIRST: without a rollback artefact there is no
371
386
  // honest way back if the new build turns out to be broken.
372
387
  const rollbackDir = path.join(stateDir(), 'rollback');
@@ -442,7 +457,14 @@ export async function selfUpdate(options) {
442
457
  // One repair attempt first, because that is what the failure usually deserves:
443
458
  // a 300 MB optional package that did not download is a network hiccup, not a
444
459
  // broken release, and reinstalling it is cheaper for the user than a rollback.
445
- if (!findClaudeCli(newPackageDir)) {
460
+ //
461
+ // Conditional since C4, and it has to be: after `USE_BUNDLED_CLAUDE` flips,
462
+ // the runner is installed WITHOUT that optional package on purpose (`--omit=
463
+ // optional`, −215 MB), so an unconditional check would find it missing and
464
+ // roll back every single runner update, for ever. The check is not deleted —
465
+ // while the bundled binary is what sessions run on, its absence is still the
466
+ // 2026-08-11 outage waiting to happen.
467
+ if (usesBundledClaude && !findClaudeCli(newPackageDir)) {
446
468
  log.warn('self-update: the Claude CLI is missing from the new build — repairing', {
447
469
  packageDir: newPackageDir,
448
470
  });
@@ -453,7 +475,7 @@ export async function selfUpdate(options) {
453
475
  log.warn('self-update: the repair install failed', { error: describe(error) });
454
476
  }
455
477
  }
456
- if (!findClaudeCli(newPackageDir)) {
478
+ if (usesBundledClaude && !findClaudeCli(newPackageDir)) {
457
479
  log.error('self-update: still no Claude CLI after the repair — rolling back', {
458
480
  packageDir: newPackageDir,
459
481
  });
@@ -465,7 +487,7 @@ export async function selfUpdate(options) {
465
487
  }
466
488
  catch (rollbackError) {
467
489
  return fail(`The new version is broken (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
468
- `Restore it on the server with: npm install -g --include=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
490
+ `Restore it on the server with: npm install -g --omit=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
469
491
  }
470
492
  }
471
493
  // The service unit may be pinned to a file inside the directory this update
@@ -553,4 +575,14 @@ function describe(error) {
553
575
  const meaningful = npmErrors || raw;
554
576
  return meaningful.length > 400 ? `…${meaningful.slice(-400)}` : meaningful;
555
577
  }
578
+ /**
579
+ * The same reading of a failed child process, for `agent-install.ts`.
580
+ *
581
+ * Exported rather than copied: installing an agent CLI shells out to the very
582
+ * same npm, and a second implementation would drift into reporting the ERESOLVE
583
+ * wall this one exists to suppress.
584
+ */
585
+ export function describeCommandFailure(error) {
586
+ return describe(error);
587
+ }
556
588
  //# sourceMappingURL=self-update.js.map
@@ -1,6 +1,9 @@
1
1
  import { JournalStore } from './journal.js';
2
2
  import { proposeCommitMessage } from './commit-message.js';
3
3
  import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
4
+ import { installAgent } from './agent-install.js';
5
+ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
+ import { type AgentVersionsMeasurement } from './agent-versions.js';
4
7
  import type { RunnerWsClient } from './ws-client.js';
5
8
  import type { SessionDescriptor } from './protocol.js';
6
9
  import type { AgentAdapter } from './adapters/types.js';
@@ -53,6 +56,50 @@ export interface SupervisorOptions {
53
56
  * announced and no rewind can be started.
54
57
  */
55
58
  checkpointsEnabled?: boolean;
59
+ /**
60
+ * Test seam for the agent version measurement.
61
+ *
62
+ * A seam and not a detail: the real one spawns `claude`, `codex` and their
63
+ * doctors, and this class is constructed in every supervisor test — an
64
+ * unstubbed probe would run four processes per test file, on machines that
65
+ * may have neither CLI.
66
+ */
67
+ measureAgentVersions?: (options?: {
68
+ force?: boolean;
69
+ }) => Promise<AgentVersionsMeasurement>;
70
+ /**
71
+ * `[agents] install_enabled` from the runner's own config (#371), by the same
72
+ * rule as `[verify]` and `[checkpoints]`. Announced AND enforced: an API that
73
+ * has not noticed the veto still cannot install anything here.
74
+ */
75
+ agentInstallEnabled?: boolean;
76
+ /**
77
+ * Test seam for installing an agent CLI. The real one downloads 200–330 MB
78
+ * and writes into the npm prefix — not something a unit test may do.
79
+ */
80
+ installAgent?: typeof installAgent;
81
+ /**
82
+ * Test seam for the Р12 sweep of old Claude version files.
83
+ *
84
+ * A seam because the real one deletes 300 MB executables out of `$HOME`, and
85
+ * the thing worth testing HERE is not the deletion — that is
86
+ * `agent-cleanup.test.ts` — but the gate in front of it: no sessions, no
87
+ * install running. A test that had to move `$HOME` to prove a conditional
88
+ * would be proving the wrong thing in the wrong place.
89
+ */
90
+ pruneAgentVersions?: typeof pruneNativeClaudeVersions;
91
+ /**
92
+ * Test seam: how often the Р12 sweep runs, in ms — the first pass and every
93
+ * one after it.
94
+ *
95
+ * The same reason `emptyTurnSettleMs` exists. The real numbers are an hour
96
+ * and a day; no suite can wait either out, and fake timers are not an option
97
+ * here — they fire the WS client's liveness watchdog, which terminates the
98
+ * socket as «half-dead» and leaves the supervisor receiving nothing at all.
99
+ * Without this seam the gate in front of the sweep would never be executed by
100
+ * a test. Never set in production.
101
+ */
102
+ agentCleanupMs?: number;
56
103
  /**
57
104
  * How long a turn that produced nothing is held before it counts (#300).
58
105
  *
@@ -117,12 +164,121 @@ export declare class Supervisor {
117
164
  * than having no restore point for that one message.
118
165
  */
119
166
  private readonly repoLockDepth;
120
- /** An update is installing right now — a second one would fight it. */
121
- private selfUpdateInFlight;
167
+ /**
168
+ * One install at a time on this machine, whoever asked for it.
169
+ *
170
+ * `self_update` and `agent_install` both end in `npm install -g` into the
171
+ * SAME npm prefix under the dedicated-user layout, so two of them at once is
172
+ * a real conflict rather than a theoretical one. The API takes a Redis lock
173
+ * before pressing either button, but that lock cannot cover an install the
174
+ * runner starts by itself (stage D) — and the runner has no Redis. This is
175
+ * the only lock that sees both.
176
+ *
177
+ * Holds the holder's name rather than a boolean, so the refusal can say
178
+ * which of the two is running.
179
+ */
180
+ private installInFlight;
122
181
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
123
182
  private readonly verify;
124
183
  private readonly verifyReports;
125
184
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
185
+ /** How often the agent versions are re-derived. See the constructor. */
186
+ private static readonly AGENT_VERSIONS_INTERVAL_MS;
187
+ private readonly agentVersionsTimer;
188
+ /**
189
+ * Tell the API which agents this machine has and at which versions.
190
+ *
191
+ * Never throws and never blocks its caller: a machine where a probe hangs
192
+ * must still start sessions. A frame that could not be written is simply not
193
+ * sent — the next `hello_ack` or the hourly tick carries the same fact, and
194
+ * the measurement behind it is cached, so retrying is nearly free.
195
+ */
196
+ /**
197
+ * Why an install cannot start right now, in words for the person who pressed.
198
+ *
199
+ * `null` means the way is clear.
200
+ */
201
+ private installBusyReason;
202
+ /**
203
+ * A version change that has happened but has not reached the API yet.
204
+ *
205
+ * `ws.send` returns false when the socket is down, and an install finishing
206
+ * during a reconnect is not a rare case — it is a 300 MB download that takes
207
+ * minutes. The VERSION recovers on its own (the hourly tick and every
208
+ * `hello_ack` re-send the measurement), but `reason` and `changed` are the
209
+ * audit line itself: dropped, «why is my Codex suddenly different» has no
210
+ * answer anywhere, which is precisely what Р14 exists to prevent.
211
+ *
212
+ * Kept in memory rather than on disk on purpose. A daemon that died mid-install
213
+ * has a bigger hole than one lost line, and persisting it would risk filing an
214
+ * install that a rollback then undid.
215
+ */
216
+ private readonly pendingVersionChanges;
217
+ /**
218
+ * Two installs cannot overlap — the local lock sees to that — but two can
219
+ * both FINISH inside one outage, and each is a line the log owes. A queue
220
+ * rather than a slot, because the second would otherwise overwrite the first
221
+ * and the older install would be the one that vanished.
222
+ *
223
+ * Bounded: an outage long enough to strand nine installs is one where the
224
+ * missing audit lines are not the problem worth solving.
225
+ */
226
+ private static readonly MAX_PENDING_VERSION_CHANGES;
227
+ publishAgentVersions(reason: 'measured' | 'manual' | 'auto', changed?: {
228
+ agent: string;
229
+ from: string | null;
230
+ to: string;
231
+ }): Promise<void>;
232
+ private queueVersionChange;
233
+ /**
234
+ * Р13: the agent of a starting session, moved forward in the background.
235
+ *
236
+ * The whole mechanism hangs off ONE fact — whether the API put a version in
237
+ * the descriptor. It does that only for machines whose «Auto-update agents»
238
+ * switch is on, so there is no second copy of the setting here to disagree
239
+ * with it, and a runner that is told nothing does nothing.
240
+ *
241
+ * Never throws and never reports anything into the session feed. A person who
242
+ * pressed «start» asked for a session; a failed background download is not
243
+ * their business, and a scary red line about one would be worse than the
244
+ * silence. Where it IS visible is the server card: the `agent_versions` frame
245
+ * at the end goes out after every outcome, so a machine that ended up without
246
+ * a working agent says so within seconds rather than at the next hourly tick.
247
+ */
248
+ private maybeAutoUpdateAgent;
249
+ /** Once a day, per Р12 — the vendor adds at most one file a day. */
250
+ private static readonly AGENT_CLEANUP_INTERVAL_MS;
251
+ /** Long enough for `hello_ack` to have told us which sessions are live. */
252
+ private static readonly AGENT_CLEANUP_FIRST_DELAY_MS;
253
+ private readonly agentCleanupTimer;
254
+ private readonly agentCleanupFirstTimer;
255
+ /**
256
+ * Throw away the Claude version files nobody will run again (Р12, §4.7).
257
+ *
258
+ * Gated on NO LIVE AGENT PROCESS — `liveSessionCount`, not `sessions.size`.
259
+ *
260
+ * The stricter reading («nothing tracked at all») was the first version of
261
+ * this gate and it made the feature dead: parked sessions sit in that map
262
+ * until somebody closes them, so on any machine actually in use the sweep
263
+ * would never once have run, and Р12 would have shipped as a no-op nobody
264
+ * noticed. A parked session is not a risk to a version file either — it has
265
+ * no process, and when it wakes it relaunches through the launcher, which is
266
+ * the one file this sweep never touches.
267
+ *
268
+ * What guards a version somebody IS running is the per-file check in
269
+ * `agent-cleanup.ts` (`/proc/<pid>/exe`), which is the plan's own mechanism —
270
+ * «проверка по списку процессов». This gate is the cheap belt beside it: it
271
+ * keeps the sweep away from the minutes when Claude is most likely to be
272
+ * mid-spawn, where the process check has a window it cannot see.
273
+ *
274
+ * Also skipped while an install holds the lock: `claude install <version>` is
275
+ * rewriting the launcher right then, and «which file is current» has no stable
276
+ * answer until it finishes.
277
+ */
278
+ private sweepOldAgentVersions;
279
+ /** How often the seat report re-derives the truth. See the constructor. */
280
+ private static readonly SLOTS_REPORT_INTERVAL_MS;
281
+ private readonly slotsTimer;
126
282
  /**
127
283
  * Push every unacked verdict at the API.
128
284
  *
@@ -132,6 +288,38 @@ export declare class Supervisor {
132
288
  */
133
289
  private flushVerifyReports;
134
290
  get activeSessionIds(): string[];
291
+ /** The last set of seat-holders we told the API about, to send only changes. */
292
+ private lastPublishedSlots;
293
+ /**
294
+ * Tell the API which sessions are actually holding a seat here (0.46.0).
295
+ *
296
+ * The seat count is a property of THIS process, and until now the API could
297
+ * only guess it from its own rows. The guess was wrong whenever a session
298
+ * ended on paper without releasing its entry, and wrong in the direction that
299
+ * costs a person their session: the dashboard offers a seat, the create
300
+ * passes, this runner refuses, the session dies in fifty milliseconds.
301
+ *
302
+ * Called from `reportStatus` — the choke point every seat change already
303
+ * passes through — and from the interval in the constructor, which is the net
304
+ * under any path that forgets. Both are safe to call as often as they like:
305
+ * this compares what it is about to say against what it last said and returns
306
+ * without touching the socket when nothing moved.
307
+ *
308
+ * Two lists, because two different questions are being asked and answering
309
+ * both with one number is wrong in both directions:
310
+ *
311
+ * - `sessionIds` — everything still tracked here, parked or not. This is the
312
+ * reconciliation list, and it must be generous: a session the API has
313
+ * buried needs laying to rest whatever state it is in on this side.
314
+ * - `blockingIds` — what a new session would really have to wait for.
315
+ * `ensureCapacity` parks an idle REVIEW or WAITING_INPUT session to make
316
+ * room, so those seats are available on demand.
317
+ *
318
+ * Reporting the second as the first would have the API stop live REVIEW
319
+ * sessions; reporting the first as the second would call a machine full while
320
+ * it had room. Both were caught by the independent QA review of this change.
321
+ */
322
+ private publishSlots;
135
323
  private onFrame;
136
324
  private startSession;
137
325
  /**
@@ -570,7 +758,61 @@ export declare class Supervisor {
570
758
  private isMidTurn;
571
759
  /** The three live setters, in the order that lets an explicit pick win. */
572
760
  private applyLiveSettings;
761
+ /**
762
+ * Let go of a session the API says no longer exists, so its files can go.
763
+ *
764
+ * `purge_session` and `clean` used to refuse outright while ANY entry for the
765
+ * id was in the map. That reads as caution and behaves as a deadlock: the
766
+ * frame arrives precisely because the API has already deleted the row, so
767
+ * nothing will ever come along to release the entry, the worktree stays on
768
+ * disk, and the retry runs until the purge ages out of its window. On
769
+ * production one such tombstone was still failing two hours later, on a
770
+ * machine that was refusing new sessions for exactly the seat it held.
771
+ *
772
+ * The API is the authority on whether a session exists. If it says gone, the
773
+ * honest answer is to end it here too — the same teardown a reconnect
774
+ * performs for a session missing from `hello_ack` — and only then report that
775
+ * we could not finish, if the process really will not go.
776
+ *
777
+ * Returns true when nothing is holding the id any more.
778
+ */
779
+ private releaseForCleanup;
780
+ /**
781
+ * How long cleanup waits for a stopped agent process to actually be gone.
782
+ *
783
+ * Deliberately a small slice of the API's ten-second command budget: removing
784
+ * the worktree still has to happen after this, under the repository lock, and
785
+ * a purge that times out on the wire is reported as a failure even when it
786
+ * succeeded here. Three seconds is enough for an ordinary exit; anything
787
+ * slower is better answered honestly, so the drain comes back and finds the
788
+ * entry already gone.
789
+ */
790
+ private static readonly CLEANUP_RELEASE_MS;
573
791
  private stopSession;
792
+ /**
793
+ * The ONE way a session ends on this runner.
794
+ *
795
+ * A terminal status is two facts, not one: the API is told the session is
796
+ * over, AND this machine stops holding a seat for it. They used to be
797
+ * separate lines at eight call sites, and three of them wrote only the first
798
+ * — `settleTurnStatus`'s failed-turn branch, `forwardEvent`'s `case 'error'`
799
+ * and `runApiRetry`. Each left a `RunningSession` in the map with a live
800
+ * `session` handle and `stopRequested === false`, which is precisely what
801
+ * `liveSessionCount` counts. The seat was then held for a session the API had
802
+ * already buried, until the next reconnect — weeks, on a healthy runner.
803
+ *
804
+ * On production (31.08.2026) that arithmetic refused a fourth session on a
805
+ * machine whose database said one was running, and the dashboard had offered
806
+ * the seat a moment earlier. `launchCrashed` had the rule right all along
807
+ * («an entry left in the map would hold one of the runner's few slots»); it
808
+ * just could not be the only place that knew it.
809
+ *
810
+ * Deliberately NOT folded into `reportStatus`: that method is also how a
811
+ * session reaches REVIEW, WAITING_INPUT and RUNNING, and a teardown hidden
812
+ * inside it would be invisible at exactly the call sites that must not tear
813
+ * anything down. The name says what it does.
814
+ */
815
+ private finishSession;
574
816
  private reconcile;
575
817
  /**
576
818
  * Housekeeping for the journal directory. Safe to call any time: live