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.
<!-- 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. -->
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.
<!-- 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 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. -->
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 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:
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("");
} else if (session.status === "error") {
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 (cancelled || currentSessionIdRef.current !== sessionId) return;
/*
@@ -907,10 +916,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsReconnecting(false);
/*
FNXC:PlanningRetry 2026-07-13-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.
FNXC:PlanningRetry 2026-07-15-00:00:
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;
}
setIsRetrying(false);
@@ -959,6 +974,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" });
currentSessionIdRef.current = retryTarget.sessionId;
liveGenerationSessionIdRef.current = retryTarget.sessionId;
connectToPlanningStream(retryTarget.sessionId);
try {
@@ -1142,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
);
draftSessionIdRef.current = null;
currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId);
connectToPlanningStream(sessionId);
@@ -1225,6 +1242,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
async (sessionId: string) => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
// Loading a database row never makes its in-flight turn local to this mount.
liveGenerationSessionIdRef.current = null;
setError(null);
setStreamingOutput("");
@@ -1272,9 +1291,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (session.status === "error") {
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({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
@@ -1872,6 +1892,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget();
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
liveGenerationSessionIdRef.current = sessionId;
try {
// Submit response - AI will broadcast events via the already-connected stream
@@ -1901,6 +1922,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget();
setStreamingOutput("");
setView({ type: "loading" });
liveGenerationSessionIdRef.current = sessionId;
connectToPlanningStream(sessionId);

View File

@@ -2358,23 +2358,15 @@ describe("PlanningModeModal", () => {
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
});
it("auto-retries when resuming an errored session", async () => {
/*
* 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),
}));
it.each(["desktop", "mobile"] as const)("FN-8332 restores an errored resumed session without auto-retry on %s", async (viewportMode) => {
mockViewport(viewportMode);
mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1",
id: `session-error-${viewportMode}`,
type: "planning",
status: "error",
title: "Errored planning",
inputPayload: JSON.stringify({ initialPlan: "Recover planning" }),
conversationHistory: "[]",
conversationHistory: JSON.stringify([{ thinkingOutput: "Persisted analysis" }]),
currentQuestion: null,
result: null,
thinkingOutput: "",
@@ -2383,7 +2375,6 @@ describe("PlanningModeModal", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-error-1" });
render(
<PlanningModeModal
@@ -2392,23 +2383,148 @@ describe("PlanningModeModal", () => {
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-error-1"
resumeSessionId={`session-error-${viewportMode}`}
/>,
);
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined);
});
await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined());
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
expect(await screen.findByRole("alert")).toHaveTextContent("Session interrupted");
fireEvent.click(screen.getByRole("button", { name: "Show AI reasoning" }));
expect(screen.getByText("Persisted analysis")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(mockRetryPlanningSession).not.toHaveBeenCalled();
expect(mockStartPlanningStreaming).not.toHaveBeenCalled();
});
it("auto-retries when selecting an errored session from the sidebar", async () => {
mockConnectPlanningStream.mockImplementation(() => ({
it("FN-8332 keeps a resumed generating stream error manual", async () => {
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(),
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([
{
id: "session-sidebar-error",
@@ -2435,7 +2551,6 @@ describe("PlanningModeModal", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-sidebar-error" });
render(
<PlanningModeModal
@@ -2447,19 +2562,13 @@ describe("PlanningModeModal", () => {
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Sidebar errored session/i })).toBeDefined();
});
await screen.findByRole("button", { name: /Sidebar errored session/i });
fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i }));
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error");
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined);
});
await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined());
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
expect(await screen.findByRole("alert")).toHaveTextContent("Sidebar session interrupted");
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(mockRetryPlanningSession).not.toHaveBeenCalled();
expect(mockStartPlanningStreaming).not.toHaveBeenCalled();
});
it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {