fix(runtime): keep same-id sibling hosts out of workspace close

The stale-owner fallback in the session controller re-routed any worktree whose
catalog partition had no tabs to whichever other partition held tabs. Only
`runtime:` environment ids rotate across relay restarts; `repoId::path` legitimately
repeats across hosts, so an SSH workspace close could retire the local copy's
tabs and resume records, or flip owners mid-close and strand the SSH PTY.

Restrict the fallback to runtime hosts, and pin the session partition once per
workspace close so record clearing targets the partition that owned the tabs.
This commit is contained in:
Jinwoo-H
2026-09-03 02:14:57 -04:00
parent 7add4f3d3f
commit b2200adfee
3 changed files with 144 additions and 19 deletions
@@ -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
}
@@ -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<ExecutionHostId, WorkspaceSessionState>([
[
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<ExecutionHostId, WorkspaceSessionState>([
[
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 = {
@@ -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
}