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 3079ce1f4da..b11e22df364 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 @@ -175,7 +175,7 @@ function syncSshSplit(runtime: OrcaRuntimeService, snapshot: RuntimeMobileSessio } describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', () => { - it('routes a stale catalog owner to the unique persisted session owner', async () => { + it('does not treat a unique foreign partition as proof of catalog host rotation', async () => { const staleHostId: ExecutionHostId = 'runtime:stale-host' const persistedTab = { id: 'tab', @@ -233,12 +233,11 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', leafId: 'leaf' }) - await expect( - runtime.closeMobileSessionTab(`id:${SSH_WORKTREE_ID}`, 'tab') - ).resolves.toMatchObject({ - closed: true - }) - expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([]) + const localBefore = sessions.get(LOCAL_EXECUTION_HOST_ID) + await expect(runtime.closeMobileSessionTab(`id:${SSH_WORKTREE_ID}`, 'tab')).rejects.toThrow( + 'tab_not_found' + ) + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)).toBe(localBefore) expect(sessions.get(staleHostId)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([]) }) @@ -322,7 +321,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', 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 () => { + it('preserves foreign resume records when catalog host rotation is unproven', async () => { const staleHostId: ExecutionHostId = 'runtime:stale-host' const sessions = new Map([ [ @@ -359,14 +358,15 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, null, { tabId: 'tab', leafId: 'left' }) + const localBefore = sessions.get(LOCAL_EXECUTION_HOST_ID) await expect(runtime.closeTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`)).resolves.toMatchObject( - { closed: 1 } + { closed: 0, stopped: 0 } ) - expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([]) - expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalPtyIncarnationsByPaneKey).toEqual({}) + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)).toBe(localBefore) + expect(localBefore?.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab:left': 'incarnation-1' }) }) - it('hydrates the persisted owner when a folder host is absent from the host index', () => { + it('does not hydrate local folder tabs into an absent runtime host partition', () => { const folderWorktreeId = 'folder:folder-1' const localSession = { ...getDefaultWorkspaceSession(), @@ -405,7 +405,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', const targets = controller.getHydrationTargets(true) - expect(targets.get(folderWorktreeId)).toBe(localSession) + expect(targets.has(folderWorktreeId)).toBe(false) }) it('waits for provider retirement on a direct worktree stop', async () => { diff --git a/src/main/runtime/runtime-workspace-session-controller.test.ts b/src/main/runtime/runtime-workspace-session-controller.test.ts new file mode 100644 index 00000000000..236ea1cfadf --- /dev/null +++ b/src/main/runtime/runtime-workspace-session-controller.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeStore } from './runtime-store-contract' +import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' + +function harness(hosts: ExecutionHostId[], folder = false) { + const worktreeId = folder ? 'folder:one' : 'repo::/project' + const foreign = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [{ id: 'foreign', worktreeId }] } + } + const empty = getDefaultWorkspaceSession() + const setWorkspaceSession = vi.fn() + const store = { + getRepos: () => + folder ? [] : hosts.map((executionHostId) => ({ id: 'repo', executionHostId })), + getFolderWorkspaces: () => (folder ? [{ id: 'one', executionHostId: hosts[0] }] : []), + getWorkspaceSessionHostIds: () => ['local', 'runtime:other', ...hosts], + getWorkspaceSession: (hostId: string) => (hostId === 'runtime:other' ? foreign : empty), + setWorkspaceSession + } as unknown as RuntimeStore + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: () => null, + hasRuntimeOwnedPtyCandidate: () => true + }) + return { controller, worktreeId, foreign, empty, setWorkspaceSession } +} + +describe('workspace session partition authority', () => { + it.each([false, true])( + 'does not adopt a foreign partition when the owner is empty (folder=%s)', + (folder) => { + const h = harness(['runtime:owner'], folder) + expect(h.controller.tryGetHostId(h.worktreeId)).toBe('runtime:owner') + expect(h.controller.get(h.worktreeId)).toBe(h.empty) + expect(h.controller.getHydrationTargets(true).has(h.worktreeId)).toBe(false) + h.controller.set(h.worktreeId, h.empty) + expect(h.setWorkspaceSession).toHaveBeenCalledWith(h.empty, 'runtime:owner') + } + ) + + it.each([ + ['local', 'runtime:owner'], + ['ssh:first', 'ssh:second'], + ['runtime:first', 'runtime:second'] + ] as ExecutionHostId[][])('refuses colliding repository owners %s and %s', (first, second) => { + const h = harness([first!, second!]) + expect(() => h.controller.tryGetHostId(h.worktreeId)).toThrow( + 'worktree_execution_host_unresolved' + ) + expect(() => h.controller.get(h.worktreeId)).toThrow('worktree_execution_host_unresolved') + expect(h.controller.getHydrationTargets(true).size).toBe(0) + expect(() => h.controller.set(h.worktreeId, h.empty)).toThrow( + 'worktree_execution_host_unresolved' + ) + expect(h.setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('accepts duplicate repository rows that agree on their host', () => { + const h = harness(['ssh:owner', 'ssh:owner']) + expect(h.controller.tryGetHostId(h.worktreeId)).toBe('ssh:owner') + }) +}) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 83f0ce80ef4..2fb37b4ba24 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -8,7 +8,7 @@ import { import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { workspaceSessionPartitionHostId } from '../../shared/workspace-session-partition-owner' +import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { RuntimeStore } from './runtime-store-contract' @@ -47,59 +47,34 @@ export class RuntimeWorkspaceSessionController { return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repo = store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) - // Why: SSH worktrees keep their own `ssh:` partition here while the renderer writes - // them to 'local'; the shared owner map records that divergence (#12723). - return repo - ? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo), 'host-partition') - : LOCAL_EXECUTION_HOST_ID - } - - private resolveHostId( - worktreeId: string, - preferredHostId: ExecutionHostId, - persistedHostIds: readonly ExecutionHostId[], - getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState - ): ExecutionHostId { - const hasPersistedTabs = (hostId: ExecutionHostId): boolean => - (getWorkspaceSession(hostId).tabsByWorktree[worktreeId]?.length ?? 0) > 0 - // 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 repoId = getRepoIdFromWorktreeId(resolvedWorktreeId) + const repos = store.getRepos?.() ?? [] + const routing = resolveWorktreeHostRouting(repos, { repoId }) + if (routing.kind === 'ambiguous') { + return null } - const persistedOwners = persistedHostIds.filter( - (hostId) => hostId !== preferredHostId && hasPersistedTabs(hostId) - ) - return persistedOwners.length === 1 ? persistedOwners[0]! : preferredHostId + return routing.kind === 'resolved' ? routing.hostId : LOCAL_EXECUTION_HOST_ID } tryGetHostId(worktreeId: string): ExecutionHostId | null { const store = this.deps.getStore() - if (!store) { - return null + // Partition contents cannot prove that two execution hosts are the same host. + const hostId = store ? this.getPreferredHostId(worktreeId, store) : null + // Existing callers default a missing partition to local; ambiguity must not take that path. + if (store && !hostId && parseWorkspaceKey(worktreeId)?.type !== 'folder') { + throw new Error('worktree_execution_host_unresolved') } - const preferredHostId = this.getPreferredHostId(worktreeId, store) - if (!preferredHostId) { - return null - } - const persistedHostIds = store?.getWorkspaceSessionHostIds?.() - if (!store.getWorkspaceSession || !persistedHostIds) { - return preferredHostId - } - return this.resolveHostId(worktreeId, preferredHostId, persistedHostIds, (hostId) => - store.getWorkspaceSession!(hostId) - ) + return hostId } getHostId(worktreeId: string): ExecutionHostId { const hostId = this.tryGetHostId(worktreeId) if (!hostId) { - throw new Error('folder_workspace_not_found') + throw new Error( + parseWorkspaceKey(worktreeId)?.type === 'folder' + ? 'folder_workspace_not_found' + : 'worktree_execution_host_unresolved' + ) } return hostId } @@ -139,23 +114,6 @@ export class RuntimeWorkspaceSessionController { return new Map() } const repos = store?.getRepos?.() ?? [] - const repoHostIdByRepoId = new Map( - repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const) - ) - const folderHostIdByWorkspaceId = new Map( - (store?.getFolderWorkspaces?.() ?? []).map((workspace) => { - const explicitHostId = - workspace.executionHostId != null - ? (parseExecutionHostId(workspace.executionHostId)?.id ?? null) - : null - const connectionId = explicitHostId ? null : this.deps.resolveFolderConnectionId(workspace) - return [ - workspace.id, - explicitHostId ?? - (connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID) - ] as const - }) - ) const hostIds = new Set(['local']) for (const repo of repos) { hostIds.add(getRepoExecutionHostId(repo)) @@ -175,20 +133,7 @@ export class RuntimeWorkspaceSessionController { } for (const [hostId, session] of sessionsByHostId) { for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - const scope = parseWorkspaceKey(worktreeId) - const catalogOwnerHostId = - scope?.type === 'folder' - ? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null) - : (repoHostIdByRepoId.get( - getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) - ) ?? LOCAL_EXECUTION_HOST_ID) - const ownerHostId = this.resolveHostId( - worktreeId, - catalogOwnerHostId ?? LOCAL_EXECUTION_HOST_ID, - [...sessionsByHostId.keys()], - (candidateHostId) => - sessionsByHostId.get(candidateHostId) ?? store.getWorkspaceSession!(candidateHostId) - ) + const ownerHostId = this.getPreferredHostId(worktreeId, store) if ( ownerHostId === hostId && (includeAllPersistedWorktrees ||