@oar-sh/oar 0.9.20 → 0.9.22
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 +325 -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 +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -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/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -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/context-usage-view.mjs +68 -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 +309 -49
- 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 +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- 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/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- 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/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -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 +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -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 +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- 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/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- 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-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- 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
|
+
}
|
|
@@ -67,7 +67,10 @@ export function createAskUserBridge({
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// `source` / `rationale` override the bridge-wide card identity for one ask
|
|
71
|
+
// (the Claude worker's safety-check approvals are not clarifications), and
|
|
72
|
+
// `allowFreeform: false` makes a card answered by its choices alone.
|
|
73
|
+
async function askSingleQuestion(entry, { signal, source, rationale, allowFreeform } = {}) {
|
|
71
74
|
const activeMsg = typeof getActiveMessage === 'function' ? getActiveMessage() : null;
|
|
72
75
|
const choices = entry.options.map((option) => option.label);
|
|
73
76
|
const promptParts = [entry.question];
|
|
@@ -82,7 +85,7 @@ export function createAskUserBridge({
|
|
|
82
85
|
mode: activeMsg?.relayMode || 'agent',
|
|
83
86
|
prompt: promptParts.join('\n\n'),
|
|
84
87
|
choices,
|
|
85
|
-
allowFreeform:
|
|
88
|
+
allowFreeform: allowFreeform !== false,
|
|
86
89
|
// Top-level, as the create route reads it (elicitation parity; absent
|
|
87
90
|
// for the flat question shape every existing caller sends).
|
|
88
91
|
...(entry.requestedSchema ? { requestedSchema: entry.requestedSchema } : {}),
|
|
@@ -92,8 +95,8 @@ export function createAskUserBridge({
|
|
|
92
95
|
attemptId: activeMsg?.attemptId || undefined,
|
|
93
96
|
timeout_ms: questionTimeoutMs,
|
|
94
97
|
context: {
|
|
95
|
-
source: questionSource,
|
|
96
|
-
rationale: questionRationale,
|
|
98
|
+
source: source || questionSource,
|
|
99
|
+
rationale: rationale || questionRationale,
|
|
97
100
|
queueMessageId: activeMsg?.id || null,
|
|
98
101
|
conversationId: activeMsg?.conversationId || null,
|
|
99
102
|
relayMode: activeMsg?.relayMode || 'agent',
|
|
@@ -106,7 +109,7 @@ export function createAskUserBridge({
|
|
|
106
109
|
return waitForRelayQuestionAnswer(questionId, { signal });
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
async function handleAskUserQuestion(input, { signal } = {}) {
|
|
112
|
+
async function handleAskUserQuestion(input, { signal, source, rationale, allowFreeform } = {}) {
|
|
110
113
|
const questions = normalizeQuestions(input);
|
|
111
114
|
if (!questions.length) {
|
|
112
115
|
return { answers: {}, structuredAnswers: {}, timedOut: false };
|
|
@@ -118,7 +121,7 @@ export function createAskUserBridge({
|
|
|
118
121
|
const structuredAnswers = {};
|
|
119
122
|
let timedOut = false;
|
|
120
123
|
for (const entry of questions) {
|
|
121
|
-
const result = await askSingleQuestion(entry, { signal });
|
|
124
|
+
const result = await askSingleQuestion(entry, { signal, source, rationale, allowFreeform });
|
|
122
125
|
answers[entry.question] = result.answer;
|
|
123
126
|
if (result.structuredAnswer) structuredAnswers[entry.question] = result.structuredAnswer;
|
|
124
127
|
if (entry.rawQuestion && entry.rawQuestion !== entry.question) {
|
|
@@ -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
|
+
}
|
package/shared/relay-asks.mjs
CHANGED
|
@@ -25,6 +25,11 @@ export const RELAY_QUESTION_KINDS = Object.freeze(['ask', 'ask_user', 'approval'
|
|
|
25
25
|
// Stored and sent between relays, so it kept the tool's name from before
|
|
26
26
|
// 0.9.18.
|
|
27
27
|
export const REMOTE_RELAY_APPROVAL_SOURCE = 'remote_relay';
|
|
28
|
+
// The `context.source` of a Claude session's Safety check card: an ask Claude
|
|
29
|
+
// Code marks as person-only (claude-sdk-adapter.mjs). Like the approval card,
|
|
30
|
+
// it is the user's to answer: never listed to an agent, never answered by
|
|
31
|
+
// one, from this relay or another.
|
|
32
|
+
export const PERSON_ONLY_QUESTION_SOURCE = 'ClaudeSafetyCheck';
|
|
28
33
|
export const RELAY_ASK_KIND = 'ask';
|
|
29
34
|
|
|
30
35
|
export const RELAY_ASK_URGENCIES = Object.freeze(['normal', 'now']);
|
|
@@ -145,6 +150,11 @@ export function normalizeRelayAskAnswer(ask, { answer = '', choice = '' } = {})
|
|
|
145
150
|
return { ok: true, value: { answer: text || picked, choice: picked } };
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/** True for a Safety check card's context (a formatted question's or a stored request envelope's). */
|
|
154
|
+
export function isPersonOnlyQuestion(context) {
|
|
155
|
+
return !!context && typeof context === 'object' && toText(context.source) === PERSON_ONLY_QUESTION_SOURCE;
|
|
156
|
+
}
|
|
157
|
+
|
|
148
158
|
/** The kind of a formatted question card (a row of /api/relay-questions). */
|
|
149
159
|
export function relayQuestionKind(question) {
|
|
150
160
|
const kind = toText(question?.kind);
|
package/shared/relay-events.mjs
CHANGED
|
@@ -560,6 +560,7 @@ export function formatRelayEventLine(event) {
|
|
|
560
560
|
const parts = [`${head} ${quote(detail.prompt, RELAY_EVENT_LIMITS.questionChars)}`];
|
|
561
561
|
if (options.length) parts.push(`options: ${options.map((option) => `[${option}]`).join(' ')}`);
|
|
562
562
|
if (detail.approvalCard) parts.push('(an approval card: only the user can answer it)');
|
|
563
|
+
else if (detail.personOnly) parts.push('(a Safety check: the user\'s to answer)');
|
|
563
564
|
else if (oneLine(detail.questionId, 80)) parts.push(`(question_id ${oneLine(detail.questionId, 80)})`);
|
|
564
565
|
if (detail.kind === 'ask') parts.push('(asked with ask: it does not block that session)');
|
|
565
566
|
return parts.join(' ');
|
|
@@ -33,10 +33,18 @@ export function createHeartbeatController({
|
|
|
33
33
|
// Optional: called after every beat the relay answered, for work that
|
|
34
34
|
// waits for a reachable relay (posts kept after a failure).
|
|
35
35
|
onAnswered = () => {},
|
|
36
|
+
// Optional: how many posts are kept for a reachable relay (flushed by
|
|
37
|
+
// onAnswered). While any is, the worker counts as busy, so the next beat,
|
|
38
|
+
// which flushes them, is not an idle wait away.
|
|
39
|
+
getKeptPostCount = null,
|
|
36
40
|
// Optional: the kind of worker this is ("claude", "claude-api", ...). The
|
|
37
41
|
// relay hands a Claude chat's row only to a worker of the row's billing,
|
|
38
42
|
// and knows a worker's kind from what it says here and in its hello.
|
|
39
43
|
workerKind = null,
|
|
44
|
+
// Optional: the build id of the code this worker started on
|
|
45
|
+
// (shared/build-id.mjs). A worker whose websocket is down still beats, so
|
|
46
|
+
// the relay learns the build here as well as from the hello.
|
|
47
|
+
buildId = null,
|
|
40
48
|
// requestPulse's window: one turn transition can flip the steering
|
|
41
49
|
// snapshot several times in quick succession, and each flip only needs to
|
|
42
50
|
// reach the relay once.
|
|
@@ -71,6 +79,7 @@ export function createHeartbeatController({
|
|
|
71
79
|
...(settleFailed.length ? { settleFailed } : {}),
|
|
72
80
|
...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
|
|
73
81
|
...(workerKind ? { workerKind: String(workerKind) } : {}),
|
|
82
|
+
...(typeof buildId === "string" && buildId.trim() ? { buildId: buildId.trim() } : {}),
|
|
74
83
|
};
|
|
75
84
|
const response = await api("POST", "/api/heartbeat", body);
|
|
76
85
|
// Failed by the relay, or skipped because this worker has no claim on
|
|
@@ -94,6 +103,27 @@ export function createHeartbeatController({
|
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Whether the worker has work the relay follows by its beats: a row it
|
|
108
|
+
* owns, a live background task, a running turn, a settle failure to report,
|
|
109
|
+
* a kept post to flush.
|
|
110
|
+
* The keepalive beats at its busy cadence while this holds
|
|
111
|
+
* (worker-keepalive.mjs). A getter that throws counts as work.
|
|
112
|
+
*/
|
|
113
|
+
function hasWork() {
|
|
114
|
+
try {
|
|
115
|
+
if (typeof getActiveQueueMessageId === "function" && String(getActiveQueueMessageId() || "").trim()) return true;
|
|
116
|
+
if (typeof getActiveQueueMessageIds === "function" && (getActiveQueueMessageIds() || []).length) return true;
|
|
117
|
+
if (typeof getBackgroundTaskIds === "function" && (getBackgroundTaskIds() || []).length) return true;
|
|
118
|
+
if (typeof getSettleFailed === "function" && (getSettleFailed() || []).length) return true;
|
|
119
|
+
if (typeof getKeptPostCount === "function" && Number(getKeptPostCount()) > 0) return true;
|
|
120
|
+
if (typeof getSteeringState === "function" && getSteeringState()?.turnActive === true) return true;
|
|
121
|
+
return false;
|
|
122
|
+
} catch {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
97
127
|
/**
|
|
98
128
|
* An out-of-cycle pulse for state the relay should see before the next
|
|
99
129
|
* periodic one (the composer's steering snapshot, a relay that just came
|
|
@@ -130,6 +160,7 @@ export function createHeartbeatController({
|
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
return {
|
|
163
|
+
hasWork,
|
|
133
164
|
pulseHeartbeat,
|
|
134
165
|
requestPulse,
|
|
135
166
|
startHeartbeat,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The worker's one keepalive timer. Each beat posts the heartbeat
|
|
2
|
+
// (heartbeat.mjs) and refreshes the websocket readiness
|
|
3
|
+
// (worker-websocket-link.mjs, built with readyRefreshMs: 0); before, each ran
|
|
4
|
+
// its own 10 s timer.
|
|
5
|
+
//
|
|
6
|
+
// Cadence: every 10 s while the worker has work (a row it owns, a delivery in
|
|
7
|
+
// flight, a hold, a background task, a running turn), and every 30 s while it
|
|
8
|
+
// is idle, but only on a relay that advertised `idle-keepalive` in its
|
|
9
|
+
// server.hello. An older relay expects a beat every 10 s (its liveness, CLI
|
|
10
|
+
// and stranded-session windows), and a worker whose socket is down knows no
|
|
11
|
+
// relay at all, so both get 10 s. Every server.hello and every delivery
|
|
12
|
+
// (nudge) keeps the busy cadence for the next 30 s and cuts an idle wait
|
|
13
|
+
// down to 10 s: the first beat naming a new turn's row is never an idle wait
|
|
14
|
+
// away, and a delivery lost on the wire or a relay that just came up is
|
|
15
|
+
// noticed as fast as before.
|
|
16
|
+
|
|
17
|
+
export const IDLE_KEEPALIVE_MS = 30_000;
|
|
18
|
+
export const BUSY_KEEPALIVE_MS = 10_000;
|
|
19
|
+
export const IDLE_KEEPALIVE_CAPABILITY = "idle-keepalive";
|
|
20
|
+
export const BUSY_HOLD_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export function createWorkerKeepalive({
|
|
23
|
+
heartbeat,
|
|
24
|
+
link,
|
|
25
|
+
idleMs = IDLE_KEEPALIVE_MS,
|
|
26
|
+
busyMs = BUSY_KEEPALIVE_MS,
|
|
27
|
+
busyHoldMs = BUSY_HOLD_MS,
|
|
28
|
+
setTimeoutImpl = setTimeout,
|
|
29
|
+
clearTimeoutImpl = clearTimeout,
|
|
30
|
+
now = () => Date.now(),
|
|
31
|
+
} = {}) {
|
|
32
|
+
let timer = null;
|
|
33
|
+
let dueAtMs = 0;
|
|
34
|
+
let running = false;
|
|
35
|
+
let busyUntilMs = 0;
|
|
36
|
+
|
|
37
|
+
function busy() {
|
|
38
|
+
if (now() < busyUntilMs) return true;
|
|
39
|
+
try {
|
|
40
|
+
if (heartbeat?.hasWork?.() === true) return true;
|
|
41
|
+
if (link?.isBusy?.() === true) return true;
|
|
42
|
+
return false;
|
|
43
|
+
} catch {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function relayAllowsIdleCadence() {
|
|
49
|
+
try {
|
|
50
|
+
return link?.serverSupports?.(IDLE_KEEPALIVE_CAPABILITY) === true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The wait before the next beat, decided after each beat. */
|
|
57
|
+
function nextDelayMs() {
|
|
58
|
+
if (busy() || !relayAllowsIdleCadence()) return busyMs;
|
|
59
|
+
return idleMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function schedule(delayMs) {
|
|
63
|
+
if (timer) clearTimeoutImpl(timer);
|
|
64
|
+
dueAtMs = now() + delayMs;
|
|
65
|
+
timer = setTimeoutImpl(beat, delayMs);
|
|
66
|
+
timer?.unref?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function beat() {
|
|
70
|
+
timer = null;
|
|
71
|
+
if (!running) return;
|
|
72
|
+
void heartbeat?.pulseHeartbeat?.();
|
|
73
|
+
try { link?.refreshReadiness?.("readiness-refresh"); } catch {}
|
|
74
|
+
schedule(nextDelayMs());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function start() {
|
|
78
|
+
if (running) return;
|
|
79
|
+
running = true;
|
|
80
|
+
// The first heartbeat goes out at once, as the heartbeat's own start did;
|
|
81
|
+
// the socket is not open yet, so its refresh waits for the next beat.
|
|
82
|
+
void heartbeat?.pulseHeartbeat?.();
|
|
83
|
+
schedule(nextDelayMs());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The relay said hello or handed over work: the busy cadence holds for a
|
|
88
|
+
* while, and a beat further away than it comes sooner.
|
|
89
|
+
*/
|
|
90
|
+
function nudge() {
|
|
91
|
+
if (!running) return;
|
|
92
|
+
busyUntilMs = Math.max(busyUntilMs, now() + busyHoldMs);
|
|
93
|
+
if (timer && dueAtMs - now() > busyMs) schedule(busyMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stop() {
|
|
97
|
+
running = false;
|
|
98
|
+
if (timer) clearTimeoutImpl(timer);
|
|
99
|
+
timer = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
start,
|
|
104
|
+
stop,
|
|
105
|
+
nudge,
|
|
106
|
+
status: () => ({ running, dueAtMs: timer ? dueAtMs : null }),
|
|
107
|
+
};
|
|
108
|
+
}
|