@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
@@ -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';
@@ -75,6 +76,9 @@ import { createDeepSeekUsageImportService } from './services/deepseek-usage-impo
75
76
  import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
76
77
  import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
77
78
  import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
79
+ import { createWorkerReplacement, createWorkerReplacementFence } from './services/worker-replacement.mjs';
80
+ import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs';
81
+ import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
78
82
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
79
83
  import { createTokenUsageService } from './services/token-usage-service.mjs';
80
84
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
@@ -119,7 +123,8 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
119
123
  import { registerPushRoutes } from './routes/push-routes.mjs';
120
124
  import { registerAboutRoutes } from './routes/about-routes.mjs';
121
125
  import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
122
- import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
126
+ import { createAboutService } from './services/about-service.mjs';
127
+ import { resolveBuildId } from '../shared/build-id.mjs';
123
128
  import { createImageOperationService } from './services/image-operation-service.mjs';
124
129
  import {
125
130
  normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
@@ -229,6 +234,9 @@ import {
229
234
  import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
230
235
  import { createWorkerScope } from './services/worker-scope.mjs';
231
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';
232
240
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
233
241
  import {
234
242
  STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
@@ -266,7 +274,11 @@ import {
266
274
  readTaskStallMinutesSetting,
267
275
  } from '../shared/task-output-text.mjs';
268
276
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
269
- 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';
270
282
  import {
271
283
  isOpenAIModelId,
272
284
  isSafeClaudeModelId,
@@ -298,7 +310,12 @@ const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-che
298
310
  // When this relay process started, and the commit its code came from (read
299
311
  // once: a checkout moved on later still runs the code it started with).
300
312
  const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
301
- const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
313
+ // The build id (version plus commit) names that code for the session workers:
314
+ // a worker that states another one started on older code. Read in the same
315
+ // pass as the commit so the two cannot disagree.
316
+ const RELAY_BUILD = resolveBuildId(PACKAGE_ROOT);
317
+ const BUILD_COMMIT = RELAY_BUILD.commit;
318
+ const BUILD_ID = RELAY_BUILD.id;
302
319
  const PUBLIC_DIR = path.join(__dirname, 'public');
303
320
  const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
304
321
  const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
@@ -1996,18 +2013,36 @@ let runtimeShutdownStarted = false;
1996
2013
  // Set once the database is open (see the retention sweep near the other sweeps).
1997
2014
  let subagentRecordRetention = null;
1998
2015
  const runtimeTimers = {
1999
- cliStatus: null,
2000
2016
  ownerWatchdog: null,
2001
2017
  staleRecovery: null,
2002
2018
  questionExpiry: null,
2003
- pendingWorkerPrime: null,
2004
- sharedViewerPrune: null,
2005
2019
  restoredTaskSettle: null,
2006
2020
  claudeApiCapClock: null,
2007
2021
  memoryLog: null,
2008
2022
  memoryWatch: null,
2023
+ // Once, 30 s after the relay listens: archived sessions' workers stopped.
2024
+ archivedWorkerSweep: null,
2009
2025
  };
2010
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
+
2011
2046
  function normalizeRemotePath(value) {
2012
2047
  if (typeof value !== 'string') return '';
2013
2048
  const trimmed = value.trim();
@@ -2850,6 +2885,16 @@ const stmts = {
2850
2885
  ...createQuestionRepository(db),
2851
2886
  ...createImageConversationRepository(db),
2852
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
+ });
2853
2898
  const statusEventService = createStatusEventService(db);
2854
2899
  // Public shares and their access log (migration 0016).
2855
2900
  const shareRepository = createShareRepository(db);
@@ -3411,6 +3456,8 @@ const sessionWorkerSupervisor = createSessionWorkerSupervisor({
3411
3456
  diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
3412
3457
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3413
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'); },
3414
3461
  });
3415
3462
  // Feature flags moved from config.json into app_settings; adopt any legacy
3416
3463
  // `features` key once, then resolve the boot snapshot (defaults < stored rows
@@ -3811,7 +3858,11 @@ function relayShutdownStatePayload() {
3811
3858
 
3812
3859
  function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3813
3860
  const result = relayRestartQueue.cancel({ requestedBy });
3814
- 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
+ }
3815
3866
  return result;
3816
3867
  }
3817
3868
 
@@ -5380,52 +5431,28 @@ function createCompactedConversation(sourceConversationId) {
5380
5431
  };
5381
5432
  }
5382
5433
 
5383
- function sanitizeActivityText(value) {
5384
- const text = String(value || '').trim();
5385
- if (!text) return '';
5386
- return text.slice(0, 200);
5387
- }
5388
-
5389
- function mapRelayActivityRow(row) {
5390
- const text = sanitizeActivityText(row?.text);
5391
- if (!text) return null;
5392
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5393
- // Structured rows (compaction boundaries) carry their payload alongside the
5394
- // prose so the transcript can promote them to a break row; junk JSON is
5395
- // simply dropped back to a prose-only entry.
5396
- let metadata = null;
5397
- if (row?.metadata_json) {
5398
- try {
5399
- const parsed = JSON.parse(String(row.metadata_json));
5400
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
5401
- } catch { metadata = null; }
5402
- }
5403
- return {
5404
- text,
5405
- subagentRunId: subagentRunId || null,
5406
- ...(metadata ? { metadata } : {}),
5407
- };
5408
- }
5409
-
5410
- const RELAY_ACTIVITY_ROW_LIMIT = 48;
5411
-
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.
5412
5438
  function relayActivityForResponse(responseMessageId) {
5413
- return capRelayActivityEntries(
5439
+ return capFinishedActivityEntries(
5414
5440
  stmts.listActivityByResponse
5415
5441
  .all(responseMessageId)
5416
5442
  .map(mapRelayActivityRow)
5417
5443
  .filter(Boolean),
5418
- RELAY_ACTIVITY_ROW_LIMIT,
5419
5444
  );
5420
5445
  }
5421
5446
 
5422
- 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) {
5423
5450
  return capRelayActivityEntries(
5424
5451
  stmts.listActivityByQueueMessage
5425
5452
  .all(queueMessageId)
5426
5453
  .map(mapRelayActivityRow)
5427
5454
  .filter(Boolean),
5428
- RELAY_ACTIVITY_ROW_LIMIT,
5455
+ inFlightActivityCap(activityCap),
5429
5456
  );
5430
5457
  }
5431
5458
 
@@ -5447,19 +5474,6 @@ function relayStreamEventsForQueueMessage(queueMessageId) {
5447
5474
  .slice(0, 5000);
5448
5475
  }
5449
5476
 
5450
- function mapRelayThoughtRow(row) {
5451
- const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
5452
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5453
- return {
5454
- reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
5455
- seq,
5456
- text: String(row?.text || ''),
5457
- done: Number(row?.done || 0) === 1,
5458
- timestamp: row?.created_at || null,
5459
- subagentRunId: subagentRunId || null,
5460
- };
5461
- }
5462
-
5463
5477
  function relayThoughtsForResponse(responseMessageId) {
5464
5478
  const rows = stmts.listThoughtsByResponse?.all(responseMessageId) || [];
5465
5479
  return normalizeRelayThoughtList(rows.map(mapRelayThoughtRow)).slice(0, 5000);
@@ -5643,7 +5657,7 @@ const usageLimitPauseService = createUsageLimitPauseService({
5643
5657
  stmts,
5644
5658
  emit: (event, payload) => io.emit(event, payload),
5645
5659
  // A follow-up resumed by hand is due at once: wake its worker.
5646
- onQueueChanged: (reason) => { void primePendingSessionWorkers(reason); },
5660
+ onQueueChanged: (reason) => { wakeSessionWorkerQueue(reason); },
5647
5661
  logger: console,
5648
5662
  });
5649
5663
 
@@ -5675,8 +5689,7 @@ const claudeBillingSwitchService = createClaudeBillingSwitchService({
5675
5689
  // What the swap waits for: a turn, a question, a compaction, background tasks.
5676
5690
  getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
5677
5691
  onQueueChanged: (reason) => {
5678
- void primePendingSessionWorkers(reason);
5679
- sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
5692
+ wakeSessionWorkerQueue(reason);
5680
5693
  },
5681
5694
  logger: console,
5682
5695
  });
@@ -5724,8 +5737,7 @@ const restartResumeService = createRestartResumeService({
5724
5737
  // The note is an ordinary pending message: the primer starts a stopped
5725
5738
  // worker, the socket service hands it to a running one.
5726
5739
  wake: () => {
5727
- void primePendingSessionWorkers('restart-resume');
5728
- sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
5740
+ wakeSessionWorkerQueue('restart-resume');
5729
5741
  },
5730
5742
  logger: console,
5731
5743
  });
@@ -5755,8 +5767,7 @@ relayEventService = createRelayEventService({
5755
5767
  // The event row is an ordinary pending message: the primer starts a
5756
5768
  // stopped worker, the socket service hands it to a running one.
5757
5769
  wakeWatcher: () => {
5758
- void primePendingSessionWorkers('relay-event');
5759
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
5770
+ wakeSessionWorkerQueue('relay-event');
5760
5771
  },
5761
5772
  relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
5762
5773
  onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
@@ -5772,8 +5783,7 @@ const relayScheduleService = createRelayScheduleService({
5772
5783
  emit: (event, payload) => io.emit(event, payload),
5773
5784
  // The scheduled message is an ordinary pending one, like a relay event's.
5774
5785
  wake: () => {
5775
- void primePendingSessionWorkers('relay-schedule');
5776
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
5786
+ wakeSessionWorkerQueue('relay-schedule');
5777
5787
  },
5778
5788
  // A firing into another session is a prompt of the conversation that set
5779
5789
  // it: the agents' loop guard counts it (and holds it once it wants the user).
@@ -5836,7 +5846,9 @@ function resolveLiveTurnQueueRow(conversationId) {
5836
5846
  return byId.get(liveId) || rows[0];
5837
5847
  }
5838
5848
 
5839
- 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 } = {}) {
5840
5852
  const row = resolveLiveTurnQueueRow(conversationId);
5841
5853
  if (!row) return null;
5842
5854
  const streamEvents = relayStreamEventsForQueueMessage(row.id);
@@ -5848,7 +5860,7 @@ function inFlightStateForConversation(conversationId) {
5848
5860
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
5849
5861
  timestamp: row.timestamp || null,
5850
5862
  processingAt: row.processing_at || null,
5851
- activities: relayActivityForQueueMessage(row.id),
5863
+ activities: relayActivityForQueueMessage(row.id, activityCap),
5852
5864
  thoughts: relayThoughtsForQueueMessage(row.id),
5853
5865
  streamEvents,
5854
5866
  streamDone: !!lastStreamEvent?.done,
@@ -6381,6 +6393,15 @@ const SHARED_VIEWER_MAX_GLOBAL = Number.isFinite(Number(config.sharedPresenceMax
6381
6393
  ? Math.max(SHARED_VIEWER_MAX_PER_CONVERSATION, Math.trunc(Number(config.sharedPresenceMaxGlobal)))
6382
6394
  : 5_000;
6383
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
+ });
6384
6405
 
6385
6406
  function sanitizeSharedViewerId(value) {
6386
6407
  const id = String(value || '').trim().replace(/[^a-zA-Z0-9:_-]+/g, '');
@@ -6521,6 +6542,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
6521
6542
  }
6522
6543
  const before = viewers.size;
6523
6544
  viewers.set(key, { lastSeenAt: now });
6545
+ sharedViewerPruneTimer.arm();
6524
6546
  const watcherCount = viewers.size;
6525
6547
  if (before !== watcherCount) {
6526
6548
  emitConversationWatcherCount(convId);
@@ -6550,6 +6572,23 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6550
6572
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
6551
6573
  };
6552
6574
  }
6575
+ // The process asking is being replaced (the worker recycle passed its holds
6576
+ // for it): no work, as for a killed session; the worker stays ready, and
6577
+ // what waits goes to the new one. Unlike the killed marker above, this
6578
+ // survives the marker's grace and every clearRestartSchedule.
6579
+ if (workerReplacementFence.isHeld(requesterSessionId, pid)) {
6580
+ return {
6581
+ message: null,
6582
+ routing: {
6583
+ enabled: true,
6584
+ requesterSessionId,
6585
+ blockedReason: 'worker-replacement',
6586
+ lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
6587
+ fallbackRestart: null,
6588
+ },
6589
+ restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
6590
+ };
6591
+ }
6553
6592
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6554
6593
  if (runtimeState.relayPaused) {
6555
6594
  return { message: null, paused: true, reason: 'relay_paused' };
@@ -6629,6 +6668,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6629
6668
  workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
6630
6669
  claudeApiClaimCheck,
6631
6670
  onBillingFence: handleClaudeBillingFence,
6671
+ // The fence check above, asked again before every claim: ensureWorker
6672
+ // and a transient error's backoff both wait.
6673
+ isReplacementFenced: () => workerReplacementFence.isHeld(requesterSessionId, pid),
6632
6674
  });
6633
6675
  if (!dequeueResult?.message) {
6634
6676
  return {
@@ -6777,7 +6819,7 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
6777
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')}`);
6778
6820
  io.emit('message_status', { messageId, conversationId: row.conversation_id, status: 'pending' });
6779
6821
  io.emit('queue_updated', { recovered: 1, reason: 'worker-ws-send-failed' });
6780
- sessionWorkerWebSocketService?.emitQueueChanged?.('worker-ws-send-failed');
6822
+ wakeSessionWorkerQueue('worker-ws-send-failed');
6781
6823
  return true;
6782
6824
  }
6783
6825
 
@@ -6867,11 +6909,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6867
6909
  }
6868
6910
  if (recovered > 0) {
6869
6911
  io.emit('queue_updated', { recovered, reason: `worker-died:${reason}` });
6870
- sessionWorkerWebSocketService?.emitQueueChanged?.(`worker-died:${reason}`);
6871
6912
  }
6872
6913
  sessionWorkerSupervisor?.markError?.(sid, `worker-died:${reason}`);
6873
6914
  // The rows stay owned, so the primer is what respawns their worker.
6874
- void primePendingSessionWorkers(`worker-died:${reason}`);
6915
+ wakeSessionWorkerQueue(`worker-died:${reason}`);
6875
6916
  return recovered;
6876
6917
  }
6877
6918
 
@@ -6942,48 +6983,50 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6942
6983
  // claude-billing-swap-service.mjs): a Claude chat's next row of the other
6943
6984
  // billing is refused to the worker it has, which is replaced, once idle, by
6944
6985
  // one of the row's kind. The old one is stopped like the kill route does, and
6945
- // the new one launched only once every process of the session is gone.
6986
+ // the new one launched only once every process of the session is gone
6987
+ // (services/worker-replacement.mjs, which the worker recycle below shares).
6988
+ // The two get one instance each so a failed stop's warning names the caller.
6989
+ // They share one replacement fence, the one the deliveries check (the socket
6990
+ // delivery above and /api/pending): a process being replaced is handed no
6991
+ // work. The worker recycle raises it; the billing swap does not, since its
6992
+ // own delivery fence already keeps the other billing's rows from the old
6993
+ // worker.
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
+ })();
7007
+ function createRuntimeWorkerReplacement(tag) {
7008
+ return createWorkerReplacement({
7009
+ registry: sessionWorkerRegistry,
7010
+ supervisor: sessionWorkerSupervisor,
7011
+ processInspector: sessionWorkerProcessInspector,
7012
+ stopSessionWorkerProcesses,
7013
+ isProcessAlive,
7014
+ killTmuxSession,
7015
+ logger: console,
7016
+ logPrefix: () => `${runtimeLogPrefix()}${tag} `,
7017
+ fence: workerReplacementFence,
7018
+ });
7019
+ }
7020
+ // A stop that fails backs off (the swap service retries it) and clears the
7021
+ // killed marker, so the session's worker, alive or relaunched, keeps asking
7022
+ // for work meanwhile. The fence still keeps the other billing's rows from it.
7023
+ const billingSwapWorkerReplacement = createRuntimeWorkerReplacement('BILLING SWAP');
6946
7024
  const claudeBillingSwapService = createClaudeBillingSwapService({
6947
7025
  getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
6948
7026
  // A row the session claimed and has not finished: never stopped under it.
6949
7027
  hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
6950
- stopWorker: async (sid) => {
6951
- const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
6952
- // Nothing may start the session while it is being stopped: a launch then
6953
- // would adopt the old process, kind and all.
6954
- sessionWorkerSupervisor?.markKilled?.(sid);
6955
- // A stop that fails backs off (the swap service retries it): the killed
6956
- // marker goes with it, so the session's worker, alive or relaunched,
6957
- // keeps asking for work meanwhile. The fence still keeps the other
6958
- // billing's rows from it.
6959
- const backOff = (error, remainingPids = []) => {
6960
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6961
- console.warn(`${runtimeLogPrefix()}BILLING SWAP STOP FAILED session=${sid.slice(0, 8)} ${error}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''} — kill marker cleared, retrying with a backoff`);
6962
- return { ok: false, error, remainingPids };
6963
- };
6964
- let stopped = null;
6965
- try {
6966
- await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
6967
- stopped = await stopSessionWorkerProcesses({
6968
- sdkSessionId: sid,
6969
- worker,
6970
- processInspector: sessionWorkerProcessInspector,
6971
- isPidAliveImpl: isProcessAlive,
6972
- killTmuxSessionImpl: killTmuxSession,
6973
- });
6974
- } catch (error) {
6975
- return backOff(String(error?.message || error || 'worker-stop-failed'));
6976
- }
6977
- if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
6978
- sessionWorkerRegistry?.removeWorker?.(sid);
6979
- sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
6980
- return { ok: true };
6981
- },
6982
- launchWorker: async (sid) => {
6983
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6984
- const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
6985
- return { ok: launched?.ok === true, error: launched?.error || null };
6986
- },
7028
+ stopWorker: billingSwapWorkerReplacement.stopWorker,
7029
+ launchWorker: billingSwapWorkerReplacement.launchWorker,
6987
7030
  onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
6988
7031
  if (!conversationId) return;
6989
7032
  const at = new Date().toISOString();
@@ -7015,6 +7058,95 @@ const claudeBillingSwapService = createClaudeBillingSwapService({
7015
7058
  logger: console,
7016
7059
  });
7017
7060
 
7061
+ // Whether a worker's session is one the relay keeps no worker for: its
7062
+ // conversation is archived (or deleted), or there is no conversation behind
7063
+ // the session at all. A conversation in use found either way (the runtime
7064
+ // session's, or the one naming the session id) keeps its worker: a session
7065
+ // is never stopped for good on a missing link alone.
7066
+ function isWorkerSessionArchived(sid) {
7067
+ const runtime = stmts.getRuntimeSessionBySdkSessionId?.get?.(sid) || null;
7068
+ const viaRuntime = runtime?.conversation_id ? (stmts.getConvAnyStatus?.get?.(runtime.conversation_id) || null) : null;
7069
+ const viaSessionId = stmts.getConvBySdkSessionId?.get?.(sid) || null;
7070
+ const inUse = (conversation) => Boolean(conversation)
7071
+ && Number(conversation.archived || 0) !== 1
7072
+ && String(conversation.status || '').trim().toLowerCase() !== 'deleted';
7073
+ return !inUse(viaRuntime) && !inUse(viaSessionId);
7074
+ }
7075
+
7076
+ // The worker recycle (services/worker-recycle-service.mjs): a worker that
7077
+ // runs older code than this relay (another build id, or none stated) is
7078
+ // replaced between turns by one on this relay's code; an archived session's
7079
+ // is stopped for good. Its own replacement instance, so a failed stop says
7080
+ // WORKER RECYCLE rather than BILLING SWAP.
7081
+ const recycleWorkerReplacement = createRuntimeWorkerReplacement('WORKER RECYCLE');
7082
+ const workerRecycleService = createWorkerRecycleService({
7083
+ relayBuildId: BUILD_ID,
7084
+ listWorkers: () => sessionWorkerRegistry.listWorkers(),
7085
+ getWorker: (sid) => sessionWorkerRegistry.getWorker(sid),
7086
+ // A row the session claimed and has not finished: never stopped under it.
7087
+ hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
7088
+ // From the supervisor's lifecycle, not its firstObservedHeartbeatAt: that
7089
+ // is set only for a worker it launched itself, and the workers that
7090
+ // reconnect after a relay restart (the ones most likely stale) never get it.
7091
+ hasHeartbeated: (sid) => {
7092
+ const lc = sessionWorkerSupervisor.getLifecycleState(sid);
7093
+ return !!lc?.lastHeartbeatAt && !lc?.awaitingHeartbeat;
7094
+ },
7095
+ isArchived: (sid) => isWorkerSessionArchived(sid),
7096
+ // The session's last delivery or turn end, for a worker that does not say
7097
+ // whether its CLI child runs (message-repository getOwnerLastActivityAt).
7098
+ quietSince: (sid) => {
7099
+ const at = Date.parse(stmts.getOwnerLastActivityAt?.get?.(sid)?.last_activity_at || '');
7100
+ return Number.isFinite(at) ? at : null;
7101
+ },
7102
+ stopWorker: recycleWorkerReplacement.stopWorker,
7103
+ launchWorker: recycleWorkerReplacement.launchWorker,
7104
+ fence: recycleWorkerReplacement.fence,
7105
+ // The stop and the launch under the per-session launch mutex the Change
7106
+ // CWD relaunch and the launch routes take (sessions-routes.mjs).
7107
+ runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7108
+ logger: console,
7109
+ });
7110
+
7111
+ // Once at start, after the restart's own worker reconnects settled: what
7112
+ // still runs for an archived conversation is stopped and stays stopped.
7113
+ // Through the recycle's replacement, one session at a time; never throws.
7114
+ async function sweepArchivedSessionWorkersAtStart() {
7115
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return null;
7116
+ if (runtimeShutdownStarted) return null;
7117
+ // The first lookup reads the process list afresh; the rest share it for the
7118
+ // inspector's cache moment, so a long archive costs one read, not one each.
7119
+ let freshRead = true;
7120
+ return sweepArchivedWorkers({
7121
+ listArchivedSessionIds: () => (stmts.listArchivedRuntimeSessionIds?.all?.() || []).map((row) => row?.sdk_session_id),
7122
+ hasLiveProcess: async (sid) => {
7123
+ if (runtimeShutdownStarted) return false;
7124
+ const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
7125
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return true;
7126
+ const fresh = freshRead;
7127
+ freshRead = false;
7128
+ return Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid, { fresh }));
7129
+ },
7130
+ isArchived: (sid) => isWorkerSessionArchived(sid),
7131
+ // The process the fence keeps deliveries from during the stop: the
7132
+ // registry's live pid, else the one the process list shows, else none
7133
+ // (every process of the session is fenced then).
7134
+ workerPid: async (sid) => {
7135
+ const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
7136
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return pid;
7137
+ const found = await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid);
7138
+ return Number(found?.processId) || null;
7139
+ },
7140
+ stopWorker: recycleWorkerReplacement.stopWorker,
7141
+ // Guarded as the recycle's archived stop is: a message that unarchives
7142
+ // the session meanwhile must not start a turn on the process being
7143
+ // stopped.
7144
+ fence: workerReplacementFence,
7145
+ runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7146
+ logger: console,
7147
+ });
7148
+ }
7149
+
7018
7150
  // The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
7019
7151
  // one seam the claim check below, the billing switch, an agent's send and a
7020
7152
  // new Claude API chat ask: this month's priced estimate in
@@ -7178,6 +7310,21 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7178
7310
  },
7179
7311
  onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
7180
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); },
7181
7328
  requestWork: requestSessionWorkerSocketDelivery,
7182
7329
  // What a worker says it is ("claude", "claude-api", ...): the registry
7183
7330
  // keeps it for the billing fence and for what a response ran on.
@@ -7187,17 +7334,45 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7187
7334
  const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7188
7335
  if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
7189
7336
  },
7337
+ // The build id a worker says it started on: the registry keeps it so a
7338
+ // worker on older code can be told from one on the relay's.
7339
+ onWorkerBuildId: (sessionId, buildId) => {
7340
+ const sid = String(sessionId || '').trim();
7341
+ if (!sid) return;
7342
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7343
+ if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
7344
+ },
7345
+ // Sent in server.hello, so a worker on other code can say so in its log.
7346
+ buildId: BUILD_ID,
7190
7347
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
7191
7348
  pathPrefix: remotePath,
7192
- 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,
7193
7352
  logger: console,
7194
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
+ });
7195
7366
  runtimeTimers.deadWorkerSweep = setInterval(() => {
7196
7367
  sweepDeadWorkerProcessingRows().catch((error) => {
7197
7368
  console.warn(`${runtimeLogPrefix()}WORKER SWEEP failed: ${error?.message || error}`);
7198
7369
  });
7199
7370
  }, 30_000);
7200
7371
  if (typeof runtimeTimers.deadWorkerSweep.unref === 'function') runtimeTimers.deadWorkerSweep.unref();
7372
+ // Workers on older code are recycled only where the relay launches them;
7373
+ // with routing off the service never starts. Its timer is its own (unref'd)
7374
+ // and stops with the runtime timers.
7375
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) workerRecycleService.start();
7201
7376
  const tmuxInspectorAccessPolicy = createTmuxInspectorAccessPolicy({
7202
7377
  sessionWorkerRegistry,
7203
7378
  sessionWorkerSupervisor,
@@ -7214,40 +7389,39 @@ const tmuxInspectorSocketService = createTmuxInspectorSocketService({
7214
7389
  logger: console,
7215
7390
  });
7216
7391
 
7217
- async function primePendingSessionWorkers(reason = 'pending-worker-prime') {
7218
- if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return 0;
7219
- if (runtimeShutdownStarted) return 0;
7220
- if (typeof sessionWorkerSupervisor?.ensureWorker !== 'function') return 0;
7221
- if (typeof stmts.listPendingWorkerOwnerSessionIds?.all !== 'function') return 0;
7222
-
7223
- // No next_attempt_at filter here: rows sitting in retry backoff still need
7224
- // their worker warm, otherwise the 2s relay poll wins the race the moment
7225
- // the backoff matures and the turn executes on the wrong provider.
7226
- const rows = stmts.listPendingWorkerOwnerSessionIds.all(25);
7227
- let requested = 0;
7228
- for (const row of rows) {
7229
- const sdkSessionId = String(row?.sdk_session_id || '').trim();
7230
- if (!sdkSessionId) continue;
7231
- if (sessionWorkerSupervisor?.isKillBlocked?.(sdkSessionId) === true) continue;
7232
- try {
7233
- // Per session: a throw here costs this session its launch, never the
7234
- // rest of the pass.
7235
- if (failClaudeApiRowsWithoutLaunch(sdkSessionId)) continue;
7236
- } catch (error) {
7237
- console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
7238
- continue;
7239
- }
7240
- requested += 1;
7241
- try {
7242
- const result = await sessionWorkerSupervisor.ensureWorker(sdkSessionId);
7243
- if (!result?.ok) {
7244
- console.warn(`${runtimeLogPrefix()}WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
7245
- }
7246
- } catch (error) {
7247
- console.warn(`${runtimeLogPrefix()}WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
7248
- }
7249
- }
7250
- 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;
7251
7425
  }
7252
7426
 
7253
7427
  // Guard first, so a tunnelled request to a worker path is rejected before any
@@ -7351,16 +7525,35 @@ app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (
7351
7525
  app.use(express.static(PUBLIC_DIR, { etag: false, setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }));
7352
7526
 
7353
7527
  // ─── CLI Status Tracking ──────────────────────────────────────────────────────
7354
- 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
+ });
7355
7536
  let cliOnline = false;
7356
7537
  let relayPaused = false;
7357
7538
  const relayBridgeOwnerService = createRelayBridgeOwnerService();
7358
7539
 
7359
7540
  const runtimeState = {
7541
+ // The relay's own build id, read once at start; null when package.json
7542
+ // names no version.
7543
+ buildId: BUILD_ID,
7544
+ // The worker recycle's status() (the relay's build id, the stale count,
7545
+ // the recycles in flight and done, who waits on what): a function, so a
7546
+ // route reads it when asked and needs no wiring of its own.
7547
+ workerRecycle: () => workerRecycleService.status(),
7360
7548
  get cliOnline() { return cliOnline; },
7361
7549
  set cliOnline(value) { cliOnline = value; },
7362
7550
  get relayPaused() { return relayPaused; },
7363
- 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
+ },
7364
7557
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
7365
7558
  get relayShutdown() { return relayRestartQueue.getState(); },
7366
7559
  // A shutdown or restart waits for the processing turns: no pending row
@@ -7693,6 +7886,8 @@ const sharedRouteDeps = {
7693
7886
  sessionWorkerSupervisor,
7694
7887
  sessionWorkerRegistry,
7695
7888
  sessionWorkerProcessInspector,
7889
+ // /api/pending refuses work to a process the worker recycle is replacing.
7890
+ workerReplacementFence,
7696
7891
  buildContextResponseText,
7697
7892
  readContextFromSessionEvents,
7698
7893
  sessionHistoryRefreshService,
@@ -7859,6 +8054,9 @@ registerAboutRoutes(app, {
7859
8054
  dbPath: DB_PATH,
7860
8055
  version: RUNNING_VERSION,
7861
8056
  commit: BUILD_COMMIT,
8057
+ // The workers' build ids are counted against this one (the About modal's
8058
+ // "Workers: 14, 3 on an older build").
8059
+ buildId: BUILD_ID,
7862
8060
  installMethod: INSTALL_METHOD,
7863
8061
  startedAt: RELAY_STARTED_AT,
7864
8062
  packageRoot: PACKAGE_ROOT,
@@ -7879,7 +8077,7 @@ registerAboutRoutes(app, {
7879
8077
  }),
7880
8078
  });
7881
8079
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
7882
- cliLastSeen = null;
8080
+ cliPresence.clear();
7883
8081
  const wasOnline = cliOnline;
7884
8082
  cliOnline = false;
7885
8083
  if (clearOwner) {
@@ -7893,20 +8091,6 @@ function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
7893
8091
  }
7894
8092
  }
7895
8093
 
7896
- function checkCliStatus() {
7897
- const wasOnline = cliOnline;
7898
- cliOnline = cliLastSeen !== null && (Date.now() - cliLastSeen) < 10_000;
7899
- if (wasOnline !== cliOnline) {
7900
- if (!cliOnline) {
7901
- markCliOffline('heartbeat-timeout');
7902
- return;
7903
- }
7904
- console.log(`${runtimeLogPrefix()}CLI ONLINE`);
7905
- io.emit('cli_status', { online: true });
7906
- }
7907
- }
7908
- runtimeTimers.cliStatus = setInterval(checkCliStatus, 2000);
7909
-
7910
8094
  if (managedOwnerPid) {
7911
8095
  runtimeTimers.ownerWatchdog = setInterval(() => {
7912
8096
  if (isProcessAlive(managedOwnerPid)) return;
@@ -7936,6 +8120,10 @@ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
7936
8120
  });
7937
8121
  function recoverStaleMessages() {
7938
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;
7939
8127
  const staleWindowMs = cliOnline
7940
8128
  ? processingTimeoutMs
7941
8129
  : Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
@@ -7975,8 +8163,6 @@ function expirePendingQuestions() {
7975
8163
  }
7976
8164
  runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
7977
8165
  expirePendingQuestions();
7978
- runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
7979
- if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
7980
8166
  // Memory in the status log: now, every 10 minutes, and once per crossing of
7981
8167
  // 80 % of the heap limit (relay-heap-guard.mjs).
7982
8168
  {
@@ -8208,7 +8394,7 @@ function emitToClientsExceptSessionId(event, payload, sessionId) {
8208
8394
  // ─── CLI Routes ───────────────────────────────────────────────────────────────
8209
8395
 
8210
8396
  function touchCli() {
8211
- cliLastSeen = Date.now();
8397
+ cliPresence.touch();
8212
8398
  relayRestartOrchestrator.noteCliOnline();
8213
8399
  if (!cliOnline) {
8214
8400
  cliOnline = true;
@@ -8217,6 +8403,7 @@ function touchCli() {
8217
8403
  }
8218
8404
  }
8219
8405
  sessionWorkerWebSocketService.start();
8406
+ queueWakeStarted = true;
8220
8407
  void primePendingSessionWorkers('startup');
8221
8408
  // Undelivered events get their timers back; stalled, all_idle and the
8222
8409
  // heartbeat are swept from here on.
@@ -8229,13 +8416,7 @@ relayScheduleService.start();
8229
8416
  relayAskLinkService.start();
8230
8417
  // Paired relays' Inboxes, every ~20 s.
8231
8418
  remoteInboxWatchService.start();
8232
- runtimeTimers.pendingWorkerPrime = setInterval(() => {
8233
- void primePendingSessionWorkers('maintenance');
8234
- detachedWorkerWatch.sweep().catch((error) => {
8235
- console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
8236
- });
8237
- }, 5_000);
8238
- if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
8419
+ pendingWorkerPrimeTimer.arm();
8239
8420
 
8240
8421
  // POST /api/heartbeat — CLI sends a ping every poll interval
8241
8422
 
@@ -8364,6 +8545,7 @@ const sshTunnelManager = createSshTunnelManager({
8364
8545
  logger: console,
8365
8546
  runtimeShutdownRef: () => runtimeShutdownStarted,
8366
8547
  configBaseDir: __dirname,
8548
+ onBlockingLifted: () => { wakeSessionWorkerQueue('ssh-tunnel-connected'); },
8367
8549
  });
8368
8550
  const tunnelState = sshTunnelManager.state;
8369
8551
  runtimeState.tunnelState = tunnelState;
@@ -8377,6 +8559,7 @@ const cloudflaredTunnelManager = createCloudflaredTunnelManager({
8377
8559
  logger: console,
8378
8560
  runtimeShutdownRef: () => runtimeShutdownStarted,
8379
8561
  configBaseDir: __dirname,
8562
+ onBlockingLifted: () => { wakeSessionWorkerQueue('cloudflared-tunnel-connected'); },
8380
8563
  });
8381
8564
  runtimeState.cloudflaredTunnelState = cloudflaredTunnelManager.state;
8382
8565
 
@@ -8494,16 +8677,18 @@ function clearRuntimeTimers() {
8494
8677
  try { clearTimeout(timer); } catch {}
8495
8678
  }
8496
8679
  subagentRecordRetention?.stop();
8680
+ try { workerRecycleService.stop(); } catch {}
8497
8681
  try { taskHealthPoller.stop(); } catch {}
8498
8682
  try { taskOutputRooms.stop(); } catch {}
8499
- runtimeTimers.cliStatus = null;
8500
8683
  runtimeTimers.ownerWatchdog = null;
8501
8684
  runtimeTimers.staleRecovery = null;
8502
8685
  runtimeTimers.questionExpiry = null;
8503
- runtimeTimers.pendingWorkerPrime = null;
8504
- runtimeTimers.sharedViewerPrune = null;
8686
+ pendingWorkerPrimeTimer.stop();
8687
+ sharedViewerPruneTimer.stop();
8505
8688
  runtimeTimers.restoredTaskSettle = null;
8689
+ runtimeTimers.archivedWorkerSweep = null;
8506
8690
  try { relayRestartQueue.dispose(); } catch {}
8691
+ try { cliPresence.clear(); } catch {}
8507
8692
  }
8508
8693
 
8509
8694
  let runtimeShutdownPromise = null;
@@ -8714,4 +8899,16 @@ httpServer.listen(config.port, listenHost, () => {
8714
8899
  // Deferred and unref'd (a few seconds behind the startup import's own CLI
8715
8900
  // spawn); a relay without an installed Copilot CLI skips it entirely.
8716
8901
  copilotModelDiscoveryService.scheduleStartupRefresh();
8902
+ // Archived conversations' workers: stopped once, 30 s in, so the workers
8903
+ // that reconnect after the restart and the first prime have settled. With
8904
+ // routing off the relay launches no workers, so there is nothing to sweep.
8905
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) {
8906
+ runtimeTimers.archivedWorkerSweep = setTimeout(() => {
8907
+ runtimeTimers.archivedWorkerSweep = null;
8908
+ sweepArchivedSessionWorkersAtStart().catch((error) => {
8909
+ console.warn(`${runtimeLogPrefix()}WORKER RECYCLE archived sweep failed: ${error?.message || error}`);
8910
+ });
8911
+ }, 30_000);
8912
+ runtimeTimers.archivedWorkerSweep.unref?.();
8913
+ }
8717
8914
  });