fix(interactive-subagents): harden parallel lifecycle delivery

This commit is contained in:
jb
2026-09-03 23:40:14 -04:00
parent d99dbce93d
commit 7a1043e5e0
11 changed files with 848 additions and 131 deletions
@@ -22,6 +22,7 @@ type SurfaceBackend = {
sendCommand(surface: string, command: string): void;
readScreen(surface: string, lines?: number): string;
readScreenAsync(surface: string, lines?: number): Promise<string>;
isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean>;
closeSurface(surface: string): void;
};
@@ -87,6 +88,10 @@ export function readScreenAsync(surface: string, lines = 50): Promise<string> {
return getBackend().readScreenAsync(surface, lines);
}
export function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
return getBackend().isSurfaceAlive(surface, signal);
}
export function closeSurface(surface: string): void {
getBackend().closeSurface(surface);
}
@@ -106,7 +111,7 @@ export function wrapWithCompletionFile(command: string, completionFile: string):
export function sendLongCommand(
surface: string,
command: string,
options?: { scriptPath?: string; scriptPreamble?: string },
options?: { scriptPath?: string; scriptPreamble?: string; replaceShell?: boolean },
): string {
const scriptPath =
options?.scriptPath ??
@@ -122,7 +127,8 @@ export function sendLongCommand(
scriptParts.push(command);
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
const launch = `${options?.replaceShell ? "exec " : ""}bash ${shellEscape(scriptPath)}`;
sendCommand(surface, launch);
return scriptPath;
}
@@ -159,6 +165,10 @@ type PollOptions = {
sentinelFile?: string;
completionFile?: string;
onTick?: (elapsed: number) => void;
onExitDetected?: () => void;
livenessCheck?: (surface: string, signal?: AbortSignal) => Promise<boolean>;
livenessIntervalMs?: number;
livenessFailureThreshold?: number;
};
/** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */
@@ -177,6 +187,11 @@ export async function pollForExit(
readSurface: (surface: string, lines?: number) => Promise<string> = readScreenAsync,
): Promise<PollResult> {
const start = Date.now();
const livenessCheck = options.livenessCheck ?? isSurfaceAlive;
const livenessIntervalMs = options.livenessIntervalMs ?? 5_000;
const livenessFailureThreshold = options.livenessFailureThreshold ?? 3;
let nextLivenessCheck = start + livenessIntervalMs;
let livenessFailures = 0;
for (;;) {
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
@@ -186,6 +201,7 @@ export async function pollForExit(
const exitFile = `${options.sessionFile}.exit`;
if (existsSync(exitFile)) {
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
options.onExitDetected?.();
rmSync(exitFile, { force: true });
return result;
}
@@ -195,6 +211,7 @@ export async function pollForExit(
if (options.sentinelFile) {
try {
if (existsSync(options.sentinelFile)) {
options.onExitDetected?.();
return { reason: "sentinel", exitCode: 0 };
}
} catch {}
@@ -203,7 +220,10 @@ export async function pollForExit(
if (options.completionFile) {
try {
const result = readCompletionFile(options.completionFile);
if (result) return result;
if (result) {
options.onExitDetected?.();
return result;
}
} catch {}
}
@@ -211,10 +231,24 @@ export async function pollForExit(
try {
const screen = await readSurface(surface, 5);
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
if (match) {
options.onExitDetected?.();
return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
}
} catch {
// Surface reads are a best-effort fallback when no completion file is available.
}
} else if (Date.now() >= nextLivenessCheck) {
nextLivenessCheck = Date.now() + livenessIntervalMs;
try {
livenessFailures = await livenessCheck(surface, signal) ? 0 : livenessFailures + 1;
} catch {
livenessFailures += 1;
}
if (signal.aborted) throw new Error("Aborted while checking subagent surface liveness");
if (livenessFailures >= livenessFailureThreshold) {
throw new Error("Subagent terminal surface closed before completion.");
}
}
const elapsed = Math.floor((Date.now() - start) / 1000);