@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
package/server/relay-tools.md
CHANGED
|
@@ -42,6 +42,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
|
|
|
42
42
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
43
43
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
|
|
44
44
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
|
|
45
|
+
- `list_sessions` and `read_session` show a session's worker process, while it has one, as "worker" (kind, buildId, stale); "stale": true means it runs older code than that relay, which replaces it by itself once the session is idle, so there is nothing to do about it.
|
|
45
46
|
- `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
|
|
46
47
|
- "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
|
|
47
48
|
- Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
|
|
@@ -461,6 +461,29 @@ export function createMessageRepository(db) {
|
|
|
461
461
|
WHERE status = 'processing'
|
|
462
462
|
AND NULLIF(owner_sdk_session_id, '') = ?
|
|
463
463
|
`),
|
|
464
|
+
// The worker recycle's quiet window (worker-recycle-service.mjs): the
|
|
465
|
+
// last time the session had a delivery or a turn end, as an ISO
|
|
466
|
+
// string, or NULL when the queue holds no row of it. Over the rows the
|
|
467
|
+
// session owns: a row is stamped with its owner at enqueue under
|
|
468
|
+
// session-worker routing and at the latest by its claim, and a
|
|
469
|
+
// conversation rebound to another session leaves its old rows with the
|
|
470
|
+
// session that ran them. The queue keeps no completion time
|
|
471
|
+
// (setDone clears processing_at), so a turn's end is its response
|
|
472
|
+
// message's timestamp; the claim and the heartbeat lease
|
|
473
|
+
// (owner_last_claimed_at) and the assignment stand for the delivery.
|
|
474
|
+
// Each column is COALESCEd: SQLite's multi-argument MAX() is NULL as
|
|
475
|
+
// soon as one argument is.
|
|
476
|
+
getOwnerLastActivityAt: db.prepare(`
|
|
477
|
+
SELECT MAX(NULLIF(MAX(
|
|
478
|
+
COALESCE(q.processing_at, ''),
|
|
479
|
+
COALESCE(q.owner_assigned_at, ''),
|
|
480
|
+
COALESCE(q.owner_last_claimed_at, ''),
|
|
481
|
+
COALESCE(m.timestamp, '')
|
|
482
|
+
), '')) AS last_activity_at
|
|
483
|
+
FROM queue q
|
|
484
|
+
LEFT JOIN messages m ON m.id = q.response_message_id
|
|
485
|
+
WHERE NULLIF(q.owner_sdk_session_id, '') = ?
|
|
486
|
+
`),
|
|
464
487
|
recoverProcessingRowKeepOwner: db.prepare(`
|
|
465
488
|
UPDATE queue
|
|
466
489
|
SET status = 'pending',
|
|
@@ -100,6 +100,25 @@ export function createSessionRepository(db) {
|
|
|
100
100
|
getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
|
|
101
101
|
getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
|
|
102
102
|
listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
|
|
103
|
+
// The session ids of archived conversations: the worker sweep at relay
|
|
104
|
+
// start stops what still runs for them. A runtime session id is unique
|
|
105
|
+
// (idx_runtime_sessions_sdk_session_id), but a conversation's own
|
|
106
|
+
// sdk_session_id is not: an id that a conversation in use (not
|
|
107
|
+
// archived, not deleted) names too is left out, so that
|
|
108
|
+
// conversation's worker is never stopped for an archived one.
|
|
109
|
+
listArchivedRuntimeSessionIds: db.prepare(`
|
|
110
|
+
SELECT DISTINCT rs.sdk_session_id AS sdk_session_id
|
|
111
|
+
FROM runtime_sessions rs
|
|
112
|
+
JOIN conversations c ON c.id = rs.conversation_id
|
|
113
|
+
WHERE c.archived = 1
|
|
114
|
+
AND NULLIF(rs.sdk_session_id, '') IS NOT NULL
|
|
115
|
+
AND NOT EXISTS (
|
|
116
|
+
SELECT 1 FROM conversations live_c
|
|
117
|
+
WHERE live_c.sdk_session_id = rs.sdk_session_id
|
|
118
|
+
AND COALESCE(live_c.archived, 0) = 0
|
|
119
|
+
AND live_c.status != 'deleted'
|
|
120
|
+
)
|
|
121
|
+
`),
|
|
103
122
|
insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
|
|
104
123
|
? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
|
|
105
124
|
: `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// environment, account, login state
|
|
7
7
|
// POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
|
|
8
8
|
// POST /api/claude-cloud-session the worker's binding report
|
|
9
|
+
// GET /api/claude-cloud-session `?conversationId=` (or the sdk session
|
|
10
|
+
// id) → the stored binding a starting
|
|
11
|
+
// worker attaches to
|
|
9
12
|
// GET /api/claude-cloud/repos the repositories New Chat may suggest
|
|
10
13
|
// (`?refresh=1` reads them afresh)
|
|
11
14
|
// GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
|
|
@@ -77,6 +80,22 @@ export function registerClaudeCloudRoutes(app, deps = {}) {
|
|
|
77
80
|
if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
|
|
78
81
|
return res.json({ ok: true, cloud: result.cloud });
|
|
79
82
|
});
|
|
83
|
+
|
|
84
|
+
// GET /api/claude-cloud-session?conversationId=<id> — a starting Claude
|
|
85
|
+
// Cloud worker asks which cloud session it is bound to and from which
|
|
86
|
+
// sequence to read on, so it opens the event stream before any delivery
|
|
87
|
+
// (a relaunched worker follows the turns the session starts by itself
|
|
88
|
+
// from its start). The id may be the conversation id or the sdk session
|
|
89
|
+
// id. `{ ok, sessionId, lastSequence }`, `sessionId: null` when there is
|
|
90
|
+
// no cloud session; 404 only for an unknown conversation.
|
|
91
|
+
app.get('/api/claude-cloud-session', auth, (req, res) => {
|
|
92
|
+
touchCli();
|
|
93
|
+
const raw = req.query?.conversationId ?? req.query?.sdkSessionId;
|
|
94
|
+
const id = Array.isArray(raw) ? raw[0] : raw;
|
|
95
|
+
const result = claudeCloudSessionService.describeStoredBinding(typeof id === 'string' ? id : '');
|
|
96
|
+
if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
|
|
97
|
+
return res.json({ ok: true, sessionId: result.sessionId, lastSequence: result.lastSequence });
|
|
98
|
+
});
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
if (claudeCloudLoginStatus) {
|
|
@@ -45,6 +45,7 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
|
|
|
45
45
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
46
46
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
47
47
|
import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
48
|
+
import { normalizeBuildId } from '../../shared/build-id.mjs';
|
|
48
49
|
import {
|
|
49
50
|
DEFAULT_TASK_STALL_MINUTES,
|
|
50
51
|
TASK_OUTPUT_LIMITS,
|
|
@@ -1201,6 +1202,12 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1201
1202
|
workerKind = null,
|
|
1202
1203
|
onBillingFence = null,
|
|
1203
1204
|
claudeApiClaimCheck = null,
|
|
1205
|
+
// () → true while the replacement fence (services/worker-replacement.mjs)
|
|
1206
|
+
// is up for the requesting process. The caller asks it before calling; it
|
|
1207
|
+
// is asked again before every claim, since ensureWorker and a transient
|
|
1208
|
+
// error's backoff both wait, and the worker recycle can raise the fence
|
|
1209
|
+
// meanwhile.
|
|
1210
|
+
isReplacementFenced = null,
|
|
1204
1211
|
} = {}) {
|
|
1205
1212
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
1206
1213
|
const routingWithWorker = routingEnabled && requesterSid;
|
|
@@ -1275,6 +1282,18 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1275
1282
|
|
|
1276
1283
|
let attempt = 0;
|
|
1277
1284
|
while (attempt <= retryLimit) {
|
|
1285
|
+
// The process asking is being replaced: no row is claimed for it, the
|
|
1286
|
+
// same answer as the caller's own fence check gives.
|
|
1287
|
+
if (routingWithWorker && typeof isReplacementFenced === 'function' && isReplacementFenced() === true) {
|
|
1288
|
+
return {
|
|
1289
|
+
message: null,
|
|
1290
|
+
blockedReason: 'worker-replacement',
|
|
1291
|
+
worker: supervisor?.getWorkerState?.(requesterSid) || null,
|
|
1292
|
+
lifecycle: supervisor?.getLifecycleState?.(requesterSid) || null,
|
|
1293
|
+
fallbackRestart: null,
|
|
1294
|
+
attempts: attempt,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1278
1297
|
const currentIso = String(nowIso || '').trim() || new Date().toISOString();
|
|
1279
1298
|
try {
|
|
1280
1299
|
const message = dequeuePendingMessage({
|
|
@@ -2065,6 +2084,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2065
2084
|
// killTmuxSessionImpl). Tests must inject these instead of relying on the
|
|
2066
2085
|
// host OS, so the suite behaves identically on Windows and POSIX.
|
|
2067
2086
|
sessionWorkerStopOverrides = null,
|
|
2087
|
+
// The replacement fence (services/worker-replacement.mjs): a worker
|
|
2088
|
+
// process the recycle is replacing is handed no work by /api/pending.
|
|
2089
|
+
workerReplacementFence = null,
|
|
2068
2090
|
resolveSessionStateRoot,
|
|
2069
2091
|
fetchUsageSummary = null,
|
|
2070
2092
|
planUsageService = null,
|
|
@@ -6245,7 +6267,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6245
6267
|
// Whether a billing swap may replace the worker now, and why not.
|
|
6246
6268
|
|| (stored.releasable ?? null) !== (steering.releasable ?? null)
|
|
6247
6269
|
|| (stored.releaseHold || null) !== (steering.releaseHold || null)
|
|
6248
|
-
|| (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
|
|
6270
|
+
|| (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
|
|
6271
|
+
// Whether the worker's CLI child still runs: the worker recycle
|
|
6272
|
+
// waits for it to be gone, so a flip must be stored.
|
|
6273
|
+
|| (stored.cliAlive ?? null) !== (steering.cliAlive ?? null);
|
|
6249
6274
|
if (workerForSteering && changed) {
|
|
6250
6275
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
6251
6276
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -6263,6 +6288,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6263
6288
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
|
|
6264
6289
|
}
|
|
6265
6290
|
}
|
|
6291
|
+
// The build id of the code the worker started on, recorded the same way:
|
|
6292
|
+
// only when it says one and the registry has another, so quiet
|
|
6293
|
+
// heartbeats stay write-free.
|
|
6294
|
+
const heartbeatBuildId = normalizeBuildId(req.body?.buildId);
|
|
6295
|
+
if (heartbeatBuildId) {
|
|
6296
|
+
const workerForBuild = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
|
|
6297
|
+
if (workerForBuild && workerForBuild.buildId !== heartbeatBuildId) {
|
|
6298
|
+
sessionWorkerRegistry?.upsertWorker?.({ ...workerForBuild, buildId: heartbeatBuildId });
|
|
6299
|
+
}
|
|
6300
|
+
}
|
|
6266
6301
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
6267
6302
|
// The worker's digest of its live background-task ids against the set
|
|
6268
6303
|
// held for it (keyed as the worker posts, by its session id). They
|
|
@@ -7009,6 +7044,24 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7009
7044
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
7010
7045
|
});
|
|
7011
7046
|
}
|
|
7047
|
+
// The process asking is being replaced (the worker recycle): no work,
|
|
7048
|
+
// as for a killed session, so no turn starts on the pid being stopped.
|
|
7049
|
+
// Unlike the killed marker, the fence outlives the marker's grace and
|
|
7050
|
+
// every clearRestartSchedule; the new worker has another pid and takes
|
|
7051
|
+
// what waited meanwhile.
|
|
7052
|
+
if (workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true) {
|
|
7053
|
+
return res.json({
|
|
7054
|
+
message: null,
|
|
7055
|
+
routing: {
|
|
7056
|
+
enabled: true,
|
|
7057
|
+
requesterSessionId,
|
|
7058
|
+
blockedReason: 'worker-replacement',
|
|
7059
|
+
lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
|
|
7060
|
+
fallbackRestart: null,
|
|
7061
|
+
},
|
|
7062
|
+
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
7063
|
+
});
|
|
7064
|
+
}
|
|
7012
7065
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
7013
7066
|
}
|
|
7014
7067
|
if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
|
|
@@ -7103,6 +7156,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7103
7156
|
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7104
7157
|
claudeApiClaimCheck,
|
|
7105
7158
|
onBillingFence: onClaudeBillingFence,
|
|
7159
|
+
// The fence check above, asked again before every claim.
|
|
7160
|
+
isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
|
|
7106
7161
|
telemetry: (payload = {}) => {
|
|
7107
7162
|
if (!sessionWorkerRoutingEnabled) return;
|
|
7108
7163
|
emitSessionWorkerTelemetry(payload.event, {
|
|
@@ -7323,6 +7378,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7323
7378
|
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7324
7379
|
claudeApiClaimCheck,
|
|
7325
7380
|
onBillingFence: onClaudeBillingFence,
|
|
7381
|
+
isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
|
|
7326
7382
|
});
|
|
7327
7383
|
if (recoveryDequeue?.message) {
|
|
7328
7384
|
msg = recoveryDequeue.message;
|
|
@@ -43,6 +43,7 @@ import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '..
|
|
|
43
43
|
import { buildContextUsageView } from '../services/context-usage-view.mjs';
|
|
44
44
|
import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
|
|
45
45
|
import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
|
|
46
|
+
import { isWorkerOnOtherBuild } from '../services/worker-recycle-service.mjs';
|
|
46
47
|
import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
|
|
47
48
|
import { createCliInstallService } from '../services/cli-install-service.mjs';
|
|
48
49
|
import { createGrokAuthService } from '../services/grok-auth-service.mjs';
|
|
@@ -69,7 +70,7 @@ import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
|
|
|
69
70
|
import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
|
|
70
71
|
import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
|
|
71
72
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
72
|
-
import {
|
|
73
|
+
import { sessionLaunchMutex } from '../services/session-launch-mutex-service.mjs';
|
|
73
74
|
import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
|
|
74
75
|
import { requestHostOf } from '../services/request-boundary.mjs';
|
|
75
76
|
import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
|
|
@@ -1452,11 +1453,27 @@ export function buildSessionWorkerStatusPayload({
|
|
|
1452
1453
|
// What the last read of the process list did ({ at, durationMs, processes,
|
|
1453
1454
|
// error }); a slow box shows here before it shows as 502s.
|
|
1454
1455
|
processList = null,
|
|
1456
|
+
// The relay's own build id (runtimeState.buildId): each worker's is shown
|
|
1457
|
+
// next to it, so a worker still on the code before a deploy stands out.
|
|
1458
|
+
relayBuildId = null,
|
|
1459
|
+
// The worker recycle's status() (stale count, recycles in flight and done,
|
|
1460
|
+
// who waits on what), or null while the recycle is not wired.
|
|
1461
|
+
recycleStatus = null,
|
|
1455
1462
|
} = {}) {
|
|
1456
1463
|
const snapshot = supervisorSnapshot && typeof supervisorSnapshot === 'object'
|
|
1457
1464
|
? supervisorSnapshot
|
|
1458
1465
|
: {};
|
|
1459
|
-
const
|
|
1466
|
+
const relayBuild = typeof relayBuildId === 'string' && relayBuildId.trim() ? relayBuildId.trim() : null;
|
|
1467
|
+
const workers = (Array.isArray(snapshot.workers) ? snapshot.workers : []).map((worker) => (
|
|
1468
|
+
worker && typeof worker === 'object'
|
|
1469
|
+
? {
|
|
1470
|
+
...worker,
|
|
1471
|
+
buildId: typeof worker.buildId === 'string' && worker.buildId ? worker.buildId : null,
|
|
1472
|
+
startedAt: worker.startedAt || null,
|
|
1473
|
+
stale: isWorkerOnOtherBuild(worker, relayBuild),
|
|
1474
|
+
}
|
|
1475
|
+
: worker
|
|
1476
|
+
));
|
|
1460
1477
|
const onlineWorkerStatuses = new Set(SESSION_WORKER_ACTIVE_STATUSES);
|
|
1461
1478
|
const onlineCount = workers.reduce((count, worker) => {
|
|
1462
1479
|
const status = normalizeWorkerStatusText(worker?.status, 'new');
|
|
@@ -1561,6 +1578,8 @@ export function buildSessionWorkerStatusPayload({
|
|
|
1561
1578
|
onlineUnassignedProcessCount,
|
|
1562
1579
|
counts: snapshot.counts && typeof snapshot.counts === 'object' ? snapshot.counts : {},
|
|
1563
1580
|
workers,
|
|
1581
|
+
buildId: relayBuild,
|
|
1582
|
+
recycle: recycleStatus && typeof recycleStatus === 'object' ? recycleStatus : null,
|
|
1564
1583
|
pendingStarts: toSafeNonNegativeInt(snapshot.pendingStarts, 0),
|
|
1565
1584
|
lifecycle: Array.isArray(snapshot.lifecycle) ? snapshot.lifecycle : [],
|
|
1566
1585
|
integrity,
|
|
@@ -2608,9 +2627,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2608
2627
|
bucketTtlMs: 60_000,
|
|
2609
2628
|
maxBuckets: 4_096,
|
|
2610
2629
|
});
|
|
2611
|
-
//
|
|
2612
|
-
//
|
|
2613
|
-
|
|
2630
|
+
// Everything that reaches the session-worker launch path is serialized by
|
|
2631
|
+
// the relay-wide sessionLaunchMutex (services/session-launch-mutex-service.mjs),
|
|
2632
|
+
// keyed by sdkSessionId, which the worker recycle takes too; plus a
|
|
2633
|
+
// coalescer so a duplicated request replays one result.
|
|
2614
2634
|
const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
|
|
2615
2635
|
|
|
2616
2636
|
function getDeepSeekProviderSettings() {
|
|
@@ -6099,7 +6119,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6099
6119
|
// Kill session stops the session's worker, and the delete counts no
|
|
6100
6120
|
// tasks for a conversation whose worker is gone.
|
|
6101
6121
|
message: activity.unstoppableTaskCount > 0
|
|
6102
|
-
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its
|
|
6122
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then delete it.'
|
|
6103
6123
|
: 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
|
|
6104
6124
|
},
|
|
6105
6125
|
};
|
|
@@ -6258,7 +6278,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6258
6278
|
error: 'conversation-active',
|
|
6259
6279
|
reason: 'background-tasks',
|
|
6260
6280
|
message: activity.unstoppableTaskCount > 0
|
|
6261
|
-
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its
|
|
6281
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then archive it.'
|
|
6262
6282
|
: 'Background tasks are still running in this conversation. Stop them in the task panel, then archive it.',
|
|
6263
6283
|
},
|
|
6264
6284
|
};
|
|
@@ -7129,6 +7149,18 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7129
7149
|
}
|
|
7130
7150
|
});
|
|
7131
7151
|
|
|
7152
|
+
// The worker recycle's status, read at request time: the recycle is set on
|
|
7153
|
+
// runtimeState after the routes are registered, and a status that throws
|
|
7154
|
+
// must not take /api/status down with it.
|
|
7155
|
+
function readWorkerRecycleStatus() {
|
|
7156
|
+
if (typeof runtimeState.workerRecycle !== 'function') return null;
|
|
7157
|
+
try {
|
|
7158
|
+
return runtimeState.workerRecycle() || null;
|
|
7159
|
+
} catch {
|
|
7160
|
+
return null;
|
|
7161
|
+
}
|
|
7162
|
+
}
|
|
7163
|
+
|
|
7132
7164
|
// GET /api/status — overall status
|
|
7133
7165
|
app.get('/api/status', auth, (req, res) => {
|
|
7134
7166
|
ensureSessionId(req, res);
|
|
@@ -7146,6 +7178,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
7146
7178
|
supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
|
|
7147
7179
|
queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
|
|
7148
7180
|
processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
|
|
7181
|
+
relayBuildId: runtimeState.buildId ?? null,
|
|
7182
|
+
recycleStatus: readWorkerRecycleStatus(),
|
|
7149
7183
|
});
|
|
7150
7184
|
const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
7151
7185
|
? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)
|