@tt-a1i/openpi 0.3.1 → 0.5.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 +184 -59
- package/SETUP.md +23 -7
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +14 -6
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +30 -42
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +146 -87
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +75 -59
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +328 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +222 -140
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +127 -131
- package/extensions/shared/activity-status.ts +36 -5
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +444 -22
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +97 -38
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +113 -0
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +42 -8
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +461 -186
- package/extensions/subagents/navigation.ts +86 -28
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +651 -536
- package/extensions/subagents/src/prompt.ts +185 -42
- package/extensions/subagents/src/result-artifact.ts +146 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +23 -6
- package/extensions/subagents/src/ui/takeover.ts +128 -337
- package/extensions/subagents/src/ui/transcript.ts +38 -501
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -44
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +147 -22
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +62 -0
- package/extensions/workflows/dashboard.ts +458 -339
- package/extensions/workflows/handoff.ts +121 -25
- package/extensions/workflows/index.ts +1042 -492
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +131 -19
- package/extensions/workflows/navigation.ts +61 -18
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +166 -10
- package/extensions/workflows/replay-safety.ts +58 -27
- package/extensions/workflows/result-delivery.ts +253 -0
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +36 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +10 -5
- package/skills/workflows/SKILL.md +53 -10
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -296
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -42,9 +42,14 @@ const MAX_SETTLED_HISTORY = MAX_TRACKED * 4;
|
|
|
42
42
|
export const RETAINED_PER_STREAM = 2 * 1024 * 1024;
|
|
43
43
|
/** Private full-log spills are bounded so a firehose cannot fill the temp disk. */
|
|
44
44
|
export const MAX_SPILL_BYTES_PER_STREAM = 256 * 1024 * 1024;
|
|
45
|
+
/** Aggregate private full-log budget across every terminal in one session. */
|
|
46
|
+
export const MAX_SPILL_BYTES_PER_SESSION = 512 * 1024 * 1024;
|
|
45
47
|
const STOP_TIMEOUT_MS = 5_000;
|
|
46
48
|
/** SIGTERM is normally enough; the second deadline covers a wedged process. */
|
|
47
49
|
const FORCE_KILL_AFTER_MS = 2_000;
|
|
50
|
+
const FORCE_CLOSE_WAIT_MS = 500;
|
|
51
|
+
/** Reserve this inside each existing termination phase for helper closure. */
|
|
52
|
+
const TASKKILL_HELPER_CLOSE_WAIT_MS = 100;
|
|
48
53
|
/** After termination, how long to wait for the natural close→flush→settle
|
|
49
54
|
* path before force-settling (a grandchild can hold the stdio pipes open). */
|
|
50
55
|
const SETTLE_GRACE_MS = 1_000;
|
|
@@ -76,16 +81,19 @@ interface MutableSnapshot extends TerminalSnapshot {
|
|
|
76
81
|
errorText?: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
function appendSnapshotError(snapshot: MutableSnapshot, message: string) {
|
|
85
|
+
snapshot.errorText = bounded(
|
|
86
|
+
snapshot.errorText ? `${snapshot.errorText}; ${message}` : message,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
interface Entry {
|
|
80
91
|
snapshot: MutableSnapshot;
|
|
81
92
|
child: ChildProcess;
|
|
82
93
|
scope: Scope.Closeable;
|
|
83
94
|
stdoutBuf: OutputBuffer;
|
|
84
95
|
stderrBuf: OutputBuffer;
|
|
85
|
-
|
|
86
|
-
/** Set in the same synchronous effect that sends SIGTERM so a natural exit
|
|
87
|
-
* before signaling keeps its truthful status. */
|
|
88
|
-
killSignaled: boolean;
|
|
96
|
+
spillFiles: SpillFile[];
|
|
89
97
|
/** Deadline won the race and initiated termination. */
|
|
90
98
|
timedOut: boolean;
|
|
91
99
|
timeoutTimer?: ReturnType<typeof setTimeout>;
|
|
@@ -99,6 +107,17 @@ interface Entry {
|
|
|
99
107
|
stdioClosed: boolean;
|
|
100
108
|
/** A settle-after-spill-flush is in flight; don't start a second one. */
|
|
101
109
|
settling: boolean;
|
|
110
|
+
/** Scope termination is deciding whether a process-tree boundary was
|
|
111
|
+
* actually reached. A concurrent close event must wait for that evidence. */
|
|
112
|
+
terminationInFlight: boolean;
|
|
113
|
+
/** At least one termination signal was sent to the child or its tree. */
|
|
114
|
+
killSignaled: boolean;
|
|
115
|
+
/** False when only a direct-child fallback was available or signaling
|
|
116
|
+
* failed, so user-visible output must not claim a process-tree kill. */
|
|
117
|
+
terminationConfirmed: boolean;
|
|
118
|
+
/** A live target could not be terminated at the promised process-tree
|
|
119
|
+
* boundary. This also covers the case where every signal attempt failed. */
|
|
120
|
+
terminationFailed: boolean;
|
|
102
121
|
/** The shell exited without stdio closing; a bounded scope close is queued
|
|
103
122
|
* to reap descendants that still hold the inherited pipes open. */
|
|
104
123
|
exitCleanupStarted: boolean;
|
|
@@ -107,6 +126,12 @@ interface Entry {
|
|
|
107
126
|
settled: Deferred.Deferred<void>;
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
interface SpillFile {
|
|
130
|
+
readonly path: string;
|
|
131
|
+
readonly file: fs.WriteStream;
|
|
132
|
+
reservedBytes: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
export interface StartOptions {
|
|
111
136
|
readonly command: string;
|
|
112
137
|
readonly title: string;
|
|
@@ -124,6 +149,10 @@ export interface KillResult {
|
|
|
124
149
|
/** True when this call initiated the termination AND the entry settled as
|
|
125
150
|
* killed (a natural exit that won the race reports killed: false). */
|
|
126
151
|
readonly killed: boolean;
|
|
152
|
+
/** A kill was attempted, but the promised process-tree boundary could not
|
|
153
|
+
* be confirmed. */
|
|
154
|
+
readonly terminationFailed?: boolean;
|
|
155
|
+
readonly errorText?: string;
|
|
127
156
|
/** Final exit rendering ("exit 0", "SIGTERM", ...) captured at settle time,
|
|
128
157
|
* so reports stay accurate even if the entry is pruned afterwards. */
|
|
129
158
|
readonly exit: string;
|
|
@@ -152,6 +181,11 @@ export interface TerminalReadModel {
|
|
|
152
181
|
/** Fire-and-forget kill (dashboard/detail `x`). Not marked consumed: the
|
|
153
182
|
* settle still flows back to the model as a follow-up message. */
|
|
154
183
|
requestKill(id: string): void;
|
|
184
|
+
/** Keep a settled entry alive while its completion snapshot is pending
|
|
185
|
+
* delivery outside the manager. */
|
|
186
|
+
retainResult(id: string): void;
|
|
187
|
+
/** Release a previously retained completion snapshot and retry pruning. */
|
|
188
|
+
releaseResult(id: string): void;
|
|
155
189
|
/**
|
|
156
190
|
* Register the settle hook. `consumed` is true when an active bg_kill is
|
|
157
191
|
* collecting the result (so it must not also be delivered as a follow-up).
|
|
@@ -182,65 +216,240 @@ export class TerminalManager extends Context.Service<
|
|
|
182
216
|
|
|
183
217
|
// --- Process helpers ------------------------------------------------------------
|
|
184
218
|
|
|
185
|
-
function shellInvocation(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
export function shellInvocation(
|
|
220
|
+
command: string,
|
|
221
|
+
platform: NodeJS.Platform = process.platform,
|
|
222
|
+
windowsShell = process.env.ComSpec ?? "cmd.exe",
|
|
223
|
+
) {
|
|
224
|
+
if (platform === "win32") {
|
|
225
|
+
// Match Node's `{ shell: true }` cmd.exe invocation. Without the outer
|
|
226
|
+
// quotes and verbatim arguments, libuv escapes embedded quotes using CRT
|
|
227
|
+
// rules, which cmd.exe does not understand.
|
|
228
|
+
return {
|
|
229
|
+
shell: windowsShell,
|
|
230
|
+
args: ["/d", "/s", "/c", `"${command}"`],
|
|
231
|
+
windowsVerbatimArguments: true,
|
|
232
|
+
};
|
|
189
233
|
}
|
|
190
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
shell: "/bin/sh",
|
|
236
|
+
args: ["-c", command],
|
|
237
|
+
windowsVerbatimArguments: false,
|
|
238
|
+
};
|
|
191
239
|
}
|
|
192
240
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
killer.once("error", () => {
|
|
209
|
-
try {
|
|
210
|
-
child.kill(signal);
|
|
211
|
-
} catch {
|
|
212
|
-
// Process may already be gone.
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
killer.once("exit", (code) => {
|
|
216
|
-
if (code === 0) return;
|
|
217
|
-
try {
|
|
218
|
-
child.kill(signal);
|
|
219
|
-
} catch {
|
|
220
|
-
// Process may already be gone.
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
killer.unref();
|
|
224
|
-
return;
|
|
225
|
-
} catch {
|
|
226
|
-
// Fall through to the direct signal when taskkill cannot be launched.
|
|
241
|
+
type ProcessSignalTarget = Pick<ChildProcess, "pid" | "kill">;
|
|
242
|
+
type TaskkillSpawner = (pid: number, force: boolean) => ChildProcess;
|
|
243
|
+
|
|
244
|
+
export type ProcessTreeSignalResult =
|
|
245
|
+
| { readonly outcome: "sent" }
|
|
246
|
+
| { readonly outcome: "already_exited"; readonly detail: string }
|
|
247
|
+
| { readonly outcome: "fallback_sent"; readonly detail: string }
|
|
248
|
+
| { readonly outcome: "unresolved"; readonly detail: string }
|
|
249
|
+
| { readonly outcome: "failed"; readonly detail: string };
|
|
250
|
+
|
|
251
|
+
export type WindowsTaskkillResult =
|
|
252
|
+
| {
|
|
253
|
+
readonly outcome: "completed";
|
|
254
|
+
readonly exitCode: number | null;
|
|
255
|
+
readonly signal: NodeJS.Signals | null;
|
|
227
256
|
}
|
|
228
|
-
}
|
|
229
|
-
|
|
257
|
+
| { readonly outcome: "launch_failed"; readonly error: string }
|
|
258
|
+
| {
|
|
259
|
+
readonly outcome: "timed_out";
|
|
260
|
+
readonly timeoutMs: number;
|
|
261
|
+
readonly helperClosed: boolean;
|
|
262
|
+
readonly helperCloseTimeoutMs: number;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
function spawnTaskkill(pid: number, force: boolean) {
|
|
266
|
+
return spawn(
|
|
267
|
+
"taskkill",
|
|
268
|
+
["/pid", String(pid), "/T", ...(force ? ["/F"] : [])],
|
|
269
|
+
{ stdio: "ignore", windowsHide: true },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Resolve after taskkill closes or its still-live helper is explicitly detached. */
|
|
274
|
+
export function waitForWindowsTaskkill(
|
|
275
|
+
pid: number,
|
|
276
|
+
force: boolean,
|
|
277
|
+
launch: TaskkillSpawner = spawnTaskkill,
|
|
278
|
+
timeoutMs = (force ? FORCE_CLOSE_WAIT_MS : FORCE_KILL_AFTER_MS) -
|
|
279
|
+
TASKKILL_HELPER_CLOSE_WAIT_MS,
|
|
280
|
+
helperCloseTimeoutMs = TASKKILL_HELPER_CLOSE_WAIT_MS,
|
|
281
|
+
) {
|
|
282
|
+
return new Promise<WindowsTaskkillResult>((resolve) => {
|
|
283
|
+
let killer: ChildProcess;
|
|
230
284
|
try {
|
|
231
|
-
|
|
285
|
+
killer = launch(pid, force);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
resolve({ outcome: "launch_failed", error: boundedError(error) });
|
|
232
288
|
return;
|
|
233
|
-
} catch {
|
|
234
|
-
// Group may already be gone; fall through to the direct signal.
|
|
235
289
|
}
|
|
236
|
-
|
|
290
|
+
|
|
291
|
+
let finished = false;
|
|
292
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
293
|
+
let helperCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
294
|
+
let timedOut = false;
|
|
295
|
+
const finish = (result: WindowsTaskkillResult) => {
|
|
296
|
+
if (finished) return;
|
|
297
|
+
finished = true;
|
|
298
|
+
if (timer) clearTimeout(timer);
|
|
299
|
+
if (helperCloseTimer) clearTimeout(helperCloseTimer);
|
|
300
|
+
killer.off("error", onError);
|
|
301
|
+
killer.off("close", onClose);
|
|
302
|
+
resolve(result);
|
|
303
|
+
};
|
|
304
|
+
const onError = (error: Error) =>
|
|
305
|
+
finish({ outcome: "launch_failed", error: boundedError(error) });
|
|
306
|
+
const onClose = (exitCode: number | null, signal: NodeJS.Signals | null) =>
|
|
307
|
+
finish(
|
|
308
|
+
timedOut
|
|
309
|
+
? {
|
|
310
|
+
outcome: "timed_out",
|
|
311
|
+
timeoutMs,
|
|
312
|
+
helperClosed: true,
|
|
313
|
+
helperCloseTimeoutMs,
|
|
314
|
+
}
|
|
315
|
+
: { outcome: "completed", exitCode, signal },
|
|
316
|
+
);
|
|
317
|
+
killer.once("error", onError);
|
|
318
|
+
killer.once("close", onClose);
|
|
319
|
+
timer = setTimeout(() => {
|
|
320
|
+
timedOut = true;
|
|
321
|
+
try {
|
|
322
|
+
killer.kill("SIGKILL");
|
|
323
|
+
} catch {
|
|
324
|
+
// The helper may already be gone; the bounded result stays the same.
|
|
325
|
+
}
|
|
326
|
+
helperCloseTimer = setTimeout(() => {
|
|
327
|
+
killer.unref();
|
|
328
|
+
finish({
|
|
329
|
+
outcome: "timed_out",
|
|
330
|
+
timeoutMs,
|
|
331
|
+
helperClosed: false,
|
|
332
|
+
helperCloseTimeoutMs,
|
|
333
|
+
});
|
|
334
|
+
}, helperCloseTimeoutMs);
|
|
335
|
+
}, timeoutMs);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function directSignal(
|
|
340
|
+
child: ProcessSignalTarget,
|
|
341
|
+
signal: NodeJS.Signals,
|
|
342
|
+
targetExited: () => boolean,
|
|
343
|
+
detail: string,
|
|
344
|
+
): ProcessTreeSignalResult {
|
|
345
|
+
if (targetExited()) return { outcome: "already_exited", detail };
|
|
237
346
|
try {
|
|
238
|
-
child.kill(signal);
|
|
239
|
-
|
|
240
|
-
|
|
347
|
+
if (child.kill(signal)) return { outcome: "fallback_sent", detail };
|
|
348
|
+
if (targetExited()) return { outcome: "already_exited", detail };
|
|
349
|
+
return {
|
|
350
|
+
outcome: "failed",
|
|
351
|
+
detail: `${detail}; direct child signal returned false`,
|
|
352
|
+
};
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (targetExited()) return { outcome: "already_exited", detail };
|
|
355
|
+
return {
|
|
356
|
+
outcome: "failed",
|
|
357
|
+
detail: `${detail}; direct child signal failed: ${boundedError(error)}`,
|
|
358
|
+
};
|
|
241
359
|
}
|
|
242
360
|
}
|
|
243
361
|
|
|
362
|
+
/** Windows process-tree signaling with explicit taskkill and fallback evidence. */
|
|
363
|
+
export async function signalWindowsProcessTree(
|
|
364
|
+
child: ProcessSignalTarget,
|
|
365
|
+
signal: NodeJS.Signals,
|
|
366
|
+
targetExited: () => boolean,
|
|
367
|
+
launch: TaskkillSpawner = spawnTaskkill,
|
|
368
|
+
): Promise<ProcessTreeSignalResult> {
|
|
369
|
+
if (targetExited()) {
|
|
370
|
+
return {
|
|
371
|
+
outcome: "already_exited",
|
|
372
|
+
detail: "target exited before taskkill started",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (!child.pid) {
|
|
376
|
+
return directSignal(
|
|
377
|
+
child,
|
|
378
|
+
signal,
|
|
379
|
+
targetExited,
|
|
380
|
+
"taskkill unavailable because the child has no pid",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const attempt = await waitForWindowsTaskkill(
|
|
385
|
+
child.pid,
|
|
386
|
+
signal === "SIGKILL",
|
|
387
|
+
launch,
|
|
388
|
+
);
|
|
389
|
+
if (attempt.outcome === "completed" && attempt.exitCode === 0) {
|
|
390
|
+
return { outcome: "sent" };
|
|
391
|
+
}
|
|
392
|
+
const detail =
|
|
393
|
+
attempt.outcome === "launch_failed"
|
|
394
|
+
? `taskkill failed to launch: ${attempt.error}`
|
|
395
|
+
: attempt.outcome === "timed_out"
|
|
396
|
+
? `taskkill timed out after ${attempt.timeoutMs}ms; helper ${attempt.helperClosed ? "closed after SIGKILL" : `did not close within an additional ${attempt.helperCloseTimeoutMs}ms`}`
|
|
397
|
+
: `taskkill exited ${attempt.exitCode ?? "without a code"}${attempt.signal ? ` (${attempt.signal})` : ""}`;
|
|
398
|
+
if (
|
|
399
|
+
attempt.outcome === "timed_out" &&
|
|
400
|
+
!attempt.helperClosed &&
|
|
401
|
+
!targetExited()
|
|
402
|
+
) {
|
|
403
|
+
return { outcome: "unresolved", detail };
|
|
404
|
+
}
|
|
405
|
+
// A failed graceful taskkill must leave the shell PID alive for the
|
|
406
|
+
// serialized `/T /F` phase. Killing only the shell here would orphan its
|
|
407
|
+
// descendants and make the original process-tree handle unusable.
|
|
408
|
+
if (
|
|
409
|
+
signal === "SIGTERM" &&
|
|
410
|
+
attempt.outcome !== "launch_failed" &&
|
|
411
|
+
!targetExited()
|
|
412
|
+
) {
|
|
413
|
+
return { outcome: "failed", detail };
|
|
414
|
+
}
|
|
415
|
+
return directSignal(child, signal, targetExited, detail);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Signal the whole process group on POSIX so descendants (servers a shell
|
|
419
|
+
* command spawned) die with it; return exact evidence for every fallback. */
|
|
420
|
+
function signalProcessTree(
|
|
421
|
+
child: ChildProcess,
|
|
422
|
+
signal: NodeJS.Signals,
|
|
423
|
+
targetExited: () => boolean,
|
|
424
|
+
) {
|
|
425
|
+
if (process.platform === "win32") {
|
|
426
|
+
return Effect.promise(() =>
|
|
427
|
+
signalWindowsProcessTree(child, signal, targetExited),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return Effect.sync((): ProcessTreeSignalResult => {
|
|
431
|
+
if (child.pid) {
|
|
432
|
+
try {
|
|
433
|
+
process.kill(-child.pid, signal);
|
|
434
|
+
return { outcome: "sent" };
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return directSignal(
|
|
437
|
+
child,
|
|
438
|
+
signal,
|
|
439
|
+
targetExited,
|
|
440
|
+
`process-group ${signal} failed: ${boundedError(error)}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return directSignal(
|
|
445
|
+
child,
|
|
446
|
+
signal,
|
|
447
|
+
targetExited,
|
|
448
|
+
"process-group signal unavailable because the child has no pid",
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
244
453
|
/** Await stdio closure without retaining a listener after interruption. */
|
|
245
454
|
function awaitChildClose(child: ChildProcess, closed: () => boolean) {
|
|
246
455
|
return Effect.callback<void>((resume) => {
|
|
@@ -260,32 +469,59 @@ function awaitChildClose(child: ChildProcess, closed: () => boolean) {
|
|
|
260
469
|
function terminateChild(
|
|
261
470
|
child: ChildProcess,
|
|
262
471
|
closed: () => boolean,
|
|
263
|
-
|
|
472
|
+
targetExited: () => boolean,
|
|
264
473
|
) {
|
|
265
474
|
return Effect.suspend(() => {
|
|
266
|
-
if (closed())
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
475
|
+
if (closed()) {
|
|
476
|
+
return Effect.succeed({
|
|
477
|
+
signalSent: false,
|
|
478
|
+
treeConfirmed: true,
|
|
479
|
+
terminationFailed: false,
|
|
480
|
+
detail: undefined as string | undefined,
|
|
271
481
|
});
|
|
482
|
+
}
|
|
483
|
+
const liveAtStart = !targetExited();
|
|
484
|
+
return Effect.gen(function* () {
|
|
485
|
+
const attempts: ProcessTreeSignalResult[] = [];
|
|
486
|
+
const gracefulDeadline = Date.now() + FORCE_KILL_AFTER_MS;
|
|
487
|
+
const graceful = yield* signalProcessTree(child, "SIGTERM", targetExited);
|
|
488
|
+
attempts.push(graceful);
|
|
272
489
|
yield* awaitChildClose(child, closed).pipe(
|
|
273
|
-
Effect.timeout(
|
|
490
|
+
Effect.timeout(Math.max(0, gracefulDeadline - Date.now())),
|
|
274
491
|
Effect.ignore,
|
|
275
492
|
);
|
|
276
|
-
if (closed())
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
493
|
+
if (!closed() && graceful.outcome !== "unresolved") {
|
|
494
|
+
const forceDeadline = Date.now() + FORCE_CLOSE_WAIT_MS;
|
|
495
|
+
attempts.push(yield* signalProcessTree(child, "SIGKILL", targetExited));
|
|
496
|
+
yield* awaitChildClose(child, closed).pipe(
|
|
497
|
+
Effect.timeout(Math.max(0, forceDeadline - Date.now())),
|
|
498
|
+
Effect.ignore,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const signalSent = attempts.some(
|
|
502
|
+
(attempt) =>
|
|
503
|
+
attempt.outcome === "sent" || attempt.outcome === "fallback_sent",
|
|
281
504
|
);
|
|
505
|
+
const treeConfirmed = attempts.some(
|
|
506
|
+
(attempt) => attempt.outcome === "sent",
|
|
507
|
+
);
|
|
508
|
+
const detail = attempts
|
|
509
|
+
.flatMap((attempt) => ("detail" in attempt ? [attempt.detail] : []))
|
|
510
|
+
.join("; ");
|
|
511
|
+
return {
|
|
512
|
+
signalSent: liveAtStart && signalSent,
|
|
513
|
+
treeConfirmed,
|
|
514
|
+
terminationFailed:
|
|
515
|
+
liveAtStart && !treeConfirmed && (signalSent || !targetExited()),
|
|
516
|
+
detail: detail || undefined,
|
|
517
|
+
};
|
|
282
518
|
});
|
|
283
519
|
});
|
|
284
520
|
}
|
|
285
521
|
|
|
286
522
|
// --- Implementation --------------------------------------------------------------
|
|
287
523
|
|
|
288
|
-
|
|
524
|
+
function* makeManager(maxSpillBytesPerSession: number) {
|
|
289
525
|
// Scoped detached forker for sync contexts (read-model kills, process-event
|
|
290
526
|
// settlement, pruning). Completed fibers remove themselves; manager scope
|
|
291
527
|
// close interrupts any work that outlives the bounded disposeAll wait.
|
|
@@ -297,10 +533,15 @@ const makeManager = Effect.gen(function* () {
|
|
|
297
533
|
* races the tool boundary after an id was validated. */
|
|
298
534
|
const settledHistory = new Map<
|
|
299
535
|
string,
|
|
300
|
-
Pick<
|
|
536
|
+
Pick<
|
|
537
|
+
KillResult,
|
|
538
|
+
"title" | "status" | "exit" | "terminationFailed" | "errorText"
|
|
539
|
+
>
|
|
301
540
|
>();
|
|
302
541
|
/** ids with an in-flight kill() collecting the result (settle → consumed). */
|
|
303
542
|
const killInterest = new Map<string, number>();
|
|
543
|
+
/** Settled entries whose copied completion snapshot is still pending delivery. */
|
|
544
|
+
const resultInterest = new Set<string>();
|
|
304
545
|
const listeners = new Set<() => void>();
|
|
305
546
|
const idListeners = new Map<string, Set<() => void>>();
|
|
306
547
|
/** bg_watch chunk listeners, keyed by terminal id. */
|
|
@@ -312,6 +553,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
312
553
|
let reserved = 0;
|
|
313
554
|
let disposed = false;
|
|
314
555
|
let spillDir: string | undefined | null;
|
|
556
|
+
let sessionSpillBytes = 0;
|
|
315
557
|
let onSettled:
|
|
316
558
|
| ((snap: TerminalSnapshot, consumed: boolean) => void)
|
|
317
559
|
| undefined;
|
|
@@ -372,12 +614,37 @@ const makeManager = Effect.gen(function* () {
|
|
|
372
614
|
const closeEntryScope = (entry: Entry) =>
|
|
373
615
|
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
|
|
374
616
|
|
|
617
|
+
const removeEntrySpillsNow = (entry: Entry) => {
|
|
618
|
+
for (const spill of entry.spillFiles) {
|
|
619
|
+
try {
|
|
620
|
+
fs.rmSync(spill.path, { force: true });
|
|
621
|
+
} catch {
|
|
622
|
+
// Retain the reservation when deletion fails. The session budget
|
|
623
|
+
// remains fail-closed and disposeAll still removes the private dir.
|
|
624
|
+
}
|
|
625
|
+
if (!fs.existsSync(spill.path)) {
|
|
626
|
+
sessionSpillBytes = Math.max(
|
|
627
|
+
0,
|
|
628
|
+
sessionSpillBytes - spill.reservedBytes,
|
|
629
|
+
);
|
|
630
|
+
spill.reservedBytes = 0;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
entry.stdoutBuf.spillPath = undefined;
|
|
634
|
+
entry.stderrBuf.spillPath = undefined;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const removeEntrySpills = (entry: Entry) =>
|
|
638
|
+
Effect.sync(() => removeEntrySpillsNow(entry));
|
|
639
|
+
|
|
375
640
|
const pruneSettled = () => {
|
|
376
641
|
if (entries.size <= MAX_TRACKED) return;
|
|
377
642
|
const candidates = [...entries.values()]
|
|
378
643
|
.filter(
|
|
379
644
|
(e) =>
|
|
380
|
-
e.snapshot.status !== "running" &&
|
|
645
|
+
e.snapshot.status !== "running" &&
|
|
646
|
+
!killInterest.has(e.snapshot.id) &&
|
|
647
|
+
!resultInterest.has(e.snapshot.id),
|
|
381
648
|
)
|
|
382
649
|
.sort(
|
|
383
650
|
(a, b) =>
|
|
@@ -387,15 +654,48 @@ const makeManager = Effect.gen(function* () {
|
|
|
387
654
|
for (const entry of candidates) {
|
|
388
655
|
if (entries.size <= MAX_TRACKED) break;
|
|
389
656
|
entries.delete(entry.snapshot.id);
|
|
390
|
-
|
|
657
|
+
removeEntrySpillsNow(entry);
|
|
658
|
+
runCleanup(
|
|
659
|
+
closeEntryScope(entry).pipe(Effect.andThen(removeEntrySpills(entry))),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
/** Reclaim settled entries before a new child can emit its first chunk. */
|
|
665
|
+
const prepareForStart = () => {
|
|
666
|
+
while (entries.size >= MAX_TRACKED) {
|
|
667
|
+
const candidate = [...entries.values()]
|
|
668
|
+
.filter(
|
|
669
|
+
(e) =>
|
|
670
|
+
e.snapshot.status !== "running" &&
|
|
671
|
+
!killInterest.has(e.snapshot.id) &&
|
|
672
|
+
!resultInterest.has(e.snapshot.id),
|
|
673
|
+
)
|
|
674
|
+
.sort(
|
|
675
|
+
(a, b) =>
|
|
676
|
+
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
|
|
677
|
+
(b.snapshot.settledAt ?? b.snapshot.createdAt),
|
|
678
|
+
)[0];
|
|
679
|
+
if (!candidate) break;
|
|
680
|
+
|
|
681
|
+
// Release the spill reservation synchronously. The scope close is still
|
|
682
|
+
// detached, but no new terminal can observe the old budget as occupied.
|
|
683
|
+
entries.delete(candidate.snapshot.id);
|
|
684
|
+
removeEntrySpillsNow(candidate);
|
|
685
|
+
runCleanup(
|
|
686
|
+
closeEntryScope(candidate).pipe(
|
|
687
|
+
Effect.andThen(removeEntrySpills(candidate)),
|
|
688
|
+
),
|
|
689
|
+
);
|
|
391
690
|
}
|
|
392
691
|
};
|
|
393
692
|
|
|
394
693
|
/** End all spill streams; resolves when their buffers are flushed to disk
|
|
395
694
|
* (bounded), so a settle notification never points at a partial file. */
|
|
396
695
|
const flushSpillStreams = (entry: Entry) => {
|
|
397
|
-
const streams = entry.
|
|
398
|
-
|
|
696
|
+
const streams = entry.spillFiles
|
|
697
|
+
.map((spill) => spill.file)
|
|
698
|
+
.filter((stream) => !stream.writableEnded);
|
|
399
699
|
return Effect.forEach(
|
|
400
700
|
streams,
|
|
401
701
|
(stream) =>
|
|
@@ -416,8 +716,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
416
716
|
Effect.sync(() => {
|
|
417
717
|
entry.stdoutBuf.spillPath = undefined;
|
|
418
718
|
entry.stderrBuf.spillPath = undefined;
|
|
419
|
-
|
|
420
|
-
|
|
719
|
+
appendSnapshotError(
|
|
720
|
+
entry.snapshot,
|
|
721
|
+
"Full-log spill flush timed out; full output may be incomplete",
|
|
722
|
+
);
|
|
421
723
|
}),
|
|
422
724
|
}),
|
|
423
725
|
);
|
|
@@ -432,7 +734,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
432
734
|
s.status = entry.timedOut
|
|
433
735
|
? "timed_out"
|
|
434
736
|
: entry.killSignaled
|
|
435
|
-
?
|
|
737
|
+
? entry.terminationConfirmed
|
|
738
|
+
? "killed"
|
|
739
|
+
: "failed"
|
|
436
740
|
: entry.processErrored
|
|
437
741
|
? "failed"
|
|
438
742
|
: s.exitCode === 0
|
|
@@ -444,6 +748,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
444
748
|
title: s.title,
|
|
445
749
|
status: s.status,
|
|
446
750
|
exit: formatExit(s),
|
|
751
|
+
terminationFailed:
|
|
752
|
+
entry.terminationFailed ||
|
|
753
|
+
(entry.killSignaled && !entry.terminationConfirmed),
|
|
754
|
+
errorText: s.errorText,
|
|
447
755
|
});
|
|
448
756
|
while (settledHistory.size > MAX_SETTLED_HISTORY) {
|
|
449
757
|
const oldest = settledHistory.keys().next().value;
|
|
@@ -525,9 +833,21 @@ const makeManager = Effect.gen(function* () {
|
|
|
525
833
|
flags: "a",
|
|
526
834
|
mode: 0o600,
|
|
527
835
|
});
|
|
836
|
+
const spillFile: SpillFile = {
|
|
837
|
+
path: spillPath,
|
|
838
|
+
file,
|
|
839
|
+
reservedBytes: 0,
|
|
840
|
+
};
|
|
528
841
|
let broken = false;
|
|
529
842
|
let capped = false;
|
|
530
|
-
|
|
843
|
+
const markUnavailable = (message: string) => {
|
|
844
|
+
capped = true;
|
|
845
|
+
const current = entry();
|
|
846
|
+
if (!current) return;
|
|
847
|
+
const buf = stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
|
|
848
|
+
buf.spillPath = undefined;
|
|
849
|
+
appendSnapshotError(current.snapshot, message);
|
|
850
|
+
};
|
|
531
851
|
file.on("error", (error) => {
|
|
532
852
|
broken = true;
|
|
533
853
|
resumeSource();
|
|
@@ -536,7 +856,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
536
856
|
const buf =
|
|
537
857
|
stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
|
|
538
858
|
buf.spillPath = undefined;
|
|
539
|
-
|
|
859
|
+
appendSnapshotError(
|
|
860
|
+
current.snapshot,
|
|
540
861
|
`Full-log spill to ${spillPath} failed: ${boundedError(error)}`,
|
|
541
862
|
);
|
|
542
863
|
}
|
|
@@ -544,25 +865,29 @@ const makeManager = Effect.gen(function* () {
|
|
|
544
865
|
return {
|
|
545
866
|
spillPath,
|
|
546
867
|
file,
|
|
868
|
+
spillFile,
|
|
547
869
|
write: (chunk: string) => {
|
|
548
870
|
// writableEnded guard: late 'data' after the settle flush must not
|
|
549
871
|
// error the ended stream (and falsely report the spill as broken).
|
|
550
872
|
if (broken || capped || file.writableEnded) return true;
|
|
551
873
|
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
current.snapshot.errorText ??= bounded(
|
|
560
|
-
`${stream} full-log spill reached the ${MAX_SPILL_BYTES_PER_STREAM}-byte safety limit`,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
874
|
+
if (
|
|
875
|
+
spillFile.reservedBytes + chunkBytes >
|
|
876
|
+
MAX_SPILL_BYTES_PER_STREAM
|
|
877
|
+
) {
|
|
878
|
+
markUnavailable(
|
|
879
|
+
`Complete ${stream} log is unavailable: the per-stream spill limit of ${MAX_SPILL_BYTES_PER_STREAM} bytes would be exceeded`,
|
|
880
|
+
);
|
|
563
881
|
return true;
|
|
564
882
|
}
|
|
565
|
-
|
|
883
|
+
if (sessionSpillBytes + chunkBytes > maxSpillBytesPerSession) {
|
|
884
|
+
markUnavailable(
|
|
885
|
+
`Complete ${stream} log is unavailable: the session spill budget of ${maxSpillBytesPerSession} bytes would be exceeded`,
|
|
886
|
+
);
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
spillFile.reservedBytes += chunkBytes;
|
|
890
|
+
sessionSpillBytes += chunkBytes;
|
|
566
891
|
const accepted = file.write(chunk);
|
|
567
892
|
if (!accepted) file.once("drain", resumeSource);
|
|
568
893
|
return accepted;
|
|
@@ -595,7 +920,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
595
920
|
);
|
|
596
921
|
|
|
597
922
|
const doStart = Effect.gen(function* () {
|
|
598
|
-
|
|
923
|
+
yield* Effect.sync(prepareForStart);
|
|
924
|
+
const { shell, args, windowsVerbatimArguments } = shellInvocation(
|
|
925
|
+
options.command,
|
|
926
|
+
);
|
|
599
927
|
const child = yield* Effect.try({
|
|
600
928
|
try: () =>
|
|
601
929
|
spawn(shell, args, {
|
|
@@ -606,6 +934,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
606
934
|
stdio: ["ignore", "pipe", "pipe"],
|
|
607
935
|
// Own process group on POSIX → group kill takes the whole tree.
|
|
608
936
|
detached: process.platform !== "win32",
|
|
937
|
+
windowsVerbatimArguments,
|
|
609
938
|
}),
|
|
610
939
|
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
611
940
|
});
|
|
@@ -657,8 +986,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
657
986
|
scope,
|
|
658
987
|
stdoutBuf,
|
|
659
988
|
stderrBuf,
|
|
660
|
-
|
|
661
|
-
(file): file is
|
|
989
|
+
spillFiles: [stdoutSpill?.spillFile, stderrSpill?.spillFile].filter(
|
|
990
|
+
(file): file is SpillFile => file !== undefined,
|
|
662
991
|
),
|
|
663
992
|
killSignaled: false,
|
|
664
993
|
timedOut: false,
|
|
@@ -666,6 +995,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
666
995
|
exited: false,
|
|
667
996
|
stdioClosed: false,
|
|
668
997
|
settling: false,
|
|
998
|
+
terminationInFlight: false,
|
|
999
|
+
terminationConfirmed: false,
|
|
1000
|
+
terminationFailed: false,
|
|
669
1001
|
exitCleanupStarted: false,
|
|
670
1002
|
settled,
|
|
671
1003
|
};
|
|
@@ -732,7 +1064,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
732
1064
|
snapshot.exitCode ??= code ?? undefined;
|
|
733
1065
|
snapshot.signal ??= signal ?? undefined;
|
|
734
1066
|
}
|
|
735
|
-
settleAfterFlush(entry);
|
|
1067
|
+
if (!entry.terminationInFlight) settleAfterFlush(entry);
|
|
736
1068
|
});
|
|
737
1069
|
|
|
738
1070
|
// One teardown path: kill(), requestKill, pruning, disposeAll, and
|
|
@@ -740,19 +1072,40 @@ const makeManager = Effect.gen(function* () {
|
|
|
740
1072
|
yield* Scope.provide(
|
|
741
1073
|
Effect.addFinalizer(() =>
|
|
742
1074
|
Effect.gen(function* () {
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
yield* terminateChild(
|
|
1075
|
+
// Defer a concurrent close settlement until the awaited helper
|
|
1076
|
+
// result tells us whether the process-tree promise was met.
|
|
1077
|
+
entry.terminationInFlight = true;
|
|
1078
|
+
const termination = yield* terminateChild(
|
|
747
1079
|
child,
|
|
748
1080
|
() => entry.stdioClosed,
|
|
749
|
-
() =>
|
|
750
|
-
entry.killSignaled ||=
|
|
751
|
-
!entry.timedOut &&
|
|
752
|
-
!entry.exited &&
|
|
753
|
-
entry.snapshot.status === "running";
|
|
754
|
-
},
|
|
1081
|
+
() => entry.exited,
|
|
755
1082
|
);
|
|
1083
|
+
entry.killSignaled ||=
|
|
1084
|
+
!entry.timedOut &&
|
|
1085
|
+
termination.signalSent &&
|
|
1086
|
+
entry.snapshot.status === "running";
|
|
1087
|
+
entry.terminationConfirmed ||=
|
|
1088
|
+
termination.signalSent && termination.treeConfirmed;
|
|
1089
|
+
entry.terminationFailed ||=
|
|
1090
|
+
!entry.timedOut && termination.terminationFailed;
|
|
1091
|
+
if (
|
|
1092
|
+
!termination.treeConfirmed &&
|
|
1093
|
+
termination.detail &&
|
|
1094
|
+
(termination.signalSent || !entry.exited)
|
|
1095
|
+
) {
|
|
1096
|
+
appendSnapshotError(
|
|
1097
|
+
entry.snapshot,
|
|
1098
|
+
`Process-tree termination could not be confirmed: ${termination.detail}`,
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
entry.terminationInFlight = false;
|
|
1102
|
+
if (
|
|
1103
|
+
entry.stdioClosed &&
|
|
1104
|
+
entry.snapshot.status === "running" &&
|
|
1105
|
+
!entry.settling
|
|
1106
|
+
) {
|
|
1107
|
+
settleAfterFlush(entry);
|
|
1108
|
+
}
|
|
756
1109
|
// Give the natural close→flush→settle path a bounded grace,
|
|
757
1110
|
// then force the settle: a grandchild holding the pipe open
|
|
758
1111
|
// (detached into a new group) must not leave the entry
|
|
@@ -769,8 +1122,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
769
1122
|
// SPILL_FLUSH_TIMEOUT_MS) — settling here first would cite a
|
|
770
1123
|
// spill file that is still being flushed.
|
|
771
1124
|
if (!entry.stdioClosed) {
|
|
772
|
-
|
|
773
|
-
|
|
1125
|
+
appendSnapshotError(
|
|
1126
|
+
entry.snapshot,
|
|
1127
|
+
"stdio did not close after termination; process state and output may be incomplete",
|
|
1128
|
+
);
|
|
774
1129
|
}
|
|
775
1130
|
entry.settling = true;
|
|
776
1131
|
yield* flushSpillStreams(entry);
|
|
@@ -870,10 +1225,15 @@ const makeManager = Effect.gen(function* () {
|
|
|
870
1225
|
// Capture the report BEFORE the ensuring below releases interest and
|
|
871
1226
|
// prunes — a just-settled entry must not vanish out from under it.
|
|
872
1227
|
return unique.map((id): KillResult => {
|
|
873
|
-
const
|
|
1228
|
+
const sourceEntry = byId.get(id);
|
|
1229
|
+
const snapshot = sourceEntry?.snapshot;
|
|
874
1230
|
const history = settledHistory.get(id);
|
|
875
1231
|
const status = snapshot?.status ?? history?.status ?? "killed";
|
|
876
1232
|
const wasRunning = runningIds.includes(id);
|
|
1233
|
+
const terminationFailed = sourceEntry
|
|
1234
|
+
? sourceEntry.terminationFailed ||
|
|
1235
|
+
(sourceEntry.killSignaled && !sourceEntry.terminationConfirmed)
|
|
1236
|
+
: history?.terminationFailed;
|
|
877
1237
|
return {
|
|
878
1238
|
id,
|
|
879
1239
|
title: snapshot?.title ?? history?.title ?? "?",
|
|
@@ -882,6 +1242,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
882
1242
|
// A natural exit can win the race with our SIGTERM; report what
|
|
883
1243
|
// actually happened rather than claiming the kill did it.
|
|
884
1244
|
killed: wasRunning && status === "killed",
|
|
1245
|
+
terminationFailed,
|
|
1246
|
+
errorText: snapshot?.errorText ?? history?.errorText,
|
|
885
1247
|
exit: snapshot
|
|
886
1248
|
? formatExit(snapshot)
|
|
887
1249
|
: (history?.exit ?? "unknown"),
|
|
@@ -902,6 +1264,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
902
1264
|
disposed = true;
|
|
903
1265
|
const all = [...entries.values()];
|
|
904
1266
|
entries.clear();
|
|
1267
|
+
resultInterest.clear();
|
|
905
1268
|
yield* Effect.forEach(
|
|
906
1269
|
all,
|
|
907
1270
|
(entry) =>
|
|
@@ -921,6 +1284,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
921
1284
|
yield* Effect.sync(() => {
|
|
922
1285
|
const dir = spillDir;
|
|
923
1286
|
spillDir = null;
|
|
1287
|
+
sessionSpillBytes = 0;
|
|
924
1288
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
925
1289
|
});
|
|
926
1290
|
yield* Effect.sync(() => notify());
|
|
@@ -965,6 +1329,13 @@ const makeManager = Effect.gen(function* () {
|
|
|
965
1329
|
// back to the model as a follow-up message (subagents precedent).
|
|
966
1330
|
runCleanup(killEntry(entry).pipe(Effect.ignore));
|
|
967
1331
|
},
|
|
1332
|
+
retainResult: (id) => {
|
|
1333
|
+
if (entries.has(id)) resultInterest.add(id);
|
|
1334
|
+
},
|
|
1335
|
+
releaseResult: (id) => {
|
|
1336
|
+
if (!resultInterest.delete(id)) return;
|
|
1337
|
+
pruneSettled();
|
|
1338
|
+
},
|
|
968
1339
|
setOnSettled: (hook) => {
|
|
969
1340
|
onSettled = hook;
|
|
970
1341
|
},
|
|
@@ -982,9 +1353,18 @@ const makeManager = Effect.gen(function* () {
|
|
|
982
1353
|
disposeAll,
|
|
983
1354
|
view,
|
|
984
1355
|
});
|
|
985
|
-
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export function makeTerminalManagerLive(
|
|
1359
|
+
maxSpillBytesPerSession = MAX_SPILL_BYTES_PER_SESSION,
|
|
1360
|
+
) {
|
|
1361
|
+
return Layer.effect(
|
|
1362
|
+
TerminalManager,
|
|
1363
|
+
Effect.gen(() => makeManager(maxSpillBytesPerSession)),
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
986
1366
|
|
|
987
1367
|
export const TerminalManagerLive: Layer.Layer<TerminalManager> = Layer.effect(
|
|
988
1368
|
TerminalManager,
|
|
989
|
-
makeManager,
|
|
1369
|
+
Effect.gen(() => makeManager(MAX_SPILL_BYTES_PER_SESSION)),
|
|
990
1370
|
);
|