@oar-sh/oar 0.9.20 → 0.9.21

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 (95) hide show
  1. package/CHANGELOG.md +212 -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 +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -42,6 +42,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
42
42
  - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
43
43
  - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
44
44
  - Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
45
+ - `list_sessions` and `read_session` show a session's worker process, while it has one, as "worker" (kind, buildId, stale); "stale": true means it runs older code than that relay, which replaces it by itself once the session is idle, so there is nothing to do about it.
45
46
  - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
46
47
  - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
47
48
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
@@ -461,6 +461,29 @@ export function createMessageRepository(db) {
461
461
  WHERE status = 'processing'
462
462
  AND NULLIF(owner_sdk_session_id, '') = ?
463
463
  `),
464
+ // The worker recycle's quiet window (worker-recycle-service.mjs): the
465
+ // last time the session had a delivery or a turn end, as an ISO
466
+ // string, or NULL when the queue holds no row of it. Over the rows the
467
+ // session owns: a row is stamped with its owner at enqueue under
468
+ // session-worker routing and at the latest by its claim, and a
469
+ // conversation rebound to another session leaves its old rows with the
470
+ // session that ran them. The queue keeps no completion time
471
+ // (setDone clears processing_at), so a turn's end is its response
472
+ // message's timestamp; the claim and the heartbeat lease
473
+ // (owner_last_claimed_at) and the assignment stand for the delivery.
474
+ // Each column is COALESCEd: SQLite's multi-argument MAX() is NULL as
475
+ // soon as one argument is.
476
+ getOwnerLastActivityAt: db.prepare(`
477
+ SELECT MAX(NULLIF(MAX(
478
+ COALESCE(q.processing_at, ''),
479
+ COALESCE(q.owner_assigned_at, ''),
480
+ COALESCE(q.owner_last_claimed_at, ''),
481
+ COALESCE(m.timestamp, '')
482
+ ), '')) AS last_activity_at
483
+ FROM queue q
484
+ LEFT JOIN messages m ON m.id = q.response_message_id
485
+ WHERE NULLIF(q.owner_sdk_session_id, '') = ?
486
+ `),
464
487
  recoverProcessingRowKeepOwner: db.prepare(`
465
488
  UPDATE queue
466
489
  SET status = 'pending',
@@ -100,6 +100,25 @@ export function createSessionRepository(db) {
100
100
  getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
101
101
  getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
102
102
  listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
103
+ // The session ids of archived conversations: the worker sweep at relay
104
+ // start stops what still runs for them. A runtime session id is unique
105
+ // (idx_runtime_sessions_sdk_session_id), but a conversation's own
106
+ // sdk_session_id is not: an id that a conversation in use (not
107
+ // archived, not deleted) names too is left out, so that
108
+ // conversation's worker is never stopped for an archived one.
109
+ listArchivedRuntimeSessionIds: db.prepare(`
110
+ SELECT DISTINCT rs.sdk_session_id AS sdk_session_id
111
+ FROM runtime_sessions rs
112
+ JOIN conversations c ON c.id = rs.conversation_id
113
+ WHERE c.archived = 1
114
+ AND NULLIF(rs.sdk_session_id, '') IS NOT NULL
115
+ AND NOT EXISTS (
116
+ SELECT 1 FROM conversations live_c
117
+ WHERE live_c.sdk_session_id = rs.sdk_session_id
118
+ AND COALESCE(live_c.archived, 0) = 0
119
+ AND live_c.status != 'deleted'
120
+ )
121
+ `),
103
122
  insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
104
123
  ? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
105
124
  : `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
@@ -6,6 +6,9 @@
6
6
  // environment, account, login state
7
7
  // POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
8
8
  // POST /api/claude-cloud-session the worker's binding report
9
+ // GET /api/claude-cloud-session `?conversationId=` (or the sdk session
10
+ // id) → the stored binding a starting
11
+ // worker attaches to
9
12
  // GET /api/claude-cloud/repos the repositories New Chat may suggest
10
13
  // (`?refresh=1` reads them afresh)
11
14
  // GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
@@ -77,6 +80,22 @@ export function registerClaudeCloudRoutes(app, deps = {}) {
77
80
  if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
78
81
  return res.json({ ok: true, cloud: result.cloud });
79
82
  });
83
+
84
+ // GET /api/claude-cloud-session?conversationId=<id> — a starting Claude
85
+ // Cloud worker asks which cloud session it is bound to and from which
86
+ // sequence to read on, so it opens the event stream before any delivery
87
+ // (a relaunched worker follows the turns the session starts by itself
88
+ // from its start). The id may be the conversation id or the sdk session
89
+ // id. `{ ok, sessionId, lastSequence }`, `sessionId: null` when there is
90
+ // no cloud session; 404 only for an unknown conversation.
91
+ app.get('/api/claude-cloud-session', auth, (req, res) => {
92
+ touchCli();
93
+ const raw = req.query?.conversationId ?? req.query?.sdkSessionId;
94
+ const id = Array.isArray(raw) ? raw[0] : raw;
95
+ const result = claudeCloudSessionService.describeStoredBinding(typeof id === 'string' ? id : '');
96
+ if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
97
+ return res.json({ ok: true, sessionId: result.sessionId, lastSequence: result.lastSequence });
98
+ });
80
99
  }
81
100
 
82
101
  if (claudeCloudLoginStatus) {
@@ -45,6 +45,7 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
45
45
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
46
46
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
47
47
  import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
48
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
48
49
  import {
49
50
  DEFAULT_TASK_STALL_MINUTES,
50
51
  TASK_OUTPUT_LIMITS,
@@ -1201,6 +1202,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1201
1202
  workerKind = null,
1202
1203
  onBillingFence = null,
1203
1204
  claudeApiClaimCheck = null,
1205
+ // () → true while the replacement fence (services/worker-replacement.mjs)
1206
+ // is up for the requesting process. The caller asks it before calling; it
1207
+ // is asked again before every claim, since ensureWorker and a transient
1208
+ // error's backoff both wait, and the worker recycle can raise the fence
1209
+ // meanwhile.
1210
+ isReplacementFenced = null,
1204
1211
  } = {}) {
1205
1212
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1206
1213
  const routingWithWorker = routingEnabled && requesterSid;
@@ -1275,6 +1282,18 @@ export async function dequeuePendingMessageForWorkerLoop({
1275
1282
 
1276
1283
  let attempt = 0;
1277
1284
  while (attempt <= retryLimit) {
1285
+ // The process asking is being replaced: no row is claimed for it, the
1286
+ // same answer as the caller's own fence check gives.
1287
+ if (routingWithWorker && typeof isReplacementFenced === 'function' && isReplacementFenced() === true) {
1288
+ return {
1289
+ message: null,
1290
+ blockedReason: 'worker-replacement',
1291
+ worker: supervisor?.getWorkerState?.(requesterSid) || null,
1292
+ lifecycle: supervisor?.getLifecycleState?.(requesterSid) || null,
1293
+ fallbackRestart: null,
1294
+ attempts: attempt,
1295
+ };
1296
+ }
1278
1297
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
1279
1298
  try {
1280
1299
  const message = dequeuePendingMessage({
@@ -2065,6 +2084,9 @@ export function registerMessagesRoutes(app, deps) {
2065
2084
  // killTmuxSessionImpl). Tests must inject these instead of relying on the
2066
2085
  // host OS, so the suite behaves identically on Windows and POSIX.
2067
2086
  sessionWorkerStopOverrides = null,
2087
+ // The replacement fence (services/worker-replacement.mjs): a worker
2088
+ // process the recycle is replacing is handed no work by /api/pending.
2089
+ workerReplacementFence = null,
2068
2090
  resolveSessionStateRoot,
2069
2091
  fetchUsageSummary = null,
2070
2092
  planUsageService = null,
@@ -6245,7 +6267,10 @@ export function registerMessagesRoutes(app, deps) {
6245
6267
  // Whether a billing swap may replace the worker now, and why not.
6246
6268
  || (stored.releasable ?? null) !== (steering.releasable ?? null)
6247
6269
  || (stored.releaseHold || null) !== (steering.releaseHold || null)
6248
- || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
6270
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
6271
+ // Whether the worker's CLI child still runs: the worker recycle
6272
+ // waits for it to be gone, so a flip must be stored.
6273
+ || (stored.cliAlive ?? null) !== (steering.cliAlive ?? null);
6249
6274
  if (workerForSteering && changed) {
6250
6275
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
6251
6276
  // Pushed rather than left for the clients' status poll: the
@@ -6263,6 +6288,16 @@ export function registerMessagesRoutes(app, deps) {
6263
6288
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6264
6289
  }
6265
6290
  }
6291
+ // The build id of the code the worker started on, recorded the same way:
6292
+ // only when it says one and the registry has another, so quiet
6293
+ // heartbeats stay write-free.
6294
+ const heartbeatBuildId = normalizeBuildId(req.body?.buildId);
6295
+ if (heartbeatBuildId) {
6296
+ const workerForBuild = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6297
+ if (workerForBuild && workerForBuild.buildId !== heartbeatBuildId) {
6298
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForBuild, buildId: heartbeatBuildId });
6299
+ }
6300
+ }
6266
6301
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6267
6302
  // The worker's digest of its live background-task ids against the set
6268
6303
  // held for it (keyed as the worker posts, by its session id). They
@@ -7009,6 +7044,24 @@ export function registerMessagesRoutes(app, deps) {
7009
7044
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
7010
7045
  });
7011
7046
  }
7047
+ // The process asking is being replaced (the worker recycle): no work,
7048
+ // as for a killed session, so no turn starts on the pid being stopped.
7049
+ // Unlike the killed marker, the fence outlives the marker's grace and
7050
+ // every clearRestartSchedule; the new worker has another pid and takes
7051
+ // what waited meanwhile.
7052
+ if (workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true) {
7053
+ return res.json({
7054
+ message: null,
7055
+ routing: {
7056
+ enabled: true,
7057
+ requesterSessionId,
7058
+ blockedReason: 'worker-replacement',
7059
+ lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
7060
+ fallbackRestart: null,
7061
+ },
7062
+ restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
7063
+ });
7064
+ }
7012
7065
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
7013
7066
  }
7014
7067
  if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
@@ -7103,6 +7156,8 @@ export function registerMessagesRoutes(app, deps) {
7103
7156
  workerKind: httpPendingWorkerKind(requesterSessionId),
7104
7157
  claudeApiClaimCheck,
7105
7158
  onBillingFence: onClaudeBillingFence,
7159
+ // The fence check above, asked again before every claim.
7160
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7106
7161
  telemetry: (payload = {}) => {
7107
7162
  if (!sessionWorkerRoutingEnabled) return;
7108
7163
  emitSessionWorkerTelemetry(payload.event, {
@@ -7323,6 +7378,7 @@ export function registerMessagesRoutes(app, deps) {
7323
7378
  workerKind: httpPendingWorkerKind(requesterSessionId),
7324
7379
  claudeApiClaimCheck,
7325
7380
  onBillingFence: onClaudeBillingFence,
7381
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7326
7382
  });
7327
7383
  if (recoveryDequeue?.message) {
7328
7384
  msg = recoveryDequeue.message;
@@ -43,6 +43,7 @@ import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '..
43
43
  import { buildContextUsageView } from '../services/context-usage-view.mjs';
44
44
  import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
45
45
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
46
+ import { isWorkerOnOtherBuild } from '../services/worker-recycle-service.mjs';
46
47
  import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
47
48
  import { createCliInstallService } from '../services/cli-install-service.mjs';
48
49
  import { createGrokAuthService } from '../services/grok-auth-service.mjs';
@@ -69,7 +70,7 @@ import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
69
70
  import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
70
71
  import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
71
72
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
72
- import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
73
+ import { sessionLaunchMutex } from '../services/session-launch-mutex-service.mjs';
73
74
  import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
74
75
  import { requestHostOf } from '../services/request-boundary.mjs';
75
76
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
@@ -1452,11 +1453,27 @@ export function buildSessionWorkerStatusPayload({
1452
1453
  // What the last read of the process list did ({ at, durationMs, processes,
1453
1454
  // error }); a slow box shows here before it shows as 502s.
1454
1455
  processList = null,
1456
+ // The relay's own build id (runtimeState.buildId): each worker's is shown
1457
+ // next to it, so a worker still on the code before a deploy stands out.
1458
+ relayBuildId = null,
1459
+ // The worker recycle's status() (stale count, recycles in flight and done,
1460
+ // who waits on what), or null while the recycle is not wired.
1461
+ recycleStatus = null,
1455
1462
  } = {}) {
1456
1463
  const snapshot = supervisorSnapshot && typeof supervisorSnapshot === 'object'
1457
1464
  ? supervisorSnapshot
1458
1465
  : {};
1459
- const workers = Array.isArray(snapshot.workers) ? snapshot.workers : [];
1466
+ const relayBuild = typeof relayBuildId === 'string' && relayBuildId.trim() ? relayBuildId.trim() : null;
1467
+ const workers = (Array.isArray(snapshot.workers) ? snapshot.workers : []).map((worker) => (
1468
+ worker && typeof worker === 'object'
1469
+ ? {
1470
+ ...worker,
1471
+ buildId: typeof worker.buildId === 'string' && worker.buildId ? worker.buildId : null,
1472
+ startedAt: worker.startedAt || null,
1473
+ stale: isWorkerOnOtherBuild(worker, relayBuild),
1474
+ }
1475
+ : worker
1476
+ ));
1460
1477
  const onlineWorkerStatuses = new Set(SESSION_WORKER_ACTIVE_STATUSES);
1461
1478
  const onlineCount = workers.reduce((count, worker) => {
1462
1479
  const status = normalizeWorkerStatusText(worker?.status, 'new');
@@ -1561,6 +1578,8 @@ export function buildSessionWorkerStatusPayload({
1561
1578
  onlineUnassignedProcessCount,
1562
1579
  counts: snapshot.counts && typeof snapshot.counts === 'object' ? snapshot.counts : {},
1563
1580
  workers,
1581
+ buildId: relayBuild,
1582
+ recycle: recycleStatus && typeof recycleStatus === 'object' ? recycleStatus : null,
1564
1583
  pendingStarts: toSafeNonNegativeInt(snapshot.pendingStarts, 0),
1565
1584
  lifecycle: Array.isArray(snapshot.lifecycle) ? snapshot.lifecycle : [],
1566
1585
  integrity,
@@ -2608,9 +2627,10 @@ export function registerSessionsRoutes(app, deps) {
2608
2627
  bucketTtlMs: 60_000,
2609
2628
  maxBuckets: 4_096,
2610
2629
  });
2611
- // Serializes everything that reaches the session-worker launch path, keyed by
2612
- // sdkSessionId, plus a coalescer so a duplicated request replays one result.
2613
- const sessionLaunchMutex = createKeyedMutex();
2630
+ // Everything that reaches the session-worker launch path is serialized by
2631
+ // the relay-wide sessionLaunchMutex (services/session-launch-mutex-service.mjs),
2632
+ // keyed by sdkSessionId, which the worker recycle takes too; plus a
2633
+ // coalescer so a duplicated request replays one result.
2614
2634
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2615
2635
 
2616
2636
  function getDeepSeekProviderSettings() {
@@ -6099,7 +6119,7 @@ export function registerSessionsRoutes(app, deps) {
6099
6119
  // Kill session stops the session's worker, and the delete counts no
6100
6120
  // tasks for a conversation whose worker is gone.
6101
6121
  message: activity.unstoppableTaskCount > 0
6102
- ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
6122
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then delete it.'
6103
6123
  : 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
6104
6124
  },
6105
6125
  };
@@ -6258,7 +6278,7 @@ export function registerSessionsRoutes(app, deps) {
6258
6278
  error: 'conversation-active',
6259
6279
  reason: 'background-tasks',
6260
6280
  message: activity.unstoppableTaskCount > 0
6261
- ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then archive it.'
6281
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then archive it.'
6262
6282
  : 'Background tasks are still running in this conversation. Stop them in the task panel, then archive it.',
6263
6283
  },
6264
6284
  };
@@ -7129,6 +7149,18 @@ export function registerSessionsRoutes(app, deps) {
7129
7149
  }
7130
7150
  });
7131
7151
 
7152
+ // The worker recycle's status, read at request time: the recycle is set on
7153
+ // runtimeState after the routes are registered, and a status that throws
7154
+ // must not take /api/status down with it.
7155
+ function readWorkerRecycleStatus() {
7156
+ if (typeof runtimeState.workerRecycle !== 'function') return null;
7157
+ try {
7158
+ return runtimeState.workerRecycle() || null;
7159
+ } catch {
7160
+ return null;
7161
+ }
7162
+ }
7163
+
7132
7164
  // GET /api/status — overall status
7133
7165
  app.get('/api/status', auth, (req, res) => {
7134
7166
  ensureSessionId(req, res);
@@ -7146,6 +7178,8 @@ export function registerSessionsRoutes(app, deps) {
7146
7178
  supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
7147
7179
  queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
7148
7180
  processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
7181
+ relayBuildId: runtimeState.buildId ?? null,
7182
+ recycleStatus: readWorkerRecycleStatus(),
7149
7183
  });
7150
7184
  const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
7151
7185
  ? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)