From a9efaf2a74d4eac5b77a11925e20e1e5ff272e77 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 10:48:12 -0700 Subject: [PATCH] FN-8302: fix terminal bootstrap after stale session creation Ensure first terminal-tab creation retries when an invalidated bootstrap request settles. - Wake the current bootstrap generation after stale session creation outcomes. - Cover scope changes that invalidate an in-flight initial tab request. Files changed: .../hooks/__tests__/useTerminalSessions.test.ts | 45 ++++++++++++++++++++++ .../dashboard/app/hooks/useTerminalSessions.ts | 33 +++++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8302 Fusion-Task-Lineage: a1d9a6f9-85eb-4168-af7f-5efc2828e3f2 Co-authored-by: Fusion (runfusion.ai) --- .../__tests__/useTerminalSessions.test.ts | 45 +++++++++++++++++++ .../app/hooks/useTerminalSessions.ts | 33 +++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index 1ad0d556f5..57d3e83f42 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { StrictMode } from "react"; import { renderHook, waitFor, act } from "@testing-library/react"; import { useTerminalSessions } from "../useTerminalSessions"; import { scopedKey } from "../../utils/projectStorage"; @@ -71,6 +72,50 @@ describe("useTerminalSessions", () => { expect(mockCreateTerminalSession).toHaveBeenCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID); }); + it("converges when a scope change invalidates an in-flight first-tab creation", async () => { + let resolveFirstCreate: (session: { sessionId: string; shell: string; cwd: string }) => void; + mockCreateTerminalSession + .mockImplementationOnce( + () => new Promise((resolve) => { + resolveFirstCreate = resolve; + }), + ) + .mockResolvedValueOnce({ + sessionId: "session-current-generation", + shell: "/bin/bash", + cwd: "/project/.worktrees/FN-8302", + }); + + const { result, rerender } = renderHook( + ({ storageScope }: { storageScope?: string }) => + useTerminalSessions(TEST_PROJECT_ID, { storageScope }), + { initialProps: { storageScope: undefined }, wrapper: StrictMode }, + ); + + await waitFor(() => { + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); + }); + + rerender({ storageScope: "task:FN-8302" }); + + await waitFor(() => { + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + resolveFirstCreate!({ + sessionId: "session-stale-generation", + shell: "/bin/bash", + cwd: "/project", + }); + }); + + await waitFor(() => { + expect(result.current.activeTab?.sessionId).toBe("session-current-generation"); + expect(result.current.bootstrapError).toBeNull(); + }); + }); + it("auto-creates first scoped tab in defaultCwd with a basename title", async () => { localStorageMock.getItem.mockReturnValue(null); mockListTerminalSessions.mockResolvedValue([]); diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index 2762138c97..e8aac51aaf 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -163,6 +163,8 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess const [bootstrapError, setBootstrapError] = useState(null); // Generation counter bumped by retryBootstrap to re-trigger auto-create effect const [retryGeneration, setRetryGeneration] = useState(0); + // FNXC:Terminal 2026-07-15-10:40: Forces auto-create to reconsider the current generation after a stale attempt settles. + const [bootstrapWakeGeneration, setBootstrapWakeGeneration] = useState(0); // Ref-based generation token to protect against stale completions from prior // bootstrap attempts. Only the current generation may mutate state. const generationRef = useRef(0); @@ -170,6 +172,12 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess useEffect(() => { generationRef.current += 1; + // FNXC:Terminal 2026-07-15-10:40: + // FN-8302 requires first-tab bootstrap to converge to an active tab or an + // actionable error. A reset can invalidate an already-started create, so + // wake the auto-create effect for the new generation instead of letting a + // stale completion leave TerminalModal on "Starting terminal..." forever. + setBootstrapWakeGeneration((generation) => generation + 1); setTabs(readTabsFromStorage(projectId, storageScope)); setIsReady(false); setServerAvailable(true); @@ -309,8 +317,11 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess "createTerminalSession" ) .then((session) => { - // Only apply state changes if this is still the current generation - if (gen !== generationRef.current) return; + // FNXC:Terminal 2026-07-15-10:40: A stale completion cannot mutate tabs, but must wake the active generation so its empty bootstrap state retries deterministically. + if (gen !== generationRef.current) { + setBootstrapWakeGeneration((generation) => generation + 1); + return; + } const newTab: TerminalTab = { id: generateTabId(), @@ -333,8 +344,11 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess setBootstrapError(null); }) .catch((err) => { - // Only set error if this is still the current generation - if (gen !== generationRef.current) return; + // FNXC:Terminal 2026-07-15-10:40: A stale failure cannot set an error, but must wake the current empty generation to preserve tab-or-error convergence. + if (gen !== generationRef.current) { + setBootstrapWakeGeneration((generation) => generation + 1); + return; + } if (!isRelativeUrlFetchError(err)) { console.error(err); } @@ -350,7 +364,16 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess }, 0); return () => clearTimeout(timeout); } - }, [bootstrapError, defaultCwd, isReady, projectId, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty + }, [ + bootstrapError, + bootstrapWakeGeneration, + defaultCwd, + isReady, + projectId, + serverAvailable, + tabs.length, + retryGeneration, + ]); // Run when ready, when tabs become empty, or after a stale attempt settles /** * Internal create tab function (used for auto-creation and user-initiated creation).