@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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacing a session's worker: the stop and the launch that the Claude
|
|
3
|
+
* billing swap (claude-billing-swap-service.mjs) and the worker recycle
|
|
4
|
+
* (worker-recycle-service.mjs) share, and the predicate both use to tell
|
|
5
|
+
* whether a worker may be replaced now.
|
|
6
|
+
*
|
|
7
|
+
* The stop is the kill route's: nothing may start the session while it is
|
|
8
|
+
* stopped, every process of the session must be gone before it counts as
|
|
9
|
+
* stopped, and only then is the registry entry dropped. The launch never
|
|
10
|
+
* reuses a live process, so two CLIs never share a session's transcript.
|
|
11
|
+
* The fence keeps the deliveries away from the process being replaced for the
|
|
12
|
+
* whole of it (createWorkerReplacementFence).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { stopSessionWorkerProcesses as defaultStopSessionWorkerProcesses } from './session-worker-stop-service.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What keeps a worker from being replaced now: null when nothing does, else
|
|
19
|
+
* 'turn', 'question', 'compaction' or 'background'. From the steering
|
|
20
|
+
* snapshot its heartbeat sends; a worker too old to say `releasable` is read
|
|
21
|
+
* from the rest of it.
|
|
22
|
+
*/
|
|
23
|
+
export function workerReleaseHold(worker) {
|
|
24
|
+
if (!worker) return null;
|
|
25
|
+
const steering = worker.steering || null;
|
|
26
|
+
if (steering && typeof steering.releasable === 'boolean') {
|
|
27
|
+
return steering.releasable ? null : (steering.releaseHold || 'turn');
|
|
28
|
+
}
|
|
29
|
+
if (steering) {
|
|
30
|
+
if (steering.holdReason === 'question') return 'question';
|
|
31
|
+
if (steering.holdReason === 'compaction') return 'compaction';
|
|
32
|
+
if (steering.turnActive) return 'turn';
|
|
33
|
+
if (steering.autoCompactWindow?.backgroundWork) return 'background';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return String(worker.status || '') === 'processing' ? 'turn' : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toFencePid(value) {
|
|
40
|
+
const pid = Number(value);
|
|
41
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The replacement fence: while a worker process is being replaced, no work
|
|
46
|
+
* is handed to it.
|
|
47
|
+
*
|
|
48
|
+
* The killed marker the stop sets does not do this. It lapses after the
|
|
49
|
+
* supervisor's short kill grace (a stop that verifies every process gone can
|
|
50
|
+
* take longer), and any path that relaunches a session lifts it at once
|
|
51
|
+
* (`clearRestartSchedule({ resetKilledMarker: true })`, the delivery's own
|
|
52
|
+
* fallback restart among them), after which a delivery would start a turn on
|
|
53
|
+
* the very process being killed. The fence is its own structure, so nothing
|
|
54
|
+
* that resets the supervisor's lifecycle state touches it.
|
|
55
|
+
*
|
|
56
|
+
* Keyed by session and the pid being replaced: the new worker has another
|
|
57
|
+
* pid and is never fenced, so it takes the work that waited meanwhile. A
|
|
58
|
+
* request that names no pid cannot be told from the old process and is
|
|
59
|
+
* refused while the session's fence is up.
|
|
60
|
+
*
|
|
61
|
+
* - `hold(sid, pid)`: raise the fence for that process of the session.
|
|
62
|
+
* - `isHeld(sid, pid)`: true while the fence is up for that pid (or for any
|
|
63
|
+
* pid of the session, when `pid` is unknown).
|
|
64
|
+
* - `release(sid, pid?)`: lower it; with a pid, only if that is the fenced
|
|
65
|
+
* one, so a late release from an older replacement never lifts a newer one.
|
|
66
|
+
*/
|
|
67
|
+
export function createWorkerReplacementFence() {
|
|
68
|
+
const held = new Map();
|
|
69
|
+
|
|
70
|
+
function key(sid) {
|
|
71
|
+
return String(sid || '').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hold(sid, pid) {
|
|
75
|
+
const sessionId = key(sid);
|
|
76
|
+
if (!sessionId) return false;
|
|
77
|
+
held.set(sessionId, toFencePid(pid));
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isHeld(sid, pid = null) {
|
|
82
|
+
const sessionId = key(sid);
|
|
83
|
+
if (!sessionId || !held.has(sessionId)) return false;
|
|
84
|
+
const fencedPid = held.get(sessionId);
|
|
85
|
+
const askingPid = toFencePid(pid);
|
|
86
|
+
// An unknown pid on either side: the old process cannot be ruled out.
|
|
87
|
+
if (fencedPid === null || askingPid === null) return true;
|
|
88
|
+
return fencedPid === askingPid;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function release(sid, pid = undefined) {
|
|
92
|
+
const sessionId = key(sid);
|
|
93
|
+
if (!sessionId || !held.has(sessionId)) return false;
|
|
94
|
+
if (pid !== undefined && toFencePid(pid) !== held.get(sessionId)) return false;
|
|
95
|
+
held.delete(sessionId);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function list() {
|
|
100
|
+
return Array.from(held, ([sdkSessionId, pid]) => ({ sdkSessionId, pid }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { hold, isHeld, release, list };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createWorkerReplacement({
|
|
107
|
+
// The session worker registry: getWorker(sid), removeWorker(sid).
|
|
108
|
+
registry,
|
|
109
|
+
// The session worker supervisor: markKilled, cancelPendingStart,
|
|
110
|
+
// clearRestartSchedule, resetHealth, ensureWorker.
|
|
111
|
+
supervisor,
|
|
112
|
+
// Handed to stopSessionWorkerProcesses to find every process of a session.
|
|
113
|
+
processInspector = null,
|
|
114
|
+
// session-worker-stop-service.mjs by default; tests inject a fake so they
|
|
115
|
+
// need no processes: async ({ sdkSessionId, worker, processInspector,
|
|
116
|
+
// isPidAliveImpl, killTmuxSessionImpl }) → { ok, error?, remainingPids? }.
|
|
117
|
+
stopSessionWorkerProcesses = defaultStopSessionWorkerProcesses,
|
|
118
|
+
isProcessAlive = undefined,
|
|
119
|
+
killTmuxSession = undefined,
|
|
120
|
+
logger = console,
|
|
121
|
+
// The caller's tag for its warnings ('BILLING SWAP', 'WORKER RECYCLE'), and
|
|
122
|
+
// a function for the runtime's own log prefix, or a fixed string.
|
|
123
|
+
logPrefix = '',
|
|
124
|
+
// The replacement fence the caller raises before the stop and lowers once
|
|
125
|
+
// the launch is done (createWorkerReplacementFence). Passed in so the
|
|
126
|
+
// runtime's replacements and its delivery paths share one; this module
|
|
127
|
+
// only hands it back.
|
|
128
|
+
fence = createWorkerReplacementFence(),
|
|
129
|
+
} = {}) {
|
|
130
|
+
if (typeof stopSessionWorkerProcesses !== 'function') {
|
|
131
|
+
throw new Error('createWorkerReplacement requires stopSessionWorkerProcesses');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function prefix() {
|
|
135
|
+
try {
|
|
136
|
+
return typeof logPrefix === 'function' ? String(logPrefix() || '') : String(logPrefix || '');
|
|
137
|
+
} catch {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stop every process of the session. `{ ok: true }` once they are all gone
|
|
144
|
+
* and the registry and the supervisor's health state are cleared; else
|
|
145
|
+
* `{ ok: false, error, remainingPids }`, and the caller retries with a
|
|
146
|
+
* backoff.
|
|
147
|
+
*
|
|
148
|
+
* `keepKilled: true` is for a session that must stay stopped (an archived
|
|
149
|
+
* conversation): the killed marker set here is left in place on either
|
|
150
|
+
* outcome, as the archive route leaves it, so nothing relaunches the
|
|
151
|
+
* session meanwhile.
|
|
152
|
+
*/
|
|
153
|
+
async function stopWorker(sid, { keepKilled = false } = {}) {
|
|
154
|
+
const worker = registry?.getWorker?.(sid) || null;
|
|
155
|
+
// Nothing may start the session while it is being stopped: a launch then
|
|
156
|
+
// would adopt the old process, kind and build and all.
|
|
157
|
+
supervisor?.markKilled?.(sid);
|
|
158
|
+
// A stop that fails backs off (the caller retries it). Unless the session
|
|
159
|
+
// is to stay stopped, the killed marker goes with it, so the session's
|
|
160
|
+
// worker, alive or relaunched, keeps asking for work meanwhile.
|
|
161
|
+
const backOff = (error, remainingPids = []) => {
|
|
162
|
+
if (!keepKilled) supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
163
|
+
const remaining = remainingPids.length ? ` remaining=${remainingPids.join(',')}` : '';
|
|
164
|
+
const marker = keepKilled ? 'kill marker kept' : 'kill marker cleared';
|
|
165
|
+
try {
|
|
166
|
+
logger.warn?.(`${prefix()}STOP FAILED session=${String(sid).slice(0, 8)} ${error}${remaining} — ${marker}, retrying with a backoff`);
|
|
167
|
+
} catch {}
|
|
168
|
+
return { ok: false, error, remainingPids };
|
|
169
|
+
};
|
|
170
|
+
let stopped = null;
|
|
171
|
+
try {
|
|
172
|
+
await supervisor?.cancelPendingStart?.(sid, { wait: true });
|
|
173
|
+
stopped = await stopSessionWorkerProcesses({
|
|
174
|
+
sdkSessionId: sid,
|
|
175
|
+
worker,
|
|
176
|
+
processInspector,
|
|
177
|
+
isPidAliveImpl: isProcessAlive,
|
|
178
|
+
killTmuxSessionImpl: killTmuxSession,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return backOff(String(error?.message || error || 'worker-stop-failed'));
|
|
182
|
+
}
|
|
183
|
+
if (!stopped?.ok) return backOff(stopped?.error || 'worker-survived-stop', stopped?.remainingPids || []);
|
|
184
|
+
registry?.removeWorker?.(sid);
|
|
185
|
+
supervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
186
|
+
return { ok: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Launch the session's worker, never reusing a live process: `{ ok, error }`. */
|
|
190
|
+
async function launchWorker(sid) {
|
|
191
|
+
supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
192
|
+
const launched = await supervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
|
|
193
|
+
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { stopWorker, launchWorker, fence };
|
|
197
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The build id names the code a process runs, so the relay can tell a session
|
|
5
|
+
// worker that started on older code from one that runs what the relay runs.
|
|
6
|
+
//
|
|
7
|
+
// It is `${version}+${commit.slice(0, 12)}` when both are known, the version
|
|
8
|
+
// alone when there is no commit, and null when there is no version either. The
|
|
9
|
+
// commit is part of it because on a git checkout a deploy is a new
|
|
10
|
+
// commit at the same version; on an npm install without a `gitHead` stamp the
|
|
11
|
+
// version alone decides.
|
|
12
|
+
//
|
|
13
|
+
// The relay and every worker compute it once at process start and never read
|
|
14
|
+
// it again, so a tree that moved on under a running process still describes
|
|
15
|
+
// the code that process started with.
|
|
16
|
+
//
|
|
17
|
+
// This module lives in shared/ because both the relay and the workers import
|
|
18
|
+
// it; it depends on nothing beyond node:fs and node:path for the same reason.
|
|
19
|
+
|
|
20
|
+
const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
|
|
21
|
+
const COMMIT_ID_LENGTH = 12;
|
|
22
|
+
|
|
23
|
+
// The relay stores the build id a worker states, so what it accepts is bounded
|
|
24
|
+
// in length and limited to the characters a version and a commit use.
|
|
25
|
+
const BUILD_ID_MAX_LENGTH = 80;
|
|
26
|
+
const BUILD_ID_PATTERN = /^[A-Za-z0-9+._-]+$/;
|
|
27
|
+
|
|
28
|
+
function readFileText(fsImpl, filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return String(fsImpl.readFileSync(filePath, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPackageManifest(packageRoot, { fsImpl, pathImpl }) {
|
|
37
|
+
if (!packageRoot) return null;
|
|
38
|
+
try {
|
|
39
|
+
const manifest = JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '');
|
|
40
|
+
return manifest && typeof manifest === 'object' ? manifest : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The commit a git checkout has checked out, read from its .git files rather
|
|
48
|
+
* than from a `git` process: HEAD, then the loose ref, then packed-refs. A
|
|
49
|
+
* linked worktree's .git is a file naming its git dir, whose refs live in the
|
|
50
|
+
* common dir. Anything unexpected reads as null. `pathImpl` is the host's path
|
|
51
|
+
* module; tests pass path.posix and path.win32.
|
|
52
|
+
*/
|
|
53
|
+
export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
|
|
54
|
+
if (!root) return null;
|
|
55
|
+
const dotGit = pathImpl.join(root, '.git');
|
|
56
|
+
let gitDir = dotGit;
|
|
57
|
+
const pointer = readFileText(fsImpl, dotGit);
|
|
58
|
+
if (pointer !== null) {
|
|
59
|
+
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
gitDir = pathImpl.resolve(root, match[1].trim());
|
|
62
|
+
}
|
|
63
|
+
const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
|
|
64
|
+
if (head === null) return null;
|
|
65
|
+
const headText = head.trim();
|
|
66
|
+
if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
|
|
67
|
+
const refMatch = headText.match(/^ref:\s*(\S+)$/);
|
|
68
|
+
if (!refMatch) return null;
|
|
69
|
+
const ref = refMatch[1];
|
|
70
|
+
const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
|
|
71
|
+
const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
|
|
72
|
+
for (const dir of new Set([gitDir, commonDir])) {
|
|
73
|
+
const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
|
|
74
|
+
if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
|
|
77
|
+
for (const line of packed.split(/\r?\n/)) {
|
|
78
|
+
const [hash, name] = line.trim().split(/\s+/);
|
|
79
|
+
if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
|
|
85
|
+
export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
86
|
+
const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
|
|
87
|
+
if (fromGit) return fromGit;
|
|
88
|
+
const stamp = String(readPackageManifest(packageRoot, { fsImpl, pathImpl })?.gitHead || '').trim();
|
|
89
|
+
return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The package.json `version`, or null when the manifest cannot be read or names none. */
|
|
93
|
+
export function readPackageVersion(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
94
|
+
const version = readPackageManifest(packageRoot, { fsImpl, pathImpl })?.version;
|
|
95
|
+
return typeof version === 'string' && version.trim() ? version.trim() : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The build id of the tree at `packageRoot`: `{ version, commit, id }`. Call it
|
|
100
|
+
* once at process start and keep the result; see the header for why.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveBuildId(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
103
|
+
const version = readPackageVersion(packageRoot, { fsImpl, pathImpl });
|
|
104
|
+
const commit = resolveBuildCommit(packageRoot, { fsImpl, pathImpl });
|
|
105
|
+
let id = null;
|
|
106
|
+
if (version && commit) id = `${version}+${commit.slice(0, COMMIT_ID_LENGTH)}`;
|
|
107
|
+
else if (version) id = version;
|
|
108
|
+
return { version, commit, id };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True only when both ids are known and differ. An unknown id on either side
|
|
113
|
+
* is not evidence of a change, so it never reads as one.
|
|
114
|
+
*/
|
|
115
|
+
export function buildIdsDiffer(a, b) {
|
|
116
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
117
|
+
const left = a.trim();
|
|
118
|
+
const right = b.trim();
|
|
119
|
+
return !!left && !!right && left !== right;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A stated build id, trimmed, or null when it is too long or carries other characters. */
|
|
123
|
+
export function normalizeBuildId(value) {
|
|
124
|
+
if (typeof value !== 'string') return null;
|
|
125
|
+
const text = value.trim();
|
|
126
|
+
if (!text || text.length > BUILD_ID_MAX_LENGTH || !BUILD_ID_PATTERN.test(text)) return null;
|
|
127
|
+
return text;
|
|
128
|
+
}
|
|
@@ -37,6 +37,10 @@ export function createHeartbeatController({
|
|
|
37
37
|
// relay hands a Claude chat's row only to a worker of the row's billing,
|
|
38
38
|
// and knows a worker's kind from what it says here and in its hello.
|
|
39
39
|
workerKind = null,
|
|
40
|
+
// Optional: the build id of the code this worker started on
|
|
41
|
+
// (shared/build-id.mjs). A worker whose websocket is down still beats, so
|
|
42
|
+
// the relay learns the build here as well as from the hello.
|
|
43
|
+
buildId = null,
|
|
40
44
|
// requestPulse's window: one turn transition can flip the steering
|
|
41
45
|
// snapshot several times in quick succession, and each flip only needs to
|
|
42
46
|
// reach the relay once.
|
|
@@ -71,6 +75,7 @@ export function createHeartbeatController({
|
|
|
71
75
|
...(settleFailed.length ? { settleFailed } : {}),
|
|
72
76
|
...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
|
|
73
77
|
...(workerKind ? { workerKind: String(workerKind) } : {}),
|
|
78
|
+
...(typeof buildId === "string" && buildId.trim() ? { buildId: buildId.trim() } : {}),
|
|
74
79
|
};
|
|
75
80
|
const response = await api("POST", "/api/heartbeat", body);
|
|
76
81
|
// Failed by the relay, or skipped because this worker has no claim on
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heartbeat steering snapshot of a worker that runs one delivered message
|
|
3
|
+
* at a time and never steers into a running turn: the Cursor, Grok and Claude
|
|
4
|
+
* cloud workers. They send it for the release fields, which tell the relay
|
|
5
|
+
* whether it may stop this worker and launch a fresh one now (its recycle
|
|
6
|
+
* after a deploy), the same fields the Claude and Copilot SDK workers send.
|
|
7
|
+
*
|
|
8
|
+
* `supported: false` keeps the composer as it was for these kinds: it offers
|
|
9
|
+
* mid-turn steering only to a worker that says `supported: true`, and reads
|
|
10
|
+
* neither the hold nor the cancellable ids of one that does not.
|
|
11
|
+
*
|
|
12
|
+
* - `activeAttempt`: the runner's `getActiveQueueAttempt()` (`{ id, attemptId }`
|
|
13
|
+
* or null), the row the running turn owns.
|
|
14
|
+
* - `turnActive`: the runner's own word that a turn runs (`isTurnActive()`),
|
|
15
|
+
* for a turn whose row has no id to show.
|
|
16
|
+
* - `backgroundTaskCount`: live background work that ends with the worker
|
|
17
|
+
* (Grok's terminals).
|
|
18
|
+
*
|
|
19
|
+
* A turn holds the release ('turn'), then live background work ('background').
|
|
20
|
+
*/
|
|
21
|
+
export function buildSimpleSteeringState({ activeAttempt = null, turnActive = false, backgroundTaskCount = 0 } = {}) {
|
|
22
|
+
const messageId = String(activeAttempt?.id || '').trim() || null;
|
|
23
|
+
const turn = turnActive === true || Boolean(messageId);
|
|
24
|
+
const tasks = Number(backgroundTaskCount);
|
|
25
|
+
const liveTasks = Number.isInteger(tasks) && tasks > 0 ? tasks : 0;
|
|
26
|
+
const releaseHold = turn ? 'turn' : (liveTasks > 0 ? 'background' : null);
|
|
27
|
+
return {
|
|
28
|
+
turnActive: turn,
|
|
29
|
+
canSteer: false,
|
|
30
|
+
holdReason: null,
|
|
31
|
+
messageId,
|
|
32
|
+
supported: false,
|
|
33
|
+
cancellableIds: [],
|
|
34
|
+
releasable: !releaseHold,
|
|
35
|
+
releaseHold,
|
|
36
|
+
backgroundTaskCount: liveTasks,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { buildIdsDiffer } from "../build-id.mjs";
|
|
2
|
+
|
|
1
3
|
function normalizeText(value) {
|
|
2
4
|
const text = String(value || "").trim();
|
|
3
5
|
return text || null;
|
|
@@ -70,6 +72,10 @@ export function createWorkerWebSocketLink({
|
|
|
70
72
|
// every hello and ready: the relay hands a Claude chat's row only to a
|
|
71
73
|
// worker of the row's billing.
|
|
72
74
|
workerKind = null,
|
|
75
|
+
// The build id of the code this worker started on (shared/build-id.mjs),
|
|
76
|
+
// stated in every hello and ready next to the kind: the relay recycles a
|
|
77
|
+
// worker whose build differs from its own once the worker is idle.
|
|
78
|
+
buildId = null,
|
|
73
79
|
minBackoffMs = 1000,
|
|
74
80
|
maxBackoffMs = 8_000,
|
|
75
81
|
jitterMs = 250,
|
|
@@ -121,6 +127,12 @@ export function createWorkerWebSocketLink({
|
|
|
121
127
|
let lastPongAt = null;
|
|
122
128
|
let lastQueueChangedAt = null;
|
|
123
129
|
let lastDeliveryAt = null;
|
|
130
|
+
// The build id the relay named in its latest server.hello (null when it
|
|
131
|
+
// named none), and whether this connection has already logged that it
|
|
132
|
+
// differs from ours: the relay acks every worker.hello with another
|
|
133
|
+
// server.hello, and the log needs the news only once per connection.
|
|
134
|
+
let relayBuildId = null;
|
|
135
|
+
let buildMismatchLogged = false;
|
|
124
136
|
|
|
125
137
|
function getNowMs() {
|
|
126
138
|
const value = Number(now());
|
|
@@ -175,6 +187,7 @@ export function createWorkerWebSocketLink({
|
|
|
175
187
|
sessionId: normalizeText(getSessionId()),
|
|
176
188
|
pid: normalizePositiveInt(getPid()),
|
|
177
189
|
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
190
|
+
...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
|
|
178
191
|
...(held ? { ready: false } : {}),
|
|
179
192
|
});
|
|
180
193
|
if (sent) lastHelloSentAt = getNowMs();
|
|
@@ -203,6 +216,7 @@ export function createWorkerWebSocketLink({
|
|
|
203
216
|
sessionId: normalizeText(getSessionId()),
|
|
204
217
|
pid: normalizePositiveInt(getPid()),
|
|
205
218
|
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
219
|
+
...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
|
|
206
220
|
});
|
|
207
221
|
if (sent) lastReadySentAt = getNowMs();
|
|
208
222
|
return sent;
|
|
@@ -393,6 +407,7 @@ export function createWorkerWebSocketLink({
|
|
|
393
407
|
clearReadyRefreshTimer();
|
|
394
408
|
// The next connection may reach a different (restarted) relay.
|
|
395
409
|
serverCapabilities = new Set();
|
|
410
|
+
buildMismatchLogged = false;
|
|
396
411
|
}
|
|
397
412
|
if (stopped || !reconnect) return true;
|
|
398
413
|
// A newer socket is already under way and owns the reconnect.
|
|
@@ -440,6 +455,13 @@ export function createWorkerWebSocketLink({
|
|
|
440
455
|
}
|
|
441
456
|
if (payload?.type === "server.hello") {
|
|
442
457
|
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
458
|
+
relayBuildId = normalizeText(payload.buildId);
|
|
459
|
+
// Only a line in the log: the relay decides when to replace this
|
|
460
|
+
// worker, and the worker takes no action of its own on it.
|
|
461
|
+
if (!buildMismatchLogged && buildIdsDiffer(relayBuildId, normalizeText(buildId))) {
|
|
462
|
+
buildMismatchLogged = true;
|
|
463
|
+
dbg(`relay build ${relayBuildId}, mine ${normalizeText(buildId)}: the relay will recycle this worker when idle`);
|
|
464
|
+
}
|
|
443
465
|
if (!serverHelloSeen) {
|
|
444
466
|
serverHelloSeen = true;
|
|
445
467
|
// The handshake is complete: only now does the backoff start over.
|
|
@@ -536,6 +558,7 @@ export function createWorkerWebSocketLink({
|
|
|
536
558
|
lastPongAt,
|
|
537
559
|
lastQueueChangedAt,
|
|
538
560
|
lastDeliveryAt,
|
|
561
|
+
relayBuildId,
|
|
539
562
|
};
|
|
540
563
|
}
|
|
541
564
|
|
|
@@ -16,7 +16,7 @@ import fs from 'node:fs';
|
|
|
16
16
|
export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
|
|
17
17
|
|
|
18
18
|
export const WORKING_DIRECTORY_GUIDANCE =
|
|
19
|
-
'Restore the folder, or choose another working directory for this conversation (
|
|
19
|
+
'Restore the folder, or choose another working directory for this conversation (︙ → Change CWD), '
|
|
20
20
|
+ 'then send the message again.';
|
|
21
21
|
|
|
22
22
|
// What a failed launch says, in the words of the SDKs: Node's spawn error, the
|