@oar-sh/oar 0.9.2 → 0.9.4
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/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -1,33 +1,123 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
|
|
4
|
+
// The error a non-2xx answer throws, the same for both transports:
|
|
5
|
+
// `HTTP <status> <path>: <detail>`, with `status`, `detail` and `body`.
|
|
6
|
+
function httpError(status, routePath, text) {
|
|
7
|
+
const rawText = String(text || "").trim();
|
|
8
|
+
let detail = rawText;
|
|
9
|
+
let payload = null;
|
|
10
|
+
if (rawText) {
|
|
11
|
+
try {
|
|
12
|
+
payload = JSON.parse(rawText);
|
|
13
|
+
detail = String(payload?.error || payload?.message || rawText).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
detail = rawText;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const error = new Error(`HTTP ${status} ${routePath}${detail ? `: ${detail}` : ""}`);
|
|
19
|
+
error.status = status;
|
|
20
|
+
error.detail = detail;
|
|
21
|
+
// The parsed error body, for callers that pass the relay's answer on as
|
|
22
|
+
// is (a refused remote_relay call carries its code there).
|
|
23
|
+
error.body = payload && typeof payload === "object" ? payload : null;
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function abortError(signal) {
|
|
28
|
+
const error = new Error("The operation was aborted");
|
|
29
|
+
error.name = "AbortError";
|
|
30
|
+
error.code = "ABORT_ERR";
|
|
31
|
+
if (signal?.reason !== undefined) error.cause = signal.reason;
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One request over node:http(s) with no clock on it at all. Global fetch
|
|
37
|
+
* (undici) gives up when no response headers arrive within 300 s, and a
|
|
38
|
+
* long call's route sends nothing until it has its answer: a remote turn of
|
|
39
|
+
* up to 600 s, or an approval card that may sit for hours. So: its own
|
|
40
|
+
* connection (`agent: false` — the global agent carries a socket timeout),
|
|
41
|
+
* no request timeout, no body timeout. Only `signal` ends it early; the
|
|
42
|
+
* relay notices the closed request and stops waiting on its side too.
|
|
43
|
+
*/
|
|
44
|
+
function requestWithoutTimeout({ url, method, headers, payload, signal }) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
reject(abortError(signal));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const target = new URL(url);
|
|
51
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
52
|
+
let settled = false;
|
|
53
|
+
let req = null;
|
|
54
|
+
const onAbort = () => {
|
|
55
|
+
const error = abortError(signal);
|
|
56
|
+
finish(error);
|
|
57
|
+
req?.destroy(error);
|
|
58
|
+
};
|
|
59
|
+
function finish(error, value) {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
63
|
+
if (error) reject(error);
|
|
64
|
+
else resolve(value);
|
|
65
|
+
}
|
|
66
|
+
req = transport.request(target, {
|
|
67
|
+
method,
|
|
68
|
+
agent: false,
|
|
69
|
+
headers: {
|
|
70
|
+
...headers,
|
|
71
|
+
...(payload !== undefined ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
|
72
|
+
},
|
|
73
|
+
}, (res) => {
|
|
74
|
+
const chunks = [];
|
|
75
|
+
res.on("data", (chunk) => { chunks.push(chunk); });
|
|
76
|
+
res.on("end", () => finish(null, { status: res.statusCode, text: Buffer.concat(chunks).toString("utf8") }));
|
|
77
|
+
res.on("error", (error) => finish(error));
|
|
78
|
+
res.on("close", () => {
|
|
79
|
+
if (!res.complete) finish(new Error(`The relay closed the connection mid-answer (${url})`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
req.on("error", (error) => finish(error));
|
|
83
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
84
|
+
if (payload !== undefined) req.write(payload);
|
|
85
|
+
req.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
1
89
|
export function createApiClient({ serverUrl, token, getHeaders }) {
|
|
2
|
-
|
|
90
|
+
/**
|
|
91
|
+
* `options.longCall`: a call the relay may legitimately hold open for many
|
|
92
|
+
* minutes (the remote_relay tool) — it goes over node:http without any
|
|
93
|
+
* timeout instead of fetch. `options.signal` aborts either kind; an aborted
|
|
94
|
+
* call rejects with an AbortError.
|
|
95
|
+
*/
|
|
96
|
+
return async function api(method, routePath, body, { longCall = false, signal = null } = {}) {
|
|
3
97
|
const url = `${serverUrl}${routePath}`;
|
|
4
98
|
const extraHeaders = typeof getHeaders === "function" ? (getHeaders() || {}) : {};
|
|
99
|
+
const headers = {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
...extraHeaders,
|
|
103
|
+
};
|
|
104
|
+
const payload = method !== "GET" ? JSON.stringify(body || {}) : undefined;
|
|
105
|
+
|
|
106
|
+
if (longCall) {
|
|
107
|
+
const res = await requestWithoutTimeout({ url, method, headers, payload, signal });
|
|
108
|
+
if (res.status < 200 || res.status > 299) throw httpError(res.status, routePath, res.text);
|
|
109
|
+
return JSON.parse(res.text);
|
|
110
|
+
}
|
|
111
|
+
|
|
5
112
|
const opts = {
|
|
6
113
|
method,
|
|
7
|
-
headers
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
...extraHeaders,
|
|
11
|
-
},
|
|
12
|
-
...(method !== "GET" ? { body: JSON.stringify(body || {}) } : {}),
|
|
114
|
+
headers,
|
|
115
|
+
...(payload !== undefined ? { body: payload } : {}),
|
|
116
|
+
...(signal ? { signal } : {}),
|
|
13
117
|
};
|
|
14
|
-
|
|
15
118
|
const res = await fetch(url, opts);
|
|
16
119
|
if (!res.ok) {
|
|
17
|
-
|
|
18
|
-
let detail = rawText;
|
|
19
|
-
if (rawText) {
|
|
20
|
-
try {
|
|
21
|
-
const payload = JSON.parse(rawText);
|
|
22
|
-
detail = String(payload?.error || payload?.message || rawText).trim();
|
|
23
|
-
} catch {
|
|
24
|
-
detail = rawText;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
const error = new Error(`HTTP ${res.status} ${routePath}${detail ? `: ${detail}` : ""}`);
|
|
28
|
-
error.status = res.status;
|
|
29
|
-
error.detail = detail;
|
|
30
|
-
throw error;
|
|
120
|
+
throw httpError(res.status, routePath, await res.text().catch(() => ""));
|
|
31
121
|
}
|
|
32
122
|
return res.json();
|
|
33
123
|
};
|
|
@@ -10,7 +10,24 @@ export function createHeartbeatController({
|
|
|
10
10
|
// queued behind it, and a background continuation at once). Each reported
|
|
11
11
|
// id gets its owner lease refreshed; unreported owned rows are recovered.
|
|
12
12
|
getActiveQueueMessageIds,
|
|
13
|
+
// Optional: the worker's steering snapshot ({turnActive, canSteer,
|
|
14
|
+
// holdReason}) for the composer. Workers that do not steer leave it unset
|
|
15
|
+
// and the heartbeat body is unchanged.
|
|
16
|
+
getSteeringState,
|
|
17
|
+
// Optional: rows the worker gave up settling ([{id, attemptId,
|
|
18
|
+
// terminalError}]); the relay fails them and names them back in
|
|
19
|
+
// `settleFailedHandled`, which `onSettleFailedHandled` receives.
|
|
20
|
+
getSettleFailed,
|
|
21
|
+
onSettleFailedHandled = () => {},
|
|
22
|
+
// requestPulse's window: one turn transition can flip the steering
|
|
23
|
+
// snapshot several times in quick succession, and each flip only needs to
|
|
24
|
+
// reach the relay once.
|
|
25
|
+
pulseCoalesceMs = 300,
|
|
26
|
+
setTimeoutImpl = setTimeout,
|
|
27
|
+
clearTimeoutImpl = clearTimeout,
|
|
13
28
|
}) {
|
|
29
|
+
let requestedPulseTimer = null;
|
|
30
|
+
|
|
14
31
|
async function pulseHeartbeat() {
|
|
15
32
|
if (!getSessionReady()) return false;
|
|
16
33
|
try {
|
|
@@ -20,17 +37,49 @@ export function createHeartbeatController({
|
|
|
20
37
|
const activeQueueMessageIds = typeof getActiveQueueMessageIds === "function"
|
|
21
38
|
? (getActiveQueueMessageIds() || []).map((id) => String(id || "").trim()).filter(Boolean)
|
|
22
39
|
: [];
|
|
40
|
+
const steering = typeof getSteeringState === "function"
|
|
41
|
+
? getSteeringState()
|
|
42
|
+
: null;
|
|
43
|
+
const settleFailed = typeof getSettleFailed === "function"
|
|
44
|
+
? (getSettleFailed() || []).filter((entry) => entry && String(entry.id || "").trim())
|
|
45
|
+
: [];
|
|
23
46
|
const body = {
|
|
24
47
|
...(activeQueueMessageId ? { activeQueueMessageId } : {}),
|
|
25
48
|
...(activeQueueMessageIds.length ? { activeQueueMessageIds } : {}),
|
|
49
|
+
...(steering && typeof steering === "object" ? { steering } : {}),
|
|
50
|
+
...(settleFailed.length ? { settleFailed } : {}),
|
|
26
51
|
};
|
|
27
|
-
await api("POST", "/api/heartbeat", body);
|
|
52
|
+
const response = await api("POST", "/api/heartbeat", body);
|
|
53
|
+
// Failed by the relay, or skipped because this worker has no claim on
|
|
54
|
+
// the row: either way the worker releases it.
|
|
55
|
+
const handled = [
|
|
56
|
+
...(Array.isArray(response?.settleFailedHandled) ? response.settleFailedHandled : []),
|
|
57
|
+
...(Array.isArray(response?.settleFailedSkipped) ? response.settleFailedSkipped : []),
|
|
58
|
+
];
|
|
59
|
+
if (handled.length) {
|
|
60
|
+
try { onSettleFailedHandled(handled); } catch {}
|
|
61
|
+
}
|
|
28
62
|
return true;
|
|
29
63
|
} catch {
|
|
30
64
|
return false;
|
|
31
65
|
}
|
|
32
66
|
}
|
|
33
67
|
|
|
68
|
+
/**
|
|
69
|
+
* An out-of-cycle pulse for state the relay should see before the next
|
|
70
|
+
* periodic one (the composer's steering snapshot, a relay that just came
|
|
71
|
+
* up). Requests inside one window share a single pulse, sent at its end so
|
|
72
|
+
* it carries the settled state.
|
|
73
|
+
*/
|
|
74
|
+
function requestPulse() {
|
|
75
|
+
if (requestedPulseTimer) return;
|
|
76
|
+
requestedPulseTimer = setTimeoutImpl(() => {
|
|
77
|
+
requestedPulseTimer = null;
|
|
78
|
+
void pulseHeartbeat();
|
|
79
|
+
}, pulseCoalesceMs);
|
|
80
|
+
requestedPulseTimer?.unref?.();
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
function startHeartbeat() {
|
|
35
84
|
if (getHeartbeatTimer()) return;
|
|
36
85
|
void pulseHeartbeat();
|
|
@@ -41,6 +90,10 @@ export function createHeartbeatController({
|
|
|
41
90
|
}
|
|
42
91
|
|
|
43
92
|
function stopHeartbeat() {
|
|
93
|
+
if (requestedPulseTimer) {
|
|
94
|
+
clearTimeoutImpl(requestedPulseTimer);
|
|
95
|
+
requestedPulseTimer = null;
|
|
96
|
+
}
|
|
44
97
|
const timer = getHeartbeatTimer();
|
|
45
98
|
if (!timer) return;
|
|
46
99
|
clearInterval(timer);
|
|
@@ -49,6 +102,7 @@ export function createHeartbeatController({
|
|
|
49
102
|
|
|
50
103
|
return {
|
|
51
104
|
pulseHeartbeat,
|
|
105
|
+
requestPulse,
|
|
52
106
|
startHeartbeat,
|
|
53
107
|
stopHeartbeat,
|
|
54
108
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Glue between a steering-capable session runner and the relay socket link —
|
|
2
|
+
// shared by every worker whose runner exposes a delivery gate (the Claude
|
|
3
|
+
// session runner, the Copilot SDK runner). Kept out of the worker entry files
|
|
4
|
+
// (which run main() on import) so the wiring itself is testable.
|
|
5
|
+
|
|
6
|
+
import { requeueOwedRows } from '../worker-crash-guard.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The runner reports every flip of its delivery gate; the link turns them
|
|
10
|
+
* into relay readiness frames. `attach` is late-bound because the runner is
|
|
11
|
+
* built before the link (the link's probes call into the runner).
|
|
12
|
+
*/
|
|
13
|
+
export function createRunnerLinkBridge() {
|
|
14
|
+
let link = null;
|
|
15
|
+
return {
|
|
16
|
+
attach(nextLink) {
|
|
17
|
+
link = nextLink || null;
|
|
18
|
+
},
|
|
19
|
+
onDeliveryReadinessChange(ready) {
|
|
20
|
+
if (!link) return;
|
|
21
|
+
if (ready) void link.notifyReady('steering-resumed');
|
|
22
|
+
else link.notifyUnready('steering-held');
|
|
23
|
+
},
|
|
24
|
+
// Only a relay that advertised the capability in server.hello understands
|
|
25
|
+
// the penalty-free hand-back; before any hello, assume an older relay.
|
|
26
|
+
canHandBackHeldDelivery() {
|
|
27
|
+
return Boolean(link?.serverSupports?.('steering-held'));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Worker shutdown (SIGTERM/SIGINT): rows whose prompt the engine already
|
|
34
|
+
* consumed and whose settle has not landed are failed terminally before the
|
|
35
|
+
* process goes — left behind, the relay's dead-worker recovery would requeue
|
|
36
|
+
* them and run them a second time. Bounded like the crash guard: a shutdown
|
|
37
|
+
* that hangs is worse than none. Other owed rows are left to the relay's
|
|
38
|
+
* recovery, as before.
|
|
39
|
+
*/
|
|
40
|
+
export async function failSettlingRowsOnShutdown({ api, runner, timeoutMs = 2_000 } = {}) {
|
|
41
|
+
const settling = (runner?.getActiveQueueMessageIds?.() || []).filter((entry) => entry?.terminalError);
|
|
42
|
+
await requeueOwedRows({ api, entries: settling, timeoutMs });
|
|
43
|
+
return settling.length;
|
|
44
|
+
}
|
|
@@ -45,6 +45,25 @@ export function createWorkerWebSocketLink({
|
|
|
45
45
|
// task). Never blocks delivery; failures are the handler's to log.
|
|
46
46
|
onControl = () => {},
|
|
47
47
|
getSessionReady = () => false,
|
|
48
|
+
// Mid-turn steering opt-in. A worker whose runner can absorb another
|
|
49
|
+
// delivered message into the live turn (the Claude session runner; the
|
|
50
|
+
// Copilot runner's steerIntoActiveTurn is compatible but not yet opted in)
|
|
51
|
+
// reports so here, and the link then keeps signalling readiness while a
|
|
52
|
+
// delivery is still in flight and runs the resulting concurrent delivery
|
|
53
|
+
// instead of coalescing it. Workers that leave this unset keep the strict
|
|
54
|
+
// single-flight contract.
|
|
55
|
+
getSteeringReady = null,
|
|
56
|
+
// Steering-capable workers only: true while the runner is holding deliveries
|
|
57
|
+
// (a question card or plan approval is open, a compaction or adoption is in
|
|
58
|
+
// progress, a turn-opening delivery is mid-flight). A held worker never
|
|
59
|
+
// signals readiness — idle or not — and tells the server so with
|
|
60
|
+
// worker.unready, because a delivery landing in a hold is handed back.
|
|
61
|
+
getDeliveryHeld = null,
|
|
62
|
+
// Called with the hello's reason once per connection, on the relay's first
|
|
63
|
+
// server.hello: from then on the relay (possibly a restarted one that knows
|
|
64
|
+
// nothing of this worker) has the socket. Later hellos only ack the
|
|
65
|
+
// worker.hello every idle readiness refresh re-sends, and are not reported.
|
|
66
|
+
onServerHello = () => {},
|
|
48
67
|
getSessionId = () => null,
|
|
49
68
|
getPid = () => null,
|
|
50
69
|
minBackoffMs = 1000,
|
|
@@ -65,11 +84,20 @@ export function createWorkerWebSocketLink({
|
|
|
65
84
|
let readyRefreshTimer = null;
|
|
66
85
|
let reconnectAttempt = 0;
|
|
67
86
|
let reconnectDelayMs = 0;
|
|
68
|
-
|
|
87
|
+
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
88
|
+
// is what a legacy worker's coalesced delivery waits on.
|
|
89
|
+
const deliveriesInFlight = new Set();
|
|
90
|
+
// message id → in-flight delivery. Only a redelivery of the SAME message is
|
|
91
|
+
// coalesced; a different message is never folded onto another's promise.
|
|
92
|
+
const deliveriesById = new Map();
|
|
69
93
|
let lastOpenAt = null;
|
|
70
94
|
let lastMessageAt = null;
|
|
71
95
|
let lastHelloSentAt = null;
|
|
72
96
|
let lastReadySentAt = null;
|
|
97
|
+
let lastUnreadySentAt = null;
|
|
98
|
+
// What the connected relay advertised in server.hello (see
|
|
99
|
+
// WORKER_PROTOCOL_CAPABILITIES in the relay's worker websocket service).
|
|
100
|
+
let serverCapabilities = new Set();
|
|
73
101
|
let lastPingSentAt = null;
|
|
74
102
|
let lastPongAt = null;
|
|
75
103
|
let lastQueueChangedAt = null;
|
|
@@ -94,20 +122,61 @@ export function createWorkerWebSocketLink({
|
|
|
94
122
|
}
|
|
95
123
|
}
|
|
96
124
|
|
|
125
|
+
function steeringCapable() {
|
|
126
|
+
return typeof getSteeringReady === "function";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function steeringReady() {
|
|
130
|
+
if (!steeringCapable()) return false;
|
|
131
|
+
try {
|
|
132
|
+
return getSteeringReady() === true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function deliveryHeld() {
|
|
139
|
+
if (typeof getDeliveryHeld !== "function") return false;
|
|
140
|
+
try {
|
|
141
|
+
return getDeliveryHeld() === true;
|
|
142
|
+
} catch {
|
|
143
|
+
// Fail closed: a held worker that signals ready bounces every delivery.
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
function notifyHello(reason = "worker-hello") {
|
|
98
149
|
if (!getSessionReady()) return false;
|
|
150
|
+
// The server treats hello as readiness; a held worker still has to bind
|
|
151
|
+
// its identity, so it says hello without claiming to be ready.
|
|
152
|
+
const held = deliveryHeld();
|
|
99
153
|
const sent = send({
|
|
100
154
|
type: "worker.hello",
|
|
101
155
|
reason,
|
|
102
156
|
sessionId: normalizeText(getSessionId()),
|
|
103
157
|
pid: normalizePositiveInt(getPid()),
|
|
158
|
+
...(held ? { ready: false } : {}),
|
|
104
159
|
});
|
|
105
160
|
if (sent) lastHelloSentAt = getNowMs();
|
|
106
161
|
return sent;
|
|
107
162
|
}
|
|
108
163
|
|
|
164
|
+
function notifyUnready(reason = "worker-unready") {
|
|
165
|
+
if (!getSessionReady()) return false;
|
|
166
|
+
const sent = send({
|
|
167
|
+
type: "worker.unready",
|
|
168
|
+
reason,
|
|
169
|
+
sessionId: normalizeText(getSessionId()),
|
|
170
|
+
pid: normalizePositiveInt(getPid()),
|
|
171
|
+
});
|
|
172
|
+
if (sent) lastUnreadySentAt = getNowMs();
|
|
173
|
+
return sent;
|
|
174
|
+
}
|
|
175
|
+
|
|
109
176
|
async function notifyReady(reason = "worker-ready") {
|
|
110
|
-
if (!getSessionReady()
|
|
177
|
+
if (!getSessionReady()) return false;
|
|
178
|
+
if (deliveryHeld()) return false;
|
|
179
|
+
if (deliveriesInFlight.size && !steeringReady()) return false;
|
|
111
180
|
const sent = send({
|
|
112
181
|
type: "worker.ready",
|
|
113
182
|
reason,
|
|
@@ -132,8 +201,21 @@ export function createWorkerWebSocketLink({
|
|
|
132
201
|
|
|
133
202
|
async function deliverPending(pending, reason = "queue-deliver") {
|
|
134
203
|
if (!getSessionReady()) return false;
|
|
135
|
-
|
|
136
|
-
|
|
204
|
+
// A redelivery of a message that is already running (socket reconnect)
|
|
205
|
+
// coalesces onto it rather than double-running the turn.
|
|
206
|
+
const messageId = normalizeText(pending?.message?.id);
|
|
207
|
+
if (messageId && deliveriesById.has(messageId)) return deliveriesById.get(messageId);
|
|
208
|
+
// A DIFFERENT message while one is in flight: a steering-capable worker's
|
|
209
|
+
// runner decides — it steers the message into the live turn, or hands it
|
|
210
|
+
// back to the queue when the turn is holding (the server's readiness can
|
|
211
|
+
// lag a hold by a round-trip). Coalescing it here used to drop its payload
|
|
212
|
+
// and strand the row `processing` until heartbeat recovery. Workers
|
|
213
|
+
// without the steering opt-in keep strict single-flight: their runners
|
|
214
|
+
// have no hand-back path, and the heartbeat recovers the row.
|
|
215
|
+
if (deliveriesInFlight.size && !steeringCapable()) {
|
|
216
|
+
return deliveriesInFlight.values().next().value;
|
|
217
|
+
}
|
|
218
|
+
const delivery = Promise.resolve()
|
|
137
219
|
.then(() => onDeliver(pending, reason))
|
|
138
220
|
.catch((error) => {
|
|
139
221
|
dbg("worker ws delivery failed", reason, error?.message || String(error));
|
|
@@ -141,9 +223,12 @@ export function createWorkerWebSocketLink({
|
|
|
141
223
|
})
|
|
142
224
|
.finally(() => {
|
|
143
225
|
lastDeliveryAt = getNowMs();
|
|
144
|
-
|
|
226
|
+
deliveriesInFlight.delete(delivery);
|
|
227
|
+
if (messageId && deliveriesById.get(messageId) === delivery) deliveriesById.delete(messageId);
|
|
145
228
|
});
|
|
146
|
-
|
|
229
|
+
deliveriesInFlight.add(delivery);
|
|
230
|
+
if (messageId) deliveriesById.set(messageId, delivery);
|
|
231
|
+
return delivery;
|
|
147
232
|
}
|
|
148
233
|
|
|
149
234
|
function clearReconnectTimer() {
|
|
@@ -173,7 +258,18 @@ export function createWorkerWebSocketLink({
|
|
|
173
258
|
return closeStaleSocket(`no-server-message:${reason}`);
|
|
174
259
|
}
|
|
175
260
|
notifyPing(reason);
|
|
176
|
-
if (
|
|
261
|
+
if (deliveryHeld()) {
|
|
262
|
+
// Re-asserted on every refresh: a ready frame that crossed the hold on
|
|
263
|
+
// the wire would otherwise leave the server's readiness stuck on.
|
|
264
|
+
notifyUnready(reason);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
if (deliveriesInFlight.size) {
|
|
268
|
+
// notifyReady gates itself: it only goes through mid-delivery when the
|
|
269
|
+
// worker is steering-ready. Hello stays an idle-only affair.
|
|
270
|
+
void notifyReady(reason);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
177
273
|
notifyHello(reason);
|
|
178
274
|
void notifyReady(reason);
|
|
179
275
|
return true;
|
|
@@ -215,6 +311,7 @@ export function createWorkerWebSocketLink({
|
|
|
215
311
|
}
|
|
216
312
|
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
217
313
|
ws = new WebSocketImpl(url);
|
|
314
|
+
let serverHelloSeen = false;
|
|
218
315
|
ws.addEventListener("open", () => {
|
|
219
316
|
const openedAt = getNowMs();
|
|
220
317
|
reconnectAttempt = 0;
|
|
@@ -240,6 +337,15 @@ export function createWorkerWebSocketLink({
|
|
|
240
337
|
return;
|
|
241
338
|
}
|
|
242
339
|
if (payload?.type === "server.hello") {
|
|
340
|
+
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
341
|
+
if (!serverHelloSeen) {
|
|
342
|
+
serverHelloSeen = true;
|
|
343
|
+
try {
|
|
344
|
+
onServerHello(String(payload.reason || "server-hello"));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
dbg("worker ws hello handler failed", error?.message || String(error));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
243
349
|
void notifyReady(String(payload.reason || "server-hello"));
|
|
244
350
|
return;
|
|
245
351
|
}
|
|
@@ -271,6 +377,8 @@ export function createWorkerWebSocketLink({
|
|
|
271
377
|
});
|
|
272
378
|
ws.addEventListener("close", () => {
|
|
273
379
|
clearReadyRefreshTimer();
|
|
380
|
+
// The next connection may reach a different (restarted) relay.
|
|
381
|
+
serverCapabilities = new Set();
|
|
274
382
|
if (stopped) return;
|
|
275
383
|
dbg("worker ws disconnected");
|
|
276
384
|
scheduleReconnect();
|
|
@@ -302,13 +410,14 @@ export function createWorkerWebSocketLink({
|
|
|
302
410
|
const connected = !!ws && ws.readyState === resolveSocketStateValue(ws, "OPEN", 1);
|
|
303
411
|
return {
|
|
304
412
|
connected,
|
|
305
|
-
delivering:
|
|
413
|
+
delivering: deliveriesInFlight.size > 0,
|
|
306
414
|
reconnectAttempt,
|
|
307
415
|
reconnectDelayMs,
|
|
308
416
|
lastOpenAt,
|
|
309
417
|
lastMessageAt,
|
|
310
418
|
lastHelloSentAt,
|
|
311
419
|
lastReadySentAt,
|
|
420
|
+
lastUnreadySentAt,
|
|
312
421
|
lastPingSentAt,
|
|
313
422
|
lastPongAt,
|
|
314
423
|
lastQueueChangedAt,
|
|
@@ -318,6 +427,8 @@ export function createWorkerWebSocketLink({
|
|
|
318
427
|
|
|
319
428
|
return {
|
|
320
429
|
notifyReady,
|
|
430
|
+
notifyUnready,
|
|
431
|
+
serverSupports: (capability) => serverCapabilities.has(String(capability || "")),
|
|
321
432
|
start,
|
|
322
433
|
stop,
|
|
323
434
|
status,
|