@oar-sh/oar 0.9.21 → 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 (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
@@ -36,6 +36,7 @@ import { applySchema } from './db-schema.mjs';
36
36
  import { createSessionRepository } from './repositories/session-repository.mjs';
37
37
  import { createMessageRepository } from './repositories/message-repository.mjs';
38
38
  import { createQuestionRepository } from './repositories/question-repository.mjs';
39
+ import { mapRelayActivityRow, mapRelayThoughtRow, sanitizeActivityText } from './repositories/relay-turn-rows.mjs';
39
40
  import { createImageConversationRepository } from './repositories/image-conversation-repository.mjs';
40
41
  import { registerSessionsRoutes } from './routes/sessions-routes.mjs';
41
42
  import { buildDequeuedRelayMessage, claudeApiRemedyText, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
@@ -233,6 +234,9 @@ import {
233
234
  import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
234
235
  import { createWorkerScope } from './services/worker-scope.mjs';
235
236
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
237
+ import { createArmableInterval, createCliPresence } from './services/idle-timers.mjs';
238
+ import { installQueueWakeHook } from './services/queue-wake-hook.mjs';
239
+ import { createPendingWorkerPrimer, createQueueWaker } from './services/queue-waker.mjs';
236
240
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
237
241
  import {
238
242
  STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
@@ -270,7 +274,11 @@ import {
270
274
  readTaskStallMinutesSetting,
271
275
  } from '../shared/task-output-text.mjs';
272
276
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
273
- import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
277
+ import {
278
+ capFinishedActivityEntries,
279
+ capRelayActivityEntries,
280
+ inFlightActivityCap,
281
+ } from './public/app/activity-replay-state.mjs';
274
282
  import {
275
283
  isOpenAIModelId,
276
284
  isSafeClaudeModelId,
@@ -2005,12 +2013,9 @@ let runtimeShutdownStarted = false;
2005
2013
  // Set once the database is open (see the retention sweep near the other sweeps).
2006
2014
  let subagentRecordRetention = null;
2007
2015
  const runtimeTimers = {
2008
- cliStatus: null,
2009
2016
  ownerWatchdog: null,
2010
2017
  staleRecovery: null,
2011
2018
  questionExpiry: null,
2012
- pendingWorkerPrime: null,
2013
- sharedViewerPrune: null,
2014
2019
  restoredTaskSettle: null,
2015
2020
  claudeApiCapClock: null,
2016
2021
  memoryLog: null,
@@ -2019,6 +2024,25 @@ const runtimeTimers = {
2019
2024
  archivedWorkerSweep: null,
2020
2025
  };
2021
2026
 
2027
+ // The worker prime and the detached-worker sweep, every 5 s while a pending
2028
+ // row waits for its worker (rows in retry backoff included, so the worker is
2029
+ // warm when the backoff matures). Armed at startup, by every prime that finds
2030
+ // a row and by every queue snapshot with a row pending; a pass that leaves no
2031
+ // such row disarms it. Declared up here so a prime early in the boot can arm
2032
+ // it; its tick runs only after the boot.
2033
+ const pendingWorkerPrimeTimer = createArmableInterval({
2034
+ intervalMs: 5_000,
2035
+ tick: async () => {
2036
+ await Promise.allSettled([
2037
+ primePendingSessionWorkers('maintenance'),
2038
+ detachedWorkerWatch.sweep().catch((error) => {
2039
+ console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
2040
+ }),
2041
+ ]);
2042
+ return hasPendingWorkerOwnerRows();
2043
+ },
2044
+ });
2045
+
2022
2046
  function normalizeRemotePath(value) {
2023
2047
  if (typeof value !== 'string') return '';
2024
2048
  const trimmed = value.trim();
@@ -2861,6 +2885,16 @@ const stmts = {
2861
2885
  ...createQuestionRepository(db),
2862
2886
  ...createImageConversationRepository(db),
2863
2887
  };
2888
+ // Every queue write that can make a row deliverable wakes the delivery
2889
+ // (services/queue-wake-hook.mjs): installed before any route or startup
2890
+ // write. The waker exists once the worker socket service does; a write
2891
+ // before that is covered by the startup prime and the service's start.
2892
+ // Without the hook (it could not be installed) the queue is polled.
2893
+ let queueWaker = null;
2894
+ const queueWakeHook = installQueueWakeHook(db, {
2895
+ onWake: (reason) => { queueWaker?.wake(reason, { prime: false }); },
2896
+ log: (line) => console.warn(`[queue-wake] ${line}`),
2897
+ });
2864
2898
  const statusEventService = createStatusEventService(db);
2865
2899
  // Public shares and their access log (migration 0016).
2866
2900
  const shareRepository = createShareRepository(db);
@@ -3422,6 +3456,8 @@ const sessionWorkerSupervisor = createSessionWorkerSupervisor({
3422
3456
  diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
3423
3457
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3424
3458
  onLaunchState: (event) => workerLaunchNoteService.handle(event),
3459
+ // A kill's grace ended: the session's rows may go to its next worker.
3460
+ onKillBlockLifted: () => { wakeSessionWorkerQueue('kill-block-lifted'); },
3425
3461
  });
3426
3462
  // Feature flags moved from config.json into app_settings; adopt any legacy
3427
3463
  // `features` key once, then resolve the boot snapshot (defaults < stored rows
@@ -3822,7 +3858,11 @@ function relayShutdownStatePayload() {
3822
3858
 
3823
3859
  function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3824
3860
  const result = relayRestartQueue.cancel({ requestedBy });
3825
- if (result?.cancelled) relayEventService?.noteRestartCancelled();
3861
+ if (result?.cancelled) {
3862
+ relayEventService?.noteRestartCancelled();
3863
+ // The queued restart held deliveries back: they go out now.
3864
+ wakeSessionWorkerQueue('restart-cancelled');
3865
+ }
3826
3866
  return result;
3827
3867
  }
3828
3868
 
@@ -5391,52 +5431,28 @@ function createCompactedConversation(sourceConversationId) {
5391
5431
  };
5392
5432
  }
5393
5433
 
5394
- function sanitizeActivityText(value) {
5395
- const text = String(value || '').trim();
5396
- if (!text) return '';
5397
- return text.slice(0, 200);
5398
- }
5399
-
5400
- function mapRelayActivityRow(row) {
5401
- const text = sanitizeActivityText(row?.text);
5402
- if (!text) return null;
5403
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5404
- // Structured rows (compaction boundaries) carry their payload alongside the
5405
- // prose so the transcript can promote them to a break row; junk JSON is
5406
- // simply dropped back to a prose-only entry.
5407
- let metadata = null;
5408
- if (row?.metadata_json) {
5409
- try {
5410
- const parsed = JSON.parse(String(row.metadata_json));
5411
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
5412
- } catch { metadata = null; }
5413
- }
5414
- return {
5415
- text,
5416
- subagentRunId: subagentRunId || null,
5417
- ...(metadata ? { metadata } : {}),
5418
- };
5419
- }
5420
-
5421
- const RELAY_ACTIVITY_ROW_LIMIT = 48;
5422
-
5434
+ // Per thread (the main agent, each sub-agent), the newest rows; structured
5435
+ // rows are always kept (activity-replay-state.mjs). A finished turn: the main
5436
+ // agent's 200, each sub-agent's 50, and a turn whose main agent ran more says
5437
+ // how many it left out.
5423
5438
  function relayActivityForResponse(responseMessageId) {
5424
- return capRelayActivityEntries(
5439
+ return capFinishedActivityEntries(
5425
5440
  stmts.listActivityByResponse
5426
5441
  .all(responseMessageId)
5427
5442
  .map(mapRelayActivityRow)
5428
5443
  .filter(Boolean),
5429
- RELAY_ACTIVITY_ROW_LIMIT,
5430
5444
  );
5431
5445
  }
5432
5446
 
5433
- function relayActivityForQueueMessage(queueMessageId) {
5447
+ // A running turn: as many per thread as the browser shows (`activityCap`, the
5448
+ // request's param; 50 when it sends none, 500 at most).
5449
+ function relayActivityForQueueMessage(queueMessageId, activityCap = undefined) {
5434
5450
  return capRelayActivityEntries(
5435
5451
  stmts.listActivityByQueueMessage
5436
5452
  .all(queueMessageId)
5437
5453
  .map(mapRelayActivityRow)
5438
5454
  .filter(Boolean),
5439
- RELAY_ACTIVITY_ROW_LIMIT,
5455
+ inFlightActivityCap(activityCap),
5440
5456
  );
5441
5457
  }
5442
5458
 
@@ -5458,19 +5474,6 @@ function relayStreamEventsForQueueMessage(queueMessageId) {
5458
5474
  .slice(0, 5000);
5459
5475
  }
5460
5476
 
5461
- function mapRelayThoughtRow(row) {
5462
- const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
5463
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5464
- return {
5465
- reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
5466
- seq,
5467
- text: String(row?.text || ''),
5468
- done: Number(row?.done || 0) === 1,
5469
- timestamp: row?.created_at || null,
5470
- subagentRunId: subagentRunId || null,
5471
- };
5472
- }
5473
-
5474
5477
  function relayThoughtsForResponse(responseMessageId) {
5475
5478
  const rows = stmts.listThoughtsByResponse?.all(responseMessageId) || [];
5476
5479
  return normalizeRelayThoughtList(rows.map(mapRelayThoughtRow)).slice(0, 5000);
@@ -5654,7 +5657,7 @@ const usageLimitPauseService = createUsageLimitPauseService({
5654
5657
  stmts,
5655
5658
  emit: (event, payload) => io.emit(event, payload),
5656
5659
  // A follow-up resumed by hand is due at once: wake its worker.
5657
- onQueueChanged: (reason) => { void primePendingSessionWorkers(reason); },
5660
+ onQueueChanged: (reason) => { wakeSessionWorkerQueue(reason); },
5658
5661
  logger: console,
5659
5662
  });
5660
5663
 
@@ -5686,8 +5689,7 @@ const claudeBillingSwitchService = createClaudeBillingSwitchService({
5686
5689
  // What the swap waits for: a turn, a question, a compaction, background tasks.
5687
5690
  getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
5688
5691
  onQueueChanged: (reason) => {
5689
- void primePendingSessionWorkers(reason);
5690
- sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
5692
+ wakeSessionWorkerQueue(reason);
5691
5693
  },
5692
5694
  logger: console,
5693
5695
  });
@@ -5735,8 +5737,7 @@ const restartResumeService = createRestartResumeService({
5735
5737
  // The note is an ordinary pending message: the primer starts a stopped
5736
5738
  // worker, the socket service hands it to a running one.
5737
5739
  wake: () => {
5738
- void primePendingSessionWorkers('restart-resume');
5739
- sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
5740
+ wakeSessionWorkerQueue('restart-resume');
5740
5741
  },
5741
5742
  logger: console,
5742
5743
  });
@@ -5766,8 +5767,7 @@ relayEventService = createRelayEventService({
5766
5767
  // The event row is an ordinary pending message: the primer starts a
5767
5768
  // stopped worker, the socket service hands it to a running one.
5768
5769
  wakeWatcher: () => {
5769
- void primePendingSessionWorkers('relay-event');
5770
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
5770
+ wakeSessionWorkerQueue('relay-event');
5771
5771
  },
5772
5772
  relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
5773
5773
  onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
@@ -5783,8 +5783,7 @@ const relayScheduleService = createRelayScheduleService({
5783
5783
  emit: (event, payload) => io.emit(event, payload),
5784
5784
  // The scheduled message is an ordinary pending one, like a relay event's.
5785
5785
  wake: () => {
5786
- void primePendingSessionWorkers('relay-schedule');
5787
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
5786
+ wakeSessionWorkerQueue('relay-schedule');
5788
5787
  },
5789
5788
  // A firing into another session is a prompt of the conversation that set
5790
5789
  // it: the agents' loop guard counts it (and holds it once it wants the user).
@@ -5847,7 +5846,9 @@ function resolveLiveTurnQueueRow(conversationId) {
5847
5846
  return byId.get(liveId) || rows[0];
5848
5847
  }
5849
5848
 
5850
- function inFlightStateForConversation(conversationId) {
5849
+ // `activityCap`: the tool lines per thread the asking page shows (the
5850
+ // conversation route passes its `activityCap` param; others get the default).
5851
+ function inFlightStateForConversation(conversationId, { activityCap = undefined } = {}) {
5851
5852
  const row = resolveLiveTurnQueueRow(conversationId);
5852
5853
  if (!row) return null;
5853
5854
  const streamEvents = relayStreamEventsForQueueMessage(row.id);
@@ -5859,7 +5860,7 @@ function inFlightStateForConversation(conversationId) {
5859
5860
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
5860
5861
  timestamp: row.timestamp || null,
5861
5862
  processingAt: row.processing_at || null,
5862
- activities: relayActivityForQueueMessage(row.id),
5863
+ activities: relayActivityForQueueMessage(row.id, activityCap),
5863
5864
  thoughts: relayThoughtsForQueueMessage(row.id),
5864
5865
  streamEvents,
5865
5866
  streamDone: !!lastStreamEvent?.done,
@@ -6392,6 +6393,15 @@ const SHARED_VIEWER_MAX_GLOBAL = Number.isFinite(Number(config.sharedPresenceMax
6392
6393
  ? Math.max(SHARED_VIEWER_MAX_PER_CONVERSATION, Math.trunc(Number(config.sharedPresenceMaxGlobal)))
6393
6394
  : 5_000;
6394
6395
  const sharedViewersByConversation = new Map();
6396
+ // Armed when the first share viewer arrives, disarmed by the prune that
6397
+ // leaves none: a relay nobody watches through a share link has no timer.
6398
+ const sharedViewerPruneTimer = createArmableInterval({
6399
+ intervalMs: 5_000,
6400
+ tick: () => {
6401
+ pruneSharedViewerPresence();
6402
+ return sharedViewersByConversation.size > 0;
6403
+ },
6404
+ });
6395
6405
 
6396
6406
  function sanitizeSharedViewerId(value) {
6397
6407
  const id = String(value || '').trim().replace(/[^a-zA-Z0-9:_-]+/g, '');
@@ -6532,6 +6542,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
6532
6542
  }
6533
6543
  const before = viewers.size;
6534
6544
  viewers.set(key, { lastSeenAt: now });
6545
+ sharedViewerPruneTimer.arm();
6535
6546
  const watcherCount = viewers.size;
6536
6547
  if (before !== watcherCount) {
6537
6548
  emitConversationWatcherCount(convId);
@@ -6808,7 +6819,7 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
6808
6819
  console.warn(`${runtimeLogPrefix()}WS REQUEUE ${messageId.slice(0, 8)} session=${requesterSessionId ? requesterSessionId.slice(0, 8) : 'unknown'} retry=${retryCount} reason=${String(reason || 'ws-send-failed')}`);
6809
6820
  io.emit('message_status', { messageId, conversationId: row.conversation_id, status: 'pending' });
6810
6821
  io.emit('queue_updated', { recovered: 1, reason: 'worker-ws-send-failed' });
6811
- sessionWorkerWebSocketService?.emitQueueChanged?.('worker-ws-send-failed');
6822
+ wakeSessionWorkerQueue('worker-ws-send-failed');
6812
6823
  return true;
6813
6824
  }
6814
6825
 
@@ -6898,11 +6909,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6898
6909
  }
6899
6910
  if (recovered > 0) {
6900
6911
  io.emit('queue_updated', { recovered, reason: `worker-died:${reason}` });
6901
- sessionWorkerWebSocketService?.emitQueueChanged?.(`worker-died:${reason}`);
6902
6912
  }
6903
6913
  sessionWorkerSupervisor?.markError?.(sid, `worker-died:${reason}`);
6904
6914
  // The rows stay owned, so the primer is what respawns their worker.
6905
- void primePendingSessionWorkers(`worker-died:${reason}`);
6915
+ wakeSessionWorkerQueue(`worker-died:${reason}`);
6906
6916
  return recovered;
6907
6917
  }
6908
6918
 
@@ -6981,7 +6991,19 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6981
6991
  // work. The worker recycle raises it; the billing swap does not, since its
6982
6992
  // own delivery fence already keeps the other billing's rows from the old
6983
6993
  // worker.
6984
- const workerReplacementFence = createWorkerReplacementFence();
6994
+ // A lifted fence (the replacement launched, or its stop failed and the old
6995
+ // worker serves again) hands out what waited behind it.
6996
+ const workerReplacementFence = (() => {
6997
+ const fence = createWorkerReplacementFence();
6998
+ return {
6999
+ ...fence,
7000
+ release(sid, pid) {
7001
+ const released = fence.release(sid, pid);
7002
+ if (released) wakeSessionWorkerQueue('replacement-fence-lifted');
7003
+ return released;
7004
+ },
7005
+ };
7006
+ })();
6985
7007
  function createRuntimeWorkerReplacement(tag) {
6986
7008
  return createWorkerReplacement({
6987
7009
  registry: sessionWorkerRegistry,
@@ -7288,6 +7310,21 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7288
7310
  },
7289
7311
  onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
7290
7312
  onWorkerSocketClosed: handleWorkerSocketClosed,
7313
+ // The CLI presence window is wider while a worker socket is open.
7314
+ onSocketCountChange: (count) => cliPresence.noteWorkerSocketCount(count),
7315
+ // A row pending (in backoff too) keeps the worker prime armed, and the
7316
+ // poll primes now and then while rows wait.
7317
+ onQueueSnapshot: (snapshot) => {
7318
+ if (snapshot.pendingCount > 0) queueWaker?.primeFromBackstop('backstop');
7319
+ },
7320
+ // A retry time or a usage-limit hold coming due wakes the queue on time.
7321
+ nextDueAtMs: () => {
7322
+ if (runtimeShutdownStarted) return null;
7323
+ const due = stmts.nextPendingAttemptAt?.get?.(new Date().toISOString())?.due;
7324
+ const dueMs = due ? Date.parse(due) : NaN;
7325
+ return Number.isFinite(dueMs) ? dueMs : null;
7326
+ },
7327
+ onDue: (reason) => { wakeSessionWorkerQueue(reason); },
7291
7328
  requestWork: requestSessionWorkerSocketDelivery,
7292
7329
  // What a worker says it is ("claude", "claude-api", ...): the registry
7293
7330
  // keeps it for the billing fence and for what a response ran on.
@@ -7309,9 +7346,23 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7309
7346
  buildId: BUILD_ID,
7310
7347
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
7311
7348
  pathPrefix: remotePath,
7312
- pollIntervalMs: 500,
7349
+ // A backstop under the queue's own wakes; the poll every 500 ms it was
7350
+ // when those cannot be installed.
7351
+ pollIntervalMs: queueWakeHook.installed ? 10_000 : 500,
7313
7352
  logger: console,
7314
7353
  });
7354
+ let queueWakeStarted = false;
7355
+ queueWaker = createQueueWaker({
7356
+ prime: (reason) => primePendingSessionWorkers(reason),
7357
+ notify: (reason) => {
7358
+ sessionWorkerWebSocketService.notifyQueueChanged(reason).catch((error) => {
7359
+ console.warn(`${runtimeLogPrefix()}QUEUE WAKE failed reason=${reason} err=${error?.message || error}`);
7360
+ });
7361
+ },
7362
+ armPrime: () => pendingWorkerPrimeTimer.arm(),
7363
+ isReady: () => queueWakeStarted && !runtimeShutdownStarted,
7364
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7365
+ });
7315
7366
  runtimeTimers.deadWorkerSweep = setInterval(() => {
7316
7367
  sweepDeadWorkerProcessingRows().catch((error) => {
7317
7368
  console.warn(`${runtimeLogPrefix()}WORKER SWEEP failed: ${error?.message || error}`);
@@ -7338,40 +7389,39 @@ const tmuxInspectorSocketService = createTmuxInspectorSocketService({
7338
7389
  logger: console,
7339
7390
  });
7340
7391
 
7341
- async function primePendingSessionWorkers(reason = 'pending-worker-prime') {
7342
- if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return 0;
7343
- if (runtimeShutdownStarted) return 0;
7344
- if (typeof sessionWorkerSupervisor?.ensureWorker !== 'function') return 0;
7345
- if (typeof stmts.listPendingWorkerOwnerSessionIds?.all !== 'function') return 0;
7346
-
7347
- // No next_attempt_at filter here: rows sitting in retry backoff still need
7348
- // their worker warm, otherwise the 2s relay poll wins the race the moment
7349
- // the backoff matures and the turn executes on the wrong provider.
7350
- const rows = stmts.listPendingWorkerOwnerSessionIds.all(25);
7351
- let requested = 0;
7352
- for (const row of rows) {
7353
- const sdkSessionId = String(row?.sdk_session_id || '').trim();
7354
- if (!sdkSessionId) continue;
7355
- if (sessionWorkerSupervisor?.isKillBlocked?.(sdkSessionId) === true) continue;
7356
- try {
7357
- // Per session: a throw here costs this session its launch, never the
7358
- // rest of the pass.
7359
- if (failClaudeApiRowsWithoutLaunch(sdkSessionId)) continue;
7360
- } catch (error) {
7361
- console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
7362
- continue;
7363
- }
7364
- requested += 1;
7365
- try {
7366
- const result = await sessionWorkerSupervisor.ensureWorker(sdkSessionId);
7367
- if (!result?.ok) {
7368
- console.warn(`${runtimeLogPrefix()}WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
7369
- }
7370
- } catch (error) {
7371
- console.warn(`${runtimeLogPrefix()}WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
7372
- }
7373
- }
7374
- return requested;
7392
+ // No next_attempt_at filter in the listing: rows sitting in retry backoff
7393
+ // still need their worker warm, otherwise the poll wins the race the moment
7394
+ // the backoff matures and the turn executes on the wrong provider.
7395
+ const pendingWorkerPrimer = createPendingWorkerPrimer({
7396
+ isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
7397
+ && !runtimeShutdownStarted
7398
+ && typeof sessionWorkerSupervisor?.ensureWorker === 'function'
7399
+ && typeof stmts.listPendingWorkerOwnerSessionIds?.all === 'function',
7400
+ listPendingOwnerSessionIds: (limit) => stmts.listPendingWorkerOwnerSessionIds.all(limit),
7401
+ isKillBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
7402
+ failRowsWithoutLaunch: (sid) => failClaudeApiRowsWithoutLaunch(sid),
7403
+ ensureWorker: (sid) => sessionWorkerSupervisor.ensureWorker(sid),
7404
+ // Rows waiting for their worker: the maintenance pass retries until none is.
7405
+ onRowsWaiting: () => pendingWorkerPrimeTimer.arm(),
7406
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7407
+ });
7408
+
7409
+ function primePendingSessionWorkers(reason = 'pending-worker-prime') {
7410
+ return pendingWorkerPrimer(reason);
7411
+ }
7412
+
7413
+ /**
7414
+ * The queue may hold work for a worker now (a row written, a gate lifted, a
7415
+ * retry come due): hand rows to the ready workers, prime the sessions with
7416
+ * pending rows, keep the maintenance pass armed.
7417
+ */
7418
+ function wakeSessionWorkerQueue(reason = 'queue-wake') {
7419
+ return queueWaker?.wake(reason) === true;
7420
+ }
7421
+
7422
+ function hasPendingWorkerOwnerRows() {
7423
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true || runtimeShutdownStarted) return false;
7424
+ return (stmts.listPendingWorkerOwnerSessionIds?.all?.(1) || []).length > 0;
7375
7425
  }
7376
7426
 
7377
7427
  // Guard first, so a tunnelled request to a worker path is rejected before any
@@ -7475,7 +7525,14 @@ app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (
7475
7525
  app.use(express.static(PUBLIC_DIR, { etag: false, setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }));
7476
7526
 
7477
7527
  // ─── CLI Status Tracking ──────────────────────────────────────────────────────
7478
- let cliLastSeen = null;
7528
+ // When the workers were last heard from, without a polling timer: the window
7529
+ // is 45 s while a worker socket is open (an idle worker beats every 30 s) and
7530
+ // 10 s once none is (services/idle-timers.mjs).
7531
+ const cliPresence = createCliPresence({
7532
+ onExpired: () => {
7533
+ if (cliOnline) markCliOffline('heartbeat-timeout');
7534
+ },
7535
+ });
7479
7536
  let cliOnline = false;
7480
7537
  let relayPaused = false;
7481
7538
  const relayBridgeOwnerService = createRelayBridgeOwnerService();
@@ -7491,7 +7548,12 @@ const runtimeState = {
7491
7548
  get cliOnline() { return cliOnline; },
7492
7549
  set cliOnline(value) { cliOnline = value; },
7493
7550
  get relayPaused() { return relayPaused; },
7494
- set relayPaused(value) { relayPaused = value; },
7551
+ set relayPaused(value) {
7552
+ const wasPaused = relayPaused;
7553
+ relayPaused = value;
7554
+ // Resumed: what was queued while paused goes out now.
7555
+ if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
7556
+ },
7495
7557
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
7496
7558
  get relayShutdown() { return relayRestartQueue.getState(); },
7497
7559
  // A shutdown or restart waits for the processing turns: no pending row
@@ -8015,7 +8077,7 @@ registerAboutRoutes(app, {
8015
8077
  }),
8016
8078
  });
8017
8079
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
8018
- cliLastSeen = null;
8080
+ cliPresence.clear();
8019
8081
  const wasOnline = cliOnline;
8020
8082
  cliOnline = false;
8021
8083
  if (clearOwner) {
@@ -8029,20 +8091,6 @@ function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
8029
8091
  }
8030
8092
  }
8031
8093
 
8032
- function checkCliStatus() {
8033
- const wasOnline = cliOnline;
8034
- cliOnline = cliLastSeen !== null && (Date.now() - cliLastSeen) < 10_000;
8035
- if (wasOnline !== cliOnline) {
8036
- if (!cliOnline) {
8037
- markCliOffline('heartbeat-timeout');
8038
- return;
8039
- }
8040
- console.log(`${runtimeLogPrefix()}CLI ONLINE`);
8041
- io.emit('cli_status', { online: true });
8042
- }
8043
- }
8044
- runtimeTimers.cliStatus = setInterval(checkCliStatus, 2000);
8045
-
8046
8094
  if (managedOwnerPid) {
8047
8095
  runtimeTimers.ownerWatchdog = setInterval(() => {
8048
8096
  if (isProcessAlive(managedOwnerPid)) return;
@@ -8072,6 +8120,10 @@ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
8072
8120
  });
8073
8121
  function recoverStaleMessages() {
8074
8122
  if (staleRecoveryClockGuard.tick().skip) return;
8123
+ // The tick itself stays: the guard reads the gaps between ticks, and a timer
8124
+ // that slept while nothing ran would look like a clock jump. Only the work
8125
+ // is skipped while no row is processing and no restart outcome is open.
8126
+ if (!stmts.hasProcessingRow.get() && !restartResumeService.pending().length) return;
8075
8127
  const staleWindowMs = cliOnline
8076
8128
  ? processingTimeoutMs
8077
8129
  : Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
@@ -8111,8 +8163,6 @@ function expirePendingQuestions() {
8111
8163
  }
8112
8164
  runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
8113
8165
  expirePendingQuestions();
8114
- runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
8115
- if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
8116
8166
  // Memory in the status log: now, every 10 minutes, and once per crossing of
8117
8167
  // 80 % of the heap limit (relay-heap-guard.mjs).
8118
8168
  {
@@ -8344,7 +8394,7 @@ function emitToClientsExceptSessionId(event, payload, sessionId) {
8344
8394
  // ─── CLI Routes ───────────────────────────────────────────────────────────────
8345
8395
 
8346
8396
  function touchCli() {
8347
- cliLastSeen = Date.now();
8397
+ cliPresence.touch();
8348
8398
  relayRestartOrchestrator.noteCliOnline();
8349
8399
  if (!cliOnline) {
8350
8400
  cliOnline = true;
@@ -8353,6 +8403,7 @@ function touchCli() {
8353
8403
  }
8354
8404
  }
8355
8405
  sessionWorkerWebSocketService.start();
8406
+ queueWakeStarted = true;
8356
8407
  void primePendingSessionWorkers('startup');
8357
8408
  // Undelivered events get their timers back; stalled, all_idle and the
8358
8409
  // heartbeat are swept from here on.
@@ -8365,13 +8416,7 @@ relayScheduleService.start();
8365
8416
  relayAskLinkService.start();
8366
8417
  // Paired relays' Inboxes, every ~20 s.
8367
8418
  remoteInboxWatchService.start();
8368
- runtimeTimers.pendingWorkerPrime = setInterval(() => {
8369
- void primePendingSessionWorkers('maintenance');
8370
- detachedWorkerWatch.sweep().catch((error) => {
8371
- console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
8372
- });
8373
- }, 5_000);
8374
- if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
8419
+ pendingWorkerPrimeTimer.arm();
8375
8420
 
8376
8421
  // POST /api/heartbeat — CLI sends a ping every poll interval
8377
8422
 
@@ -8500,6 +8545,7 @@ const sshTunnelManager = createSshTunnelManager({
8500
8545
  logger: console,
8501
8546
  runtimeShutdownRef: () => runtimeShutdownStarted,
8502
8547
  configBaseDir: __dirname,
8548
+ onBlockingLifted: () => { wakeSessionWorkerQueue('ssh-tunnel-connected'); },
8503
8549
  });
8504
8550
  const tunnelState = sshTunnelManager.state;
8505
8551
  runtimeState.tunnelState = tunnelState;
@@ -8513,6 +8559,7 @@ const cloudflaredTunnelManager = createCloudflaredTunnelManager({
8513
8559
  logger: console,
8514
8560
  runtimeShutdownRef: () => runtimeShutdownStarted,
8515
8561
  configBaseDir: __dirname,
8562
+ onBlockingLifted: () => { wakeSessionWorkerQueue('cloudflared-tunnel-connected'); },
8516
8563
  });
8517
8564
  runtimeState.cloudflaredTunnelState = cloudflaredTunnelManager.state;
8518
8565
 
@@ -8633,15 +8680,15 @@ function clearRuntimeTimers() {
8633
8680
  try { workerRecycleService.stop(); } catch {}
8634
8681
  try { taskHealthPoller.stop(); } catch {}
8635
8682
  try { taskOutputRooms.stop(); } catch {}
8636
- runtimeTimers.cliStatus = null;
8637
8683
  runtimeTimers.ownerWatchdog = null;
8638
8684
  runtimeTimers.staleRecovery = null;
8639
8685
  runtimeTimers.questionExpiry = null;
8640
- runtimeTimers.pendingWorkerPrime = null;
8641
- runtimeTimers.sharedViewerPrune = null;
8686
+ pendingWorkerPrimeTimer.stop();
8687
+ sharedViewerPruneTimer.stop();
8642
8688
  runtimeTimers.restoredTaskSettle = null;
8643
8689
  runtimeTimers.archivedWorkerSweep = null;
8644
8690
  try { relayRestartQueue.dispose(); } catch {}
8691
+ try { cliPresence.clear(); } catch {}
8645
8692
  }
8646
8693
 
8647
8694
  let runtimeShutdownPromise = null;
@@ -211,6 +211,9 @@ export function createCloudflaredTunnelManager({
211
211
  io = null,
212
212
  logger = console,
213
213
  runtimeShutdownRef = () => false,
214
+ // The tunnel stopped blocking queue delivery (it connected): the relay
215
+ // hands out what waited for it.
216
+ onBlockingLifted = () => {},
214
217
  platform = process.platform,
215
218
  spawnImpl = spawn,
216
219
  nowIso = () => new Date().toISOString(),
@@ -267,7 +270,11 @@ export function createCloudflaredTunnelManager({
267
270
  };
268
271
 
269
272
  const updateBlockingState = () => {
273
+ const wasBlocking = state.blocking;
270
274
  state.blocking = state.required && state.mode === 'managed' && !state.connected;
275
+ if (wasBlocking && !state.blocking) {
276
+ try { onBlockingLifted(); } catch {}
277
+ }
271
278
  };
272
279
 
273
280
  const scheduleReconnect = (spawnTunnel, { slowest = false } = {}) => {