From 36583bc1c741834e3cb976b1fb75f6ff45effcf9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 21 Jul 2026 00:45:34 -0700 Subject: [PATCH] fix(planning): resume initial generation after stop Reset the initial-generation guard, ignore stale stream callbacks, and preserve session-specific elapsed time across refreshes. --- .changeset/fix-planning-stop-resume.md | 7 ++++ packages/core/src/async-ai-session-store.ts | 11 ++++- .../app/components/PlanningModeModal.tsx | 40 ++++++++++++++----- .../PlanningModeModal.planning-flow.test.tsx | 38 +++++++++++++++++- 4 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-planning-stop-resume.md diff --git a/.changeset/fix-planning-stop-resume.md b/.changeset/fix-planning-stop-resume.md new file mode 100644 index 0000000000..c5f5993ec8 --- /dev/null +++ b/.changeset/fix-planning-stop-resume.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Resume initial planning cleanly after stopping generation and preserve session timers across refreshes. +category: fix +dev: Ignores stale stopped streams and prevents draft updates from overwriting active generation metadata. diff --git a/packages/core/src/async-ai-session-store.ts b/packages/core/src/async-ai-session-store.ts index 3a4a83dd44..371806c795 100644 --- a/packages/core/src/async-ai-session-store.ts +++ b/packages/core/src/async-ai-session-store.ts @@ -410,7 +410,16 @@ export async function updateDraft( const result = await handle .update(schema.project.aiSessions) .set({ inputPayload: payloadValue as Record, updatedAt: now }) - .where(and(eq(schema.project.aiSessions.id, id), eq(schema.project.aiSessions.type, "planning"))) + /* + FNXC:PlanningMode 2026-07-21-00:42: + A debounced editor write can arrive after Start Planning changes the row to generating. + Restrict the mutation atomically so stale draft text cannot erase the generation timestamp. + */ + .where(and( + eq(schema.project.aiSessions.id, id), + eq(schema.project.aiSessions.type, "planning"), + eq(schema.project.aiSessions.status, "draft"), + )) .returning({ id: schema.project.aiSessions.id }); return result.length > 0; } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 5803f43584..f9b4c3fc22 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -440,6 +440,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [newSessionFocusSignal, setNewSessionFocusSignal] = useState(0); const modalRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); + const streamConnectionEpochRef = useRef(0); const currentSessionIdRef = useRef(null); const viewRef = useRef({ type: "initial" }); /* @@ -1053,13 +1054,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const connectToPlanningStream = useCallback( (sessionId: string) => { + const streamEpoch = ++streamConnectionEpochRef.current; streamConnectionRef.current?.close(); // Guard handlers against late events from a connection the user has // already navigated away from (e.g. clicked "New Session" while the // previous SSE flushed a buffered question). currentSessionIdRef is // cleared by resetDetailState and reassigned by handleStartPlanning / // loadSession before each connectToPlanningStream call. - const isStaleEvent = () => currentSessionIdRef.current !== sessionId; + const isStaleEvent = () => currentSessionIdRef.current !== sessionId + || streamConnectionEpochRef.current !== streamEpoch; const connection = connectPlanningStream(sessionId, projectId, { onThinking: (data) => { @@ -1149,6 +1152,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setStreamingOutput(""); }, onError: (message) => { + if (isStaleEvent()) return; const errorMessage = message || t("planning.sessionFailed", "Session failed while contacting the AI."); // A single transient stream error (e.g. tab was backgrounded long @@ -1159,6 +1163,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat (async () => { try { const session = await fetchAiSession(sessionId); + if (isStaleEvent()) return; if ( session && (session.status === "generating" || session.status === "awaiting_input") @@ -1169,6 +1174,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } catch { // fall through to error view below } + if (isStaleEvent()) return; /* FNXC:PlanningRetry 2026-07-15-00:00: @@ -1203,6 +1209,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat })(); }, onComplete: () => { + if (isStaleEvent()) return; setIsRetrying(false); resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); @@ -1361,6 +1368,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (!startedPlan || startPlanningInFlightRef.current) return; startPlanningInFlightRef.current = true; + if (draftDebounceRef.current) { + clearTimeout(draftDebounceRef.current); + draftDebounceRef.current = null; + } + setActivePlanPrompt(startedPlan); setError(null); setStreamingOutput(""); @@ -1383,10 +1395,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat let draftSessionId = draftSessionIdRef.current; if (!draftSessionId) { - if (draftDebounceRef.current) { - clearTimeout(draftDebounceRef.current); - draftDebounceRef.current = null; - } const draftPromise = draftCreatePromiseRef.current ?? createPlanningDraft(startedPlan, projectId, modelOverride); draftCreatePromiseRef.current = draftPromise; draftCreateInFlightRef.current = true; @@ -1551,9 +1559,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // An unavailable payload cannot provide a safe copy target. } setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : ""); - const persistedGenerationStartedAt = typeof inputPayload?.generationStartedAt === "string" - ? Date.parse(inputPayload.generationStartedAt) - : Number.NaN; + const generationStartedAtSource = typeof inputPayload?.generationStartedAt === "string" + ? inputPayload.generationStartedAt + : session.updatedAt; + const persistedGenerationStartedAt = Date.parse(generationStartedAtSource); setGenerationStartTime( session.status === "generating" && Number.isFinite(persistedGenerationStartedAt) ? persistedGenerationStartedAt @@ -2268,7 +2277,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setGenerationStartTime(Date.now()); setView({ type: "loading" }); setStreamingOutput(""); // Clear old thinking output when entering loading state + currentSessionIdRef.current = sessionId; liveGenerationSessionIdRef.current = sessionId; + if (!streamConnectionRef.current?.isConnected()) { + connectToPlanningStream(sessionId); + } try { // Submit response. SSE remains the primary live path, while the HTTP payload closes @@ -2349,7 +2362,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } }); } }, - [conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view] + [connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view] ); const handleStopGeneration = useCallback(async () => { @@ -2361,14 +2374,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const summary = runningSummaryRef.current; const history = conversationHistoryRef.current; + currentSessionIdRef.current = null; + liveGenerationSessionIdRef.current = null; + streamConnectionEpochRef.current += 1; + streamConnectionRef.current?.close(); + streamConnectionRef.current = null; + try { await stopPlanningGeneration(sessionId, projectId); } catch { // best-effort; server-side timeout/stop event may have already fired } - streamConnectionRef.current?.close(); - streamConnectionRef.current = null; + startPlanningInFlightRef.current = false; setIsRetrying(false); setIsAutoRetrying(false); setIsRefiningSummary(false); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 0e82c44087..95386c075b 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -363,12 +363,15 @@ describe("PlanningModeModal sequential flow", () => { mockFetchAiSession.mockImplementation(async (sessionId: string) => ({ ...base, id: sessionId, + updatedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(), status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update", - generationStartedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(), + ...(sessionId === "session-1" + ? { generationStartedAt: new Date(now - 25_000).toISOString() } + : {}), }), })); const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" }; @@ -397,8 +400,39 @@ describe("PlanningModeModal sequential flow", () => { await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1")); expect(await screen.findByText("What should change?")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts"); + await waitFor(() => expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts")); expect(screen.queryByText(/Generation stopped by user/i)).toBeNull(); + + const stoppedStreamHandlers = mockConnectPlanningStream.mock.calls[0]?.[2]; + mockRespondToPlanning.mockResolvedValue({ + currentQuestion: { id: "q-next", type: "text", question: "What comes next?" }, + summary: summaryWithRefinements, + }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith( + "session-1", + { "q-prior": "Preserve drafts" }, + "project-1", + )); + expect(await screen.findByText("What comes next?")).toBeInTheDocument(); + expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); + + stoppedStreamHandlers?.onError?.("Stream error"); + await Promise.resolve(); + expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); + expect(screen.queryByText("Stream error")).toBeNull(); + }); + it("can restart initial planning after stopping its first generation", async () => { + render(); + fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } }); + fireEvent.click(screen.getByRole("button", { name: "Start Planning" })); + await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(1)); + + fireEvent.click(await screen.findByRole("button", { name: "Stop" })); + expect(await screen.findByLabelText("What do you want to build?")).toHaveValue("Build secure accounts"); + fireEvent.click(screen.getByRole("button", { name: "Start Planning" })); + + await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(2)); }); it("renders exactly one write-your-own choice for normalized select questions", async () => { mockFetchAiSession.mockResolvedValue({