From b97e48507e0793b9fb0b24a74e441edeeac207ce Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 13 Jun 2026 02:53:42 -0700 Subject: [PATCH] Quiet disconnected SSH polling Co-authored-by: Orca --- .../host-context-implementation-checklist.md | 4 ++ .../multihost-remote-verification-report.html | 6 +++ src/main/ipc/preflight.test.ts | 25 +++++++++++++ src/main/ipc/preflight.ts | 4 +- src/main/ipc/pty.test.ts | 37 +++++++++++++++++++ src/main/ipc/pty.ts | 13 +++++++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index a551b6d52a8..b8f743a6c07 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -140,6 +140,10 @@ Done means: Local, SSH, remote server, and future cloud VMs share one Host model - [~] Integration-test Linear/Jira with explicit source account selection. - [x] Integration-test automations on local host, SSH host, and remote server where supported. - [x] Integration-test disconnected host UX for Tasks, New Workspace modal, sidebar, and automations. + - 2026-06-13 follow-up: verified patched main IPC against a live Docker SSH target. + Connected SSH PTY inspection returned `bash`; after disconnect, PTY inspection returned + `null`/`false` and remote agent detection returned `[]` without repeated main-process + handler errors. - [ ] Integration-test new client with older remote server and older client with newer server where practical. - [x] Electron-test Add Project flows: browse folder, clone URL, and create project from scratch on local and SSH hosts. - [x] Electron-test workspace options and sidebar host filtering. diff --git a/notes/multihost-remote-verification-report.html b/notes/multihost-remote-verification-report.html index 57bc6cb6ddb..56f0903a3c6 100644 --- a/notes/multihost-remote-verification-report.html +++ b/notes/multihost-remote-verification-report.html @@ -273,6 +273,12 @@ Docker SSH target through Orca IPC, added an SSH project, created an SSH worktree, tested disconnected SSH UX, opened Tasks, opened Automations, and captured screenshots below. +
  • + Follow-up disconnected SSH validation launched a patched second Electron instance, + connected a Docker SSH PTY, observed foreground process bash, disconnected + the host, then verified PTY inspection returned null/false + and remote agent detection returned [] without repeated handler errors. +
  • Focused locked-SSH Add Project regression test passed: src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx, diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 1f845f01603..311affa5987 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -589,6 +589,31 @@ describe('preflight', () => { }) }) + it('returns no remote agents when the SSH connection is unavailable', async () => { + getActiveMultiplexerMock.mockReturnValue(null) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + }) + + it('returns no remote agents when the SSH connection is disposed', async () => { + const request = vi.fn() + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => true, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + expect(request).not.toHaveBeenCalled() + }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 15591d81750..82981552c5f 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -248,7 +248,9 @@ export async function refreshShellPathAndDetectAgents( export async function detectRemoteAgents(args: { connectionId: string }): Promise { const mux = getActiveMultiplexer(args.connectionId) if (!mux || mux.isDisposed()) { - throw new Error(`No active SSH connection for "${args.connectionId}"`) + // Why: remote agent detection is passive UI polling. A disconnected host has + // no detectable agents until reconnect, but should not spam IPC errors. + return [] } const result = (await mux.request('preflight.detectAgents', { commands: KNOWN_AGENT_COMMANDS diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a165d4d3c45..94c41a23be7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2831,6 +2831,43 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated') }) + it('returns idle process inspection results for detached SSH PTYs without a provider', async () => { + const provider = { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + registerSshPtyProvider('ssh-1', provider as never) + registerPtyHandlers(mainWindow as never) + setPtyOwnership('remote-pty', 'ssh-1') + unregisterSshPtyProvider('ssh-1') + + await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe( + false + ) + await expect( + handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' }) + ).resolves.toBeNull() + expect(provider.hasChildProcesses).not.toHaveBeenCalled() + expect(provider.getForegroundProcess).not.toHaveBeenCalled() + }) + it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { const spawn = vi.fn(async () => ({ id: 'remote-pty' })) registerSshPtyProvider('ssh-1', { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9636c46215d..1e0afbda7ea 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -226,6 +226,13 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should + // read as idle instead of surfacing repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + return connectionId == null || sshProviders.has(connectionId) +} + function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId } @@ -2801,6 +2808,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return false + } return getProviderForPty(args.id).hasChildProcesses(args.id) } ) @@ -2808,6 +2818,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getForegroundProcess', async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return null + } return getProviderForPty(args.id).getForegroundProcess(args.id) } )