@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.
- package/CHANGELOG.md +325 -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 +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -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/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -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/context-usage-view.mjs +68 -0
- 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 +309 -49
- 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 +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- 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/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- 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/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -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 +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -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 +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- 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/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- 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-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -88,6 +88,12 @@ export function createSessionWorkerSupervisor({
|
|
|
88
88
|
// short enough that stopping a turn and immediately sending a new message
|
|
89
89
|
// does not add 30s in front of the worker's boot.
|
|
90
90
|
killBlockGraceMs = 3_000,
|
|
91
|
+
// A kill's block on new launches and deliveries for the session ended (its
|
|
92
|
+
// grace ran out, or the marker was cleared): (sessionId). The relay hands
|
|
93
|
+
// out what waited for it.
|
|
94
|
+
onKillBlockLifted = null,
|
|
95
|
+
setTimeoutImpl = setTimeout,
|
|
96
|
+
clearTimeoutImpl = clearTimeout,
|
|
91
97
|
isPidAlive = defaultIsPidAlive,
|
|
92
98
|
diagnosticPlanReference = null,
|
|
93
99
|
log = null,
|
|
@@ -98,6 +104,8 @@ export function createSessionWorkerSupervisor({
|
|
|
98
104
|
|
|
99
105
|
const pendingStarts = new Map();
|
|
100
106
|
const lifecycleBySession = new Map();
|
|
107
|
+
// sessionId → the timer that reports the end of its kill block.
|
|
108
|
+
const killBlockTimers = new Map();
|
|
101
109
|
const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
|
|
102
110
|
const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
|
|
103
111
|
const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
|
|
@@ -301,9 +309,35 @@ export function createSessionWorkerSupervisor({
|
|
|
301
309
|
});
|
|
302
310
|
}
|
|
303
311
|
|
|
312
|
+
function reportKillBlockLifted(sessionId) {
|
|
313
|
+
if (typeof onKillBlockLifted !== 'function') return;
|
|
314
|
+
try { onKillBlockLifted(sessionId); } catch {}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function clearKillBlockTimer(sessionId) {
|
|
318
|
+
const timer = killBlockTimers.get(sessionId);
|
|
319
|
+
if (!timer) return false;
|
|
320
|
+
clearTimeoutImpl(timer);
|
|
321
|
+
killBlockTimers.delete(sessionId);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
304
325
|
function markKilled(sessionId) {
|
|
305
326
|
if (!normalizeSessionId(sessionId)) return null;
|
|
306
327
|
const killed = setLifecycle(sessionId, { killedAtMs: nowMs() });
|
|
328
|
+
// The block itself is read off the clock (isKillBlocked); this timer only
|
|
329
|
+
// says when it ends, so the rows that waited go out then.
|
|
330
|
+
if (killGraceMs > 0 && typeof onKillBlockLifted === 'function') {
|
|
331
|
+
clearKillBlockTimer(sessionId);
|
|
332
|
+
const timer = setTimeoutImpl(() => {
|
|
333
|
+
killBlockTimers.delete(sessionId);
|
|
334
|
+
if (!isKillBlocked(sessionId)) reportKillBlockLifted(sessionId);
|
|
335
|
+
// A few ms past the grace: the timer's clock and nowMs() may differ
|
|
336
|
+
// by a tick.
|
|
337
|
+
}, killGraceMs + 5);
|
|
338
|
+
timer?.unref?.();
|
|
339
|
+
killBlockTimers.set(sessionId, timer);
|
|
340
|
+
}
|
|
307
341
|
return toLifecycleSnapshot(sessionId, killed);
|
|
308
342
|
}
|
|
309
343
|
|
|
@@ -489,10 +523,12 @@ export function createSessionWorkerSupervisor({
|
|
|
489
523
|
patch.degradedAtMs = null;
|
|
490
524
|
patch.uiState = 'white';
|
|
491
525
|
}
|
|
526
|
+
const liftsKillBlock = resetKilledMarker && clearKillBlockTimer(sessionId);
|
|
492
527
|
if (resetKilledMarker) {
|
|
493
528
|
patch.killedAtMs = null;
|
|
494
529
|
}
|
|
495
530
|
const cleared = setLifecycle(sessionId, patch);
|
|
531
|
+
if (liftsKillBlock) reportKillBlockLifted(sessionId);
|
|
496
532
|
return toLifecycleSnapshot(sessionId, cleared);
|
|
497
533
|
}
|
|
498
534
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { URL } from 'url';
|
|
4
|
+
import { normalizeBuildId } from '../../shared/build-id.mjs';
|
|
4
5
|
|
|
5
6
|
function normalizeText(value) {
|
|
6
7
|
const text = String(value || '').trim();
|
|
@@ -85,18 +86,21 @@ function queueSnapshotChanged(left, right) {
|
|
|
85
86
|
|| left.parkedCount !== right.parkedCount;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
// The worker
|
|
89
|
-
// worker-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
89
|
+
// The worker keepalive beats every 30 s while the worker is idle and every
|
|
90
|
+
// 10 s while it works (shared/worker-runtime/worker-keepalive.mjs; an older
|
|
91
|
+
// worker always every 10 s), and the link itself abandons a connection after
|
|
92
|
+
// 90 s of server silence (staleConnectionMs). Mirror that on this side: three
|
|
93
|
+
// missed idle beats and a retained socket no longer proves the worker is
|
|
94
|
+
// alive.
|
|
95
|
+
export const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 90_000;
|
|
93
96
|
|
|
94
97
|
// Protocol features this relay understands, advertised in server.hello. A
|
|
95
98
|
// worker that updated on disk before the relay restarted must not rely on
|
|
96
99
|
// them otherwise: an older relay ignores worker.unready / hello ready:false
|
|
97
100
|
// and treats a `steering-held` requeue as a failure (retry, backoff,
|
|
98
|
-
// worker marked errored).
|
|
99
|
-
|
|
101
|
+
// worker marked errored). `idle-keepalive`: an idle worker may beat every
|
|
102
|
+
// 30 s; an older relay's liveness and CLI windows expect a beat every 10 s.
|
|
103
|
+
export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held', 'idle-keepalive']);
|
|
100
104
|
|
|
101
105
|
export function createSessionWorkerWebSocketService({
|
|
102
106
|
WebSocketServerImpl,
|
|
@@ -107,10 +111,30 @@ export function createSessionWorkerWebSocketService({
|
|
|
107
111
|
noteWorkerHeartbeat = () => {},
|
|
108
112
|
onDeliverySendFailed = async () => {},
|
|
109
113
|
onWorkerSocketClosed = async () => {},
|
|
114
|
+
// The number of open worker sockets changed: (count). The CLI presence
|
|
115
|
+
// window is wider while a worker socket is open.
|
|
116
|
+
onSocketCountChange = () => {},
|
|
117
|
+
// Every queue snapshot the poll takes: ({pendingCount, processingCount,
|
|
118
|
+
// parkedCount}). The relay arms its worker prime while rows are pending.
|
|
119
|
+
onQueueSnapshot = () => {},
|
|
120
|
+
// The earliest future retry time of a pending row, in ms (null: none). A
|
|
121
|
+
// one-shot timer fires then, so a backoff or a usage-limit hold ends on
|
|
122
|
+
// time rather than at the next poll.
|
|
123
|
+
nextDueAtMs = () => null,
|
|
124
|
+
// That timer fired with a row due: the relay wakes its queue (prime, hand
|
|
125
|
+
// out, arm), since a row whose hold ended may have no worker yet.
|
|
126
|
+
onDue = () => {},
|
|
110
127
|
requestWork = async () => null,
|
|
111
128
|
// A worker stated its kind ("claude", "claude-api", ...) in a hello or a
|
|
112
129
|
// ready: (sessionId, kind, pid). The registry keeps it.
|
|
113
130
|
onWorkerKind = () => {},
|
|
131
|
+
// A worker stated the build id of the code it started on, in a hello or a
|
|
132
|
+
// ready: (sessionId, buildId, pid). The registry keeps it, so the relay can
|
|
133
|
+
// tell a worker on older code from one that runs what the relay runs.
|
|
134
|
+
onWorkerBuildId = () => {},
|
|
135
|
+
// The relay's own build id, sent in every server.hello so a worker can log
|
|
136
|
+
// that it runs other code; null leaves the key out.
|
|
137
|
+
buildId = null,
|
|
114
138
|
// Liveness probe for a socket's worker process. A killed worker's socket
|
|
115
139
|
// survives its process by an event-loop turn (the kill path blocks the loop,
|
|
116
140
|
// so this service's timer runs before the socket close lands), and asking for
|
|
@@ -136,7 +160,13 @@ export function createSessionWorkerWebSocketService({
|
|
|
136
160
|
const logDebug = typeof logger?.debug === 'function' ? logger.debug.bind(logger) : noop;
|
|
137
161
|
const logWarn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noop;
|
|
138
162
|
|
|
163
|
+
const relayBuildId = normalizeBuildId(buildId);
|
|
164
|
+
|
|
139
165
|
let interval = null;
|
|
166
|
+
let dueTimer = null;
|
|
167
|
+
// The retry time the armed due timer stands for.
|
|
168
|
+
let dueTimerForMs = null;
|
|
169
|
+
let stopped = false;
|
|
140
170
|
let lastSnapshot = null;
|
|
141
171
|
let attached = false;
|
|
142
172
|
|
|
@@ -145,10 +175,15 @@ export function createSessionWorkerWebSocketService({
|
|
|
145
175
|
return Number.isFinite(value) ? value : Date.now();
|
|
146
176
|
}
|
|
147
177
|
|
|
178
|
+
function noteSocketCount() {
|
|
179
|
+
try { onSocketCountChange(clients.size); } catch {}
|
|
180
|
+
}
|
|
181
|
+
|
|
148
182
|
function removeSocket(socket, reason) {
|
|
149
183
|
const meta = clientState.get(socket);
|
|
150
|
-
clients.delete(socket);
|
|
184
|
+
const removed = clients.delete(socket);
|
|
151
185
|
clientState.delete(socket);
|
|
186
|
+
if (removed) noteSocketCount();
|
|
152
187
|
// Death-detection hook: a worker socket closing while its session owns
|
|
153
188
|
// an in-flight processing row used to be silently forgotten, leaving
|
|
154
189
|
// the row to the 600s stale sweep. The handler PID-probes and recovers.
|
|
@@ -212,11 +247,12 @@ export function createSessionWorkerWebSocketService({
|
|
|
212
247
|
};
|
|
213
248
|
}
|
|
214
249
|
|
|
215
|
-
function emitQueueChanged(reason = 'queue-update') {
|
|
250
|
+
function emitQueueChanged(reason = 'queue-update', { skip = null } = {}) {
|
|
216
251
|
const event = toQueueChangedEvent(reason);
|
|
217
252
|
const payload = JSON.stringify(event);
|
|
218
253
|
for (const socket of clients) {
|
|
219
254
|
if (socket.readyState !== socket.OPEN) continue;
|
|
255
|
+
if (skip?.has(socket)) continue;
|
|
220
256
|
try {
|
|
221
257
|
socket.send(payload);
|
|
222
258
|
} catch {
|
|
@@ -245,6 +281,32 @@ export function createSessionWorkerWebSocketService({
|
|
|
245
281
|
try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
|
|
246
282
|
}
|
|
247
283
|
|
|
284
|
+
// The build id a hello or ready states, kept and reported like the kind. A
|
|
285
|
+
// value that is malformed or too long is ignored rather than stored, since
|
|
286
|
+
// the worker side is not trusted to bound what it sends.
|
|
287
|
+
function noteWorkerBuildId(meta, payload) {
|
|
288
|
+
const workerBuildId = normalizeBuildId(payload?.buildId);
|
|
289
|
+
if (!workerBuildId || meta.workerBuildId === workerBuildId) return;
|
|
290
|
+
meta.workerBuildId = workerBuildId;
|
|
291
|
+
try { onWorkerBuildId(meta.sessionId, workerBuildId, meta.pid); } catch {}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Both server.hello frames (on connection and the ack of worker.hello) carry
|
|
295
|
+
// the same fields; an unknown relay build id leaves its key out rather than
|
|
296
|
+
// sending null.
|
|
297
|
+
function serverHello(reason, sessionId) {
|
|
298
|
+
const event = {
|
|
299
|
+
type: 'server.hello',
|
|
300
|
+
reason,
|
|
301
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
302
|
+
queue: normalizeQueueSnapshot(queueCounts()),
|
|
303
|
+
sessionId,
|
|
304
|
+
timestamp: nowIso(),
|
|
305
|
+
};
|
|
306
|
+
if (relayBuildId) event.buildId = relayBuildId;
|
|
307
|
+
return event;
|
|
308
|
+
}
|
|
309
|
+
|
|
248
310
|
function noteHeartbeat(meta, reason = 'worker-message') {
|
|
249
311
|
if (!meta?.sessionId) return;
|
|
250
312
|
try {
|
|
@@ -328,25 +390,115 @@ export function createSessionWorkerWebSocketService({
|
|
|
328
390
|
}
|
|
329
391
|
}
|
|
330
392
|
|
|
393
|
+
/** Hands a row to every ready socket; resolves to the sockets that got one. */
|
|
331
394
|
async function maybeDeliverToReadyClients(reason = 'queue-update') {
|
|
395
|
+
const sockets = [];
|
|
332
396
|
const deliveries = [];
|
|
333
397
|
for (const socket of clients) {
|
|
334
398
|
const meta = clientState.get(socket);
|
|
335
399
|
if (!meta?.ready || meta.delivering) continue;
|
|
400
|
+
sockets.push(socket);
|
|
336
401
|
deliveries.push(maybeDeliverToSocket(socket, reason));
|
|
337
402
|
}
|
|
338
|
-
|
|
339
|
-
|
|
403
|
+
const handed = new Set();
|
|
404
|
+
if (!deliveries.length) return handed;
|
|
405
|
+
const results = await Promise.allSettled(deliveries);
|
|
406
|
+
results.forEach((result, index) => {
|
|
407
|
+
if (result.status === 'fulfilled' && result.value === true) handed.add(sockets[index]);
|
|
408
|
+
});
|
|
409
|
+
return handed;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Ready sockets get their rows first; then every other socket hears that
|
|
413
|
+
// the queue changed (a worker the relay does not count as ready, a steering
|
|
414
|
+
// one mid-turn, answers with a ready of its own). A socket just handed a
|
|
415
|
+
// row is busy with it and is not told.
|
|
416
|
+
async function deliverThenAnnounce(reason, { announce = true } = {}) {
|
|
417
|
+
const handed = await maybeDeliverToReadyClients(reason);
|
|
418
|
+
if (announce) emitQueueChanged(reason, { skip: handed });
|
|
419
|
+
return handed;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The queue may have work: a write, a gate that lifted, a retry come due.
|
|
424
|
+
* Hands rows out now instead of at the next poll.
|
|
425
|
+
*/
|
|
426
|
+
async function notifyQueueChanged(reason = 'queue-update') {
|
|
427
|
+
if (stopped) return new Set();
|
|
428
|
+
lastSnapshot = normalizeQueueSnapshot(queueCounts());
|
|
429
|
+
// A turn that ended with nothing behind it: no row to hand out, so no
|
|
430
|
+
// dequeue attempt per ready socket and no announcement.
|
|
431
|
+
const handed = lastSnapshot.pendingCount > 0 ? await deliverThenAnnounce(reason) : new Set();
|
|
432
|
+
armDueTimer();
|
|
433
|
+
return handed;
|
|
340
434
|
}
|
|
341
435
|
|
|
436
|
+
// The backstop. With nothing pending nothing can be handed out: no dequeue
|
|
437
|
+
// attempt, only the count change announced. With rows pending (a retry in
|
|
438
|
+
// backoff, a gate, a worker not yet up) every tick tries the ready sockets,
|
|
439
|
+
// as a net under the event-driven wakes.
|
|
342
440
|
function monitorQueue() {
|
|
343
441
|
const snapshot = normalizeQueueSnapshot(queueCounts());
|
|
442
|
+
try { onQueueSnapshot(snapshot); } catch {}
|
|
344
443
|
const changed = queueSnapshotChanged(snapshot, lastSnapshot);
|
|
345
444
|
lastSnapshot = snapshot;
|
|
346
|
-
if (
|
|
445
|
+
if (snapshot.pendingCount > 0) {
|
|
446
|
+
void deliverThenAnnounce(changed ? 'queue-count-changed' : 'ready-check', { announce: changed });
|
|
447
|
+
} else if (changed) {
|
|
347
448
|
emitQueueChanged('queue-count-changed');
|
|
348
449
|
}
|
|
349
|
-
|
|
450
|
+
armDueTimer();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const DUE_TIMER_MIN_MS = 50;
|
|
454
|
+
const DUE_TIMER_MAX_MS = 60_000;
|
|
455
|
+
|
|
456
|
+
function clearDueTimer() {
|
|
457
|
+
dueTimerForMs = null;
|
|
458
|
+
if (!dueTimer) return;
|
|
459
|
+
clearTimeout(dueTimer);
|
|
460
|
+
dueTimer = null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* One timer for the earliest future retry time. An armed timer for the same
|
|
465
|
+
* time is kept; any other arm clears the one before. A time further than a
|
|
466
|
+
* minute away is re-read after a minute.
|
|
467
|
+
*/
|
|
468
|
+
function armDueTimer() {
|
|
469
|
+
if (stopped) {
|
|
470
|
+
clearDueTimer();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// A timer whose time has come fires and re-arms itself: a poll in the
|
|
474
|
+
// same instant (its row no longer "future") must not clear it unfired.
|
|
475
|
+
if (dueTimer && dueTimerForMs !== null && dueTimerForMs <= nowMsSafe()) return;
|
|
476
|
+
let dueAt = null;
|
|
477
|
+
try { dueAt = nextDueAtMs(); } catch { dueAt = null; }
|
|
478
|
+
const dueMs = Number(dueAt);
|
|
479
|
+
if (dueAt === null || dueAt === undefined || !Number.isFinite(dueMs)) {
|
|
480
|
+
clearDueTimer();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (dueTimer && dueTimerForMs === dueMs) return;
|
|
484
|
+
clearDueTimer();
|
|
485
|
+
const untilDueMs = dueMs - nowMsSafe();
|
|
486
|
+
const delayMs = Math.min(DUE_TIMER_MAX_MS, Math.max(DUE_TIMER_MIN_MS, untilDueMs));
|
|
487
|
+
// A time further than the cap: this firing only re-reads.
|
|
488
|
+
const reachesDue = untilDueMs <= DUE_TIMER_MAX_MS;
|
|
489
|
+
dueTimerForMs = dueMs;
|
|
490
|
+
dueTimer = setTimeout(() => {
|
|
491
|
+
dueTimer = null;
|
|
492
|
+
dueTimerForMs = null;
|
|
493
|
+
if (stopped) return;
|
|
494
|
+
if (reachesDue) {
|
|
495
|
+
// Whatever came due may go now; a row that was claimed or cancelled
|
|
496
|
+
// meanwhile makes this a wake with nothing to do.
|
|
497
|
+
try { onDue('retry-due'); } catch {}
|
|
498
|
+
}
|
|
499
|
+
armDueTimer();
|
|
500
|
+
}, delayMs);
|
|
501
|
+
if (typeof dueTimer.unref === 'function') dueTimer.unref();
|
|
350
502
|
}
|
|
351
503
|
|
|
352
504
|
function rejectUpgrade(socket) {
|
|
@@ -396,15 +548,9 @@ export function createSessionWorkerWebSocketService({
|
|
|
396
548
|
deliveryFailures: 0,
|
|
397
549
|
});
|
|
398
550
|
if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
|
|
551
|
+
noteSocketCount();
|
|
399
552
|
touchCli();
|
|
400
|
-
emitEvent(ws,
|
|
401
|
-
type: 'server.hello',
|
|
402
|
-
reason: 'connected',
|
|
403
|
-
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
404
|
-
queue: normalizeQueueSnapshot(queueCounts()),
|
|
405
|
-
sessionId: identity.sessionId,
|
|
406
|
-
timestamp: nowIso(),
|
|
407
|
-
});
|
|
553
|
+
emitEvent(ws, serverHello('connected', identity.sessionId));
|
|
408
554
|
|
|
409
555
|
ws.on('message', (raw) => {
|
|
410
556
|
touchCli();
|
|
@@ -420,19 +566,13 @@ export function createSessionWorkerWebSocketService({
|
|
|
420
566
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
421
567
|
meta.lastSeenAtMs = nowMsSafe();
|
|
422
568
|
noteWorkerKind(meta, payload);
|
|
569
|
+
noteWorkerBuildId(meta, payload);
|
|
423
570
|
// A worker holding deliveries (open question card, compaction) binds
|
|
424
571
|
// its identity with hello but must not be handed work.
|
|
425
572
|
meta.ready = payload.ready !== false;
|
|
426
573
|
meta.lastHelloAt = nowIso();
|
|
427
574
|
noteHeartbeat(meta, 'worker-hello');
|
|
428
|
-
emitEvent(ws,
|
|
429
|
-
type: 'server.hello',
|
|
430
|
-
reason: 'ack',
|
|
431
|
-
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
432
|
-
queue: normalizeQueueSnapshot(queueCounts()),
|
|
433
|
-
sessionId: meta.sessionId,
|
|
434
|
-
timestamp: nowIso(),
|
|
435
|
-
});
|
|
575
|
+
emitEvent(ws, serverHello('ack', meta.sessionId));
|
|
436
576
|
void maybeDeliverToSocket(ws, 'worker-hello');
|
|
437
577
|
return;
|
|
438
578
|
}
|
|
@@ -440,10 +580,16 @@ export function createSessionWorkerWebSocketService({
|
|
|
440
580
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
441
581
|
meta.lastSeenAtMs = nowMsSafe();
|
|
442
582
|
noteWorkerKind(meta, payload);
|
|
583
|
+
noteWorkerBuildId(meta, payload);
|
|
443
584
|
meta.ready = true;
|
|
444
585
|
meta.lastReadyAt = nowIso();
|
|
445
|
-
|
|
446
|
-
|
|
586
|
+
const readyReason = String(payload.reason || 'worker-ready');
|
|
587
|
+
noteHeartbeat(meta, readyReason);
|
|
588
|
+
// A keepalive's ready with nothing pending has nothing to draw: one
|
|
589
|
+
// count instead of a dequeue attempt. Any row that appears wakes the
|
|
590
|
+
// queue, and this socket stays ready for it.
|
|
591
|
+
if (readyReason === 'readiness-refresh' && normalizeQueueSnapshot(queueCounts()).pendingCount === 0) return;
|
|
592
|
+
void maybeDeliverToSocket(ws, readyReason);
|
|
447
593
|
return;
|
|
448
594
|
}
|
|
449
595
|
if (payload.type === 'worker.unready') {
|
|
@@ -502,6 +648,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
502
648
|
}
|
|
503
649
|
|
|
504
650
|
function start() {
|
|
651
|
+
stopped = false;
|
|
505
652
|
if (!attached) {
|
|
506
653
|
httpServer.on('upgrade', onUpgrade);
|
|
507
654
|
wss.on('connection', onConnection);
|
|
@@ -513,10 +660,13 @@ export function createSessionWorkerWebSocketService({
|
|
|
513
660
|
}
|
|
514
661
|
lastSnapshot = normalizeQueueSnapshot(queueCounts());
|
|
515
662
|
emitQueueChanged('started');
|
|
663
|
+
armDueTimer();
|
|
516
664
|
logDebug(`[worker-ws] listening on ${wsPath}`);
|
|
517
665
|
}
|
|
518
666
|
|
|
519
667
|
function stop() {
|
|
668
|
+
stopped = true;
|
|
669
|
+
clearDueTimer();
|
|
520
670
|
if (interval) {
|
|
521
671
|
clearInterval(interval);
|
|
522
672
|
interval = null;
|
|
@@ -530,6 +680,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
530
680
|
}
|
|
531
681
|
clients.clear();
|
|
532
682
|
clientState.clear();
|
|
683
|
+
noteSocketCount();
|
|
533
684
|
try { wss.close(); } catch (error) {
|
|
534
685
|
logWarn(`[worker-ws] close failed: ${error?.message || error}`);
|
|
535
686
|
}
|
|
@@ -586,6 +737,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
586
737
|
stop,
|
|
587
738
|
status,
|
|
588
739
|
emitQueueChanged,
|
|
740
|
+
notifyQueueChanged,
|
|
589
741
|
emitDraining,
|
|
590
742
|
handleUpgrade,
|
|
591
743
|
sendControlToSession,
|
|
@@ -171,6 +171,9 @@ export function createSshTunnelManager({
|
|
|
171
171
|
io = null,
|
|
172
172
|
logger = console,
|
|
173
173
|
runtimeShutdownRef = () => false,
|
|
174
|
+
// The tunnel stopped blocking queue delivery (it connected): the relay
|
|
175
|
+
// hands out what waited for it.
|
|
176
|
+
onBlockingLifted = () => {},
|
|
174
177
|
platform = process.platform,
|
|
175
178
|
spawnImpl = spawn,
|
|
176
179
|
nowIso = () => new Date().toISOString(),
|
|
@@ -225,7 +228,11 @@ export function createSshTunnelManager({
|
|
|
225
228
|
};
|
|
226
229
|
|
|
227
230
|
const updateBlockingState = () => {
|
|
231
|
+
const wasBlocking = state.blocking;
|
|
228
232
|
state.blocking = state.required && state.mode === 'managed' && !state.connected;
|
|
233
|
+
if (wasBlocking && !state.blocking) {
|
|
234
|
+
try { onBlockingLifted(); } catch {}
|
|
235
|
+
}
|
|
229
236
|
};
|
|
230
237
|
|
|
231
238
|
const scheduleReconnect = (spawnTunnel, delayOverrideMs = null) => {
|