fix(session): refuse unproven cross-host partition adoption

This commit is contained in:
Neil
2026-09-11 01:19:00 -07:00
committed by Neil
parent 48db6cf8ea
commit 640dde2c71
3 changed files with 97 additions and 87 deletions
@@ -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<ExecutionHostId, WorkspaceSessionState>([
[
@@ -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 () => {
@@ -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')
})
})
@@ -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:<targetId>` 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<ExecutionHostId>(['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 ||