fix(ssh): let agent session history recover once the relay is ready

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.
This commit is contained in:
Neil
2026-08-16 17:35:57 -07:00
parent cd92c98b50
commit 583418092e
2 changed files with 66 additions and 0 deletions
@@ -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)
})
})
@@ -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 => {