@oar-sh/oar 0.9.19 → 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/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -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-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- 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-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -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 +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- 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 +611 -70
- 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-control-state.mjs +12 -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 +16 -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 +20 -2
- 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 +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- 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/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -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 +15 -4
- 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 +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -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
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
withRemotePromptHeader,
|
|
32
32
|
} from '../../shared/remote-relay-contract.mjs';
|
|
33
33
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
34
|
+
import { PLAN_ONLY_ERROR_CODE, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
|
|
34
35
|
import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
|
|
35
36
|
import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
36
37
|
import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
|
|
@@ -146,6 +147,9 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
|
146
147
|
});
|
|
147
148
|
// Worker statuses that mean "working on something right now" (scope active).
|
|
148
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']);
|
|
149
153
|
// Terminal turn failures are stored as ordinary assistant rows; these are the
|
|
150
154
|
// texts the relay writes for them (buildTerminalFailureTextForChat and the two
|
|
151
155
|
// recovery-limit paths in messages-routes / server-runtime). Each is the whole
|
|
@@ -193,6 +197,11 @@ function shortId(value) {
|
|
|
193
197
|
return toText(value).slice(0, 8) || '-';
|
|
194
198
|
}
|
|
195
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
|
+
|
|
196
205
|
function nonNegativeInt(value) {
|
|
197
206
|
const number = Number(value);
|
|
198
207
|
return Number.isInteger(number) && number >= 0 ? number : 0;
|
|
@@ -1493,17 +1502,42 @@ export function createRemoteRelayDispatcher({
|
|
|
1493
1502
|
};
|
|
1494
1503
|
}
|
|
1495
1504
|
|
|
1496
|
-
|
|
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();
|
|
1497
1517
|
try {
|
|
1498
1518
|
const status = await call(ctx, 'GET', '/api/status');
|
|
1499
1519
|
const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
.
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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
|
+
};
|
|
1507
1541
|
}
|
|
1508
1542
|
|
|
1509
1543
|
/** The session group of a listed row: id, display name and the row's role. */
|
|
@@ -1520,7 +1554,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1520
1554
|
};
|
|
1521
1555
|
}
|
|
1522
1556
|
|
|
1523
|
-
function mapSession(row,
|
|
1557
|
+
function mapSession(row, workers, ctx = null, groups = null) {
|
|
1524
1558
|
const id = toText(row?.id);
|
|
1525
1559
|
const via = toText(row?.origin?.relayName);
|
|
1526
1560
|
// A session an agent of that relay created there, and from which session.
|
|
@@ -1530,7 +1564,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1530
1564
|
title: toText(row?.title),
|
|
1531
1565
|
provider: toText(row?.runtimeProviderType) || 'github',
|
|
1532
1566
|
model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
|
|
1533
|
-
active: row?.activeTurn === true || !!
|
|
1567
|
+
active: row?.activeTurn === true || !!workers?.working.has(id) || isBusyState(row?.sessionState),
|
|
1534
1568
|
...sessionActivityFields(row),
|
|
1535
1569
|
updatedAt: row?.updatedAt || null,
|
|
1536
1570
|
messageCount: nonNegativeInt(row?.messageCount),
|
|
@@ -1540,6 +1574,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1540
1574
|
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
1541
1575
|
// The owner let this agent-started session's agents start sessions.
|
|
1542
1576
|
...(row?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
|
|
1577
|
+
// Agents cannot take it out of plan mode.
|
|
1578
|
+
...(row?.planOnly === true ? { planOnly: true } : {}),
|
|
1543
1579
|
// The session group it sits in (sessions an agent started here are
|
|
1544
1580
|
// grouped under that agent's session; the owner can group by hand).
|
|
1545
1581
|
...sessionGroupField(row, groups),
|
|
@@ -1620,10 +1656,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1620
1656
|
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
|
|
1621
1657
|
if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
|
|
1622
1658
|
}
|
|
1623
|
-
|
|
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;
|
|
1624
1662
|
const filtering = !!query || scope === 'active';
|
|
1625
1663
|
const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
|
|
1626
|
-
&& (scope !== 'active' || row?.activeTurn === true ||
|
|
1664
|
+
&& (scope !== 'active' || row?.activeTurn === true || workers.working.has(toText(row?.id)) || isBusyState(row?.sessionState));
|
|
1627
1665
|
|
|
1628
1666
|
const sessions = [];
|
|
1629
1667
|
const seen = new Set();
|
|
@@ -1646,7 +1684,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1646
1684
|
// A group served again whole (its cursor row vanished) repeats rows.
|
|
1647
1685
|
if (seen.has(toText(row?.id))) continue;
|
|
1648
1686
|
seen.add(toText(row?.id));
|
|
1649
|
-
if (keep(row)) sessions.push(mapSession(row,
|
|
1687
|
+
if (keep(row)) sessions.push(mapSession(row, workers, ctx, groups));
|
|
1650
1688
|
if (sessions.length >= limit) break;
|
|
1651
1689
|
}
|
|
1652
1690
|
scanned += consumed;
|
|
@@ -1664,6 +1702,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1664
1702
|
hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
|
|
1665
1703
|
if (sessions.length >= limit || !hasMore || !rows.length) break;
|
|
1666
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
|
+
}
|
|
1667
1711
|
|
|
1668
1712
|
const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
|
|
1669
1713
|
const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
|
|
@@ -1746,7 +1790,12 @@ export function createRemoteRelayDispatcher({
|
|
|
1746
1790
|
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
|
|
1747
1791
|
});
|
|
1748
1792
|
const conversationId = toText(conversation?.id) || args.session;
|
|
1749
|
-
|
|
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);
|
|
1750
1799
|
const { messages, omitted } = shapeMessages(conversation?.messages, {
|
|
1751
1800
|
maxChars: args.max_chars,
|
|
1752
1801
|
includeActivity: args.include_activity,
|
|
@@ -1794,6 +1843,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1794
1843
|
...sessionActivityFields(conversation, { console: true }),
|
|
1795
1844
|
...(via ? { via } : {}),
|
|
1796
1845
|
...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
|
|
1846
|
+
...(conversation?.planOnly === true ? { planOnly: true } : {}),
|
|
1847
|
+
...workerField(worker),
|
|
1797
1848
|
...(group ? { group } : {}),
|
|
1798
1849
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1799
1850
|
},
|
|
@@ -2148,7 +2199,8 @@ export function createRemoteRelayDispatcher({
|
|
|
2148
2199
|
// Grok pins its model at bootstrap and refuses any other, so it gets none.
|
|
2149
2200
|
const model = requestedModel
|
|
2150
2201
|
|| (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
|
|
2151
|
-
|
|
2202
|
+
// Only a mode the agent names: without one the target runs in its own.
|
|
2203
|
+
const mode = toText(args.mode);
|
|
2152
2204
|
// The remote runs a prompt that names no effort with its provider's
|
|
2153
2205
|
// default, not with the one the session is set to: pass that one on, so
|
|
2154
2206
|
// the session keeps its own. This session's effort is never mirrored here.
|
|
@@ -2296,7 +2348,14 @@ export function createRemoteRelayDispatcher({
|
|
|
2296
2348
|
note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
|
|
2297
2349
|
}
|
|
2298
2350
|
}
|
|
2299
|
-
|
|
2351
|
+
// planOnly starts the session locked in plan mode: with no mode named it
|
|
2352
|
+
// starts in plan; another mode named is refused before anything is made.
|
|
2353
|
+
const planOnly = args.planOnly === true || toText(args.planOnly).toLowerCase() === 'true';
|
|
2354
|
+
if (planOnly && planOnlyRefusesMode(args.mode)) {
|
|
2355
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2356
|
+
`planOnly keeps a session in plan mode; it cannot start in ${toText(args.mode)} mode. Leave mode out or name "plan". No session was created.`);
|
|
2357
|
+
}
|
|
2358
|
+
const mode = args.mode || (planOnly ? 'plan' : ctx.caller.mode) || toText(catalog.status.defaultRelayMode) || '';
|
|
2300
2359
|
const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
|
|
2301
2360
|
const origin = await buildOrigin(ctx);
|
|
2302
2361
|
|
|
@@ -2327,6 +2386,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2327
2386
|
providerType: provider,
|
|
2328
2387
|
...(model ? { model } : {}),
|
|
2329
2388
|
...(mode ? { relayMode: mode } : {}),
|
|
2389
|
+
...(planOnly ? { planOnly: true } : {}),
|
|
2330
2390
|
...(effort ? { reasoningEffort: effort } : {}),
|
|
2331
2391
|
title,
|
|
2332
2392
|
...(args.cwd ? { cwd: args.cwd } : {}),
|
|
@@ -2372,6 +2432,11 @@ export function createRemoteRelayDispatcher({
|
|
|
2372
2432
|
} else if (args.group && (!group || (ctx.group?.id && group.id !== ctx.group.id))) {
|
|
2373
2433
|
groupWarning = `The session was created, but not in group "${clipLine(args.group, 80)}": ${groupWhere(ctx)} put it ${group ? `in "${group.name || group.id}" (${group.id})` : 'in no group'}. Tell the user.`;
|
|
2374
2434
|
}
|
|
2435
|
+
// A relay that knows planOnly echoes it; an older one created the session
|
|
2436
|
+
// without the lock (in the mode asked for).
|
|
2437
|
+
const planOnlyWarning = planOnly && bootstrap?.planOnly !== true
|
|
2438
|
+
? `The session was created WITHOUT the plan-only lock: ${groupWhere(ctx)} ignored planOnly (its OAR is older and does not take it). It started in ${mode || 'its default'} mode, but agents can switch it to another mode. Tell the user.`
|
|
2439
|
+
: null;
|
|
2375
2440
|
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
2376
2441
|
// A relay that swapped the asked model for another (one that predates
|
|
2377
2442
|
// these checks does for Copilot) is named, never passed over in silence.
|
|
@@ -2436,14 +2501,20 @@ export function createRemoteRelayDispatcher({
|
|
|
2436
2501
|
// The session group it went into, as list_sessions shows it.
|
|
2437
2502
|
group,
|
|
2438
2503
|
...(groupWarning ? { groupWarning } : {}),
|
|
2504
|
+
...(planOnly ? { planOnly: bootstrap?.planOnly === true } : {}),
|
|
2505
|
+
...(planOnlyWarning ? { planOnlyWarning } : {}),
|
|
2439
2506
|
},
|
|
2440
2507
|
});
|
|
2441
|
-
if (!groupWarning) return result;
|
|
2508
|
+
if (!groupWarning && !planOnlyWarning) return result;
|
|
2442
2509
|
// Never a plain success: the summary and the note say what is missing.
|
|
2510
|
+
const missing = [
|
|
2511
|
+
groupWarning ? `no group: ${groupReported ? 'not applied' : 'target too old'}` : '',
|
|
2512
|
+
planOnlyWarning ? 'no plan-only lock: target too old' : '',
|
|
2513
|
+
].filter(Boolean).join('; ');
|
|
2443
2514
|
return {
|
|
2444
2515
|
...result,
|
|
2445
|
-
summary: `${result.summary} (
|
|
2446
|
-
note: [groupWarning, result.note].map(toText).filter(Boolean).join(' '),
|
|
2516
|
+
summary: `${result.summary} (${missing})`,
|
|
2517
|
+
note: [groupWarning, planOnlyWarning, result.note].map(toText).filter(Boolean).join(' '),
|
|
2447
2518
|
};
|
|
2448
2519
|
}
|
|
2449
2520
|
|
|
@@ -2639,6 +2710,10 @@ export function createRemoteRelayDispatcher({
|
|
|
2639
2710
|
if (remoteCode === SESSION_SETTINGS_CODES.busy) {
|
|
2640
2711
|
return failed(409, C.busy, text, { reason: toText(body.reason) || null });
|
|
2641
2712
|
}
|
|
2713
|
+
// A plan-only session: no other mode while the flag stays.
|
|
2714
|
+
if (remoteCode === PLAN_ONLY_ERROR_CODE) {
|
|
2715
|
+
return failed(409, PLAN_ONLY_ERROR_CODE, text, { note: 'Clear the flag in the same call (planOnly: false with the mode), or ask the user.' });
|
|
2716
|
+
}
|
|
2642
2717
|
if (remoteCode === SESSION_SETTINGS_CODES.groupNotFound || remoteCode === SESSION_SETTINGS_CODES.groupAmbiguous) {
|
|
2643
2718
|
return failed(400, C.unknownGroup, text, {
|
|
2644
2719
|
groups: mapGroupList(body).slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
|
|
@@ -3486,6 +3561,13 @@ export function createRemoteRelayDispatcher({
|
|
|
3486
3561
|
return failure(404, C.notFound, what ? `${what} was not found on relay "${relay.name}".` : message, extra);
|
|
3487
3562
|
}
|
|
3488
3563
|
const remoteCode = toText(remoteBody?.code) || null;
|
|
3564
|
+
// A plan-only session refused a mode the agent named.
|
|
3565
|
+
if (remoteCode === PLAN_ONLY_ERROR_CODE) {
|
|
3566
|
+
const session = args.session ? `Session ${args.session} on relay "${relay.name}"` : `Relay "${relay.name}"`;
|
|
3567
|
+
return failure(409, PLAN_ONLY_ERROR_CODE,
|
|
3568
|
+
`${session} is plan-only: agents can't switch it to another mode. Send without mode (it runs in plan), or clear planOnly with update_session first, or ask the user. Nothing was sent.`,
|
|
3569
|
+
{ ...extra, remoteCode });
|
|
3570
|
+
}
|
|
3489
3571
|
const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
|
|
3490
3572
|
// A refused effort: the ones the remote's model takes, so the agent can pick one.
|
|
3491
3573
|
const supportedEfforts = isEffortRejection(error) ? supportedEffortsOf(error) : [];
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { resumeRelayMode } from '../../shared/plan-only.mjs';
|
|
43
|
+
import { readPlanOnly } from './conversation-preferences-service.mjs';
|
|
42
44
|
|
|
43
45
|
export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
|
|
44
46
|
export const RESTART_RESUME_KIND = 'relay-restart-resume';
|
|
@@ -91,7 +93,7 @@ export function createRestartResumeService({
|
|
|
91
93
|
if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
|
|
92
94
|
const listProcessing = db.prepare(`
|
|
93
95
|
SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
|
|
94
|
-
owner_sdk_session_id, runtime_session_id
|
|
96
|
+
owner_sdk_session_id, runtime_session_id, text
|
|
95
97
|
FROM queue WHERE status = 'processing'
|
|
96
98
|
`);
|
|
97
99
|
const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
|
|
@@ -212,7 +214,8 @@ export function createRestartResumeService({
|
|
|
212
214
|
// The model and mode the interrupted turn ran with.
|
|
213
215
|
const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
|
|
214
216
|
const modelVariant = text(cutRow?.model_variant_id) || model;
|
|
215
|
-
|
|
217
|
+
// On a plan-only session only the "Approve plan" turn goes on in agent mode.
|
|
218
|
+
const relayMode = resumeRelayMode({ relayMode: cutRow?.relay_mode, text: cutRow?.text, planOnly: readPlanOnly(conversation) });
|
|
216
219
|
const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
|
|
217
220
|
const write = db.transaction(() => {
|
|
218
221
|
stmts.insertMsg.run(
|
|
@@ -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();
|
|
@@ -1126,6 +1126,9 @@ export async function launchSessionCli({
|
|
|
1126
1126
|
locateExecutableImpl = locateExecutable,
|
|
1127
1127
|
// Asked after a failed spawn whether its folder is gone (the error, or null).
|
|
1128
1128
|
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
1129
|
+
// Under a systemd user service: puts the worker in a scope of its own
|
|
1130
|
+
// (worker-scope.mjs). None: the worker starts as it is.
|
|
1131
|
+
workerScope = null,
|
|
1129
1132
|
} = {}) {
|
|
1130
1133
|
const target = String(targetSessionId || '').trim();
|
|
1131
1134
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -1237,7 +1240,7 @@ export async function launchSessionCli({
|
|
|
1237
1240
|
? prepareWorkerLogFile(target, launchSessionEnv)
|
|
1238
1241
|
: null;
|
|
1239
1242
|
try {
|
|
1240
|
-
|
|
1243
|
+
const tmuxArgs = [
|
|
1241
1244
|
'new-session',
|
|
1242
1245
|
'-d',
|
|
1243
1246
|
'-s',
|
|
@@ -1251,7 +1254,10 @@ export async function launchSessionCli({
|
|
|
1251
1254
|
workerLogPath: tmuxWorkerLogPath || '',
|
|
1252
1255
|
extraCliArgs: extensionCliArgs,
|
|
1253
1256
|
}),
|
|
1254
|
-
]
|
|
1257
|
+
];
|
|
1258
|
+
// A tmux server this call starts is in the worker's own scope.
|
|
1259
|
+
const tmuxLaunch = workerScope ? workerScope.wrap('tmux', tmuxArgs) : { command: 'tmux', args: tmuxArgs };
|
|
1260
|
+
execFileSyncImpl(tmuxLaunch.command, tmuxLaunch.args, {
|
|
1255
1261
|
env: tmuxEnv,
|
|
1256
1262
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1257
1263
|
});
|
|
@@ -1351,7 +1357,10 @@ export async function launchSessionCli({
|
|
|
1351
1357
|
}
|
|
1352
1358
|
}
|
|
1353
1359
|
}
|
|
1354
|
-
|
|
1360
|
+
// POSIX: in a scope of its own under a systemd user service; the process
|
|
1361
|
+
// id is still the worker's.
|
|
1362
|
+
const detachedLaunch = platform !== 'win32' && workerScope ? workerScope.wrap(spawnCommand, spawnArgs) : { command: spawnCommand, args: spawnArgs };
|
|
1363
|
+
const child = spawnImpl(detachedLaunch.command, detachedLaunch.args, {
|
|
1355
1364
|
cwd: launchProcessCwd,
|
|
1356
1365
|
env: launchSessionEnv,
|
|
1357
1366
|
detached: true,
|
|
@@ -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);
|
|
@@ -22,12 +22,17 @@ function toPidList(rows, extraPid) {
|
|
|
22
22
|
|
|
23
23
|
// The process list is read off the relay's thread, fresh (a stop must see
|
|
24
24
|
// the children started a moment ago). Throws when the list could not be
|
|
25
|
-
// read: the caller must not pretend it stopped what it could not see.
|
|
26
|
-
|
|
25
|
+
// read: the caller must not pretend it stopped what it could not see. The
|
|
26
|
+
// registry's pid counts only while that process is alive: a worker that is
|
|
27
|
+
// gone is stopped already (on Windows its stop call alone could fail).
|
|
28
|
+
async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
|
|
27
29
|
const rows = platform === 'win32'
|
|
28
30
|
? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
|
|
29
31
|
: await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
|
|
30
|
-
|
|
32
|
+
const listed = toPidList(rows || []);
|
|
33
|
+
const workerPid = Number(worker?.pid);
|
|
34
|
+
const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
|
|
35
|
+
return workerAlive ? toPidList(rows || [], workerPid) : listed;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
async function stopWindowsPids(processInspector, pids) {
|
|
@@ -36,13 +41,29 @@ async function stopWindowsPids(processInspector, pids) {
|
|
|
36
41
|
|
|
37
42
|
export const PROCESS_LIST_UNREADABLE = 'process-list-unreadable';
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether `pid` is a live process: signal 0 reaches it. EPERM means it
|
|
46
|
+
* exists but belongs to someone else: alive. The default for every stop
|
|
47
|
+
* that is given no check of its own.
|
|
48
|
+
*/
|
|
49
|
+
export function isProcessAlive(pid, { killImpl = (target, signal) => process.kill(target, signal) } = {}) {
|
|
50
|
+
const value = Number(pid);
|
|
51
|
+
if (!Number.isInteger(value) || value <= 0) return false;
|
|
52
|
+
try {
|
|
53
|
+
killImpl(value, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return String(error?.code || '').toUpperCase() === 'EPERM';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
export async function stopSessionWorkerProcesses({
|
|
40
61
|
sdkSessionId,
|
|
41
62
|
worker = null,
|
|
42
63
|
processInspector = null,
|
|
43
64
|
platform = process.platform,
|
|
44
65
|
killImpl = (pid, signal) => process.kill(pid, signal),
|
|
45
|
-
isPidAliveImpl =
|
|
66
|
+
isPidAliveImpl = isProcessAlive,
|
|
46
67
|
killTmuxSessionImpl = null,
|
|
47
68
|
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
48
69
|
nowMs = Date.now,
|
|
@@ -67,7 +88,7 @@ export async function stopSessionWorkerProcesses({
|
|
|
67
88
|
|
|
68
89
|
let pids;
|
|
69
90
|
try {
|
|
70
|
-
pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
|
|
91
|
+
pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
|
|
71
92
|
} catch (error) {
|
|
72
93
|
// Nothing is stopped blind: the known pid alone would leave the tree
|
|
73
94
|
// running, and the caller would relaunch next to it.
|
|
@@ -115,7 +136,7 @@ export async function stopSessionWorkerProcesses({
|
|
|
115
136
|
try {
|
|
116
137
|
if (platform === 'win32') {
|
|
117
138
|
// Re-enumerate: children spawned after the first snapshot are invisible to it.
|
|
118
|
-
const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
|
|
139
|
+
const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
|
|
119
140
|
escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
|
|
120
141
|
await stopWindowsPids(processInspector, escalationTargets);
|
|
121
142
|
} else {
|
|
@@ -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'));
|