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

@@ -2357,6 +2357,21 @@ export function retryPlanningSession(
);
}
/** Stop in-flight planning generation for a session */
export function stopPlanningGeneration(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ success: boolean }> {
return api<{ success: boolean }>(
withProjectId(`/planning/${encodeURIComponent(sessionId)}/stop`, projectId),
{
method: "POST",
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
},
);
}
/** Cancel an active planning session */
export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
return api<void>(withProjectId("/planning/cancel", projectId), {

View File

@@ -972,6 +972,35 @@
color: var(--text);
}
.planning-loading-actions {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
margin-top: var(--space-md);
}
.planning-stop-btn {
border-color: var(--color-error);
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 12%, transparent);
}
.planning-stop-btn:hover {
border-color: var(--color-error-dark);
color: var(--color-error-dark);
background: color-mix(in srgb, var(--color-error) 18%, transparent);
}
.planning-stop-btn {
min-height: 36px;
}
.planning-elapsed {
font-size: 12px;
color: var(--text-muted);
}
/* Subtask Drag-and-Drop Styles */
.subtask-item {
transition: opacity var(--transition-fast), transform var(--transition-fast);
@@ -1159,6 +1188,17 @@
font-size: 12px;
}
.planning-loading-actions {
flex-direction: column;
width: 100%;
}
.planning-loading-actions .btn {
width: 100%;
min-height: 36px;
justify-content: center;
}
/* Prevent mobile zoom on focus for planning text inputs (16px minimum) */
.planning-textarea {
font-size: 16px;

View File

@@ -16,6 +16,7 @@ import {
createTasksFromPlanning,
fetchModels,
cancelPlanning,
stopPlanningGeneration,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
@@ -30,7 +31,7 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
@@ -106,6 +107,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
@@ -164,6 +167,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}, [streamingOutput]);
useEffect(() => {
if (view.type !== "loading") {
setGenerationStartTime(null);
setElapsedSeconds(0);
return;
}
const startedAt = Date.now();
setGenerationStartTime(startedAt);
setElapsedSeconds(0);
const timer = setInterval(() => {
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
}, 1000);
return () => clearInterval(timer);
}, [view.type]);
const resetDetailState = useCallback(() => {
setInitialPlan("");
setView({ type: "initial" });
@@ -827,6 +848,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
[projectId, sessionTabId, view]
);
const handleStopGeneration = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) {
return;
}
try {
await stopPlanningGeneration(sessionId, projectId, sessionTabId);
} catch {
// best-effort; server-side timeout/stop event may have already fired
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: "Generation stopped by user. You can retry or start a new session.",
});
setStreamingOutput("");
}, [projectId, sessionTabId]);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
@@ -1206,6 +1251,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>{streamingOutput ? "AI is thinking..." : "Generating next question..."}</p>
{generationStartTime && (
<div className="planning-elapsed">Thinking ({elapsedSeconds}s)</div>
)}
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
@@ -1214,6 +1262,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
>
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
<div className="planning-loading-actions">
<button className="btn planning-stop-btn" type="button" onClick={() => void handleStopGeneration()}>
<StopCircle size={14} />
<span className="icon-ml-6">Stop</span>
</button>
</div>
{showThinking && streamingOutput && (
<div className="planning-thinking-output" ref={thinkingOutputRef}>
<pre>{streamingOutput}</pre>

View File

@@ -14,6 +14,7 @@ const mockConnectPlanningStream = vi.fn();
const mockRespondToPlanning = vi.fn();
const mockRetryPlanningSession = vi.fn();
const mockCancelPlanning = vi.fn();
const mockStopPlanningGeneration = vi.fn();
const mockCreateTaskFromPlanning = vi.fn();
const mockStartPlanningBreakdown = vi.fn();
const mockCreateTasksFromPlanning = vi.fn();
@@ -41,6 +42,7 @@ vi.mock("../../api", () => ({
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
@@ -216,6 +218,7 @@ describe("PlanningModeModal", () => {
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockStopPlanningGeneration.mockResolvedValue({ success: true });
// Default: simulate receiving a question after a brief delay
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -577,6 +580,49 @@ describe("PlanningModeModal", () => {
});
});
it("shows stop action in loading and stops generation", async () => {
let streamHandlers: any;
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: closeSpy,
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Stop" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() => {
expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(closeSpy).toHaveBeenCalled();
expect(screen.getByText("Generation stopped by user. You can retry or start a new session.")).toBeDefined();
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
// avoid dangling handlers reference lint
expect(streamHandlers).toBeDefined();
});
it("shows error message when planning fails", async () => {
// Override the default mock to simulate an error
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {

View File

@@ -12,6 +12,7 @@ import {
submitResponse,
retrySession,
cancelSession,
stopGeneration,
getSession,
getCurrentQuestion,
getSummary,
@@ -32,6 +33,7 @@ import {
generateSubtasksFromPlanning,
formatInterviewQA,
SESSION_TTL_MS,
GENERATION_TIMEOUT_MS,
} from "../planning.js";
import { createApiRoutes } from "../routes.js";
import { request, get } from "../test-request.js";
@@ -1109,6 +1111,66 @@ describe("planning module", () => {
});
});
describe("generation controls", () => {
it("returns false when stopping unknown session", () => {
expect(stopGeneration("missing-session")).toBe(false);
});
it("stops in-flight generation and sets user-visible error", async () => {
let resolvePrompt: (() => void) | undefined;
const hangingAgent = {
session: {
state: { messages: [] as Array<{ role: string; content: string }> },
prompt: vi.fn(
() =>
new Promise<void>((resolve) => {
resolvePrompt = resolve;
}),
),
dispose: vi.fn(),
},
};
__setCreateFnAgent(async () => hangingAgent as any);
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(hangingAgent.session.prompt).toHaveBeenCalledTimes(1);
});
const stopped = stopGeneration(sessionId);
expect(stopped).toBe(true);
expect(hangingAgent.session.dispose).toHaveBeenCalled();
await flushAsyncWork();
expect(getSession(sessionId)?.error).toContain("Generation stopped by user");
resolvePrompt?.();
});
it("times out stalled generation and transitions session to error", async () => {
vi.useFakeTimers();
try {
const hangingAgent = {
session: {
state: { messages: [] as Array<{ role: string; content: string }> },
prompt: vi.fn(() => new Promise<void>(() => {})),
dispose: vi.fn(),
},
};
__setCreateFnAgent(async () => hangingAgent as any);
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS + 10);
await flushAsyncWork();
expect(getSession(sessionId)?.error).toContain("timed out");
} finally {
vi.useRealTimers();
}
});
});
describe("rehydrateFromStore", () => {
it("rehydrates planning sessions from SQLite rows", () => {
const store = new MockAiSessionStore();

View File

@@ -9852,6 +9852,27 @@ describe("Planning Mode Routes", () => {
});
});
describe("POST /planning/:sessionId/stop", () => {
it("stops an active generation", async () => {
const stopSpy = vi.spyOn(planningModule, "stopGeneration").mockReturnValue(true);
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-123/stop");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(stopSpy).toHaveBeenCalledWith("session-123");
});
it("returns 404 when session is missing", async () => {
vi.spyOn(planningModule, "stopGeneration").mockReturnValue(false);
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-404/stop");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("POST /planning/cancel", () => {
it("cancels an active session", async () => {
// Create a session first

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);

View File

@@ -604,6 +604,40 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
});
router.post("/planning/:sessionId/stop", async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
if (!lockCheck.allowed) {
res.status(409).json({
error: "Session locked by another tab",
lockedByTab: lockCheck.currentHolder,
});
return;
}
const { stopGeneration } = await import("../planning.js");
const stopped = stopGeneration(sessionId);
if (!stopped) {
throw notFound(`Planning session ${sessionId} not found or expired`);
}
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to stop planning session");
}
});
/**
* POST /api/planning/cancel
* Cancel and cleanup a planning session.
@@ -1113,6 +1147,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
"POST /planning/start-streaming",
"POST /planning/respond",
"POST /planning/:sessionId/retry",
"POST /planning/:sessionId/stop",
"POST /planning/cancel",
"POST /planning/create-task",
"POST /planning/start-breakdown",