mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
fix(session): refuse unproven cross-host partition adoption
This commit is contained in:
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user