pi-cohort 7.6.4 → 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 CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## [7.6.4] - 2026-10-29
4
9
 
5
10
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "7.6.4",
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; resolveReady?.(); resolveReady = undefined; rejectReady = undefined; continue;
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; connection.end(); cleanup(); pending.resolve(); continue;
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: options.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
- append(pi, context, config, result(messages));
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
- // A failed or interrupted durable outcome must not become success from zero exit.
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
  }
@@ -184,7 +184,7 @@ export async function runExternalBackgroundAttempt(
184
184
  model: attemptResult.model,
185
185
  error: attemptResult.error,
186
186
  finalOutput: attemptResult.finalOutput,
187
- interrupted: attemptResult.outcome === "interrupted",
187
+ interrupted: attemptResult.interrupted,
188
188
  observedMutationAttempt: observedMutationFromMessages(attemptResult.messages),
189
189
  };
190
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
- result = failedResult(agentName, task, runtimeFailure ? (error as Error).message : safeExternalFailure("attempt"));
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.outcome === "interrupted";
136
+ const interruptedByControl = attemptResult.interrupted;
137
137
 
138
- return finalizeSingleAttempt(result, progress, {
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
- const first = await Promise.race([
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 === "abort") {
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
- await control.request("shutdown");
295
- exit = await controlDeadline(exitPromise, controlTimeoutMs, "post-shutdown exit");
296
- replayed = replayExecutionSession(await readSession(request.sessionFile), identity);
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
- replayed = replayExecutionSession(await readSession(request.sessionFile), identity);
301
- if (!replayed.result) throw new ExternalExecutionDiagnosticError("result_missing");
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
- watcher?.close();
307
- await control?.close();
308
- active = false;
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 => {
@@ -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[];