@bridge4dev/runner 0.55.0 → 0.56.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.
@@ -26,7 +26,7 @@ import { agentByDbValue } from './agent-registry.js';
26
26
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
27
27
  import { rememberWorkspacePath } from './environment.js';
28
28
  import { hostLoadChangedEnough, hostLoadHeartbeatDue, readHostLoad, HOST_LOAD_HEARTBEAT_MS, HOST_LOAD_SAMPLE_INTERVAL_MS, } from './host-load.js';
29
- import { markScopeOomKillsSeen, readScopeMemoryStatus, sessionScopeUnitOf, } from './session-cage.js';
29
+ import { markScopeOomKillsSeen, readScopeMemoryStatus, sessionScopeUnitOf, stoppedProcesses, } from './session-cage.js';
30
30
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
31
31
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
32
32
  import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
@@ -144,6 +144,8 @@ export class Supervisor {
144
144
  * which of the two is running.
145
145
  */
146
146
  installInFlight = null;
147
+ /** A restore-point collection is running; a second reconnect must not start another (#388). */
148
+ checkpointGcInFlight = false;
147
149
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
148
150
  verify;
149
151
  verifyReports = new VerifyReportQueue();
@@ -715,18 +717,15 @@ export class Supervisor {
715
717
  // where it was came from the slice over ALL sessions, and telling this
716
718
  // session it went over its ceiling would be false (measured on this
717
719
  // host: the killed neighbour read `max 0, oom 0, oom_kill 1`).
718
- const ownWall = status.ownLimitOom > base.ownOom;
719
- const killed = status.oomKills - base.oomKills === 1
720
- ? 'a process'
721
- : `${status.oomKills - base.oomKills} processes`;
720
+ const ownCeiling = status.ownLimitOom > base.ownOom;
721
+ const stopped = stoppedProcesses(status.oomKills - base.oomKills);
722
722
  this.sendEvent(running, 'notice', {
723
723
  level: 'warn',
724
- text: ownWall
725
- ? `This session went over its memory ceiling${status.maxBytes === null ? '' : ` of ${mb(status.maxBytes)}`} and the kernel killed ${killed} inside it. ` +
726
- 'The session itself is still running; the command that was in flight most likely died with it — check its output before going on.'
727
- : `The machine ran out of memory for agent sessions — they share one ceiling — and the kernel killed ${killed} in this session. ` +
728
- `This session was using ${mb(status.currentBytes)}${status.maxBytes === null ? '' : ` of its own ${mb(status.maxBytes)}`}, so it was not necessarily the greedy one. ` +
729
- 'It is still running; the command that was in flight most likely died with it.',
724
+ text: ownCeiling
725
+ ? `This session reached its${status.maxBytes === null ? '' : ` ${mb(status.maxBytes)}`} memory ceiling and ${stopped} — ` +
726
+ 'check the output of the command that was running.'
727
+ : `The machine ran out of memory for agent sessions and ${stoppedProcesses(status.oomKills - base.oomKills, 'this one')} — ` +
728
+ 'run fewer sessions at once, or give the machine more memory.',
730
729
  });
731
730
  // So a death minutes later is not blamed on a kill the feed already carries.
732
731
  markScopeOomKillsSeen(running.descriptor.id, status.oomKills);
@@ -744,6 +743,10 @@ export class Supervisor {
744
743
  */
745
744
  const throttled = status.highEvents > base.highEvents ||
746
745
  (status.highBytes !== null && status.currentBytes > status.highBytes);
746
+ // `memory.high` reads `max` on a machine where the share never applied —
747
+ // then there is no number to name, and «its 0 MB share» is worse than no
748
+ // number at all.
749
+ const share = status.highBytes === null ? '' : ` ${mb(status.highBytes)}`;
747
750
  let { brakedSince, warnedLong, calmTicks } = base;
748
751
  if (throttled) {
749
752
  calmTicks = 0;
@@ -752,18 +755,15 @@ export class Supervisor {
752
755
  warnedLong = false;
753
756
  this.sendEvent(running, 'notice', {
754
757
  level: 'warn',
755
- text: `This session is over its memory share (${mb(status.highBytes ?? 0)}, using ${mb(status.currentBytes)}), so the kernel is slowing it down instead of killing it. ` +
756
- 'Heavy commands will finish slower until memory is freed.',
758
+ text: `This session has hit its${share} memory share — commands run slower until it uses less memory.`,
757
759
  });
758
760
  }
759
761
  else if (!warnedLong && now - brakedSince >= longMs) {
760
762
  warnedLong = true;
761
763
  this.sendEvent(running, 'notice', {
762
764
  level: 'warn',
763
- text: `Still over its memory share after ${Math.round(longMs / 60_000)} min (using ${mb(status.currentBytes)} of a ${mb(status.highBytes ?? 0)} share). ` +
764
- `If the current command is stuck rather than slow, stop it or pause the session.${status.maxBytes === null
765
- ? ''
766
- : ` The hard ceiling, where a process would be killed, is ${mb(status.maxBytes)}.`}`,
765
+ text: `This session has been over its${share} memory share for ${Math.round(longMs / 60_000)} min and is now using ${mb(status.currentBytes)} — ` +
766
+ 'if a command is stuck rather than slow, stop the turn or pause the session.',
767
767
  });
768
768
  }
769
769
  }
@@ -776,7 +776,7 @@ export class Supervisor {
776
776
  warnedLong = false;
777
777
  this.sendEvent(running, 'notice', {
778
778
  level: 'info',
779
- text: `Memory is back under the session's share (${mb(status.highBytes ?? 0)}); it runs at full speed again.`,
779
+ text: `This session is back under its${share} memory share — it runs at full speed again.`,
780
780
  });
781
781
  }
782
782
  }
@@ -3403,9 +3403,20 @@ export class Supervisor {
3403
3403
  * a second session opened. The neighbours are recorded on the point instead —
3404
3404
  * the conversation can always be rewound to it, the files cannot.
3405
3405
  *
3406
- * Every refusal is now audible. A restore point that was never taken is
3407
- * invisible until the day somebody reaches for it, and «the button is not
3408
- * there» is not a sentence anybody can act on.
3406
+ * A refusal is audible when it is a refusal — when the person can do
3407
+ * something about it, or when a way back they might reach for is not there.
3408
+ * A restore point that was never taken is invisible until the day somebody
3409
+ * reaches for it, and «the button is not there» is not a sentence anybody can
3410
+ * act on.
3411
+ *
3412
+ * «This session was still answering» is the exception, and it is the only one
3413
+ * (#384). It is not a fault and not a state to act on: it is what a follow-up
3414
+ * note to a working agent looks like from in here, thirty times in a day on
3415
+ * one machine, and it costs almost nothing — the point in front of the turn
3416
+ * already stands, and rewinding to it takes back the files AND the
3417
+ * conversation, including the note. So it goes to the runner's own log, where
3418
+ * support can answer «why is there no point for that step», and not into the
3419
+ * feed, where it read as breakage.
3409
3420
  */
3410
3421
  async captureCheckpoint(running, kind, messageSeq) {
3411
3422
  const worktreePath = running.worktreePath;
@@ -3423,7 +3434,13 @@ export class Supervisor {
3423
3434
  return;
3424
3435
  }
3425
3436
  if (kind === 'TURN' && this.isSessionMidTurn(running)) {
3426
- this.noticeOncePerTurn(running, 'checkpoint-self-busy', 'No restore point was taken for this step: this session was still answering when it was due.');
3437
+ // Silent in the feed, on purpose (#384) — see the note above. The `return`
3438
+ // is not cosmetic and does not go: reading a tree the agent is writing
3439
+ // produces a restore point that restores half a file (gotcha 438).
3440
+ log.info('supervisor: no restore point — the session was mid-turn', {
3441
+ sessionId: running.descriptor.id,
3442
+ ...(messageSeq === undefined ? {} : { messageSeq }),
3443
+ });
3427
3444
  return;
3428
3445
  }
3429
3446
  if (await this.isRepoLocked(worktreePath)) {
@@ -3489,16 +3506,18 @@ export class Supervisor {
3489
3506
  /**
3490
3507
  * Say something once per BUSY PERIOD, not once per message (#310).
3491
3508
  *
3492
- * A folder held by a neighbour stays held for minutes, and a session mid-turn
3493
- * can be sent three follow-up notes inside one answer. Keying this on the
3494
- * message seq would have counted each of those as its own turn and said the
3495
- * same sentence three times — the noise the frequency policy exists to
3496
- * prevent. The set is cleared when the session next comes to rest
3497
- * (`reportStatus`), which is exactly when the reason stops being true.
3509
+ * A repository held by another git command stays held for as long as that
3510
+ * command runs, and three follow-up notes can arrive inside one answer.
3511
+ * Keying this on the message seq would have counted each of those as its own
3512
+ * turn and said the same sentence three times — the noise the frequency
3513
+ * policy exists to prevent. The set is cleared when the session next comes to
3514
+ * rest (`reportStatus`), which is exactly when the reason stops being true.
3498
3515
  *
3499
3516
  * A SET of keys, not the last one said: two different reasons can both come
3500
3517
  * up inside one period, and remembering only the most recent would let them
3501
- * take turns re-announcing each other.
3518
+ * take turns re-announcing each other. One key uses this today — «another git
3519
+ * command holds this repository» — and the set stays a set for that reason,
3520
+ * not out of habit: the mid-turn key left when it stopped being said (#384).
3502
3521
  */
3503
3522
  noticeOncePerTurn(running, key, text) {
3504
3523
  running.noticesThisTurn ??= new Set();
@@ -4277,14 +4296,26 @@ export class Supervisor {
4277
4296
  // switched off leaves restore points — a full copy of a working tree —
4278
4297
  // with no row anywhere pointing at them, and nothing else on this machine
4279
4298
  // would ever collect them.
4280
- void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
4281
- if (result.droppedRefs > 0) {
4282
- log.info('supervisor: collected orphaned restore points', {
4283
- sessions: result.droppedSessions.length,
4284
- refs: result.droppedRefs,
4285
- });
4286
- }
4287
- }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
4299
+ //
4300
+ // One at a time (#388): reconnects come in runs, and since the collection
4301
+ // holds the store while it collects, a second one started on top of the
4302
+ // first would only queue — in front of the restore points of whoever is
4303
+ // working. The next reconnect collects whatever this pass leaves.
4304
+ if (!this.checkpointGcInFlight) {
4305
+ this.checkpointGcInFlight = true;
4306
+ void pruneCheckpoints({ liveSessionIds: known })
4307
+ .then((result) => {
4308
+ if (result.droppedRefs > 0) {
4309
+ log.info('supervisor: collected orphaned restore points', {
4310
+ sessions: result.droppedSessions.length,
4311
+ refs: result.droppedRefs,
4312
+ });
4313
+ }
4314
+ }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }))
4315
+ .finally(() => {
4316
+ this.checkpointGcInFlight = false;
4317
+ });
4318
+ }
4288
4319
  for (const [sessionId, running] of [...this.sessions]) {
4289
4320
  if (known.has(sessionId))
4290
4321
  continue;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.55.0";
1
+ export declare const RUNNER_VERSION = "0.56.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.55.0';
2
+ export const RUNNER_VERSION = '0.56.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.55.0",
3
+ "version": "0.56.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",