From 583418092e2e15717b953f95b2a8796cdaaf6464 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:35:57 -0700 Subject: [PATCH] fix(ssh): let agent session history recover once the relay is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported against the adhoc build: a workspace whose editor was loading remote files perfectly still showed "SSH relay is not ready" and "0 shown · 0 recent" in the Agent Session History panel, permanently. That string is what the relay throws before it is ready, which is ordinary at startup and again for the window a reconnect leaves the session not-ready. The panel had three refresh triggers — mount, window refocus, and a newly seen agent session id — and none of them fire when the relay simply becomes ready. So a transient startup error became a stuck panel next to a workspace that plainly worked, which is why the report described it as broken while everything else was fine. The file explorer already recovers from exactly this, off exactly this signal, with the rationale written down at use-file-explorer-tree-load-effects.ts: it loads before SSH providers are registered, so it retries when sshConnectedGeneration bumps. This panel simply never did. Same idiom, same gate — only retries when there was a prior error, so a local workspace or one that already listed fine does not rescan every time some unrelated host connects. Two tests in the existing suite. The retry one fails on the unfixed code with "the panel never retried after SSH became ready"; the second pins the gate, since a retry that fires on every connection bump would turn one bug into a rescan storm. --- .../ai-vault-session-refresh.test.ts | 45 +++++++++++++++++++ .../right-sidebar/ai-vault-session-refresh.ts | 21 +++++++++ 2 files changed, 66 insertions(+) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index c493604febf..19f0d47ce82 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -755,4 +755,49 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2', 'working') }) expect(listSessionsMock).toHaveBeenCalledTimes(3) }) + + // Reported against an adhoc build: a workspace whose editor was loading files fine still showed + // "SSH relay is not ready" with "0 shown · 0 recent" in this panel. That error is what the relay + // throws before it is ready, which is ordinary at startup and for the window a reconnect leaves it + // not-ready — but nothing here retried on the relay simply becoming ready. The remaining triggers + // are mount, window refocus and a new agent session id, so the error stuck while the rest of the + // workspace worked. The file explorer already recovers off this same signal. + it('retries after a not-ready failure once the SSH connection lands', async () => { + listSessionsMock.mockRejectedValueOnce(new Error('SSH relay is not ready')) + await renderHook(['/home/neil/projects/orca']) + await flushMicrotasks() + + expect(latest?.error).toBe('SSH relay is not ready') + const callsWhileBroken = listSessionsMock.mock.calls.length + + listSessionsMock.mockResolvedValue(EMPTY_RESULT) + await act(async () => { + useAppStore.setState({ sshConnectedGeneration: 1 }) + }) + await flushMicrotasks() + + expect( + listSessionsMock.mock.calls.length, + 'the panel never retried after SSH became ready' + ).toBeGreaterThan(callsWhileBroken) + expect(latest?.error).toBeNull() + }) + + it('does not rescan on a connection bump when the last listing succeeded', async () => { + // Gated on a prior error so a local workspace, or one that already listed fine, does not rescan + // every time some unrelated host connects. + listSessionsMock.mockResolvedValue(EMPTY_RESULT) + await renderHook(['/home/neil/projects/orca']) + await flushMicrotasks() + + expect(latest?.error).toBeNull() + const callsWhileHealthy = listSessionsMock.mock.calls.length + + await act(async () => { + useAppStore.setState({ sshConnectedGeneration: 1 }) + }) + await flushMicrotasks() + + expect(listSessionsMock.mock.calls.length).toBe(callsWhileHealthy) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index d6bf0bb120c..0c64607853e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -267,6 +267,27 @@ export function useAiVaultSessionRefresh( void refresh({ force: false, reuseLoadedDepth: true }) }, [executionHostScope, refresh, scanScopeKey]) + // Why: this panel can query the relay before it is ready — at startup, and again for the window + // in which a reconnect leaves the session not-ready — and the query throws 'SSH relay is not + // ready'. Nothing else here retries: the remaining triggers are mount, window refocus and a new + // agent session id, so a user whose workspace is otherwise working sits on that error + // indefinitely. The file explorer already recovers this way for the same reason + // (use-file-explorer-tree-load-effects.ts); this panel simply never did. + // + // Gated on a prior error so a local workspace, or one that already listed fine, does not rescan + // every time some other host connects. + const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const sshGenerationRef = useRef(sshConnectedGeneration) + useEffect(() => { + if (sshConnectedGeneration <= sshGenerationRef.current) { + return + } + sshGenerationRef.current = sshConnectedGeneration + if (error !== null) { + void refresh({ background: true, force: false }) + } + }, [sshConnectedGeneration, error, refresh]) + // Refocus checks the shared host cache without forcing another transcript scan. useEffect(() => { const onRefocus = (): void => {