@oar-sh/oar 0.9.12 → 0.9.14
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 +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -2,6 +2,9 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
|
|
5
|
+
import { RESCUE_DIR_NAME } from "../../server/services/earlier-database-service.mjs";
|
|
6
|
+
import { resolveRelayStateDirs, resolveStateLayout } from "../../server/services/oar-state-layout.mjs";
|
|
7
|
+
|
|
5
8
|
function toAbsolutePath(value) {
|
|
6
9
|
if (!value) return "";
|
|
7
10
|
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
|
@@ -12,15 +15,21 @@ function looksLikeServerDir(serverDir) {
|
|
|
12
15
|
return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Where the extension finds the relay, its config and logs. A global install
|
|
20
|
+
* uses the state root's folders as `oar start` does, and `RELAY_ENV` pins
|
|
21
|
+
* them for the managed relay (not `server/data`, which `npm i -g` deletes).
|
|
22
|
+
* The environment wins; a checkout keeps its `server/` folders.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveRelayPaths(importMetaUrl, { env = process.env, platform = process.platform } = {}) {
|
|
16
25
|
const __dirname = path.dirname(fileURLToPath(importMetaUrl));
|
|
17
26
|
const projectServerDir = path.resolve(__dirname, "../../../server");
|
|
18
27
|
const cwdServerDir = path.resolve(process.cwd(), "server");
|
|
19
|
-
const envRoot = toAbsolutePath(
|
|
20
|
-
const envServerDir = toAbsolutePath(
|
|
21
|
-
const envConfigPath = toAbsolutePath(
|
|
22
|
-
const envToolsPath = toAbsolutePath(
|
|
23
|
-
const envLogDir = toAbsolutePath(
|
|
28
|
+
const envRoot = toAbsolutePath(env.COPILOT_WEB_RELAY_ROOT || "");
|
|
29
|
+
const envServerDir = toAbsolutePath(env.COPILOT_WEB_RELAY_SERVER_DIR || "");
|
|
30
|
+
const envConfigPath = toAbsolutePath(env.COPILOT_WEB_RELAY_CONFIG || "");
|
|
31
|
+
const envToolsPath = toAbsolutePath(env.COPILOT_WEB_RELAY_TOOLS || "");
|
|
32
|
+
const envLogDir = toAbsolutePath(env.COPILOT_WEB_RELAY_LOG_DIR || "");
|
|
24
33
|
|
|
25
34
|
const resolvedServerDir =
|
|
26
35
|
(looksLikeServerDir(envServerDir) && envServerDir) ||
|
|
@@ -30,11 +39,23 @@ export function resolveRelayPaths(importMetaUrl) {
|
|
|
30
39
|
projectServerDir;
|
|
31
40
|
|
|
32
41
|
const SERVER_DIR = resolvedServerDir;
|
|
33
|
-
const
|
|
42
|
+
const packageRoot = path.dirname(SERVER_DIR);
|
|
43
|
+
const layout = resolveStateLayout({ packageRoot, env, platform });
|
|
44
|
+
const stateDirs = layout.checkout ? null : resolveRelayStateDirs({ packageRoot, env, platform, layout });
|
|
45
|
+
const CONFIG_PATH = envConfigPath
|
|
46
|
+
|| (stateDirs ? path.join(layout.configDir, "config.json") : path.resolve(SERVER_DIR, "config.json"));
|
|
34
47
|
const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
|
|
35
|
-
const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
|
|
48
|
+
const LOG_DIR = envLogDir || stateDirs?.logDir || path.resolve(SERVER_DIR, "logs");
|
|
36
49
|
const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
|
|
37
50
|
const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
|
|
51
|
+
const RELAY_ENV = stateDirs
|
|
52
|
+
? {
|
|
53
|
+
COPILOT_WEB_RELAY_ROOT: packageRoot,
|
|
54
|
+
COPILOT_WEB_RELAY_CONFIG: CONFIG_PATH,
|
|
55
|
+
COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir,
|
|
56
|
+
COPILOT_WEB_RELAY_LOG_DIR: LOG_DIR,
|
|
57
|
+
}
|
|
58
|
+
: {};
|
|
38
59
|
|
|
39
60
|
return {
|
|
40
61
|
__dirname,
|
|
@@ -44,9 +65,34 @@ export function resolveRelayPaths(importMetaUrl) {
|
|
|
44
65
|
LOG_DIR,
|
|
45
66
|
SERVER_LOG_PATH,
|
|
46
67
|
SERVER_ERR_PATH,
|
|
68
|
+
RELAY_ENV,
|
|
47
69
|
};
|
|
48
70
|
}
|
|
49
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The package's `server/config.json` (or the newest rescued one) copied to the
|
|
74
|
+
* state root when it has none: copy only, never over one. Returns the source.
|
|
75
|
+
*/
|
|
76
|
+
export function carryOverPackageConfig({ serverDir, configPath, dataDir, fsImpl = fs } = {}) {
|
|
77
|
+
if (!configPath || !dataDir || fsImpl.existsSync(configPath)) return null;
|
|
78
|
+
const sources = [path.join(serverDir, "config.json")];
|
|
79
|
+
try {
|
|
80
|
+
const rescueRoot = path.join(dataDir, RESCUE_DIR_NAME);
|
|
81
|
+
for (const name of fsImpl.readdirSync(rescueRoot).filter((entry) => !entry.endsWith(".partial")).sort().reverse()) {
|
|
82
|
+
sources.push(path.join(rescueRoot, name, "config.json"));
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
const source = sources.find((file) => fsImpl.existsSync(file));
|
|
86
|
+
if (!source) return null;
|
|
87
|
+
try {
|
|
88
|
+
fsImpl.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
89
|
+
fsImpl.copyFileSync(source, configPath, fs.constants.COPYFILE_EXCL);
|
|
90
|
+
return source;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
export function loadTokenFromConfig(configPath) {
|
|
51
97
|
try {
|
|
52
98
|
const cfg = loadRelayConfig(configPath);
|
|
@@ -99,7 +99,9 @@ export function createRelayPromptBuilder({
|
|
|
99
99
|
// Guidance is advisory: a preview lookup that fails must cost the block,
|
|
100
100
|
// never the turn.
|
|
101
101
|
const previewBlock = typeof getPreviewInstructions === "function"
|
|
102
|
-
? await Promise.resolve()
|
|
102
|
+
? await Promise.resolve()
|
|
103
|
+
.then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || "") }))
|
|
104
|
+
.catch(() => "")
|
|
103
105
|
: "";
|
|
104
106
|
instructions = applyPreviewInstructions(toolInstructions, previewBlock);
|
|
105
107
|
}
|
|
@@ -69,6 +69,15 @@ export function createWorkerWebSocketLink({
|
|
|
69
69
|
minBackoffMs = 1000,
|
|
70
70
|
maxBackoffMs = 8_000,
|
|
71
71
|
jitterMs = 250,
|
|
72
|
+
// A connection counts as made only at the relay's first server.hello (the
|
|
73
|
+
// relay sends it as soon as it accepts the socket). One still connecting,
|
|
74
|
+
// or open but silent, when this runs out is given up and retried: a relay
|
|
75
|
+
// that hangs in its shutdown can hold a socket in either state without
|
|
76
|
+
// ever ending it.
|
|
77
|
+
connectTimeoutMs = 15_000,
|
|
78
|
+
// Failed attempts after the first few are logged at most once per this
|
|
79
|
+
// interval, with a count of the ones in between.
|
|
80
|
+
failureLogIntervalMs = 60_000,
|
|
72
81
|
readyRefreshMs = 10_000,
|
|
73
82
|
staleConnectionMs = 30_000,
|
|
74
83
|
WebSocketImpl = globalThis.WebSocket,
|
|
@@ -84,6 +93,10 @@ export function createWorkerWebSocketLink({
|
|
|
84
93
|
let readyRefreshTimer = null;
|
|
85
94
|
let reconnectAttempt = 0;
|
|
86
95
|
let reconnectDelayMs = 0;
|
|
96
|
+
// The current socket's connect deadline (see connectTimeoutMs).
|
|
97
|
+
let connectDeadlineTimer = null;
|
|
98
|
+
let lastFailureLoggedAt = null;
|
|
99
|
+
let failuresNotLogged = 0;
|
|
87
100
|
// Ends the current socket's connection (see connect).
|
|
88
101
|
let endCurrentSocket = null;
|
|
89
102
|
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
@@ -239,6 +252,12 @@ export function createWorkerWebSocketLink({
|
|
|
239
252
|
reconnectTimer = null;
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
function clearConnectDeadline() {
|
|
256
|
+
if (!connectDeadlineTimer) return;
|
|
257
|
+
clearTimeoutImpl(connectDeadlineTimer);
|
|
258
|
+
connectDeadlineTimer = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
function clearReadyRefreshTimer() {
|
|
243
262
|
if (!readyRefreshTimer) return;
|
|
244
263
|
clearIntervalImpl(readyRefreshTimer);
|
|
@@ -290,7 +309,23 @@ export function createWorkerWebSocketLink({
|
|
|
290
309
|
if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
|
|
291
310
|
}
|
|
292
311
|
|
|
293
|
-
|
|
312
|
+
// One line per failed attempt for the first few, then at most one per
|
|
313
|
+
// failureLogIntervalMs: a relay that stays away for an hour must not fill
|
|
314
|
+
// the worker log with a line every 8 seconds.
|
|
315
|
+
function logFailedAttempt(reason) {
|
|
316
|
+
const nowMs = getNowMs();
|
|
317
|
+
const intervalMs = Math.max(0, Number(failureLogIntervalMs) || 0);
|
|
318
|
+
if (reconnectAttempt > 3 && lastFailureLoggedAt !== null && (nowMs - lastFailureLoggedAt) < intervalMs) {
|
|
319
|
+
failuresNotLogged += 1;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const notLogged = failuresNotLogged ? [`notLogged=${failuresNotLogged}`] : [];
|
|
323
|
+
dbg("worker ws disconnected", reason, `attempt=${reconnectAttempt}`, `retryInMs=${reconnectDelayMs}`, ...notLogged);
|
|
324
|
+
lastFailureLoggedAt = nowMs;
|
|
325
|
+
failuresNotLogged = 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scheduleReconnect(reason) {
|
|
294
329
|
if (stopped) return;
|
|
295
330
|
// One timer at a time: a second end reported for the same outage must not
|
|
296
331
|
// stack another attempt or push the backoff up.
|
|
@@ -299,7 +334,7 @@ export function createWorkerWebSocketLink({
|
|
|
299
334
|
const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
|
|
300
335
|
const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
|
|
301
336
|
reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
|
|
302
|
-
|
|
337
|
+
logFailedAttempt(reason);
|
|
303
338
|
reconnectTimer = setTimeoutImpl(() => {
|
|
304
339
|
reconnectTimer = null;
|
|
305
340
|
connect();
|
|
@@ -312,42 +347,70 @@ export function createWorkerWebSocketLink({
|
|
|
312
347
|
const openState = resolveSocketStateValue(ws, "OPEN", 1);
|
|
313
348
|
const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
|
|
314
349
|
if (ws.readyState === openState || ws.readyState === connectingState) return;
|
|
350
|
+
// A socket left closing whose end was never reported: give it up
|
|
351
|
+
// quietly, this connect is its reconnect.
|
|
352
|
+
endCurrentSocket?.("replaced", { reconnect: false });
|
|
315
353
|
}
|
|
316
354
|
if (typeof WebSocketImpl !== "function") {
|
|
317
355
|
dbg("worker ws unavailable: global WebSocket is missing");
|
|
318
356
|
return;
|
|
319
357
|
}
|
|
320
358
|
clearReconnectTimer();
|
|
321
|
-
|
|
322
|
-
|
|
359
|
+
let socket = null;
|
|
360
|
+
try {
|
|
361
|
+
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
362
|
+
socket = new WebSocketImpl(url);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// A socket that fails in its constructor raises no events: nothing but
|
|
365
|
+
// this would ever try again.
|
|
366
|
+
scheduleReconnect(`connect-failed: ${error?.message || String(error)}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
323
369
|
ws = socket;
|
|
324
370
|
let serverHelloSeen = false;
|
|
325
|
-
// Set once this socket's end has been handled, by whichever of error
|
|
326
|
-
// close
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
371
|
+
// Set once this socket's end has been handled, by whichever of error,
|
|
372
|
+
// close, the connect deadline or a stale/draining close comes first;
|
|
373
|
+
// every later event from it is ignored. Node 22 fires no close for a
|
|
374
|
+
// socket closed while still connecting, and closing it from its own
|
|
375
|
+
// error handler raised the error again, so the reconnect has to be
|
|
376
|
+
// scheduled from the error as well, and only once.
|
|
330
377
|
let ended = false;
|
|
331
|
-
function endSocket(reason) {
|
|
378
|
+
function endSocket(reason, { reconnect = true } = {}) {
|
|
332
379
|
if (ended) return false;
|
|
333
380
|
ended = true;
|
|
381
|
+
if (endCurrentSocket === endSocket) {
|
|
382
|
+
endCurrentSocket = null;
|
|
383
|
+
clearConnectDeadline();
|
|
384
|
+
}
|
|
334
385
|
if (ws === socket) {
|
|
335
386
|
ws = null;
|
|
336
387
|
clearReadyRefreshTimer();
|
|
337
388
|
// The next connection may reach a different (restarted) relay.
|
|
338
389
|
serverCapabilities = new Set();
|
|
339
390
|
}
|
|
340
|
-
if (stopped) return true;
|
|
341
|
-
|
|
342
|
-
|
|
391
|
+
if (stopped || !reconnect) return true;
|
|
392
|
+
// A newer socket is already under way and owns the reconnect.
|
|
393
|
+
if (ws) return true;
|
|
394
|
+
scheduleReconnect(reason);
|
|
343
395
|
return true;
|
|
344
396
|
}
|
|
345
397
|
endCurrentSocket = endSocket;
|
|
398
|
+
clearConnectDeadline();
|
|
399
|
+
const deadlineMs = Math.max(0, Number(connectTimeoutMs) || 0);
|
|
400
|
+
if (deadlineMs > 0) {
|
|
401
|
+
connectDeadlineTimer = setTimeoutImpl(() => {
|
|
402
|
+
connectDeadlineTimer = null;
|
|
403
|
+
if (ended) return;
|
|
404
|
+
const opened = socket.readyState === resolveSocketStateValue(socket, "OPEN", 1);
|
|
405
|
+
// Ended before the close call, as in the error handler below.
|
|
406
|
+
endSocket(opened ? "no-server-hello" : "connect-timeout");
|
|
407
|
+
try { socket.close?.(); } catch {}
|
|
408
|
+
}, deadlineMs);
|
|
409
|
+
if (typeof connectDeadlineTimer?.unref === "function") connectDeadlineTimer.unref();
|
|
410
|
+
}
|
|
346
411
|
socket.addEventListener("open", () => {
|
|
347
412
|
if (ended) return;
|
|
348
413
|
const openedAt = getNowMs();
|
|
349
|
-
reconnectAttempt = 0;
|
|
350
|
-
reconnectDelayMs = 0;
|
|
351
414
|
lastOpenAt = openedAt;
|
|
352
415
|
lastMessageAt = openedAt;
|
|
353
416
|
dbg("worker ws connected");
|
|
@@ -373,6 +436,14 @@ export function createWorkerWebSocketLink({
|
|
|
373
436
|
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
374
437
|
if (!serverHelloSeen) {
|
|
375
438
|
serverHelloSeen = true;
|
|
439
|
+
// The handshake is complete: only now does the backoff start over.
|
|
440
|
+
// A relay that accepts the socket and drops it before its hello
|
|
441
|
+
// keeps backing off instead of being retried every second.
|
|
442
|
+
clearConnectDeadline();
|
|
443
|
+
reconnectAttempt = 0;
|
|
444
|
+
reconnectDelayMs = 0;
|
|
445
|
+
lastFailureLoggedAt = null;
|
|
446
|
+
failuresNotLogged = 0;
|
|
376
447
|
try {
|
|
377
448
|
onServerHello(String(payload.reason || "server-hello"));
|
|
378
449
|
} catch (error) {
|
|
@@ -388,6 +459,9 @@ export function createWorkerWebSocketLink({
|
|
|
388
459
|
}
|
|
389
460
|
if (payload?.type === "server.draining") {
|
|
390
461
|
dbg("worker ws draining", payload.reason || "relay-shutdown");
|
|
462
|
+
// A relay stuck in its shutdown may never complete the close
|
|
463
|
+
// handshake: the reconnect does not wait for the close event.
|
|
464
|
+
endSocket("draining");
|
|
391
465
|
try { socket.close?.(); } catch {}
|
|
392
466
|
return;
|
|
393
467
|
}
|
|
@@ -408,15 +482,16 @@ export function createWorkerWebSocketLink({
|
|
|
408
482
|
dbg("worker ws blocked", payload.reason || "blocked");
|
|
409
483
|
}
|
|
410
484
|
});
|
|
411
|
-
socket.addEventListener("close", () => {
|
|
412
|
-
|
|
485
|
+
socket.addEventListener("close", (event) => {
|
|
486
|
+
const code = Number(event?.code);
|
|
487
|
+
endSocket(Number.isFinite(code) && code > 0 ? `close code=${code}` : "close");
|
|
413
488
|
});
|
|
414
|
-
socket.addEventListener("error", (
|
|
489
|
+
socket.addEventListener("error", (event) => {
|
|
415
490
|
if (ended) return;
|
|
416
|
-
|
|
491
|
+
const detail = event?.message || event?.error?.message || "unknown";
|
|
417
492
|
// Ended before the close call, so an error or close it raises
|
|
418
493
|
// (synchronously on some runtimes) finds the socket already handled.
|
|
419
|
-
endSocket(
|
|
494
|
+
endSocket(`error: ${detail}`);
|
|
420
495
|
try { socket.close?.(); } catch {}
|
|
421
496
|
});
|
|
422
497
|
}
|
|
@@ -429,6 +504,7 @@ export function createWorkerWebSocketLink({
|
|
|
429
504
|
function stop() {
|
|
430
505
|
stopped = true;
|
|
431
506
|
clearReconnectTimer();
|
|
507
|
+
clearConnectDeadline();
|
|
432
508
|
clearReadyRefreshTimer();
|
|
433
509
|
if (ws) {
|
|
434
510
|
try { ws.close(); } catch {}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The session's working directory, checked before a runtime is started in it.
|
|
2
|
+
//
|
|
3
|
+
// A session runs in the folder it was given (COPILOT_WORKSPACE_ROOT). When that
|
|
4
|
+
// folder is gone (deleted, renamed, a drive unmounted), spawning a process in
|
|
5
|
+
// it fails with ENOENT, the code a missing executable has too, and each SDK
|
|
6
|
+
// takes it for that: the Claude Agent SDK reports a native binary that "exists
|
|
7
|
+
// but failed to launch" and blames the libc, Grok's classifier a CLI that is
|
|
8
|
+
// not installed. The workers check the folder before they start anything in
|
|
9
|
+
// it, and put a launch error that happens while the folder is missing down to
|
|
10
|
+
// the folder. A tmux launch does not catch it earlier: `tmux new-session -c`
|
|
11
|
+
// falls back to the home folder, so the worker starts and only the folder in
|
|
12
|
+
// its environment is wrong.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
|
|
17
|
+
|
|
18
|
+
export const WORKING_DIRECTORY_GUIDANCE =
|
|
19
|
+
'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
|
|
20
|
+
+ 'then send the message again.';
|
|
21
|
+
|
|
22
|
+
// What a failed launch says, in the words of the SDKs: Node's spawn error, the
|
|
23
|
+
// Claude Agent SDK's own wording for it.
|
|
24
|
+
const LAUNCH_FAILURE_TEXT = /\bENOENT\b|\bspawn\b|failed to launch|failed to spawn|not found at/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The error a turn fails with when `cwd` cannot be worked in, or null when it
|
|
28
|
+
* can. Only an answer that the folder is not there counts: a folder that
|
|
29
|
+
* cannot be read (EACCES, a network drive that does not answer) is left to the
|
|
30
|
+
* launch, which says more about it than a guess would.
|
|
31
|
+
*/
|
|
32
|
+
export function missingWorkingDirectoryError(cwd, { statSync = fs.statSync } = {}) {
|
|
33
|
+
const folder = String(cwd || '').trim();
|
|
34
|
+
if (!folder) return null;
|
|
35
|
+
let stats = null;
|
|
36
|
+
try {
|
|
37
|
+
stats = statSync(folder);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null;
|
|
40
|
+
}
|
|
41
|
+
if (stats?.isDirectory()) return null;
|
|
42
|
+
const error = new Error(stats
|
|
43
|
+
? `The working directory of this conversation is not a folder: ${folder}.`
|
|
44
|
+
: `The working directory of this conversation no longer exists: ${folder}.`);
|
|
45
|
+
error.code = WORKING_DIRECTORY_MISSING_CODE;
|
|
46
|
+
error.workingDirectory = folder;
|
|
47
|
+
error.guidance = WORKING_DIRECTORY_GUIDANCE;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isWorkingDirectoryMissingError(error) {
|
|
52
|
+
return error?.code === WORKING_DIRECTORY_MISSING_CODE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A launch that failed while the folder is missing failed because of it: the
|
|
57
|
+
* folder's error in its place. Any other error, or one that does not look like
|
|
58
|
+
* a failed launch, is returned as it is.
|
|
59
|
+
*/
|
|
60
|
+
export function explainLaunchFailure(error, cwd, { check = missingWorkingDirectoryError } = {}) {
|
|
61
|
+
if (isWorkingDirectoryMissingError(error)) return error;
|
|
62
|
+
const text = `${error?.code || ''} ${error?.message || error || ''}`;
|
|
63
|
+
if (!LAUNCH_FAILURE_TEXT.test(text)) return error;
|
|
64
|
+
return check(cwd) || error;
|
|
65
|
+
}
|