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

@@ -29,6 +29,7 @@ import {
resetDiagnosticsSink,
nonfatal,
} from "./ai-session-diagnostics.js";
import { GenerationGuard, isAbortError } from "./ai-session-timeout.js";
// Re-export JSON parsing utilities from mission-interview for external consumers
export {
@@ -122,6 +123,14 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
/**
* Per-turn generation timeout. Bounds a stalled model stream or hung tool
* call so the session cannot stay pinned in `generating` indefinitely.
*/
export const GENERATION_TIMEOUT_MS = 120_000;
const generationGuard = new GenerationGuard();
/** Milestone interview system prompt */
export const MILESTONE_INTERVIEW_SYSTEM_PROMPT = `You are a milestone planning assistant for a project management system.
@@ -346,6 +355,9 @@ function cleanupInMemorySession(sessionId: string): boolean {
return false;
}
// Abort any in-flight generation so prompt() rejects promptly.
generationGuard.stop(sessionId);
if (session.agent) {
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
session.agent = undefined;
@@ -356,6 +368,24 @@ function cleanupInMemorySession(sessionId: string): boolean {
return true;
}
function setTargetSessionError(session: TargetInterviewSession, message: string): void {
session.error = message;
session.updatedAt = new Date();
persistSession(session, "error", message);
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "error",
data: message,
});
}
/**
* Manually abort an in-flight milestone/slice interview generation.
* Returns true if a generation was active and got aborted.
*/
export function stopMilestoneSliceInterviewGeneration(sessionId: string): boolean {
return generationGuard.stop(sessionId);
}
function getSessionType(targetType: TargetType): "milestone_interview" | "slice_interview" {
return targetType === "milestone" ? "milestone_interview" : "slice_interview";
}
@@ -744,12 +774,26 @@ async function ensureInterviewAgent(
return;
}
await session.agent.session.prompt(
[
"Previous conversation summary:",
historySummary,
"Use this context when handling the next user response.",
].join("\n\n"),
await generationGuard.run(
session.id,
GENERATION_TIMEOUT_MS,
{
onTimeout: () => setTargetSessionError(
session,
"AI generation timed out while restoring context. You can retry or start a new session.",
),
onUserStop: () => setTargetSessionError(
session,
"Generation stopped by user. You can retry or start a new session.",
),
},
() => session.agent!.session.prompt(
[
"Previous conversation summary:",
historySummary,
"Use this context when handling the next user response.",
].join("\n\n"),
),
);
}
@@ -791,128 +835,138 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
}
try {
session.thinkingOutput = "";
await generationGuard.run(
session.id,
GENERATION_TIMEOUT_MS,
{
onTimeout: () => setTargetSessionError(
session,
"AI generation timed out. You can retry or start a new session.",
),
onUserStop: () => setTargetSessionError(
session,
"Generation stopped by user. You can retry or start a new session.",
),
},
async () => {
const agent = session.agent!;
session.thinkingOutput = "";
await session.agent.session.prompt(message);
await agent.session.prompt(message);
// Get the response text from the agent's state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (session.agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
// Get the response text from the agent's state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = session.thinkingOutput;
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse with retry using the target interview parser
let parsed: TargetInterviewResponse | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
parsed = parseTargetInterviewResponseImpl(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
diagnostics.warn(
"Parse attempt failed, requesting reformat",
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
);
try {
session.thinkingOutput = "";
await session.agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' +
'or {"type":"complete","data":{"title":"...","description":"...","planningNotes":"...","verification":"..."}}' +
". No markdown, no explanation, just the JSON."
);
const retryMessage = (session.agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = session.thinkingOutput;
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch (retryErr) {
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
break;
let responseText = session.thinkingOutput;
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
}
}
if (!parsed) {
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
diagnostics.error(
"All parse attempts exhausted for session",
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
);
session.error = errorMsg;
session.updatedAt = new Date();
persistSession(session, "error", errorMsg);
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "error",
data: errorMsg,
});
// Parse with retry using the target interview parser
let parsed: TargetInterviewResponse | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
parsed = parseTargetInterviewResponseImpl(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
diagnostics.warn(
"Parse attempt failed, requesting reformat",
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
);
try {
session.thinkingOutput = "";
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' +
'or {"type":"complete","data":{"title":"...","description":"...","planningNotes":"...","verification":"..."}}' +
". No markdown, no explanation, just the JSON."
);
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = session.thinkingOutput;
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch (retryErr) {
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
break;
}
}
}
}
if (!parsed) {
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
diagnostics.error(
"All parse attempts exhausted for session",
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
);
setTargetSessionError(session, errorMsg);
return;
}
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
});
} else if (parsed.type === "complete") {
session.summary = parsed.data;
session.currentQuestion = undefined;
session.error = undefined;
session.updatedAt = new Date();
persistSession(session, "complete");
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "summary",
data: parsed.data,
});
milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "complete" });
}
},
);
} catch (err) {
// Timeout / user-stop already published an error state via the guard
// handlers. Don't double-broadcast a generic AbortError.
if (isAbortError(err)) {
return;
}
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
});
} else if (parsed.type === "complete") {
session.summary = parsed.data;
session.currentQuestion = undefined;
session.error = undefined;
session.updatedAt = new Date();
persistSession(session, "complete");
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "summary",
data: parsed.data,
});
milestoneSliceInterviewStreamManager.broadcast(session.id, { type: "complete" });
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", errorMessage);
milestoneSliceInterviewStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
});
setTargetSessionError(session, errorMessage);
}
}
@@ -1265,6 +1319,7 @@ export function __resetMilestoneSliceInterviewState(): void {
sessions.clear();
rateLimits.clear();
milestoneSliceInterviewStreamManager.reset();
generationGuard.reset();
if (_aiSessionStore && _aiSessionDeletedListener) {
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);