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({