fix(interactive-subagents): harden parallel lifecycle delivery
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user