mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user