diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts new file mode 100644 index 000000000..1534be0de --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const CONNECTION_ID = 'conn-1' +const SSH_HOST_ID: ExecutionHostId = `ssh:${CONNECTION_ID}` +const SSH_REPO_ID = 'ssh-repo' +const SSH_WORKTREE_ID = `${SSH_REPO_ID}::/remote/worktree` +const SSH_PTY_LEFT = `ssh:${CONNECTION_ID}@@pty-left` +const SSH_PTY_RIGHT = `ssh:${CONNECTION_ID}@@pty-right` + +const SSH_REPO = { + id: SSH_REPO_ID, + path: '/remote/worktree', + displayName: 'ssh-repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: CONNECTION_ID +} as const + +function makeSshSnapshot(): RuntimeMobileSessionTabsSnapshot { + const parentLayout = { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: 'left', + ptyIdsByLeafId: { left: SSH_PTY_LEFT, right: SSH_PTY_RIGHT } + } + return { + worktree: SSH_WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: 'tab::left', + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab', tabOrder: ['tab'] }], + tabs: [ + { + type: 'terminal', + id: 'tab::left', + parentTabId: 'tab', + leafId: 'left', + ptyId: SSH_PTY_LEFT, + title: 'Left', + parentLayout, + isActive: true + }, + { + type: 'terminal', + id: 'tab::right', + parentTabId: 'tab', + leafId: 'right', + ptyId: SSH_PTY_RIGHT, + title: 'Right', + parentLayout, + isActive: false + } + ] + } +} + +function makePersistedSshSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [SSH_WORKTREE_ID]: [ + { + id: 'tab', + ptyId: SSH_PTY_LEFT, + worktreeId: SSH_WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: SSH_PTY_LEFT, right: SSH_PTY_RIGHT } + } + } + } +} + +type PartitionedStoreHarness = { + store: never + sessions: Map + writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] + reads: (ExecutionHostId | undefined)[] +} + +/** A store that keeps one workspace session per execution host, like the real one. */ +function partitionedStore(): PartitionedStoreHarness { + const sessions = new Map([ + [LOCAL_EXECUTION_HOST_ID, getDefaultWorkspaceSession()], + [SSH_HOST_ID, makePersistedSshSession()] + ]) + const writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] = [] + const reads: (ExecutionHostId | undefined)[] = [] + const store = { + getRepos: () => [SSH_REPO], + getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => { + reads.push(hostId) + return sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession() + }, + setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => { + writes.push({ hostId, session }) + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session) + }, + flushOrThrow: vi.fn() + } as never + return { store, sessions, writes, reads } +} + +function syncSshSplit(runtime: OrcaRuntimeService, snapshot: RuntimeMobileSessionTabsSnapshot) { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'left', + layout: + snapshot.tabs[0]?.type === 'terminal' + ? (snapshot.tabs[0].parentLayout?.root ?? null) + : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + leafId: 'left', + paneRuntimeId: 1, + ptyId: SSH_PTY_LEFT + }, + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + leafId: 'right', + paneRuntimeId: 2, + ptyId: SSH_PTY_RIGHT + } + ], + mobileSessionTabs: [snapshot] + }) +} + +describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', () => { + it('retires an exited SSH pane from the SSH partition and leaves the local partition untouched', async () => { + const harness = partitionedStore() + const localBefore = harness.sessions.get(LOCAL_EXECUTION_HOST_ID)! + const runtime = new OrcaRuntimeService(harness.store) + runtime.attachWindow(1) + syncSshSplit(runtime, makeSshSnapshot()) + runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, CONNECTION_ID, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + + runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') + + // The durable retirement must land in the pane's own host partition. + expect(harness.writes.map((write) => write.hostId)).toEqual([SSH_HOST_ID]) + const sshSession = harness.sessions.get(SSH_HOST_ID)! + expect(sshSession.terminalLayoutsByTabId.tab).toMatchObject({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: SSH_PTY_RIGHT } + }) + expect(sshSession.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: 'tab', ptyId: SSH_PTY_RIGHT }) + ]) + expect(sshSession.terminalTopologyRevisionByRepoId).toMatchObject({ [SSH_REPO_ID]: 1 }) + + // Negative safety: the local partition is a different host and must not move. + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)).toBe(localBefore) + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalTopologyRevisionByRepoId).toEqual( + localBefore.terminalTopologyRevisionByRepoId + ) + + // And the published surface is actually gone for clients. + expect((await runtime.listMobileSessionTabs(`id:${SSH_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::right' }) + ]) + }) + + it('still retires a local pane from the local partition', async () => { + const harness = partitionedStore() + const sshBefore = harness.sessions.get(SSH_HOST_ID)! + const localRepo = { + id: 'local-repo', + path: '/worktree', + displayName: 'local-repo', + badgeColor: 'blue', + addedAt: 1 + } as const + const localWorktreeId = 'local-repo::/worktree' + const localSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'tab', + ptyId: 'pty-left', + worktreeId: localWorktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + } + } + harness.sessions.set(LOCAL_EXECUTION_HOST_ID, localSession) + const store = { + ...(harness.store as object), + getRepos: () => [SSH_REPO, localRepo], + getRepo: (id: string) => + id === SSH_REPO_ID ? SSH_REPO : id === 'local-repo' ? localRepo : undefined + } as never + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + const snapshot = makeSshSnapshot() + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: localWorktreeId, + title: 'Terminal', + activeLeafId: 'left', + layout: + snapshot.tabs[0]?.type === 'terminal' + ? (snapshot.tabs[0].parentLayout?.root ?? null) + : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: localWorktreeId, + leafId: 'left', + paneRuntimeId: 1, + ptyId: 'pty-left' + }, + { + tabId: 'tab', + worktreeId: localWorktreeId, + leafId: 'right', + paneRuntimeId: 2, + ptyId: 'pty-right' + } + ], + mobileSessionTabs: [ + { + ...snapshot, + worktree: localWorktreeId, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' + ? { + ...tab, + ptyId: tab.leafId === 'left' ? 'pty-left' : 'pty-right', + parentLayout: tab.parentLayout + ? { + ...tab.parentLayout, + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + : undefined + } + : tab + ) + } + ] + }) + runtime.registerPty('pty-left', localWorktreeId, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + expect(harness.writes.map((write) => write.hostId ?? LOCAL_EXECUTION_HOST_ID)).toEqual([ + LOCAL_EXECUTION_HOST_ID + ]) + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab).toMatchObject( + { + root: { type: 'leaf', leafId: 'right' } + } + ) + expect(harness.sessions.get(SSH_HOST_ID)).toBe(sshBefore) + expect((await runtime.listMobileSessionTabs(`id:${localWorktreeId}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::right' }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index 9c95156c7..342017557 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -531,7 +532,8 @@ describe('OrcaRuntimeService terminal surface retirement', () => { ptyIdsByLeafId: { right: 'pty-right' } }) } - }) + }), + LOCAL_EXECUTION_HOST_ID ) }) @@ -659,7 +661,8 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }, terminalSurfaceTombstonesByPaneKey: {}, terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - }) + }), + LOCAL_EXECUTION_HOST_ID ) expect(flushOrThrow).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aa9ee8d55..79abd93b6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6671,6 +6671,69 @@ export class OrcaRuntimeService { return next } + /** + * Retires each surface in the session partition of the host that owns its worktree. + * Why: an SSH pane's durable surface lives in that connection's partition; retiring it + * against the local partition strands the real ghost and bumps a foreign host's epoch. + * Returns null when nothing may be published because persistence is unavailable or failed. + */ + private persistTerminalSurfaceRetirements( + retiredSurfaces: readonly RetiredTerminalSurface[] + ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { + const surfacesByHostId = new Map() + for (const surface of retiredSurfaces) { + const hostId = + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID + const bucket = surfacesByHostId.get(hostId) + if (bucket) { + bucket.push(surface) + } else { + surfacesByHostId.set(hostId, [surface]) + } + } + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] + const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] + for (const [hostId, surfaces] of surfacesByHostId) { + const session = this.store?.getWorkspaceSession?.(hostId) + if (!session) { + unpersisted.push(...surfaces) + continue + } + // Why: publishing absence before its host membership fence is durable lets a crash or + // stale renderer write resurrect the retired surface. + if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + return null + } + let nextSession = session + const acceptedForHost: RetiredTerminalSurface[] = [] + for (const surface of surfaces) { + const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) + if (candidate !== nextSession) { + acceptedForHost.push(surface) + nextSession = candidate + } + } + if (acceptedForHost.length === 0) { + continue + } + accepted.push(...acceptedForHost) + pendingWrites.push({ hostId, session: nextSession }) + } + if (pendingWrites.length > 0) { + try { + for (const write of pendingWrites) { + this.store?.setWorkspaceSession?.(write.session, write.hostId) + } + this.store?.flushOrThrow?.() + } catch (error) { + console.error('[runtime] failed to persist terminal retirement:', error) + return null + } + } + return { accepted, unpersisted } + } + private retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, @@ -6704,43 +6767,21 @@ export class OrcaRuntimeService { if (retiredSurfaces.length === 0) { return } - let publishableRetiredSurfaces = retiredSurfaces - const session = this.store?.getWorkspaceSession?.() - if (session) { - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return - } - let nextSession = session - const acceptedSurfaces: RetiredTerminalSurface[] = [] - for (const surface of retiredSurfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedSurfaces.push(surface) - nextSession = candidate - } - } - if (acceptedSurfaces.length === 0) { - return - } - try { - this.store.setWorkspaceSession(nextSession) - this.store.flushOrThrow() - } catch (error) { - console.error('[runtime] failed to persist terminal retirement:', error) - return - } - // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - publishableRetiredSurfaces = acceptedSurfaces - } else { - for (const surface of retiredSurfaces) { - const repoId = getRepoIdFromWorktreeId(surface.worktreeId) - this.terminalTopologyRevisionByRepoId.set( - repoId, - (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 - ) - } + const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) + if (!persisted) { + return + } + for (const surface of persisted.unpersisted) { + const repoId = getRepoIdFromWorktreeId(surface.worktreeId) + this.terminalTopologyRevisionByRepoId.set( + repoId, + (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 + ) + } + // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. + const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] + if (publishableRetiredSurfaces.length === 0) { + return } for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const retired = retireTerminalSurfacesFromSnapshot({