@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
|
@@ -147,6 +147,9 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
|
147
147
|
});
|
|
148
148
|
// Worker statuses that mean "working on something right now" (scope active).
|
|
149
149
|
const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
150
|
+
// A worker process that is up; the one a session's `worker` field names when
|
|
151
|
+
// the registry holds more than one entry for its conversation.
|
|
152
|
+
const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
150
153
|
// Terminal turn failures are stored as ordinary assistant rows; these are the
|
|
151
154
|
// texts the relay writes for them (buildTerminalFailureTextForChat and the two
|
|
152
155
|
// recovery-limit paths in messages-routes / server-runtime). Each is the whole
|
|
@@ -194,6 +197,11 @@ function shortId(value) {
|
|
|
194
197
|
return toText(value).slice(0, 8) || '-';
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
/** A session item's `worker` field, left out when the relay has no worker registered for it. */
|
|
201
|
+
function workerField(worker) {
|
|
202
|
+
return worker ? { worker: { ...worker } } : {};
|
|
203
|
+
}
|
|
204
|
+
|
|
197
205
|
function nonNegativeInt(value) {
|
|
198
206
|
const number = Number(value);
|
|
199
207
|
return Number.isInteger(number) && number >= 0 ? number : 0;
|
|
@@ -1494,17 +1502,42 @@ export function createRemoteRelayDispatcher({
|
|
|
1494
1502
|
};
|
|
1495
1503
|
}
|
|
1496
1504
|
|
|
1497
|
-
|
|
1505
|
+
/**
|
|
1506
|
+
* The target relay's session workers, from its /api/status: `working`, the
|
|
1507
|
+
* conversations whose worker is starting or running a turn, and
|
|
1508
|
+
* `workerFor(conversationId)`, that conversation's `worker` field (kind,
|
|
1509
|
+
* build id, and whether it runs other code than that relay, which the relay
|
|
1510
|
+
* judges against its own build id and recycles once idle), or null. A relay
|
|
1511
|
+
* that predates build ids gives null build ids and never `stale`; one that
|
|
1512
|
+
* cannot be read gives nothing.
|
|
1513
|
+
*/
|
|
1514
|
+
async function sessionWorkers(ctx) {
|
|
1515
|
+
const working = new Set();
|
|
1516
|
+
const byConversation = new Map();
|
|
1498
1517
|
try {
|
|
1499
1518
|
const status = await call(ctx, 'GET', '/api/status');
|
|
1500
1519
|
const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
.
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1520
|
+
for (const worker of workers) {
|
|
1521
|
+
const conversationId = toText(worker?.conversationId);
|
|
1522
|
+
if (!conversationId) continue;
|
|
1523
|
+
const workerStatus = toText(worker?.status).toLowerCase();
|
|
1524
|
+
if (WORKING_WORKER_STATUSES.has(workerStatus)) working.add(conversationId);
|
|
1525
|
+
const known = byConversation.get(conversationId);
|
|
1526
|
+
if (known && (known.live || !LIVE_WORKER_STATUSES.has(workerStatus))) continue;
|
|
1527
|
+
byConversation.set(conversationId, {
|
|
1528
|
+
live: LIVE_WORKER_STATUSES.has(workerStatus),
|
|
1529
|
+
field: {
|
|
1530
|
+
kind: toText(worker?.kind) || null,
|
|
1531
|
+
buildId: toText(worker?.buildId) || null,
|
|
1532
|
+
stale: worker?.stale === true,
|
|
1533
|
+
},
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
} catch {}
|
|
1537
|
+
return {
|
|
1538
|
+
working,
|
|
1539
|
+
workerFor: (conversationId) => byConversation.get(toText(conversationId))?.field || null,
|
|
1540
|
+
};
|
|
1508
1541
|
}
|
|
1509
1542
|
|
|
1510
1543
|
/** The session group of a listed row: id, display name and the row's role. */
|
|
@@ -1521,7 +1554,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1521
1554
|
};
|
|
1522
1555
|
}
|
|
1523
1556
|
|
|
1524
|
-
function mapSession(row,
|
|
1557
|
+
function mapSession(row, workers, ctx = null, groups = null) {
|
|
1525
1558
|
const id = toText(row?.id);
|
|
1526
1559
|
const via = toText(row?.origin?.relayName);
|
|
1527
1560
|
// A session an agent of that relay created there, and from which session.
|
|
@@ -1531,7 +1564,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1531
1564
|
title: toText(row?.title),
|
|
1532
1565
|
provider: toText(row?.runtimeProviderType) || 'github',
|
|
1533
1566
|
model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
|
|
1534
|
-
active: row?.activeTurn === true || !!
|
|
1567
|
+
active: row?.activeTurn === true || !!workers?.working.has(id) || isBusyState(row?.sessionState),
|
|
1535
1568
|
...sessionActivityFields(row),
|
|
1536
1569
|
updatedAt: row?.updatedAt || null,
|
|
1537
1570
|
messageCount: nonNegativeInt(row?.messageCount),
|
|
@@ -1623,10 +1656,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1623
1656
|
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
|
|
1624
1657
|
if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
|
|
1625
1658
|
}
|
|
1626
|
-
|
|
1659
|
+
// The active scope filters on the workers, so it reads them first; the
|
|
1660
|
+
// others read them once the list is known, for the `worker` fields only.
|
|
1661
|
+
const workers = scope === 'active' ? await sessionWorkers(ctx) : null;
|
|
1627
1662
|
const filtering = !!query || scope === 'active';
|
|
1628
1663
|
const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
|
|
1629
|
-
&& (scope !== 'active' || row?.activeTurn === true ||
|
|
1664
|
+
&& (scope !== 'active' || row?.activeTurn === true || workers.working.has(toText(row?.id)) || isBusyState(row?.sessionState));
|
|
1630
1665
|
|
|
1631
1666
|
const sessions = [];
|
|
1632
1667
|
const seen = new Set();
|
|
@@ -1649,7 +1684,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1649
1684
|
// A group served again whole (its cursor row vanished) repeats rows.
|
|
1650
1685
|
if (seen.has(toText(row?.id))) continue;
|
|
1651
1686
|
seen.add(toText(row?.id));
|
|
1652
|
-
if (keep(row)) sessions.push(mapSession(row,
|
|
1687
|
+
if (keep(row)) sessions.push(mapSession(row, workers, ctx, groups));
|
|
1653
1688
|
if (sessions.length >= limit) break;
|
|
1654
1689
|
}
|
|
1655
1690
|
scanned += consumed;
|
|
@@ -1667,6 +1702,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1667
1702
|
hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
|
|
1668
1703
|
if (sessions.length >= limit || !hasMore || !rows.length) break;
|
|
1669
1704
|
}
|
|
1705
|
+
// Each session's worker process, while the relay has one registered for it.
|
|
1706
|
+
const workerInfo = workers || (sessions.length ? await sessionWorkers(ctx) : null);
|
|
1707
|
+
for (let index = 0; index < sessions.length; index += 1) {
|
|
1708
|
+
const worker = workerInfo?.workerFor(sessions[index].id);
|
|
1709
|
+
if (worker) sessions[index] = { ...sessions[index], ...workerField(worker) };
|
|
1710
|
+
}
|
|
1670
1711
|
|
|
1671
1712
|
const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
|
|
1672
1713
|
const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
|
|
@@ -1749,7 +1790,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1749
1790
|
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
|
|
1750
1791
|
});
|
|
1751
1792
|
const conversationId = toText(conversation?.id) || args.session;
|
|
1752
|
-
|
|
1793
|
+
// Neither read rejects: a relay that cannot say leaves the fields out.
|
|
1794
|
+
const [pendingQuestions, workers] = await Promise.all([
|
|
1795
|
+
loadPendingQuestions(ctx, conversationId, { includeAsks: true }),
|
|
1796
|
+
sessionWorkers(ctx),
|
|
1797
|
+
]);
|
|
1798
|
+
const worker = workers.workerFor(conversationId);
|
|
1753
1799
|
const { messages, omitted } = shapeMessages(conversation?.messages, {
|
|
1754
1800
|
maxChars: args.max_chars,
|
|
1755
1801
|
includeActivity: args.include_activity,
|
|
@@ -1798,6 +1844,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1798
1844
|
...(via ? { via } : {}),
|
|
1799
1845
|
...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
|
|
1800
1846
|
...(conversation?.planOnly === true ? { planOnly: true } : {}),
|
|
1847
|
+
...workerField(worker),
|
|
1801
1848
|
...(group ? { group } : {}),
|
|
1802
1849
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1803
1850
|
},
|
|
@@ -83,7 +83,7 @@ export function resolveBootReportingWindowMs(env = process.env, log = () => {})
|
|
|
83
83
|
export const UNREPORTED_BACKGROUND_TASK_ID = 'unreported-background-work';
|
|
84
84
|
// It stays until the worker reports, and a worker on older code never does
|
|
85
85
|
// for a task that prints nothing: the way out is the session's Kill session.
|
|
86
|
-
export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its
|
|
86
|
+
export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its ︙ menu ends it)';
|
|
87
87
|
// A `running` background run row that its worker's task set does not list
|
|
88
88
|
// and nothing has updated for this long is not running work (see
|
|
89
89
|
// `runListedByWorker`).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session launch mutex, one for the whole relay process, keyed by
|
|
3
|
+
* sdkSessionId.
|
|
4
|
+
*
|
|
5
|
+
* What runs under it: the Change CWD relaunch, the launch route and the
|
|
6
|
+
* delete and archive routes (sessions-routes.mjs), the worker recycle's
|
|
7
|
+
* recycle, archived stop and launch retry (worker-recycle-service.mjs), and
|
|
8
|
+
* the archived sweep at relay start (sweepArchivedWorkers there). What does
|
|
9
|
+
* not: the Claude billing swap, which keeps the old worker away from the
|
|
10
|
+
* rows by its own delivery fence; the send path's ensureWorker; and the kill
|
|
11
|
+
* route (messages-routes.mjs), so a Kill that lands in a recycle's window
|
|
12
|
+
* between its stop and its launch (about 3 s) can look undone, because the
|
|
13
|
+
* launch starts a fresh idle worker. No turn is cut by that. Taking the
|
|
14
|
+
* mutex in those places is a follow-up.
|
|
15
|
+
*
|
|
16
|
+
* It used to be built inside the sessions routes' registration, where nothing
|
|
17
|
+
* outside the routes could take it, so a recycle could stop a worker while a
|
|
18
|
+
* relaunch was half done. A module singleton is the one place both import.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createKeyedMutex } from './keyed-mutex-service.mjs';
|
|
22
|
+
|
|
23
|
+
export const sessionLaunchMutex = createKeyedMutex();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { normalizeBuildId } from '../../shared/build-id.mjs';
|
|
4
|
+
|
|
3
5
|
function normalizeSessionId(value) {
|
|
4
6
|
const text = String(value || '').trim();
|
|
5
7
|
return text || null;
|
|
@@ -141,6 +143,12 @@ export function normalizeWorkerSteeringSnapshot(raw) {
|
|
|
141
143
|
const tasks = Number(raw.backgroundTaskCount);
|
|
142
144
|
snapshot.backgroundTaskCount = Number.isInteger(tasks) && tasks > 0 ? Math.min(tasks, 999) : 0;
|
|
143
145
|
}
|
|
146
|
+
// Whether the worker's CLI child still runs. The recycle replaces a worker
|
|
147
|
+
// only once that child is gone, because a live CLI may hold scheduled jobs
|
|
148
|
+
// of its own (CronCreate, ScheduleWakeup) that a stop would lose. Only a
|
|
149
|
+
// worker that reports it carries the key: an absent key means "cannot say",
|
|
150
|
+
// which the recycle treats differently from false.
|
|
151
|
+
if (typeof raw.cliAlive === 'boolean') snapshot.cliAlive = raw.cliAlive;
|
|
144
152
|
return snapshot;
|
|
145
153
|
}
|
|
146
154
|
|
|
@@ -160,6 +168,12 @@ function sanitizeState(raw = null) {
|
|
|
160
168
|
lastError: String(base.lastError || '').trim() || null,
|
|
161
169
|
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
162
170
|
kind: normalizeWorkerKind(base.kind),
|
|
171
|
+
// The build id of the code the worker process started on, as it stated
|
|
172
|
+
// it; null when it did not say or said something malformed.
|
|
173
|
+
buildId: normalizeBuildId(base.buildId),
|
|
174
|
+
// When the relay first heard of the current pid: the process's age as far
|
|
175
|
+
// as the relay can tell, without asking the OS. Null without a pid.
|
|
176
|
+
startedAt: normalizeTimestamp(base.startedAt, null),
|
|
163
177
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
164
178
|
createdAt,
|
|
165
179
|
};
|
|
@@ -266,11 +280,33 @@ export function createSessionWorkerRegistry() {
|
|
|
266
280
|
// heartbeated — indefinitely, if none ever did.
|
|
267
281
|
// The kind is the same: a process's own word. An upsert that does not
|
|
268
282
|
// state it keeps the known one, unless the process it described is gone.
|
|
283
|
+
// The build id and the start time follow the same rule.
|
|
269
284
|
const statesKind = Object.prototype.hasOwnProperty.call(rawState, 'kind');
|
|
285
|
+
const statesBuildId = Object.prototype.hasOwnProperty.call(rawState, 'buildId');
|
|
286
|
+
const statesStartedAt = Object.prototype.hasOwnProperty.call(rawState, 'startedAt');
|
|
270
287
|
if (!statesKind) next.kind = existing?.kind || null;
|
|
288
|
+
if (!statesBuildId) next.buildId = existing?.buildId || null;
|
|
289
|
+
if (!statesStartedAt) next.startedAt = existing?.startedAt || null;
|
|
271
290
|
if (existing && isWorkerReplacedOrGone(existing, next)) {
|
|
272
291
|
next.steering = null;
|
|
273
292
|
if (!statesKind) next.kind = null;
|
|
293
|
+
if (!statesBuildId) next.buildId = null;
|
|
294
|
+
if (!statesStartedAt) next.startedAt = null;
|
|
295
|
+
}
|
|
296
|
+
// A pid the entry did not have before is the first the relay hears of
|
|
297
|
+
// that process, so its start time is now. Callers spread the old entry
|
|
298
|
+
// into the upsert that brings the new pid, so a stated startedAt counts
|
|
299
|
+
// only when it is not the old process's own.
|
|
300
|
+
// The build id is that process's own word as well, and it arrives with its
|
|
301
|
+
// hello or heartbeat, not with the upsert that brings the pid. A build id
|
|
302
|
+
// equal to the old entry's is the old process's, spread in, and would make
|
|
303
|
+
// a recycled worker read as the old code (or an old worker as current)
|
|
304
|
+
// until the new one spoke; it is dropped. A different one was stated for
|
|
305
|
+
// this process and stays.
|
|
306
|
+
if (next.pid && next.pid !== existing?.pid) {
|
|
307
|
+
const statedOwnStart = statesStartedAt && next.startedAt && next.startedAt !== existing?.startedAt;
|
|
308
|
+
if (!statedOwnStart) next.startedAt = new Date().toISOString();
|
|
309
|
+
if (next.buildId && next.buildId === existing?.buildId) next.buildId = null;
|
|
274
310
|
}
|
|
275
311
|
clearIndexesForSession(existing);
|
|
276
312
|
bySession.set(state.sdkSessionId, next);
|
|
@@ -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();
|
|
@@ -111,6 +112,13 @@ export function createSessionWorkerWebSocketService({
|
|
|
111
112
|
// A worker stated its kind ("claude", "claude-api", ...) in a hello or a
|
|
112
113
|
// ready: (sessionId, kind, pid). The registry keeps it.
|
|
113
114
|
onWorkerKind = () => {},
|
|
115
|
+
// A worker stated the build id of the code it started on, in a hello or a
|
|
116
|
+
// ready: (sessionId, buildId, pid). The registry keeps it, so the relay can
|
|
117
|
+
// tell a worker on older code from one that runs what the relay runs.
|
|
118
|
+
onWorkerBuildId = () => {},
|
|
119
|
+
// The relay's own build id, sent in every server.hello so a worker can log
|
|
120
|
+
// that it runs other code; null leaves the key out.
|
|
121
|
+
buildId = null,
|
|
114
122
|
// Liveness probe for a socket's worker process. A killed worker's socket
|
|
115
123
|
// survives its process by an event-loop turn (the kill path blocks the loop,
|
|
116
124
|
// so this service's timer runs before the socket close lands), and asking for
|
|
@@ -136,6 +144,8 @@ export function createSessionWorkerWebSocketService({
|
|
|
136
144
|
const logDebug = typeof logger?.debug === 'function' ? logger.debug.bind(logger) : noop;
|
|
137
145
|
const logWarn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noop;
|
|
138
146
|
|
|
147
|
+
const relayBuildId = normalizeBuildId(buildId);
|
|
148
|
+
|
|
139
149
|
let interval = null;
|
|
140
150
|
let lastSnapshot = null;
|
|
141
151
|
let attached = false;
|
|
@@ -245,6 +255,32 @@ export function createSessionWorkerWebSocketService({
|
|
|
245
255
|
try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
// The build id a hello or ready states, kept and reported like the kind. A
|
|
259
|
+
// value that is malformed or too long is ignored rather than stored, since
|
|
260
|
+
// the worker side is not trusted to bound what it sends.
|
|
261
|
+
function noteWorkerBuildId(meta, payload) {
|
|
262
|
+
const workerBuildId = normalizeBuildId(payload?.buildId);
|
|
263
|
+
if (!workerBuildId || meta.workerBuildId === workerBuildId) return;
|
|
264
|
+
meta.workerBuildId = workerBuildId;
|
|
265
|
+
try { onWorkerBuildId(meta.sessionId, workerBuildId, meta.pid); } catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Both server.hello frames (on connection and the ack of worker.hello) carry
|
|
269
|
+
// the same fields; an unknown relay build id leaves its key out rather than
|
|
270
|
+
// sending null.
|
|
271
|
+
function serverHello(reason, sessionId) {
|
|
272
|
+
const event = {
|
|
273
|
+
type: 'server.hello',
|
|
274
|
+
reason,
|
|
275
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
276
|
+
queue: normalizeQueueSnapshot(queueCounts()),
|
|
277
|
+
sessionId,
|
|
278
|
+
timestamp: nowIso(),
|
|
279
|
+
};
|
|
280
|
+
if (relayBuildId) event.buildId = relayBuildId;
|
|
281
|
+
return event;
|
|
282
|
+
}
|
|
283
|
+
|
|
248
284
|
function noteHeartbeat(meta, reason = 'worker-message') {
|
|
249
285
|
if (!meta?.sessionId) return;
|
|
250
286
|
try {
|
|
@@ -397,14 +433,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
397
433
|
});
|
|
398
434
|
if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
|
|
399
435
|
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
|
-
});
|
|
436
|
+
emitEvent(ws, serverHello('connected', identity.sessionId));
|
|
408
437
|
|
|
409
438
|
ws.on('message', (raw) => {
|
|
410
439
|
touchCli();
|
|
@@ -420,19 +449,13 @@ export function createSessionWorkerWebSocketService({
|
|
|
420
449
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
421
450
|
meta.lastSeenAtMs = nowMsSafe();
|
|
422
451
|
noteWorkerKind(meta, payload);
|
|
452
|
+
noteWorkerBuildId(meta, payload);
|
|
423
453
|
// A worker holding deliveries (open question card, compaction) binds
|
|
424
454
|
// its identity with hello but must not be handed work.
|
|
425
455
|
meta.ready = payload.ready !== false;
|
|
426
456
|
meta.lastHelloAt = nowIso();
|
|
427
457
|
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
|
-
});
|
|
458
|
+
emitEvent(ws, serverHello('ack', meta.sessionId));
|
|
436
459
|
void maybeDeliverToSocket(ws, 'worker-hello');
|
|
437
460
|
return;
|
|
438
461
|
}
|
|
@@ -440,6 +463,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
440
463
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
441
464
|
meta.lastSeenAtMs = nowMsSafe();
|
|
442
465
|
noteWorkerKind(meta, payload);
|
|
466
|
+
noteWorkerBuildId(meta, payload);
|
|
443
467
|
meta.ready = true;
|
|
444
468
|
meta.lastReadyAt = nowIso();
|
|
445
469
|
noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
|