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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 10:48:12 -07:00
parent 8d1620ea23
commit a9efaf2a74
2 changed files with 73 additions and 5 deletions

View File

@@ -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([]);

View File

@@ -163,6 +163,8 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
const [bootstrapError, setBootstrapError] = useState<string | null>(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).