@bridge4dev/runner 0.45.0 → 0.45.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.
@@ -53,6 +53,15 @@ export interface SupervisorOptions {
53
53
  * announced and no rewind can be started.
54
54
  */
55
55
  checkpointsEnabled?: boolean;
56
+ /**
57
+ * How long a turn that produced nothing is held before it counts (#300).
58
+ *
59
+ * A test seam, like `proposeCommitMessage` above: the real window is 25
60
+ * seconds and no suite can wait that out, so the behaviour would go
61
+ * unverified — which is exactly how the independent review found the hold
62
+ * firing over a working agent (QA-2026-08-16 M-4). Never set in production.
63
+ */
64
+ emptyTurnSettleMs?: number;
56
65
  }
57
66
  export declare class Supervisor {
58
67
  private readonly ws;
@@ -78,6 +87,8 @@ export declare class Supervisor {
78
87
  * agent mid-thought.
79
88
  */
80
89
  private static readonly EMPTY_TURN_SETTLE_MS;
90
+ /** The window actually used — the constant, or a test's own shorter one. */
91
+ private readonly emptyTurnSettleMs;
81
92
  /** A finished session's journal is kept this long for a late reconnect. */
82
93
  private static readonly JOURNAL_TTL_MS;
83
94
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -351,6 +362,21 @@ export declare class Supervisor {
351
362
  * phantom can never fire after the turn it belonged to has closed properly.
352
363
  */
353
364
  private clearEmptyTurn;
365
+ /**
366
+ * Record how many subagents are alive, and say so when it matters (#236).
367
+ *
368
+ * One place, because there are now two callers with opposite news — a frame
369
+ * from the adapter, and the process going away — and «is it the human's turn»
370
+ * must be answered the same way by both.
371
+ *
372
+ * The report on reaching zero is the whole point: the agent is already
373
+ * sitting in a resting status, so nothing else will ever tell the API that
374
+ * the session finally became the person's. `REVIEW` counts as well as
375
+ * `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
376
+ * same position, and leaving it out was how one of the two statuses kept its
377
+ * notification suppressed for good (QA-2026-08-16 M-5).
378
+ */
379
+ private setBackgroundTasks;
354
380
  private isParkable;
355
381
  /**
356
382
  * Stop the agent process but keep the session resumable.
@@ -77,6 +77,8 @@ export class Supervisor {
77
77
  * agent mid-thought.
78
78
  */
79
79
  static EMPTY_TURN_SETTLE_MS = 25_000;
80
+ /** The window actually used — the constant, or a test's own shorter one. */
81
+ emptyTurnSettleMs;
80
82
  /** A finished session's journal is kept this long for a late reconnect. */
81
83
  static JOURNAL_TTL_MS = 72 * 3_600_000;
82
84
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -114,6 +116,7 @@ export class Supervisor {
114
116
  this.ws = ws;
115
117
  this.opts = opts;
116
118
  this.journals = opts.journals ?? new JournalStore();
119
+ this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
117
120
  this.verify = new VerifyRunner({
118
121
  enabled: opts.verifyEnabled !== false,
119
122
  onReport: (report) => {
@@ -376,7 +379,7 @@ export class Supervisor {
376
379
  if (orphaned) {
377
380
  this.orphanMessages.delete(descriptor.id);
378
381
  for (const message of orphaned) {
379
- this.acceptUserMessage(descriptor.id, message.text, message.attachments);
382
+ this.acceptUserMessage(descriptor.id, message.text, message.attachments, message.messageId);
380
383
  }
381
384
  }
382
385
  try {
@@ -1063,6 +1066,20 @@ export class Supervisor {
1063
1066
  // Idle process ended (parked or died between turns) — stay resumable.
1064
1067
  running.session = null;
1065
1068
  running.parkRequested = false;
1069
+ /**
1070
+ * The subagents died with it (QA-2026-08-16 M-5).
1071
+ *
1072
+ * Here rather than in `park()`, because parking only ASKS the process to
1073
+ * stop and this is where it actually went. The adapter stops publishing
1074
+ * the moment it is told to stop, so without this the last frame's count
1075
+ * stands for ever: the strip says «Working in background» over a parked
1076
+ * session and — far worse — the API goes on suppressing that session's
1077
+ * «your turn» notification for the rest of its life, because the number
1078
+ * it stored never reaches zero again.
1079
+ *
1080
+ * Before the `reportStatus` just below, so the zero rides out on it.
1081
+ */
1082
+ running.backgroundTasks = 0;
1066
1083
  running.costBaseUsd = running.costUsd; // next process starts from here
1067
1084
  // Persist the clock: a parked session can sit for hours and the runner
1068
1085
  // may be restarted before it ever runs again.
@@ -1301,6 +1318,11 @@ export class Supervisor {
1301
1318
  if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
1302
1319
  return;
1303
1320
  }
1321
+ // Not held — so any phantom still waiting from an earlier turn is stale and
1322
+ // must not outlive this ending. Cleared HERE and not before the decision
1323
+ // above, or `holdEmptyTurn`'s «two in a row is a quiet agent, not two
1324
+ // phantoms» rule could never see the first one (QA-2026-08-16 M-6).
1325
+ this.clearEmptyTurn(running);
1304
1326
  this.settleTurnStatus(running, descriptor, event);
1305
1327
  }
1306
1328
  /**
@@ -1364,7 +1386,7 @@ export class Supervisor {
1364
1386
  return false;
1365
1387
  log.info('supervisor: holding a turn that produced nothing', {
1366
1388
  sessionId: descriptor.id,
1367
- settleMs: Supervisor.EMPTY_TURN_SETTLE_MS,
1389
+ settleMs: this.emptyTurnSettleMs,
1368
1390
  });
1369
1391
  const timer = setTimeout(() => {
1370
1392
  running.emptyTurnTimer = undefined;
@@ -1374,7 +1396,7 @@ export class Supervisor {
1374
1396
  return;
1375
1397
  log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
1376
1398
  this.settleTurnStatus(running, descriptor, event);
1377
- }, Supervisor.EMPTY_TURN_SETTLE_MS);
1399
+ }, this.emptyTurnSettleMs);
1378
1400
  timer.unref();
1379
1401
  running.emptyTurnTimer = timer;
1380
1402
  return true;
@@ -1392,6 +1414,35 @@ export class Supervisor {
1392
1414
  clearTimeout(running.emptyTurnTimer);
1393
1415
  running.emptyTurnTimer = undefined;
1394
1416
  }
1417
+ /**
1418
+ * Record how many subagents are alive, and say so when it matters (#236).
1419
+ *
1420
+ * One place, because there are now two callers with opposite news — a frame
1421
+ * from the adapter, and the process going away — and «is it the human's turn»
1422
+ * must be answered the same way by both.
1423
+ *
1424
+ * The report on reaching zero is the whole point: the agent is already
1425
+ * sitting in a resting status, so nothing else will ever tell the API that
1426
+ * the session finally became the person's. `REVIEW` counts as well as
1427
+ * `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
1428
+ * same position, and leaving it out was how one of the two statuses kept its
1429
+ * notification suppressed for good (QA-2026-08-16 M-5).
1430
+ */
1431
+ setBackgroundTasks(running, live) {
1432
+ if (live === running.backgroundTasks)
1433
+ return;
1434
+ const finished = running.backgroundTasks > 0 && live === 0;
1435
+ running.backgroundTasks = live;
1436
+ const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
1437
+ ? running.lastReported
1438
+ : null;
1439
+ if (finished && resting) {
1440
+ this.reportStatus(running.descriptor.id, resting, {
1441
+ costUsd: running.costUsd,
1442
+ activeMs: Supervisor.spentMs(running),
1443
+ });
1444
+ }
1445
+ }
1395
1446
  isParkable(running) {
1396
1447
  return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
1397
1448
  running.openQuestions.size === 0 &&
@@ -1522,11 +1573,6 @@ export class Supervisor {
1522
1573
  // when it finally succeeds or finally gives up.
1523
1574
  if (!event.ok && this.armApiRetry(running, descriptor, event))
1524
1575
  return;
1525
- // A previous turn's phantom, if any, is over: this turn has ended for
1526
- // real, and a timer still holding its predecessor would report a status
1527
- // for a turn that is two turns old (#300 — the hold itself lives in
1528
- // `completeTurn`, where the decision belongs).
1529
- this.clearEmptyTurn(running);
1530
1576
  this.completeTurn(running, descriptor, event);
1531
1577
  return;
1532
1578
  }
@@ -1757,21 +1803,7 @@ export class Supervisor {
1757
1803
  * turn (`endTaskTurn` keeps the live set).
1758
1804
  */
1759
1805
  const live = event.tasks.filter((task) => task.status === 'running').length;
1760
- if (live !== running.backgroundTasks) {
1761
- const wasWaiting = running.backgroundTasks > 0 && live === 0;
1762
- running.backgroundTasks = live;
1763
- // The moment the last subagent finishes is the moment the session
1764
- // really does become the human's — and the agent is sitting in
1765
- // WAITING_INPUT, so nothing else will ever say so. `reportStatus`
1766
- // carries the new number and the API turns it into the notification
1767
- // it withheld earlier.
1768
- if (wasWaiting && running.lastReported === 'WAITING_INPUT') {
1769
- this.reportStatus(running.descriptor.id, 'WAITING_INPUT', {
1770
- costUsd: running.costUsd,
1771
- activeMs: Supervisor.spentMs(running),
1772
- });
1773
- }
1774
- }
1806
+ this.setBackgroundTasks(running, live);
1775
1807
  return;
1776
1808
  }
1777
1809
  case 'notice': {
@@ -1884,7 +1916,20 @@ export class Supervisor {
1884
1916
  // instead of dropping it silently.
1885
1917
  log.warn('supervisor: message for unknown session — requesting descriptor', { sessionId });
1886
1918
  const queued = this.orphanMessages.get(sessionId) ?? [];
1887
- queued.push({ text, ...(attachments?.length ? { attachments } : {}) });
1919
+ /**
1920
+ * The name travels with the words (QA-2026-08-16 M-2).
1921
+ *
1922
+ * Without it the replay below took the message a SECOND time under no
1923
+ * name, and the API's redelivery of the row it queued for the same
1924
+ * `unknown_session` then looked brand new to `deliveredMessageIds` — the
1925
+ * agent got one instruction twice, in the one scenario that reliably
1926
+ * produces both copies.
1927
+ */
1928
+ queued.push({
1929
+ text,
1930
+ ...(attachments?.length ? { attachments } : {}),
1931
+ ...(messageId ? { messageId } : {}),
1932
+ });
1888
1933
  this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
1889
1934
  this.ws.send({ type: 'session_unknown', sessionId });
1890
1935
  return 'unknown_session';
@@ -2202,6 +2247,11 @@ export class Supervisor {
2202
2247
  this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
2203
2248
  }
2204
2249
  };
2250
+ // M-4 (QA-2026-08-16): a new turn is starting, so a phantom held from the
2251
+ // previous one must not fire 25 seconds from now and report WAITING_INPUT
2252
+ // over an agent that is working — the very lie #300 is about, and this time
2253
+ // with an irreversible notification behind it.
2254
+ this.clearEmptyTurn(running);
2205
2255
  if (running.session && !running.parkRequested) {
2206
2256
  // #252: the words this turn is actually running. `lastPrompt` deliberately
2207
2257
  // stays put — three older latches relaunch a PROCESS with it, and handing
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.45.0";
1
+ export declare const RUNNER_VERSION = "0.45.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.45.0';
2
+ export const RUNNER_VERSION = '0.45.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.45.0",
3
+ "version": "0.45.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",