Fix SSH state for paired remote clients + complete target re-adoption sweep (STA-1468) (#7767)

* Sweep all persisted carriers of a removed SSH target id on re-adoption

reassignSshTargetId re-pointed repos and worktree metas but left the old
target id embedded in persisted session pty ids (ssh:<id>@@pty-N in tabs,
layouts, remoteSessionIdsByTabId), the startup reconnect list
(activeConnectionIdsAtShutdown, replayed via ssh.connect at boot — the
exact 'SSH target not found' in STA-1468), sleeping-agent resume records,
provisioned project host setups, sidebar host-scope arrays, and relay pty
leases. Any survivor resurfaces later as a failing connect or reattach.

New ssh-target-id-migration module re-points every carrier in one pass,
wired into reassignSshTargetId with per-carrier unit and store-level
round-trip tests.

Co-authored-by: Orca <help@stably.ai>

* Bridge SSH connection state to paired remote clients

The SSH surface was desktop-only: ssh:state-changed went to the host's
own BrowserWindow and the web client's ssh API was a no-op stub, so a
paired client's reconnect overlay never learned the host connected and
its target labels stayed empty (STA-1468 — overlay stuck on 'please
connect' over a live terminal).

- New sshStateChanged runtime client event, emitted from broadcastSshState
  through OrcaRuntimeService onto the existing clientEvents stream.
- New ssh.listTargets / ssh.listRemovedTargetLabels RPC methods next to
  the previously unused ssh.getState / ssh.connect.
- Web preload now routes listTargets / listRemovedTargetLabels / getState
  / connect to the paired host's runtime RPC instead of stubbing them.
- useIpcEvents applies sshStateChanged on paired web clients through the
  same guarded path as desktop ssh.onStateChanged; desktop clients ignore
  the event since a foreign runtime's targets would pollute their local
  SSH store.

Co-authored-by: Orca <help@stably.ai>

* Harden the SSH reconnect overlay against stale or unknown target state

- Only present the destructive 'SSH host removed' state on positive
  evidence (a removal tombstone label, or a hydrated non-empty target
  list lacking the id). A client whose SSH state never hydrated has an
  empty labels map for every id and must not offer workspace removal.
- After a failed Connect, resync target metadata so a stale overlay
  converges to the ghost/re-adopted state instead of offering the same
  failing Connect forever (the repeated 'SSH target not found' toast
  loop in STA-1468).

Co-authored-by: Orca <help@stably.ai>

* Address CodeRabbit review on #7767

- Re-key workspaceSessionsByHostId partitions stored under a removed SSH
  host id during re-adoption (no writer keys partitions by ssh host today,
  but the schema tolerates it — re-key instead of stranding; live partition
  wins when both keys exist).
- Track SSH target-list hydration explicitly (sshTargetsHydrated) instead
  of inferring it from a non-empty label map, so a legitimately empty
  target list still counts as removal evidence and a never-hydrated client
  still never offers destructive removal.
- Apply the refreshed target list before the best-effort removed-labels
  fetch in the overlay resync, so a labels failure can't discard it.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-08 11:36:19 -07:00 committed by GitHub
parent 38e82b4fff
commit 864594ffdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 763 additions and 63 deletions

View File

@ -778,6 +778,39 @@ describe('SSH IPC handlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
})
it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => {
const runtime = {
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
notifySshStateChanged: vi.fn()
}
registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never)
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue({})
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
// Why: paired remote clients only learn SSH state through this hook —
// without it their reconnect overlays never clear (STA-1468).
expect(runtime.notifySshStateChanged).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ targetId: 'ssh-1', status: 'connected' })
)
})
it('preserves active port forwards and live connections across handler re-registration', async () => {
const target: SshTarget = {
id: 'ssh-1',

View File

@ -90,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un
return registeredGetSshState?.(targetId)
}
/** Public targets for runtime RPC clients — same list the desktop renderer gets. */
export function listRegisteredSshTargets(): SshTarget[] {
return sshStore?.listTargets() ?? []
}
/** Removed-target id → last known label, for ghost-host display on paired clients. */
export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
return sshStore?.listRemovedTargetLabels() ?? {}
}
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
if (!connectionManager) {
return
@ -235,13 +245,14 @@ function broadcastSshState(
if (isRuntimeOwnedSshTargetId(targetId)) {
return
}
const enrichedState = withSshRemotePlatform(targetId, state)
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', {
targetId,
state: withSshRemotePlatform(targetId, state)
})
win.webContents.send('ssh:state-changed', { targetId, state: enrichedState })
}
// Why: paired remote clients have no ssh:state-changed IPC; without this
// their terminals keep a stale reconnect overlay after the host connects.
currentRuntime?.notifySshStateChanged?.(targetId, enrichedState)
}
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {

View File

@ -3198,6 +3198,140 @@ describe('Store', () => {
expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new')
})
it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }))
store.setWorkspaceSession({
activeRepoId: 'r1',
activeWorktreeId: 'r1::/wt',
activeTabId: 'tab1',
tabsByWorktree: {
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })]
},
terminalLayoutsByTabId: {},
remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' },
activeConnectionIdsAtShutdown: ['ssh-old']
})
store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' })
store.updateUI({
workspaceHostScope: 'ssh:ssh-old',
visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'],
workspaceHostOrder: ['ssh:ssh-old', 'local']
})
store.reassignSshTargetId('ssh-old', 'ssh-new')
store.flush()
const reloaded = await createStore()
const session = reloaded.getWorkspaceSession()
expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2')
expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' })
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new'])
expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1)
expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0)
const ui = reloaded.getUI()
expect(ui.workspaceHostScope).toBe('ssh:ssh-new')
expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new'])
expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local'])
})
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
const store = await createStore()
store.setWorkspaceSession(
{
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {
'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })]
},
terminalLayoutsByTabId: {}
},
'ssh:ssh-old'
)
store.reassignSshTargetId('ssh-old', 'ssh-new')
store.flush()
const reloaded = await createStore()
// Old-key partition is gone; the re-keyed one carries migrated pty ids.
expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe(
'ssh:ssh-new@@pty-9'
)
})
it('reassignSshTargetId keeps the live partition when both host keys exist', async () => {
const store = await createStore()
const baseSession = {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
terminalLayoutsByTabId: {}
}
store.setWorkspaceSession(
{ ...baseSession, tabsByWorktree: { 'r1::/dead': [] } },
'ssh:ssh-old'
)
store.setWorkspaceSession(
{ ...baseSession, tabsByWorktree: { 'r1::/live': [] } },
'ssh:ssh-new'
)
store.reassignSshTargetId('ssh-old', 'ssh-new')
expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({})
expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] })
})
it('reassignSshTargetId re-points an independent provisioned host setup', async () => {
const store = await createStore()
store.addRepo({
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
upstream: { owner: 'stablyai', repo: 'cloud-project' }
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-old',
setupId: 'cloud-project::ssh-old',
setupMethod: 'provisioned'
})
// Meta-only re-adoption (no repo pinned to the old id) must still migrate
// the provisioned setup, or new worktrees would be born on a dead host id.
store.reassignSshTargetId('ssh-old', 'ssh-new')
const setups = store.getProjectHostSetups()
const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old')
expect(provisioned?.hostId).toBe('ssh:ssh-new')
})
it('reassignSshTargetId drops a stale setup when the new host already has one', async () => {
const store = await createStore()
store.addRepo({
...makeRepo({ id: 'r1', displayName: 'Cloud Project' }),
upstream: { owner: 'stablyai', repo: 'cloud-project' }
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-old',
setupId: 'setup-old',
setupMethod: 'provisioned'
})
store.createProjectHostSetup({
projectId: 'github:stablyai/cloud-project',
hostId: 'ssh:ssh-new',
setupId: 'setup-new',
setupMethod: 'provisioned'
})
store.reassignSshTargetId('ssh-old', 'ssh-new')
const setups = store.getProjectHostSetups()
expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined()
expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new')
})
// ── 7. updateRepo ──────────────────────────────────────────────────
it('updateRepo modifies the repo in place', async () => {

View File

@ -113,6 +113,10 @@ import {
type ExecutionHostId
} from '../shared/execution-host'
import { toRelaySshPtyId } from './providers/ssh-pty-id'
import {
migrateUiHostScopeSshTargetId,
migrateWorkspaceSessionSshTargetId
} from './ssh/ssh-target-id-migration'
import { isWslUncPath } from '../shared/wsl-paths'
import {
isTerminalLeafId,
@ -6131,13 +6135,71 @@ export class Store {
metaChanged = true
}
}
// Why: repo-row rewrites can affect host-setup compatibility, but meta-only
// rewrites cannot — keep that sync under the repo gate. Persist whenever
// either repos OR metas changed, so meta-only re-points aren't lost on quit.
if (repoCount > 0) {
// Why: the old id also survives in session pty ids, the startup reconnect
// list, sleeping-agent records, host setups, host-scope UI, and pty leases;
// any un-migrated carrier later throws `SSH target not found` (STA-1468).
let carrierChanged = migrateWorkspaceSessionSshTargetId(
this.state.workspaceSession,
oldTargetId,
newTargetId
)
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) {
carrierChanged = true
}
}
// Why: partitions are read by host id, so one stored under the removed id
// would be orphaned. No writer keys partitions by ssh host today, but the
// schema tolerates it — re-key rather than strand it. If the new key
// already has a partition, that one is live; drop the dead old one.
const partitions = this.state.workspaceSessionsByHostId
const oldPartition = partitions?.[oldHostId]
if (partitions && oldPartition) {
delete partitions[oldHostId]
partitions[newHostId] ??= oldPartition
carrierChanged = true
}
if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) {
carrierChanged = true
}
for (const lease of this.state.sshRemotePtyLeases ?? []) {
if (lease.targetId === oldTargetId) {
lease.targetId = newTargetId
carrierChanged = true
}
}
let setupsChanged = false
const keptSetups: ProjectHostSetup[] = []
for (const setup of this.state.projectHostSetups) {
if (setup.hostId !== oldHostId) {
keptSetups.push(setup)
continue
}
const duplicate = this.state.projectHostSetups.some(
(entry) =>
entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId
)
// Why: a setup already exists for the re-added host — the old row is a
// stale ghost that would violate the (projectId, hostId) uniqueness.
if (duplicate) {
setupsChanged = true
continue
}
setup.hostId = newHostId
setup.updatedAt = Date.now()
keptSetups.push(setup)
setupsChanged = true
}
if (setupsChanged) {
this.state.projectHostSetups = keptSetups
}
// Why: repo-row and host-setup rewrites can affect host-setup compatibility,
// but meta-only rewrites cannot — keep that sync under this gate. Persist
// whenever anything changed, so partial re-points aren't lost on quit.
if (repoCount > 0 || setupsChanged) {
this.syncProjectHostSetupCompatibilityState()
}
if (repoCount > 0 || metaChanged) {
if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) {
this.scheduleSave()
}
return repoCount

View File

@ -130,6 +130,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/executi
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type { SshConnectionState } from '../../shared/ssh-types'
import type {
LinearCurrentIssueContextHints,
LinearAttachResult,
@ -2806,6 +2807,12 @@ export class OrcaRuntimeService {
this.emitClientEvent({ type: 'reposChanged' })
}
// Why: SSH state changes originate in main's ssh handlers, not in runtime
// methods, so they need a public entry point onto the client-event stream.
notifySshStateChanged(targetId: string, state: SshConnectionState): void {
this.emitClientEvent({ type: 'sshStateChanged', targetId, state })
}
private notifyActivateWorktree(
repoId: string,
worktreeId: string,

View File

@ -4,14 +4,23 @@ import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SSH_METHODS } from './ssh'
const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({
const {
connectRegisteredSshTargetMock,
getRegisteredSshStateMock,
listRegisteredSshTargetsMock,
listRegisteredRemovedSshTargetLabelsMock
} = vi.hoisted(() => ({
connectRegisteredSshTargetMock: vi.fn(),
getRegisteredSshStateMock: vi.fn()
getRegisteredSshStateMock: vi.fn(),
listRegisteredSshTargetsMock: vi.fn(),
listRegisteredRemovedSshTargetLabelsMock: vi.fn()
}))
vi.mock('../../../ipc/ssh', () => ({
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
getRegisteredSshState: getRegisteredSshStateMock
getRegisteredSshState: getRegisteredSshStateMock,
listRegisteredSshTargets: listRegisteredSshTargetsMock,
listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock
}))
function makeRequest(method: string, params?: unknown): RpcRequest {
@ -62,4 +71,26 @@ describe('ssh RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { state: null } })
})
it('lists the registered SSH targets for paired clients', async () => {
const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }]
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listTargets'))
expect(response).toMatchObject({ ok: true, result: { targets } })
})
it('lists removed-target labels for ghost-host display on paired clients', async () => {
const labels = { 'ssh-old': 'Dev box' }
listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels'))
expect(response).toMatchObject({ ok: true, result: { labels } })
})
})

View File

@ -1,5 +1,10 @@
import { z } from 'zod'
import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh'
import {
connectRegisteredSshTarget,
getRegisteredSshState,
listRegisteredRemovedSshTargetLabels,
listRegisteredSshTargets
} from '../../../ipc/ssh'
import { defineMethod, type RpcMethod } from '../core'
const SshTarget = z.object({
@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [
name: 'ssh.connect',
params: SshTarget,
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
}),
defineMethod({
name: 'ssh.listTargets',
params: null,
handler: () => ({ targets: listRegisteredSshTargets() })
}),
defineMethod({
name: 'ssh.listRemovedTargetLabels',
params: null,
handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() })
})
]

View File

@ -308,6 +308,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'settings.update',
'ssh.connect',
'ssh.getState',
'ssh.listRemovedTargetLabels',
'ssh.listTargets',
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',

View File

@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types'
import {
migrateUiHostScopeSshTargetId,
migrateWorkspaceSessionSshTargetId
} from './ssh-target-id-migration'
const OLD_ID = 'ssh-1783337351840-ohabf0'
const NEW_ID = 'ssh-1783400000000-fresh1'
const makeTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({
id: 'tab1',
ptyId: null,
worktreeId: 'r1::/wt',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
...overrides
})
const makeSession = (overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState => ({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
...overrides
})
describe('migrateWorkspaceSessionSshTargetId', () => {
it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => {
const session = makeSession({
tabsByWorktree: {
'r1::/wt': [
makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }),
makeTab({ id: 'tab2', ptyId: 'local-pty-7' }),
makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` })
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` }
}
},
remoteSessionIdsByTabId: {
tab1: `ssh:${OLD_ID}@@pty-3`,
tab3: `ssh:other-target@@pty-1`
}
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
const tabs = session.tabsByWorktree['r1::/wt']
expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`)
// Local and other-target pty ids must be untouched.
expect(tabs[1].ptyId).toBe('local-pty-7')
expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`)
expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
'leaf-1': `ssh:${NEW_ID}@@pty-3`
})
expect(session.remoteSessionIdsByTabId).toEqual({
tab1: `ssh:${NEW_ID}@@pty-3`,
tab3: `ssh:other-target@@pty-1`
})
})
it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => {
const session = makeSession({
activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated']
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated'])
})
it('re-points sleeping agent records pinned to the old connection', () => {
const session = makeSession({
sleepingAgentSessionsByPaneKey: {
'tab1:leaf-1': {
paneKey: 'tab1:leaf-1',
worktreeId: 'r1::/wt',
agent: 'claude',
providerSession: { key: 'session_id', id: 's-1' },
prompt: 'p',
state: 'done',
capturedAt: 1,
updatedAt: 1,
connectionId: OLD_ID
}
}
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true)
expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID)
})
it('returns false when nothing references the old id', () => {
const session = makeSession({
tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] },
activeConnectionIdsAtShutdown: ['ssh-unrelated']
})
expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false)
expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated'])
})
})
describe('migrateUiHostScopeSshTargetId', () => {
const makeUi = (overrides: Partial<PersistedUIState>): PersistedUIState =>
({ ...overrides }) as PersistedUIState
it('re-points scope, visible hosts, and host order, deduping collisions', () => {
const ui = makeUi({
workspaceHostScope: `ssh:${OLD_ID}`,
visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`],
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local']
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`)
expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`])
expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local'])
})
it('returns false when the old host id appears nowhere', () => {
const ui = makeUi({
workspaceHostScope: 'all',
visibleWorkspaceHostIds: ['local'],
workspaceHostOrder: ['local']
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)
})
})

View File

@ -0,0 +1,116 @@
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
import { toSshExecutionHostId } from '../../shared/execution-host'
/**
* Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts).
*
* reassignSshTargetId re-points repos/worktree metas, but the removed target's
* id is also embedded in other persisted state: app-scoped SSH pty ids
* ("ssh:<targetId>@@pty-N") inside the workspace session, the startup
* reconnect list, sleeping-agent resume records, and the sidebar host-scope
* arrays. Any survivor resurfaces later as `SSH target "<old>" not found` at
* connect/reattach time (STA-1468), so every carrier must migrate together.
*
* All helpers mutate in place (matching how the Store edits this.state) and
* return whether anything changed so callers can gate scheduleSave.
*/
function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null {
const parsed = parseAppSshPtyId(ptyId)
if (!parsed || parsed.connectionId !== oldTargetId) {
return null
}
return toAppSshPtyId(newTargetId, parsed.relayPtyId)
}
function rewriteSshPtyIdRecordValues(
record: Record<string, string> | undefined,
oldTargetId: string,
newTargetId: string
): boolean {
if (!record) {
return false
}
let changed = false
for (const [key, ptyId] of Object.entries(record)) {
const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId)
if (next) {
record[key] = next
changed = true
}
}
return changed
}
/** Re-point every old-target-id carrier inside one workspace session partition. */
export function migrateWorkspaceSessionSshTargetId(
session: WorkspaceSessionState,
oldTargetId: string,
newTargetId: string
): boolean {
let changed = false
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (!tab.ptyId) {
continue
}
const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId)
if (next) {
tab.ptyId = next
changed = true
}
}
}
for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) {
if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) {
changed = true
}
}
if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) {
changed = true
}
if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) {
session.activeConnectionIdsAtShutdown = [
...new Set(
session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id))
)
]
changed = true
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
if (record.connectionId === oldTargetId) {
record.connectionId = newTargetId
changed = true
}
}
return changed
}
/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */
export function migrateUiHostScopeSshTargetId(
ui: PersistedUIState,
oldTargetId: string,
newTargetId: string
): boolean {
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
let changed = false
if (ui.workspaceHostScope === oldHostId) {
ui.workspaceHostScope = newHostId
changed = true
}
if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) {
ui.visibleWorkspaceHostIds = [
...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id)))
]
changed = true
}
if (ui.workspaceHostOrder?.includes(oldHostId)) {
ui.workspaceHostOrder = [
...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id)))
]
changed = true
}
return changed
}

View File

@ -323,8 +323,15 @@ export default function TerminalPane({
// Why: the target was removed entirely (a ghost) when it's no longer a known
// SSH target. Reconnecting to it can only fail ("SSH target not found"), so
// the overlay must offer to remove the workspace instead of Connect.
// Removal needs positive evidence — a removal tombstone label, or a
// successfully hydrated target list (even an empty one) that lacks the id.
// A client whose SSH state never hydrated (paired client on an older host)
// must not offer workspace removal off that ignorance.
const sshReconnectTargetRemoved = useAppStore((store) =>
sshReconnectTargetId ? !store.sshTargetLabels.has(sshReconnectTargetId) : false
sshReconnectTargetId
? store.removedSshTargetLabels.has(sshReconnectTargetId) ||
(store.sshTargetsHydrated && !store.sshTargetLabels.has(sshReconnectTargetId))
: false
)
useVisibleTerminalTabClaim({ isVisible, tabId })

View File

@ -32,12 +32,18 @@ vi.mock('@/i18n/i18n', () => ({
fallback.replace('{{value0}}', values?.value0 ?? '')
}))
function installSshConnect(connect: ReturnType<typeof vi.fn>): void {
function installSshConnect(
connect: ReturnType<typeof vi.fn>,
overrides: Record<string, ReturnType<typeof vi.fn>> = {}
): void {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
connect
connect,
listTargets: vi.fn().mockResolvedValue([]),
listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
...overrides
}
}
})
@ -110,6 +116,56 @@ describe('TerminalSshReconnectOverlay', () => {
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled()
})
it('resyncs target metadata after a failed connect so a stale overlay converges', async () => {
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
const listTargets = vi
.fn()
.mockResolvedValue([
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
])
const listRemovedTargetLabels = vi.fn().mockResolvedValue({ 'ssh-dead': 'devbox (removed)' })
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
// Why: the metadata refresh is what flips TerminalPane's targetRemoved
// derivation, replacing the failing Connect loop with the ghost-host UI.
await waitFor(() => {
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
expect(useAppStore.getState().removedSshTargetLabels.get('ssh-dead')).toBe('devbox (removed)')
})
})
it('still applies the target list when the removed-labels refresh fails', async () => {
const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-dead" not found'))
const listTargets = vi
.fn()
.mockResolvedValue([
{ id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
])
const listRemovedTargetLabels = vi.fn().mockRejectedValue(new Error('unavailable'))
installSshConnect(connect, { listTargets, listRemovedTargetLabels })
const user = userEvent.setup()
render(
<TerminalSshReconnectOverlay targetId="ssh-dead" targetLabel="devbox" status="disconnected" />
)
await user.click(screen.getByRole('button', { name: 'Connect' }))
// Why: a removed-labels failure must not discard the refreshed target
// list — it alone is enough evidence for targetRemoved to converge.
await waitFor(() => {
expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
expect(useAppStore.getState().sshTargetsHydrated).toBe(true)
})
})
it('offers to remove the workspace (not Connect) when the SSH target was removed', async () => {
const connect = vi.fn().mockResolvedValue(undefined)
installSshConnect(connect)

View File

@ -100,6 +100,17 @@ export function TerminalSshReconnectOverlay({
'SSH connection failed'
)
)
// Why: a failed connect usually means the renderer's target metadata is
// stale (target removed, or re-added under a new id). Resync it so the
// overlay converges to the ghost/re-adopted state instead of offering
// the same failing Connect forever (STA-1468). Apply the target list
// first — a removed-labels failure must not discard it.
void (async () => {
const targets = await window.api.ssh.listTargets()
useAppStore.getState().setSshTargetsMetadata(targets)
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
})().catch(() => {})
} finally {
if (mountedRef.current) {
setConnecting(false)

View File

@ -83,6 +83,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
import { detectLanguage } from '@/lib/language-detect'
@ -963,11 +964,26 @@ export function useIpcEvents(): void {
}
})
// Assigned later in this effect, next to the ssh.onStateChanged wiring;
// events can't fire before that because subscriptions attach asynchronously.
let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null =
null
const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => {
if (event.type === 'reposChanged') {
runtimeProjectRefreshScheduler.request(environmentId)
return
}
if (event.type === 'sshStateChanged') {
// Why: only a paired web client routes its ssh.* API to the host that
// emitted this event, so only there can the store mirror host SSH state
// (STA-1468). Desktop clients own a local SSH surface a foreign
// runtime's targets would pollute, so they ignore it.
if (isPairedWebClientWindow()) {
handleSshStateChangedEvent?.({ targetId: event.targetId, state: event.state })
}
return
}
if (event.type === 'worktreesChanged') {
void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() =>
worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId })
@ -2667,49 +2683,49 @@ export function useIpcEvents(): void {
let sshTargetStateEventId = 0
const latestSshTargetStateEventByTargetId = new Map<string, number>()
unsubs.push(
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
const stateEventId = ++sshTargetStateEventId
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
if (!store.sshTargetLabels.has(data.targetId)) {
// Why: targets added after boot aren't in the labels map, while
// removed targets can still race a final disconnect event. Confirm
// with main before mutating renderer state for an unknown target id.
window.api.ssh
.listTargets()
// Why: this refresh is now a deletion guard, not just a label fetch.
// Retry once so a transient IPC failure does not drop a real added-target event.
.catch(() => window.api.ssh.listTargets())
.then((targets) => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
return
}
handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
const stateEventId = ++sshTargetStateEventId
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
if (!store.sshTargetLabels.has(data.targetId)) {
// Why: targets added after boot aren't in the labels map, while
// removed targets can still race a final disconnect event. Confirm
// with main before mutating renderer state for an unknown target id.
window.api.ssh
.listTargets()
// Why: this refresh is now a deletion guard, not just a label fetch.
// Retry once so a transient IPC failure does not drop a real added-target event.
.catch(() => window.api.ssh.listTargets())
.then((targets) => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
const latestStore = useAppStore.getState()
if (!targets.some((target) => target.id === data.targetId)) {
// Why: disconnect/state events can race after target removal.
// Treat absence from main's target list as deletion, not a new target.
latestStore.clearRemovedSshTargetState(data.targetId)
return
}
latestStore.setSshTargetsMetadata(targets)
applySshConnectionStateChange(data.targetId, state)
})
.catch(() => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
latestSshTargetStateEventByTargetId.delete(data.targetId)
const latestStore = useAppStore.getState()
if (!targets.some((target) => target.id === data.targetId)) {
// Why: disconnect/state events can race after target removal.
// Treat absence from main's target list as deletion, not a new target.
latestStore.clearRemovedSshTargetState(data.targetId)
return
}
latestStore.setSshTargetsMetadata(targets)
applySshConnectionStateChange(data.targetId, state)
})
.catch(() => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) {
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
}
})
return
}
}
})
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
})
)
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state)
}
unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent))
let remoteWorkspaceClientId: string | null = null
let remoteWorkspaceClientIdPromise: Promise<string | null> | null = null

View File

@ -53,6 +53,7 @@ function isRuntimeClientEvent(
return (
message.type === 'reposChanged' ||
message.type === 'worktreesChanged' ||
message.type === 'sshStateChanged' ||
message.type === 'linearLinkedIssueUpdated' ||
message.type === 'activateWorktree'
)

View File

@ -146,7 +146,7 @@ describe('createSshSlice', () => {
it('keeps SSH target label references stable when refreshed metadata is unchanged', () => {
const store = createTestStore()
const labels = new Map([['ssh-1', 'Remote']])
store.setState({ sshTargetLabels: labels })
store.setState({ sshTargetLabels: labels, sshTargetsHydrated: true })
const previousState = store.getState()
store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }])
@ -155,6 +155,18 @@ describe('createSshSlice', () => {
expect(store.getState().sshTargetLabels).toBe(labels)
})
it('marks targets hydrated on the first load, even when the list is empty', () => {
const store = createTestStore()
expect(store.getState().sshTargetsHydrated).toBe(false)
store.getState().setSshTargetsMetadata([])
// Why: an empty target set is still positive knowledge — the overlay's
// targetRemoved derivation may only trust absence after a real load.
expect(store.getState().sshTargetsHydrated).toBe(true)
expect(store.getState().sshTargetLabels.size).toBe(0)
})
it('keeps SSH connection state references stable when duplicate state arrives', () => {
const store = createTestStore()
const sshConnectionStates = new Map([

View File

@ -37,6 +37,11 @@ export type SshSlice = {
* tombstones). Lets ghost-host UI show a friendly name instead of the raw id
* for a workspace still pinned to a deleted target. */
removedSshTargetLabels: Map<string, string>
/** True once a target list actually loaded (even an empty one). Distinguishes
* "this client knows the target set" from "never hydrated" (e.g. a paired
* client on a host without the ssh RPC), so absence from sshTargetLabels
* only counts as removal evidence when this is set. */
sshTargetsHydrated: boolean
remoteWorkspaceHydratedTargetIds: Set<string>
remoteWorkspaceSyncStatusByTargetId: Record<string, RemoteWorkspaceSyncStatus>
sshCredentialQueue: SshCredentialRequest[]
@ -71,6 +76,7 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
removedSshTargetLabels: new Map(),
sshTargetsHydrated: false,
remoteWorkspaceHydratedTargetIds: new Set(),
remoteWorkspaceSyncStatusByTargetId: {},
sshCredentialQueue: [],
@ -101,10 +107,13 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
setSshTargetsMetadata: (targets) =>
set((s) => {
if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) {
return s
// Why: an unchanged (even empty) list is still a successful load — the
// hydration flag must flip on the first fetch of an empty target set.
return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true }
}
return {
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label]))
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])),
sshTargetsHydrated: true
}
}),
clearRemovedSshTargetState: (targetId) =>

View File

@ -35,6 +35,7 @@ import type {
WorkspaceSessionState
} from '../../../shared/types'
import type { SkillDiscoveryResult } from '../../../shared/skills'
import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types'
import {
getDefaultOnboardingState,
getDefaultSettings,
@ -2659,19 +2660,51 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
return {
listTargets: () => Promise.resolve([]),
listRemovedTargetLabels: () => Promise.resolve({}),
// Why: SSH connections are owned by the paired host. Read/connect route to
// its runtime RPC so remote worktrees can show real connection state and
// reconnect (STA-1468); target management stays desktop-only.
listTargets: async () => {
if (!requireActiveEnvironmentOrNull()) {
return []
}
const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>('ssh.listTargets')
return targets
},
listRemovedTargetLabels: async () => {
if (!requireActiveEnvironmentOrNull()) {
return {}
}
const { labels } = await callRuntimeResult<{ labels: Record<string, string> }>(
'ssh.listRemovedTargetLabels'
)
return labels
},
addTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
updateTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
removeTarget: () => Promise.resolve(),
importConfig: () => Promise.resolve([]),
connect: () => Promise.resolve(null),
connect: async (args) => {
const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>(
'ssh.connect',
{ targetId: args.targetId }
)
return state
},
disconnect: () => Promise.resolve(),
terminateSessions: () => Promise.resolve(),
resetRelay: () => Promise.resolve(),
getState: () => Promise.resolve(null),
getState: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return null
}
const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>(
'ssh.getState',
{ targetId: args.targetId }
)
return state
},
needsPassphrasePrompt: () => Promise.resolve(false),
testConnection: () =>
Promise.resolve({

View File

@ -4,10 +4,15 @@ import type {
WorktreeSetupLaunch,
WorktreeStartupLaunch
} from './types'
import type { SshConnectionState } from './ssh-types'
export type RuntimeClientEvent =
| { type: 'reposChanged' }
| { type: 'worktreesChanged'; repoId: string }
// Why: SSH connections live on the runtime host; paired clients have no IPC
// channel for ssh:state-changed, so without this event their reconnect
// overlays never learn the host connected (STA-1468).
| { type: 'sshStateChanged'; targetId: string; state: SshConnectionState }
| {
type: 'linearLinkedIssueUpdated'
worktreeId: string