@oar-sh/oar 0.9.20 → 0.9.22

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -88,6 +88,12 @@ export function createSessionWorkerSupervisor({
88
88
  // short enough that stopping a turn and immediately sending a new message
89
89
  // does not add 30s in front of the worker's boot.
90
90
  killBlockGraceMs = 3_000,
91
+ // A kill's block on new launches and deliveries for the session ended (its
92
+ // grace ran out, or the marker was cleared): (sessionId). The relay hands
93
+ // out what waited for it.
94
+ onKillBlockLifted = null,
95
+ setTimeoutImpl = setTimeout,
96
+ clearTimeoutImpl = clearTimeout,
91
97
  isPidAlive = defaultIsPidAlive,
92
98
  diagnosticPlanReference = null,
93
99
  log = null,
@@ -98,6 +104,8 @@ export function createSessionWorkerSupervisor({
98
104
 
99
105
  const pendingStarts = new Map();
100
106
  const lifecycleBySession = new Map();
107
+ // sessionId → the timer that reports the end of its kill block.
108
+ const killBlockTimers = new Map();
101
109
  const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
102
110
  const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
103
111
  const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
@@ -301,9 +309,35 @@ export function createSessionWorkerSupervisor({
301
309
  });
302
310
  }
303
311
 
312
+ function reportKillBlockLifted(sessionId) {
313
+ if (typeof onKillBlockLifted !== 'function') return;
314
+ try { onKillBlockLifted(sessionId); } catch {}
315
+ }
316
+
317
+ function clearKillBlockTimer(sessionId) {
318
+ const timer = killBlockTimers.get(sessionId);
319
+ if (!timer) return false;
320
+ clearTimeoutImpl(timer);
321
+ killBlockTimers.delete(sessionId);
322
+ return true;
323
+ }
324
+
304
325
  function markKilled(sessionId) {
305
326
  if (!normalizeSessionId(sessionId)) return null;
306
327
  const killed = setLifecycle(sessionId, { killedAtMs: nowMs() });
328
+ // The block itself is read off the clock (isKillBlocked); this timer only
329
+ // says when it ends, so the rows that waited go out then.
330
+ if (killGraceMs > 0 && typeof onKillBlockLifted === 'function') {
331
+ clearKillBlockTimer(sessionId);
332
+ const timer = setTimeoutImpl(() => {
333
+ killBlockTimers.delete(sessionId);
334
+ if (!isKillBlocked(sessionId)) reportKillBlockLifted(sessionId);
335
+ // A few ms past the grace: the timer's clock and nowMs() may differ
336
+ // by a tick.
337
+ }, killGraceMs + 5);
338
+ timer?.unref?.();
339
+ killBlockTimers.set(sessionId, timer);
340
+ }
307
341
  return toLifecycleSnapshot(sessionId, killed);
308
342
  }
309
343
 
@@ -489,10 +523,12 @@ export function createSessionWorkerSupervisor({
489
523
  patch.degradedAtMs = null;
490
524
  patch.uiState = 'white';
491
525
  }
526
+ const liftsKillBlock = resetKilledMarker && clearKillBlockTimer(sessionId);
492
527
  if (resetKilledMarker) {
493
528
  patch.killedAtMs = null;
494
529
  }
495
530
  const cleared = setLifecycle(sessionId, patch);
531
+ if (liftsKillBlock) reportKillBlockLifted(sessionId);
496
532
  return toLifecycleSnapshot(sessionId, cleared);
497
533
  }
498
534
 
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { URL } from 'url';
4
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
4
5
 
5
6
  function normalizeText(value) {
6
7
  const text = String(value || '').trim();
@@ -85,18 +86,21 @@ function queueSnapshotChanged(left, right) {
85
86
  || left.parkedCount !== right.parkedCount;
86
87
  }
87
88
 
88
- // The worker link heartbeats every ~10s (readyRefreshMs in
89
- // worker-websocket-link.mjs) and itself abandons a connection after 30s of
90
- // server silence (staleConnectionMs). Mirror that on this side: three missed
91
- // heartbeats and a retained socket no longer proves the worker is alive.
92
- const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
89
+ // The worker keepalive beats every 30 s while the worker is idle and every
90
+ // 10 s while it works (shared/worker-runtime/worker-keepalive.mjs; an older
91
+ // worker always every 10 s), and the link itself abandons a connection after
92
+ // 90 s of server silence (staleConnectionMs). Mirror that on this side: three
93
+ // missed idle beats and a retained socket no longer proves the worker is
94
+ // alive.
95
+ export const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 90_000;
93
96
 
94
97
  // Protocol features this relay understands, advertised in server.hello. A
95
98
  // worker that updated on disk before the relay restarted must not rely on
96
99
  // them otherwise: an older relay ignores worker.unready / hello ready:false
97
100
  // and treats a `steering-held` requeue as a failure (retry, backoff,
98
- // worker marked errored).
99
- export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
101
+ // worker marked errored). `idle-keepalive`: an idle worker may beat every
102
+ // 30 s; an older relay's liveness and CLI windows expect a beat every 10 s.
103
+ export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held', 'idle-keepalive']);
100
104
 
101
105
  export function createSessionWorkerWebSocketService({
102
106
  WebSocketServerImpl,
@@ -107,10 +111,30 @@ export function createSessionWorkerWebSocketService({
107
111
  noteWorkerHeartbeat = () => {},
108
112
  onDeliverySendFailed = async () => {},
109
113
  onWorkerSocketClosed = async () => {},
114
+ // The number of open worker sockets changed: (count). The CLI presence
115
+ // window is wider while a worker socket is open.
116
+ onSocketCountChange = () => {},
117
+ // Every queue snapshot the poll takes: ({pendingCount, processingCount,
118
+ // parkedCount}). The relay arms its worker prime while rows are pending.
119
+ onQueueSnapshot = () => {},
120
+ // The earliest future retry time of a pending row, in ms (null: none). A
121
+ // one-shot timer fires then, so a backoff or a usage-limit hold ends on
122
+ // time rather than at the next poll.
123
+ nextDueAtMs = () => null,
124
+ // That timer fired with a row due: the relay wakes its queue (prime, hand
125
+ // out, arm), since a row whose hold ended may have no worker yet.
126
+ onDue = () => {},
110
127
  requestWork = async () => null,
111
128
  // A worker stated its kind ("claude", "claude-api", ...) in a hello or a
112
129
  // ready: (sessionId, kind, pid). The registry keeps it.
113
130
  onWorkerKind = () => {},
131
+ // A worker stated the build id of the code it started on, in a hello or a
132
+ // ready: (sessionId, buildId, pid). The registry keeps it, so the relay can
133
+ // tell a worker on older code from one that runs what the relay runs.
134
+ onWorkerBuildId = () => {},
135
+ // The relay's own build id, sent in every server.hello so a worker can log
136
+ // that it runs other code; null leaves the key out.
137
+ buildId = null,
114
138
  // Liveness probe for a socket's worker process. A killed worker's socket
115
139
  // survives its process by an event-loop turn (the kill path blocks the loop,
116
140
  // so this service's timer runs before the socket close lands), and asking for
@@ -136,7 +160,13 @@ export function createSessionWorkerWebSocketService({
136
160
  const logDebug = typeof logger?.debug === 'function' ? logger.debug.bind(logger) : noop;
137
161
  const logWarn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noop;
138
162
 
163
+ const relayBuildId = normalizeBuildId(buildId);
164
+
139
165
  let interval = null;
166
+ let dueTimer = null;
167
+ // The retry time the armed due timer stands for.
168
+ let dueTimerForMs = null;
169
+ let stopped = false;
140
170
  let lastSnapshot = null;
141
171
  let attached = false;
142
172
 
@@ -145,10 +175,15 @@ export function createSessionWorkerWebSocketService({
145
175
  return Number.isFinite(value) ? value : Date.now();
146
176
  }
147
177
 
178
+ function noteSocketCount() {
179
+ try { onSocketCountChange(clients.size); } catch {}
180
+ }
181
+
148
182
  function removeSocket(socket, reason) {
149
183
  const meta = clientState.get(socket);
150
- clients.delete(socket);
184
+ const removed = clients.delete(socket);
151
185
  clientState.delete(socket);
186
+ if (removed) noteSocketCount();
152
187
  // Death-detection hook: a worker socket closing while its session owns
153
188
  // an in-flight processing row used to be silently forgotten, leaving
154
189
  // the row to the 600s stale sweep. The handler PID-probes and recovers.
@@ -212,11 +247,12 @@ export function createSessionWorkerWebSocketService({
212
247
  };
213
248
  }
214
249
 
215
- function emitQueueChanged(reason = 'queue-update') {
250
+ function emitQueueChanged(reason = 'queue-update', { skip = null } = {}) {
216
251
  const event = toQueueChangedEvent(reason);
217
252
  const payload = JSON.stringify(event);
218
253
  for (const socket of clients) {
219
254
  if (socket.readyState !== socket.OPEN) continue;
255
+ if (skip?.has(socket)) continue;
220
256
  try {
221
257
  socket.send(payload);
222
258
  } catch {
@@ -245,6 +281,32 @@ export function createSessionWorkerWebSocketService({
245
281
  try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
246
282
  }
247
283
 
284
+ // The build id a hello or ready states, kept and reported like the kind. A
285
+ // value that is malformed or too long is ignored rather than stored, since
286
+ // the worker side is not trusted to bound what it sends.
287
+ function noteWorkerBuildId(meta, payload) {
288
+ const workerBuildId = normalizeBuildId(payload?.buildId);
289
+ if (!workerBuildId || meta.workerBuildId === workerBuildId) return;
290
+ meta.workerBuildId = workerBuildId;
291
+ try { onWorkerBuildId(meta.sessionId, workerBuildId, meta.pid); } catch {}
292
+ }
293
+
294
+ // Both server.hello frames (on connection and the ack of worker.hello) carry
295
+ // the same fields; an unknown relay build id leaves its key out rather than
296
+ // sending null.
297
+ function serverHello(reason, sessionId) {
298
+ const event = {
299
+ type: 'server.hello',
300
+ reason,
301
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
302
+ queue: normalizeQueueSnapshot(queueCounts()),
303
+ sessionId,
304
+ timestamp: nowIso(),
305
+ };
306
+ if (relayBuildId) event.buildId = relayBuildId;
307
+ return event;
308
+ }
309
+
248
310
  function noteHeartbeat(meta, reason = 'worker-message') {
249
311
  if (!meta?.sessionId) return;
250
312
  try {
@@ -328,25 +390,115 @@ export function createSessionWorkerWebSocketService({
328
390
  }
329
391
  }
330
392
 
393
+ /** Hands a row to every ready socket; resolves to the sockets that got one. */
331
394
  async function maybeDeliverToReadyClients(reason = 'queue-update') {
395
+ const sockets = [];
332
396
  const deliveries = [];
333
397
  for (const socket of clients) {
334
398
  const meta = clientState.get(socket);
335
399
  if (!meta?.ready || meta.delivering) continue;
400
+ sockets.push(socket);
336
401
  deliveries.push(maybeDeliverToSocket(socket, reason));
337
402
  }
338
- if (!deliveries.length) return;
339
- await Promise.allSettled(deliveries);
403
+ const handed = new Set();
404
+ if (!deliveries.length) return handed;
405
+ const results = await Promise.allSettled(deliveries);
406
+ results.forEach((result, index) => {
407
+ if (result.status === 'fulfilled' && result.value === true) handed.add(sockets[index]);
408
+ });
409
+ return handed;
410
+ }
411
+
412
+ // Ready sockets get their rows first; then every other socket hears that
413
+ // the queue changed (a worker the relay does not count as ready, a steering
414
+ // one mid-turn, answers with a ready of its own). A socket just handed a
415
+ // row is busy with it and is not told.
416
+ async function deliverThenAnnounce(reason, { announce = true } = {}) {
417
+ const handed = await maybeDeliverToReadyClients(reason);
418
+ if (announce) emitQueueChanged(reason, { skip: handed });
419
+ return handed;
420
+ }
421
+
422
+ /**
423
+ * The queue may have work: a write, a gate that lifted, a retry come due.
424
+ * Hands rows out now instead of at the next poll.
425
+ */
426
+ async function notifyQueueChanged(reason = 'queue-update') {
427
+ if (stopped) return new Set();
428
+ lastSnapshot = normalizeQueueSnapshot(queueCounts());
429
+ // A turn that ended with nothing behind it: no row to hand out, so no
430
+ // dequeue attempt per ready socket and no announcement.
431
+ const handed = lastSnapshot.pendingCount > 0 ? await deliverThenAnnounce(reason) : new Set();
432
+ armDueTimer();
433
+ return handed;
340
434
  }
341
435
 
436
+ // The backstop. With nothing pending nothing can be handed out: no dequeue
437
+ // attempt, only the count change announced. With rows pending (a retry in
438
+ // backoff, a gate, a worker not yet up) every tick tries the ready sockets,
439
+ // as a net under the event-driven wakes.
342
440
  function monitorQueue() {
343
441
  const snapshot = normalizeQueueSnapshot(queueCounts());
442
+ try { onQueueSnapshot(snapshot); } catch {}
344
443
  const changed = queueSnapshotChanged(snapshot, lastSnapshot);
345
444
  lastSnapshot = snapshot;
346
- if (changed) {
445
+ if (snapshot.pendingCount > 0) {
446
+ void deliverThenAnnounce(changed ? 'queue-count-changed' : 'ready-check', { announce: changed });
447
+ } else if (changed) {
347
448
  emitQueueChanged('queue-count-changed');
348
449
  }
349
- void maybeDeliverToReadyClients(changed ? 'queue-count-changed' : 'ready-check');
450
+ armDueTimer();
451
+ }
452
+
453
+ const DUE_TIMER_MIN_MS = 50;
454
+ const DUE_TIMER_MAX_MS = 60_000;
455
+
456
+ function clearDueTimer() {
457
+ dueTimerForMs = null;
458
+ if (!dueTimer) return;
459
+ clearTimeout(dueTimer);
460
+ dueTimer = null;
461
+ }
462
+
463
+ /**
464
+ * One timer for the earliest future retry time. An armed timer for the same
465
+ * time is kept; any other arm clears the one before. A time further than a
466
+ * minute away is re-read after a minute.
467
+ */
468
+ function armDueTimer() {
469
+ if (stopped) {
470
+ clearDueTimer();
471
+ return;
472
+ }
473
+ // A timer whose time has come fires and re-arms itself: a poll in the
474
+ // same instant (its row no longer "future") must not clear it unfired.
475
+ if (dueTimer && dueTimerForMs !== null && dueTimerForMs <= nowMsSafe()) return;
476
+ let dueAt = null;
477
+ try { dueAt = nextDueAtMs(); } catch { dueAt = null; }
478
+ const dueMs = Number(dueAt);
479
+ if (dueAt === null || dueAt === undefined || !Number.isFinite(dueMs)) {
480
+ clearDueTimer();
481
+ return;
482
+ }
483
+ if (dueTimer && dueTimerForMs === dueMs) return;
484
+ clearDueTimer();
485
+ const untilDueMs = dueMs - nowMsSafe();
486
+ const delayMs = Math.min(DUE_TIMER_MAX_MS, Math.max(DUE_TIMER_MIN_MS, untilDueMs));
487
+ // A time further than the cap: this firing only re-reads.
488
+ const reachesDue = untilDueMs <= DUE_TIMER_MAX_MS;
489
+ dueTimerForMs = dueMs;
490
+ dueTimer = setTimeout(() => {
491
+ dueTimer = null;
492
+ dueTimerForMs = null;
493
+ if (stopped) return;
494
+ if (reachesDue) {
495
+ // Whatever came due may go now; a row that was claimed or cancelled
496
+ // meanwhile makes this a wake with nothing to do.
497
+ try { onDue('retry-due'); } catch {}
498
+ }
499
+ armDueTimer();
500
+ }, delayMs);
501
+ if (typeof dueTimer.unref === 'function') dueTimer.unref();
350
502
  }
351
503
 
352
504
  function rejectUpgrade(socket) {
@@ -396,15 +548,9 @@ export function createSessionWorkerWebSocketService({
396
548
  deliveryFailures: 0,
397
549
  });
398
550
  if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
551
+ noteSocketCount();
399
552
  touchCli();
400
- emitEvent(ws, {
401
- type: 'server.hello',
402
- reason: 'connected',
403
- capabilities: WORKER_PROTOCOL_CAPABILITIES,
404
- queue: normalizeQueueSnapshot(queueCounts()),
405
- sessionId: identity.sessionId,
406
- timestamp: nowIso(),
407
- });
553
+ emitEvent(ws, serverHello('connected', identity.sessionId));
408
554
 
409
555
  ws.on('message', (raw) => {
410
556
  touchCli();
@@ -420,19 +566,13 @@ export function createSessionWorkerWebSocketService({
420
566
  if (!bindSocketIdentity(ws, meta, payload)) return;
421
567
  meta.lastSeenAtMs = nowMsSafe();
422
568
  noteWorkerKind(meta, payload);
569
+ noteWorkerBuildId(meta, payload);
423
570
  // A worker holding deliveries (open question card, compaction) binds
424
571
  // its identity with hello but must not be handed work.
425
572
  meta.ready = payload.ready !== false;
426
573
  meta.lastHelloAt = nowIso();
427
574
  noteHeartbeat(meta, 'worker-hello');
428
- emitEvent(ws, {
429
- type: 'server.hello',
430
- reason: 'ack',
431
- capabilities: WORKER_PROTOCOL_CAPABILITIES,
432
- queue: normalizeQueueSnapshot(queueCounts()),
433
- sessionId: meta.sessionId,
434
- timestamp: nowIso(),
435
- });
575
+ emitEvent(ws, serverHello('ack', meta.sessionId));
436
576
  void maybeDeliverToSocket(ws, 'worker-hello');
437
577
  return;
438
578
  }
@@ -440,10 +580,16 @@ export function createSessionWorkerWebSocketService({
440
580
  if (!bindSocketIdentity(ws, meta, payload)) return;
441
581
  meta.lastSeenAtMs = nowMsSafe();
442
582
  noteWorkerKind(meta, payload);
583
+ noteWorkerBuildId(meta, payload);
443
584
  meta.ready = true;
444
585
  meta.lastReadyAt = nowIso();
445
- noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
446
- void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
586
+ const readyReason = String(payload.reason || 'worker-ready');
587
+ noteHeartbeat(meta, readyReason);
588
+ // A keepalive's ready with nothing pending has nothing to draw: one
589
+ // count instead of a dequeue attempt. Any row that appears wakes the
590
+ // queue, and this socket stays ready for it.
591
+ if (readyReason === 'readiness-refresh' && normalizeQueueSnapshot(queueCounts()).pendingCount === 0) return;
592
+ void maybeDeliverToSocket(ws, readyReason);
447
593
  return;
448
594
  }
449
595
  if (payload.type === 'worker.unready') {
@@ -502,6 +648,7 @@ export function createSessionWorkerWebSocketService({
502
648
  }
503
649
 
504
650
  function start() {
651
+ stopped = false;
505
652
  if (!attached) {
506
653
  httpServer.on('upgrade', onUpgrade);
507
654
  wss.on('connection', onConnection);
@@ -513,10 +660,13 @@ export function createSessionWorkerWebSocketService({
513
660
  }
514
661
  lastSnapshot = normalizeQueueSnapshot(queueCounts());
515
662
  emitQueueChanged('started');
663
+ armDueTimer();
516
664
  logDebug(`[worker-ws] listening on ${wsPath}`);
517
665
  }
518
666
 
519
667
  function stop() {
668
+ stopped = true;
669
+ clearDueTimer();
520
670
  if (interval) {
521
671
  clearInterval(interval);
522
672
  interval = null;
@@ -530,6 +680,7 @@ export function createSessionWorkerWebSocketService({
530
680
  }
531
681
  clients.clear();
532
682
  clientState.clear();
683
+ noteSocketCount();
533
684
  try { wss.close(); } catch (error) {
534
685
  logWarn(`[worker-ws] close failed: ${error?.message || error}`);
535
686
  }
@@ -586,6 +737,7 @@ export function createSessionWorkerWebSocketService({
586
737
  stop,
587
738
  status,
588
739
  emitQueueChanged,
740
+ notifyQueueChanged,
589
741
  emitDraining,
590
742
  handleUpgrade,
591
743
  sendControlToSession,
@@ -171,6 +171,9 @@ export function createSshTunnelManager({
171
171
  io = null,
172
172
  logger = console,
173
173
  runtimeShutdownRef = () => false,
174
+ // The tunnel stopped blocking queue delivery (it connected): the relay
175
+ // hands out what waited for it.
176
+ onBlockingLifted = () => {},
174
177
  platform = process.platform,
175
178
  spawnImpl = spawn,
176
179
  nowIso = () => new Date().toISOString(),
@@ -225,7 +228,11 @@ export function createSshTunnelManager({
225
228
  };
226
229
 
227
230
  const updateBlockingState = () => {
231
+ const wasBlocking = state.blocking;
228
232
  state.blocking = state.required && state.mode === 'managed' && !state.connected;
233
+ if (wasBlocking && !state.blocking) {
234
+ try { onBlockingLifted(); } catch {}
235
+ }
229
236
  };
230
237
 
231
238
  const scheduleReconnect = (spawnTunnel, delayOverrideMs = null) => {