FN-8332: preserve planning session progress after reload

Restore persisted Planning Mode sessions without starting a new generation.

- Limit automatic retries to generations started by the current mounted UI
- Render saved questions, summaries, thinking, and errors with manual retry after reload or resume
- Cover desktop, mobile, stream, and polling recovery paths and document the behavior
- Add a patch changeset for the published package

Files changed:
 .changeset/fn-8332-planning-reload-resume.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 .../dashboard/app/components/PlanningModeModal.tsx |  36 ++++-
 .../PlanningModeModal.planning-flow.test.tsx       | 175 +++++++++++++++++----
 4 files changed, 180 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-8332

Fusion-Task-Lineage: 6b128dc3-a091-4e81-bc6a-9c541d8e4cf5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 13:32:33 -07:00
parent 40d125b9d1
commit e0d2fd6085
4 changed files with 181 additions and 43 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Resume saved Planning Mode progress after reload without automatically re-running generation.
category: fix
dev: Persisted planning errors now restore the manual Retry/Dismiss panel; automatic retry remains live-turn only.

View File

@@ -511,8 +511,8 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma
When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface. When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface.
<!-- FNXC:PlanningRetry 2026-07-13-00:00: If Planning AI generation stalls and the server persists a terminal generation error, Planning Mode should auto-retry the same session up to three times before showing the permanent Retry/Dismiss error panel; any question or summary progress resets that budget. --> <!-- FNXC:PlanningRetry 2026-07-15-00:00: FN-8332 confines automatic Planning Mode retry to failures observed by an active in-session SSE/poll turn. Browser reload or session resume must restore the persisted progress/error verbatim and leave retry as an explicit user choice. -->
When Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. When an active Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again.
<!-- FNXC:Planning 2026-07-15-00:00: FN-8003 preserves the user’s original planning idea across error and mid-interview recovery surfaces. --> <!-- FNXC:Planning 2026-07-15-00:00: FN-8003 preserves the user’s original planning idea across error and mid-interview recovery surfaces. -->
Use **Copy prompt** in the error panel or an active interview question to copy the original “What do you want to build?” text, then paste it into **New session** to restart cleanly. Use **Copy prompt** in the error panel or an active interview question to copy the original “What do you want to build?” text, then paste it into **New session** to restart cleanly.

View File

@@ -369,6 +369,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const currentSessionIdRef = useRef<string | null>(null); const currentSessionIdRef = useRef<string | null>(null);
const viewRef = useRef<ViewState>({ type: "initial" }); const viewRef = useRef<ViewState>({ type: "initial" });
/* /*
FNXC:PlanningRetry 2026-07-15-00:00:
FN-8332 permits automatic retry only for a generation this mounted Planning
Mode instance started. A reloaded session may reconnect to observe a server
turn, but its persisted error must stay manual instead of spending another
generation.
*/
const liveGenerationSessionIdRef = useRef<string | null>(null);
/*
FNXC:PlanningRetry 2026-07-13-00:00: FNXC:PlanningRetry 2026-07-13-00:00:
FN-7946 requires stuck or terminal Planning Mode generation errors to auto-retry at most three times before the permanent error view appears. Keep the budget in refs for async SSE/poll/loadSession handlers, mirror the current attempt in state for the visible "Retrying" loading message, and reset the budget when successful progress reaches question or summary. FN-7946 requires stuck or terminal Planning Mode generation errors to auto-retry at most three times before the permanent error view appears. Keep the budget in refs for async SSE/poll/loadSession handlers, mirror the current attempt in state for the visible "Retrying" loading message, and reset the budget when successful progress reaches question or summary.
*/ */
@@ -665,7 +673,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput(""); setStreamingOutput("");
} else if (session.status === "error") { } else if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
const handled = await startPlanningAutoRetryRef.current(sessionId, errorMessage); const handled = liveGenerationSessionIdRef.current === sessionId
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage);
if (handled) return; if (handled) return;
if (cancelled || currentSessionIdRef.current !== sessionId) return; if (cancelled || currentSessionIdRef.current !== sessionId) return;
/* /*
@@ -907,10 +916,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsReconnecting(false); setIsReconnecting(false);
/* /*
FNXC:PlanningRetry 2026-07-13-00:00: FNXC:PlanningRetry 2026-07-15-00:00:
A terminal/persisted Planning Mode generation error is treated as a stuck-class turn. Try the existing /planning/:id/retry path up to MAX_PLANNING_AUTO_RETRIES before surfacing the permanent Retry/Dismiss error panel; overlapping SSE and poll signals share the same single-flight guard. FN-8332 limits the stuck-turn retry budget to generations started by
this mounted UI. A resumed stream may observe a terminal persisted
error, but it must surface the manual Retry/Dismiss panel instead;
overlapping live SSE and poll signals still share the single-flight guard.
*/ */
if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) { if (
liveGenerationSessionIdRef.current === sessionId
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage)
) {
return; return;
} }
setIsRetrying(false); setIsRetrying(false);
@@ -959,6 +974,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" }); setView({ type: "loading" });
currentSessionIdRef.current = retryTarget.sessionId; currentSessionIdRef.current = retryTarget.sessionId;
liveGenerationSessionIdRef.current = retryTarget.sessionId;
connectToPlanningStream(retryTarget.sessionId); connectToPlanningStream(retryTarget.sessionId);
try { try {
@@ -1142,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
); );
draftSessionIdRef.current = null; draftSessionIdRef.current = null;
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId); setSelectedSessionId(sessionId);
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);
@@ -1225,6 +1242,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
async (sessionId: string) => { async (sessionId: string) => {
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
// Loading a database row never makes its in-flight turn local to this mount.
liveGenerationSessionIdRef.current = null;
setError(null); setError(null);
setStreamingOutput(""); setStreamingOutput("");
@@ -1272,9 +1291,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (session.status === "error") { if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) { /*
return; FNXC:PlanningRetry 2026-07-15-00:00:
} FN-8332 requires browser-reload/session-resume to render the durable planning state verbatim and never dispatch a new generation. Auto-retry remains exclusively for live in-session SSE and loading-poll failures; persisted errors must expose the manual Retry/Dismiss panel.
*/
setView({ setView({
type: "error", type: "error",
session: { sessionId, currentQuestion: null, summary: null }, session: { sessionId, currentQuestion: null, summary: null },
@@ -1872,6 +1892,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget(); resetPlanningAutoRetryBudget();
setView({ type: "loading" }); setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state setStreamingOutput(""); // Clear old thinking output when entering loading state
liveGenerationSessionIdRef.current = sessionId;
try { try {
// Submit response - AI will broadcast events via the already-connected stream // Submit response - AI will broadcast events via the already-connected stream
@@ -1901,6 +1922,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget(); resetPlanningAutoRetryBudget();
setStreamingOutput(""); setStreamingOutput("");
setView({ type: "loading" }); setView({ type: "loading" });
liveGenerationSessionIdRef.current = sessionId;
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);

View File

@@ -2358,23 +2358,15 @@ describe("PlanningModeModal", () => {
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
}); });
it("auto-retries when resuming an errored session", async () => { it.each(["desktop", "mobile"] as const)("FN-8332 restores an errored resumed session without auto-retry on %s", async (viewportMode) => {
/* mockViewport(viewportMode);
* FNXC:PlanningRetry 2026-07-15-00:00:
* FN-8025 requires the stream to remain in the retry loading window while this test observes the transient status.
* Do not use the suite default here: its delayed question event clears the auto-retry state before the assertion can run.
*/
mockConnectPlanningStream.mockImplementation(() => ({
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
}));
mockFetchAiSession.mockResolvedValueOnce({ mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1", id: `session-error-${viewportMode}`,
type: "planning", type: "planning",
status: "error", status: "error",
title: "Errored planning", title: "Errored planning",
inputPayload: JSON.stringify({ initialPlan: "Recover planning" }), inputPayload: JSON.stringify({ initialPlan: "Recover planning" }),
conversationHistory: "[]", conversationHistory: JSON.stringify([{ thinkingOutput: "Persisted analysis" }]),
currentQuestion: null, currentQuestion: null,
result: null, result: null,
thinkingOutput: "", thinkingOutput: "",
@@ -2383,7 +2375,6 @@ describe("PlanningModeModal", () => {
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
}); });
mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-error-1" });
render( render(
<PlanningModeModal <PlanningModeModal
@@ -2392,23 +2383,148 @@ describe("PlanningModeModal", () => {
onTaskCreated={mockOnTaskCreated} onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()} onTasksCreated={vi.fn()}
tasks={mockTasks} tasks={mockTasks}
resumeSessionId="session-error-1" resumeSessionId={`session-error-${viewportMode}`}
/>, />,
); );
await waitFor(() => { expect(await screen.findByRole("alert")).toHaveTextContent("Session interrupted");
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined); fireEvent.click(screen.getByRole("button", { name: "Show AI reasoning" }));
}); expect(screen.getByText("Persisted analysis")).toBeInTheDocument();
await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined()); expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); expect(mockRetryPlanningSession).not.toHaveBeenCalled();
expect(mockStartPlanningStreaming).not.toHaveBeenCalled();
}); });
it("auto-retries when selecting an errored session from the sidebar", async () => { it("FN-8332 keeps a resumed generating stream error manual", async () => {
mockConnectPlanningStream.mockImplementation(() => ({ let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) };
});
mockFetchAiSession
.mockResolvedValueOnce({
id: "session-resumed-generating-stream",
type: "planning",
status: "generating",
title: "Resumed generation",
inputPayload: JSON.stringify({ initialPlan: "Restore a live server turn" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Persisted thinking",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "session-resumed-generating-stream",
type: "planning",
status: "error",
title: "Resumed generation",
inputPayload: JSON.stringify({ initialPlan: "Restore a live server turn" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Persisted thinking",
error: "Persisted server failure",
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:01.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-resumed-generating-stream"
/>,
);
await waitFor(() => expect(streamHandlers).toBeDefined());
await act(async () => {
streamHandlers.onError?.("Stream disconnected");
});
expect(await screen.findByRole("alert")).toHaveTextContent("Stream disconnected");
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(mockRetryPlanningSession).not.toHaveBeenCalled();
});
it("FN-8332 keeps a resumed generating poll error manual", async () => {
let pollTick: (() => void | Promise<void>) | undefined;
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler, timeout?: number) => {
if (timeout === 8000) {
pollTick = callback as () => void | Promise<void>;
}
return 1 as unknown as ReturnType<typeof setInterval>;
});
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: vi.fn(), close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true), isConnected: vi.fn().mockReturnValue(true),
})); }));
mockFetchAiSession
.mockResolvedValueOnce({
id: "session-resumed-generating-poll",
type: "planning",
status: "generating",
title: "Resumed polling generation",
inputPayload: JSON.stringify({ initialPlan: "Restore polling turn" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "session-resumed-generating-poll",
type: "planning",
status: "error",
title: "Resumed polling generation",
inputPayload: JSON.stringify({ initialPlan: "Restore polling turn" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Persisted polling failure",
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:01.000Z",
});
try {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-resumed-generating-poll"
/>,
);
await waitFor(() => expect(pollTick).toBeDefined());
await act(async () => {
await pollTick?.();
});
expect(await screen.findByRole("alert")).toHaveTextContent("Persisted polling failure");
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(mockRetryPlanningSession).not.toHaveBeenCalled();
} finally {
setIntervalSpy.mockRestore();
}
});
it("FN-8332 restores an errored sidebar selection without auto-retry", async () => {
mockFetchAiSessions.mockResolvedValueOnce([ mockFetchAiSessions.mockResolvedValueOnce([
{ {
id: "session-sidebar-error", id: "session-sidebar-error",
@@ -2435,7 +2551,6 @@ describe("PlanningModeModal", () => {
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z",
}); });
mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-sidebar-error" });
render( render(
<PlanningModeModal <PlanningModeModal
@@ -2447,19 +2562,13 @@ describe("PlanningModeModal", () => {
/>, />,
); );
await waitFor(() => { await screen.findByRole("button", { name: /Sidebar errored session/i });
expect(screen.getByRole("button", { name: /Sidebar errored session/i })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i })); fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i }));
await waitFor(() => { expect(await screen.findByRole("alert")).toHaveTextContent("Sidebar session interrupted");
expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error"); expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined); expect(mockRetryPlanningSession).not.toHaveBeenCalled();
}); expect(mockStartPlanningStreaming).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined());
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
}); });
it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => { it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {