@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
@@ -0,0 +1,772 @@
1
+ /**
2
+ * The worker recycle: session workers that run older code than the relay are
3
+ * replaced, between turns, by workers on the relay's code.
4
+ *
5
+ * A session worker loads its code once at start. A deploy plus a relay
6
+ * restart replaces the relay process, but the workers only reconnect and keep
7
+ * running what they started with. Each worker states its build id (hello,
8
+ * ready, heartbeat; shared/build-id.mjs); this service compares it with the
9
+ * relay's and, on a periodic sweep:
10
+ *
11
+ * - waits while the session owns a turn the relay handed out (a `processing`
12
+ * row) or the worker is not releasable (a turn, a question, a compaction,
13
+ * background tasks: workerReleaseHold), and while its CLI child still runs
14
+ * (below);
15
+ * - stops a stale worker of an archived conversation and leaves the session
16
+ * kill-blocked, as the archive route does: an archived session keeps no
17
+ * worker;
18
+ * - else stops the worker and every process of the session and launches a
19
+ * new one (worker-replacement.mjs, the Change CWD mechanism).
20
+ *
21
+ * Releasable is not enough: a worker between two turns of a conversation in
22
+ * use is releasable too, and recycling it would cost the user a cold CLI on
23
+ * the next message. So a stale worker is recycled only once its CLI child is
24
+ * gone as well. The Claude and Copilot runners stop the child after their
25
+ * idle time and say so (`steering.cliAlive`); a worker that does not say (an
26
+ * older build, a kind without a CLI child) is taken as idle only after
27
+ * `quietMs` without a delivery or a turn end for the session, the runners'
28
+ * own idle time.
29
+ *
30
+ * From the moment a recycle passes its holds until its launch is done, the
31
+ * replacement fence (worker-replacement.mjs) keeps every delivery away from
32
+ * the process being replaced, and the stop and the launch run under the
33
+ * relay's per-session launch mutex (session-launch-mutex-service.mjs), as the
34
+ * Change CWD relaunch does. Before the launch the session is asked again
35
+ * whether it is archived, and whether a new worker came up by another way
36
+ * meanwhile (a delivery's primer): that one is kept, never killed by a second
37
+ * launch.
38
+ *
39
+ * The launches are paced, at most `maxInFlight` at a time and one per
40
+ * `minStartGapMs` on the relay: a deploy leaves every worker stale at once,
41
+ * and each Node worker boot is a memory spike. A stop or a launch that fails
42
+ * is retried with a backoff, and a session that keeps failing is named once
43
+ * in a warning; nothing reaches the chat, since nothing of the conversation
44
+ * is lost and the next message relaunches the worker anyway.
45
+ *
46
+ * Storm guards: a worker whose build id equals the relay's is never touched,
47
+ * a relay that does not know its own build id recycles nothing, a worker
48
+ * process (pid) is recycled at most once, so a stale-looking entry the stop
49
+ * left behind is never stopped again, and a session is recycled at most once
50
+ * per relay build id: a new process that still states another id (or none)
51
+ * runs from a tree other than the relay's, and recycling it again would loop.
52
+ */
53
+
54
+ import { buildIdsDiffer, normalizeBuildId } from '../../shared/build-id.mjs';
55
+ import { createWorkerReplacementFence, workerReleaseHold } from './worker-replacement.mjs';
56
+
57
+ const LOG_TAG = '[worker-recycle]';
58
+
59
+ /**
60
+ * What a stale worker can wait for, as `status().waiting[].hold` names it:
61
+ * the worker's own release holds (workerReleaseHold; 'turn' also for a
62
+ * processing row the session owns), 'cli' while its CLI child runs, and
63
+ * 'quiet' while a worker that does not report its CLI child has had a
64
+ * delivery or a turn end within `quietMs`.
65
+ */
66
+ export const RECYCLE_HOLDS = Object.freeze(['turn', 'question', 'compaction', 'background', 'cli', 'quiet']);
67
+ const RECYCLABLE_STATUSES = new Set(['ready', 'processing']);
68
+
69
+ function sid8(sid) {
70
+ return String(sid || '').slice(0, 8);
71
+ }
72
+
73
+ function toPid(value) {
74
+ const pid = Number(value);
75
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
76
+ }
77
+
78
+ // A registry time (ISO string or epoch ms) in epoch ms, or null.
79
+ function toTimeMs(value) {
80
+ if (value === null || value === undefined || value === '') return null;
81
+ const ms = typeof value === 'number' ? value : Date.parse(String(value));
82
+ return Number.isFinite(ms) ? ms : null;
83
+ }
84
+
85
+ function errorText(error, fallback) {
86
+ return String(error?.message || error || fallback);
87
+ }
88
+
89
+ /**
90
+ * Whether a worker runs other code than the relay, for display (/api/status,
91
+ * /api/about, relay_tool rows): its build id differs from the relay's, or it
92
+ * states none, which only a worker older than the build id does. Unlike
93
+ * isStale it knows no grace, status or heartbeat: it answers "which build",
94
+ * not "recycle now". A relay that does not know its own id compares nothing.
95
+ */
96
+ export function isWorkerOnOtherBuild(worker, relayBuildId) {
97
+ const relayId = normalizeBuildId(relayBuildId);
98
+ if (!relayId || !worker) return false;
99
+ const workerId = normalizeBuildId(worker.buildId);
100
+ return workerId ? buildIdsDiffer(workerId, relayId) : true;
101
+ }
102
+
103
+ export function createWorkerRecycleService({
104
+ // The relay's own build id, computed once at relay start.
105
+ relayBuildId = null,
106
+ // () → the registry's worker entries, in registry order.
107
+ listWorkers = () => [],
108
+ // sid → the registry's entry for the session's worker, or null.
109
+ getWorker = () => null,
110
+ // sid → whether the session owns a turn the relay handed out and has not
111
+ // seen finish (a `processing` queue row). The relay's own record, asked
112
+ // before the worker's snapshot, which can lag a claim by a pulse.
113
+ hasRunningTurn = () => false,
114
+ // sid → whether the worker process the registry names has heartbeated to
115
+ // this relay. A worker that states no build id is stale only once it has
116
+ // (and its entry is old enough, below): a process still booting has not
117
+ // said anything yet.
118
+ hasHeartbeated = () => false,
119
+ // sid → whether the session's conversation is archived (or the session has
120
+ // no runtime session row at all): its worker is stopped, never relaunched.
121
+ isArchived = () => false,
122
+ // sid → when the session last had a delivery or a turn end (epoch ms or an
123
+ // ISO string), or null when the queue knows of none. Asked only for a
124
+ // worker that does not report `steering.cliAlive`.
125
+ quietSince = () => null,
126
+ // async (sid, { keepKilled }) → { ok, error? } (createWorkerReplacement).
127
+ stopWorker,
128
+ // async sid → { ok, error? } (createWorkerReplacement).
129
+ launchWorker,
130
+ // The replacement fence the runtime's deliveries check
131
+ // (createWorkerReplacement().fence): raised for the pid being replaced when
132
+ // a recycle passes its holds, lowered once its launch is done.
133
+ fence = createWorkerReplacementFence(),
134
+ // (sid, fn) → fn's result, run under the relay's per-session launch mutex
135
+ // (sessionLaunchMutex.runExclusive); the stop and the launch go through it.
136
+ runExclusive = (sid, fn) => fn(),
137
+ now = () => Date.now(),
138
+ setTimeoutImpl = setTimeout,
139
+ clearTimeoutImpl = clearTimeout,
140
+ sweepIntervalMs = 15_000,
141
+ maxInFlight = 2,
142
+ minStartGapMs = 3_000,
143
+ retryBaseMs = 5_000,
144
+ retryMaxMs = 60_000,
145
+ stuckAfter = 3,
146
+ waitLogEveryMs = 60_000,
147
+ // A worker that states no build id is stale only once its registry entry's
148
+ // `startedAt` is this old. The supervisor marks a fresh launch 'ready' with
149
+ // a pid before it has said hello, and the session's heartbeat flag can still
150
+ // stem from the previous pid; a worker on current code states its build id
151
+ // in its first hello, within a second of starting, so an id still absent
152
+ // after this long is old code.
153
+ nullBuildIdGraceMs = 30_000,
154
+ // How long a worker that does not report its CLI child must have been
155
+ // without a delivery or a turn end: the Claude and Copilot runners'
156
+ // idleShutdownMs, after which they stop the child themselves.
157
+ quietMs = 10 * 60_000,
158
+ logger = console,
159
+ } = {}) {
160
+ if (typeof stopWorker !== 'function' || typeof launchWorker !== 'function') {
161
+ throw new Error('createWorkerRecycleService requires stopWorker and launchWorker');
162
+ }
163
+ const relayId = normalizeBuildId(relayBuildId);
164
+ const bySession = new Map();
165
+ // The sessions whose stale worker waits for a hold, as of the last sweep.
166
+ let waiting = new Map();
167
+ // sid → the relay build id a recycle of the session launched under. The
168
+ // relay's id is fixed for the process, so in practice "recycled once".
169
+ const recycledUnder = new Map();
170
+ // The sessions left outdated after a recycle, as of the last sweep
171
+ // (sid → the build id the new process states, or null), and those already
172
+ // named in a warning.
173
+ let skipped = new Map();
174
+ const skipWarned = new Set();
175
+ let inFlight = 0;
176
+ let recycled = 0;
177
+ // Relay-wide pacing: when the last recycle was admitted and when the last
178
+ // launch went out. Admission is gated on both, and a launch waits for the
179
+ // gap again, since stops take different times.
180
+ let lastAdmitAt = -Infinity;
181
+ let lastLaunchAt = -Infinity;
182
+ let sweepTimer = null;
183
+ let soonTimer = null;
184
+ let soonAt = Infinity;
185
+ let started = false;
186
+ let unknownLogged = false;
187
+ // A sweep put off a recycle for the cap or the gap: the next one comes as
188
+ // soon as a slot frees instead of a whole interval later, so a deploy's
189
+ // stale workers come back about one per gap.
190
+ let deferred = false;
191
+
192
+ function log(message) {
193
+ try { logger.log?.(`${LOG_TAG} ${message}`); } catch {}
194
+ }
195
+
196
+ function warn(message) {
197
+ try { logger.warn?.(`${LOG_TAG} ${message}`); } catch {}
198
+ }
199
+
200
+ function stateFor(sid) {
201
+ let state = bySession.get(sid);
202
+ if (!state) {
203
+ state = {
204
+ inFlight: false,
205
+ failures: 0,
206
+ nextAttemptAt: 0,
207
+ stuckWarned: false,
208
+ // The last pid this service stopped for the session: never stopped
209
+ // again; a new pid is judged by its own build id.
210
+ recycledPid: null,
211
+ // A recycle whose stop went through and whose launch has not yet:
212
+ // `{ oldPid }`. The registry entry is gone by then, so the sweep
213
+ // retries it from here.
214
+ pendingLaunch: null,
215
+ lastWaitLogAt: -Infinity,
216
+ // When this service first saw the session's current pid: the quiet
217
+ // window's start for a worker with no start time and no queue rows.
218
+ firstSeen: null,
219
+ };
220
+ bySession.set(sid, state);
221
+ }
222
+ return state;
223
+ }
224
+
225
+ // Stale, and not already recycled once under this relay build.
226
+ function isStale(worker) {
227
+ if (!isOutdated(worker)) return false;
228
+ return recycledUnder.get(String(worker.sdkSessionId || '').trim()) !== relayId;
229
+ }
230
+
231
+ // The worker runs other code than the relay and may be replaced for it.
232
+ function isOutdated(worker) {
233
+ if (!relayId || !worker) return false;
234
+ const sid = String(worker.sdkSessionId || '').trim();
235
+ if (!sid || !toPid(worker.pid)) return false;
236
+ if (!RECYCLABLE_STATUSES.has(String(worker.status || ''))) return false;
237
+ const workerId = normalizeBuildId(worker.buildId);
238
+ if (workerId) return buildIdsDiffer(workerId, relayId);
239
+ const startedAtMs = toTimeMs(worker.startedAt);
240
+ // No start time, no age: unknown is not evidence.
241
+ if (startedAtMs === null || now() - startedAtMs < nullBuildIdGraceMs) return false;
242
+ try {
243
+ return hasHeartbeated(sid) === true;
244
+ } catch {
245
+ // Unknown is not evidence: a process that may still be booting is
246
+ // never stopped on a guess.
247
+ return false;
248
+ }
249
+ }
250
+
251
+ function backoffMs(failures) {
252
+ return Math.min(retryMaxMs, retryBaseMs * (2 ** (failures - 1)));
253
+ }
254
+
255
+ function noteFailure(sid, state, step, error) {
256
+ state.failures += 1;
257
+ const delay = backoffMs(state.failures);
258
+ state.nextAttemptAt = now() + delay;
259
+ log(`${step} failed ${sid8(sid)} (${state.failures}): ${error}; retrying in ${Math.round(delay / 1000)}s`);
260
+ if (state.failures >= stuckAfter && !state.stuckWarned) {
261
+ state.stuckWarned = true;
262
+ warn(`session ${sid} is stuck: ${state.failures} recycle attempts failed (last: ${step}: ${error}); retrying every ${Math.round(retryMaxMs / 1000)}s at most`);
263
+ }
264
+ }
265
+
266
+ function noteSuccess(state) {
267
+ state.failures = 0;
268
+ state.nextAttemptAt = 0;
269
+ state.stuckWarned = false;
270
+ }
271
+
272
+ function delay(ms) {
273
+ return new Promise((resolve) => {
274
+ const timer = setTimeoutImpl(resolve, Math.max(0, ms));
275
+ timer?.unref?.();
276
+ });
277
+ }
278
+
279
+ // Resolves once a launch may go out under the relay-wide gap. The caller
280
+ // takes the slot (lastLaunchAt) itself, synchronously right before its
281
+ // launch, so a launch it then decides against costs no slot.
282
+ async function waitForLaunchSlot() {
283
+ for (;;) {
284
+ const remaining = lastLaunchAt + minStartGapMs - now();
285
+ if (remaining <= 0) break;
286
+ await delay(remaining);
287
+ }
288
+ }
289
+
290
+ // A recycle's launch is done (its own, a worker another path brought up, or
291
+ // none for a session archived meanwhile): the fence comes down.
292
+ function endPendingLaunch(sid, state) {
293
+ const oldPid = state.pendingLaunch?.oldPid ?? state.recycledPid;
294
+ state.pendingLaunch = null;
295
+ noteSuccess(state);
296
+ fence.release(sid, oldPid);
297
+ }
298
+
299
+ /**
300
+ * The launch half of a recycle; the stop went through already. Runs under
301
+ * the launch mutex.
302
+ *
303
+ * The fence stays up through a failed launch and its backoff: the old pid is
304
+ * gone by then, so the fence refuses only a process that no longer exists,
305
+ * and lowering it would let nothing through that could run a turn. It comes
306
+ * down once a launch is done: this one or a retry succeeds, a worker came up
307
+ * by another way, or the session was archived.
308
+ */
309
+ async function launch(sid, state) {
310
+ await waitForLaunchSlot();
311
+ // Asked after the wait, which can be seconds long, and synchronously up to
312
+ // the launch, so nothing changes between the answer and the launch.
313
+ let archived = false;
314
+ let current = null;
315
+ try {
316
+ archived = isArchived(sid) === true;
317
+ current = getWorker(sid) || null;
318
+ } catch (error) {
319
+ noteFailure(sid, state, 'probe', errorText(error, 'unknown'));
320
+ return;
321
+ }
322
+ const oldPid = state.pendingLaunch?.oldPid ?? 'none';
323
+ if (archived) {
324
+ // Archived since the stop: the session stays without a worker, and its
325
+ // killed marker (set by the stop) is left as the archive route leaves it.
326
+ endPendingLaunch(sid, state);
327
+ log(`stopped ${sid8(sid)} (archived)`);
328
+ return;
329
+ }
330
+ const livePid = toPid(current?.pid);
331
+ if (livePid && livePid !== state.recycledPid) {
332
+ // Another path (the primer, the next delivery) already brought a new
333
+ // worker up, on the relay's code since it started after the deploy. A
334
+ // launch now would stop it again (no process reuse), mid-boot or
335
+ // mid-turn: the job is done.
336
+ endPendingLaunch(sid, state);
337
+ recycled += 1;
338
+ recycledUnder.set(sid, relayId);
339
+ log(`recycled ${sid8(sid)} pid ${oldPid}→${livePid} (started elsewhere)`);
340
+ return;
341
+ }
342
+ lastLaunchAt = now();
343
+ let launched = null;
344
+ try {
345
+ launched = await launchWorker(sid);
346
+ } catch (error) {
347
+ launched = { ok: false, error: errorText(error, 'worker-launch-failed') };
348
+ }
349
+ if (!launched?.ok) {
350
+ noteFailure(sid, state, 'launch', launched?.error || 'unknown');
351
+ return;
352
+ }
353
+ endPendingLaunch(sid, state);
354
+ recycled += 1;
355
+ recycledUnder.set(sid, relayId);
356
+ let newPid = null;
357
+ try { newPid = toPid(getWorker(sid)?.pid); } catch {}
358
+ log(`recycled ${sid8(sid)} pid ${oldPid}→${newPid || 'pending'}`);
359
+ }
360
+
361
+ // Whether the registry still names the process the sweep judged. The launch
362
+ // mutex can have made the recycle wait for a Change CWD relaunch, which
363
+ // replaces the worker: the fresh one is not the stale one and is left alone.
364
+ function stillSameProcess(sid, pid) {
365
+ try {
366
+ return toPid(getWorker(sid)?.pid) === pid;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ // The fence for `pid` is up when this starts (the sweep raised it).
373
+ async function recycle(sid, worker, state) {
374
+ const pid = toPid(worker.pid);
375
+ let stopped = false;
376
+ try {
377
+ await runExclusive(sid, async () => {
378
+ if (!stillSameProcess(sid, pid)) return;
379
+ log(`recycling ${sid8(sid)} kind=${worker.kind || 'unknown'} from=${normalizeBuildId(worker.buildId) || 'none'} to=${relayId}`);
380
+ let result = null;
381
+ try {
382
+ result = await stopWorker(sid);
383
+ } catch (error) {
384
+ result = { ok: false, error: errorText(error, 'worker-stop-failed') };
385
+ }
386
+ if (!result?.ok) {
387
+ noteFailure(sid, state, 'stop', result?.error || 'unknown');
388
+ return;
389
+ }
390
+ stopped = true;
391
+ state.recycledPid = pid;
392
+ state.pendingLaunch = { oldPid: pid };
393
+ await launch(sid, state);
394
+ });
395
+ } finally {
396
+ // A stop that did not go through (or never ran) leaves the old worker
397
+ // serving its session: it takes work again at once.
398
+ if (!stopped) fence.release(sid, pid);
399
+ }
400
+ }
401
+
402
+ async function stopArchived(sid, worker, state) {
403
+ const pid = toPid(worker.pid);
404
+ try {
405
+ await runExclusive(sid, async () => {
406
+ if (!stillSameProcess(sid, pid)) return;
407
+ let stopped = null;
408
+ try {
409
+ stopped = await stopWorker(sid, { keepKilled: true });
410
+ } catch (error) {
411
+ stopped = { ok: false, error: errorText(error, 'worker-stop-failed') };
412
+ }
413
+ if (!stopped?.ok) {
414
+ noteFailure(sid, state, 'stop', stopped?.error || 'unknown');
415
+ return;
416
+ }
417
+ state.recycledPid = pid;
418
+ noteSuccess(state);
419
+ log(`stopped ${sid8(sid)} (archived)`);
420
+ });
421
+ } finally {
422
+ // Nothing is launched for an archived session: the killed marker the
423
+ // stop keeps is what holds it stopped, not the fence.
424
+ fence.release(sid, pid);
425
+ }
426
+ }
427
+
428
+ // Runs one recycle step for a session, keeping the in-flight count; never
429
+ // rejects.
430
+ function begin(state, work) {
431
+ state.inFlight = true;
432
+ inFlight += 1;
433
+ return Promise.resolve()
434
+ .then(work)
435
+ .catch((error) => { warn(`recycle step failed unexpectedly: ${errorText(error, 'unknown')}`); })
436
+ .finally(() => {
437
+ state.inFlight = false;
438
+ inFlight -= 1;
439
+ if (deferred) scheduleSoon(Math.max(0, lastAdmitAt + minStartGapMs - now()));
440
+ });
441
+ }
442
+
443
+ function canLaunchNow() {
444
+ const at = now();
445
+ return at - lastAdmitAt >= minStartGapMs && at - lastLaunchAt >= minStartGapMs;
446
+ }
447
+
448
+ /**
449
+ * What the stale worker waits for, or null when it may be recycled now:
450
+ * a turn the relay handed out, the worker's own release hold, then its CLI
451
+ * child ('cli' while the worker says it runs) or, for a worker that does
452
+ * not say, a delivery or a turn end within `quietMs` ('quiet'). The quiet
453
+ * window starts at the session's last delivery or turn end, else at the
454
+ * worker's start, else when this service first saw the process. Throws
455
+ * when a probe does; the caller counts that as a probe failure.
456
+ */
457
+ function holdFor(sid, worker, state) {
458
+ if (hasRunningTurn(sid) === true) return 'turn';
459
+ const releaseHold = workerReleaseHold(worker);
460
+ if (releaseHold) return releaseHold;
461
+ const cliAlive = worker?.steering?.cliAlive;
462
+ if (cliAlive === true) return 'cli';
463
+ if (cliAlive === false) return null;
464
+ const pid = toPid(worker.pid);
465
+ if (state.firstSeen?.pid !== pid) state.firstSeen = { pid, at: now() };
466
+ const since = toTimeMs(quietSince(sid)) ?? toTimeMs(worker.startedAt) ?? state.firstSeen.at;
467
+ return now() - since < quietMs ? 'quiet' : null;
468
+ }
469
+
470
+ function noteWaiting(sid, state, hold, nextWaiting) {
471
+ nextWaiting.set(sid, hold);
472
+ if (now() - state.lastWaitLogAt >= waitLogEveryMs) {
473
+ state.lastWaitLogAt = now();
474
+ log(`waiting ${sid8(sid)}: ${hold}`);
475
+ }
476
+ }
477
+
478
+ // A session recycled under this build whose new process is outdated still:
479
+ // its tree is not the relay's (another COPILOT_WEB_RELAY_ROOT, a package.json
480
+ // one side cannot read). Named once; another recycle would start the same
481
+ // code again.
482
+ function noteSkipped(sid, worker, nextSkipped) {
483
+ const buildId = normalizeBuildId(worker.buildId);
484
+ nextSkipped.set(sid, buildId);
485
+ if (skipWarned.has(sid)) return;
486
+ skipWarned.add(sid);
487
+ if (buildId) {
488
+ warn(`${sid8(sid)} still on ${buildId} after a recycle under ${relayId}: the worker tree differs from the relay's; not recycled again under this build`);
489
+ } else {
490
+ warn(`${sid8(sid)} still states no build id after a recycle under ${relayId}: the worker tree differs from the relay's; not recycled again under this build`);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * One pass over the registry. Starts the recycles it may and resolves once
496
+ * they are done (the timer does not wait for that); never rejects.
497
+ */
498
+ async function sweep() {
499
+ if (!relayId) return;
500
+ const startedSteps = [];
501
+ const nextWaiting = new Map();
502
+ const nextSkipped = new Map();
503
+ deferred = false;
504
+ let workers = [];
505
+ try {
506
+ workers = Array.from(listWorkers() || []);
507
+ } catch (error) {
508
+ warn(`cannot list workers: ${errorText(error, 'unknown')}`);
509
+ return;
510
+ }
511
+ const running = new Set();
512
+ for (const worker of workers) {
513
+ const sid = String(worker?.sdkSessionId || '').trim();
514
+ if (!sid) continue;
515
+ const pid = toPid(worker.pid);
516
+ const existing = bySession.get(sid);
517
+ // A new process that came up by another way (a delivery primed it)
518
+ // ends a launch this service still owed the session.
519
+ if (pid && pid !== existing?.recycledPid) {
520
+ running.add(sid);
521
+ if (existing?.pendingLaunch && !existing.inFlight) endPendingLaunch(sid, existing);
522
+ }
523
+ if (!isOutdated(worker)) continue;
524
+ if (recycledUnder.get(sid) === relayId) {
525
+ // The process the recycle stopped, listed late, needs no word.
526
+ if (pid && pid !== existing?.recycledPid) noteSkipped(sid, worker, nextSkipped);
527
+ continue;
528
+ }
529
+ const state = stateFor(sid);
530
+ if (state.inFlight || now() < state.nextAttemptAt) continue;
531
+ if (state.recycledPid && state.recycledPid === toPid(worker.pid)) continue;
532
+ let hold = null;
533
+ let archived = false;
534
+ try {
535
+ hold = holdFor(sid, worker, state);
536
+ if (!hold) archived = isArchived(sid) === true;
537
+ } catch (error) {
538
+ // The relay cannot tell whether the session is busy or archived: the
539
+ // worker is not stopped on a guess, and the probe is retried later.
540
+ noteFailure(sid, state, 'probe', errorText(error, 'unknown'));
541
+ continue;
542
+ }
543
+ if (hold) {
544
+ noteWaiting(sid, state, hold, nextWaiting);
545
+ continue;
546
+ }
547
+ if (inFlight >= maxInFlight) {
548
+ deferred = true;
549
+ continue;
550
+ }
551
+ if (archived) {
552
+ // Fenced from here: the holds just passed, and a delivery to the
553
+ // process being stopped would start a turn on it.
554
+ fence.hold(sid, pid);
555
+ startedSteps.push(begin(state, () => stopArchived(sid, worker, state)));
556
+ continue;
557
+ }
558
+ if (!canLaunchNow()) {
559
+ deferred = true;
560
+ continue;
561
+ }
562
+ lastAdmitAt = now();
563
+ // Fenced from the moment the holds passed, synchronously, before any
564
+ // await: no delivery can reach the old process between this check and
565
+ // its stop, nor between the stop and the launch.
566
+ fence.hold(sid, pid);
567
+ startedSteps.push(begin(state, () => recycle(sid, worker, state)));
568
+ }
569
+ // Launches owed after a stop went through: the registry lists no new
570
+ // process for those sessions.
571
+ for (const [sid, state] of bySession) {
572
+ if (running.has(sid) || !state.pendingLaunch || state.inFlight || now() < state.nextAttemptAt) continue;
573
+ let archived = false;
574
+ try {
575
+ archived = isArchived(sid) === true;
576
+ } catch (error) {
577
+ noteFailure(sid, state, 'probe', errorText(error, 'unknown'));
578
+ continue;
579
+ }
580
+ if (archived) {
581
+ // Archived since the stop: the session stays without a worker.
582
+ endPendingLaunch(sid, state);
583
+ log(`stopped ${sid8(sid)} (archived)`);
584
+ continue;
585
+ }
586
+ if (inFlight >= maxInFlight || !canLaunchNow()) {
587
+ deferred = true;
588
+ continue;
589
+ }
590
+ lastAdmitAt = now();
591
+ startedSteps.push(begin(state, () => runExclusive(sid, () => launch(sid, state))));
592
+ }
593
+ waiting = nextWaiting;
594
+ skipped = nextSkipped;
595
+ if (deferred && inFlight < maxInFlight) {
596
+ scheduleSoon(Math.max(0, lastAdmitAt + minStartGapMs - now()));
597
+ }
598
+ await Promise.all(startedSteps);
599
+ }
600
+
601
+ function runSweep() {
602
+ void sweep().catch(() => {});
603
+ }
604
+
605
+ // An extra sweep at `ms` from now, unless one is due sooner; only while
606
+ // the service runs.
607
+ function scheduleSoon(ms) {
608
+ if (!started) return;
609
+ const at = now() + ms;
610
+ if (soonTimer && soonAt <= at) return;
611
+ if (soonTimer) clearTimeoutImpl(soonTimer);
612
+ soonAt = at;
613
+ soonTimer = setTimeoutImpl(() => {
614
+ soonTimer = null;
615
+ soonAt = Infinity;
616
+ runSweep();
617
+ }, ms);
618
+ soonTimer?.unref?.();
619
+ }
620
+
621
+ function scheduleNext() {
622
+ sweepTimer = setTimeoutImpl(() => {
623
+ sweepTimer = null;
624
+ if (!started) return;
625
+ runSweep();
626
+ scheduleNext();
627
+ }, sweepIntervalMs);
628
+ sweepTimer?.unref?.();
629
+ }
630
+
631
+ function start() {
632
+ if (started) return;
633
+ if (!relayId) {
634
+ if (!unknownLogged) {
635
+ unknownLogged = true;
636
+ warn('relay build id unknown; workers are not recycled');
637
+ }
638
+ return;
639
+ }
640
+ started = true;
641
+ scheduleNext();
642
+ }
643
+
644
+ function stop() {
645
+ started = false;
646
+ if (sweepTimer) clearTimeoutImpl(sweepTimer);
647
+ if (soonTimer) clearTimeoutImpl(soonTimer);
648
+ sweepTimer = null;
649
+ soonTimer = null;
650
+ soonAt = Infinity;
651
+ }
652
+
653
+ function status() {
654
+ let staleCount = 0;
655
+ try {
656
+ for (const worker of listWorkers() || []) {
657
+ if (isStale(worker)) staleCount += 1;
658
+ }
659
+ } catch {}
660
+ return {
661
+ relayBuildId: relayId,
662
+ staleCount,
663
+ inFlight,
664
+ recycled,
665
+ waiting: Array.from(waiting, ([sdkSessionId, hold]) => ({ sdkSessionId, hold })),
666
+ skipped: Array.from(skipped, ([sdkSessionId, buildId]) => ({ sdkSessionId, buildId })),
667
+ };
668
+ }
669
+
670
+ return { start, stop, sweep, isStale, status };
671
+ }
672
+
673
+ /**
674
+ * The archived sweep at relay start: what still runs for an archived
675
+ * conversation is stopped for good, whatever code it runs. The archive route
676
+ * stops a worker when the conversation is archived, but a worker started
677
+ * since (or before the route did so) would otherwise live on, unused, until
678
+ * the host restarts. The recycle above stops only stale ones.
679
+ *
680
+ * One session at a time, so a long list never stops many processes at once;
681
+ * the killed marker stays (`keepKilled`), as the archive route leaves it.
682
+ * Each stop is guarded as the recycle's archived stop is: it runs under the
683
+ * launch mutex, the session is asked again whether it is archived right
684
+ * before it, and the replacement fence is up for the process being stopped
685
+ * until the stop is over. A message for an archived session unarchives it
686
+ * and lifts the killed marker, and without the fence the old worker would
687
+ * claim the row and be killed mid-turn.
688
+ *
689
+ * Never rejects: every failure is logged, and the session is left to the next
690
+ * relay start or to the recycle. Resolves to the session ids it stopped and
691
+ * the ones it could not.
692
+ */
693
+ export async function sweepArchivedWorkers({
694
+ // () → the session ids of archived conversations (may be a promise).
695
+ listArchivedSessionIds = () => [],
696
+ // async sid → whether a process of the session is alive.
697
+ hasLiveProcess = async () => false,
698
+ // sid → whether the session is still archived, asked under the launch mutex
699
+ // right before its stop: a conversation unarchived since the list was read
700
+ // keeps its worker.
701
+ isArchived = () => true,
702
+ // sid → the pid of the session's worker process (may be a promise), or null
703
+ // when unknown: the fence then refuses every process of the session.
704
+ workerPid = () => null,
705
+ // async (sid, { keepKilled }) → { ok, error? } (createWorkerReplacement).
706
+ stopWorker,
707
+ // The replacement fence the runtime's deliveries check, raised for the pid
708
+ // being stopped and lowered once the stop is over, whatever its outcome:
709
+ // a failed stop leaves the old worker serving its session.
710
+ fence = createWorkerReplacementFence(),
711
+ // (sid, fn) → fn's result, run under the relay's per-session launch mutex
712
+ // (sessionLaunchMutex.runExclusive), as the recycle's stops are.
713
+ runExclusive = (sid, fn) => fn(),
714
+ logger = console,
715
+ } = {}) {
716
+ const result = { stopped: [], failed: [] };
717
+ // A session found unarchived under the mutex: neither stopped nor failed.
718
+ const KEPT = Symbol('kept');
719
+ const log = (message) => { try { logger.log?.(`${LOG_TAG} ${message}`); } catch {} };
720
+ const warn = (message) => { try { logger.warn?.(`${LOG_TAG} ${message}`); } catch {} };
721
+ if (typeof stopWorker !== 'function') {
722
+ warn('archived sweep skipped: no stopWorker');
723
+ return result;
724
+ }
725
+ let sids = [];
726
+ try {
727
+ const listed = await listArchivedSessionIds();
728
+ sids = Array.from(new Set(Array.from(listed || [], (sid) => String(sid || '').trim()).filter(Boolean)));
729
+ } catch (error) {
730
+ warn(`archived sweep: cannot list archived sessions: ${errorText(error, 'unknown')}`);
731
+ return result;
732
+ }
733
+ for (const sid of sids) {
734
+ try {
735
+ if (await hasLiveProcess(sid) !== true) continue;
736
+ const stopped = await runExclusive(sid, async () => {
737
+ const pid = toPid(await workerPid(sid));
738
+ // Asked synchronously up to the fence: a message that unarchives the
739
+ // session after this answer finds the process being stopped fenced.
740
+ if (isArchived(sid) !== true) {
741
+ log(`kept ${sid8(sid)}: no longer archived`);
742
+ return KEPT;
743
+ }
744
+ // A fence another replacement raised for this process is its own to
745
+ // lower; this stop only lowers one it raised.
746
+ const raised = !fence.isHeld(sid, pid);
747
+ if (raised) fence.hold(sid, pid);
748
+ try {
749
+ return await stopWorker(sid, { keepKilled: true });
750
+ } catch (error) {
751
+ return { ok: false, error: errorText(error, 'worker-stop-failed') };
752
+ } finally {
753
+ if (raised) fence.release(sid, pid);
754
+ }
755
+ });
756
+ if (stopped === KEPT) continue;
757
+ if (stopped?.ok) {
758
+ result.stopped.push(sid);
759
+ log(`stopped ${sid8(sid)} (archived at start)`);
760
+ } else {
761
+ result.failed.push(sid);
762
+ warn(`could not stop ${sid8(sid)} (archived at start): ${stopped?.error || 'unknown'}`);
763
+ }
764
+ } catch (error) {
765
+ // The relay cannot tell whether the session runs or is still archived:
766
+ // nothing is stopped on a guess.
767
+ result.failed.push(sid);
768
+ warn(`archived sweep skipped ${sid8(sid)}: ${errorText(error, 'unknown')}`);
769
+ }
770
+ }
771
+ return result;
772
+ }