@bridge4dev/runner 0.64.1 → 0.65.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.
@@ -28,9 +28,9 @@ import { cageAuthority } from './cage-authority.js';
28
28
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
29
29
  import { rememberWorkspacePath } from './environment.js';
30
30
  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';
31
+ import { markScopeOomKillsSeen, readScopeHold, readScopeMemoryStatus, readSliceLimits, restartStoppedCommands, sessionAgentPid, sessionCage, sessionScopeUnitOf, setLiveLadderSource, stopSessionScope, stoppedProcesses, sweepOrphanSessionScopes, } from './session-cage.js';
32
32
  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';
33
+ 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
34
  import { machineReserveBytes } from './service-unit.js';
35
35
  import { sessionLimitsChangedEnough, sessionLimitsHeartbeatDue, SESSION_LIMITS_HEARTBEAT_MS, } from './session-limits.js';
36
36
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
@@ -1693,7 +1693,28 @@ export class Supervisor {
1693
1693
  this.lastHostLoadSentAt = now;
1694
1694
  }
1695
1695
  }
1696
+ /**
1697
+ * Set by {@link shutdown}, and read wherever this supervisor could still be
1698
+ * ASKED to do something after it has let go of everything (#431).
1699
+ *
1700
+ * It exists because the daemon now outlives `shutdown()` by up to three
1701
+ * seconds — the time its cages are given to stop. Until that change every
1702
+ * caller exited in the same tick and «after shutdown» was not a state
1703
+ * anything could be in.
1704
+ */
1705
+ shuttingDown = false;
1696
1706
  async onFrame(frame) {
1707
+ /**
1708
+ * Nothing from the wire is answered once the shutdown has started.
1709
+ *
1710
+ * The socket is closed right after the synchronous half of `shutdown()`,
1711
+ * but closing is a handshake: a frame already in flight still lands. The
1712
+ * client refuses to deliver it (`ws-client.ts`) and this is the second
1713
+ * door, on the side that would act on it — an `event_ack` here deletes the
1714
+ * journal of a session this supervisor has already released.
1715
+ */
1716
+ if (this.shuttingDown)
1717
+ return;
1697
1718
  switch (frame.type) {
1698
1719
  case 'hello_ack':
1699
1720
  this.helloAcked = true;
@@ -1866,6 +1887,21 @@ export class Supervisor {
1866
1887
  * the ask any more.
1867
1888
  */
1868
1889
  withdrawReason = 'not_held') {
1890
+ /**
1891
+ * Nothing is started once this runner is going down (#431).
1892
+ *
1893
+ * The wire is already shut by then, but this door has a second caller: the
1894
+ * tail of `pumpEvents`, which runs a microtask after the agent's stream
1895
+ * ends — and since the shutdown waits for its cages, the process is still
1896
+ * here to run it. What it would build first is a worktree and a branch, and
1897
+ * what it would build last is a fresh cage, moments before `process.exit`.
1898
+ */
1899
+ if (this.shuttingDown) {
1900
+ log.warn('supervisor: not starting a session, this runner is going down', {
1901
+ sessionId: descriptor.id,
1902
+ });
1903
+ return;
1904
+ }
1869
1905
  const existing = this.sessions.get(descriptor.id);
1870
1906
  if (existing) {
1871
1907
  // A resume (higher epoch) can land while the previous life is still
@@ -2192,6 +2228,22 @@ export class Supervisor {
2192
2228
  });
2193
2229
  return LAUNCH_REFUSED;
2194
2230
  }
2231
+ /**
2232
+ * …and no agent at all once the daemon is on its way out (#431).
2233
+ *
2234
+ * The five one-shot relaunches live in the tail of `pumpEvents`, which runs
2235
+ * a microtask after the event stream of the dying process ends — and since
2236
+ * the shutdown waits for its cages, the process is still here to run it.
2237
+ * A launch there would put a NEW cage on the machine moments before
2238
+ * `process.exit`, leaving behind exactly the orphan this ticket is about.
2239
+ * Found by the independent review of #431.
2240
+ */
2241
+ if (this.shuttingDown) {
2242
+ log.warn('supervisor: not launching an agent, this runner is going down', {
2243
+ sessionId: descriptor.id,
2244
+ });
2245
+ return LAUNCH_REFUSED;
2246
+ }
2195
2247
  // An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
2196
2248
  // Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
2197
2249
  // would be a limit that can never fire while the UI shows $0.00. Those
@@ -3404,6 +3456,39 @@ export class Supervisor {
3404
3456
  clearTimeout(running.compactionWatchdog);
3405
3457
  delete running.compactionWatchdog;
3406
3458
  }
3459
+ /**
3460
+ * A card that interrupted a RESTING session has been answered: go back to
3461
+ * rest instead of reporting a turn (#382).
3462
+ *
3463
+ * Which card it was does not matter — a Codex helper's approval, a Claude
3464
+ * background subagent's, an ask either of them parked while the session was
3465
+ * already the person's. What matters is that no turn of this session was
3466
+ * running when the card went out, so there is no turn to go back to and
3467
+ * nothing that would end one: on Codex the helper's own ending is explicitly
3468
+ * not the session's, so «Working» stood until somebody typed.
3469
+ *
3470
+ * Only when the burst is over — both card sets empty — because answering one
3471
+ * of three still leaves the session parked on the other two. The frame
3472
+ * carries the background count like every other status report, so the badge
3473
+ * and the Inbox see «resting, with helpers» rather than «your turn».
3474
+ *
3475
+ * Returns true when it handled the resolution, so the callers' «the human
3476
+ * answered, bill again» branches stay out of it.
3477
+ */
3478
+ restAfterCard(running) {
3479
+ const back = running.restBeforeCard;
3480
+ if (!back)
3481
+ return false;
3482
+ if (running.openPermissions.size > 0 || running.openQuestions.size > 0)
3483
+ return true;
3484
+ // `reportStatus` clears the memory — the status it sends is the newest
3485
+ // truth about this session, whatever it is.
3486
+ this.reportStatus(running.descriptor.id, back, {
3487
+ costUsd: running.costUsd,
3488
+ activeMs: Supervisor.spentMs(running),
3489
+ });
3490
+ return true;
3491
+ }
3407
3492
  /**
3408
3493
  * Record how many subagents are alive, and say so when it matters (#236).
3409
3494
  *
@@ -3817,6 +3902,8 @@ export class Supervisor {
3817
3902
  source: event.source,
3818
3903
  reason: event.reason,
3819
3904
  });
3905
+ if (this.restAfterCard(running))
3906
+ return;
3820
3907
  if (event.source === 'user' &&
3821
3908
  event.allow &&
3822
3909
  running.lastReported === 'WAITING_PERMISSION') {
@@ -3844,6 +3931,11 @@ export class Supervisor {
3844
3931
  // is the bug session 7 removed (five of the first twelve prod sessions
3845
3932
  // died having spent their budget waiting for a human).
3846
3933
  running.openQuestions.add(event.askId);
3934
+ // #382, the same as a permission card: an ask from work that outlives
3935
+ // the turn finds the session at rest, and the answer must put it back.
3936
+ if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
3937
+ running.restBeforeCard = running.lastReported;
3938
+ }
3847
3939
  this.sendEvent(running, 'question', {
3848
3940
  askId: event.askId,
3849
3941
  questions: event.questions,
@@ -3872,6 +3964,8 @@ export class Supervisor {
3872
3964
  // `openQuestions.size` matters: both agents can park several asks at
3873
3965
  // once, and reporting RUNNING while another card is still waiting would
3874
3966
  // bill a human's thinking time all over again (QA-106 M4).
3967
+ if (this.restAfterCard(running))
3968
+ return;
3875
3969
  if (event.source === 'user' &&
3876
3970
  running.openQuestions.size === 0 &&
3877
3971
  running.lastReported === 'WAITING_INPUT') {
@@ -7563,6 +7657,13 @@ export class Supervisor {
7563
7657
  // The API infers WAITING_PERMISSION from the event itself, so this never
7564
7658
  // goes through reportStatus — but the budget clock still has to stop, or
7565
7659
  // an ask-mode session bills every second the human spends reading the card.
7660
+ //
7661
+ // #382: remember what the card interrupted. A card from work that
7662
+ // outlives the turn finds the session at rest, and the answer must put it
7663
+ // back there rather than into a turn nobody is running.
7664
+ if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
7665
+ running.restBeforeCard = running.lastReported;
7666
+ }
7566
7667
  running.lastReported = 'WAITING_PERMISSION';
7567
7668
  this.syncBudgetClock(running);
7568
7669
  }
@@ -7572,6 +7673,8 @@ export class Supervisor {
7572
7673
  const running = this.sessions.get(sessionId);
7573
7674
  if (running) {
7574
7675
  running.lastReported = status;
7676
+ // #382: any status report is newer than the rest a card interrupted.
7677
+ delete running.restBeforeCard;
7575
7678
  // Coming to rest ends the busy period the once-per-turn notices were
7576
7679
  // limited to: the next one is about a new answer and deserves saying.
7577
7680
  if (!Supervisor.MID_TURN_STATUSES.includes(status)) {
@@ -7625,8 +7728,40 @@ export class Supervisor {
7625
7728
  // changed, and the interval in the constructor is the net under both.
7626
7729
  this.publishSlots();
7627
7730
  }
7628
- /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
7629
- shutdown() {
7731
+ /**
7732
+ * How long the whole fleet of cages is given to stop before this process goes
7733
+ * anyway (#431).
7734
+ *
7735
+ * The bus has been measured at 2.7 s under load and the stops run in
7736
+ * parallel, so three seconds is room for all of them on a bad day. Going over
7737
+ * it costs nothing: `stop` has been ACCEPTED by then, and systemd finishes
7738
+ * the job whether or not anybody is still here to watch. systemd's own
7739
+ * patience with this service is `TimeoutStopSec`, far above this.
7740
+ */
7741
+ static CAGE_STOP_BUDGET_MS = 3_000;
7742
+ /**
7743
+ * Graceful daemon shutdown: kill agents, take their cages with them, keep
7744
+ * sessions resumable server-side.
7745
+ *
7746
+ * **Everything that has to survive this process happens BEFORE the first
7747
+ * `await`** — the withdrawn cards, the feed line, and the word to each agent
7748
+ * that it is over (a signal for Codex; for Claude, stdin closed and the SDK's
7749
+ * own `close()`, whose SIGTERM is a timer this process will not live to see —
7750
+ * on a caged machine what actually ends it is the stop below). A caller that
7751
+ * does not wait (the suite does not) gets exactly the shutdown it always got;
7752
+ * what it misses is only the stop of the cgroups. Written this way rather
7753
+ * than as two methods because «kill the agent» and «put its cage out» are one
7754
+ * action, and the shape of the code is what stops them drifting apart again
7755
+ * (грабля §479).
7756
+ *
7757
+ * What the caller owes in return: close the socket between the two halves.
7758
+ * The events above need it open, and the seconds below must not be a window
7759
+ * in which a `session_start` arrives at a supervisor that has already let go
7760
+ * of everything (`index.ts`).
7761
+ */
7762
+ async shutdown() {
7763
+ // First line: every door that could put work back on this machine reads it.
7764
+ this.shuttingDown = true;
7630
7765
  clearInterval(this.slotsTimer);
7631
7766
  clearInterval(this.hostLoadTimer);
7632
7767
  clearInterval(this.stallTimer);
@@ -7635,6 +7770,10 @@ export class Supervisor {
7635
7770
  clearTimeout(this.agentCleanupFirstTimer);
7636
7771
  clearInterval(this.agentCleanupTimer);
7637
7772
  this.authRelay.cancel();
7773
+ const unitOf = this.opts.sessionScopeUnitOf ?? sessionScopeUnitOf;
7774
+ const readProcesses = this.opts.readScopeProcesses ?? readScopeProcesses;
7775
+ const agentPidOf = this.opts.sessionAgentPid ?? sessionAgentPid;
7776
+ const cages = [];
7638
7777
  for (const running of this.sessions.values()) {
7639
7778
  this.clearBudgetTimers(running);
7640
7779
  // BEFORE stop(), and from here rather than from the adapter: the adapter
@@ -7656,15 +7795,86 @@ export class Supervisor {
7656
7795
  error: String(error),
7657
7796
  });
7658
7797
  }
7798
+ /**
7799
+ * What this restart costs, in the person's units, before anything is
7800
+ * killed (#431).
7801
+ *
7802
+ * The count comes from the cage itself rather than from anything this
7803
+ * process believes: the runner does not track what the agent started —
7804
+ * that is the whole shape of the problem — and the cgroup does. Read
7805
+ * BEFORE `stop()`, because a moment later there is nothing left to count.
7806
+ *
7807
+ * Silent when the number is zero: an agent that was only thinking loses
7808
+ * nothing, and «0 commands were stopped» is a line a person has to read
7809
+ * and cannot use (D8).
7810
+ */
7811
+ const unit = unitOf(running.descriptor.id);
7812
+ if (unit !== null) {
7813
+ cages.push(unit);
7814
+ try {
7815
+ const commands = countRunningCommands(readProcesses(unit), agentPidOf(running.descriptor.id));
7816
+ if (commands > 0) {
7817
+ this.sendEvent(running, 'notice', {
7818
+ level: 'warn',
7819
+ text: restartStoppedCommands(commands),
7820
+ });
7821
+ }
7822
+ }
7823
+ catch (error) {
7824
+ // /proc said nothing readable. The cage is still stopped below — the
7825
+ // person simply does not get the number.
7826
+ log.warn('supervisor: could not count what this session was running', {
7827
+ sessionId: running.descriptor.id,
7828
+ error: String(error),
7829
+ });
7830
+ }
7831
+ }
7659
7832
  // The reason matters here: this path also runs for «Update runner», and a
7660
7833
  // card that vanishes during an update must say why (session 12).
7661
- running.session?.stop('runner_restarted');
7834
+ //
7835
+ // Guarded like its neighbours in this loop: one adapter that throws on
7836
+ // its way out must not cost every OTHER session the stop of its cage —
7837
+ // the lines below it are what puts them out (independent review of #431).
7838
+ try {
7839
+ running.session?.stop('runner_restarted');
7840
+ }
7841
+ catch (error) {
7842
+ log.warn('supervisor: an agent did not take its stop cleanly', {
7843
+ sessionId: running.descriptor.id,
7844
+ error: String(error),
7845
+ });
7846
+ }
7662
7847
  }
7663
7848
  this.sessions.clear();
7664
7849
  // A build that was mid-flight is abandoned, not judged: its row stays
7665
7850
  // RUNNING until the API's sweep turns it into LOST. A verdict nobody
7666
7851
  // observed must never become PASSED.
7667
7852
  this.verify.shutdown();
7853
+ await this.stopCages(cages);
7854
+ }
7855
+ /**
7856
+ * Put out every cage this daemon was holding, in parallel and on a clock.
7857
+ *
7858
+ * Until 0.65.0 nothing did this: `releaseSessionScope` hangs off the agent
7859
+ * process's `exit`, and on a restart the daemon is gone before that event
7860
+ * fires. What stayed behind was a whole tree — a build, its docker client,
7861
+ * their children — holding the name of a scope systemd will then refuse to
7862
+ * reuse, so the FIRST start of that session after the restart died as
7863
+ * «exited before the session was ready (code 1)» (#431).
7864
+ */
7865
+ async stopCages(units) {
7866
+ if (units.length === 0)
7867
+ return;
7868
+ const stop = this.opts.stopSessionScope ?? stopSessionScope;
7869
+ log.info('supervisor: stopping the cages of this runner', { count: units.length });
7870
+ const all = Promise.allSettled(units.map((unit) => stop(unit))).then(() => undefined);
7871
+ let timer;
7872
+ const budget = new Promise((resolve) => {
7873
+ timer = setTimeout(resolve, this.opts.cageStopBudgetMs ?? Supervisor.CAGE_STOP_BUDGET_MS);
7874
+ });
7875
+ await Promise.race([all, budget]);
7876
+ if (timer)
7877
+ clearTimeout(timer);
7668
7878
  }
7669
7879
  }
7670
7880
  /**
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.64.1";
1
+ export declare const RUNNER_VERSION = "0.65.1";
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.64.1';
2
+ export const RUNNER_VERSION = '0.65.1';
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.64.1",
3
+ "version": "0.65.1",
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",