fix(dashboard,core): bound AI session prompt() with timeout + abort

Subtask, mission-interview, and milestone/slice-interview sessions could pin
their `generating` state forever when the underlying provider stream stalled
silently or a tool call hung. Wrap each `agent.session.prompt()` in a new
GenerationGuard helper (per-session AbortController + timer) so a stuck turn
becomes a bounded error users can retry. Adds matching `stop*Generation`
exports and threads abort through cleanup so dismissing a modal cancels the
in-flight call instead of leaking it.

Also closes the gh-cli tool hang vector: `runGhAsync` / `runGhJsonAsync` now
accept `{ signal, timeoutMs }` (default 30s). Github-touching extension tools
forward the AI tool's signal so an aborted agent kills the `gh` child instead
of orphaning it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 08:05:12 -07:00
parent 596982dd75
commit 2f7ba29ead
11 changed files with 1164 additions and 266 deletions

View File

@@ -8,6 +8,7 @@ import {
createSessionDiagnostics,
resetDiagnosticsSink,
} from "./ai-session-diagnostics.js";
import { GenerationGuard, isAbortError } from "./ai-session-timeout.js";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
@@ -77,6 +78,14 @@ export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
/**
* Subtask breakdown is a single-turn flow with no follow-up questions, so
* a stuck `prompt()` (silent stream stall, hung tool call) blocks the whole
* UI. 90 s is generous for a small JSON response and bounds the worst case.
*/
export const GENERATION_TIMEOUT_MS = 90_000;
const generationGuard = new GenerationGuard();
/** Minimal interface for the agent object created by createFnAgent */
interface SubtaskAgent {
@@ -208,6 +217,10 @@ function cleanupInMemorySubtaskSession(sessionId: string): boolean {
return false;
}
// Abort any in-flight generation so the agent.session.prompt() rejects
// promptly and we don't leak the timer / AbortController.
generationGuard.stop(sessionId);
try {
session.agent?.session?.dispose?.();
} catch {
@@ -407,6 +420,11 @@ async function startSubtaskGeneration(
try {
await generateSubtasks(sessionId, cwd, promptOverrides);
} catch (err) {
// Timeout / user-stop already published an error state via the guard
// handlers. Don't overwrite it with a generic AbortError message.
if (isAbortError(err)) {
return;
}
const existing = sessions.get(sessionId);
if (!existing) return;
existing.status = "error";
@@ -451,22 +469,39 @@ async function generateSubtasks(
});
session.agent = agent;
await agent.session.prompt(session.initialDescription);
const messages = agent.session.state.messages as Array<{ role: string; content?: string | Array<{ type: string; text: string }> }>;
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
let responseText = session.thinkingOutput;
if (typeof lastAssistant?.content === "string") {
responseText = lastAssistant.content;
} else if (Array.isArray(lastAssistant?.content)) {
responseText = lastAssistant.content
.filter((item): item is { type: "text"; text: string } => item.type === "text")
.map((item) => item.text)
.join("");
}
await generationGuard.run(
sessionId,
GENERATION_TIMEOUT_MS,
{
onTimeout: () => setSubtaskError(
sessionId,
"AI generation timed out. You can retry or start a new session.",
),
onUserStop: () => setSubtaskError(
sessionId,
"Generation stopped by user. You can retry or start a new session.",
),
},
async () => {
await agent.session.prompt(session.initialDescription);
const subtasks = parseSubtasks(responseText);
completeSession(sessionId, subtasks);
const messages = agent.session.state.messages as Array<{ role: string; content?: string | Array<{ type: string; text: string }> }>;
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
let responseText = session.thinkingOutput;
if (typeof lastAssistant?.content === "string") {
responseText = lastAssistant.content;
} else if (Array.isArray(lastAssistant?.content)) {
responseText = lastAssistant.content
.filter((item): item is { type: "text"; text: string } => item.type === "text")
.map((item) => item.text)
.join("");
}
const subtasks = parseSubtasks(responseText);
completeSession(sessionId, subtasks);
},
);
return;
}
@@ -520,6 +555,24 @@ function generateFallbackSubtasks(initialDescription: string): SubtaskItem[] {
];
}
function setSubtaskError(sessionId: string, message: string): void {
const session = sessions.get(sessionId);
if (!session) return;
session.status = "error";
session.error = message;
session.updatedAt = new Date();
persistSubtaskSession(session, "error", message);
subtaskStreamManager.broadcast(sessionId, { type: "error", data: message });
}
/**
* Manually abort an in-flight subtask generation (UI "stop" button).
* Returns true if a generation was active and got aborted.
*/
export function stopSubtaskGeneration(sessionId: string): boolean {
return generationGuard.stop(sessionId);
}
function completeSession(sessionId: string, subtasks: SubtaskItem[]): void {
const session = sessions.get(sessionId);
if (!session) return;
@@ -622,6 +675,7 @@ export function __resetSubtaskBreakdownState(): void {
}
sessions.clear();
subtaskStreamManager.reset();
generationGuard.reset();
if (_aiSessionStore && _aiSessionDeletedListener) {
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);