fix(FN-2861): harden planning session recovery and retry UX

- Add planning and subtask retry routes with session-lock checks and proper API error mapping
- Improve planning session execution with timeout/abort handling, stop-generation support, and resilient stream catch-up behavior
- Update Planning Mode modal to handle reconnects, retry-from-error flow, stop action, and cross-tab session state synchronization
- Expand dashboard tests for planning routes, planning session behavior, and PlanningModeModal retry/error coverage
This commit is contained in:
Fusion
2026-04-28 08:16:12 -07:00
committed by gsxdsm
parent 2f7ba29ead
commit ff58623c32
8 changed files with 401 additions and 33 deletions

View File

@@ -179,6 +179,9 @@ const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
/** Rate limiting window in milliseconds (1 hour) */
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
/** Generation timeout in milliseconds (120 seconds). */
export const GENERATION_TIMEOUT_MS = 120_000;
// ── Types ───────────────────────────────────────────────────────────────────
/** SSE event types for planning session streaming */
@@ -236,6 +239,9 @@ const sessions = new Map<string, Session>();
/** Rate limiting state indexed by IP */
const rateLimits = new Map<string, RateLimitEntry>();
/** Active planning generations keyed by session ID. */
const activeGenerations = new Map<string, { abortController: AbortController; timer: NodeJS.Timeout }>();
// ── AI Session Persistence ────────────────────────────────────────────────
/** Optional store for persisting session state across reloads/browsers. */
@@ -281,6 +287,12 @@ function cleanupInMemorySession(sessionId: string): boolean {
return false;
}
const activeGeneration = activeGenerations.get(sessionId);
if (activeGeneration) {
clearTimeout(activeGeneration.timer);
activeGenerations.delete(sessionId);
}
if (session.agent) {
try {
session.agent.session.dispose?.();
@@ -1030,14 +1042,71 @@ const MAX_PARSE_RETRIES = 1;
* one retry attempt is made with a reformat prompt before emitting a
* terminal session error.
*/
function setSessionError(session: Session, message: string): void {
session.error = message;
session.updatedAt = new Date();
persistSession(session, "error", message);
planningStreamManager.broadcast(session.id, {
type: "error",
data: message,
});
}
function createAbortError(): Error {
const error = new Error("Generation aborted");
error.name = "AbortError";
return error;
}
async function runGenerationWithTimeout<T>(session: Session, operation: () => Promise<T>): Promise<T> {
const existing = activeGenerations.get(session.id);
if (existing) {
clearTimeout(existing.timer);
existing.abortController.abort();
}
const abortController = new AbortController();
let timeoutTriggered = false;
const timer = setTimeout(() => {
timeoutTriggered = true;
setSessionError(session, "AI generation timed out. You can retry or start a new session.");
abortController.abort();
}, GENERATION_TIMEOUT_MS);
activeGenerations.set(session.id, { abortController, timer });
const abortPromise = new Promise<never>((_, reject) => {
abortController.signal.addEventListener(
"abort",
() => reject(createAbortError()),
{ once: true },
);
});
try {
return await Promise.race([operation(), abortPromise]);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (!timeoutTriggered && !session.error) {
setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
}
}
throw error;
} finally {
clearTimeout(timer);
activeGenerations.delete(session.id);
}
}
async function continueAgentConversation(session: Session, message: string): Promise<void> {
if (!session.agent) {
throw new InvalidSessionStateError("AI agent not initialized");
}
try {
// Clear thinking output for this turn
session.thinkingOutput = "";
await runGenerationWithTimeout(session, async () => {
// Clear thinking output for this turn
session.thinkingOutput = "";
// Send message to agent using .prompt() - it will stream thinking via onThinking callback
await session.agent.session.prompt(message);
@@ -1137,39 +1206,38 @@ async function continueAgentConversation(session: Session, message: string): Pro
return;
}
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
planningStreamManager.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");
planningStreamManager.broadcast(session.id, {
type: "summary",
data: parsed.data,
});
planningStreamManager.broadcast(session.id, { type: "complete" });
}
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
planningStreamManager.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");
planningStreamManager.broadcast(session.id, {
type: "summary",
data: parsed.data,
});
planningStreamManager.broadcast(session.id, { type: "complete" });
}
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
}
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);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
});
setSessionError(session, errorMessage);
}
}
@@ -1452,6 +1520,32 @@ export async function retrySession(
await continueAgentConversation(session, replayMessage);
}
export function stopGeneration(sessionId: string): boolean {
const session = sessions.get(sessionId);
const activeGeneration = activeGenerations.get(sessionId);
if (!session || !activeGeneration) {
return false;
}
activeGeneration.abortController.abort();
clearTimeout(activeGeneration.timer);
activeGenerations.delete(sessionId);
if (session.agent) {
nonfatal(
() => session.agent?.session.dispose?.(),
diagnostics,
"Error disposing agent for stop-generation",
{ sessionId, operation: "stop-generation-dispose" },
);
session.agent = undefined;
}
setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
return true;
}
/**
* Format user response as a message for the AI agent.
*/
@@ -1702,6 +1796,7 @@ export function __resetPlanningState(): void {
sessions.clear();
rateLimits.clear();
planningStreamManager.reset();
activeGenerations.clear();
if (_aiSessionStore && _aiSessionDeletedListener) {
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);