pi-cohort 7.6.3 → 7.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/execution-backend/child-host-controller.ts +41 -13
- package/src/execution-backend/reporting-extension.ts +4 -3
- package/src/runs/background/external-attempt.ts +4 -3
- package/src/runs/background/subagent-runner.ts +22 -10
- package/src/runs/foreground/chain-execution.ts +4 -4
- package/src/runs/foreground/execution.ts +14 -3
- package/src/runs/foreground/external-single-attempt.ts +4 -2
- package/src/runs/foreground/subagent-executor.ts +3 -2
- package/src/runs/shared/external-execution.ts +167 -23
- package/src/shared/formatters.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.6.5] - 2026-10-29
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Interrupted external children complete bounded child/host cleanup before settling and remain paused with zero exit and preserved diagnostics. Applied abort evidence handles error-shaped terminals without rewriting an earlier terminal; cleanup uncertainty does not trigger fallback or fail-fast. Normal interruption retains the diagnostic surface, but forced cleanup may close it; `retained` records the last known close outcome, not proof of liveness or inspectability. Unconfirmed termination reports `Process termination could not be confirmed; manual cleanup may be required.` The paired pi-gauntlet completion checks exclude explicitly interrupted children from closure-review and cadence credit. Actual-process tests with fake backends prove core behavior only: the live cmux runtime and reporter's installed adapter version remain unavailable, so no live reproduction or companion retention compliance is claimed. (#36)
|
|
7
|
+
|
|
8
|
+
## [7.6.4] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Async children on external execution backends no longer falsely need attention while a tool call runs past the idle threshold within an open turn; replay now opens the turn, preserving the silence ceiling and final-answer closure. (#37)
|
|
12
|
+
|
|
3
13
|
## [7.6.3] - 2026-10-28
|
|
4
14
|
|
|
5
15
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cohort",
|
|
3
|
-
"version": "7.6.
|
|
3
|
+
"version": "7.6.5",
|
|
4
4
|
"description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,7 @@ import { getPiSpawnCommand, PiRuntimeLaunchError, type PiRuntimeIdentity } from
|
|
|
11
11
|
|
|
12
12
|
export interface ChildHostAttempt { readonly attemptId: string; readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly environment: Readonly<Record<string, string>>; }
|
|
13
13
|
export interface ChildHostExit { readonly status: number | null; readonly signal: string | null; }
|
|
14
|
-
export interface ChildHostController { readonly lease: ExecutionBackendLease; readonly ready: Promise<void>; startAttempt(attempt: ChildHostAttempt): Promise<ChildHostExit>; shutdown(): Promise<void>; releaseObserver(): Promise<void>; }
|
|
14
|
+
export interface ChildHostController { readonly lease: ExecutionBackendLease; readonly ready: Promise<void>; readonly hostExit: Promise<ChildHostExit>; dispose(): void; startAttempt(attempt: ChildHostAttempt): Promise<ChildHostExit>; shutdown(): Promise<void>; releaseObserver(): Promise<void>; }
|
|
15
15
|
export interface ChildHostControllerOptions { readonly runtime: PiRuntimeIdentity; readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
|
|
16
16
|
export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: (root: string) => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
|
|
17
17
|
|
|
@@ -36,6 +36,8 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
36
36
|
let server: net.Server | undefined;
|
|
37
37
|
let socket: net.Socket | undefined;
|
|
38
38
|
let closed = false;
|
|
39
|
+
let disposed = false;
|
|
40
|
+
const launchAborter = new AbortController();
|
|
39
41
|
let abortListener: (() => void) | undefined;
|
|
40
42
|
const cleanup = () => {
|
|
41
43
|
if (abortListener) options.signal.removeEventListener("abort", abortListener);
|
|
@@ -75,10 +77,17 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
75
77
|
rejectReady?.(cause); rejectReady = undefined;
|
|
76
78
|
if (active) { active.reject(cause); active = undefined; }
|
|
77
79
|
if (pendingShutdown) { pendingShutdown.reject(cause); pendingShutdown = undefined; }
|
|
78
|
-
cleanup();
|
|
80
|
+
if (!ready) cleanup();
|
|
81
|
+
};
|
|
82
|
+
const disconnect = () => {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
const cause = disconnected(!ready ? "before ready" : active ? "during attempt" : pendingShutdown ? "before shutdown acknowledgement" : "after ready");
|
|
85
|
+
disconnectCause = cause;
|
|
86
|
+
fail(cause);
|
|
79
87
|
};
|
|
80
88
|
server = (dependencies.createServer ?? net.createServer)(connection => {
|
|
81
89
|
if (socket || closed) { connection.destroy(); return; }
|
|
90
|
+
connection.on("error", disconnect);
|
|
82
91
|
socket = connection;
|
|
83
92
|
const decoder = new ChildHostFrameDecoder();
|
|
84
93
|
connection.on("data", data => {
|
|
@@ -88,7 +97,10 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
88
97
|
if (message.runId !== options.runId || message.childId !== options.childId) { connection.destroy(); return; }
|
|
89
98
|
if (!ready) {
|
|
90
99
|
if (message.kind !== "host_ready") { connection.destroy(); return; }
|
|
91
|
-
ready = true;
|
|
100
|
+
ready = true;
|
|
101
|
+
if (abortListener) options.signal.removeEventListener("abort", abortListener);
|
|
102
|
+
abortListener = undefined;
|
|
103
|
+
resolveReady?.(); resolveReady = undefined; rejectReady = undefined; continue;
|
|
92
104
|
}
|
|
93
105
|
if (message.kind === "attempt_started" && active?.attemptId === message.attemptId && !active.started) { active.started = true; continue; }
|
|
94
106
|
if (message.kind === "attempt_spawn_error" && active?.attemptId === message.attemptId) {
|
|
@@ -98,21 +110,15 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
98
110
|
const pending = active; active = undefined; pending.resolve({ status: message.status, signal: message.signal }); continue;
|
|
99
111
|
}
|
|
100
112
|
if (message.kind === "shutdown_ack" && pendingShutdown && !active) {
|
|
101
|
-
const pending = pendingShutdown; pendingShutdown = undefined; closed = true;
|
|
113
|
+
const pending = pendingShutdown; pendingShutdown = undefined; closed = true; pending.resolve(); continue;
|
|
102
114
|
}
|
|
103
115
|
connection.destroy(); return;
|
|
104
116
|
}
|
|
105
117
|
});
|
|
106
|
-
connection.once("close",
|
|
107
|
-
if (!closed) {
|
|
108
|
-
const cause = disconnected(!ready ? "before ready" : active ? "during attempt" : pendingShutdown ? "before shutdown acknowledgement" : "after ready");
|
|
109
|
-
disconnectCause = cause;
|
|
110
|
-
fail(cause);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
118
|
+
connection.once("close", disconnect);
|
|
113
119
|
});
|
|
114
120
|
server.once("error", cause => fail(cause instanceof Error ? cause : new Error("child host server error")));
|
|
115
|
-
abortListener = () => fail(aborted());
|
|
121
|
+
abortListener = () => { launchAborter.abort(); fail(aborted()); };
|
|
116
122
|
options.signal.addEventListener("abort", abortListener, { once: true });
|
|
117
123
|
await new Promise<void>((resolve, reject) => server!.listen(socketPath!, () => resolve()).once("error", reject));
|
|
118
124
|
fileSystem.chmodSync(socketPath, 0o600);
|
|
@@ -127,13 +133,34 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
127
133
|
environment,
|
|
128
134
|
runId: options.runId,
|
|
129
135
|
childId: options.childId,
|
|
130
|
-
signal:
|
|
136
|
+
signal: launchAborter.signal,
|
|
131
137
|
...(options.title === undefined ? {} : { awareness: { title: options.title } }),
|
|
132
138
|
});
|
|
139
|
+
let resolveHostExit!: (exit: ChildHostExit) => void;
|
|
140
|
+
const hostExit = new Promise<ChildHostExit>(resolve => { resolveHostExit = resolve; });
|
|
141
|
+
void (async () => {
|
|
142
|
+
try {
|
|
143
|
+
for await (const event of lease!.events) {
|
|
144
|
+
if (disposed) break;
|
|
145
|
+
if (event.type === "exited" && event.surface.kind === lease!.handle.surface.kind && event.surface.id === lease!.handle.surface.id) {
|
|
146
|
+
resolveHostExit({ status: event.status, signal: event.signal });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch { /* Missing exit evidence remains unconfirmed. */ }
|
|
150
|
+
})();
|
|
133
151
|
return {
|
|
152
|
+
hostExit,
|
|
153
|
+
dispose() {
|
|
154
|
+
if (disposed) return;
|
|
155
|
+
disposed = true;
|
|
156
|
+
fail(disconnected("after disposal"));
|
|
157
|
+
attemptedIds.clear();
|
|
158
|
+
cleanup();
|
|
159
|
+
},
|
|
134
160
|
get lease() { return lease!; },
|
|
135
161
|
ready: readyPromise,
|
|
136
162
|
startAttempt(attempt) {
|
|
163
|
+
if (socket?.writableEnded || socket?.destroyed) disconnect();
|
|
137
164
|
if (closed || !socket || !ready) return Promise.reject(disconnectCause ?? disconnected("before ready"));
|
|
138
165
|
if (pendingShutdown) return Promise.reject(new Error("child host is shutting down"));
|
|
139
166
|
if (active) return Promise.reject(new Error("child host is busy"));
|
|
@@ -146,6 +173,7 @@ export async function createChildHostController(options: ChildHostControllerOpti
|
|
|
146
173
|
});
|
|
147
174
|
},
|
|
148
175
|
shutdown() {
|
|
176
|
+
if (socket?.writableEnded || socket?.destroyed) disconnect();
|
|
149
177
|
if (closed) return disconnectCause ? Promise.reject(disconnectCause) : Promise.resolve();
|
|
150
178
|
if (!socket || !ready) return Promise.reject(disconnectCause ?? disconnected("before ready"));
|
|
151
179
|
if (active || pendingShutdown) return Promise.reject(new Error("child host is busy"));
|
|
@@ -51,12 +51,12 @@ function append(
|
|
|
51
51
|
} as ExecutionReport);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function result(messages: readonly AgentMessage[]): Omit<ExecutionReport, "protocolVersion" | "runId" | "childId" | "attemptId" | "sequence" | "timestamp"> {
|
|
54
|
+
function result(messages: readonly AgentMessage[], appliedAbort: boolean): Omit<ExecutionReport, "protocolVersion" | "runId" | "childId" | "attemptId" | "sequence" | "timestamp"> {
|
|
55
55
|
messages.forEach(assertNoMalformedStandardMessage);
|
|
56
56
|
const standardMessages = messages.filter(isStandardMessage);
|
|
57
57
|
const finalAssistant = [...standardMessages].reverse().find(isAssistantMessage);
|
|
58
58
|
if (!finalAssistant) return { kind: "result", outcome: "failed", finalOutput: "", error: "result_missing" };
|
|
59
|
-
const interrupted = finalAssistant.stopReason === "aborted";
|
|
59
|
+
const interrupted = appliedAbort || finalAssistant.stopReason === "aborted";
|
|
60
60
|
const failed = !interrupted && (finalAssistant.stopReason === "error" || typeof finalAssistant.errorMessage === "string");
|
|
61
61
|
return {
|
|
62
62
|
kind: "result",
|
|
@@ -134,7 +134,8 @@ export function createExecutionReportingExtension(
|
|
|
134
134
|
if (existing.some((report) => report.kind === "result")) return;
|
|
135
135
|
if (!existing.some((report) => report.kind === "settled")) append(pi, context, config, { kind: "settled" });
|
|
136
136
|
const messages = context.sessionManager.getBranch().flatMap((entry) => entry.type === "message" ? [entry.message] : []);
|
|
137
|
-
|
|
137
|
+
const appliedAbort = existing.some((report) => report.kind === "control" && report.action === "abort" && report.state === "applied");
|
|
138
|
+
append(pi, context, config, result(messages, appliedAbort));
|
|
138
139
|
});
|
|
139
140
|
};
|
|
140
141
|
}
|
|
@@ -76,8 +76,8 @@ export interface ExternalBackgroundAttemptResult {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function mapExitCode(result: ExternalAttemptResult): number | null {
|
|
79
|
-
if (result.outcome === "success") return 0;
|
|
80
|
-
//
|
|
79
|
+
if (result.interrupted || result.outcome === "success") return 0;
|
|
80
|
+
// Non-interrupted failures must not become success from zero exit.
|
|
81
81
|
const code = result.exit.code;
|
|
82
82
|
return code !== null && code !== 0 ? code : 1;
|
|
83
83
|
}
|
|
@@ -123,6 +123,7 @@ function synthesizeChildEvents(message: unknown, emit: (event: SynthesizedChildE
|
|
|
123
123
|
emit({ type: "tool_execution_end" });
|
|
124
124
|
emit({ type: "tool_result_end", message });
|
|
125
125
|
} else if (msg.role === "assistant") {
|
|
126
|
+
emit({ type: "message_start" });
|
|
126
127
|
// Emit tool_execution_start for each tool call in the assistant content.
|
|
127
128
|
if (Array.isArray(msg.content)) {
|
|
128
129
|
for (const part of msg.content) {
|
|
@@ -183,7 +184,7 @@ export async function runExternalBackgroundAttempt(
|
|
|
183
184
|
model: attemptResult.model,
|
|
184
185
|
error: attemptResult.error,
|
|
185
186
|
finalOutput: attemptResult.finalOutput,
|
|
186
|
-
interrupted: attemptResult.
|
|
187
|
+
interrupted: attemptResult.interrupted,
|
|
187
188
|
observedMutationAttempt: observedMutationFromMessages(attemptResult.messages),
|
|
188
189
|
};
|
|
189
190
|
}
|
|
@@ -6,6 +6,7 @@ import * as path from "node:path";
|
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import type { Message } from "@earendil-works/pi-ai";
|
|
8
8
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
9
|
+
import { formatInterruptedCleanup } from "../../shared/formatters.ts";
|
|
9
10
|
import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
|
|
10
11
|
import { getPiSpawnCommand, parsePiRuntime, PiRuntimeLaunchError, type PiRuntimeIdentity } from "../shared/pi-spawn.ts";
|
|
11
12
|
import { captureSingleOutputSnapshot, finalizeSingleOutput, formatFileOnlyFailure, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
|
|
@@ -90,6 +91,7 @@ import type { DetachedExecutionBackendConfig } from "../../execution-backend/typ
|
|
|
90
91
|
import {
|
|
91
92
|
createExternalExecution,
|
|
92
93
|
externalExecutionDiagnosticCode,
|
|
94
|
+
isInterruptedExternalAttemptError,
|
|
93
95
|
type ExternalExecution,
|
|
94
96
|
} from "../shared/external-execution.ts";
|
|
95
97
|
import { runExternalBackgroundAttempt } from "./external-attempt.ts";
|
|
@@ -135,6 +137,7 @@ interface SubagentRunConfig {
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
interface StepResult {
|
|
140
|
+
interrupted?: RunPiStreamingResult["interrupted"];
|
|
138
141
|
agent: string;
|
|
139
142
|
output: string;
|
|
140
143
|
error?: string;
|
|
@@ -823,12 +826,14 @@ export async function runSingleStep(
|
|
|
823
826
|
});
|
|
824
827
|
} catch (error) {
|
|
825
828
|
if (error instanceof PiRuntimeLaunchError) throw error;
|
|
829
|
+
const interrupted = isInterruptedExternalAttemptError(error);
|
|
826
830
|
run = {
|
|
827
831
|
stderr: "",
|
|
828
|
-
exitCode: 1,
|
|
832
|
+
exitCode: interrupted ? 0 : 1,
|
|
833
|
+
interrupted,
|
|
829
834
|
messages: [],
|
|
830
835
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
831
|
-
error: safeExternalFailure("attempt", error),
|
|
836
|
+
error: interrupted ? error.message : safeExternalFailure("attempt", error),
|
|
832
837
|
finalOutput: "",
|
|
833
838
|
executionHandle: externalOwner.handle,
|
|
834
839
|
};
|
|
@@ -837,14 +842,14 @@ export async function runSingleStep(
|
|
|
837
842
|
cleanupTempDir(tempDir); // F1: caller (not helper) cleans up tempDir
|
|
838
843
|
}
|
|
839
844
|
|
|
840
|
-
const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
|
|
845
|
+
const hiddenError = run.exitCode === 0 && !run.interrupted && !run.error ? detectSubagentError(run.messages) : null;
|
|
841
846
|
const blockedOutput = stripAcceptanceReport(getFinalOutput(run.messages));
|
|
842
847
|
const blockedError = run.exitCode === 0 && !run.interrupted && !run.error && !hiddenError?.hasError && blockedLine(blockedOutput)
|
|
843
848
|
? blockedOutput
|
|
844
849
|
: undefined;
|
|
845
850
|
let structuredOutput: unknown;
|
|
846
851
|
let structuredError: string | undefined;
|
|
847
|
-
if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError && !blockedError) {
|
|
852
|
+
if (effectiveStructuredOutput && run.exitCode === 0 && !run.interrupted && !run.error && !hiddenError?.hasError && !blockedError) {
|
|
848
853
|
const structured = readStructuredOutput({
|
|
849
854
|
schema: effectiveStructuredOutput.schema,
|
|
850
855
|
schemaPath: effectiveStructuredOutput.schemaPath,
|
|
@@ -853,7 +858,7 @@ export async function runSingleStep(
|
|
|
853
858
|
if (structured.error) structuredError = structured.error;
|
|
854
859
|
else structuredOutput = structured.value;
|
|
855
860
|
}
|
|
856
|
-
const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && !blockedError && step.completionGuard !== false
|
|
861
|
+
const completionGuard = run.exitCode === 0 && !run.interrupted && !run.error && !hiddenError?.hasError && !blockedError && step.completionGuard !== false
|
|
857
862
|
? evaluateCompletionMutationGuard({
|
|
858
863
|
agent: step.agent,
|
|
859
864
|
task: taskForCompletionGuard,
|
|
@@ -871,7 +876,7 @@ export async function runSingleStep(
|
|
|
871
876
|
? 1
|
|
872
877
|
: hiddenError?.hasError
|
|
873
878
|
? (hiddenError.exitCode ?? 1)
|
|
874
|
-
: run.error && run.exitCode === 0
|
|
879
|
+
: run.error && !run.interrupted && run.exitCode === 0
|
|
875
880
|
? 1
|
|
876
881
|
: run.exitCode;
|
|
877
882
|
const error = blockedError
|
|
@@ -884,7 +889,7 @@ export async function runSingleStep(
|
|
|
884
889
|
: run.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined));
|
|
885
890
|
const attempt: ModelAttempt = {
|
|
886
891
|
model: candidate ?? run.model ?? step.model ?? "default",
|
|
887
|
-
success: effectiveExitCode === 0 && !error,
|
|
892
|
+
success: effectiveExitCode === 0 && (run.interrupted || !error),
|
|
888
893
|
exitCode: effectiveExitCode,
|
|
889
894
|
error,
|
|
890
895
|
usage: run.usage,
|
|
@@ -894,7 +899,7 @@ export async function runSingleStep(
|
|
|
894
899
|
completionGuardTriggeredFinal = completionGuardTriggered;
|
|
895
900
|
finalOutputSnapshot = outputSnapshot;
|
|
896
901
|
finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
|
|
897
|
-
if (attempt.success || completionGuardTriggered || blockedError) break;
|
|
902
|
+
if (run.interrupted || attempt.success || completionGuardTriggered || blockedError) break;
|
|
898
903
|
if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
|
|
899
904
|
attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
|
|
900
905
|
}
|
|
@@ -1145,7 +1150,7 @@ export async function runSingleStep(
|
|
|
1145
1150
|
|
|
1146
1151
|
// Finish only after core finalization, retaining every failed external leaf.
|
|
1147
1152
|
if (externalOwner) {
|
|
1148
|
-
const isDelivered = effectiveFinalExitCode === 0 && !acceptanceCanFailRun;
|
|
1153
|
+
const isDelivered = effectiveFinalExitCode === 0 && !acceptanceCanFailRun && !finalResult?.interrupted;
|
|
1149
1154
|
let finishResult: Awaited<ReturnType<ExternalExecution["finish"]>>;
|
|
1150
1155
|
try {
|
|
1151
1156
|
finishResult = await externalOwner.finish(isDelivered ? "delivered" : "retained");
|
|
@@ -1998,6 +2003,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
1998
2003
|
onChildEvent: (event) => updateStepFromChildEvent(fi, event),
|
|
1999
2004
|
});
|
|
2000
2005
|
const taskEndTime = Date.now();
|
|
2006
|
+
if (singleResult.interrupted) interrupted = true;
|
|
2001
2007
|
statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
|
|
2002
2008
|
statusPayload.steps[fi].endedAt = taskEndTime;
|
|
2003
2009
|
statusPayload.steps[fi].durationMs = taskEndTime - taskStartTime;
|
|
@@ -2029,6 +2035,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2029
2035
|
agent: pr.agent,
|
|
2030
2036
|
output: pr.output,
|
|
2031
2037
|
error: pr.error,
|
|
2038
|
+
...(pr.interrupted ? { interrupted: true } : {}),
|
|
2032
2039
|
success: pr.exitCode === 0,
|
|
2033
2040
|
exitCode: pr.exitCode,
|
|
2034
2041
|
skipped: pr.skipped,
|
|
@@ -2293,6 +2300,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2293
2300
|
const taskEndTime = Date.now();
|
|
2294
2301
|
const taskDuration = taskEndTime - taskStartTime;
|
|
2295
2302
|
|
|
2303
|
+
if (singleResult.interrupted) interrupted = true;
|
|
2296
2304
|
statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
|
|
2297
2305
|
statusPayload.steps[fi].endedAt = taskEndTime;
|
|
2298
2306
|
statusPayload.steps[fi].durationMs = taskDuration;
|
|
@@ -2361,6 +2369,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2361
2369
|
agent: pr.agent,
|
|
2362
2370
|
output: pr.output,
|
|
2363
2371
|
error: pr.error,
|
|
2372
|
+
...(pr.interrupted ? { interrupted: true } : {}),
|
|
2364
2373
|
success: pr.exitCode === 0,
|
|
2365
2374
|
exitCode: pr.exitCode,
|
|
2366
2375
|
skipped: pr.skipped,
|
|
@@ -2477,6 +2486,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2477
2486
|
agent: singleResult.agent,
|
|
2478
2487
|
output: singleResult.output,
|
|
2479
2488
|
error: singleResult.error,
|
|
2489
|
+
...(singleResult.interrupted ? { interrupted: true } : {}),
|
|
2480
2490
|
success: singleResult.exitCode === 0,
|
|
2481
2491
|
exitCode: singleResult.exitCode,
|
|
2482
2492
|
sessionFile: singleResult.sessionFile,
|
|
@@ -2511,6 +2521,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2511
2521
|
}
|
|
2512
2522
|
|
|
2513
2523
|
const stepEndTime = Date.now();
|
|
2524
|
+
if (singleResult.interrupted) interrupted = true;
|
|
2514
2525
|
statusPayload.steps[flatIndex].status = singleResult.exitCode === 0 ? "complete" : "failed";
|
|
2515
2526
|
statusPayload.steps[flatIndex].endedAt = stepEndTime;
|
|
2516
2527
|
statusPayload.steps[flatIndex].durationMs = stepEndTime - stepStartTime;
|
|
@@ -2682,11 +2693,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2682
2693
|
mode: resultMode,
|
|
2683
2694
|
success: !interrupted && results.every((r) => r.success),
|
|
2684
2695
|
state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
|
|
2685
|
-
summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
|
|
2696
|
+
summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." + formatInterruptedCleanup(results) : summary,
|
|
2686
2697
|
results: results.map((r) => ({
|
|
2687
2698
|
agent: r.agent,
|
|
2688
2699
|
output: r.output,
|
|
2689
2700
|
error: r.error,
|
|
2701
|
+
...(r.interrupted ? { interrupted: true } : {}),
|
|
2690
2702
|
success: r.success,
|
|
2691
2703
|
skipped: r.skipped || undefined,
|
|
2692
2704
|
sessionFile: r.sessionFile,
|
|
@@ -35,7 +35,7 @@ import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skill
|
|
|
35
35
|
import type { ExtensionConfig } from "../../shared/types.ts";
|
|
36
36
|
import { runSync } from "./execution.ts";
|
|
37
37
|
import type { PiRuntimeIdentity } from "../shared/pi-spawn.ts";
|
|
38
|
-
import { buildChainSummary, buildChainOutputsSection, buildChainStopSection, type ChainSummaryOptions } from "../../shared/formatters.ts";
|
|
38
|
+
import { buildChainSummary, buildChainOutputsSection, buildChainStopSection, formatInterruptedCleanup, type ChainSummaryOptions } from "../../shared/formatters.ts";
|
|
39
39
|
import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, resolveChildCwd } from "../../shared/utils.ts";
|
|
40
40
|
import { recordRun } from "../shared/run-history.ts";
|
|
41
41
|
import {
|
|
@@ -748,7 +748,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
748
748
|
const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
|
|
749
749
|
if (interrupted) {
|
|
750
750
|
return {
|
|
751
|
-
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` }],
|
|
751
|
+
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` + formatInterruptedCleanup(results) }],
|
|
752
752
|
details: buildChainExecutionDetails(makeDetailsInput({
|
|
753
753
|
currentStepIndex: stepIndex,
|
|
754
754
|
currentFlatIndex: globalTaskIndex - step.parallel.length + interruptedIndexInStep,
|
|
@@ -957,7 +957,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
957
957
|
const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
|
|
958
958
|
if (interrupted) {
|
|
959
959
|
return {
|
|
960
|
-
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` }],
|
|
960
|
+
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` + formatInterruptedCleanup(results) }],
|
|
961
961
|
details: buildChainExecutionDetails(makeDetailsInput({
|
|
962
962
|
currentStepIndex: stepIndex,
|
|
963
963
|
currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + interruptedIndexInStep,
|
|
@@ -1228,7 +1228,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
|
|
|
1228
1228
|
|
|
1229
1229
|
if (r.interrupted) {
|
|
1230
1230
|
return {
|
|
1231
|
-
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
|
|
1231
|
+
content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` + formatInterruptedCleanup(results) }],
|
|
1232
1232
|
details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
|
|
1233
1233
|
};
|
|
1234
1234
|
}
|
|
@@ -76,6 +76,8 @@ import {
|
|
|
76
76
|
} from "./attempt-finalization.ts";
|
|
77
77
|
import {
|
|
78
78
|
createExternalExecution,
|
|
79
|
+
externalExecutionDiagnosticCode,
|
|
80
|
+
isInterruptedExternalAttemptError,
|
|
79
81
|
type ExternalExecution,
|
|
80
82
|
type ExternalExecutionOptions,
|
|
81
83
|
} from "../shared/external-execution.ts";
|
|
@@ -808,7 +810,16 @@ export async function runSync(
|
|
|
808
810
|
});
|
|
809
811
|
} catch (error) {
|
|
810
812
|
runtimeFailure = error instanceof PiRuntimeLaunchError;
|
|
811
|
-
|
|
813
|
+
if (isInterruptedExternalAttemptError(error)) {
|
|
814
|
+
const code = externalExecutionDiagnosticCode(error);
|
|
815
|
+
result = {
|
|
816
|
+
...failedResult(agentName, task, `${error.message}${code ? ` (${code})` : ""}`),
|
|
817
|
+
exitCode: 0,
|
|
818
|
+
interrupted: true,
|
|
819
|
+
};
|
|
820
|
+
} else {
|
|
821
|
+
result = failedResult(agentName, task, runtimeFailure ? (error as Error).message : safeExternalFailure("attempt"));
|
|
822
|
+
}
|
|
812
823
|
}
|
|
813
824
|
} else {
|
|
814
825
|
try {
|
|
@@ -825,7 +836,7 @@ export async function runSync(
|
|
|
825
836
|
sumUsage(aggregateUsage, result.usage);
|
|
826
837
|
totalToolCount += result.progressSummary?.toolCount ?? 0;
|
|
827
838
|
totalDurationMs += result.progressSummary?.durationMs ?? 0;
|
|
828
|
-
const attemptSucceeded = result.exitCode === 0 && !result.error;
|
|
839
|
+
const attemptSucceeded = result.exitCode === 0 && (result.interrupted || !result.error);
|
|
829
840
|
const attempt: ModelAttempt = {
|
|
830
841
|
model: candidate ?? result.model ?? agent.model ?? "default",
|
|
831
842
|
success: attemptSucceeded,
|
|
@@ -834,7 +845,7 @@ export async function runSync(
|
|
|
834
845
|
usage: { ...result.usage },
|
|
835
846
|
};
|
|
836
847
|
modelAttempts.push(attempt);
|
|
837
|
-
if (attemptSucceeded) {
|
|
848
|
+
if (result.interrupted || attemptSucceeded) {
|
|
838
849
|
break;
|
|
839
850
|
}
|
|
840
851
|
if (runtimeFailure || blockedLine(result.error ?? "") || !isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
|
|
@@ -133,9 +133,9 @@ export async function runExternalSingleAttempt(input: RunExternalSingleAttemptIn
|
|
|
133
133
|
result.exitCode = exitCodeOf(attemptResult.exit);
|
|
134
134
|
|
|
135
135
|
const observedMutationAttempt = toolMutationFromAssistantMessages(attemptResult.messages);
|
|
136
|
-
const interruptedByControl = attemptResult.
|
|
136
|
+
const interruptedByControl = attemptResult.interrupted;
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
const finalized = finalizeSingleAttempt(result, progress, {
|
|
139
139
|
agent,
|
|
140
140
|
options,
|
|
141
141
|
outputSnapshot: shared.outputSnapshot,
|
|
@@ -145,6 +145,8 @@ export async function runExternalSingleAttempt(input: RunExternalSingleAttemptIn
|
|
|
145
145
|
emitControlEvent,
|
|
146
146
|
startTime,
|
|
147
147
|
});
|
|
148
|
+
if (attemptResult.interrupted) finalized.error = attemptResult.error;
|
|
149
|
+
return finalized;
|
|
148
150
|
} finally {
|
|
149
151
|
cleanupTempDir(tempDir);
|
|
150
152
|
}
|
|
@@ -15,6 +15,7 @@ import { handleManagementAction } from "../../agents/agent-management.ts";
|
|
|
15
15
|
import { buildDoctorReport } from "../../extension/doctor.ts";
|
|
16
16
|
import { clearPendingForegroundControlNotices } from "../../extension/control-notices.ts";
|
|
17
17
|
import { runSync } from "./execution.ts";
|
|
18
|
+
import { formatInterruptedCleanup } from "../../shared/formatters.ts";
|
|
18
19
|
import { recordSyncCost, renderGrandTotal, sumNestedCost } from "../../extension/grand-total.ts";
|
|
19
20
|
import { resolveModelCandidate } from "../shared/model-fallback.ts";
|
|
20
21
|
import { aggregateParallelOutputs } from "../shared/parallel-utils.ts";
|
|
@@ -1843,7 +1844,7 @@ async function runParallelPath(data: ExecutionContextData, deps: CallExecutorDep
|
|
|
1843
1844
|
rememberForegroundRun(deps.state, { runtime: data.runtime, sessionRoot: data.sessionRoot, runId, mode: "parallel", cwd: effectiveCwd, results: details.results });
|
|
1844
1845
|
if (interrupted) {
|
|
1845
1846
|
return {
|
|
1846
|
-
content: [{ type: "text", text: withWorktreeSuffix(`Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.`) }],
|
|
1847
|
+
content: [{ type: "text", text: withWorktreeSuffix(`Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.`) + formatInterruptedCleanup(results) }],
|
|
1847
1848
|
details,
|
|
1848
1849
|
};
|
|
1849
1850
|
}
|
|
@@ -2124,7 +2125,7 @@ async function runSinglePath(data: ExecutionContextData, deps: CallExecutorDeps)
|
|
|
2124
2125
|
|
|
2125
2126
|
if (r.interrupted) {
|
|
2126
2127
|
return {
|
|
2127
|
-
content: [{ type: "text", text: `Run paused after interrupt (${params.agent}). Waiting for explicit next action.` }],
|
|
2128
|
+
content: [{ type: "text", text: `Run paused after interrupt (${params.agent}). Waiting for explicit next action.` + formatInterruptedCleanup([r]) }],
|
|
2128
2129
|
details,
|
|
2129
2130
|
};
|
|
2130
2131
|
}
|
|
@@ -79,6 +79,10 @@ export function externalExecutionDiagnosticCode(error: unknown): ExternalExecuti
|
|
|
79
79
|
return error instanceof ExternalExecutionDiagnosticError ? error.code : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function isInterruptedExternalAttemptError(error: unknown): error is Error & { interrupted: true } {
|
|
83
|
+
return error instanceof Error && (error as Error & { interrupted?: boolean }).interrupted === true;
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
export type ExternalExecutionDisposition = "delivered" | "retained";
|
|
83
87
|
export interface ExternalExecutionFinishResult {
|
|
84
88
|
readonly handle: ExecutionSurfaceHandle;
|
|
@@ -104,6 +108,7 @@ export interface ExternalExecutionDependencies {
|
|
|
104
108
|
readonly readSession?: (sessionFile: string) => Promise<string>;
|
|
105
109
|
readonly ensureSessionFile?: (sessionFile: string) => void;
|
|
106
110
|
readonly readyDeadline?: (ready: Promise<void>, timeoutMs: number, safeLabel: string) => Promise<void>;
|
|
111
|
+
readonly monotonicNow?: () => number;
|
|
107
112
|
readonly controlDeadline?: <T>(promise: Promise<T>, timeoutMs: number, label: string) => Promise<T>;
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -168,9 +173,9 @@ function proveControl(replayed: ReplayedSession, action: "abort" | "shutdown"):
|
|
|
168
173
|
if (requested < 0 || applied <= requested) throw new Error(`durable ${action} control evidence is missing`);
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
function mapResult(request: ExternalAttemptRequest, replayed: ReplayedSession, exit: ChildHostExit): ExternalAttemptResult {
|
|
176
|
+
function mapResult(request: ExternalAttemptRequest, replayed: ReplayedSession, exit: ChildHostExit, validateExit = true): ExternalAttemptResult {
|
|
172
177
|
if (!replayed.result) throw new ExternalExecutionDiagnosticError("result_missing");
|
|
173
|
-
if (replayed.result.outcome === "success" && (exit.status !== 0 || exit.signal !== null)) throw new Error("successful durable result conflicts with child exit");
|
|
178
|
+
if (validateExit && replayed.result.outcome === "success" && (exit.status !== 0 || exit.signal !== null)) throw new Error("successful durable result conflicts with child exit");
|
|
174
179
|
const messages = replayed.messages.filter((message): message is Message => message.role === "user" || message.role === "assistant" || message.role === "toolResult");
|
|
175
180
|
const aggregate = usageFrom(messages);
|
|
176
181
|
return {
|
|
@@ -240,6 +245,8 @@ export async function createExternalExecution(
|
|
|
240
245
|
const deadline = dependencies.readyDeadline ?? withReadyDeadline;
|
|
241
246
|
const controlTimeoutMs = options.controlTimeoutMs ?? 30_000;
|
|
242
247
|
const controlDeadline = dependencies.controlDeadline ?? withControlDeadline;
|
|
248
|
+
const now = dependencies.monotonicNow ?? (() => performance.now());
|
|
249
|
+
let interruptedFinish: ExternalExecutionFinishResult | undefined;
|
|
243
250
|
const ready = deadline(controller.ready, options.readyTimeoutMs ?? 30_000, controller.lease.handle.display.label);
|
|
244
251
|
void ready.catch(() => {});
|
|
245
252
|
const watchDependencies = defaultWatchDependencies();
|
|
@@ -260,11 +267,20 @@ export async function createExternalExecution(
|
|
|
260
267
|
const runAttempt = async (request: ExternalAttemptRequest): Promise<ExternalAttemptResult> => {
|
|
261
268
|
if (active) throw new Error("external execution owner is busy");
|
|
262
269
|
if (!request.attemptId || attemptedIds.has(request.attemptId)) throw new Error("external execution attemptId has already been used");
|
|
270
|
+
if (interruptedFinish) throw new Error("external execution owner has been interruption-cleaned");
|
|
263
271
|
active = true;
|
|
264
272
|
attemptedIds.add(request.attemptId);
|
|
265
273
|
let control: ExecutionControlChannel | undefined;
|
|
266
274
|
let watcher: SessionWatcher | undefined;
|
|
267
275
|
const abort = abortPromise([request.signal, request.interruptSignal]);
|
|
276
|
+
let interruptedAt: number | undefined;
|
|
277
|
+
let disposed = false;
|
|
278
|
+
const acceptInterruption = () => { interruptedAt ??= now(); };
|
|
279
|
+
const signals = [request.signal, request.interruptSignal];
|
|
280
|
+
for (const signal of signals) {
|
|
281
|
+
if (signal?.aborted) acceptInterruption();
|
|
282
|
+
else signal?.addEventListener("abort", acceptInterruption, { once: true });
|
|
283
|
+
}
|
|
268
284
|
try {
|
|
269
285
|
await ready;
|
|
270
286
|
ensureSessionFile(request.sessionFile);
|
|
@@ -273,39 +289,163 @@ export async function createExternalExecution(
|
|
|
273
289
|
watcher = createWatcher(request.sessionFile, identity, request.onNewSessionMessage);
|
|
274
290
|
const environment = { ...request.environment, [PI_COHORT_REPORT_CONFIG]: control.configPath };
|
|
275
291
|
const exitPromise = controller.startAttempt({ attemptId: request.attemptId, command: request.command, args: [...request.args], cwd: request.cwd, environment });
|
|
276
|
-
|
|
292
|
+
let observedExit: ChildHostExit | undefined;
|
|
293
|
+
let hostExited = false;
|
|
294
|
+
void exitPromise.then(exit => { observedExit = exit; }, () => {});
|
|
295
|
+
void controller.hostExit.then(() => { hostExited = true; }, () => {});
|
|
296
|
+
const finalizeInterrupted = async (initial?: ReplayedSession, initialError?: unknown, shutdownRequest?: Promise<string>): Promise<ExternalAttemptResult> => {
|
|
297
|
+
acceptInterruption();
|
|
298
|
+
let endpoint = interruptedAt! + controlTimeoutMs;
|
|
299
|
+
let fallback = false;
|
|
300
|
+
let closed = false;
|
|
301
|
+
let closeAttempted = false;
|
|
302
|
+
let hostShutdownRequested = false;
|
|
303
|
+
const diagnostics: string[] = [];
|
|
304
|
+
const note = (error: unknown) => { diagnostics.push(error instanceof Error ? error.message : String(error)); };
|
|
305
|
+
const wait = <T>(promise: Promise<T>, label: string) => {
|
|
306
|
+
void promise.catch(() => {});
|
|
307
|
+
const remaining = Math.max(0, endpoint - now());
|
|
308
|
+
return remaining > 0 ? controlDeadline(promise, remaining, label) : Promise.reject(new Error(`${label} deadline exhausted`));
|
|
309
|
+
};
|
|
310
|
+
const forceCleanup = async () => {
|
|
311
|
+
if (closeAttempted) return;
|
|
312
|
+
closeAttempted = true;
|
|
313
|
+
endpoint = interruptedAt! + 2 * controlTimeoutMs;
|
|
314
|
+
try { await wait(options.backend.close(controller.lease.handle, "explicit_cleanup"), "explicit cleanup"); closed = true; } catch (error) { note(error); }
|
|
315
|
+
};
|
|
316
|
+
let replayed = initial;
|
|
317
|
+
let primary = initialError;
|
|
318
|
+
if (!initial) {
|
|
319
|
+
try { await wait(control!.request("abort"), "abort request"); } catch (error) { note(error); }
|
|
320
|
+
if (!initialError) {
|
|
321
|
+
try { replayed = await wait(watcher!.terminal, "post-abort terminal"); } catch (error) { primary = error; fallback = true; }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
try { await wait(shutdownRequest ?? control!.request("shutdown"), "child shutdown"); } catch (error) { note(error); fallback = true; }
|
|
325
|
+
if (fallback) endpoint = interruptedAt! + 2 * controlTimeoutMs;
|
|
326
|
+
try {
|
|
327
|
+
replayed = replayExecutionSession(await wait(readSession(request.sessionFile), "final replay"), identity);
|
|
328
|
+
if (!replayed.result && !initialError) primary = new ExternalExecutionDiagnosticError("result_missing");
|
|
329
|
+
} catch (error) { primary ??= error; }
|
|
330
|
+
if (fallback) await forceCleanup();
|
|
331
|
+
try { await wait(exitPromise, "post-shutdown exit"); } catch (error) { note(error); fallback = true; }
|
|
332
|
+
if (observedExit) {
|
|
333
|
+
hostShutdownRequested = true;
|
|
334
|
+
try { await wait(controller.shutdown(), "host shutdown"); } catch (error) { note(error); fallback = true; }
|
|
335
|
+
}
|
|
336
|
+
if (fallback) await forceCleanup();
|
|
337
|
+
if (observedExit && !hostShutdownRequested) {
|
|
338
|
+
hostShutdownRequested = true;
|
|
339
|
+
try { await wait(controller.shutdown(), "host shutdown"); } catch (error) { note(error); }
|
|
340
|
+
}
|
|
341
|
+
if (!observedExit) {
|
|
342
|
+
// A failed observation is not exit evidence; keep resources until the remaining budget expires.
|
|
343
|
+
const remainingExit = exitPromise.catch(() => new Promise<ChildHostExit>(() => {}));
|
|
344
|
+
try { await wait(remainingExit, "child exit observation"); } catch (error) { note(error); }
|
|
345
|
+
if (observedExit) {
|
|
346
|
+
hostShutdownRequested = true;
|
|
347
|
+
try { await wait(controller.shutdown(), "host shutdown"); } catch (error) { note(error); }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Socket delivery is not application; shutdown reports can arrive until process settlement.
|
|
351
|
+
if (observedExit && now() < endpoint) {
|
|
352
|
+
try { replayed = replayExecutionSession(await wait(readSession(request.sessionFile), "final replay"), identity); }
|
|
353
|
+
catch (error) { primary ??= error; }
|
|
354
|
+
}
|
|
355
|
+
if (!hostExited) {
|
|
356
|
+
try { await wait(controller.hostExit, "host exit observation"); } catch (error) { note(error); }
|
|
357
|
+
}
|
|
358
|
+
if (!primary) {
|
|
359
|
+
try {
|
|
360
|
+
if (!replayed?.result) throw new ExternalExecutionDiagnosticError("result_missing");
|
|
361
|
+
if (!initial) proveControl(replayed, "abort");
|
|
362
|
+
proveControl(replayed, "shutdown");
|
|
363
|
+
} catch (error) { primary = error; }
|
|
364
|
+
}
|
|
365
|
+
try { watcher!.close(); } catch (error) { note(error); }
|
|
366
|
+
finally {
|
|
367
|
+
try { controller.dispose(); } catch (error) { note(error); }
|
|
368
|
+
finally {
|
|
369
|
+
disposed = true;
|
|
370
|
+
try { await wait(control!.close(), "control disposal"); } catch (error) { note(error); }
|
|
371
|
+
finally {
|
|
372
|
+
try { await wait(releaseObserver(), "observer release"); } catch (error) { note(error); }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
interruptedFinish = { handle: controller.lease.handle, retained: !closed };
|
|
377
|
+
if (!observedExit || !hostExited || (fallback && !closed)) diagnostics.push("Process termination could not be confirmed; manual cleanup may be required.");
|
|
378
|
+
if (primary) {
|
|
379
|
+
const error = primary instanceof Error ? primary : new Error(String(primary));
|
|
380
|
+
Object.assign(error, { interrupted: true });
|
|
381
|
+
if (diagnostics.length) error.message += `; ${diagnostics.join("; ")}`;
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
const result = mapResult(request, replayed!, observedExit ?? { status: null, signal: null }, false);
|
|
385
|
+
return { ...result, interrupted: true, ...(diagnostics.length ? { error: [result.error, ...diagnostics].filter(Boolean).join("; ") } : {}) };
|
|
386
|
+
};
|
|
387
|
+
let first;
|
|
388
|
+
try { first = await Promise.race([
|
|
277
389
|
watcher.terminal.then(replayed => ({ kind: "result" as const, replayed })),
|
|
278
390
|
exitPromise.then(exit => ({ kind: "exit" as const, exit })),
|
|
279
391
|
abort.promise.then(() => ({ kind: "abort" as const })),
|
|
392
|
+
]); } catch (error) {
|
|
393
|
+
if (interruptedAt !== undefined) return await finalizeInterrupted(undefined, error);
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
if (first.kind === "abort") return await finalizeInterrupted();
|
|
397
|
+
if (first.kind === "result" && (interruptedAt !== undefined || first.replayed.result?.outcome === "interrupted")) return await finalizeInterrupted(first.replayed);
|
|
398
|
+
const finalReplay = () => Promise.race([
|
|
399
|
+
readSession(request.sessionFile).then(content => replayExecutionSession(content, identity)),
|
|
400
|
+
abort.promise.then(() => undefined),
|
|
280
401
|
]);
|
|
281
402
|
let replayed: ReplayedSession;
|
|
282
403
|
let exit: ChildHostExit;
|
|
283
|
-
if (first.kind === "
|
|
284
|
-
await control.request("abort");
|
|
285
|
-
replayed = await controlDeadline(watcher.terminal, controlTimeoutMs, "post-abort terminal");
|
|
286
|
-
if (replayed.result.outcome !== "interrupted") throw new Error("abort did not produce an interrupted durable result");
|
|
287
|
-
await control.request("shutdown");
|
|
288
|
-
exit = await controlDeadline(exitPromise, controlTimeoutMs, "post-shutdown exit");
|
|
289
|
-
replayed = replayExecutionSession(await readSession(request.sessionFile), identity);
|
|
290
|
-
proveControl(replayed, "abort");
|
|
291
|
-
proveControl(replayed, "shutdown");
|
|
292
|
-
} else if (first.kind === "result") {
|
|
404
|
+
if (first.kind === "result") {
|
|
293
405
|
replayed = first.replayed;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
406
|
+
const shutdownRequest = control.request("shutdown");
|
|
407
|
+
try {
|
|
408
|
+
const shutdown = await Promise.race([shutdownRequest.then(() => "shutdown" as const), abort.promise.then(() => "abort" as const)]);
|
|
409
|
+
if (shutdown === "abort") return await finalizeInterrupted(replayed, undefined, shutdownRequest);
|
|
410
|
+
const stopped = await Promise.race([
|
|
411
|
+
controlDeadline(exitPromise, controlTimeoutMs, "post-shutdown exit").then(exit => ({ exit })),
|
|
412
|
+
abort.promise.then(() => ({ exit: undefined })),
|
|
413
|
+
]);
|
|
414
|
+
if (!stopped.exit || interruptedAt !== undefined) return await finalizeInterrupted(replayed, undefined, shutdownRequest);
|
|
415
|
+
exit = stopped.exit;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (interruptedAt !== undefined && !disposed) return await finalizeInterrupted(replayed, error, shutdownRequest);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
let final: ReplayedSession | undefined;
|
|
421
|
+
try { final = await finalReplay(); } catch (error) {
|
|
422
|
+
if (interruptedAt !== undefined) return await finalizeInterrupted(replayed, error, shutdownRequest);
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
if (!final || interruptedAt !== undefined) return await finalizeInterrupted(replayed, undefined, shutdownRequest);
|
|
426
|
+
replayed = final;
|
|
297
427
|
proveControl(replayed, "shutdown");
|
|
298
428
|
} else {
|
|
299
429
|
exit = first.exit;
|
|
300
|
-
|
|
301
|
-
|
|
430
|
+
let final: ReplayedSession | undefined;
|
|
431
|
+
try {
|
|
432
|
+
final = await finalReplay();
|
|
433
|
+
if (final && !final.result) throw new ExternalExecutionDiagnosticError("result_missing");
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (interruptedAt !== undefined) return await finalizeInterrupted(undefined, error);
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
if (!final) return await finalizeInterrupted();
|
|
439
|
+
replayed = final;
|
|
440
|
+
if (interruptedAt !== undefined || replayed.result!.outcome === "interrupted") return await finalizeInterrupted(replayed);
|
|
302
441
|
}
|
|
303
442
|
return mapResult(request, replayed, exit);
|
|
304
443
|
} finally {
|
|
305
444
|
abort.close();
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
445
|
+
for (const signal of signals) signal?.removeEventListener("abort", acceptInterruption);
|
|
446
|
+
try {
|
|
447
|
+
if (!disposed) { watcher?.close(); await control?.close(); }
|
|
448
|
+
} finally { active = false; }
|
|
309
449
|
}
|
|
310
450
|
};
|
|
311
451
|
|
|
@@ -317,13 +457,17 @@ export async function createExternalExecution(
|
|
|
317
457
|
if (finishDisposition && finishDisposition !== disposition) {
|
|
318
458
|
return Promise.reject(new Error(`external execution owner is already finishing as ${finishDisposition}`));
|
|
319
459
|
}
|
|
460
|
+
if (interruptedFinish && disposition === "delivered") {
|
|
461
|
+
return Promise.reject(new Error("interruption-cleaned external execution owner accepts only retained finish"));
|
|
462
|
+
}
|
|
320
463
|
finishDisposition = disposition;
|
|
321
464
|
return (finishPromise ??= (async () => {
|
|
322
465
|
if (active) throw new Error("external execution owner is busy");
|
|
466
|
+
if (interruptedFinish) return interruptedFinish;
|
|
323
467
|
if (disposition === "retained") {
|
|
324
468
|
await finishWithCleanup(
|
|
325
469
|
async () => { await controller.shutdown(); },
|
|
326
|
-
releaseObserver,
|
|
470
|
+
async () => { try { await releaseObserver(); } finally { controller.dispose(); } },
|
|
327
471
|
);
|
|
328
472
|
return { handle: controller.lease.handle, retained: true };
|
|
329
473
|
}
|
|
@@ -332,7 +476,7 @@ export async function createExternalExecution(
|
|
|
332
476
|
await controller.shutdown();
|
|
333
477
|
await options.backend.close(controller.lease.handle, "delivered");
|
|
334
478
|
},
|
|
335
|
-
releaseObserver,
|
|
479
|
+
async () => { try { await releaseObserver(); } finally { controller.dispose(); } },
|
|
336
480
|
);
|
|
337
481
|
return { handle: controller.lease.handle, retained: false };
|
|
338
482
|
})().catch(error => {
|
package/src/shared/formatters.ts
CHANGED
|
@@ -53,6 +53,13 @@ export function formatDuration(ms: number): string {
|
|
|
53
53
|
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function formatInterruptedCleanup(results: readonly Pick<SingleResult, "agent" | "interrupted" | "error">[]): string {
|
|
57
|
+
const lines = results.flatMap((result, index) => result.interrupted && result.error
|
|
58
|
+
? [`Child ${index + 1} (${result.agent}): ${result.error}`]
|
|
59
|
+
: []);
|
|
60
|
+
return lines.length ? `\n\n${lines.join("\n")}` : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
export interface ChainSummaryOptions {
|
|
57
64
|
stop?: StopRecord;
|
|
58
65
|
outputs?: ChainTaskOutputEntry[];
|