@twelvehart/orcats 0.2.2 → 0.3.0
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/README.md +3 -3
- package/dist/backends/acp-client.d.ts +1 -1
- package/dist/backends/acp-client.d.ts.map +1 -1
- package/dist/backends/acp-run.d.ts.map +1 -1
- package/dist/backends/claude-run.d.ts.map +1 -1
- package/dist/backends/codex-jsonl.d.ts +2 -1
- package/dist/backends/codex-jsonl.d.ts.map +1 -1
- package/dist/backends/codex-run.d.ts +2 -1
- package/dist/backends/codex-run.d.ts.map +1 -1
- package/dist/backends/codex.d.ts.map +1 -1
- package/dist/backends/pi-run.d.ts.map +1 -1
- package/dist/backends/select.d.ts +1 -1
- package/dist/backends/select.d.ts.map +1 -1
- package/dist/backends/subprocess-run.d.ts +4 -5
- package/dist/backends/subprocess-run.d.ts.map +1 -1
- package/dist/backends/subprocess-termination.d.ts +4 -0
- package/dist/backends/subprocess-termination.d.ts.map +1 -0
- package/dist/baseline/index.d.ts +3 -1
- package/dist/baseline/index.d.ts.map +1 -1
- package/dist/cli/version.d.ts +1 -1
- package/dist/conversation/conversation.d.ts +2 -0
- package/dist/conversation/conversation.d.ts.map +1 -1
- package/dist/conversation/settlement-reservation.d.ts +10 -0
- package/dist/conversation/settlement-reservation.d.ts.map +1 -0
- package/dist/model/backend-config.d.ts +2 -0
- package/dist/model/backend-config.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/backends/acp-client.ts +12 -10
- package/src/backends/acp-run.ts +18 -18
- package/src/backends/claude-run.ts +7 -5
- package/src/backends/codex-jsonl.ts +5 -0
- package/src/backends/codex-run.ts +15 -1
- package/src/backends/codex.ts +4 -1
- package/src/backends/pi-run.ts +8 -2
- package/src/backends/select.ts +4 -1
- package/src/backends/subprocess-run.ts +534 -58
- package/src/backends/subprocess-termination.ts +56 -0
- package/src/baseline/index.ts +4 -2
- package/src/cli/embedded.ts +19 -0
- package/src/cli/main.ts +6 -3
- package/src/cli/version.ts +1 -1
- package/src/conversation/conversation.ts +92 -7
- package/src/conversation/settlement-reservation.ts +127 -0
- package/src/model/backend-config.ts +8 -0
- package/src/monitor/index.ts +4 -2
- package/src/run-output/index.ts +2 -2
|
@@ -4,6 +4,15 @@ import {
|
|
|
4
4
|
type BackendTag
|
|
5
5
|
} from "../model/index.ts";
|
|
6
6
|
import type { StreamConversation } from "../conversation/index.ts";
|
|
7
|
+
import {
|
|
8
|
+
observeConversationCancellationCompletion,
|
|
9
|
+
reportConversationCancellationFailure,
|
|
10
|
+
reserveConversationSettlement
|
|
11
|
+
} from "../conversation/settlement-reservation.ts";
|
|
12
|
+
import {
|
|
13
|
+
registerSubprocessExitWaitCancellation,
|
|
14
|
+
terminateSubprocess
|
|
15
|
+
} from "./subprocess-termination.ts";
|
|
7
16
|
|
|
8
17
|
const DefaultSubprocessInactivityTimeoutMs = 120_000;
|
|
9
18
|
|
|
@@ -79,17 +88,35 @@ interface SubprocessTimeout {
|
|
|
79
88
|
readonly kind: SubprocessTimeoutKind;
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
interface SubprocessTerminationFailed {
|
|
92
|
+
readonly type: "termination_failed";
|
|
93
|
+
readonly error: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type SubprocessTimeoutSettlement = SubprocessTimeout | SubprocessTerminationFailed;
|
|
97
|
+
|
|
98
|
+
type SubprocessTerminal =
|
|
99
|
+
| { readonly type: "cancelled" }
|
|
100
|
+
| { readonly type: "consumer" }
|
|
101
|
+
| { readonly type: "timeout"; readonly settlement: SubprocessTimeoutSettlement }
|
|
102
|
+
| {
|
|
103
|
+
readonly type: "exited";
|
|
104
|
+
readonly consumer: SubprocessConsumer;
|
|
105
|
+
readonly exit: number | null;
|
|
106
|
+
readonly stderr: string;
|
|
107
|
+
}
|
|
108
|
+
| { readonly type: "error"; readonly error: unknown };
|
|
109
|
+
|
|
82
110
|
/** Shared spawn → stdout-line-stream → consumer → outcome plumbing for
|
|
83
111
|
* subprocess-stream backends (codex, claude, pi). Owns process spawn, line
|
|
84
112
|
* splitting, stderr capture, non-zero-exit failure, cancellation checks, and
|
|
85
113
|
* timeout settlement; the per-backend command/args builder and line consumer
|
|
86
114
|
* plug in.
|
|
87
115
|
*
|
|
88
|
-
* The helper
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* {@link backendFailed} in that catch. */
|
|
116
|
+
* The helper propagates spawn / stream / consumer exceptions unless a timeout
|
|
117
|
+
* has already reserved settlement. In that case timeout termination owns the
|
|
118
|
+
* outcome. Each driver wraps propagated errors so failure and resource teardown
|
|
119
|
+
* stay ordered; use {@link errorMessage} + {@link backendFailed} there. */
|
|
93
120
|
export async function runSubprocessConversation<B extends BackendTag>(
|
|
94
121
|
options: RunSubprocessOptions<B>
|
|
95
122
|
): Promise<void> {
|
|
@@ -107,24 +134,73 @@ export async function runSubprocessConversation<B extends BackendTag>(
|
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
const process = spawnProcess(options.command, options.args, spawnOptions);
|
|
110
|
-
options.setProcess?.(process);
|
|
111
137
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const timeout: Deferred<SubprocessTimeout> = Promise.withResolvers<SubprocessTimeout>();
|
|
138
|
+
const timeout: Deferred<SubprocessTimeoutSettlement> =
|
|
139
|
+
Promise.withResolvers<SubprocessTimeoutSettlement>();
|
|
116
140
|
const inactivityMs = options.inactivityTimeoutMs ?? DefaultSubprocessInactivityTimeoutMs;
|
|
117
141
|
const wallClockMs = options.wallClockTimeoutMs ?? DefaultSubprocessWallClockTimeoutMs;
|
|
118
|
-
let
|
|
142
|
+
let streamTeardownDeadline: number | undefined;
|
|
143
|
+
let timeoutStarted = false;
|
|
144
|
+
let releaseTimeoutSettlement: (() => void) | undefined;
|
|
119
145
|
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
120
146
|
|
|
121
147
|
const settleTimeout = (kind: SubprocessTimeoutKind): void => {
|
|
122
|
-
if (
|
|
148
|
+
if (timeoutStarted) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
timeoutStarted = true;
|
|
152
|
+
releaseTimeoutSettlement = reserveConversationSettlement(conversation);
|
|
153
|
+
void terminateSubprocess(process).then(
|
|
154
|
+
() => {
|
|
155
|
+
timeout.resolve({ type: "timeout", kind });
|
|
156
|
+
},
|
|
157
|
+
(error: unknown) => {
|
|
158
|
+
timeout.resolve({ type: "termination_failed", error });
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
const hasTimeoutStarted = (): boolean => timeoutStarted;
|
|
163
|
+
const isConversationAborted = (): boolean => conversation.signal.aborted;
|
|
164
|
+
const awaitStreamTeardown = async <T>(operation: PromiseLike<T> | T): Promise<T> => {
|
|
165
|
+
streamTeardownDeadline ??= Date.now() + Math.max(wallClockMs, 0);
|
|
166
|
+
const remainingMs = streamTeardownDeadline - Date.now();
|
|
167
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
168
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
169
|
+
const fail = (): void => {
|
|
170
|
+
reject(
|
|
171
|
+
new Error(
|
|
172
|
+
`subprocess stream teardown exceeded ${String(wallClockMs)}ms wall-clock limit`
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
if (remainingMs <= 0) {
|
|
177
|
+
fail();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
timer = setTimeout(fail, remainingMs);
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
return await Promise.race([Promise.resolve(operation), deadline]);
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const failStreamCleanup = (error: unknown): void => {
|
|
189
|
+
if (
|
|
190
|
+
isConversationAborted() &&
|
|
191
|
+
reportConversationCancellationFailure(conversation, error)
|
|
192
|
+
) {
|
|
123
193
|
return;
|
|
124
194
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
195
|
+
conversation.fail(backendFailed(backend, errorMessage(error)));
|
|
196
|
+
};
|
|
197
|
+
const settleSubprocessTimeout = (settlement: SubprocessTimeoutSettlement): void => {
|
|
198
|
+
if (settlement.type === "termination_failed") {
|
|
199
|
+
conversation.fail(backendFailed(backend, errorMessage(settlement.error)));
|
|
200
|
+
} else {
|
|
201
|
+
failSubprocessTimeout(conversation, backend, settlement.kind, inactivityMs, wallClockMs);
|
|
202
|
+
}
|
|
203
|
+
releaseTimeoutSettlement?.();
|
|
128
204
|
};
|
|
129
205
|
const resetInactivityTimer = (): void => {
|
|
130
206
|
clearTimeout(inactivityTimer);
|
|
@@ -138,52 +214,265 @@ export async function runSubprocessConversation<B extends BackendTag>(
|
|
|
138
214
|
}, Math.max(wallClockMs, 0));
|
|
139
215
|
resetInactivityTimer();
|
|
140
216
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
const releaseRunSettlement = reserveConversationSettlement(conversation);
|
|
218
|
+
const conversationAborted = Symbol("conversation aborted during subprocess work");
|
|
219
|
+
const observedConversationAbort = Promise.withResolvers<typeof conversationAborted>();
|
|
220
|
+
const onConversationAbort = (): void => {
|
|
221
|
+
observedConversationAbort.resolve(conversationAborted);
|
|
222
|
+
};
|
|
223
|
+
if (conversation.signal.aborted) {
|
|
224
|
+
onConversationAbort();
|
|
225
|
+
} else {
|
|
226
|
+
conversation.signal.addEventListener("abort", onConversationAbort, { once: true });
|
|
227
|
+
}
|
|
228
|
+
const conversationSettled = Symbol("conversation settled during subprocess read");
|
|
229
|
+
const observedConversationSettlement: Promise<typeof conversationSettled> =
|
|
230
|
+
conversation.awaitResult().then(() => conversationSettled);
|
|
231
|
+
const observedCancellationCompletion =
|
|
232
|
+
observeConversationCancellationCompletion(conversation);
|
|
233
|
+
let stderr: TextCollector | undefined;
|
|
234
|
+
let stdoutIterator: AsyncIterator<string | Uint8Array> | undefined;
|
|
235
|
+
let lines: AsyncIterator<string> | undefined;
|
|
236
|
+
|
|
237
|
+
const cancelStdout = async (): Promise<void> => {
|
|
238
|
+
const cleanupErrors: unknown[] = [];
|
|
239
|
+
try {
|
|
240
|
+
if (isDestroyable(process.stdout)) {
|
|
241
|
+
process.stdout.destroy();
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
cleanupErrors.push(error);
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
await awaitStreamTeardown(stdoutIterator?.return?.());
|
|
248
|
+
} catch (error) {
|
|
249
|
+
cleanupErrors.push(error);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await awaitStreamTeardown(lines?.return?.());
|
|
253
|
+
} catch (error) {
|
|
254
|
+
cleanupErrors.push(error);
|
|
255
|
+
}
|
|
256
|
+
if (cleanupErrors.length > 0) {
|
|
257
|
+
throw cleanupErrors[0];
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const cancelStderr = async (): Promise<void> => {
|
|
261
|
+
if (stderr) {
|
|
262
|
+
await awaitStreamTeardown(stderr.cancel());
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (process.stderr && isDestroyable(process.stderr)) {
|
|
266
|
+
process.stderr.destroy();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const cleanupStreams = async (): Promise<readonly unknown[]> => {
|
|
270
|
+
const cleanupErrors: unknown[] = [];
|
|
271
|
+
await Promise.all([
|
|
272
|
+
cancelStdout().catch((error: unknown) => {
|
|
273
|
+
cleanupErrors.push(error);
|
|
274
|
+
}),
|
|
275
|
+
cancelStderr().catch((error: unknown) => {
|
|
276
|
+
cleanupErrors.push(error);
|
|
277
|
+
})
|
|
278
|
+
]);
|
|
279
|
+
return cleanupErrors;
|
|
280
|
+
};
|
|
281
|
+
const awaitConversationStop = async (): Promise<void> => {
|
|
282
|
+
if (isConversationAborted()) {
|
|
283
|
+
await observedCancellationCompletion;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await Promise.race([
|
|
287
|
+
process.exit.catch(() => null),
|
|
288
|
+
observedConversationSettlement
|
|
289
|
+
]);
|
|
290
|
+
};
|
|
291
|
+
const terminateAndCleanup = async (
|
|
292
|
+
completeStderr: boolean
|
|
293
|
+
): Promise<readonly unknown[]> => {
|
|
294
|
+
const cleanupErrors: unknown[] = [];
|
|
295
|
+
let terminationSucceeded = false;
|
|
296
|
+
try {
|
|
297
|
+
await terminateSubprocess(process);
|
|
298
|
+
terminationSucceeded = true;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
cleanupErrors.push(error);
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
await cancelStdout();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
cleanupErrors.push(error);
|
|
306
|
+
}
|
|
307
|
+
if (completeStderr && terminationSucceeded && stderr) {
|
|
308
|
+
try {
|
|
309
|
+
await awaitStreamTeardown(stderr.result);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
cleanupErrors.push(error);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
try {
|
|
315
|
+
await cancelStderr();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
cleanupErrors.push(error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return cleanupErrors;
|
|
321
|
+
};
|
|
322
|
+
const observeSubprocess = async (
|
|
323
|
+
consumer: SubprocessConsumer,
|
|
324
|
+
lineIterator: AsyncIterator<string>,
|
|
325
|
+
stderrCollector: TextCollector
|
|
326
|
+
): Promise<SubprocessTerminal> => {
|
|
144
327
|
for (;;) {
|
|
145
|
-
const next:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
328
|
+
const next:
|
|
329
|
+
| IteratorResult<string>
|
|
330
|
+
| SubprocessTimeoutSettlement
|
|
331
|
+
| typeof conversationAborted
|
|
332
|
+
| typeof conversationSettled = await Promise.race([
|
|
333
|
+
lineIterator.next(),
|
|
334
|
+
timeout.promise,
|
|
335
|
+
observedConversationAbort.promise,
|
|
336
|
+
observedConversationSettlement
|
|
337
|
+
]);
|
|
338
|
+
if (next === conversationAborted || next === conversationSettled) {
|
|
339
|
+
return { type: "cancelled" };
|
|
340
|
+
}
|
|
341
|
+
if (isSubprocessTimeoutSettlement(next)) {
|
|
342
|
+
return { type: "timeout", settlement: next };
|
|
343
|
+
}
|
|
344
|
+
if (hasTimeoutStarted()) {
|
|
345
|
+
return { type: "timeout", settlement: await timeout.promise };
|
|
149
346
|
}
|
|
150
347
|
if (next.done) {
|
|
151
348
|
break;
|
|
152
349
|
}
|
|
153
350
|
resetInactivityTimer();
|
|
154
|
-
if (
|
|
155
|
-
return;
|
|
351
|
+
if (isConversationAborted()) {
|
|
352
|
+
return { type: "cancelled" };
|
|
353
|
+
}
|
|
354
|
+
const consumed: undefined | SubprocessTimeoutSettlement | typeof conversationAborted =
|
|
355
|
+
await Promise.race([
|
|
356
|
+
consumer.consume(next.value).then(() => undefined),
|
|
357
|
+
timeout.promise,
|
|
358
|
+
observedConversationAbort.promise
|
|
359
|
+
]);
|
|
360
|
+
if (consumed === conversationAborted) {
|
|
361
|
+
return { type: "cancelled" };
|
|
362
|
+
}
|
|
363
|
+
if (consumed !== undefined) {
|
|
364
|
+
return { type: "timeout", settlement: consumed };
|
|
365
|
+
}
|
|
366
|
+
if (hasTimeoutStarted()) {
|
|
367
|
+
return { type: "timeout", settlement: await timeout.promise };
|
|
368
|
+
}
|
|
369
|
+
if (isConversationAborted()) {
|
|
370
|
+
return { type: "cancelled" };
|
|
156
371
|
}
|
|
157
|
-
await consumer.consume(next.value);
|
|
158
372
|
if (consumer.signal.aborted) {
|
|
159
|
-
|
|
160
|
-
// terminal event (success, modeled failure, or early parse/tool error).
|
|
161
|
-
// Safe because we only reach here after consuming that event — the agent's
|
|
162
|
-
// session rollout is already flushed — and it stops a persistent process
|
|
163
|
-
// (pi rpc) or a stalled CLI from lingering after Orca has the outcome.
|
|
164
|
-
process.kill();
|
|
165
|
-
break;
|
|
373
|
+
return { type: "consumer" };
|
|
166
374
|
}
|
|
167
375
|
}
|
|
168
376
|
|
|
169
|
-
// The consumer already settled the conversation (success/failure); the
|
|
170
|
-
// exit-code path below is only for a stream that ended without one.
|
|
171
377
|
if (consumer.signal.aborted) {
|
|
172
|
-
return;
|
|
378
|
+
return { type: "consumer" };
|
|
173
379
|
}
|
|
174
380
|
|
|
175
|
-
const exit:
|
|
176
|
-
|
|
177
|
-
|
|
381
|
+
const exit:
|
|
382
|
+
| number
|
|
383
|
+
| null
|
|
384
|
+
| SubprocessTimeoutSettlement
|
|
385
|
+
| typeof conversationAborted
|
|
386
|
+
| typeof conversationSettled = await Promise.race([
|
|
387
|
+
process.exit,
|
|
388
|
+
timeout.promise,
|
|
389
|
+
observedConversationAbort.promise,
|
|
390
|
+
observedConversationSettlement
|
|
391
|
+
]);
|
|
392
|
+
if (exit === conversationAborted || exit === conversationSettled) {
|
|
393
|
+
return { type: "cancelled" };
|
|
394
|
+
}
|
|
395
|
+
if (isSubprocessTimeoutSettlement(exit)) {
|
|
396
|
+
return { type: "timeout", settlement: exit };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const stderrResult:
|
|
400
|
+
| string
|
|
401
|
+
| SubprocessTimeoutSettlement
|
|
402
|
+
| typeof conversationAborted
|
|
403
|
+
| typeof conversationSettled = await Promise.race([
|
|
404
|
+
stderrCollector.result,
|
|
405
|
+
timeout.promise,
|
|
406
|
+
observedConversationAbort.promise,
|
|
407
|
+
observedConversationSettlement
|
|
408
|
+
]);
|
|
409
|
+
if (stderrResult === conversationAborted || stderrResult === conversationSettled) {
|
|
410
|
+
return { type: "cancelled" };
|
|
411
|
+
}
|
|
412
|
+
if (isSubprocessTimeoutSettlement(stderrResult)) {
|
|
413
|
+
return { type: "timeout", settlement: stderrResult };
|
|
414
|
+
}
|
|
415
|
+
if (hasTimeoutStarted()) {
|
|
416
|
+
return { type: "timeout", settlement: await timeout.promise };
|
|
417
|
+
}
|
|
418
|
+
if (isConversationAborted()) {
|
|
419
|
+
return { type: "cancelled" };
|
|
420
|
+
}
|
|
421
|
+
return { type: "exited", consumer, exit, stderr: stderrResult };
|
|
422
|
+
};
|
|
423
|
+
const finalizeSubprocess = async (terminal: SubprocessTerminal): Promise<void> => {
|
|
424
|
+
clearTimeout(inactivityTimer);
|
|
425
|
+
clearTimeout(wallClockTimer);
|
|
426
|
+
streamTeardownDeadline = Date.now() + Math.max(wallClockMs, 0);
|
|
427
|
+
|
|
428
|
+
if (terminal.type === "consumer") {
|
|
429
|
+
const cleanupErrors = await terminateAndCleanup(true);
|
|
430
|
+
if (cleanupErrors.length > 0) {
|
|
431
|
+
failStreamCleanup(cleanupErrors[0]);
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (terminal.type === "timeout") {
|
|
436
|
+
const cleanupErrors = await cleanupStreams();
|
|
437
|
+
if (cleanupErrors.length > 0) {
|
|
438
|
+
failStreamCleanup(cleanupErrors[0]);
|
|
439
|
+
}
|
|
440
|
+
settleSubprocessTimeout(terminal.settlement);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (terminal.type === "cancelled") {
|
|
444
|
+
await awaitConversationStop();
|
|
445
|
+
const cleanupErrors = await cleanupStreams();
|
|
446
|
+
if (cleanupErrors.length > 0) {
|
|
447
|
+
reportConversationCancellationFailure(conversation, cleanupErrors[0]);
|
|
448
|
+
}
|
|
449
|
+
if (hasTimeoutStarted()) {
|
|
450
|
+
settleSubprocessTimeout(await timeout.promise);
|
|
451
|
+
}
|
|
178
452
|
return;
|
|
179
453
|
}
|
|
180
|
-
|
|
181
|
-
|
|
454
|
+
if (terminal.type === "error") {
|
|
455
|
+
const cleanupErrors = await terminateAndCleanup(false);
|
|
456
|
+
if (isConversationAborted() && cleanupErrors.length > 0) {
|
|
457
|
+
reportConversationCancellationFailure(conversation, cleanupErrors[0]);
|
|
458
|
+
}
|
|
459
|
+
throw terminal.error;
|
|
460
|
+
}
|
|
461
|
+
if (isConversationAborted()) {
|
|
462
|
+
await awaitConversationStop();
|
|
463
|
+
const cleanupErrors = await cleanupStreams();
|
|
464
|
+
if (cleanupErrors.length > 0) {
|
|
465
|
+
reportConversationCancellationFailure(conversation, cleanupErrors[0]);
|
|
466
|
+
}
|
|
467
|
+
if (hasTimeoutStarted()) {
|
|
468
|
+
settleSubprocessTimeout(await timeout.promise);
|
|
469
|
+
}
|
|
182
470
|
return;
|
|
183
471
|
}
|
|
184
472
|
|
|
185
|
-
|
|
186
|
-
|
|
473
|
+
const stderrText = terminal.stderr.trim();
|
|
474
|
+
if (terminal.exit !== 0) {
|
|
475
|
+
const exitCodeText = terminal.exit === null ? "unknown" : String(terminal.exit);
|
|
187
476
|
conversation.fail(
|
|
188
477
|
backendFailed(
|
|
189
478
|
backend,
|
|
@@ -192,11 +481,56 @@ export async function runSubprocessConversation<B extends BackendTag>(
|
|
|
192
481
|
);
|
|
193
482
|
return;
|
|
194
483
|
}
|
|
484
|
+
terminal.consumer.finish();
|
|
485
|
+
};
|
|
195
486
|
|
|
196
|
-
|
|
487
|
+
try {
|
|
488
|
+
let terminal: SubprocessTerminal;
|
|
489
|
+
try {
|
|
490
|
+
options.setProcess?.(process);
|
|
491
|
+
stderr = startTextCollector(process.stderr);
|
|
492
|
+
stdoutIterator = process.stdout[Symbol.asyncIterator]();
|
|
493
|
+
const stdoutChunks: AsyncIterable<string | Uint8Array> = {
|
|
494
|
+
[Symbol.asyncIterator]: () => stdoutIterator as AsyncIterator<string | Uint8Array>
|
|
495
|
+
};
|
|
496
|
+
lines = splitLines(stdoutChunks)[Symbol.asyncIterator]();
|
|
497
|
+
const startup:
|
|
498
|
+
| undefined
|
|
499
|
+
| SubprocessTimeoutSettlement
|
|
500
|
+
| typeof conversationAborted
|
|
501
|
+
| typeof conversationSettled =
|
|
502
|
+
options.onStart === undefined
|
|
503
|
+
? undefined
|
|
504
|
+
: await Promise.race([
|
|
505
|
+
Promise.resolve(options.onStart(process)).then(() => undefined),
|
|
506
|
+
timeout.promise,
|
|
507
|
+
observedConversationAbort.promise,
|
|
508
|
+
observedConversationSettlement
|
|
509
|
+
]);
|
|
510
|
+
if (
|
|
511
|
+
startup === conversationAborted ||
|
|
512
|
+
startup === conversationSettled ||
|
|
513
|
+
isConversationAborted()
|
|
514
|
+
) {
|
|
515
|
+
terminal = { type: "cancelled" };
|
|
516
|
+
} else if (isSubprocessTimeoutSettlement(startup)) {
|
|
517
|
+
terminal = { type: "timeout", settlement: startup };
|
|
518
|
+
} else if (hasTimeoutStarted()) {
|
|
519
|
+
terminal = { type: "timeout", settlement: await timeout.promise };
|
|
520
|
+
} else {
|
|
521
|
+
terminal = await observeSubprocess(options.createConsumer(), lines, stderr);
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
terminal = hasTimeoutStarted()
|
|
525
|
+
? { type: "timeout", settlement: await timeout.promise }
|
|
526
|
+
: { type: "error", error };
|
|
527
|
+
}
|
|
528
|
+
await finalizeSubprocess(terminal);
|
|
197
529
|
} finally {
|
|
198
530
|
clearTimeout(inactivityTimer);
|
|
199
531
|
clearTimeout(wallClockTimer);
|
|
532
|
+
conversation.signal.removeEventListener("abort", onConversationAbort);
|
|
533
|
+
releaseRunSettlement();
|
|
200
534
|
}
|
|
201
535
|
}
|
|
202
536
|
|
|
@@ -206,9 +540,11 @@ export function spawnSubprocess(
|
|
|
206
540
|
options: SubprocessSpawnOptions
|
|
207
541
|
): SubprocessProcess {
|
|
208
542
|
const stdinMode = options.stdin ?? "ignore";
|
|
543
|
+
const useProcessGroup = process.platform !== "win32";
|
|
209
544
|
const child = spawn(command, [...args], {
|
|
210
545
|
cwd: options.cwd,
|
|
211
546
|
env: options.env,
|
|
547
|
+
detached: useProcessGroup,
|
|
212
548
|
stdio: [stdinMode === "pipe" ? "pipe" : "ignore", "pipe", "pipe"]
|
|
213
549
|
});
|
|
214
550
|
|
|
@@ -216,16 +552,29 @@ export function spawnSubprocess(
|
|
|
216
552
|
throw new Error(`failed to capture stdout for ${command}`);
|
|
217
553
|
}
|
|
218
554
|
|
|
219
|
-
const
|
|
220
|
-
child.on("error",
|
|
221
|
-
child.on("close",
|
|
222
|
-
|
|
223
|
-
|
|
555
|
+
const leaderExit = Promise.withResolvers<number | null>();
|
|
556
|
+
child.on("error", leaderExit.reject);
|
|
557
|
+
child.on("close", leaderExit.resolve);
|
|
558
|
+
const processGroupExit =
|
|
559
|
+
useProcessGroup && child.pid !== undefined
|
|
560
|
+
? waitForProcessGroupExit(child.pid, leaderExit.promise)
|
|
561
|
+
: undefined;
|
|
562
|
+
const subprocess: SubprocessProcess = {
|
|
224
563
|
stdout: child.stdout,
|
|
225
564
|
...(child.stderr ? { stderr: child.stderr } : {}),
|
|
226
|
-
exit:
|
|
565
|
+
exit: processGroupExit?.promise ?? leaderExit.promise,
|
|
227
566
|
kill(signal?: NodeJS.Signals) {
|
|
228
|
-
child.
|
|
567
|
+
if (!useProcessGroup || child.pid === undefined) {
|
|
568
|
+
child.kill(signal);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
process.kill(-child.pid, signal ?? "SIGTERM");
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
229
578
|
},
|
|
230
579
|
write(data: string) {
|
|
231
580
|
child.stdin?.write(data);
|
|
@@ -234,6 +583,72 @@ export function spawnSubprocess(
|
|
|
234
583
|
child.stdin?.end();
|
|
235
584
|
}
|
|
236
585
|
};
|
|
586
|
+
if (processGroupExit !== undefined) {
|
|
587
|
+
registerSubprocessExitWaitCancellation(subprocess, (error: unknown) => {
|
|
588
|
+
processGroupExit.cancel(error);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return subprocess;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
interface ProcessGroupExitWait {
|
|
595
|
+
readonly promise: Promise<number | null>;
|
|
596
|
+
cancel(error: unknown): void;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function waitForProcessGroupExit(
|
|
600
|
+
processGroupId: number,
|
|
601
|
+
leaderExit: Promise<number | null>
|
|
602
|
+
): ProcessGroupExitWait {
|
|
603
|
+
const completion = Promise.withResolvers<number | null>();
|
|
604
|
+
let settled = false;
|
|
605
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
606
|
+
const reject = (error: unknown): void => {
|
|
607
|
+
if (settled) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
settled = true;
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
timer = undefined;
|
|
613
|
+
completion.reject(error);
|
|
614
|
+
};
|
|
615
|
+
const poll = (exitCode: number | null): void => {
|
|
616
|
+
if (settled) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
if (!isProcessGroupAlive(processGroupId)) {
|
|
621
|
+
settled = true;
|
|
622
|
+
completion.resolve(exitCode);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
reject(error);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
timer = setTimeout(() => {
|
|
630
|
+
timer = undefined;
|
|
631
|
+
poll(exitCode);
|
|
632
|
+
}, 10);
|
|
633
|
+
};
|
|
634
|
+
void leaderExit.then(poll, reject);
|
|
635
|
+
void completion.promise.catch(() => undefined);
|
|
636
|
+
return {
|
|
637
|
+
promise: completion.promise,
|
|
638
|
+
cancel: reject
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function isProcessGroupAlive(processGroupId: number): boolean {
|
|
643
|
+
try {
|
|
644
|
+
process.kill(-processGroupId, 0);
|
|
645
|
+
return true;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
237
652
|
}
|
|
238
653
|
|
|
239
654
|
export async function* splitLines(
|
|
@@ -262,25 +677,86 @@ export async function* splitLines(
|
|
|
262
677
|
export async function collectText(
|
|
263
678
|
chunks: AsyncIterable<string | Uint8Array> | undefined
|
|
264
679
|
): Promise<string> {
|
|
680
|
+
return await startTextCollector(chunks).result;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
interface TextCollector {
|
|
684
|
+
readonly result: Promise<string>;
|
|
685
|
+
cancel(): Promise<void>;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function startTextCollector(
|
|
689
|
+
chunks: AsyncIterable<string | Uint8Array> | undefined
|
|
690
|
+
): TextCollector {
|
|
265
691
|
if (!chunks) {
|
|
266
|
-
return
|
|
692
|
+
return {
|
|
693
|
+
result: Promise.resolve(""),
|
|
694
|
+
cancel: () => Promise.resolve()
|
|
695
|
+
};
|
|
267
696
|
}
|
|
268
697
|
|
|
269
698
|
const decoder = new TextDecoder();
|
|
270
699
|
const text: string[] = [];
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
700
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
701
|
+
const result = (async (): Promise<string> => {
|
|
702
|
+
for (;;) {
|
|
703
|
+
const next = await iterator.next();
|
|
704
|
+
if (next.done) {
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
text.push(decodeChunk(decoder, next.value));
|
|
708
|
+
}
|
|
709
|
+
text.push(decoder.decode());
|
|
710
|
+
return text.join("");
|
|
711
|
+
})();
|
|
712
|
+
void result.catch(() => undefined);
|
|
713
|
+
let cancellation: Promise<void> | undefined;
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
result,
|
|
717
|
+
cancel() {
|
|
718
|
+
cancellation ??= (async (): Promise<void> => {
|
|
719
|
+
const cleanupErrors: unknown[] = [];
|
|
720
|
+
try {
|
|
721
|
+
if (isDestroyable(chunks)) {
|
|
722
|
+
chunks.destroy();
|
|
723
|
+
}
|
|
724
|
+
} catch (error) {
|
|
725
|
+
cleanupErrors.push(error);
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
await iterator.return?.();
|
|
729
|
+
} catch (error) {
|
|
730
|
+
cleanupErrors.push(error);
|
|
731
|
+
}
|
|
732
|
+
if (cleanupErrors.length > 0) {
|
|
733
|
+
throw cleanupErrors[0];
|
|
734
|
+
}
|
|
735
|
+
})();
|
|
736
|
+
return cancellation;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function isDestroyable(value: unknown): value is { destroy(): unknown } {
|
|
742
|
+
return (
|
|
743
|
+
typeof value === "object" &&
|
|
744
|
+
value !== null &&
|
|
745
|
+
"destroy" in value &&
|
|
746
|
+
typeof value.destroy === "function"
|
|
747
|
+
);
|
|
276
748
|
}
|
|
277
749
|
|
|
278
750
|
function decodeChunk(decoder: TextDecoder, chunk: string | Uint8Array): string {
|
|
279
751
|
return typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
280
752
|
}
|
|
281
753
|
|
|
282
|
-
function
|
|
283
|
-
|
|
754
|
+
function isSubprocessTimeoutSettlement(value: unknown): value is SubprocessTimeoutSettlement {
|
|
755
|
+
if (typeof value !== "object" || value === null) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const type = (value as { readonly type?: unknown }).type;
|
|
759
|
+
return type === "timeout" || type === "termination_failed";
|
|
284
760
|
}
|
|
285
761
|
|
|
286
762
|
function failSubprocessTimeout<B extends BackendTag>(
|