@oar-sh/oar 0.9.4 → 0.9.6
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/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// What a runtime leaves behind when it goes, and how that is stopped.
|
|
2
|
+
//
|
|
3
|
+
// A session worker starts its agent runtime as a child process, and the
|
|
4
|
+
// runtime starts the agent's commands as ITS children. A runtime that ends in
|
|
5
|
+
// good order ends them too. One that is killed does not: the command runs on
|
|
6
|
+
// with nobody left to read its result. Measured on Linux with the Copilot
|
|
7
|
+
// runtime 1.0.89 (2026-09-29, a scripted provider, the command
|
|
8
|
+
// `python3 -c 'import time; time.sleep(170)'`):
|
|
9
|
+
//
|
|
10
|
+
// - `session.disconnect()` + `client.stop()`: the runtime ended its commands
|
|
11
|
+
// itself, none was left (a command started with `detach: true` is the
|
|
12
|
+
// exception, it is meant to outlive the session and did);
|
|
13
|
+
// - SIGKILL, and the SDK's `forceStop()` which is a SIGKILL: every command
|
|
14
|
+
// ran on, re-parented to pid 1. From that moment nothing in the process
|
|
15
|
+
// table says whose it was;
|
|
16
|
+
// - the runtime is no group leader (it shares the worker's process group),
|
|
17
|
+
// and it starts every command in a session of its own (`setsid`), so there
|
|
18
|
+
// is no process group that holds the runtime and its commands and could be
|
|
19
|
+
// signalled as one;
|
|
20
|
+
// - a command is in the process table 20 to 60 ms after its
|
|
21
|
+
// `tool.execution_start`.
|
|
22
|
+
//
|
|
23
|
+
// So the tree is read while the runtime lives and kept: every process with the
|
|
24
|
+
// time it was started, which is what tells a process from a later one that was
|
|
25
|
+
// given the same pid. Stopping walks that list, checks each entry against the
|
|
26
|
+
// process table of the moment, and signals only what is still the same
|
|
27
|
+
// process.
|
|
28
|
+
//
|
|
29
|
+
// Windows keeps the pid of a parent in its children after the parent has
|
|
30
|
+
// ended, so there the tree can still be read afterwards (`orphansKeepParent`).
|
|
31
|
+
// It also hands the pid of an ended process out again, which is why a child
|
|
32
|
+
// older than its parent is no child of it (`isChildProcessOf`).
|
|
33
|
+
//
|
|
34
|
+
// Nothing here reads the platform or the process table itself: the lister and
|
|
35
|
+
// the signal function are passed in.
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_TREE_POLL_MS = 2_000;
|
|
38
|
+
/**
|
|
39
|
+
* When the tree is read after a command's start event: once as soon as the
|
|
40
|
+
* command can be there, and once more for one that took its time. A runtime
|
|
41
|
+
* that is killed before the first look leaves a command nobody knows of.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_TREE_COMMAND_LOOKS_MS = Object.freeze([80, 400]);
|
|
44
|
+
/** How long a process is given to end on SIGTERM before it is killed. */
|
|
45
|
+
export const DEFAULT_TREE_STOP_GRACE_MS = 3_000;
|
|
46
|
+
/** How long the result waits for a killed process to be gone. */
|
|
47
|
+
export const DEFAULT_TREE_KILL_WAIT_MS = 1_000;
|
|
48
|
+
export const DEFAULT_TREE_EXIT_POLL_MS = 100;
|
|
49
|
+
|
|
50
|
+
function toPid(value) {
|
|
51
|
+
const pid = Number(value);
|
|
52
|
+
return Number.isInteger(pid) && pid > 0 ? pid : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeProcess(proc) {
|
|
56
|
+
const processId = toPid(proc?.processId);
|
|
57
|
+
if (!processId) return null;
|
|
58
|
+
return {
|
|
59
|
+
...proc,
|
|
60
|
+
processId,
|
|
61
|
+
parentProcessId: toPid(proc?.parentProcessId),
|
|
62
|
+
createdAt: Number(proc?.createdAt) > 0 ? Number(proc.createdAt) : 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is `child` a process `parent` started? Windows keeps the parent's pid in a
|
|
68
|
+
* process for good and hands the pid of an ended process out again: a process
|
|
69
|
+
* whose creator is long gone can so appear as the child of a process that has
|
|
70
|
+
* nothing to do with it. A process older than its parent is no child of it.
|
|
71
|
+
* Where a start time is not known, the pid decides.
|
|
72
|
+
*/
|
|
73
|
+
export function isChildProcessOf(child, parent) {
|
|
74
|
+
if (!child || !parent) return false;
|
|
75
|
+
if (!(child.createdAt > 0) || !(parent.createdAt > 0)) return true;
|
|
76
|
+
return child.createdAt >= parent.createdAt;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The processes below `root` in a process list, a parent before its children.
|
|
81
|
+
* `root` is an entry of its own (`processId`, `createdAt`) and need not be in
|
|
82
|
+
* the list: an ended parent can still be walked from where its children kept
|
|
83
|
+
* its pid. `accept(child, parent)` leaves a process, and with it everything it
|
|
84
|
+
* started, out.
|
|
85
|
+
*/
|
|
86
|
+
export function collectProcessDescendants(processes, root, { accept = () => true } = {}) {
|
|
87
|
+
const rootPid = toPid(root?.processId);
|
|
88
|
+
if (!rootPid) return [];
|
|
89
|
+
const childrenByParent = new Map();
|
|
90
|
+
for (const proc of Array.isArray(processes) ? processes : []) {
|
|
91
|
+
const parentPid = toPid(proc?.parentProcessId);
|
|
92
|
+
if (!parentPid || !toPid(proc?.processId)) continue;
|
|
93
|
+
const children = childrenByParent.get(parentPid) || [];
|
|
94
|
+
children.push(proc);
|
|
95
|
+
childrenByParent.set(parentPid, children);
|
|
96
|
+
}
|
|
97
|
+
const seen = new Set([rootPid]);
|
|
98
|
+
const found = [];
|
|
99
|
+
const walk = (parent) => {
|
|
100
|
+
for (const child of childrenByParent.get(toPid(parent.processId)) || []) {
|
|
101
|
+
const pid = toPid(child.processId);
|
|
102
|
+
if (seen.has(pid)) continue;
|
|
103
|
+
if (!isChildProcessOf(child, parent)) continue;
|
|
104
|
+
if (!accept(child, parent)) continue;
|
|
105
|
+
seen.add(pid);
|
|
106
|
+
found.push(child);
|
|
107
|
+
walk(child);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(root);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The same process, not a later one with its pid. An unknown start time proves nothing. */
|
|
115
|
+
function isSameProcess(known, live) {
|
|
116
|
+
if (!known || !live) return false;
|
|
117
|
+
if (toPid(known.processId) !== toPid(live.processId)) return false;
|
|
118
|
+
return known.createdAt > 0 && known.createdAt === live.createdAt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isThenable(value) {
|
|
122
|
+
return !!value && typeof value.then === 'function';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The process tree of one runtime.
|
|
127
|
+
*
|
|
128
|
+
* `lister` reads the process table: `list()` returns every process as
|
|
129
|
+
* `{ processId, parentProcessId, createdAt, ... }` (or a promise of that),
|
|
130
|
+
* `read(pid)` one of them where that is cheap, `describe(pid)` its command
|
|
131
|
+
* line where the list does not carry it. `signal(pid, name)` is
|
|
132
|
+
* `process.kill`. `protectedPids` are never signalled whatever the list says:
|
|
133
|
+
* the worker itself and the relay above it.
|
|
134
|
+
*
|
|
135
|
+
* `keepSubtree(process)` names a child of the runtime that is left alone with
|
|
136
|
+
* everything it started (a command the user asked to outlive the session).
|
|
137
|
+
*
|
|
138
|
+
* `isBusy()` says whether the runtime can have commands at all right now; an
|
|
139
|
+
* idle runtime is not read.
|
|
140
|
+
*/
|
|
141
|
+
export function createProcessTreeWatch({
|
|
142
|
+
rootPid,
|
|
143
|
+
lister,
|
|
144
|
+
signal,
|
|
145
|
+
protectedPids = [],
|
|
146
|
+
keepSubtree = () => false,
|
|
147
|
+
isBusy = () => true,
|
|
148
|
+
pollMs = DEFAULT_TREE_POLL_MS,
|
|
149
|
+
commandLooksMs = DEFAULT_TREE_COMMAND_LOOKS_MS,
|
|
150
|
+
stopGraceMs = DEFAULT_TREE_STOP_GRACE_MS,
|
|
151
|
+
killWaitMs = DEFAULT_TREE_KILL_WAIT_MS,
|
|
152
|
+
exitPollMs = DEFAULT_TREE_EXIT_POLL_MS,
|
|
153
|
+
now = Date.now,
|
|
154
|
+
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); }),
|
|
155
|
+
label = 'runtime',
|
|
156
|
+
dbg = () => {},
|
|
157
|
+
} = {}) {
|
|
158
|
+
const runtimePid = toPid(rootPid);
|
|
159
|
+
const orphansKeepParent = lister?.orphansKeepParent === true;
|
|
160
|
+
const guarded = new Set([1, runtimePid, ...protectedPids.map(toPid)].filter(Boolean));
|
|
161
|
+
// The runtime as it was first seen. Its start time is what tells it from a
|
|
162
|
+
// later process with its pid.
|
|
163
|
+
let root = null;
|
|
164
|
+
// Whether the last list read still had it.
|
|
165
|
+
let rootEnded = false;
|
|
166
|
+
// The last tree read: pid → process, a parent before its children.
|
|
167
|
+
let members = new Map();
|
|
168
|
+
let kept = 0;
|
|
169
|
+
let lastLoggedCount = -1;
|
|
170
|
+
let pollTimer = null;
|
|
171
|
+
const lookTimers = new Set();
|
|
172
|
+
let closed = false;
|
|
173
|
+
let stopping = null;
|
|
174
|
+
let latest = Promise.resolve({ count: 0, commands: 0 });
|
|
175
|
+
|
|
176
|
+
function commandLineOf(proc) {
|
|
177
|
+
if (typeof proc?.commandLine === 'string') return proc.commandLine;
|
|
178
|
+
if (typeof lister.describe !== 'function') return '';
|
|
179
|
+
try { return String(lister.describe(proc.processId) || ''); } catch { return ''; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The children of the runtime that are commands, as far as the list can tell. */
|
|
183
|
+
function commandCount() {
|
|
184
|
+
let count = 0;
|
|
185
|
+
for (const proc of members.values()) {
|
|
186
|
+
if (proc.parentProcessId !== runtimePid) continue;
|
|
187
|
+
// The runtime starts a command in a session of its own; what it starts
|
|
188
|
+
// inside its own session is a helper of the runtime. Where the list
|
|
189
|
+
// does not know sessions, every child counts.
|
|
190
|
+
if (proc.sessionLeader === false) continue;
|
|
191
|
+
count += 1;
|
|
192
|
+
}
|
|
193
|
+
return count;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function summary() {
|
|
197
|
+
return { count: members.size, commands: commandCount() };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Take a process list in. Keeps the last tree where this one cannot tell. */
|
|
201
|
+
function apply(list) {
|
|
202
|
+
const entries = (Array.isArray(list) ? list : []).map(normalizeProcess).filter(Boolean);
|
|
203
|
+
const live = entries.find((proc) => proc.processId === runtimePid) || null;
|
|
204
|
+
let walkFrom = null;
|
|
205
|
+
let replacedAt = 0;
|
|
206
|
+
if (live && !root) {
|
|
207
|
+
if (live.createdAt > 0) root = { processId: runtimePid, createdAt: live.createdAt };
|
|
208
|
+
walkFrom = root;
|
|
209
|
+
} else if (live && isSameProcess(root, live)) {
|
|
210
|
+
walkFrom = root;
|
|
211
|
+
} else if (root && orphansKeepParent) {
|
|
212
|
+
// The runtime has ended and its children still name it. A process that
|
|
213
|
+
// was given its pid since has children of its own: they are younger
|
|
214
|
+
// than it.
|
|
215
|
+
walkFrom = root;
|
|
216
|
+
replacedAt = live?.createdAt || 0;
|
|
217
|
+
}
|
|
218
|
+
rootEnded = !!root && !isSameProcess(root, live);
|
|
219
|
+
if (!walkFrom) {
|
|
220
|
+
// The runtime has ended and nothing names it any more. What is known
|
|
221
|
+
// is the last tree; what has ended since is no longer part of it.
|
|
222
|
+
for (const [pid, known] of members) {
|
|
223
|
+
if (!isSameProcess(known, entries.find((proc) => proc.processId === pid))) members.delete(pid);
|
|
224
|
+
}
|
|
225
|
+
return entries;
|
|
226
|
+
}
|
|
227
|
+
kept = 0;
|
|
228
|
+
const found = collectProcessDescendants(entries, walkFrom, {
|
|
229
|
+
accept: (child, parent) => {
|
|
230
|
+
if (parent !== walkFrom) return true;
|
|
231
|
+
if (replacedAt > 0 && !(child.createdAt > 0 && child.createdAt < replacedAt)) return false;
|
|
232
|
+
if (!keepSubtree({ ...child, commandLine: commandLineOf(child) })) return true;
|
|
233
|
+
kept += 1;
|
|
234
|
+
return false;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
members = new Map(found.map((proc) => [proc.processId, proc]));
|
|
238
|
+
if (members.size !== lastLoggedCount) {
|
|
239
|
+
lastLoggedCount = members.size;
|
|
240
|
+
dbg(
|
|
241
|
+
`${label} process tree read: ${members.size} process${members.size === 1 ? '' : 'es'} below pid ${runtimePid}`
|
|
242
|
+
+ `${kept ? ` (${kept} detached command${kept === 1 ? '' : 's'} left out)` : ''}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return entries;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The process list as a promise, whatever the lister does. */
|
|
249
|
+
async function readList() {
|
|
250
|
+
return lister.list();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read the tree now. With a lister that answers at once (Linux reads
|
|
255
|
+
* `/proc`) the tree is current when this returns, which the caller relies on
|
|
256
|
+
* when the runtime is about to be stopped. Never throws, never rejects.
|
|
257
|
+
*/
|
|
258
|
+
function snapshot() {
|
|
259
|
+
if (!runtimePid || closed || stopping) return Promise.resolve(summary());
|
|
260
|
+
const failed = (error) => {
|
|
261
|
+
dbg(`${label} process tree could not be read`, error?.message || String(error));
|
|
262
|
+
return summary();
|
|
263
|
+
};
|
|
264
|
+
try {
|
|
265
|
+
const listed = lister.list();
|
|
266
|
+
if (isThenable(listed)) {
|
|
267
|
+
latest = Promise.resolve(listed)
|
|
268
|
+
.then((list) => { if (!closed) apply(list); return summary(); })
|
|
269
|
+
.catch(failed);
|
|
270
|
+
} else {
|
|
271
|
+
apply(listed);
|
|
272
|
+
latest = Promise.resolve(summary());
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
latest = Promise.resolve(failed(error));
|
|
276
|
+
}
|
|
277
|
+
return latest;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stopTimers() {
|
|
281
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
282
|
+
for (const timer of lookTimers) clearTimeout(timer);
|
|
283
|
+
pollTimer = null;
|
|
284
|
+
lookTimers.clear();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A command is starting: look once it is there. */
|
|
288
|
+
function commandStarting() {
|
|
289
|
+
if (closed || stopping || orphansKeepParent || lookTimers.size || !runtimePid) return;
|
|
290
|
+
for (const waitMs of commandLooksMs) {
|
|
291
|
+
const timer = setTimeout(() => {
|
|
292
|
+
lookTimers.delete(timer);
|
|
293
|
+
void snapshot();
|
|
294
|
+
}, waitMs);
|
|
295
|
+
timer.unref?.();
|
|
296
|
+
lookTimers.add(timer);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function readOne(pid, entries) {
|
|
301
|
+
if (typeof lister.read === 'function') {
|
|
302
|
+
try { return normalizeProcess(lister.read(pid)); } catch { return null; }
|
|
303
|
+
}
|
|
304
|
+
return entries.find((proc) => proc.processId === pid) || null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function send(target, name) {
|
|
308
|
+
try {
|
|
309
|
+
signal(target.processId, name);
|
|
310
|
+
return true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// ESRCH: it ended by itself meanwhile, which is what was wanted.
|
|
313
|
+
if (error?.code !== 'ESRCH') dbg(`${label} process ${target.processId} could not be signalled (${name})`, error?.message || String(error));
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isGone(target) {
|
|
319
|
+
if (typeof lister.read === 'function') {
|
|
320
|
+
let live = null;
|
|
321
|
+
try { live = normalizeProcess(lister.read(target.processId)); } catch { live = null; }
|
|
322
|
+
return !isSameProcess(target, live);
|
|
323
|
+
}
|
|
324
|
+
// No cheap read: ask whether the pid exists. A pid that was handed out
|
|
325
|
+
// again reads as alive here and is refused before the kill.
|
|
326
|
+
try {
|
|
327
|
+
signal(target.processId, 0);
|
|
328
|
+
return false;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return error?.code !== 'EPERM';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function waitUntilGone(targets, waitMs) {
|
|
335
|
+
const deadline = now() + Math.max(0, waitMs);
|
|
336
|
+
const step = Math.max(1, exitPollMs);
|
|
337
|
+
// Bounded by the number of looks as well: a clock that stands still must
|
|
338
|
+
// not keep the teardown waiting for ever.
|
|
339
|
+
let looks = Math.ceil(Math.max(0, waitMs) / step);
|
|
340
|
+
let left = targets.filter((target) => !isGone(target));
|
|
341
|
+
while (left.length && looks > 0 && now() < deadline) {
|
|
342
|
+
looks -= 1;
|
|
343
|
+
await sleep(Math.min(step, Math.max(1, deadline - now())));
|
|
344
|
+
left = left.filter((target) => !isGone(target));
|
|
345
|
+
}
|
|
346
|
+
return left;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function stopTree(reason) {
|
|
350
|
+
const result = { reason, found: 0, ended: [], killed: [], left: [], refused: [] };
|
|
351
|
+
// The tree as it was last read, and below what this read adds to it.
|
|
352
|
+
const known = [...members.values()];
|
|
353
|
+
let entries = [];
|
|
354
|
+
try {
|
|
355
|
+
entries = apply(await readList());
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Without a process list nothing can be checked, so nothing is signalled.
|
|
358
|
+
dbg(`${label} process tree not stopped (${reason}): the process list could not be read`, error?.message || String(error));
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
const targets = new Map();
|
|
362
|
+
const consider = (proc) => {
|
|
363
|
+
const pid = proc.processId;
|
|
364
|
+
if (targets.has(pid) || guarded.has(pid)) return false;
|
|
365
|
+
const live = readOne(pid, entries);
|
|
366
|
+
if (!live) return false;
|
|
367
|
+
if (!isSameProcess(proc, live)) {
|
|
368
|
+
if (!result.refused.includes(pid)) result.refused.push(pid);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
targets.set(pid, proc);
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
374
|
+
for (const proc of [...known, ...members.values()]) {
|
|
375
|
+
if (!consider(proc)) continue;
|
|
376
|
+
// What it has started since the tree was last read.
|
|
377
|
+
for (const child of collectProcessDescendants(entries, proc)) consider(child);
|
|
378
|
+
}
|
|
379
|
+
result.found = targets.size;
|
|
380
|
+
if (result.refused.length) {
|
|
381
|
+
dbg(`${label} process tree (${reason}): pid ${result.refused.join(', ')} now belongs to another process; left alone`);
|
|
382
|
+
}
|
|
383
|
+
if (!targets.size) {
|
|
384
|
+
dbg(`${label} process tree (${reason}): nothing left to stop`);
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
const list = [...targets.values()];
|
|
388
|
+
dbg(`stopping what the ${label} left running (${reason}): pid ${list.map((target) => target.processId).join(', ')}`);
|
|
389
|
+
// A parent before its children: a shell that is told first does not go on
|
|
390
|
+
// to its next command when the one it waits for ends.
|
|
391
|
+
for (const target of list) send(target, 'SIGTERM');
|
|
392
|
+
const stubborn = await waitUntilGone(list, stopGraceMs);
|
|
393
|
+
const stubbornPids = new Set(stubborn.map((target) => target.processId));
|
|
394
|
+
result.ended = list.filter((target) => !stubbornPids.has(target.processId)).map((target) => target.processId);
|
|
395
|
+
if (stubborn.length) {
|
|
396
|
+
let fresh = [];
|
|
397
|
+
try {
|
|
398
|
+
fresh = (await readList()).map(normalizeProcess).filter(Boolean);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
dbg(`${label} process list could not be read before the kill`, error?.message || String(error));
|
|
401
|
+
}
|
|
402
|
+
const toKill = new Map();
|
|
403
|
+
for (const target of stubborn) {
|
|
404
|
+
if (!isSameProcess(target, readOne(target.processId, fresh))) {
|
|
405
|
+
result.ended.push(target.processId);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
toKill.set(target.processId, target);
|
|
409
|
+
// A process that ignores SIGTERM goes on starting others.
|
|
410
|
+
for (const child of collectProcessDescendants(fresh, target)) {
|
|
411
|
+
if (!toKill.has(child.processId) && !guarded.has(child.processId) && child.createdAt > 0) {
|
|
412
|
+
toKill.set(child.processId, child);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const kills = [...toKill.values()];
|
|
417
|
+
for (const target of kills) send(target, 'SIGKILL');
|
|
418
|
+
const left = await waitUntilGone(kills, killWaitMs);
|
|
419
|
+
const leftPids = new Set(left.map((target) => target.processId));
|
|
420
|
+
result.killed = kills.filter((target) => !leftPids.has(target.processId)).map((target) => target.processId);
|
|
421
|
+
result.left = [...leftPids];
|
|
422
|
+
}
|
|
423
|
+
dbg(
|
|
424
|
+
`${label} process tree stopped (${reason}): ${result.ended.length} ended on SIGTERM, `
|
|
425
|
+
+ `${result.killed.length} killed, ${result.left.length} still running`
|
|
426
|
+
+ `${result.left.length ? ` (pid ${result.left.join(', ')})` : ''}`,
|
|
427
|
+
);
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Stop what is left of the tree: SIGTERM, a bounded wait, SIGKILL for what
|
|
433
|
+
* is still there. One stop per tree; the watch is over afterwards. Never
|
|
434
|
+
* rejects.
|
|
435
|
+
*/
|
|
436
|
+
function stop(reason = 'stop') {
|
|
437
|
+
if (stopping) return stopping;
|
|
438
|
+
stopTimers();
|
|
439
|
+
if (!runtimePid) return Promise.resolve({ reason, found: 0, ended: [], killed: [], left: [], refused: [] });
|
|
440
|
+
stopping = stopTree(reason)
|
|
441
|
+
.catch((error) => {
|
|
442
|
+
dbg(`${label} process tree stop failed (${reason})`, error?.message || String(error));
|
|
443
|
+
return { reason, found: 0, ended: [], killed: [], left: [], refused: [] };
|
|
444
|
+
})
|
|
445
|
+
.finally(() => { closed = true; });
|
|
446
|
+
return stopping;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The runtime ended in good order, or its commands are none of our business. */
|
|
450
|
+
function close() {
|
|
451
|
+
closed = true;
|
|
452
|
+
stopTimers();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (runtimePid) {
|
|
456
|
+
void snapshot();
|
|
457
|
+
// Where the children keep the pid of an ended parent the tree is read
|
|
458
|
+
// when it is needed, and reading it costs a process there.
|
|
459
|
+
if (!orphansKeepParent && pollMs > 0) {
|
|
460
|
+
pollTimer = setInterval(() => {
|
|
461
|
+
if (isBusy()) void snapshot();
|
|
462
|
+
}, pollMs);
|
|
463
|
+
pollTimer.unref?.();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
rootPid: runtimePid,
|
|
469
|
+
snapshot,
|
|
470
|
+
/** The read in flight, or the last one. */
|
|
471
|
+
latest: () => latest,
|
|
472
|
+
/**
|
|
473
|
+
* Whether the runtime had ended when the list was last read. False where
|
|
474
|
+
* that is not known: a runtime that was never seen is not taken for dead.
|
|
475
|
+
*/
|
|
476
|
+
runtimeEnded: () => latest.then(() => rootEnded, () => false),
|
|
477
|
+
commandStarting,
|
|
478
|
+
members: () => [...members.values()],
|
|
479
|
+
commandCount,
|
|
480
|
+
stop,
|
|
481
|
+
close,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What a failed request to the relay says about the relay.
|
|
2
|
+
//
|
|
3
|
+
// A worker talks to its relay over HTTP on the same host. Requests fail when
|
|
4
|
+
// the relay restarts, when a connection is reset, and when a proxy in front of
|
|
5
|
+
// it answers in its place; none of that says anything about the thing that
|
|
6
|
+
// was asked. A 4xx is the relay's own answer and does.
|
|
7
|
+
|
|
8
|
+
const UNREACHABLE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN']);
|
|
9
|
+
|
|
10
|
+
function errorCode(error) {
|
|
11
|
+
return String(error?.cause?.code || error?.code || '').trim().toUpperCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The request did not get through, or something between here and the relay
|
|
16
|
+
* answered for it: worth asking again. A request the caller aborted itself
|
|
17
|
+
* (`AbortError`) is not; one that ran into its time limit (`TimeoutError`) is.
|
|
18
|
+
*/
|
|
19
|
+
export function isTransientRelayError(error) {
|
|
20
|
+
if (error?.name === 'AbortError') return false;
|
|
21
|
+
const status = Number(error?.status);
|
|
22
|
+
if (!Number.isFinite(status) || status <= 0) return true;
|
|
23
|
+
return status >= 500 || status === 408 || status === 429;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The request provably never reached the relay, so sending it again cannot do it twice. */
|
|
27
|
+
export function isRelayUnreachableError(error) {
|
|
28
|
+
const status = Number(error?.status);
|
|
29
|
+
if (Number.isFinite(status) && status > 0) return false;
|
|
30
|
+
return UNREACHABLE_CODES.has(errorCode(error));
|
|
31
|
+
}
|
|
@@ -71,21 +71,21 @@ function classifyTerminalError(text, hint = {}) {
|
|
|
71
71
|
return {
|
|
72
72
|
code: "missing-tool-output",
|
|
73
73
|
message: "No tool output was returned for a required function call.",
|
|
74
|
-
guidance: "
|
|
74
|
+
guidance: "Send the message again to retry. If this keeps happening, include the error code when you report it.",
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
if (lower.includes("tool call") && (lower.includes("not found") || lower.includes("missing"))) {
|
|
78
78
|
return {
|
|
79
79
|
code: "tool-call-missing",
|
|
80
80
|
message: "A required tool call was missing in the runtime response.",
|
|
81
|
-
guidance: "
|
|
81
|
+
guidance: "Send the message again to retry. If it repeats, include the error code when you report it.",
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
if (lower.includes("tool output") && lower.includes("invalid")) {
|
|
85
85
|
return {
|
|
86
86
|
code: "invalid-tool-output",
|
|
87
87
|
message: "Tool output returned from the runtime was invalid.",
|
|
88
|
-
guidance: "
|
|
88
|
+
guidance: "Send the message again to retry. If it repeats, include the error code when you report it.",
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
// Monthly/plan quota exhaustion is not transient: retrying before the
|
|
@@ -103,7 +103,7 @@ function classifyTerminalError(text, hint = {}) {
|
|
|
103
103
|
return {
|
|
104
104
|
code: "request-invalid",
|
|
105
105
|
message: "The runtime rejected the request as invalid and non-retryable.",
|
|
106
|
-
guidance: "Retry after adjusting the request. If it persists,
|
|
106
|
+
guidance: "Retry after adjusting the request. If it persists, include the error code when you report it.",
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|