@bridge4dev/runner 0.65.0 → 0.66.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.
@@ -19,6 +19,7 @@ import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs
19
19
  import { fsView } from './fsview.js';
20
20
  import { publishFile } from './file-publish.js';
21
21
  import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
22
+ import { isAccountCommand, runAccountCommand } from './account-commands.js';
22
23
  import { isSupervisedProcess, selfUpdate } from './self-update.js';
23
24
  import { installAgent } from './agent-install.js';
24
25
  import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
@@ -28,9 +29,9 @@ import { cageAuthority } from './cage-authority.js';
28
29
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
29
30
  import { rememberWorkspacePath } from './environment.js';
30
31
  import { hostLoadChangedEnough, hostLoadHeartbeatDue, readHostLoad, HOST_LOAD_HEARTBEAT_MS, HOST_LOAD_SAMPLE_INTERVAL_MS, } from './host-load.js';
31
- import { markScopeOomKillsSeen, readScopeHold, readScopeMemoryStatus, readSliceLimits, sessionAgentPid, sessionCage, sessionScopeUnitOf, setLiveLadderSource, stoppedProcesses, sweepOrphanSessionScopes, } from './session-cage.js';
32
+ import { markScopeOomKillsSeen, readScopeHold, readScopeMemoryStatus, readSliceLimits, restartStoppedCommands, sessionAgentPid, sessionCage, sessionScopeUnitOf, setLiveLadderSource, stopSessionScope, stoppedProcesses, sweepOrphanSessionScopes, } from './session-cage.js';
32
33
  import { allocateSessionMemory, planLimitMove, SESSION_GUARANTEE_BYTES, } from './session-allocator.js';
33
- import { freshStallState, pickKillCandidate, readScopeProcesses, readStallSample, setScopeProperties, signalSubtree, stallStep, STALL_GRACE_MS, STALL_MUTE_AFTER_LOWER_MS, STALL_SIGKILL_AFTER_MS, STALL_WINDOW_MS, } from './session-stall.js';
34
+ import { countRunningCommands, freshStallState, pickKillCandidate, readScopeProcesses, readStallSample, setScopeProperties, signalSubtree, stallStep, STALL_GRACE_MS, STALL_MUTE_AFTER_LOWER_MS, STALL_SIGKILL_AFTER_MS, STALL_WINDOW_MS, } from './session-stall.js';
34
35
  import { machineReserveBytes } from './service-unit.js';
35
36
  import { sessionLimitsChangedEnough, sessionLimitsHeartbeatDue, SESSION_LIMITS_HEARTBEAT_MS, } from './session-limits.js';
36
37
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
@@ -1693,7 +1694,28 @@ export class Supervisor {
1693
1694
  this.lastHostLoadSentAt = now;
1694
1695
  }
1695
1696
  }
1697
+ /**
1698
+ * Set by {@link shutdown}, and read wherever this supervisor could still be
1699
+ * ASKED to do something after it has let go of everything (#431).
1700
+ *
1701
+ * It exists because the daemon now outlives `shutdown()` by up to three
1702
+ * seconds — the time its cages are given to stop. Until that change every
1703
+ * caller exited in the same tick and «after shutdown» was not a state
1704
+ * anything could be in.
1705
+ */
1706
+ shuttingDown = false;
1696
1707
  async onFrame(frame) {
1708
+ /**
1709
+ * Nothing from the wire is answered once the shutdown has started.
1710
+ *
1711
+ * The socket is closed right after the synchronous half of `shutdown()`,
1712
+ * but closing is a handshake: a frame already in flight still lands. The
1713
+ * client refuses to deliver it (`ws-client.ts`) and this is the second
1714
+ * door, on the side that would act on it — an `event_ack` here deletes the
1715
+ * journal of a session this supervisor has already released.
1716
+ */
1717
+ if (this.shuttingDown)
1718
+ return;
1697
1719
  switch (frame.type) {
1698
1720
  case 'hello_ack':
1699
1721
  this.helloAcked = true;
@@ -1866,6 +1888,21 @@ export class Supervisor {
1866
1888
  * the ask any more.
1867
1889
  */
1868
1890
  withdrawReason = 'not_held') {
1891
+ /**
1892
+ * Nothing is started once this runner is going down (#431).
1893
+ *
1894
+ * The wire is already shut by then, but this door has a second caller: the
1895
+ * tail of `pumpEvents`, which runs a microtask after the agent's stream
1896
+ * ends — and since the shutdown waits for its cages, the process is still
1897
+ * here to run it. What it would build first is a worktree and a branch, and
1898
+ * what it would build last is a fresh cage, moments before `process.exit`.
1899
+ */
1900
+ if (this.shuttingDown) {
1901
+ log.warn('supervisor: not starting a session, this runner is going down', {
1902
+ sessionId: descriptor.id,
1903
+ });
1904
+ return;
1905
+ }
1869
1906
  const existing = this.sessions.get(descriptor.id);
1870
1907
  if (existing) {
1871
1908
  // A resume (higher epoch) can land while the previous life is still
@@ -2192,6 +2229,22 @@ export class Supervisor {
2192
2229
  });
2193
2230
  return LAUNCH_REFUSED;
2194
2231
  }
2232
+ /**
2233
+ * …and no agent at all once the daemon is on its way out (#431).
2234
+ *
2235
+ * The five one-shot relaunches live in the tail of `pumpEvents`, which runs
2236
+ * a microtask after the event stream of the dying process ends — and since
2237
+ * the shutdown waits for its cages, the process is still here to run it.
2238
+ * A launch there would put a NEW cage on the machine moments before
2239
+ * `process.exit`, leaving behind exactly the orphan this ticket is about.
2240
+ * Found by the independent review of #431.
2241
+ */
2242
+ if (this.shuttingDown) {
2243
+ log.warn('supervisor: not launching an agent, this runner is going down', {
2244
+ sessionId: descriptor.id,
2245
+ });
2246
+ return LAUNCH_REFUSED;
2247
+ }
2195
2248
  // An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
2196
2249
  // Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
2197
2250
  // would be a limit that can never fire while the UI shows $0.00. Those
@@ -2650,7 +2703,7 @@ export class Supervisor {
2650
2703
  const { descriptor } = running;
2651
2704
  try {
2652
2705
  for await (const event of session.events) {
2653
- this.forwardEvent(running, event);
2706
+ this.forwardEvent(running, event, session);
2654
2707
  }
2655
2708
  }
2656
2709
  catch (error) {
@@ -3566,7 +3619,14 @@ export class Supervisor {
3566
3619
  this.syncBudgetClock(running);
3567
3620
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
3568
3621
  }
3569
- forwardEvent(running, event) {
3622
+ forwardEvent(running, event,
3623
+ /**
3624
+ * The process this event came from (#422). A refusal and a working turn are
3625
+ * attributed to the account THAT process started under – read off the
3626
+ * session that produced the event, not off whatever holds the slot by the
3627
+ * time the event is handled.
3628
+ */
3629
+ source = running.session) {
3570
3630
  const { descriptor } = running;
3571
3631
  // Ticket #225: ANY event is the process proving it came up — its own
3572
3632
  // capabilities probe answers within about two seconds of a healthy launch,
@@ -3760,7 +3820,7 @@ export class Supervisor {
3760
3820
  if (event.code === 'auth_expired') {
3761
3821
  const refused = relayAgent(String(descriptor.agent).toLowerCase());
3762
3822
  if (refused)
3763
- noteAgentAuthFailure(refused);
3823
+ noteAgentAuthFailure(refused, source?.accountId);
3764
3824
  }
3765
3825
  // Only `auth_missing` is worth a relaunch, and the distinction is the
3766
3826
  // adapter's own (`probeAuth`), not a guess from prose.
@@ -3865,7 +3925,7 @@ export class Supervisor {
3865
3925
  if (event.role === 'assistant') {
3866
3926
  const working = relayAgent(String(descriptor.agent).toLowerCase());
3867
3927
  if (working)
3868
- clearAgentAuthFailure(working);
3928
+ clearAgentAuthFailure(working, source?.accountId);
3869
3929
  }
3870
3930
  this.sendEvent(running, 'message', { role: event.role, text: event.text });
3871
3931
  return;
@@ -7431,6 +7491,11 @@ export class Supervisor {
7431
7491
  });
7432
7492
  }
7433
7493
  default:
7494
+ // #422: the five account commands – their own module, one door each
7495
+ // over `claude-homes.ts` (plan §8).
7496
+ if (isAccountCommand(frame.name)) {
7497
+ return void reply(await runAccountCommand(frame.name, frame.args ?? {}, this.authRelay));
7498
+ }
7434
7499
  return void reply({ ok: false, error: `Unknown command` });
7435
7500
  }
7436
7501
  }
@@ -7676,8 +7741,40 @@ export class Supervisor {
7676
7741
  // changed, and the interval in the constructor is the net under both.
7677
7742
  this.publishSlots();
7678
7743
  }
7679
- /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
7680
- shutdown() {
7744
+ /**
7745
+ * How long the whole fleet of cages is given to stop before this process goes
7746
+ * anyway (#431).
7747
+ *
7748
+ * The bus has been measured at 2.7 s under load and the stops run in
7749
+ * parallel, so three seconds is room for all of them on a bad day. Going over
7750
+ * it costs nothing: `stop` has been ACCEPTED by then, and systemd finishes
7751
+ * the job whether or not anybody is still here to watch. systemd's own
7752
+ * patience with this service is `TimeoutStopSec`, far above this.
7753
+ */
7754
+ static CAGE_STOP_BUDGET_MS = 3_000;
7755
+ /**
7756
+ * Graceful daemon shutdown: kill agents, take their cages with them, keep
7757
+ * sessions resumable server-side.
7758
+ *
7759
+ * **Everything that has to survive this process happens BEFORE the first
7760
+ * `await`** — the withdrawn cards, the feed line, and the word to each agent
7761
+ * that it is over (a signal for Codex; for Claude, stdin closed and the SDK's
7762
+ * own `close()`, whose SIGTERM is a timer this process will not live to see —
7763
+ * on a caged machine what actually ends it is the stop below). A caller that
7764
+ * does not wait (the suite does not) gets exactly the shutdown it always got;
7765
+ * what it misses is only the stop of the cgroups. Written this way rather
7766
+ * than as two methods because «kill the agent» and «put its cage out» are one
7767
+ * action, and the shape of the code is what stops them drifting apart again
7768
+ * (грабля §479).
7769
+ *
7770
+ * What the caller owes in return: close the socket between the two halves.
7771
+ * The events above need it open, and the seconds below must not be a window
7772
+ * in which a `session_start` arrives at a supervisor that has already let go
7773
+ * of everything (`index.ts`).
7774
+ */
7775
+ async shutdown() {
7776
+ // First line: every door that could put work back on this machine reads it.
7777
+ this.shuttingDown = true;
7681
7778
  clearInterval(this.slotsTimer);
7682
7779
  clearInterval(this.hostLoadTimer);
7683
7780
  clearInterval(this.stallTimer);
@@ -7686,6 +7783,10 @@ export class Supervisor {
7686
7783
  clearTimeout(this.agentCleanupFirstTimer);
7687
7784
  clearInterval(this.agentCleanupTimer);
7688
7785
  this.authRelay.cancel();
7786
+ const unitOf = this.opts.sessionScopeUnitOf ?? sessionScopeUnitOf;
7787
+ const readProcesses = this.opts.readScopeProcesses ?? readScopeProcesses;
7788
+ const agentPidOf = this.opts.sessionAgentPid ?? sessionAgentPid;
7789
+ const cages = [];
7689
7790
  for (const running of this.sessions.values()) {
7690
7791
  this.clearBudgetTimers(running);
7691
7792
  // BEFORE stop(), and from here rather than from the adapter: the adapter
@@ -7707,15 +7808,86 @@ export class Supervisor {
7707
7808
  error: String(error),
7708
7809
  });
7709
7810
  }
7811
+ /**
7812
+ * What this restart costs, in the person's units, before anything is
7813
+ * killed (#431).
7814
+ *
7815
+ * The count comes from the cage itself rather than from anything this
7816
+ * process believes: the runner does not track what the agent started —
7817
+ * that is the whole shape of the problem — and the cgroup does. Read
7818
+ * BEFORE `stop()`, because a moment later there is nothing left to count.
7819
+ *
7820
+ * Silent when the number is zero: an agent that was only thinking loses
7821
+ * nothing, and «0 commands were stopped» is a line a person has to read
7822
+ * and cannot use (D8).
7823
+ */
7824
+ const unit = unitOf(running.descriptor.id);
7825
+ if (unit !== null) {
7826
+ cages.push(unit);
7827
+ try {
7828
+ const commands = countRunningCommands(readProcesses(unit), agentPidOf(running.descriptor.id));
7829
+ if (commands > 0) {
7830
+ this.sendEvent(running, 'notice', {
7831
+ level: 'warn',
7832
+ text: restartStoppedCommands(commands),
7833
+ });
7834
+ }
7835
+ }
7836
+ catch (error) {
7837
+ // /proc said nothing readable. The cage is still stopped below — the
7838
+ // person simply does not get the number.
7839
+ log.warn('supervisor: could not count what this session was running', {
7840
+ sessionId: running.descriptor.id,
7841
+ error: String(error),
7842
+ });
7843
+ }
7844
+ }
7710
7845
  // The reason matters here: this path also runs for «Update runner», and a
7711
7846
  // card that vanishes during an update must say why (session 12).
7712
- running.session?.stop('runner_restarted');
7847
+ //
7848
+ // Guarded like its neighbours in this loop: one adapter that throws on
7849
+ // its way out must not cost every OTHER session the stop of its cage —
7850
+ // the lines below it are what puts them out (independent review of #431).
7851
+ try {
7852
+ running.session?.stop('runner_restarted');
7853
+ }
7854
+ catch (error) {
7855
+ log.warn('supervisor: an agent did not take its stop cleanly', {
7856
+ sessionId: running.descriptor.id,
7857
+ error: String(error),
7858
+ });
7859
+ }
7713
7860
  }
7714
7861
  this.sessions.clear();
7715
7862
  // A build that was mid-flight is abandoned, not judged: its row stays
7716
7863
  // RUNNING until the API's sweep turns it into LOST. A verdict nobody
7717
7864
  // observed must never become PASSED.
7718
7865
  this.verify.shutdown();
7866
+ await this.stopCages(cages);
7867
+ }
7868
+ /**
7869
+ * Put out every cage this daemon was holding, in parallel and on a clock.
7870
+ *
7871
+ * Until 0.65.0 nothing did this: `releaseSessionScope` hangs off the agent
7872
+ * process's `exit`, and on a restart the daemon is gone before that event
7873
+ * fires. What stayed behind was a whole tree — a build, its docker client,
7874
+ * their children — holding the name of a scope systemd will then refuse to
7875
+ * reuse, so the FIRST start of that session after the restart died as
7876
+ * «exited before the session was ready (code 1)» (#431).
7877
+ */
7878
+ async stopCages(units) {
7879
+ if (units.length === 0)
7880
+ return;
7881
+ const stop = this.opts.stopSessionScope ?? stopSessionScope;
7882
+ log.info('supervisor: stopping the cages of this runner', { count: units.length });
7883
+ const all = Promise.allSettled(units.map((unit) => stop(unit))).then(() => undefined);
7884
+ let timer;
7885
+ const budget = new Promise((resolve) => {
7886
+ timer = setTimeout(resolve, this.opts.cageStopBudgetMs ?? Supervisor.CAGE_STOP_BUDGET_MS);
7887
+ });
7888
+ await Promise.race([all, budget]);
7889
+ if (timer)
7890
+ clearTimeout(timer);
7719
7891
  }
7720
7892
  }
7721
7893
  /**
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.65.0";
1
+ export declare const RUNNER_VERSION = "0.66.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.65.0';
2
+ export const RUNNER_VERSION = '0.66.0';
3
3
  //# sourceMappingURL=version.js.map
package/dist/ws-client.js CHANGED
@@ -137,6 +137,20 @@ export class RunnerWsClient {
137
137
  this.lastInboundAt = Date.now();
138
138
  });
139
139
  socket.on('message', (data) => {
140
+ /**
141
+ * A client that was stopped answers nothing, in either direction (#431).
142
+ *
143
+ * `close()` is the START of a handshake, not a cut: the socket lives on
144
+ * for a round trip, and this listener used to keep delivering frames off
145
+ * it. That was harmless while every caller of `stop()` exited in the same
146
+ * tick; since the daemon waits for its cages to be put out, the window is
147
+ * seconds wide, and the supervisor on the other side of `frame` has
148
+ * already let go of every session. An `event_ack` landing there DELETES
149
+ * the journal of a live session (found by the independent review of
150
+ * #431); a `session_start` starts an agent nobody will ever stop.
151
+ */
152
+ if (this.stopped)
153
+ return;
140
154
  this.lastInboundAt = Date.now();
141
155
  let parsed;
142
156
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",