@bridge4dev/runner 0.58.1 → 0.59.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.
@@ -1798,10 +1798,17 @@ class ClaudeSession {
1798
1798
  // as work. The id is forgotten when that task settles instead, which is the
1799
1799
  // moment it stops being able to come back.
1800
1800
  this.turnEpoch += 1;
1801
- // Straight through `flushTasks` rather than an empty frame of its own: the
1802
- // counters changed, so the fingerprint differs and it will publish — and
1803
- // what it publishes is the truth about what is still running.
1801
+ // Straight through `flushTasks` rather than an empty frame of its own, and
1802
+ // with the de-duplication disarmed for this one frame (plan
1803
+ // `workflow-mode-fixes` S1 p.10). The turn's counters usually changed, but
1804
+ // not always: a task started in an earlier turn and still running, and a
1805
+ // turn that started nothing new, produce exactly the frame the last turn
1806
+ // ended on — and the end of a turn is the moment the API and the tray most
1807
+ // need to hear what is still running, whether or not it is news. The
1808
+ // supervisor de-duplicates the COUNT on its own side; this frame is the
1809
+ // tray's freshness, not the database's.
1804
1810
  this.taskPublishedAt = 0;
1811
+ this.lastTaskFingerprint = '';
1805
1812
  this.flushTasks();
1806
1813
  }
1807
1814
  /**
package/dist/policy.js CHANGED
@@ -802,7 +802,9 @@ export function evaluateGitPolicy(command, ctx) {
802
802
  if (policy.pushBanned) {
803
803
  return {
804
804
  decision: 'deny',
805
- reason: 'git push is not allowed — a human presses «Push» in the Git panel. (This project has «Принудительно запретить push» switched on.)',
805
+ reason:
806
+ // eslint-disable-next-line @devbridge/no-cyrillic-ui-text -- quotes the Russian NAME of a project setting; it has to match the dashboard word for word (#269 leaves it alone on purpose).
807
+ 'git push is not allowed — a human presses «Push» in the Git panel. (This project has «Принудительно запретить push» switched on.)',
806
808
  };
807
809
  }
808
810
  // Everything below exists only once pushing is permitted. With the shipped
@@ -228,12 +228,12 @@ export function inspectPattern(pattern) {
228
228
  const worst = Math.max(...heavy.map((p) => p.bound ?? 0));
229
229
  return {
230
230
  dangerous: true,
231
- reason: `Поиск остановлен: счётчик {…,${worst}} рядом с ещё одним участком переменной длины. ` +
232
- `Встроенный в Claude Code движок на такой форме занимает ~5.4 ГБ и ~67 секунд ещё ` +
233
- `на разборе шаблона — это роняет дев-сервер и рвёт связь по всем сессиям машины ` +
234
- `(подробности: docs/standards/project-gotchas.md §345). ` +
235
- `Повтори через «command grep» — системный grep обрабатывает этот шаблон нормально. ` +
236
- `Либо уменьши границу счётчика ниже ${QUANTIFIER_DANGER_BOUND}.`,
231
+ reason: `Search blocked: quantifier {…,${worst}} sits next to another variable-length part. ` +
232
+ `On a pattern like this the engine built into Claude Code needs ~5.4 GB and ~67 seconds ` +
233
+ `just to parse it, and that takes the dev server down and drops every session on that ` +
234
+ `machine (details: docs/standards/project-gotchas.md §345). ` +
235
+ `Run it again with command grep: the system grep handles this pattern fine. ` +
236
+ `Or lower the quantifier bound below ${QUANTIFIER_DANGER_BOUND}.`,
237
237
  };
238
238
  }
239
239
  /**
@@ -210,7 +210,30 @@ export interface SupervisorOptions {
210
210
  readScopeHold?: (unit: string) => ScopeHold | null;
211
211
  /** #398 S4: the heartbeat for the memory frame. Real one is a minute. */
212
212
  sessionLimitsHeartbeatMs?: number;
213
+ /**
214
+ * S1 of plan `workflow-mode-fixes`: how often a resting session re-asserts
215
+ * a background count it still holds — a test seam over
216
+ * `BACKGROUND_TASKS_HEARTBEAT_MS`. Real one is five minutes.
217
+ */
218
+ backgroundTasksHeartbeatMs?: number;
213
219
  }
220
+ /**
221
+ * How often a resting session says «I still hold N background tasks» — the
222
+ * same `session_status` frame, the same status, sent again (plan
223
+ * `workflow-mode-fixes` S1 p.9).
224
+ *
225
+ * The count is a level the API stamps on arrival, and every reader over there
226
+ * — the workflow gate, its reminder, the «Agents» badge, the Inbox — believes
227
+ * it for an hour from that stamp. A task that simply runs (a forty-minute
228
+ * build) changes nothing in the tray, so nothing else would ever send the
229
+ * count again, and the platform would stop believing a live task after an
230
+ * hour. This is the frame that keeps it believed for as long as it is true.
231
+ *
232
+ * Mirrored by value in `@devbridge/shared` (`constants/dev-sessions.ts`),
233
+ * which this package cannot import; `supervisor.test.ts` holds the two copies
234
+ * together and keeps this well inside the API's trust window.
235
+ */
236
+ export declare const BACKGROUND_TASKS_HEARTBEAT_MS: number;
214
237
  export declare class Supervisor {
215
238
  private readonly ws;
216
239
  private readonly opts;
@@ -438,6 +461,14 @@ export declare class Supervisor {
438
461
  private publishSlots;
439
462
  /** The heartbeat window actually used — the constant, or a test's own. */
440
463
  private readonly hostLoadHeartbeatMs;
464
+ /** S1 p.9: how often a held background count is said again. */
465
+ private readonly backgroundTasksHeartbeatMs;
466
+ /**
467
+ * The API has answered this socket's `hello` (S1 p.9). Frames sent before
468
+ * that are closed with 4002; the reconnect path re-sends every status in
469
+ * `reconcile` anyway, so the heartbeat has nothing to say until then.
470
+ */
471
+ private helloAcked;
441
472
  private readonly hostLoadTimer;
442
473
  /** #398 S2: the stall detector's clock, and the deadline it enforces. */
443
474
  private readonly stallTimer;
@@ -655,6 +686,28 @@ export declare class Supervisor {
655
686
  */
656
687
  private idleSessionCeiling;
657
688
  private publishSessionLimits;
689
+ /**
690
+ * Re-assert the background count of every resting session that still holds
691
+ * one (S1 p.9) — the same `session_status`, the same status, sent again.
692
+ *
693
+ * Only at rest, and only in the two statuses somebody over there reads the
694
+ * age of the number in: `REVIEW` and `WAITING_INPUT`. NOT in
695
+ * `WAITING_PERMISSION` — after a denied card `lastReported` stays there
696
+ * until the turn ends while the API has already healed its row to `RUNNING`,
697
+ * and repeating the old status would put the row back to «waiting for
698
+ * permission» with no card behind it, a fresh `statusChangedAt` and a
699
+ * needless nudge of the workflow engine. Not mid-turn either: a turn ends
700
+ * with a status frame of its own.
701
+ *
702
+ * Straight through `reportStatus`, past the equality check in
703
+ * `setBackgroundTasks`: that one exists to stay quiet when nothing changed,
704
+ * and «nothing changed» is exactly what this frame is for. Measured from the
705
+ * last status frame of ANY kind — every one carries the count — so a session
706
+ * that just reported is not reported twice, and a reconnect (which re-sends
707
+ * every tracked session's status in `reconcile`) restarts the five minutes
708
+ * by itself.
709
+ */
710
+ private publishBackgroundHeartbeats;
658
711
  private publishHostLoad;
659
712
  private onFrame;
660
713
  private startSession;
@@ -80,6 +80,23 @@ function freshLevels() {
80
80
  };
81
81
  }
82
82
  const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
83
+ /**
84
+ * How often a resting session says «I still hold N background tasks» — the
85
+ * same `session_status` frame, the same status, sent again (plan
86
+ * `workflow-mode-fixes` S1 p.9).
87
+ *
88
+ * The count is a level the API stamps on arrival, and every reader over there
89
+ * — the workflow gate, its reminder, the «Agents» badge, the Inbox — believes
90
+ * it for an hour from that stamp. A task that simply runs (a forty-minute
91
+ * build) changes nothing in the tray, so nothing else would ever send the
92
+ * count again, and the platform would stop believing a live task after an
93
+ * hour. This is the frame that keeps it believed for as long as it is true.
94
+ *
95
+ * Mirrored by value in `@devbridge/shared` (`constants/dev-sessions.ts`),
96
+ * which this package cannot import; `supervisor.test.ts` holds the two copies
97
+ * together and keeps this well inside the API's trust window.
98
+ */
99
+ export const BACKGROUND_TASKS_HEARTBEAT_MS = 5 * 60 * 1000;
83
100
  export class Supervisor {
84
101
  ws;
85
102
  opts;
@@ -177,6 +194,9 @@ export class Supervisor {
177
194
  ws.on('frame', (frame) => {
178
195
  void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
179
196
  });
197
+ ws.on('close', () => {
198
+ this.helloAcked = false;
199
+ });
180
200
  /**
181
201
  * The seat report is on a heartbeat, not only on the events that change it.
182
202
  *
@@ -233,8 +253,14 @@ export class Supervisor {
233
253
  * actually moved (or the heartbeat came due).
234
254
  */
235
255
  this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
256
+ this.backgroundTasksHeartbeatMs =
257
+ opts.backgroundTasksHeartbeatMs ?? BACKGROUND_TASKS_HEARTBEAT_MS;
236
258
  this.hostLoadTimer = setInterval(() => {
237
259
  this.publishHostLoad();
260
+ // The fourth thing only a tick can notice (S1 p.9): a background task
261
+ // that is simply still running produces no event, and the API's belief
262
+ // in the count runs out an hour after it was last said.
263
+ this.publishBackgroundHeartbeats();
238
264
  // Same cadence, same reason: what a session's cgroup is going through is
239
265
  // something that happens to it, and only a tick can notice (#387).
240
266
  this.watchSessionCages();
@@ -641,6 +667,14 @@ export class Supervisor {
641
667
  }
642
668
  /** The heartbeat window actually used — the constant, or a test's own. */
643
669
  hostLoadHeartbeatMs;
670
+ /** S1 p.9: how often a held background count is said again. */
671
+ backgroundTasksHeartbeatMs;
672
+ /**
673
+ * The API has answered this socket's `hello` (S1 p.9). Frames sent before
674
+ * that are closed with 4002; the reconnect path re-sends every status in
675
+ * `reconcile` anyway, so the heartbeat has nothing to say until then.
676
+ */
677
+ helloAcked = false;
644
678
  hostLoadTimer;
645
679
  /** #398 S2: the stall detector's clock, and the deadline it enforces. */
646
680
  stallTimer;
@@ -1531,6 +1565,58 @@ export class Supervisor {
1531
1565
  this.lastLimitsSentAt = measuredAt;
1532
1566
  }
1533
1567
  }
1568
+ /**
1569
+ * Re-assert the background count of every resting session that still holds
1570
+ * one (S1 p.9) — the same `session_status`, the same status, sent again.
1571
+ *
1572
+ * Only at rest, and only in the two statuses somebody over there reads the
1573
+ * age of the number in: `REVIEW` and `WAITING_INPUT`. NOT in
1574
+ * `WAITING_PERMISSION` — after a denied card `lastReported` stays there
1575
+ * until the turn ends while the API has already healed its row to `RUNNING`,
1576
+ * and repeating the old status would put the row back to «waiting for
1577
+ * permission» with no card behind it, a fresh `statusChangedAt` and a
1578
+ * needless nudge of the workflow engine. Not mid-turn either: a turn ends
1579
+ * with a status frame of its own.
1580
+ *
1581
+ * Straight through `reportStatus`, past the equality check in
1582
+ * `setBackgroundTasks`: that one exists to stay quiet when nothing changed,
1583
+ * and «nothing changed» is exactly what this frame is for. Measured from the
1584
+ * last status frame of ANY kind — every one carries the count — so a session
1585
+ * that just reported is not reported twice, and a reconnect (which re-sends
1586
+ * every tracked session's status in `reconcile`) restarts the five minutes
1587
+ * by itself.
1588
+ */
1589
+ publishBackgroundHeartbeats() {
1590
+ // `helloAcked` and not merely `connected`: the socket is open from the
1591
+ // `open` event, but the gateway answers any frame that reaches it before
1592
+ // its own `hello` bookkeeping is done with a close (4002) — a window of
1593
+ // tens of milliseconds on every reconnect that a tick can land in.
1594
+ if (!this.ws.connected || !this.helloAcked)
1595
+ return;
1596
+ const now = Date.now();
1597
+ for (const running of this.sessions.values()) {
1598
+ if (running.backgroundTasks <= 0)
1599
+ continue;
1600
+ // Only a session with a process behind it. The count is zeroed when the
1601
+ // process is found gone, but not on every path that loses one: a
1602
+ // relaunch that crashed leaves the entry with `session = null` and the
1603
+ // count it was holding, and re-asserting that would be the platform
1604
+ // confirming work nobody is doing.
1605
+ if (!running.session)
1606
+ continue;
1607
+ if (running.lastReported !== 'REVIEW' && running.lastReported !== 'WAITING_INPUT')
1608
+ continue;
1609
+ const sinceLastSent = now - (running.statusSentAt ?? 0);
1610
+ // A backwards clock step is «due», not «early» — same allowance as the
1611
+ // host-load heartbeat, for the same reason.
1612
+ if (sinceLastSent < this.backgroundTasksHeartbeatMs && sinceLastSent >= 0)
1613
+ continue;
1614
+ this.reportStatus(running.descriptor.id, running.lastReported, {
1615
+ costUsd: running.costUsd,
1616
+ activeMs: Supervisor.spentMs(running),
1617
+ });
1618
+ }
1619
+ }
1534
1620
  publishHostLoad() {
1535
1621
  const sample = (this.opts.readHostLoad ?? readHostLoad)();
1536
1622
  if (!sample)
@@ -1549,6 +1635,7 @@ export class Supervisor {
1549
1635
  async onFrame(frame) {
1550
1636
  switch (frame.type) {
1551
1637
  case 'hello_ack':
1638
+ this.helloAcked = true;
1552
1639
  // A new connection knows nothing about the seats we reported to the
1553
1640
  // last one — the API keeps that beside the socket, not in the database,
1554
1641
  // because it is only true while the socket is. Forget what we told the
@@ -6978,6 +7065,10 @@ export class Supervisor {
6978
7065
  // would mean it goes on claiming background work forever.
6979
7066
  ...(running ? { backgroundTasks: running.backgroundTasks } : {}),
6980
7067
  });
7068
+ // …and the count's age starts here (S1 p.9): the background heartbeat is
7069
+ // due five minutes after the last frame that carried it, whichever it was.
7070
+ if (running)
7071
+ running.statusSentAt = Date.now();
6981
7072
  // Every seat change is accompanied by a status report — a session starting,
6982
7073
  // parking, ending. Hooking the seat report here rather than at each of those
6983
7074
  // is the whole point: «remember to also tell the API» is the rule that had
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.58.1";
1
+ export declare const RUNNER_VERSION = "0.59.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.58.1';
2
+ export const RUNNER_VERSION = '0.59.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.58.1",
3
+ "version": "0.59.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",