FN-7824: auto-reconnect terminal on first launch instead of parking disconnected

Terminal WebSocket sessions now retry with capped backoff through cold-start failures instead of giving up and requiring a manual Reconnect click.

- useTerminal tracks whether a socket has ever successfully opened via hasEverConnectedRef
- a never-connected initial connect ignores MAX_RECONNECT_ATTEMPTS and keeps retrying at capped backoff, staying in the reconnecting affordance until it opens
- mid-session drops (sockets that opened at least once) keep the existing bounded give-up behavior, and permanent 4000/4004 closes remain terminal
- context-change invalidation now uses a ref flag (contextChangedSinceLastEffectRef) consumed inside the effect instead of a transient boolean dependency, avoiding cleanup re-runs that tore down the replacement socket during context-switch/reconnect races
- manual reconnect() and context/session changes reset hasEverConnectedRef so cold-start behavior reapplies per session
- added a patch changeset and expanded useTerminal test coverage for first-launch reconnect vs. mid-session disconnect behavior
- documented the first-launch reconnect behavior in docs/dashboard-guide.md

Files changed:
 .changeset/FN-7824-terminal-first-launch-autoreconnect.md         |   7 +
 docs/dashboard-guide.md                                           |   3 +
 packages/dashboard/app/hooks/__tests__/useTerminal.test.ts        | 208 ++++++++++++++++++++-
 packages/dashboard/app/hooks/useTerminal.ts                       |  34 +++-
 4 files changed, 234 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7824

Fusion-Task-Lineage: 7ed696d0-449e-4dc0-9be0-48b429b8c844

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 18:52:28 -07:00
parent 26f0c5ae8a
commit 79264d4990
4 changed files with 234 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Terminal now auto-reconnects on first launch instead of getting stuck on "Disconnected".
category: fix
dev: useTerminal tracks whether the socket has ever opened; a never-connected initial connect keeps retrying at capped backoff (staying "reconnecting") until it opens, while mid-session drops and 4000/4004 permanent closes are unchanged.

View File

@@ -628,6 +628,9 @@ Mailbox view shows inbox/outbox communication threads and unread state.
Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path.
<!-- FNXC:Terminal 2026-07-11-18:20: FN-7824 first-launch terminal sockets auto-retry with capped backoff until the first successful open, so the manual Reconnect affordance is reserved for terminal sessions that already connected and then exhaust their mid-session reconnect budget. -->
On first launch or first open, the terminal keeps reconnecting automatically until its initial WebSocket opens; it should show **Reconnecting...** during that cold-start recovery rather than requiring a manual **Reconnect** click. If an already-connected terminal drops and exhausts its bounded reconnect budget, Fusion then parks it as **Disconnected** and surfaces the manual **Reconnect** control.
<!-- FNXC:TaskDetailTerminal 2026-07-11-13:20: FN-7826 makes the Task Detail interactive Terminal tab always available while preserving the existing CLI-agent Session tab label. The first shell uses task.worktree when present and otherwise falls back to the project base directory, including for multi-repo workspace tasks with no single worktree, while task-scoped terminal tabs remain separate from the footer/global project terminal. --> <!-- FNXC:TaskDetailTerminal 2026-07-11-13:20: FN-7826 makes the Task Detail interactive Terminal tab always available while preserving the existing CLI-agent Session tab label. The first shell uses task.worktree when present and otherwise falls back to the project base directory, including for multi-repo workspace tasks with no single worktree, while task-scoped terminal tabs remain separate from the footer/global project terminal. -->
Task Detail has two terminal-adjacent tabs when both are applicable: **Session** shows the pre-existing CLI agent session transcript/control surface, while **Terminal** embeds the interactive multi-tab terminal inside the task detail body. The interactive **Terminal** tab is always available in Task Detail; its first shell starts in the task worktree when one is recorded, otherwise it starts in the project base directory (project root), including for multi-repo workspace tasks that have no single task worktree. Its terminal tabs are stored separately from the footer/global project terminal tabs. Task Detail has two terminal-adjacent tabs when both are applicable: **Session** shows the pre-existing CLI agent session transcript/control surface, while **Terminal** embeds the interactive multi-tab terminal inside the task detail body. The interactive **Terminal** tab is always available in Task Detail; its first shell starts in the task worktree when one is recorded, otherwise it starts in the project base directory (project root), including for multi-repo workspace tasks that have no single task worktree. Its terminal tabs are stored separately from the footer/global project terminal tabs.

View File

@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useTerminal } from "../useTerminal"; import { useTerminal } from "../useTerminal";
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 16000, 16000];
class MockWebSocket { class MockWebSocket {
static CONNECTING = 0; static CONNECTING = 0;
static OPEN = 1; static OPEN = 1;
@@ -46,6 +48,20 @@ class MockWebSocket {
} }
} }
function closeLatestSocketAndAdvance(code: number, cycleIndex: number): MockWebSocket {
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
socket.emitClose(code);
});
act(() => {
vi.advanceTimersByTime(RECONNECT_DELAYS_MS[cycleIndex] ?? 16000);
});
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
}
describe("useTerminal", () => { describe("useTerminal", () => {
const originalWebSocket = globalThis.WebSocket; const originalWebSocket = globalThis.WebSocket;
@@ -237,6 +253,147 @@ describe("useTerminal", () => {
expect(result.current.connectionStatus).toBe("disconnected"); expect(result.current.connectionStatus).toBe("disconnected");
}); });
describe("first-launch reconnect surface enumeration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
{ label: "without projectId", projectId: undefined },
{ label: "with projectId", projectId: "proj-456" },
])(
"keeps a never-opened initial socket retrying with capped backoff $label",
({ projectId }) => {
const { result } = renderHook(() => useTerminal("test-session-123", projectId));
expect(result.current.connectionStatus).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
for (let cycle = 0; cycle < 7; cycle++) {
const beforeCloseCount = MockWebSocket.instances.length;
const closingSocket = MockWebSocket.instances[beforeCloseCount - 1];
act(() => {
closingSocket.emitClose(1006);
});
expect(result.current.connectionStatus).toBe("reconnecting");
act(() => {
vi.advanceTimersByTime(RECONNECT_DELAYS_MS[cycle]);
});
expect(MockWebSocket.instances).toHaveLength(beforeCloseCount + 1);
expect(result.current.connectionStatus).toBe("reconnecting");
}
const finalSocket = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
finalSocket.emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
expect(MockWebSocket.instances).toHaveLength(8);
},
);
it("keeps 4004 terminal and fires onSessionInvalid instead of retrying", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
MockWebSocket.instances[0].emitClose(4004);
});
act(() => {
vi.advanceTimersByTime(16000);
});
expect(result.current.connectionStatus).toBe("disconnected");
expect(onSessionInvalid).toHaveBeenCalledTimes(1);
expect(MockWebSocket.instances).toHaveLength(1);
});
it("keeps 4000 terminal without retrying", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitClose(4000);
});
act(() => {
vi.advanceTimersByTime(16000);
});
expect(result.current.connectionStatus).toBe("disconnected");
expect(MockWebSocket.instances).toHaveLength(1);
});
it("preserves bounded give-up behavior after a socket opened once", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
for (let cycle = 0; cycle < 5; cycle++) {
closeLatestSocketAndAdvance(1006, cycle);
}
expect(MockWebSocket.instances).toHaveLength(6);
act(() => {
MockWebSocket.instances[MockWebSocket.instances.length - 1].emitClose(1006);
});
act(() => {
vi.advanceTimersByTime(16000);
});
expect(result.current.connectionStatus).toBe("disconnected");
expect(MockWebSocket.instances).toHaveLength(6);
});
it("does not create a socket or retry when sessionId is null", () => {
const { result } = renderHook(() => useTerminal(null));
act(() => {
vi.advanceTimersByTime(16000);
});
expect(result.current.connectionStatus).toBe("disconnected");
expect(MockWebSocket.instances).toHaveLength(0);
});
it("resets never-connected retry state on context change without leaking stale timers", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
act(() => {
MockWebSocket.instances[0].emitClose(1006);
});
expect(result.current.connectionStatus).toBe("reconnecting");
rerender({ sessionId: "test-session-456", projectId: "proj-B" });
const countAfterContextChange = MockWebSocket.instances.length;
const activeContextSocket = MockWebSocket.instances[countAfterContextChange - 1];
expect(activeContextSocket.url).toContain("sessionId=test-session-456");
expect(activeContextSocket.url).toContain("projectId=proj-B");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(MockWebSocket.instances).toHaveLength(countAfterContextChange);
expect(result.current.connectionStatus).toBe("connecting");
});
});
describe("onSessionInvalid callback", () => { describe("onSessionInvalid callback", () => {
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => { it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
const { result } = renderHook(() => useTerminal("test-session-123")); const { result } = renderHook(() => useTerminal("test-session-123"));
@@ -870,18 +1027,14 @@ describe("useTerminal", () => {
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
}); });
// At this point:
// - ws1 was created (original)
// - ws1 close triggered reconnect
// - reconnect timeout fired and created ws2 (for stale context A)
// - context changed to B
// - closeWebSocketForContextChange closed ws1 and ws2
// - connect() created ws3 (for new context B)
// The stale ws2 reconnect should NOT have created another instance
expect(MockWebSocket.instances).toHaveLength(3); expect(MockWebSocket.instances).toHaveLength(3);
// The final ws should be for project B
expect(MockWebSocket.instances[2].url).toContain("projectId=proj-B"); expect(MockWebSocket.instances[2].url).toContain("projectId=proj-B");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(MockWebSocket.instances).toHaveLength(3);
}); });
it("resets connection status on context change", () => { it("resets connection status on context change", () => {
@@ -946,6 +1099,8 @@ describe("useTerminal", () => {
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(MockWebSocket.instances).toHaveLength(3);
// Final ws should be for project B // Final ws should be for project B
const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1]; const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
expect(finalWs.url).toContain("projectId=proj-B"); expect(finalWs.url).toContain("projectId=proj-B");
@@ -959,6 +1114,39 @@ describe("useTerminal", () => {
expect(result.current.connectionStatus).toBe("connected"); expect(result.current.connectionStatus).toBe("connected");
}); });
it("connects after context switch races an in-flight automatic reconnect", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
act(() => {
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitClose(1006);
});
act(() => {
vi.advanceTimersByTime(1000);
});
expect(MockWebSocket.instances).toHaveLength(2);
expect(MockWebSocket.instances[1].url).toContain("projectId=proj-A");
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
expect(MockWebSocket.instances).toHaveLength(3);
const projectBSocket = MockWebSocket.instances[2];
expect(projectBSocket.url).toContain("projectId=proj-B");
act(() => {
vi.advanceTimersByTime(0);
projectBSocket.emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
});
it("clears buffer on context change", () => { it("clears buffer on context change", () => {
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useTerminal("test-session-123", projectId), ({ projectId }: { projectId?: string }) => useTerminal("test-session-123", projectId),

View File

@@ -94,8 +94,12 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
// Track previous values to detect context changes // Track previous values to detect context changes
const previousSessionIdRef = useRef<string | null>(sessionId); const previousSessionIdRef = useRef<string | null>(sessionId);
const previousProjectIdRef = useRef<string | undefined>(projectId); const previousProjectIdRef = useRef<string | undefined>(projectId);
const contextChangedSinceLastEffectRef = useRef(false);
// Detect context change: either projectId or sessionId changed /*
* FNXC:Terminal 2026-07-11-18:42:
* Context changes must invalidate stale WebSocket callbacks during render, before effects run, but the effect consumes a ref flag instead of depending on the transient boolean. Depending on that boolean makes React run cleanup again when it flips back to false after a status update, tearing down the replacement socket during context-switch/reconnect races.
*/
const contextChanged = const contextChanged =
previousSessionIdRef.current !== sessionId || previousSessionIdRef.current !== sessionId ||
previousProjectIdRef.current !== projectId; previousProjectIdRef.current !== projectId;
@@ -104,6 +108,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
previousSessionIdRef.current = sessionId; previousSessionIdRef.current = sessionId;
previousProjectIdRef.current = projectId; previousProjectIdRef.current = projectId;
contextVersionRef.current++; contextVersionRef.current++;
contextChangedSinceLastEffectRef.current = true;
} }
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -111,6 +116,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null); const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isManualCloseRef = useRef(false); const isManualCloseRef = useRef(false);
const hasEverConnectedRef = useRef(false);
// Callback refs to avoid re-subscriptions // Callback refs to avoid re-subscriptions
const onDataCallbacksRef = useRef<Set<(data: string) => void>>(new Set()); const onDataCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
@@ -221,6 +227,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
// Clear buffers on context change to prevent stale replay // Clear buffers on context change to prevent stale replay
initialBufferRef.current = createEmptyBuffer(); initialBufferRef.current = createEmptyBuffer();
hasEverConnectedRef.current = false;
}, []); }, []);
// Cleanup function (used for unmount and manual reconnect) // Cleanup function (used for unmount and manual reconnect)
@@ -256,7 +263,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
}, []); }, []);
// Connect function // Connect function
const connect = useCallback(() => { const connect = useCallback((nextStatus: ConnectionStatus = "connecting") => {
if (!sessionId) { if (!sessionId) {
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
return; return;
@@ -281,7 +288,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
} }
isManualCloseRef.current = false; isManualCloseRef.current = false;
setConnectionStatus("connecting"); setConnectionStatus(nextStatus);
// Capture the context version at connection start. Stale callbacks from // Capture the context version at connection start. Stale callbacks from
// previous project/session contexts will be rejected by comparing against // previous project/session contexts will be rejected by comparing against
@@ -312,6 +319,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
// late-arriving messages from a previous session are discarded and // late-arriving messages from a previous session are discarded and
// the new session's scrollback/data is captured in a fresh buffer. // the new session's scrollback/data is captured in a fresh buffer.
initialBufferRef.current = createEmptyBuffer(); initialBufferRef.current = createEmptyBuffer();
hasEverConnectedRef.current = true;
setConnectionStatus("connected"); setConnectionStatus("connected");
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
@@ -425,10 +433,16 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
return; return;
} }
/**
* FNXC:Terminal 2026-07-11-18:20:
* FN-7824: A cold first launch can close the WebSocket before the backend is ready often enough to exhaust the normal reconnect budget. Never-opened sockets must keep retrying with capped backoff and stay in the reconnecting affordance so operators do not need a manual Reconnect click. Sockets that have opened at least once are real mid-session drops and keep the bounded give-up behavior, while permanent 4000/4004 closes remain terminal above.
*/
const isInitialConnect = !hasEverConnectedRef.current;
// Attempt reconnect with exponential backoff // Attempt reconnect with exponential backoff
reconnectAttemptsRef.current++; reconnectAttemptsRef.current++;
if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) { if (!isInitialConnect && reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) {
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
return; return;
} }
@@ -446,7 +460,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
return; return;
} }
if (!isManualCloseRef.current) { if (!isManualCloseRef.current) {
connect(); connect("reconnecting");
} }
}, Math.min(delay, 16000)); }, Math.min(delay, 16000));
}; };
@@ -459,6 +473,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
// Manual reconnect // Manual reconnect
const reconnect = useCallback(() => { const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
hasEverConnectedRef.current = false;
cleanup(); cleanup();
connect(); connect();
}, [cleanup, connect]); }, [cleanup, connect]);
@@ -466,25 +481,28 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
// Connect when sessionId or projectId changes // Connect when sessionId or projectId changes
// Handle context change: close existing WebSocket, cancel timers, reset state // Handle context change: close existing WebSocket, cancel timers, reset state
useEffect(() => { useEffect(() => {
// If context changed, perform cleanup before connecting to new context // If context changed, perform cleanup before connecting to new context.
if (contextChanged) { if (contextChangedSinceLastEffectRef.current) {
contextChangedSinceLastEffectRef.current = false;
// Use internal cleanup that doesn't mark as manual close, // Use internal cleanup that doesn't mark as manual close,
// allowing proper context transition without stale onclose interference // allowing proper context transition without stale onclose interference
closeWebSocketForContextChange(); closeWebSocketForContextChange();
// Reset transient state // Reset transient state
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
hasEverConnectedRef.current = false;
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
} }
if (sessionId) { if (sessionId) {
connect(); connect();
} else { } else {
hasEverConnectedRef.current = false;
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
} }
return cleanup; return cleanup;
}, [sessionId, projectId, contextChanged, connect, cleanup, closeWebSocketForContextChange]); }, [sessionId, projectId, connect, cleanup, closeWebSocketForContextChange]);
return { return {
connectionStatus, connectionStatus,