@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.
- package/CHANGELOG.md +212 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-session-process.mjs +7 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +3 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +4 -2
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +556 -68
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +3 -0
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +18 -2
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +5 -2
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2249 -1590
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +23 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +57 -1
- package/server/routes/sessions-routes.mjs +41 -7
- package/server/server-runtime.mjs +190 -40
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/remote-relay-dispatcher.mjs +61 -14
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/build-id.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- 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
|
+
}
|