diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 12effe7fdcb..23cb9464887 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -12,7 +12,11 @@ import type { import type { WorktreeTerminalMutationKind } from './worktree-terminal-mutation-lock' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' -import { getWorktreeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { + getWorktreeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence' import { summarizeWorktreePtyStopVerdict } from './worktree-pty-stop-verdict' @@ -61,6 +65,9 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso const hostFence = this.getWorktreeHostFence(worktree) return await this.runWorktreeTerminalMutation(worktree.id, async () => { + // Why: emptying a rotated runtime partition re-routes the session owner, so the + // records cleared below live in the partition that owned the tabs at the start. + const sessionHostId = this.getWorkspaceSessionHostIdForWorktree(worktree.id) const snapshot = await this.listMobileSessionTabs(`id:${worktree.id}`) const targetPtyIds = this.collectWorktreePtyIds(worktree.id, hostFence, true) const parentTabIds = [ @@ -80,7 +87,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } closed += 1 } - this.clearWorktreeTerminalResumeRecords(worktree.id, parentTabIds) + this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) const { stopped } = await this.stopTerminalsForWorktree(`id:${worktree.id}`, { resolvedWorktreeId: worktree.id, ...hostFence @@ -103,12 +110,17 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso private clearWorktreeTerminalResumeRecords( worktreeId: string, + hostId: ExecutionHostId, closedTabIds: readonly string[] ): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session) { - return + if ( + !this.store?.getWorkspaceSession || + !this.store.setWorkspaceSession || + !this.store.flushOrThrow + ) { + throw new Error('workspace_session_unavailable') } + const session = this.store.getWorkspaceSession(hostId) const sleepingAgentSessionsByPaneKey = Object.fromEntries( Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( ([, record]) => record.worktreeId !== worktreeId @@ -134,25 +146,20 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso if (!hasChanges) { return } - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - throw new Error('workspace_session_unavailable') - } const next: WorkspaceSessionState = { ...session, sleepingAgentSessionsByPaneKey, terminalPtyIncarnationsByPaneKey } - this.setWorkspaceSessionForWorktree(worktreeId, next) - const staged = this.getWorkspaceSessionForWorktree(worktreeId) + this.store.setWorkspaceSession(next, hostId) + const staged = this.store.getWorkspaceSession(hostId) try { this.store.flushOrThrow() } catch (error) { - const current = this.getWorkspaceSessionForWorktree(worktreeId) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) - } + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) } throw error } 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 index 958642d1b69..b5d6a39e17c 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -242,6 +242,120 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', expect(sessions.get(staleHostId)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([]) }) + it('keeps a same-id local workspace out of an SSH workspace close', async () => { + const localTab = { + id: 'local-tab', + ptyId: 'local-pty', + worktreeId: SSH_WORKTREE_ID, + title: 'Local agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const sessions = new Map([ + [ + LOCAL_EXECUTION_HOST_ID, + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [SSH_WORKTREE_ID]: [localTab] }, + terminalLayoutsByTabId: { + 'local-tab': { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'local-pty' } + } + }, + sleepingAgentSessionsByPaneKey: { + 'local-tab:leaf': { worktreeId: SSH_WORKTREE_ID, agentType: 'claude', sessionId: 's' } + } + } + ], + // The SSH copy of the same `repoId::path` currently has no terminals. + [SSH_HOST_ID, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] + ]) + const store = { + getRepos: () => [SSH_REPO], + getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), + getWorktreeMeta: () => ({ hostId: SSH_HOST_ID }), + getAllWorktreeMeta: () => ({ [SSH_WORKTREE_ID]: { hostId: SSH_HOST_ID } }), + setWorktreeMeta: vi.fn(), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => + sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(), + setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn() + } as never + const runtime = new OrcaRuntimeService(store) + const stopAndWait = vi.fn(async () => true) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty('local-pty', SSH_WORKTREE_ID, null, { tabId: 'local-tab', leafId: 'leaf' }) + + await expect(runtime.closeTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`)).resolves.toEqual({ + closed: 0, + stopped: 0, + retiredSurfaces: true + }) + expect(stopAndWait).not.toHaveBeenCalled() + const local = sessions.get(LOCAL_EXECUTION_HOST_ID)! + expect(local.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([localTab]) + expect(Object.keys(local.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['local-tab:leaf']) + }) + + it('clears resume records from the partition that owned the tabs when the catalog owner rotated', async () => { + const staleHostId: ExecutionHostId = 'runtime:stale-host' + const sessions = new Map([ + [ + LOCAL_EXECUTION_HOST_ID, + { + ...makePersistedSshSession(), + terminalPtyIncarnationsByPaneKey: { 'tab:left': 'incarnation-1' } + } + ], + [staleHostId, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] + ]) + const store = { + getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], + getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), + getWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => ({ [SSH_WORKTREE_ID]: {} }), + setWorktreeMeta: vi.fn(), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => + sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(), + setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn() + } as never + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, null, { tabId: 'tab', leafId: 'left' }) + + await expect(runtime.closeTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`)).resolves.toMatchObject( + { closed: 1 } + ) + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([]) + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalPtyIncarnationsByPaneKey).toEqual({}) + }) + it('hydrates the persisted owner when a folder host is absent from the host index', () => { const folderWorktreeId = 'folder:folder-1' const localSession = { diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index dc8b1d43d0b..ff5871b4d5d 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -58,14 +58,18 @@ export class RuntimeWorkspaceSessionController { ): ExecutionHostId { const hasPersistedTabs = (hostId: ExecutionHostId): boolean => (getWorkspaceSession(hostId).tabsByWorktree[worktreeId]?.length ?? 0) > 0 - if (hasPersistedTabs(preferredHostId)) { + // Why: only runtime environment ids rotate across relay restarts. An empty SSH or + // local partition is the truth, and `repoId::path` repeats across hosts, so a + // same-id workspace elsewhere must never be adopted as this one's owner. + if ( + parseExecutionHostId(preferredHostId)?.kind !== 'runtime' || + hasPersistedTabs(preferredHostId) + ) { return preferredHostId } const persistedOwners = persistedHostIds.filter( (hostId) => hostId !== preferredHostId && hasPersistedTabs(hostId) ) - // Relay restarts can leave catalog metadata on an obsolete partition while - // the durable tab owner remains unique. return persistedOwners.length === 1 ? persistedOwners[0]! : preferredHostId }