@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"!server/README.md",
|
|
26
26
|
"!server/data/",
|
|
27
27
|
"!server/logs/",
|
|
28
|
+
"!server/backups/",
|
|
28
29
|
"!server/config.json",
|
|
29
30
|
"!server/uploads/",
|
|
30
31
|
"server/uploads/README.md",
|
|
@@ -43,8 +44,9 @@
|
|
|
43
44
|
"preinstall": "node server/services/package-data-rescue-preinstall.mjs",
|
|
44
45
|
"start": "node server/server.js",
|
|
45
46
|
"copilot:relay": "gh copilot -- --allow-all",
|
|
46
|
-
"test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
47
|
+
"test": "node --import ./scripts/node-test-keep-alive.mjs --import ./scripts/node-test-worktree-guard.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
47
48
|
"test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
|
|
49
|
+
"test:packed-update": "node --test tests/packed-update/packed-update.test.mjs",
|
|
48
50
|
"test:e2e": "node tests/run-e2e.mjs",
|
|
49
51
|
"setup:git": "node scripts/setup-git.mjs",
|
|
50
52
|
"land": "node scripts/land.mjs",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
2
|
+
import { tokenUsageModel } from '../../shared/turn-token-usage.mjs';
|
|
2
3
|
import {
|
|
3
4
|
compactBoundaryActivityAction,
|
|
4
5
|
displayToolName,
|
|
@@ -68,6 +69,57 @@ export function readCloudResult(payload) {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A cloud session's running token totals per model, for the token booking
|
|
74
|
+
* (POST /api/turn-token-usage with `sessionTotals`; the relay books what
|
|
75
|
+
* they grew by): `[{ model, inputTokens, cacheWriteTokens, cacheReadTokens,
|
|
76
|
+
* outputTokens, costUsd, costSource, costUnknown }]` out of a result's
|
|
77
|
+
* `modelUsage`. A field the entry does not carry is null, not 0. The cache
|
|
78
|
+
* fields of the session read (`sessionTokens`, readCloudSessionUsage) cover
|
|
79
|
+
* the whole session, so they stand in only when the session ran one model;
|
|
80
|
+
* with more, a missing field stays null. Model ids drop the `[1m]` tier.
|
|
81
|
+
*/
|
|
82
|
+
export function cloudSessionTokenTotals(modelUsage, sessionTokens = null) {
|
|
83
|
+
if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
|
|
84
|
+
const count = (value) => {
|
|
85
|
+
const n = finiteOrNull(value);
|
|
86
|
+
return n === null || n < 0 ? null : Math.trunc(n);
|
|
87
|
+
};
|
|
88
|
+
const totals = new Map();
|
|
89
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
90
|
+
const model = tokenUsageModel(name);
|
|
91
|
+
if (!model || !entry || typeof entry !== 'object') continue;
|
|
92
|
+
const costUsd = finiteOrNull(entry.costUSD);
|
|
93
|
+
const next = {
|
|
94
|
+
model,
|
|
95
|
+
inputTokens: count(entry.inputTokens),
|
|
96
|
+
cacheWriteTokens: count(entry.cacheCreationInputTokens),
|
|
97
|
+
cacheReadTokens: count(entry.cacheReadInputTokens),
|
|
98
|
+
outputTokens: count(entry.outputTokens),
|
|
99
|
+
costUsd: costUsd === null || costUsd < 0 ? null : costUsd,
|
|
100
|
+
costSource: null,
|
|
101
|
+
costUnknown: entry.costBasis === 'unknown',
|
|
102
|
+
};
|
|
103
|
+
const current = totals.get(model);
|
|
104
|
+
if (!current) {
|
|
105
|
+
totals.set(model, next);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const field of ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens', 'costUsd']) {
|
|
109
|
+
if (next[field] !== null) current[field] = (current[field] ?? 0) + next[field];
|
|
110
|
+
}
|
|
111
|
+
current.costUnknown = current.costUnknown || next.costUnknown;
|
|
112
|
+
}
|
|
113
|
+
const entries = [...totals.values()];
|
|
114
|
+
if (entries.length === 1 && sessionTokens) {
|
|
115
|
+
const [only] = entries;
|
|
116
|
+
if (only.cacheWriteTokens === null) only.cacheWriteTokens = count(sessionTokens.cacheWriteTokens);
|
|
117
|
+
if (only.cacheReadTokens === null) only.cacheReadTokens = count(sessionTokens.cacheReadTokens);
|
|
118
|
+
}
|
|
119
|
+
for (const entry of entries) entry.costSource = entry.costUsd === null ? null : 'claude-code';
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
71
123
|
/**
|
|
72
124
|
* What `GET /v1/code/sessions/{id}` says after a turn, for the relay's usage
|
|
73
125
|
* routes: the context occupancy in the shape `/api/claude-context-usage`
|
|
@@ -99,6 +151,14 @@ export function readCloudSessionUsage(sessionBody, { model = '' } = {}) {
|
|
|
99
151
|
};
|
|
100
152
|
return {
|
|
101
153
|
contextUsage,
|
|
154
|
+
// The session's token totals over every model: what the token booking
|
|
155
|
+
// falls back on for the cache fields (cloudSessionTokenTotals).
|
|
156
|
+
tokens: {
|
|
157
|
+
inputTokens: finiteOrNull(usage.input_tokens),
|
|
158
|
+
outputTokens: finiteOrNull(usage.output_tokens),
|
|
159
|
+
cacheReadTokens: finiteOrNull(usage.cache_read_tokens),
|
|
160
|
+
cacheWriteTokens: finiteOrNull(usage.cache_write_tokens),
|
|
161
|
+
},
|
|
102
162
|
costUsd: finiteOrNull(usage.cost_usd),
|
|
103
163
|
sessionUrl: String(shape?.session_url || sessionBody?.sessionUrl || '').trim() || null,
|
|
104
164
|
workerStatus: String(shape?.worker_status || '').trim().toLowerCase() || null,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../claude-worker/claude-turn-publisher.mjs';
|
|
19
19
|
import { summarizeToolInput } from '../claude-worker/sdk-message-normalizer.mjs';
|
|
20
20
|
import {
|
|
21
|
+
cloudSessionTokenTotals,
|
|
21
22
|
createClaudeCloudEventNormalizer,
|
|
22
23
|
readCloudResult,
|
|
23
24
|
readCloudSessionUsage,
|
|
@@ -177,6 +178,18 @@ export function createClaudeCloudSessionRunner({
|
|
|
177
178
|
// Background work is over and nothing has moved for this long: ask the
|
|
178
179
|
// session whether the agent is still going to say something.
|
|
179
180
|
backgroundQuietMs = 45_000,
|
|
181
|
+
// Between turns, while the cloud may still be working on something no
|
|
182
|
+
// relay turn owns (its last known status was not idle, or a turn ended here
|
|
183
|
+
// without its `result`), the session is read again this often: the relay
|
|
184
|
+
// waits on that answer before it replaces this worker (releaseHold).
|
|
185
|
+
statusRecheckMs = 20_000,
|
|
186
|
+
// A session that stays not idle this long is no longer read (one warning
|
|
187
|
+
// line); the next delivery starts the reads again. The hold stays: the
|
|
188
|
+
// relay does not replace a worker whose session it last saw working.
|
|
189
|
+
statusRecheckGiveUpMs = 2 * 60 * 60_000,
|
|
190
|
+
// How often the stored binding is asked for again at start when the relay
|
|
191
|
+
// does not answer (attachOnStart); three tries in all.
|
|
192
|
+
bindingRetryMs = 5_000,
|
|
180
193
|
} = {}) {
|
|
181
194
|
const publisher = createClaudeTurnPublisher({ api, dbg });
|
|
182
195
|
// The account's usage limit as the sandbox's Claude Code reports it: the
|
|
@@ -210,10 +223,23 @@ export function createClaudeCloudSessionRunner({
|
|
|
210
223
|
// - `attributionRequestId`: the request whose answer is still to come.
|
|
211
224
|
// - `model`: the model this process last gave the sandbox (the create's,
|
|
212
225
|
// or a later set_model); undefined while it does not know.
|
|
226
|
+
// - `workerStatus`: the session's `worker_status` at the last read of it
|
|
227
|
+
// ('idle' when the cloud has nothing running), null while unknown.
|
|
213
228
|
let session = null;
|
|
214
229
|
let turn = null;
|
|
215
230
|
let stream = null;
|
|
216
231
|
let idleTimer = null;
|
|
232
|
+
let statusTimer = null;
|
|
233
|
+
// A read of the session between turns that may attach it (syncSession):
|
|
234
|
+
// a delivery waits for it, so the two never read the log at once.
|
|
235
|
+
let pendingSync = null;
|
|
236
|
+
// When the current run of status rechecks began (null: none runs), and
|
|
237
|
+
// whether it was given up (statusRecheckGiveUpMs).
|
|
238
|
+
let recheckSince = null;
|
|
239
|
+
let recheckGivenUp = false;
|
|
240
|
+
// A turn the session started by itself is being registered with the relay.
|
|
241
|
+
let openingStrayTurn = false;
|
|
242
|
+
let disposed = false;
|
|
217
243
|
// Events are handled one at a time and in order; posting a user message
|
|
218
244
|
// takes a place in the same line, so no event of the turn it starts is
|
|
219
245
|
// looked at before the turn's start is known.
|
|
@@ -248,6 +274,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
248
274
|
attribution: undefined,
|
|
249
275
|
attributionRequestId: null,
|
|
250
276
|
model: undefined,
|
|
277
|
+
workerStatus: null,
|
|
251
278
|
};
|
|
252
279
|
}
|
|
253
280
|
|
|
@@ -274,6 +301,33 @@ export function createClaudeCloudSessionRunner({
|
|
|
274
301
|
return Boolean(turn);
|
|
275
302
|
}
|
|
276
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Why the relay may not stop this worker and launch a fresh one now, or ''
|
|
306
|
+
* when it may (the hold names of the relay's STEERING_RELEASE_HOLDS):
|
|
307
|
+
* - 'turn': a turn runs here, delivered or one the session started itself
|
|
308
|
+
* (from the moment the relay is asked for its row).
|
|
309
|
+
* - 'background': no turn runs here, but the cloud may still be working on
|
|
310
|
+
* one: a turn that ended here without its `result` (a Stop the cloud did
|
|
311
|
+
* not confirm, the background-hold ceiling, a turn the relay would not
|
|
312
|
+
* register), or a session whose last known status is not idle. A worker
|
|
313
|
+
* replaced then would lose what that work still writes: the count lives
|
|
314
|
+
* only in this process, and a fresh one reads on from the stored cursor
|
|
315
|
+
* as if nothing were running.
|
|
316
|
+
* A session this process has not read yet holds nothing.
|
|
317
|
+
*/
|
|
318
|
+
function releaseHold() {
|
|
319
|
+
if (turn || openingStrayTurn) return 'turn';
|
|
320
|
+
if (!session) return '';
|
|
321
|
+
if (session.unfinishedTurns > 0) return 'background';
|
|
322
|
+
if (session.workerStatus && session.workerStatus !== 'idle') return 'background';
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Remember what a read of the session said about the cloud's work. */
|
|
327
|
+
function noteSessionRead(workerStatus) {
|
|
328
|
+
if (session) session.workerStatus = workerStatus || null;
|
|
329
|
+
}
|
|
330
|
+
|
|
277
331
|
// ---------------------------------------------------------------------------
|
|
278
332
|
// Relay-facing publishing
|
|
279
333
|
|
|
@@ -455,6 +509,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
455
509
|
let workerStatus = '';
|
|
456
510
|
try {
|
|
457
511
|
({ workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs)));
|
|
512
|
+
noteSessionRead(workerStatus);
|
|
458
513
|
} catch (error) {
|
|
459
514
|
dbg('cloud session read failed', error?.message || String(error));
|
|
460
515
|
}
|
|
@@ -531,6 +586,9 @@ export function createClaudeCloudSessionRunner({
|
|
|
531
586
|
if (!response?.messageId) {
|
|
532
587
|
dbg('stray cloud turn not registered; skipping it until its result');
|
|
533
588
|
session.unfinishedTurns += 1;
|
|
589
|
+
// No turn here will end the wait on its `result`; the session's
|
|
590
|
+
// status will.
|
|
591
|
+
scheduleStatusRecheck();
|
|
534
592
|
return null;
|
|
535
593
|
}
|
|
536
594
|
const message = {
|
|
@@ -568,6 +626,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
568
626
|
endTurn(t);
|
|
569
627
|
if (turn === t) turn = null;
|
|
570
628
|
scheduleIdleClose();
|
|
629
|
+
scheduleStatusRecheck();
|
|
571
630
|
}
|
|
572
631
|
})();
|
|
573
632
|
return t;
|
|
@@ -591,7 +650,14 @@ export function createClaudeCloudSessionRunner({
|
|
|
591
650
|
// continuation of its own, so the chat shows it.
|
|
592
651
|
if (!t && session.attached && sequence !== null && session.unfinishedTurns === 0
|
|
593
652
|
&& sequence > session.cursorSeq && startsStrayTurn(event, payload, type)) {
|
|
594
|
-
|
|
653
|
+
// The relay is asked for a row first: the turn runs already, and the
|
|
654
|
+
// release holds meanwhile as it will once the row is there.
|
|
655
|
+
openingStrayTurn = true;
|
|
656
|
+
try {
|
|
657
|
+
t = await openStrayTurn(sequence, payload, type);
|
|
658
|
+
} finally {
|
|
659
|
+
openingStrayTurn = false;
|
|
660
|
+
}
|
|
595
661
|
if (!t) return;
|
|
596
662
|
}
|
|
597
663
|
|
|
@@ -962,12 +1028,159 @@ export function createClaudeCloudSessionRunner({
|
|
|
962
1028
|
async function confirmUnfinishedTurns() {
|
|
963
1029
|
try {
|
|
964
1030
|
const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
|
|
1031
|
+
noteSessionRead(workerStatus);
|
|
965
1032
|
if (workerStatus === 'idle') session.unfinishedTurns = 0;
|
|
966
1033
|
} catch (error) {
|
|
967
1034
|
dbg('cloud session read failed', error?.message || String(error));
|
|
968
1035
|
}
|
|
969
1036
|
}
|
|
970
1037
|
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
// Between turns: the session's status, and the attach at start
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The session's status, read once (bounded); null when the read failed, in
|
|
1043
|
+
* which case what was known before stays.
|
|
1044
|
+
*/
|
|
1045
|
+
async function readWorkerStatus() {
|
|
1046
|
+
try {
|
|
1047
|
+
const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
|
|
1048
|
+
noteSessionRead(workerStatus);
|
|
1049
|
+
return workerStatus || null;
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
dbg('cloud session read failed', error?.message || String(error));
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Read the session again later while it holds the release with no turn
|
|
1058
|
+
* here to end the wait (a turn's end schedules this itself), or while the
|
|
1059
|
+
* session bound at start is not attached yet.
|
|
1060
|
+
*/
|
|
1061
|
+
function scheduleStatusRecheck() {
|
|
1062
|
+
if (statusTimer || disposed || !session || turn) return;
|
|
1063
|
+
if (session.attached && !releaseHold()) {
|
|
1064
|
+
recheckSince = null;
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (recheckGivenUp) return;
|
|
1068
|
+
if (recheckSince === null) {
|
|
1069
|
+
recheckSince = now();
|
|
1070
|
+
} else if (now() - recheckSince >= statusRecheckGiveUpMs) {
|
|
1071
|
+
// Not forever: a session stuck in a working state would be read every
|
|
1072
|
+
// statusRecheckMs for the life of the worker.
|
|
1073
|
+
recheckGivenUp = true;
|
|
1074
|
+
dbg(
|
|
1075
|
+
`warning: the cloud session has not been idle for ${Math.round(statusRecheckGiveUpMs / 60_000)} min;`,
|
|
1076
|
+
'no more status reads until the next delivery, and the relay does not replace this worker meanwhile',
|
|
1077
|
+
);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
statusTimer = setTimeoutImpl(() => {
|
|
1081
|
+
statusTimer = null;
|
|
1082
|
+
void syncSession();
|
|
1083
|
+
}, statusRecheckMs);
|
|
1084
|
+
statusTimer?.unref?.();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function clearStatusRecheck() {
|
|
1088
|
+
if (statusTimer) clearTimeoutImpl(statusTimer);
|
|
1089
|
+
statusTimer = null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Between turns: read the session, and act on an idle one. An idle cloud
|
|
1094
|
+
* has no `result` left to send, so the turns that ended here without one
|
|
1095
|
+
* are done with (as confirmUnfinishedTurns does before a send). An idle
|
|
1096
|
+
* session bound at start but not attached yet is read on from the stored
|
|
1097
|
+
* cursor up to now and its stream opened, so a turn the session starts by
|
|
1098
|
+
* itself from here on is followed as a continuation, as the worker before
|
|
1099
|
+
* this one did. A session that is working is not attached: the turn it
|
|
1100
|
+
* runs may be a delivered one a dead worker left behind, which the relay
|
|
1101
|
+
* delivers again and the delivery follows from the log (catchUp); taken
|
|
1102
|
+
* for a turn of the session's own, it would be published twice.
|
|
1103
|
+
* A delivery waits for this (pendingSync).
|
|
1104
|
+
*/
|
|
1105
|
+
function syncSession() {
|
|
1106
|
+
if (pendingSync) return pendingSync;
|
|
1107
|
+
if (disposed || !session || turn || openingStrayTurn) return Promise.resolve(false);
|
|
1108
|
+
const run = (async () => {
|
|
1109
|
+
let attachedNow = false;
|
|
1110
|
+
const status = await readWorkerStatus();
|
|
1111
|
+
if (!disposed && session && !turn && status === 'idle') {
|
|
1112
|
+
session.unfinishedTurns = 0;
|
|
1113
|
+
if (!session.attached) {
|
|
1114
|
+
await catchUp();
|
|
1115
|
+
if (!disposed && session && !session.attached) {
|
|
1116
|
+
session.attached = true;
|
|
1117
|
+
ensureStream();
|
|
1118
|
+
attachedNow = true;
|
|
1119
|
+
dbg('cloud event stream opened between turns', `after=${session.lastSeq}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return attachedNow;
|
|
1124
|
+
})();
|
|
1125
|
+
pendingSync = run
|
|
1126
|
+
.catch((error) => {
|
|
1127
|
+
dbg('cloud session sync failed', error?.message || String(error));
|
|
1128
|
+
return false;
|
|
1129
|
+
})
|
|
1130
|
+
.finally(() => {
|
|
1131
|
+
pendingSync = null;
|
|
1132
|
+
scheduleStatusRecheck();
|
|
1133
|
+
});
|
|
1134
|
+
return pendingSync;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** The binding the relay stores for this conversation, or null. */
|
|
1138
|
+
async function readStoredBinding() {
|
|
1139
|
+
const routePath = `/api/claude-cloud-session?conversationId=${encodeURIComponent(sdkSessionId)}`;
|
|
1140
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await api('GET', routePath);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
// A relay without the route answers 404 for good: the first delivery
|
|
1145
|
+
// binds the session, as before.
|
|
1146
|
+
if (Number(error?.status) === 404 || attempt >= 3 || disposed) {
|
|
1147
|
+
dbg('stored cloud session not read', error?.message || String(error));
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
await sleep(bindingRetryMs);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* At worker start: bind the cloud session the relay stores for this
|
|
1157
|
+
* conversation and open its event stream from the stored cursor, before
|
|
1158
|
+
* any delivery. The worker this one replaces kept its stream open for its
|
|
1159
|
+
* whole life (idleCloseMs = 0) and so mirrored the turns the session
|
|
1160
|
+
* starts by itself (a message sent on claude.ai, the agent's closing turn
|
|
1161
|
+
* after background work); without this a fresh worker would see none of
|
|
1162
|
+
* them until its first delivery. A conversation with no cloud session yet
|
|
1163
|
+
* has nothing to attach: its first delivery creates one. A session that is
|
|
1164
|
+
* working at start is read again every statusRecheckMs and attached once
|
|
1165
|
+
* idle (syncSession says why not before).
|
|
1166
|
+
*
|
|
1167
|
+
* Resolves true when the stream was opened.
|
|
1168
|
+
*/
|
|
1169
|
+
async function attachOnStart() {
|
|
1170
|
+
if (session || turn || disposed || pendingSync) return false;
|
|
1171
|
+
const run = (async () => {
|
|
1172
|
+
const binding = await readStoredBinding();
|
|
1173
|
+
const id = String(binding?.sessionId || '').trim();
|
|
1174
|
+
if (!id || session || disposed) return false;
|
|
1175
|
+
session = newSession(id, { cursor: toCloudSequence(binding.lastSequence) ?? 0 });
|
|
1176
|
+
return true;
|
|
1177
|
+
})();
|
|
1178
|
+
// A delivery that arrives meanwhile waits until the binding is known.
|
|
1179
|
+
pendingSync = run.catch(() => false).finally(() => { pendingSync = null; });
|
|
1180
|
+
if (!await pendingSync) return false;
|
|
1181
|
+
return syncSession();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
971
1184
|
/**
|
|
972
1185
|
* Before a message goes into a session that exists: hand the sandbox the
|
|
973
1186
|
* commit attribution the relay delivered, unless this process already did.
|
|
@@ -1172,6 +1385,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
1172
1385
|
dbg('cloud session read failed', error?.message || String(error));
|
|
1173
1386
|
}
|
|
1174
1387
|
if (usage.sessionUrl) session.url = usage.sessionUrl;
|
|
1388
|
+
if (usage.workerStatus !== undefined) noteSessionRead(usage.workerStatus);
|
|
1175
1389
|
const costUsd = usage.costUsd ?? state.result?.totalCostUsd ?? null;
|
|
1176
1390
|
await reportSession(message, {
|
|
1177
1391
|
...(costUsd !== null ? { costUsd } : {}),
|
|
@@ -1180,6 +1394,32 @@ export function createClaudeCloudSessionRunner({
|
|
|
1180
1394
|
state.contextUsage = usage.contextUsage;
|
|
1181
1395
|
await publisher.publishContextUsage({ message, state, model, sdkSessionId });
|
|
1182
1396
|
await publisher.publishPlanUsage({ message, state, sdkSessionId });
|
|
1397
|
+
await publishTurnTokens(message, state, usage.tokens);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// A worker process names its run once: a turn requeued onto a restarted
|
|
1401
|
+
// worker is another run of it (POST /api/turn-token-usage).
|
|
1402
|
+
const tokenRunId = crypto.randomUUID();
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* The session's running token totals per model (the result's
|
|
1406
|
+
* `modelUsage`), under the turn: the relay books what they grew by since
|
|
1407
|
+
* the session's other turns. Advisory; nothing without `modelUsage`.
|
|
1408
|
+
*/
|
|
1409
|
+
async function publishTurnTokens(message, state, sessionTokens) {
|
|
1410
|
+
const sessionTotals = cloudSessionTokenTotals(state.result?.modelUsage, sessionTokens);
|
|
1411
|
+
if (!sessionTotals.length || !session.id) return;
|
|
1412
|
+
await api('POST', '/api/turn-token-usage', {
|
|
1413
|
+
conversationId: message.conversationId,
|
|
1414
|
+
turnId: String(message.id || ''),
|
|
1415
|
+
runId: tokenRunId,
|
|
1416
|
+
provider: 'claude-cloud',
|
|
1417
|
+
nativeSessionId: session.id,
|
|
1418
|
+
at: new Date().toISOString(),
|
|
1419
|
+
sessionTotals,
|
|
1420
|
+
}).catch((error) => {
|
|
1421
|
+
dbg('turn token publish failed', error?.message || String(error));
|
|
1422
|
+
});
|
|
1183
1423
|
}
|
|
1184
1424
|
|
|
1185
1425
|
async function finishTurn(t, outcome) {
|
|
@@ -1291,7 +1531,19 @@ export function createClaudeCloudSessionRunner({
|
|
|
1291
1531
|
dbg('turn already active; rejecting concurrent deliver', message.id);
|
|
1292
1532
|
return false;
|
|
1293
1533
|
}
|
|
1534
|
+
// The session is being read between turns (the attach at start, or a
|
|
1535
|
+
// status check): this delivery binds and reads it after that, never
|
|
1536
|
+
// beside it. A turn the session started meanwhile takes precedence.
|
|
1537
|
+
while (pendingSync) await pendingSync;
|
|
1538
|
+
if (turn) {
|
|
1539
|
+
dbg('turn already active; rejecting concurrent deliver', message.id);
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1294
1542
|
clearIdleClose();
|
|
1543
|
+
clearStatusRecheck();
|
|
1544
|
+
// A delivery starts the status reads afresh, also after they were given up.
|
|
1545
|
+
recheckSince = null;
|
|
1546
|
+
recheckGivenUp = false;
|
|
1295
1547
|
const t = openTurn(message);
|
|
1296
1548
|
const relayHoldMs = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
1297
1549
|
if (Number.isFinite(relayHoldMs) && relayHoldMs > 0) t.holdMs = relayHoldMs;
|
|
@@ -1310,11 +1562,14 @@ export function createClaudeCloudSessionRunner({
|
|
|
1310
1562
|
endTurn(t);
|
|
1311
1563
|
turn = null;
|
|
1312
1564
|
scheduleIdleClose();
|
|
1565
|
+
scheduleStatusRecheck();
|
|
1313
1566
|
}
|
|
1314
1567
|
}
|
|
1315
1568
|
|
|
1316
1569
|
async function dispose() {
|
|
1570
|
+
disposed = true;
|
|
1317
1571
|
clearIdleClose();
|
|
1572
|
+
clearStatusRecheck();
|
|
1318
1573
|
if (turn) endTurn(turn);
|
|
1319
1574
|
const entry = stream;
|
|
1320
1575
|
stream = null;
|
|
@@ -1326,6 +1581,8 @@ export function createClaudeCloudSessionRunner({
|
|
|
1326
1581
|
getActiveQueueMessageId,
|
|
1327
1582
|
getActiveQueueAttempt,
|
|
1328
1583
|
isTurnActive,
|
|
1584
|
+
releaseHold,
|
|
1585
|
+
attachOnStart,
|
|
1329
1586
|
dispose,
|
|
1330
1587
|
};
|
|
1331
1588
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
15
15
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
16
16
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
17
|
+
import { resolveBuildId } from '../../shared/build-id.mjs';
|
|
17
18
|
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
18
19
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
19
20
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
@@ -21,6 +22,7 @@ import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credenti
|
|
|
21
22
|
import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
|
|
22
23
|
import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
|
|
23
24
|
import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
|
|
25
|
+
import { buildClaudeCloudSteeringState } from './claude-cloud-worker-steering.mjs';
|
|
24
26
|
|
|
25
27
|
// Windows console launch: the launcher names the worker log, this copies
|
|
26
28
|
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
@@ -51,6 +53,10 @@ async function main() {
|
|
|
51
53
|
|
|
52
54
|
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
53
55
|
|| path.resolve(__dirname, '..', 'config.json');
|
|
56
|
+
// The code this process started on, read once from the repo root and kept
|
|
57
|
+
// for its whole life: the relay recycles a worker whose build differs from
|
|
58
|
+
// its own, and a tree that moves on under us must not change our answer.
|
|
59
|
+
const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
|
|
54
60
|
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
55
61
|
const token = loadTokenFromConfig(configPath);
|
|
56
62
|
const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
|
|
@@ -101,17 +107,28 @@ async function main() {
|
|
|
101
107
|
let heartbeatTimer = null;
|
|
102
108
|
const heartbeat = createHeartbeatController({
|
|
103
109
|
api,
|
|
110
|
+
buildId,
|
|
104
111
|
pollMs: HEARTBEAT_MS,
|
|
105
112
|
getSessionReady: () => true,
|
|
106
113
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
107
114
|
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
108
115
|
getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
|
|
116
|
+
// No steering here; the snapshot tells the relay when it may replace
|
|
117
|
+
// this worker: between turns, with no turn that ended here without its
|
|
118
|
+
// result, and with the cloud session idle at its last read. Anything else
|
|
119
|
+
// is cloud work whose output only this process would still publish. The
|
|
120
|
+
// cloud session lives on in the cloud; the fresh worker binds it at start
|
|
121
|
+
// and re-opens its event stream from the cursor the relay stores
|
|
122
|
+
// (runner.attachOnStart below), so the turns the session starts by
|
|
123
|
+
// itself are still followed.
|
|
124
|
+
getSteeringState: () => buildClaudeCloudSteeringState(runner),
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
const wsLink = createWorkerWebSocketLink({
|
|
112
128
|
serverUrl,
|
|
113
129
|
token,
|
|
114
130
|
dbg,
|
|
131
|
+
buildId,
|
|
115
132
|
getSessionReady: () => true,
|
|
116
133
|
getSessionId: () => sdkSessionId,
|
|
117
134
|
getPid: () => process.pid,
|
|
@@ -144,9 +161,13 @@ async function main() {
|
|
|
144
161
|
getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
|
|
145
162
|
});
|
|
146
163
|
|
|
147
|
-
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
|
|
164
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
|
|
148
165
|
heartbeat.startHeartbeat();
|
|
149
166
|
wsLink.start();
|
|
167
|
+
// Not awaited: deliveries wait for it inside the runner, and a relay that
|
|
168
|
+
// cannot say which cloud session this is leaves the binding to the first
|
|
169
|
+
// delivery, as before.
|
|
170
|
+
runner.attachOnStart().catch((error) => dbg('cloud session attach at start failed', error?.message || String(error)));
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
main().catch((error) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heartbeat steering snapshot of the Claude Cloud worker. It steers
|
|
3
|
+
* nothing (`supported: false`, so the composer offers no mid-turn steering and
|
|
4
|
+
* reads neither the hold nor the cancellable ids); it is sent for the release
|
|
5
|
+
* fields, which tell the relay whether it may stop this worker and launch a
|
|
6
|
+
* fresh one now (its recycle after a deploy).
|
|
7
|
+
*
|
|
8
|
+
* The rule is the runner's (`releaseHold()` in claude-cloud-session-process.mjs):
|
|
9
|
+
* not while a turn runs here, not while a turn that ended here without its
|
|
10
|
+
* `result` may still run in the cloud, and not while the cloud session's last
|
|
11
|
+
* known status is not idle. The generic snapshot of the other small mains
|
|
12
|
+
* (shared/worker-runtime/simple-steering-state.mjs) knows only the turn; the
|
|
13
|
+
* cloud session can be busy with no relay turn at all.
|
|
14
|
+
*
|
|
15
|
+
* `runner` is the object createClaudeCloudSessionRunner returns.
|
|
16
|
+
*/
|
|
17
|
+
export function buildClaudeCloudSteeringState(runner) {
|
|
18
|
+
const attempt = runner?.getActiveQueueAttempt?.() || null;
|
|
19
|
+
const messageId = String(attempt?.id || '').trim() || null;
|
|
20
|
+
const turnActive = runner?.isTurnActive?.() === true || Boolean(messageId);
|
|
21
|
+
// A runner without the hold function still never says yes mid-turn.
|
|
22
|
+
const hold = String(runner?.releaseHold?.() || '').trim() || (turnActive ? 'turn' : '');
|
|
23
|
+
return {
|
|
24
|
+
turnActive,
|
|
25
|
+
canSteer: false,
|
|
26
|
+
holdReason: null,
|
|
27
|
+
messageId,
|
|
28
|
+
supported: false,
|
|
29
|
+
cancellableIds: [],
|
|
30
|
+
releasable: !hold,
|
|
31
|
+
releaseHold: hold || null,
|
|
32
|
+
backgroundTaskCount: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -75,6 +75,7 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
|
75
75
|
group: z.string().nullable().optional().describe(FIELDS.group.description),
|
|
76
76
|
auto_compact_window: number('auto_compact_window'),
|
|
77
77
|
thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
|
|
78
|
+
planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
|
|
78
79
|
thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
|
|
79
80
|
question_id: text('question_id'),
|
|
80
81
|
// ask: the contract's validator refuses a question or detail that is too
|
|
@@ -615,6 +615,9 @@ export function createClaudeSessionRunner({
|
|
|
615
615
|
// Both Claude billings report to the Claude API booking; DeepSeek has
|
|
616
616
|
// its own.
|
|
617
617
|
...(claudeBillingOf(workerKind) ? { billingUsageRoute: '/api/claude-api-usage', billingKind: workerKind } : {}),
|
|
618
|
+
// Every Claude-family worker books its turns' tokens.
|
|
619
|
+
turnTokenProvider: workerKind,
|
|
620
|
+
...(typeof backend?.turnTokenCost === 'function' ? { turnTokenCost: backend.turnTokenCost } : {}),
|
|
618
621
|
...(backend
|
|
619
622
|
? {
|
|
620
623
|
describeFailure: backend.describeFailure,
|
|
@@ -4247,11 +4250,17 @@ export function createClaudeSessionRunner({
|
|
|
4247
4250
|
// `releasable`: the relay may replace this worker now (a billing swap):
|
|
4248
4251
|
// no CLI process and no delivery on its way in, or a process
|
|
4249
4252
|
// canReleaseForRespawn would let go of.
|
|
4253
|
+
// `cliAlive`: the CLI child is still up. The relay's recycle replaces a
|
|
4254
|
+
// stale worker only once it is releasable AND its CLI is gone; a
|
|
4255
|
+
// releasable worker whose CLI is up sits in the idle window, where a
|
|
4256
|
+
// scheduled job may still wake it, and is left to its own idle-out. A
|
|
4257
|
+
// closing process is not gone yet, so it still counts as alive.
|
|
4250
4258
|
if (!proc || proc.closing || proc.aborted) {
|
|
4251
4259
|
const admitting = admittingDeliveries > 0;
|
|
4252
4260
|
return {
|
|
4253
4261
|
turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
|
|
4254
4262
|
releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
|
|
4263
|
+
cliAlive: Boolean(proc),
|
|
4255
4264
|
};
|
|
4256
4265
|
}
|
|
4257
4266
|
const ctx = proc.activeCtx || null;
|
|
@@ -4290,6 +4299,7 @@ export function createClaudeSessionRunner({
|
|
|
4290
4299
|
releasable: !hold,
|
|
4291
4300
|
releaseHold: hold || null,
|
|
4292
4301
|
backgroundTaskCount: proc.liveTasks.size,
|
|
4302
|
+
cliAlive: true,
|
|
4293
4303
|
};
|
|
4294
4304
|
}
|
|
4295
4305
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
15
15
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
16
16
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
17
|
+
import { resolveBuildId } from '../../shared/build-id.mjs';
|
|
17
18
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
18
19
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
19
20
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
@@ -59,6 +60,10 @@ export async function runClaudeSessionWorker({
|
|
|
59
60
|
|
|
60
61
|
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
61
62
|
|| path.resolve(__dirname, '..', 'config.json');
|
|
63
|
+
// The code this process started on, read once from the repo root and kept
|
|
64
|
+
// for its whole life: the relay recycles a worker whose build differs from
|
|
65
|
+
// its own, and a tree that moves on under us must not change our answer.
|
|
66
|
+
const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
|
|
62
67
|
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
63
68
|
const token = loadTokenFromConfig(configPath);
|
|
64
69
|
const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
|
|
@@ -143,6 +148,7 @@ export async function runClaudeSessionWorker({
|
|
|
143
148
|
let heartbeatTimer = null;
|
|
144
149
|
heartbeat = createHeartbeatController({
|
|
145
150
|
api,
|
|
151
|
+
buildId,
|
|
146
152
|
// What this worker runs on: the relay hands it only rows of that billing.
|
|
147
153
|
workerKind: turnRunner.workerKind,
|
|
148
154
|
pollMs: HEARTBEAT_MS,
|
|
@@ -191,6 +197,7 @@ export async function runClaudeSessionWorker({
|
|
|
191
197
|
getSessionId: () => sdkSessionId,
|
|
192
198
|
getPid: () => process.pid,
|
|
193
199
|
workerKind: turnRunner.workerKind,
|
|
200
|
+
buildId,
|
|
194
201
|
onDeliver: async (pending, reason) => {
|
|
195
202
|
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
196
203
|
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
@@ -263,7 +270,7 @@ export async function runClaudeSessionWorker({
|
|
|
263
270
|
onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
|
|
264
271
|
});
|
|
265
272
|
|
|
266
|
-
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''}`);
|
|
273
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''} build=${buildId || 'unknown'}`);
|
|
267
274
|
heartbeat.startHeartbeat();
|
|
268
275
|
wsLink.start();
|
|
269
276
|
}
|