From 2ad7e9649c50e6db1050d4d116dcaec0020ce7cb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:51:10 -0700 Subject: [PATCH] report reconnect pause instead of dial error when budget exhausted When an automatic SSH reconnect exhausts the budget mid-dial, the retry loop stops early and rethrows the last dial error. Surfacing that error without the pause status hides why the retries stopped, leaving the user to guess that manual Connect is needed to restart them. Report the pause status and message instead so the reason surfaces to the run record. Auth failures are still reported as-is since they signal a credentials problem, not a retry-budget condition. --- src/main/ipc/ssh.test.ts | 63 +++++++++++++++++++ src/main/ipc/ssh.ts | 16 +++++ .../hooks/useAutomationDispatchEvents.test.ts | 51 +++++++++++++++ .../src/hooks/useAutomationDispatchEvents.ts | 4 +- 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 60f453714a0..a846e2958c4 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -637,6 +637,69 @@ describe('SSH IPC handlers', () => { }) }) + it('ssh:connect reports the pause when the budget runs out during an automatic dial', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getState.mockReturnValue(null) + mockConnectionManager.connect.mockImplementation(async () => { + // Stands in for wall-clock elapsing mid-dial: connect()'s own guard cuts the retry loop short + // and rethrows the last dial error, so this catch is the only place the reason can surface. + sshAutoReconnectBudget.reset('ssh-1') + sshAutoReconnectBudget.deadlineFor('ssh-1', Date.now() - AUTO_RECONNECT_BUDGET_MS) + throw new Error('connect ETIMEDOUT') + }) + + await expect( + handlers.get('ssh:connect')!(null, { targetId: 'ssh-1', initiator: 'auto' }) + ).rejects.toThrow('ETIMEDOUT') + + // Why not the raw dial error: the retries stopped by policy, and only the pause tells the user + // that Connect is what restarts them. + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({ + status: 'reconnection-failed', + error: AUTO_RECONNECT_PAUSED_MESSAGE + }) + }) + + it('ssh:connect still reports an auth failure when the budget is spent', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getState.mockReturnValue(null) + mockConnectionManager.connect.mockImplementation(async () => { + sshAutoReconnectBudget.reset('ssh-1') + sshAutoReconnectBudget.deadlineFor('ssh-1', Date.now() - AUTO_RECONNECT_BUDGET_MS) + throw new Error('All configured authentication methods failed') + }) + + await expect( + handlers.get('ssh:connect')!(null, { targetId: 'ssh-1', initiator: 'auto' }) + ).rejects.toThrow('All configured authentication methods failed') + + // Bad credentials are not a retry-budget problem — masking them with the pause hides the fix. + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toBeUndefined() + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'ssh:state-changed', + expect.objectContaining({ + state: expect.objectContaining({ + status: 'auth-failed', + error: 'All configured authentication methods failed' + }) + }) + ) + }) + it('ssh:connect does not park an automatic connect while the target is still connected', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index a7a36656d8a..708107c64bc 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1178,6 +1178,22 @@ export function registerSshHandlers( await abandonFailedSshSession(targetId, session) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) + // Why: connect()'s retry loop gives up early once the budget runs out mid-loop, so report the + // pause instead of the last dial's error — otherwise the "use Connect" reason never surfaces. + if ( + status !== 'auth-failed' && + initiator === 'auto' && + sshAutoReconnectBudget.isExhausted(targetId, Date.now()) + ) { + publishRelayOverride( + getCurrentMainWindow, + targetId, + 'reconnection-failed', + AUTO_RECONNECT_PAUSED_MESSAGE, + 0 + ) + throw err + } broadcastSshState(getCurrentMainWindow, targetId, { targetId, status, diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 04162c867f9..b48280af917 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -399,6 +399,57 @@ describe('useAutomationDispatchEvents setup launch', () => { ) }) + it('records why a parked SSH target skipped the run', async () => { + const folderWorkspace = { + id: 'folder:fw-1', + repoId: 'folder-workspace:group-1', + displayName: 'SSH folder', + path: '/srv/project' + } + state.repos = [ + { + id: 'repo-1', + connectionId: 'ssh-folder', + executionHostId: null, + path: '/srv/project/repo' + } + ] + state.folderWorkspaces = [ + { + id: 'fw-1', + projectGroupId: 'group-1', + folderPath: '/srv/project', + connectionId: 'ssh-folder' + } + ] + state.projectGroups = [{ id: 'group-1', connectionId: 'ssh-folder' }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + mockSshGetState.mockResolvedValue({ status: 'disconnected' }) + // A parked target resolves with the pause instead of throwing, so the reason only reaches the + // run record if the skip carries the state's error. + mockSshConnect.mockResolvedValue({ + status: 'reconnection-failed', + error: 'The SSH host is unreachable. Automatic reconnect is paused — use Connect to retry.' + }) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + setupDecision: 'skip', + runContext: { repoId: 'repo-1', hostId: 'ssh:ssh-folder' } + }) + ) + + expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'skipped_unavailable', + error: 'The SSH host is unreachable. Automatic reconnect is paused — use Connect to retry.' + }) + ) + }) + it('dispatches a local folder workspace without SSH', async () => { const folderWorkspace = { id: 'folder:fw-local', diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 6bde3d8d438..7f582678a76 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -173,7 +173,9 @@ export function useAutomationDispatchEvents(): void { initiator: 'auto' }) if (connected?.status !== 'connected') { - throw new Error('SSH target is unavailable.') + // Why prefer the state's error: a paused auto-reconnect budget explains that the + // run needs a manual Connect, which the generic message hides. + throw new Error(connected?.error || 'SSH target is unavailable.') } } catch (error) { await markDispatchResult({