@tt-a1i/openpi 0.4.0 → 0.6.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 +116 -46
- package/SETUP.md +29 -7
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +155 -0
- package/extensions/ai-providers/LICENSE.upstream +23 -0
- package/extensions/ai-providers/README.md +59 -0
- package/extensions/ai-providers/antigravity/credentials.ts +52 -0
- package/extensions/ai-providers/antigravity/discovery.ts +130 -0
- package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
- package/extensions/ai-providers/antigravity/models.ts +84 -0
- package/extensions/ai-providers/antigravity/oauth.ts +700 -0
- package/extensions/ai-providers/antigravity/provider.ts +1116 -0
- package/extensions/ai-providers/antigravity/routing.ts +340 -0
- package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
- package/extensions/ai-providers/cursor/constants.ts +5 -0
- package/extensions/ai-providers/cursor/credentials.ts +14 -0
- package/extensions/ai-providers/cursor/discovery.ts +291 -0
- package/extensions/ai-providers/cursor/input-images.ts +106 -0
- package/extensions/ai-providers/cursor/models.ts +45 -0
- package/extensions/ai-providers/cursor/oauth.ts +263 -0
- package/extensions/ai-providers/cursor/proto.ts +1064 -0
- package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
- package/extensions/ai-providers/cursor/provider.ts +1175 -0
- package/extensions/ai-providers/cursor/proxy.ts +213 -0
- package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
- package/extensions/ai-providers/index.ts +86 -0
- package/extensions/ai-providers/oauth-adapter.ts +81 -0
- package/extensions/ai-providers/usage.ts +10 -0
- package/extensions/background-terminals/index.ts +38 -3
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +484 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +13 -5
- package/extensions/background-terminals/src/result-delivery.ts +47 -24
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/index.ts +68 -27
- package/extensions/cron/schedule.ts +12 -2
- package/extensions/file-mutation-display/render.ts +17 -257
- package/extensions/file-search/src/binaries.ts +57 -41
- package/extensions/git-read/index.ts +1 -3
- package/extensions/model-info/cache-diagnostics.ts +220 -0
- package/extensions/model-info/index.ts +65 -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 +82 -6
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +153 -86
- 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 +138 -130
- package/extensions/shared/activity-status.ts +30 -0
- 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/capability-intent.ts +1 -1
- package/extensions/shared/child-session.ts +457 -21
- package/extensions/shared/completion-inbox.ts +193 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/setup-config.ts +83 -34
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/structured-output.ts +154 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +72 -15
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +29 -2
- 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 +313 -62
- package/extensions/subagents/navigation.ts +34 -5
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +450 -70
- package/extensions/subagents/src/domain.ts +21 -1
- package/extensions/subagents/src/manager.ts +39 -2
- package/extensions/subagents/src/prompt.ts +49 -7
- package/extensions/subagents/src/result-artifact.ts +36 -0
- package/extensions/subagents/src/result-delivery.ts +39 -14
- package/extensions/subagents/src/runtime.ts +15 -1
- package/extensions/subagents/src/ui/takeover.ts +73 -257
- package/extensions/subagents/src/ui/transcript.ts +38 -535
- 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/ui-customization/footer.ts +16 -45
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +42 -6
- package/extensions/web/index.ts +257 -0
- package/extensions/workflows/acceptance.ts +43 -19
- package/extensions/workflows/artifacts.ts +137 -47
- package/extensions/workflows/completion-projection.ts +459 -0
- package/extensions/workflows/coordinator.ts +8 -10
- package/extensions/workflows/dashboard.ts +175 -228
- package/extensions/workflows/handoff.ts +70 -16
- package/extensions/workflows/index.ts +501 -198
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +79 -5
- package/extensions/workflows/navigation.ts +32 -8
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +70 -16
- package/extensions/workflows/replay-safety.ts +42 -21
- package/extensions/workflows/result-delivery.ts +214 -76
- package/extensions/workflows/retention.ts +599 -0
- package/extensions/workflows/runner.ts +389 -345
- package/extensions/workflows/sandbox-child.cjs +25 -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 +34 -14
- package/skills/subagents/REFERENCE.md +190 -0
- package/skills/subagents/SKILL.md +2 -1
- package/skills/workflows/REFERENCE.md +6 -4
- package/skills/workflows/SKILL.md +1 -1
- package/web/adapter/pi-adapter.ts +664 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/pi-coding-agent-entry.ts +162 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +1069 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +467 -0
- package/web/runtime/pi-runtime.ts +1206 -0
- package/web/runtime/types.ts +102 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1700 -0
- package/web/ui/index.html +142 -0
- package/web/ui/styles.css +680 -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 -303
|
@@ -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,238 @@ 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
|
+
// Closing the helper or observing the shell exit does not prove that all
|
|
399
|
+
// descendants exited. Preserve uncertainty instead of killing only the shell.
|
|
400
|
+
if (attempt.outcome === "timed_out") {
|
|
401
|
+
return { outcome: "unresolved", detail };
|
|
402
|
+
}
|
|
403
|
+
// A failed graceful taskkill must leave the shell PID alive for the
|
|
404
|
+
// serialized `/T /F` phase. Killing only the shell here would orphan its
|
|
405
|
+
// descendants and make the original process-tree handle unusable.
|
|
406
|
+
if (
|
|
407
|
+
signal === "SIGTERM" &&
|
|
408
|
+
attempt.outcome !== "launch_failed" &&
|
|
409
|
+
!targetExited()
|
|
410
|
+
) {
|
|
411
|
+
return { outcome: "failed", detail };
|
|
412
|
+
}
|
|
413
|
+
return directSignal(child, signal, targetExited, detail);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Signal the whole process group on POSIX so descendants (servers a shell
|
|
417
|
+
* command spawned) die with it; return exact evidence for every fallback. */
|
|
418
|
+
function signalProcessTree(
|
|
419
|
+
child: ChildProcess,
|
|
420
|
+
signal: NodeJS.Signals,
|
|
421
|
+
targetExited: () => boolean,
|
|
422
|
+
) {
|
|
423
|
+
if (process.platform === "win32") {
|
|
424
|
+
return Effect.promise(() =>
|
|
425
|
+
signalWindowsProcessTree(child, signal, targetExited),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return Effect.sync((): ProcessTreeSignalResult => {
|
|
429
|
+
if (child.pid) {
|
|
430
|
+
try {
|
|
431
|
+
process.kill(-child.pid, signal);
|
|
432
|
+
return { outcome: "sent" };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return directSignal(
|
|
435
|
+
child,
|
|
436
|
+
signal,
|
|
437
|
+
targetExited,
|
|
438
|
+
`process-group ${signal} failed: ${boundedError(error)}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return directSignal(
|
|
443
|
+
child,
|
|
444
|
+
signal,
|
|
445
|
+
targetExited,
|
|
446
|
+
"process-group signal unavailable because the child has no pid",
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
244
451
|
/** Await stdio closure without retaining a listener after interruption. */
|
|
245
452
|
function awaitChildClose(child: ChildProcess, closed: () => boolean) {
|
|
246
453
|
return Effect.callback<void>((resume) => {
|
|
@@ -260,32 +467,59 @@ function awaitChildClose(child: ChildProcess, closed: () => boolean) {
|
|
|
260
467
|
function terminateChild(
|
|
261
468
|
child: ChildProcess,
|
|
262
469
|
closed: () => boolean,
|
|
263
|
-
|
|
470
|
+
targetExited: () => boolean,
|
|
264
471
|
) {
|
|
265
472
|
return Effect.suspend(() => {
|
|
266
|
-
if (closed())
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
473
|
+
if (closed()) {
|
|
474
|
+
return Effect.succeed({
|
|
475
|
+
signalSent: false,
|
|
476
|
+
treeConfirmed: true,
|
|
477
|
+
terminationFailed: false,
|
|
478
|
+
detail: undefined as string | undefined,
|
|
271
479
|
});
|
|
480
|
+
}
|
|
481
|
+
const liveAtStart = !targetExited();
|
|
482
|
+
return Effect.gen(function* () {
|
|
483
|
+
const attempts: ProcessTreeSignalResult[] = [];
|
|
484
|
+
const gracefulDeadline = Date.now() + FORCE_KILL_AFTER_MS;
|
|
485
|
+
const graceful = yield* signalProcessTree(child, "SIGTERM", targetExited);
|
|
486
|
+
attempts.push(graceful);
|
|
272
487
|
yield* awaitChildClose(child, closed).pipe(
|
|
273
|
-
Effect.timeout(
|
|
488
|
+
Effect.timeout(Math.max(0, gracefulDeadline - Date.now())),
|
|
274
489
|
Effect.ignore,
|
|
275
490
|
);
|
|
276
|
-
if (closed())
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
491
|
+
if (!closed() && graceful.outcome !== "unresolved") {
|
|
492
|
+
const forceDeadline = Date.now() + FORCE_CLOSE_WAIT_MS;
|
|
493
|
+
attempts.push(yield* signalProcessTree(child, "SIGKILL", targetExited));
|
|
494
|
+
yield* awaitChildClose(child, closed).pipe(
|
|
495
|
+
Effect.timeout(Math.max(0, forceDeadline - Date.now())),
|
|
496
|
+
Effect.ignore,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const signalSent = attempts.some(
|
|
500
|
+
(attempt) =>
|
|
501
|
+
attempt.outcome === "sent" || attempt.outcome === "fallback_sent",
|
|
281
502
|
);
|
|
503
|
+
const treeConfirmed = attempts.some(
|
|
504
|
+
(attempt) => attempt.outcome === "sent",
|
|
505
|
+
);
|
|
506
|
+
const detail = attempts
|
|
507
|
+
.flatMap((attempt) => ("detail" in attempt ? [attempt.detail] : []))
|
|
508
|
+
.join("; ");
|
|
509
|
+
return {
|
|
510
|
+
signalSent: liveAtStart && signalSent,
|
|
511
|
+
treeConfirmed,
|
|
512
|
+
terminationFailed:
|
|
513
|
+
liveAtStart && !treeConfirmed && (signalSent || !targetExited()),
|
|
514
|
+
detail: detail || undefined,
|
|
515
|
+
};
|
|
282
516
|
});
|
|
283
517
|
});
|
|
284
518
|
}
|
|
285
519
|
|
|
286
520
|
// --- Implementation --------------------------------------------------------------
|
|
287
521
|
|
|
288
|
-
|
|
522
|
+
function* makeManager(maxSpillBytesPerSession: number) {
|
|
289
523
|
// Scoped detached forker for sync contexts (read-model kills, process-event
|
|
290
524
|
// settlement, pruning). Completed fibers remove themselves; manager scope
|
|
291
525
|
// close interrupts any work that outlives the bounded disposeAll wait.
|
|
@@ -297,10 +531,15 @@ const makeManager = Effect.gen(function* () {
|
|
|
297
531
|
* races the tool boundary after an id was validated. */
|
|
298
532
|
const settledHistory = new Map<
|
|
299
533
|
string,
|
|
300
|
-
Pick<
|
|
534
|
+
Pick<
|
|
535
|
+
KillResult,
|
|
536
|
+
"title" | "status" | "exit" | "terminationFailed" | "errorText"
|
|
537
|
+
>
|
|
301
538
|
>();
|
|
302
539
|
/** ids with an in-flight kill() collecting the result (settle → consumed). */
|
|
303
540
|
const killInterest = new Map<string, number>();
|
|
541
|
+
/** Settled entries whose copied completion snapshot is still pending delivery. */
|
|
542
|
+
const resultInterest = new Set<string>();
|
|
304
543
|
const listeners = new Set<() => void>();
|
|
305
544
|
const idListeners = new Map<string, Set<() => void>>();
|
|
306
545
|
/** bg_watch chunk listeners, keyed by terminal id. */
|
|
@@ -312,6 +551,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
312
551
|
let reserved = 0;
|
|
313
552
|
let disposed = false;
|
|
314
553
|
let spillDir: string | undefined | null;
|
|
554
|
+
let sessionSpillBytes = 0;
|
|
315
555
|
let onSettled:
|
|
316
556
|
| ((snap: TerminalSnapshot, consumed: boolean) => void)
|
|
317
557
|
| undefined;
|
|
@@ -372,12 +612,37 @@ const makeManager = Effect.gen(function* () {
|
|
|
372
612
|
const closeEntryScope = (entry: Entry) =>
|
|
373
613
|
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
|
|
374
614
|
|
|
615
|
+
const removeEntrySpillsNow = (entry: Entry) => {
|
|
616
|
+
for (const spill of entry.spillFiles) {
|
|
617
|
+
try {
|
|
618
|
+
fs.rmSync(spill.path, { force: true });
|
|
619
|
+
} catch {
|
|
620
|
+
// Retain the reservation when deletion fails. The session budget
|
|
621
|
+
// remains fail-closed and disposeAll still removes the private dir.
|
|
622
|
+
}
|
|
623
|
+
if (!fs.existsSync(spill.path)) {
|
|
624
|
+
sessionSpillBytes = Math.max(
|
|
625
|
+
0,
|
|
626
|
+
sessionSpillBytes - spill.reservedBytes,
|
|
627
|
+
);
|
|
628
|
+
spill.reservedBytes = 0;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
entry.stdoutBuf.spillPath = undefined;
|
|
632
|
+
entry.stderrBuf.spillPath = undefined;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const removeEntrySpills = (entry: Entry) =>
|
|
636
|
+
Effect.sync(() => removeEntrySpillsNow(entry));
|
|
637
|
+
|
|
375
638
|
const pruneSettled = () => {
|
|
376
639
|
if (entries.size <= MAX_TRACKED) return;
|
|
377
640
|
const candidates = [...entries.values()]
|
|
378
641
|
.filter(
|
|
379
642
|
(e) =>
|
|
380
|
-
e.snapshot.status !== "running" &&
|
|
643
|
+
e.snapshot.status !== "running" &&
|
|
644
|
+
!killInterest.has(e.snapshot.id) &&
|
|
645
|
+
!resultInterest.has(e.snapshot.id),
|
|
381
646
|
)
|
|
382
647
|
.sort(
|
|
383
648
|
(a, b) =>
|
|
@@ -387,15 +652,48 @@ const makeManager = Effect.gen(function* () {
|
|
|
387
652
|
for (const entry of candidates) {
|
|
388
653
|
if (entries.size <= MAX_TRACKED) break;
|
|
389
654
|
entries.delete(entry.snapshot.id);
|
|
390
|
-
|
|
655
|
+
removeEntrySpillsNow(entry);
|
|
656
|
+
runCleanup(
|
|
657
|
+
closeEntryScope(entry).pipe(Effect.andThen(removeEntrySpills(entry))),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
/** Reclaim settled entries before a new child can emit its first chunk. */
|
|
663
|
+
const prepareForStart = () => {
|
|
664
|
+
while (entries.size >= MAX_TRACKED) {
|
|
665
|
+
const candidate = [...entries.values()]
|
|
666
|
+
.filter(
|
|
667
|
+
(e) =>
|
|
668
|
+
e.snapshot.status !== "running" &&
|
|
669
|
+
!killInterest.has(e.snapshot.id) &&
|
|
670
|
+
!resultInterest.has(e.snapshot.id),
|
|
671
|
+
)
|
|
672
|
+
.sort(
|
|
673
|
+
(a, b) =>
|
|
674
|
+
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
|
|
675
|
+
(b.snapshot.settledAt ?? b.snapshot.createdAt),
|
|
676
|
+
)[0];
|
|
677
|
+
if (!candidate) break;
|
|
678
|
+
|
|
679
|
+
// Release the spill reservation synchronously. The scope close is still
|
|
680
|
+
// detached, but no new terminal can observe the old budget as occupied.
|
|
681
|
+
entries.delete(candidate.snapshot.id);
|
|
682
|
+
removeEntrySpillsNow(candidate);
|
|
683
|
+
runCleanup(
|
|
684
|
+
closeEntryScope(candidate).pipe(
|
|
685
|
+
Effect.andThen(removeEntrySpills(candidate)),
|
|
686
|
+
),
|
|
687
|
+
);
|
|
391
688
|
}
|
|
392
689
|
};
|
|
393
690
|
|
|
394
691
|
/** End all spill streams; resolves when their buffers are flushed to disk
|
|
395
692
|
* (bounded), so a settle notification never points at a partial file. */
|
|
396
693
|
const flushSpillStreams = (entry: Entry) => {
|
|
397
|
-
const streams = entry.
|
|
398
|
-
|
|
694
|
+
const streams = entry.spillFiles
|
|
695
|
+
.map((spill) => spill.file)
|
|
696
|
+
.filter((stream) => !stream.writableEnded);
|
|
399
697
|
return Effect.forEach(
|
|
400
698
|
streams,
|
|
401
699
|
(stream) =>
|
|
@@ -416,8 +714,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
416
714
|
Effect.sync(() => {
|
|
417
715
|
entry.stdoutBuf.spillPath = undefined;
|
|
418
716
|
entry.stderrBuf.spillPath = undefined;
|
|
419
|
-
|
|
420
|
-
|
|
717
|
+
appendSnapshotError(
|
|
718
|
+
entry.snapshot,
|
|
719
|
+
"Full-log spill flush timed out; full output may be incomplete",
|
|
720
|
+
);
|
|
421
721
|
}),
|
|
422
722
|
}),
|
|
423
723
|
);
|
|
@@ -432,7 +732,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
432
732
|
s.status = entry.timedOut
|
|
433
733
|
? "timed_out"
|
|
434
734
|
: entry.killSignaled
|
|
435
|
-
?
|
|
735
|
+
? entry.terminationConfirmed
|
|
736
|
+
? "killed"
|
|
737
|
+
: "failed"
|
|
436
738
|
: entry.processErrored
|
|
437
739
|
? "failed"
|
|
438
740
|
: s.exitCode === 0
|
|
@@ -444,6 +746,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
444
746
|
title: s.title,
|
|
445
747
|
status: s.status,
|
|
446
748
|
exit: formatExit(s),
|
|
749
|
+
terminationFailed:
|
|
750
|
+
entry.terminationFailed ||
|
|
751
|
+
(entry.killSignaled && !entry.terminationConfirmed),
|
|
752
|
+
errorText: s.errorText,
|
|
447
753
|
});
|
|
448
754
|
while (settledHistory.size > MAX_SETTLED_HISTORY) {
|
|
449
755
|
const oldest = settledHistory.keys().next().value;
|
|
@@ -525,9 +831,21 @@ const makeManager = Effect.gen(function* () {
|
|
|
525
831
|
flags: "a",
|
|
526
832
|
mode: 0o600,
|
|
527
833
|
});
|
|
834
|
+
const spillFile: SpillFile = {
|
|
835
|
+
path: spillPath,
|
|
836
|
+
file,
|
|
837
|
+
reservedBytes: 0,
|
|
838
|
+
};
|
|
528
839
|
let broken = false;
|
|
529
840
|
let capped = false;
|
|
530
|
-
|
|
841
|
+
const markUnavailable = (message: string) => {
|
|
842
|
+
capped = true;
|
|
843
|
+
const current = entry();
|
|
844
|
+
if (!current) return;
|
|
845
|
+
const buf = stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
|
|
846
|
+
buf.spillPath = undefined;
|
|
847
|
+
appendSnapshotError(current.snapshot, message);
|
|
848
|
+
};
|
|
531
849
|
file.on("error", (error) => {
|
|
532
850
|
broken = true;
|
|
533
851
|
resumeSource();
|
|
@@ -536,7 +854,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
536
854
|
const buf =
|
|
537
855
|
stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
|
|
538
856
|
buf.spillPath = undefined;
|
|
539
|
-
|
|
857
|
+
appendSnapshotError(
|
|
858
|
+
current.snapshot,
|
|
540
859
|
`Full-log spill to ${spillPath} failed: ${boundedError(error)}`,
|
|
541
860
|
);
|
|
542
861
|
}
|
|
@@ -544,25 +863,29 @@ const makeManager = Effect.gen(function* () {
|
|
|
544
863
|
return {
|
|
545
864
|
spillPath,
|
|
546
865
|
file,
|
|
866
|
+
spillFile,
|
|
547
867
|
write: (chunk: string) => {
|
|
548
868
|
// writableEnded guard: late 'data' after the settle flush must not
|
|
549
869
|
// error the ended stream (and falsely report the spill as broken).
|
|
550
870
|
if (broken || capped || file.writableEnded) return true;
|
|
551
871
|
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
|
-
}
|
|
872
|
+
if (
|
|
873
|
+
spillFile.reservedBytes + chunkBytes >
|
|
874
|
+
MAX_SPILL_BYTES_PER_STREAM
|
|
875
|
+
) {
|
|
876
|
+
markUnavailable(
|
|
877
|
+
`Complete ${stream} log is unavailable: the per-stream spill limit of ${MAX_SPILL_BYTES_PER_STREAM} bytes would be exceeded`,
|
|
878
|
+
);
|
|
563
879
|
return true;
|
|
564
880
|
}
|
|
565
|
-
|
|
881
|
+
if (sessionSpillBytes + chunkBytes > maxSpillBytesPerSession) {
|
|
882
|
+
markUnavailable(
|
|
883
|
+
`Complete ${stream} log is unavailable: the session spill budget of ${maxSpillBytesPerSession} bytes would be exceeded`,
|
|
884
|
+
);
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
spillFile.reservedBytes += chunkBytes;
|
|
888
|
+
sessionSpillBytes += chunkBytes;
|
|
566
889
|
const accepted = file.write(chunk);
|
|
567
890
|
if (!accepted) file.once("drain", resumeSource);
|
|
568
891
|
return accepted;
|
|
@@ -595,7 +918,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
595
918
|
);
|
|
596
919
|
|
|
597
920
|
const doStart = Effect.gen(function* () {
|
|
598
|
-
|
|
921
|
+
yield* Effect.sync(prepareForStart);
|
|
922
|
+
const { shell, args, windowsVerbatimArguments } = shellInvocation(
|
|
923
|
+
options.command,
|
|
924
|
+
);
|
|
599
925
|
const child = yield* Effect.try({
|
|
600
926
|
try: () =>
|
|
601
927
|
spawn(shell, args, {
|
|
@@ -606,6 +932,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
606
932
|
stdio: ["ignore", "pipe", "pipe"],
|
|
607
933
|
// Own process group on POSIX → group kill takes the whole tree.
|
|
608
934
|
detached: process.platform !== "win32",
|
|
935
|
+
windowsVerbatimArguments,
|
|
609
936
|
}),
|
|
610
937
|
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
611
938
|
});
|
|
@@ -657,8 +984,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
657
984
|
scope,
|
|
658
985
|
stdoutBuf,
|
|
659
986
|
stderrBuf,
|
|
660
|
-
|
|
661
|
-
(file): file is
|
|
987
|
+
spillFiles: [stdoutSpill?.spillFile, stderrSpill?.spillFile].filter(
|
|
988
|
+
(file): file is SpillFile => file !== undefined,
|
|
662
989
|
),
|
|
663
990
|
killSignaled: false,
|
|
664
991
|
timedOut: false,
|
|
@@ -666,6 +993,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
666
993
|
exited: false,
|
|
667
994
|
stdioClosed: false,
|
|
668
995
|
settling: false,
|
|
996
|
+
terminationInFlight: false,
|
|
997
|
+
terminationConfirmed: false,
|
|
998
|
+
terminationFailed: false,
|
|
669
999
|
exitCleanupStarted: false,
|
|
670
1000
|
settled,
|
|
671
1001
|
};
|
|
@@ -732,7 +1062,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
732
1062
|
snapshot.exitCode ??= code ?? undefined;
|
|
733
1063
|
snapshot.signal ??= signal ?? undefined;
|
|
734
1064
|
}
|
|
735
|
-
settleAfterFlush(entry);
|
|
1065
|
+
if (!entry.terminationInFlight) settleAfterFlush(entry);
|
|
736
1066
|
});
|
|
737
1067
|
|
|
738
1068
|
// One teardown path: kill(), requestKill, pruning, disposeAll, and
|
|
@@ -740,19 +1070,40 @@ const makeManager = Effect.gen(function* () {
|
|
|
740
1070
|
yield* Scope.provide(
|
|
741
1071
|
Effect.addFinalizer(() =>
|
|
742
1072
|
Effect.gen(function* () {
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
yield* terminateChild(
|
|
1073
|
+
// Defer a concurrent close settlement until the awaited helper
|
|
1074
|
+
// result tells us whether the process-tree promise was met.
|
|
1075
|
+
entry.terminationInFlight = true;
|
|
1076
|
+
const termination = yield* terminateChild(
|
|
747
1077
|
child,
|
|
748
1078
|
() => entry.stdioClosed,
|
|
749
|
-
() =>
|
|
750
|
-
entry.killSignaled ||=
|
|
751
|
-
!entry.timedOut &&
|
|
752
|
-
!entry.exited &&
|
|
753
|
-
entry.snapshot.status === "running";
|
|
754
|
-
},
|
|
1079
|
+
() => entry.exited,
|
|
755
1080
|
);
|
|
1081
|
+
entry.killSignaled ||=
|
|
1082
|
+
!entry.timedOut &&
|
|
1083
|
+
termination.signalSent &&
|
|
1084
|
+
entry.snapshot.status === "running";
|
|
1085
|
+
entry.terminationConfirmed ||=
|
|
1086
|
+
termination.signalSent && termination.treeConfirmed;
|
|
1087
|
+
entry.terminationFailed ||=
|
|
1088
|
+
!entry.timedOut && termination.terminationFailed;
|
|
1089
|
+
if (
|
|
1090
|
+
!termination.treeConfirmed &&
|
|
1091
|
+
termination.detail &&
|
|
1092
|
+
(termination.signalSent || !entry.exited)
|
|
1093
|
+
) {
|
|
1094
|
+
appendSnapshotError(
|
|
1095
|
+
entry.snapshot,
|
|
1096
|
+
`Process-tree termination could not be confirmed: ${termination.detail}`,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
entry.terminationInFlight = false;
|
|
1100
|
+
if (
|
|
1101
|
+
entry.stdioClosed &&
|
|
1102
|
+
entry.snapshot.status === "running" &&
|
|
1103
|
+
!entry.settling
|
|
1104
|
+
) {
|
|
1105
|
+
settleAfterFlush(entry);
|
|
1106
|
+
}
|
|
756
1107
|
// Give the natural close→flush→settle path a bounded grace,
|
|
757
1108
|
// then force the settle: a grandchild holding the pipe open
|
|
758
1109
|
// (detached into a new group) must not leave the entry
|
|
@@ -769,8 +1120,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
769
1120
|
// SPILL_FLUSH_TIMEOUT_MS) — settling here first would cite a
|
|
770
1121
|
// spill file that is still being flushed.
|
|
771
1122
|
if (!entry.stdioClosed) {
|
|
772
|
-
|
|
773
|
-
|
|
1123
|
+
appendSnapshotError(
|
|
1124
|
+
entry.snapshot,
|
|
1125
|
+
"stdio did not close after termination; process state and output may be incomplete",
|
|
1126
|
+
);
|
|
774
1127
|
}
|
|
775
1128
|
entry.settling = true;
|
|
776
1129
|
yield* flushSpillStreams(entry);
|
|
@@ -870,10 +1223,15 @@ const makeManager = Effect.gen(function* () {
|
|
|
870
1223
|
// Capture the report BEFORE the ensuring below releases interest and
|
|
871
1224
|
// prunes — a just-settled entry must not vanish out from under it.
|
|
872
1225
|
return unique.map((id): KillResult => {
|
|
873
|
-
const
|
|
1226
|
+
const sourceEntry = byId.get(id);
|
|
1227
|
+
const snapshot = sourceEntry?.snapshot;
|
|
874
1228
|
const history = settledHistory.get(id);
|
|
875
1229
|
const status = snapshot?.status ?? history?.status ?? "killed";
|
|
876
1230
|
const wasRunning = runningIds.includes(id);
|
|
1231
|
+
const terminationFailed = sourceEntry
|
|
1232
|
+
? sourceEntry.terminationFailed ||
|
|
1233
|
+
(sourceEntry.killSignaled && !sourceEntry.terminationConfirmed)
|
|
1234
|
+
: history?.terminationFailed;
|
|
877
1235
|
return {
|
|
878
1236
|
id,
|
|
879
1237
|
title: snapshot?.title ?? history?.title ?? "?",
|
|
@@ -882,6 +1240,8 @@ const makeManager = Effect.gen(function* () {
|
|
|
882
1240
|
// A natural exit can win the race with our SIGTERM; report what
|
|
883
1241
|
// actually happened rather than claiming the kill did it.
|
|
884
1242
|
killed: wasRunning && status === "killed",
|
|
1243
|
+
terminationFailed,
|
|
1244
|
+
errorText: snapshot?.errorText ?? history?.errorText,
|
|
885
1245
|
exit: snapshot
|
|
886
1246
|
? formatExit(snapshot)
|
|
887
1247
|
: (history?.exit ?? "unknown"),
|
|
@@ -902,6 +1262,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
902
1262
|
disposed = true;
|
|
903
1263
|
const all = [...entries.values()];
|
|
904
1264
|
entries.clear();
|
|
1265
|
+
resultInterest.clear();
|
|
905
1266
|
yield* Effect.forEach(
|
|
906
1267
|
all,
|
|
907
1268
|
(entry) =>
|
|
@@ -921,6 +1282,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
921
1282
|
yield* Effect.sync(() => {
|
|
922
1283
|
const dir = spillDir;
|
|
923
1284
|
spillDir = null;
|
|
1285
|
+
sessionSpillBytes = 0;
|
|
924
1286
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
925
1287
|
});
|
|
926
1288
|
yield* Effect.sync(() => notify());
|
|
@@ -965,6 +1327,13 @@ const makeManager = Effect.gen(function* () {
|
|
|
965
1327
|
// back to the model as a follow-up message (subagents precedent).
|
|
966
1328
|
runCleanup(killEntry(entry).pipe(Effect.ignore));
|
|
967
1329
|
},
|
|
1330
|
+
retainResult: (id) => {
|
|
1331
|
+
if (entries.has(id)) resultInterest.add(id);
|
|
1332
|
+
},
|
|
1333
|
+
releaseResult: (id) => {
|
|
1334
|
+
if (!resultInterest.delete(id)) return;
|
|
1335
|
+
pruneSettled();
|
|
1336
|
+
},
|
|
968
1337
|
setOnSettled: (hook) => {
|
|
969
1338
|
onSettled = hook;
|
|
970
1339
|
},
|
|
@@ -982,9 +1351,18 @@ const makeManager = Effect.gen(function* () {
|
|
|
982
1351
|
disposeAll,
|
|
983
1352
|
view,
|
|
984
1353
|
});
|
|
985
|
-
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export function makeTerminalManagerLive(
|
|
1357
|
+
maxSpillBytesPerSession = MAX_SPILL_BYTES_PER_SESSION,
|
|
1358
|
+
) {
|
|
1359
|
+
return Layer.effect(
|
|
1360
|
+
TerminalManager,
|
|
1361
|
+
Effect.gen(() => makeManager(maxSpillBytesPerSession)),
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
986
1364
|
|
|
987
1365
|
export const TerminalManagerLive: Layer.Layer<TerminalManager> = Layer.effect(
|
|
988
1366
|
TerminalManager,
|
|
989
|
-
makeManager,
|
|
1367
|
+
Effect.gen(() => makeManager(MAX_SPILL_BYTES_PER_SESSION)),
|
|
990
1368
|
);
|