@oar-sh/oar 0.9.10 → 0.9.11
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 +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +787 -22
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -7,17 +7,28 @@
|
|
|
7
7
|
*
|
|
8
8
|
* `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
|
|
9
9
|
* relay event service reads its edges (a task started or ended) and its quiet
|
|
10
|
-
* time (stalled). Memory only
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* time (stalled). Memory only, and a restart of the relay starts it empty
|
|
11
|
+
* while the workers and their tasks keep running. What brings a running
|
|
12
|
+
* task back:
|
|
13
|
+
* - the worker sends its set again when the relay asks for it on a
|
|
14
|
+
* heartbeat (`noteTaskSet` with `resync`), which restores the task with
|
|
15
|
+
* its original start time and without a second `task_started`;
|
|
16
|
+
* - sub-agent runs are read from the database;
|
|
17
|
+
* - a worker that cannot send its set again yet but says it has background
|
|
18
|
+
* work shows one placeholder task (`hasUnreportedBackgroundWork`).
|
|
19
|
+
* Until the workers had time to report, `bootReporting()` says so: the
|
|
20
|
+
* suspend check and `all_idle` wait that long. The recently finished tasks
|
|
21
|
+
* and the activity clock are gone for good (the clock starts at the restart,
|
|
22
|
+
* so nothing reads as stalled at boot).
|
|
14
23
|
*
|
|
15
24
|
* Sources:
|
|
16
25
|
* - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
|
|
17
26
|
* a new task id is a start, a task gone from the set has ended, a changed
|
|
18
|
-
* task (tool call, summary, tokens) is activity.
|
|
27
|
+
* task (tool call, summary, tokens) is activity. A task that started
|
|
28
|
+
* before this runtime did was announced by the runtime before it.
|
|
19
29
|
* - `noteTaskSettled` — how a task ended, where the worker says so (the
|
|
20
|
-
* Claude worker's task_notification).
|
|
30
|
+
* Claude worker's task_notification). A task this runtime never knew
|
|
31
|
+
* still gets its end event when the worker describes it.
|
|
21
32
|
* - `noteSubagentRun` — every sub-agent run status the relay stores; a
|
|
22
33
|
* background run's terminal status is how its task ended, and a
|
|
23
34
|
* background run with no task in the set (Copilot, Cursor) ends here.
|
|
@@ -37,6 +48,44 @@ export const TASK_END_GRACE_MS = 5_000;
|
|
|
37
48
|
// again and again). The sweep in stale-subagent-run-sweep.mjs closes such
|
|
38
49
|
// rows; this bound keeps them out of the state meanwhile.
|
|
39
50
|
export const STALE_RUNNING_RUN_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
51
|
+
// How long after a runtime start the surviving workers may still be
|
|
52
|
+
// reporting their tasks: four heartbeat periods and a little. Inside it an
|
|
53
|
+
// idle-looking relay is not known to be idle.
|
|
54
|
+
export const BOOT_REPORTING_WINDOW_MS = 45_000;
|
|
55
|
+
// The most an override may ask for: a window is a hold on suspends and on
|
|
56
|
+
// all_idle, and timers take no delay above 2^31 ms.
|
|
57
|
+
export const BOOT_REPORTING_WINDOW_MAX_MS = 10 * 60_000;
|
|
58
|
+
/**
|
|
59
|
+
* The window of this runtime: BOOT_REPORTING_WINDOW_MS, unless
|
|
60
|
+
* OAR_BOOT_REPORTING_WINDOW_MS pins another, from 0 (no hold at all: a test
|
|
61
|
+
* relay that must read as idle straight after its start) to
|
|
62
|
+
* BOOT_REPORTING_WINDOW_MAX_MS. An override in effect is logged once; a
|
|
63
|
+
* value that is no number, or below 0, is ignored and logged too.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveBootReportingWindowMs(env = process.env, log = () => {}) {
|
|
66
|
+
const raw = String(env?.OAR_BOOT_REPORTING_WINDOW_MS ?? '').trim();
|
|
67
|
+
if (!raw) return BOOT_REPORTING_WINDOW_MS;
|
|
68
|
+
const pinned = Number(raw);
|
|
69
|
+
if (!Number.isFinite(pinned) || pinned < 0) {
|
|
70
|
+
log(`OAR_BOOT_REPORTING_WINDOW_MS=${raw.slice(0, 40)} is not a number of milliseconds from 0; using ${BOOT_REPORTING_WINDOW_MS}`);
|
|
71
|
+
return BOOT_REPORTING_WINDOW_MS;
|
|
72
|
+
}
|
|
73
|
+
const windowMs = Math.min(BOOT_REPORTING_WINDOW_MAX_MS, Math.trunc(pinned));
|
|
74
|
+
log(windowMs === 0
|
|
75
|
+
? 'OAR_BOOT_REPORTING_WINDOW_MS=0: no wait after a relay start; a suspend and all_idle do not wait for sessions that are still reporting'
|
|
76
|
+
: `OAR_BOOT_REPORTING_WINDOW_MS: a suspend and all_idle wait ${windowMs} ms after a relay start${windowMs === pinned ? '' : ` (asked for ${raw.slice(0, 40)}, kept within 0 to ${BOOT_REPORTING_WINDOW_MAX_MS})`}`);
|
|
77
|
+
return windowMs;
|
|
78
|
+
}
|
|
79
|
+
// The one task shown for a worker that reports background work the relay
|
|
80
|
+
// was never told the details of.
|
|
81
|
+
export const UNREPORTED_BACKGROUND_TASK_ID = 'unreported-background-work';
|
|
82
|
+
// It stays until the worker reports, and a worker on older code never does
|
|
83
|
+
// for a task that prints nothing: the way out is the session's Kill session.
|
|
84
|
+
export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its ⋯ menu ends it)';
|
|
85
|
+
// A `running` background run row that its worker's task set does not list
|
|
86
|
+
// and nothing has updated for this long is not running work (see
|
|
87
|
+
// `runListedByWorker`).
|
|
88
|
+
export const UNLISTED_RUNNING_RUN_MAX_QUIET_MS = 15 * 60_000;
|
|
40
89
|
const RECENT_TASKS_MAX = 5;
|
|
41
90
|
const RECENT_TASK_TTL_MS = 60 * 60_000;
|
|
42
91
|
const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
|
|
@@ -98,6 +147,20 @@ export function createSessionActivityTracker({
|
|
|
98
147
|
},
|
|
99
148
|
clearTimer = (timer) => clearTimeout(timer),
|
|
100
149
|
onEdge = () => {},
|
|
150
|
+
// conversationId → whether its live worker reports background work that no
|
|
151
|
+
// task set describes (see the placeholder in `describe`).
|
|
152
|
+
hasUnreportedBackgroundWork = () => false,
|
|
153
|
+
// The conversations that may be true for, so that readers which only ask
|
|
154
|
+
// about the conversations this tracker knows ask about them too.
|
|
155
|
+
listUnreportedBackgroundWork = () => [],
|
|
156
|
+
// (conversationId, runRow) → whether the task set of the conversation's
|
|
157
|
+
// worker lists the run: true, false, or null when the worker has reported
|
|
158
|
+
// no set in this runtime or its sets do not speak for its runs. A
|
|
159
|
+
// `running` background row that is not listed and that nothing has
|
|
160
|
+
// updated for UNLISTED_RUNNING_RUN_MAX_QUIET_MS is a row whose end was
|
|
161
|
+
// never written: it is kept out of the state, as the day-old ones are.
|
|
162
|
+
runListedByWorker = () => null,
|
|
163
|
+
bootReportingWindowMs = BOOT_REPORTING_WINDOW_MS,
|
|
101
164
|
logger = console,
|
|
102
165
|
} = {}) {
|
|
103
166
|
const startedAtMs = now();
|
|
@@ -105,6 +168,10 @@ export function createSessionActivityTracker({
|
|
|
105
168
|
const sessions = new Map();
|
|
106
169
|
// Edges already reported for an end, by `${conversationId}:${key}`.
|
|
107
170
|
const endedKeys = new Map();
|
|
171
|
+
// `${conversationId}:${taskId}` of the ends nobody reported: the task was
|
|
172
|
+
// only missing from a set for the grace. A later set that lists it again
|
|
173
|
+
// says it never ended.
|
|
174
|
+
const endedByAbsence = new Set();
|
|
108
175
|
|
|
109
176
|
let listRuns = null;
|
|
110
177
|
let getQueueRows = null;
|
|
@@ -151,7 +218,10 @@ export function createSessionActivityTracker({
|
|
|
151
218
|
}
|
|
152
219
|
if (endedKeys.size > 2000) {
|
|
153
220
|
for (const [key, when] of endedKeys) {
|
|
154
|
-
if (at - when > RECENT_TASK_TTL_MS)
|
|
221
|
+
if (at - when > RECENT_TASK_TTL_MS) {
|
|
222
|
+
endedKeys.delete(key);
|
|
223
|
+
endedByAbsence.delete(key);
|
|
224
|
+
}
|
|
155
225
|
}
|
|
156
226
|
}
|
|
157
227
|
}
|
|
@@ -179,7 +249,7 @@ export function createSessionActivityTracker({
|
|
|
179
249
|
};
|
|
180
250
|
}
|
|
181
251
|
|
|
182
|
-
function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true } = {}) {
|
|
252
|
+
function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true, absent = false } = {}) {
|
|
183
253
|
if (task.graceTimer) {
|
|
184
254
|
clearTimer(task.graceTimer);
|
|
185
255
|
task.graceTimer = null;
|
|
@@ -194,6 +264,7 @@ export function createSessionActivityTracker({
|
|
|
194
264
|
task.exitCode = code;
|
|
195
265
|
task.endedAtMs = now();
|
|
196
266
|
markEnded(conversationId, task);
|
|
267
|
+
if (absent) endedByAbsence.add(`${conversationId}:${task.taskId}`);
|
|
197
268
|
entry.recent.unshift(task);
|
|
198
269
|
entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
|
|
199
270
|
entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
|
|
@@ -227,11 +298,26 @@ export function createSessionActivityTracker({
|
|
|
227
298
|
task.graceTimer = null;
|
|
228
299
|
const late = runStatus(task.toolUseId);
|
|
229
300
|
if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
|
|
230
|
-
else finishTask(conversationId, task, { status: 'completed', reported: false });
|
|
301
|
+
else finishTask(conversationId, task, { status: 'completed', reported: false, absent: true });
|
|
231
302
|
}, TASK_END_GRACE_MS);
|
|
232
303
|
}
|
|
233
304
|
|
|
234
|
-
|
|
305
|
+
/** Takes back an end that was only an absence; the task's old row goes from the recent ones. */
|
|
306
|
+
function unEnd(conversationId, entry, taskId, toolUseId) {
|
|
307
|
+
endedByAbsence.delete(`${conversationId}:${taskId}`);
|
|
308
|
+
const index = entry.recent.findIndex((task) => task.taskId === taskId);
|
|
309
|
+
const [old] = index >= 0 ? entry.recent.splice(index, 1) : [null];
|
|
310
|
+
for (const key of [taskId, toolUseId ? `run:${toolUseId}` : '', old?.toolUseId ? `run:${old.toolUseId}` : '']) {
|
|
311
|
+
if (key) endedKeys.delete(`${conversationId}:${key}`);
|
|
312
|
+
}
|
|
313
|
+
return old;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `resync`: the set is the worker's answer to the relay asking for it
|
|
318
|
+
* again, not a change of the set.
|
|
319
|
+
*/
|
|
320
|
+
function noteTaskSet(conversationId, tasks, { resync = false } = {}) {
|
|
235
321
|
const id = toText(conversationId);
|
|
236
322
|
if (!id) return;
|
|
237
323
|
const entry = session(id);
|
|
@@ -262,15 +348,25 @@ export function createSessionActivityTracker({
|
|
|
262
348
|
known.leftAtMs = 0;
|
|
263
349
|
continue;
|
|
264
350
|
}
|
|
265
|
-
|
|
266
|
-
if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) }))
|
|
351
|
+
let revived = null;
|
|
352
|
+
if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) {
|
|
353
|
+
// Settled already (its notice came before the set dropped it).
|
|
354
|
+
if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
|
|
355
|
+
// Ended only because a set did not list it (a set that arrived out
|
|
356
|
+
// of turn, a worker that left it out for a moment) and listed
|
|
357
|
+
// again: it never ended. It comes back as the task it was, with no
|
|
358
|
+
// second start. The end that was reported for it stays reported;
|
|
359
|
+
// its real end will be another.
|
|
360
|
+
revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
|
|
361
|
+
}
|
|
362
|
+
const taskStartedAtMs = msOf(raw.startedAt);
|
|
267
363
|
const task = {
|
|
268
364
|
taskId,
|
|
269
365
|
toolUseId: toText(raw.toolUseId) || null,
|
|
270
366
|
taskType: toText(raw.taskType),
|
|
271
367
|
kind: taskKindOf(raw.taskType),
|
|
272
368
|
description: toText(raw.description).slice(0, 500),
|
|
273
|
-
startedAtMs:
|
|
369
|
+
startedAtMs: taskStartedAtMs || revived?.startedAtMs || at,
|
|
274
370
|
lastActivityAtMs: at,
|
|
275
371
|
summary: toText(raw.summary) || null,
|
|
276
372
|
model: toText(raw.model) || null,
|
|
@@ -282,7 +378,12 @@ export function createSessionActivityTracker({
|
|
|
282
378
|
};
|
|
283
379
|
entry.live.set(taskId, task);
|
|
284
380
|
entry.lastActivityAt = at;
|
|
285
|
-
|
|
381
|
+
// A task older than this runtime was announced by the one before it,
|
|
382
|
+
// whichever post brings it back (the resync, or a progress update that
|
|
383
|
+
// overtakes it). One that started since is new even in a resync: its
|
|
384
|
+
// first post was lost. Without a start time a resync decides.
|
|
385
|
+
const announcedBefore = taskStartedAtMs ? taskStartedAtMs < startedAtMs : resync;
|
|
386
|
+
if (!announcedBefore && !revived) emitEdge('task_started', id, task);
|
|
286
387
|
}
|
|
287
388
|
for (const task of [...entry.live.values()]) {
|
|
288
389
|
if (seen.has(task.taskId) || task.leftAtMs) continue;
|
|
@@ -290,7 +391,9 @@ export function createSessionActivityTracker({
|
|
|
290
391
|
}
|
|
291
392
|
}
|
|
292
393
|
|
|
293
|
-
function noteTaskSettled(conversationId, {
|
|
394
|
+
function noteTaskSettled(conversationId, {
|
|
395
|
+
taskId, toolUseId, status, summary, exitCode, description, taskType, startedAt,
|
|
396
|
+
} = {}) {
|
|
294
397
|
const id = toText(conversationId);
|
|
295
398
|
if (!id) return;
|
|
296
399
|
const entry = session(id);
|
|
@@ -305,13 +408,69 @@ export function createSessionActivityTracker({
|
|
|
305
408
|
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
306
409
|
};
|
|
307
410
|
entry.lastActivityAt = now();
|
|
308
|
-
if (!task)
|
|
411
|
+
if (!task && key && endedByAbsence.delete(`${id}:${key}`)) {
|
|
412
|
+
// It was taken for ended when a set no longer listed it; this is the
|
|
413
|
+
// word on how it ended. A real end is final: no later set brings the
|
|
414
|
+
// task back.
|
|
415
|
+
const ended = entry.recent.find((candidate) => candidate.taskId === key);
|
|
416
|
+
if (ended) {
|
|
417
|
+
ended.status = settled.status;
|
|
418
|
+
ended.statusReported = true;
|
|
419
|
+
if (settled.summary) ended.summary = settled.summary.slice(0, 500);
|
|
420
|
+
ended.exitCode = settled.exitCode ?? exitCodeFromSummary(ended.summary);
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!task) {
|
|
425
|
+
// A task this runtime never knew (it ended while the relay was away, or
|
|
426
|
+
// its set never arrived): the end is still an event when the worker
|
|
427
|
+
// says what the task was. Once only, like every end: a sub-agent's
|
|
428
|
+
// end also comes as its run's status, so the notice is paired with
|
|
429
|
+
// the run that carries its task id when it names no run. (With no
|
|
430
|
+
// such link the two are two ends; rare, and better than a guess.)
|
|
431
|
+
if (!key || !toText(description)) return;
|
|
432
|
+
const runId = run || (taskKindOf(taskType) === 'subagent'
|
|
433
|
+
? runOfUnknownTask(id, { taskId: key })
|
|
434
|
+
: '');
|
|
435
|
+
const unknown = {
|
|
436
|
+
taskId: key,
|
|
437
|
+
toolUseId: runId || null,
|
|
438
|
+
taskType: toText(taskType),
|
|
439
|
+
kind: taskKindOf(taskType),
|
|
440
|
+
description: toText(description).slice(0, 500),
|
|
441
|
+
startedAtMs: msOf(startedAt) || now(),
|
|
442
|
+
lastActivityAtMs: now(),
|
|
443
|
+
summary: null,
|
|
444
|
+
model: null,
|
|
445
|
+
status: 'running',
|
|
446
|
+
};
|
|
447
|
+
finishTask(id, unknown, settled);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
309
450
|
task.settled = settled;
|
|
310
451
|
// Gone from the set already, or about to: settle now either way. A task
|
|
311
452
|
// still listed would leave with this status anyway.
|
|
312
453
|
finishTask(id, task, settled);
|
|
313
454
|
}
|
|
314
455
|
|
|
456
|
+
/**
|
|
457
|
+
* The background run a described settle notice without a run id speaks
|
|
458
|
+
* of: the run that carries the task id. Nothing looser: a run picked by
|
|
459
|
+
* name or by start time can be another agent's, whose own end would then
|
|
460
|
+
* never be reported.
|
|
461
|
+
*/
|
|
462
|
+
function runOfUnknownTask(conversationId, { taskId }) {
|
|
463
|
+
if (!listRuns) return '';
|
|
464
|
+
let rows = [];
|
|
465
|
+
try {
|
|
466
|
+
rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
|
|
467
|
+
} catch {
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
const match = rows.find((row) => Number(row.background) === 1 && toText(row.task_id) === taskId);
|
|
471
|
+
return match ? toText(match.id) : '';
|
|
472
|
+
}
|
|
473
|
+
|
|
315
474
|
function noteSubagentRun(run) {
|
|
316
475
|
const id = toText(run?.conversation_id || run?.conversationId);
|
|
317
476
|
const runId = toText(run?.id || run?.subagentRunId);
|
|
@@ -320,7 +479,21 @@ export function createSessionActivityTracker({
|
|
|
320
479
|
entry.lastActivityAt = now();
|
|
321
480
|
const status = normalizeSubagentRunStatus(run?.status);
|
|
322
481
|
if (!isTerminalSubagentRunStatus(status)) return;
|
|
323
|
-
const
|
|
482
|
+
const runTaskId = toText(run?.task_id || run?.taskId);
|
|
483
|
+
const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId)
|
|
484
|
+
|| (runTaskId && entry.live.get(runTaskId))
|
|
485
|
+
|| null;
|
|
486
|
+
if (task) {
|
|
487
|
+
task.toolUseId = task.toolUseId || runId;
|
|
488
|
+
} else {
|
|
489
|
+
// The run of a task that was taken for ended by its absence: its end
|
|
490
|
+
// is real now, and final.
|
|
491
|
+
const ended = entry.recent.find((candidate) => candidate.toolUseId === runId || (runTaskId && candidate.taskId === runTaskId));
|
|
492
|
+
if (ended && endedByAbsence.delete(`${id}:${ended.taskId}`)) {
|
|
493
|
+
ended.status = normalizeTaskStatus(status);
|
|
494
|
+
ended.statusReported = true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
324
497
|
if (task) {
|
|
325
498
|
// The run's end is the task's: finish it once it has left the set,
|
|
326
499
|
// which the grace timer is waiting for; a task still listed keeps
|
|
@@ -410,8 +583,13 @@ export function createSessionActivityTracker({
|
|
|
410
583
|
return [];
|
|
411
584
|
}
|
|
412
585
|
const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
|
|
586
|
+
const quietBefore = now() - UNLISTED_RUNNING_RUN_MAX_QUIET_MS;
|
|
587
|
+
const unlisted = (row) => {
|
|
588
|
+
if (Number(row.background) !== 1 || msOf(row.updated_at || row.started_at) >= quietBefore) return false;
|
|
589
|
+
try { return runListedByWorker(conversationId, row) === false; } catch { return false; }
|
|
590
|
+
};
|
|
413
591
|
const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
|
|
414
|
-
&& msOf(row.updated_at || row.started_at) < staleBefore;
|
|
592
|
+
&& (msOf(row.updated_at || row.started_at) < staleBefore || unlisted(row));
|
|
415
593
|
return rows
|
|
416
594
|
.filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
|
|
417
595
|
.map((row) => {
|
|
@@ -449,8 +627,23 @@ export function createSessionActivityTracker({
|
|
|
449
627
|
.map((task) => toText(task.toolUseId))
|
|
450
628
|
.filter(Boolean));
|
|
451
629
|
const fromRuns = runTasks(id, covered);
|
|
630
|
+
const runningRuns = fromRuns.filter((task) => task.status === 'running' && !task.inTurn);
|
|
631
|
+
let unreported = false;
|
|
632
|
+
if (!live.length && !runningRuns.length) {
|
|
633
|
+
try { unreported = hasUnreportedBackgroundWork(id) === true; } catch {}
|
|
634
|
+
}
|
|
452
635
|
const tasks = [
|
|
453
636
|
...live.map(publicTask),
|
|
637
|
+
...(unreported ? [{
|
|
638
|
+
id: UNREPORTED_BACKGROUND_TASK_ID,
|
|
639
|
+
kind: 'unknown',
|
|
640
|
+
type: null,
|
|
641
|
+
description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
|
|
642
|
+
startedAt: null,
|
|
643
|
+
lastActivityAt: null,
|
|
644
|
+
status: 'running',
|
|
645
|
+
placeholder: true,
|
|
646
|
+
}] : []),
|
|
454
647
|
...fromRuns.filter((task) => task.status === 'running'),
|
|
455
648
|
...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
|
|
456
649
|
.sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
|
|
@@ -471,7 +664,21 @@ export function createSessionActivityTracker({
|
|
|
471
664
|
}
|
|
472
665
|
|
|
473
666
|
function knownConversationIds() {
|
|
474
|
-
|
|
667
|
+
const ids = new Set(sessions.keys());
|
|
668
|
+
try {
|
|
669
|
+
for (const id of listUnreportedBackgroundWork() || []) if (toText(id)) ids.add(toText(id));
|
|
670
|
+
} catch {}
|
|
671
|
+
return [...ids];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* `{ active, ageMs, remainingMs }`: whether this runtime is young enough
|
|
676
|
+
* that workers which outlived the one before it may not have reported yet.
|
|
677
|
+
*/
|
|
678
|
+
function bootReporting() {
|
|
679
|
+
const ageMs = Math.max(0, now() - startedAtMs);
|
|
680
|
+
const remainingMs = Math.max(0, bootReportingWindowMs - ageMs);
|
|
681
|
+
return { active: remainingMs > 0, ageMs, remainingMs };
|
|
475
682
|
}
|
|
476
683
|
|
|
477
684
|
return {
|
|
@@ -483,5 +690,6 @@ export function createSessionActivityTracker({
|
|
|
483
690
|
forget,
|
|
484
691
|
describe,
|
|
485
692
|
knownConversationIds,
|
|
693
|
+
bootReporting,
|
|
486
694
|
};
|
|
487
695
|
}
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
isOarMcpServerDisabled,
|
|
15
15
|
} from '../mcp/oar-mcp-server.mjs';
|
|
16
16
|
import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
17
|
+
import {
|
|
18
|
+
describeSpawnEnvironment,
|
|
19
|
+
envPathKey,
|
|
20
|
+
readEnvValue,
|
|
21
|
+
windowsCmdPath,
|
|
22
|
+
} from '../../shared/windows-system-tools.mjs';
|
|
17
23
|
|
|
18
24
|
export { isOarMcpServerDisabled };
|
|
19
25
|
|
|
@@ -43,6 +49,87 @@ function normalizeText(value) {
|
|
|
43
49
|
return text || null;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
// ─── Which Node runs a worker ────────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// The relay's own Node by default: its absolute path works whatever PATH the
|
|
55
|
+
// relay was started with. A relay started by the Windows boot task found no
|
|
56
|
+
// `node` on that task's PATH, and every worker launch failed without a trace.
|
|
57
|
+
// COPILOT_WEB_RELAY_NODE still overrides it.
|
|
58
|
+
|
|
59
|
+
export function resolveWorkerNodeExecutable(env = {}, { execPath = process.execPath } = {}) {
|
|
60
|
+
return normalizeText(env?.COPILOT_WEB_RELAY_NODE) || execPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pathImplFor(platform) {
|
|
64
|
+
return platform === 'win32' ? path.win32 : path.posix;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPathLike(value) {
|
|
68
|
+
return /[\\/]/.test(String(value || ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The folder of the worker's Node when it is a path, else null. */
|
|
72
|
+
export function resolveWorkerNodeDirectory(env = {}, { platform = process.platform, execPath = process.execPath } = {}) {
|
|
73
|
+
const executable = resolveWorkerNodeExecutable(env, { execPath });
|
|
74
|
+
const pathImpl = pathImplFor(platform);
|
|
75
|
+
const candidate = isPathLike(executable) ? executable : execPath;
|
|
76
|
+
if (!isPathLike(candidate)) return null;
|
|
77
|
+
return pathImpl.dirname(candidate);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The environment with the worker's Node folder at the END of PATH when it is
|
|
82
|
+
* not on it yet. The CLIs a worker starts, and the commands its agent runs,
|
|
83
|
+
* may call a bare `node`; appending only matters when PATH has none, and never
|
|
84
|
+
* changes which one a PATH that has one picks.
|
|
85
|
+
*/
|
|
86
|
+
export function appendNodeDirectoryToPath(env = {}, nodeDirectory, { platform = process.platform } = {}) {
|
|
87
|
+
const dir = normalizeText(nodeDirectory);
|
|
88
|
+
if (!dir) return env;
|
|
89
|
+
const pathImpl = pathImplFor(platform);
|
|
90
|
+
const key = envPathKey(env, platform);
|
|
91
|
+
const current = String(env?.[key] || '');
|
|
92
|
+
const comparable = (entry) => {
|
|
93
|
+
const trimmed = String(entry || '').trim().replace(/[\\/]+$/, '');
|
|
94
|
+
return platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
95
|
+
};
|
|
96
|
+
const wanted = comparable(dir);
|
|
97
|
+
if (current.split(pathImpl.delimiter).some((entry) => comparable(entry) === wanted)) return env;
|
|
98
|
+
const separator = current && !current.endsWith(pathImpl.delimiter) ? pathImpl.delimiter : '';
|
|
99
|
+
return { ...env, [key]: `${current}${separator}${dir}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where `command` resolves to with this environment's PATH (and PATHEXT on
|
|
104
|
+
* Windows), or null. A command with a folder part must be a file.
|
|
105
|
+
*/
|
|
106
|
+
export function locateExecutable(command, {
|
|
107
|
+
env = {},
|
|
108
|
+
platform = process.platform,
|
|
109
|
+
fsImpl = fs,
|
|
110
|
+
} = {}) {
|
|
111
|
+
const name = String(command || '').trim();
|
|
112
|
+
if (!name) return null;
|
|
113
|
+
const pathImpl = pathImplFor(platform);
|
|
114
|
+
const isFile = (candidate) => {
|
|
115
|
+
try { return fsImpl.statSync(candidate).isFile(); } catch { return false; }
|
|
116
|
+
};
|
|
117
|
+
const extensions = platform === 'win32' && !pathImpl.extname(name)
|
|
118
|
+
? (readEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => ext.trim()).filter(Boolean)
|
|
119
|
+
: [''];
|
|
120
|
+
const names = extensions.map((ext) => `${name}${ext}`);
|
|
121
|
+
if (extensions[0] !== '') names.push(name);
|
|
122
|
+
if (isPathLike(name)) return names.find(isFile) || null;
|
|
123
|
+
const dirs = readEnvValue(env, 'PATH').split(pathImpl.delimiter).map((dir) => dir.trim()).filter(Boolean);
|
|
124
|
+
for (const dir of dirs) {
|
|
125
|
+
for (const candidate of names) {
|
|
126
|
+
const full = pathImpl.join(dir, candidate);
|
|
127
|
+
if (isFile(full)) return full;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
46
133
|
export function resolveOpenAIWireApi(model = '') {
|
|
47
134
|
const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
|
|
48
135
|
if (
|
|
@@ -352,7 +439,7 @@ function resolveNodeWorkerDescriptor(env = {}) {
|
|
|
352
439
|
function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs = []) {
|
|
353
440
|
const nodeWorker = resolveNodeWorkerDescriptor(env);
|
|
354
441
|
if (nodeWorker) {
|
|
355
|
-
const nodeExecutable =
|
|
442
|
+
const nodeExecutable = resolveWorkerNodeExecutable(env);
|
|
356
443
|
return `${shellQuote(nodeExecutable)} ${shellQuote(nodeWorker.scriptPath)} --session-id ${shellQuote(targetSessionId)}`;
|
|
357
444
|
}
|
|
358
445
|
const cliExecutable = normalizeText(env?.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
|
|
@@ -689,7 +776,16 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
689
776
|
if (!value) continue;
|
|
690
777
|
exports.push(`${key}=${shellQuote(value)}`);
|
|
691
778
|
}
|
|
692
|
-
|
|
779
|
+
// `sh -lc` builds PATH from the login profile, not from the relay's; the
|
|
780
|
+
// relay's Node folder goes at its end when it is not on it (see
|
|
781
|
+
// appendNodeDirectoryToPath).
|
|
782
|
+
const statements = exports.map((entry) => `export ${entry};`);
|
|
783
|
+
const nodeDirectory = resolveWorkerNodeDirectory(launchEnv);
|
|
784
|
+
if (nodeDirectory) {
|
|
785
|
+
const quotedDir = shellQuote(nodeDirectory);
|
|
786
|
+
statements.push(`case ":$PATH:" in *:${quotedDir}:*) ;; *) PATH="$PATH:"${quotedDir};; esac; export PATH;`);
|
|
787
|
+
}
|
|
788
|
+
const prefix = statements.length ? `${statements.join(' ')} ` : '';
|
|
693
789
|
const secretPrefix = normalizedSecretEnvFilePath
|
|
694
790
|
? `. ${shellQuote(normalizedSecretEnvFilePath)} || exit $?; rm -f ${shellQuote(normalizedSecretEnvFilePath)}; rmdir ${shellQuote(path.dirname(normalizedSecretEnvFilePath))}; `
|
|
695
791
|
: '';
|
|
@@ -714,6 +810,43 @@ function sleep(ms) {
|
|
|
714
810
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
715
811
|
}
|
|
716
812
|
|
|
813
|
+
/** How a spawned child ended, filled in as it happens. */
|
|
814
|
+
function watchChildExit(child) {
|
|
815
|
+
const state = { tracked: typeof child?.on === 'function', exited: false, code: null, signal: null, error: null };
|
|
816
|
+
if (!state.tracked) return state;
|
|
817
|
+
try {
|
|
818
|
+
// A listener also keeps a failed spawn's 'error' from going unhandled.
|
|
819
|
+
child.on('error', (error) => { state.error = error; });
|
|
820
|
+
child.on('exit', (code, signal) => {
|
|
821
|
+
state.exited = true;
|
|
822
|
+
state.code = code;
|
|
823
|
+
state.signal = signal;
|
|
824
|
+
});
|
|
825
|
+
} catch {
|
|
826
|
+
state.tracked = false;
|
|
827
|
+
}
|
|
828
|
+
return state;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Why `cmd /c start <program>` did not start the worker, or null when nothing
|
|
833
|
+
* says it failed. Exported for the tests.
|
|
834
|
+
*/
|
|
835
|
+
export function describeWindowsStartFailure(state, { spawnCommand = 'cmd.exe', program = 'node' } = {}) {
|
|
836
|
+
if (!state?.tracked) return null;
|
|
837
|
+
if (state.error) {
|
|
838
|
+
return `worker-spawn-failed: could not run ${spawnCommand} (${state.error.code || state.error.message || 'error'})`;
|
|
839
|
+
}
|
|
840
|
+
if (!state.exited) {
|
|
841
|
+
return `worker-spawn-failed: Windows could not start ${program} (start did not return; it may be showing an error dialog)`;
|
|
842
|
+
}
|
|
843
|
+
if (state.code !== 0) {
|
|
844
|
+
const how = state.code === null || state.code === undefined ? `signal ${state.signal || 'unknown'}` : `exit code ${state.code}`;
|
|
845
|
+
return `worker-spawn-failed: Windows could not start ${program} (start ended with ${how})`;
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
|
|
717
850
|
function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
|
|
718
851
|
const launchProcessCwd = String(processCwd || '').trim();
|
|
719
852
|
const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
|
|
@@ -750,6 +883,9 @@ export async function launchSessionCli({
|
|
|
750
883
|
tmuxPollDelayMs = 200,
|
|
751
884
|
detachedPollAttempts = 10,
|
|
752
885
|
detachedPollDelayMs = 200,
|
|
886
|
+
// win32: how much longer a `start` still running after the polls gets to
|
|
887
|
+
// return before the launch counts as failed (a busy boot can be slow).
|
|
888
|
+
windowsStartGraceMs = 3_000,
|
|
753
889
|
allowProcessReuse = true,
|
|
754
890
|
createSecretEnvFileImpl = createWorkerSecretEnvFile,
|
|
755
891
|
// Extension engine: writes the session's OAR MCP config, returns its path
|
|
@@ -761,6 +897,9 @@ export async function launchSessionCli({
|
|
|
761
897
|
// Told when the process list could not be read (the error); the launch
|
|
762
898
|
// goes on without it.
|
|
763
899
|
onProcessListError = null,
|
|
900
|
+
// Where a launch that went wrong explains itself (one line per call).
|
|
901
|
+
log = null,
|
|
902
|
+
locateExecutableImpl = locateExecutable,
|
|
764
903
|
} = {}) {
|
|
765
904
|
const target = String(targetSessionId || '').trim();
|
|
766
905
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -809,9 +948,13 @@ export async function launchSessionCli({
|
|
|
809
948
|
env,
|
|
810
949
|
});
|
|
811
950
|
const launchSessionEnv = {
|
|
812
|
-
...launchEnv,
|
|
951
|
+
...appendNodeDirectoryToPath(launchEnv, resolveWorkerNodeDirectory(launchEnv, { platform }), { platform }),
|
|
813
952
|
SESSION_ID: target,
|
|
814
953
|
};
|
|
954
|
+
const explain = (message) => {
|
|
955
|
+
try { log?.(`worker launcher: session=${target.slice(0, 8)} ${message}`); } catch {}
|
|
956
|
+
};
|
|
957
|
+
const spawnEnvironmentText = () => describeSpawnEnvironment(launchSessionEnv, platform);
|
|
815
958
|
// Set per launch below, never inherited: a relay started from inside a
|
|
816
959
|
// worker session would otherwise hand its workers that session's log.
|
|
817
960
|
delete launchSessionEnv[WORKER_LOG_FILE_ENV];
|
|
@@ -824,6 +967,17 @@ export async function launchSessionCli({
|
|
|
824
967
|
// session, so its prompt context need not teach the HTTP fallback.
|
|
825
968
|
if (extensionCliArgs.length) launchSessionEnv.OAR_MCP_SERVER_ATTACHED = '1';
|
|
826
969
|
|
|
970
|
+
// A Node that is not there fails loudly here instead of quietly later (on
|
|
971
|
+
// Windows, `start` shows its error in a dialog no one may be able to see).
|
|
972
|
+
if (resolveNodeWorkerDescriptor(launchSessionEnv)) {
|
|
973
|
+
const nodeExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
|
|
974
|
+
if (!locateExecutableImpl(nodeExecutable, { env: launchSessionEnv, platform })) {
|
|
975
|
+
const overridden = Boolean(normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_NODE));
|
|
976
|
+
explain(`cannot start: Node "${nodeExecutable}"${overridden ? ' (COPILOT_WEB_RELAY_NODE)' : ''} was not found; ${spawnEnvironmentText()}`);
|
|
977
|
+
throw new Error(`worker-node-not-found: ${nodeExecutable}${overridden ? ' (set by COPILOT_WEB_RELAY_NODE)' : ''}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
827
981
|
if (isTmuxAvailable({ platform, execFileSyncImpl })) {
|
|
828
982
|
const sessionName = normalizeTmuxSessionName(target);
|
|
829
983
|
const tmuxEnv = { ...launchSessionEnv };
|
|
@@ -897,14 +1051,20 @@ export async function launchSessionCli({
|
|
|
897
1051
|
}
|
|
898
1052
|
|
|
899
1053
|
const nodeWorker = resolveNodeWorkerDescriptor(launchSessionEnv);
|
|
900
|
-
const nodeWorkerExecutable =
|
|
1054
|
+
const nodeWorkerExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
|
|
901
1055
|
const posixCliExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
|
|
902
1056
|
|| normalizeText(launchSessionEnv.COPILOT_CLI_EXECUTABLE)
|
|
903
1057
|
|| normalizeText(launchSessionEnv.COPILOT_CLI_PATH)
|
|
904
1058
|
|| 'copilot';
|
|
1059
|
+
// win32: Node quotes every argument that holds a space or a tab, so
|
|
1060
|
+
// `start "<title>" "C:\Program Files\nodejs\node.exe" ...` reaches cmd.exe
|
|
1061
|
+
// with the title and the path each in one piece; `/s` strips nothing here
|
|
1062
|
+
// because the line after `/c` starts with `start`, not a quote. The title
|
|
1063
|
+
// always holds a space, so `start` never mistakes the program for it.
|
|
905
1064
|
const spawnCommand = platform === 'win32'
|
|
906
|
-
? (
|
|
1065
|
+
? windowsCmdPath({ env: launchSessionEnv })
|
|
907
1066
|
: (nodeWorker ? nodeWorkerExecutable : posixCliExecutable);
|
|
1067
|
+
const startedProgram = nodeWorker ? nodeWorkerExecutable : (platform === 'win32' ? 'gh' : posixCliExecutable);
|
|
908
1068
|
const spawnArgs = platform === 'win32'
|
|
909
1069
|
? (nodeWorker
|
|
910
1070
|
? [
|
|
@@ -964,6 +1124,7 @@ export async function launchSessionCli({
|
|
|
964
1124
|
stdio: detachedStdio,
|
|
965
1125
|
windowsHide: platform !== 'win32',
|
|
966
1126
|
});
|
|
1127
|
+
const childState = watchChildExit(child);
|
|
967
1128
|
child.unref?.();
|
|
968
1129
|
if (Array.isArray(detachedStdio) && typeof detachedStdio[1] === 'number') {
|
|
969
1130
|
// The child inherited the descriptor; the parent's copy must not leak.
|
|
@@ -984,7 +1145,24 @@ export async function launchSessionCli({
|
|
|
984
1145
|
child,
|
|
985
1146
|
};
|
|
986
1147
|
}
|
|
1148
|
+
if (childState.error || (childState.exited && childState.code !== 0)) break;
|
|
1149
|
+
}
|
|
1150
|
+
const graceUntil = Date.now() + Math.max(0, Number(windowsStartGraceMs) || 0);
|
|
1151
|
+
while (childState.tracked && !childState.exited && !childState.error && Date.now() < graceUntil) {
|
|
1152
|
+
await sleep(50);
|
|
1153
|
+
}
|
|
1154
|
+
// `cmd /c start` returns at once when the program started. Still running,
|
|
1155
|
+
// or ended with an error, means it did not: name the program, and log the
|
|
1156
|
+
// command and the PATH it was looked up on.
|
|
1157
|
+
const failure = describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
|
|
1158
|
+
if (failure) {
|
|
1159
|
+
if (childState.tracked && !childState.exited) {
|
|
1160
|
+
try { child.kill?.(); } catch {}
|
|
1161
|
+
}
|
|
1162
|
+
explain(`${failure}; command: ${spawnCommand} ${spawnArgs.join(' ')}; ${spawnEnvironmentText()}`);
|
|
1163
|
+
throw new Error(failure);
|
|
987
1164
|
}
|
|
1165
|
+
explain(`no worker process seen after ${attempts} check${attempts === 1 ? '' : 's'}; it may still report in. program: ${startedProgram}; ${spawnEnvironmentText()}`);
|
|
988
1166
|
return {
|
|
989
1167
|
pid: null,
|
|
990
1168
|
reused: false,
|
|
@@ -994,7 +1172,13 @@ export async function launchSessionCli({
|
|
|
994
1172
|
};
|
|
995
1173
|
}
|
|
996
1174
|
const pid = parsePositiveInt(child?.pid);
|
|
997
|
-
if (!pid)
|
|
1175
|
+
if (!pid) {
|
|
1176
|
+
// A failed spawn reports its error on the next tick.
|
|
1177
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1178
|
+
const reason = childState.error?.code || childState.error?.message || '';
|
|
1179
|
+
explain(`could not start ${spawnCommand}${reason ? ` (${reason})` : ''}; ${spawnEnvironmentText()}`);
|
|
1180
|
+
throw new Error(`worker-spawn-unhealthy:missing-pid${reason ? ` (${spawnCommand}: ${reason})` : ''}`);
|
|
1181
|
+
}
|
|
998
1182
|
return {
|
|
999
1183
|
pid,
|
|
1000
1184
|
reused: false,
|