@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.
Files changed (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. 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
- return async function api(method, routePath, body) {
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
- "Content-Type": "application/json",
9
- Authorization: `Bearer ${token}`,
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
- const rawText = String(await res.text().catch(() => "")).trim();
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
- let deliveryInFlight = null;
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() || deliveryInFlight) return false;
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
- if (deliveryInFlight) return deliveryInFlight;
136
- deliveryInFlight = Promise.resolve()
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
- deliveryInFlight = null;
226
+ deliveriesInFlight.delete(delivery);
227
+ if (messageId && deliveriesById.get(messageId) === delivery) deliveriesById.delete(messageId);
145
228
  });
146
- return deliveryInFlight;
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 (deliveryInFlight) return true;
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: !!deliveryInFlight,
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,