fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs (#30942)

* fix(runtime): a failed orphan adoption write rolls back instead of keeping the adopted tabs

The adoption rollback ran outside the store's durable transaction, so the store
rebased it like a stale renderer write below the topology fence and kept the
adopted tabs, layouts and incarnations; the next save then persisted them.
Run the adoption through runDurableMutation, whose rollback phase skips that
rebase, and refuse if a queued write changed the session it was checked against.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(runtime): an adoption into an unwritten host partition is not refused as a conflict

Co-authored-by: Claude <noreply@anthropic.com>

* test: run the orphan adoption rollback test in the SQLite runtime project

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(runtime): check and write an orphan adoption inside the store transaction

A snapshot comparison could not see a queued binding write that edits the
session in place. Plan the adoption against the session the transaction
reads instead, so queued writes are validated against rather than guessed at.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-19 19:58:14 -07:00
committed by GitHub
co-authored by Claude
parent 9a79de59a5
commit 8de37199e2
6 changed files with 592 additions and 240 deletions
@@ -224,6 +224,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/host-terminal-close-persistence-durability.test.ts',
'src/main/runtime/orca-runtime-terminal-close-records.test.ts',
'src/main/runtime/orca-runtime-terminal-surface-close.test.ts',
'src/main/runtime/runtime-terminal-orphan-adoption-rollback.test.ts',
'src/main/runtime/orchestration/mailbox-owner.test.ts',
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
@@ -74,12 +74,10 @@ function adoptKeptPane(session: WorkspaceSessionState) {
]),
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
},
session,
sessionWorktreeId: GIT_KEY,
repoId: 'repo-1',
worktreeWslDistro: null,
currentRevision: 0,
ports: {
readSession: () => ({ session, sessionWorktreeId: GIT_KEY, currentRevision: 0 }),
getPty: () => pty,
getLeaves: () => [],
getLeaf: () => undefined,
@@ -0,0 +1,243 @@
import type {
RuntimeMobileSessionTabsSnapshot,
RuntimeTerminalOrphanAdoptionRequest
} from '../../shared/runtime-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { makePaneKey } from '../../shared/stable-pane-id'
import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { worktreeIdsEqual } from '../../shared/worktree/id'
/** The host session an adoption is checked against, read where the adoption is written. */
export type RuntimeTerminalOrphanAdoptionSession = {
session: WorkspaceSessionState
sessionWorktreeId: string
currentRevision: number
}
export type RuntimeTerminalOrphanAdoptionPlanPorts = {
getPty: (handle: string) => RuntimePtyWorktreeRecord | null
getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[]
getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined
/** The title display surfaces show for the PTY, which an adopted tab persists. */
getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null
getMobileSnapshots: () => Iterable<RuntimeMobileSessionTabsSnapshot>
}
export type RuntimeTerminalOrphanAdoptionPlanArgs = {
/** The partition the session is read from and written to. */
hostId: ExecutionHostId
request: RuntimeTerminalOrphanAdoptionRequest
workspace: TerminalWorkspaceLaunchScope
inventory: PtyControllerInventory
worktreeWslDistro: string | null
ports: RuntimeTerminalOrphanAdoptionPlanPorts
}
type ValidatedClaim = {
claim: RuntimeTerminalOrphanAdoptionRequest['claims'][number]
pty: RuntimePtyWorktreeRecord
paneKey: string
}
export type RuntimeTerminalOrphanAdoptionPlan =
| { kind: 'replay'; validated: ValidatedClaim[]; currentRevision: number }
| {
kind: 'adopt'
validated: ValidatedClaim[]
currentRevision: number
session: WorkspaceSessionState
persisted: WorkspaceSessionState
}
/** Checks every claim against `read` and builds the session to write; throws the refusal code. */
export function planRuntimeTerminalOrphanAdoption(
args: RuntimeTerminalOrphanAdoptionPlanArgs,
read: RuntimeTerminalOrphanAdoptionSession
): RuntimeTerminalOrphanAdoptionPlan {
const { request, workspace, inventory, ports } = args
const { session, sessionWorktreeId, currentRevision } = read
const seenPtyIds = new Set<string>()
const seenPaneKeys = new Set<string>()
const validated = request.claims.map((claim) => {
const paneKey = makePaneKey(claim.tabId, claim.leafId)
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
throw new Error('terminal_orphan_claim_duplicate')
}
seenPtyIds.add(claim.ptyId)
seenPaneKeys.add(paneKey)
const pty = ports.getPty(claim.terminal)
const controllerIdentity = inventory.terminalIdentityByPtyId.get(claim.ptyId)
if (
!pty ||
pty.ptyId !== claim.ptyId ||
controllerIdentity?.handle !== claim.terminal ||
controllerIdentity?.incarnationId !== claim.incarnationId ||
!inventory.livePtyIds.has(claim.ptyId) ||
!pty.connected ||
!pty.incarnationId ||
pty.incarnationId !== claim.incarnationId
) {
throw new Error('terminal_orphan_stale')
}
if (
!worktreeIdsEqual(pty.worktreeId, workspace.id) ||
!terminalOrphanExecutionOwnersEqual(
{ connectionId: workspace.connectionId, wslDistro: args.worktreeWslDistro },
{
connectionId: pty.connectionId ?? null,
...(controllerIdentity?.wslDistro !== undefined
? { wslDistro: controllerIdentity.wslDistro }
: process.platform === 'win32' && !workspace.connectionId
? {}
: { wslDistro: null })
}
)
) {
throw new Error('terminal_orphan_owner_mismatch')
}
if (
ports
.getLeaves(claim.ptyId)
.some(
(owner) =>
!worktreeIdsEqual(owner.worktreeId, workspace.id) ||
owner.tabId !== claim.tabId ||
owner.leafId !== claim.leafId
)
) {
throw new Error('terminal_orphan_already_visual')
}
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
throw new Error('terminal_orphan_competing_owner')
}
return { claim, pty, paneKey }
})
const persistedBindings = new Map<string, { worktreeId: string; paneKey: string }[]>()
const addBinding = (ptyId: string, worktreeId: string, paneKey: string): void => {
const bindings = persistedBindings.get(ptyId) ?? []
bindings.push({ worktreeId, paneKey })
persistedBindings.set(ptyId, bindings)
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
for (const tab of tabs) {
const layout = session.terminalLayoutsByTabId[tab.id]
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (ptyId) {
addBinding(ptyId, worktreeId, makePaneKey(tab.id, leafId))
}
}
if (tab.ptyId && !layout) {
addBinding(tab.ptyId, worktreeId, tab.id)
}
}
}
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
const bindings = persistedBindings.get(ptyId) ?? []
if (bindings.length > 1) {
throw new Error('terminal_orphan_competing_owner')
}
return bindings[0] ?? null
}
const isExactPersisted = validated.every(({ claim, paneKey }) => {
const binding = persistedBinding(claim.ptyId)
return (
binding !== null &&
worktreeIdsEqual(binding.worktreeId, workspace.id) &&
binding.paneKey === paneKey &&
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
)
})
if (isExactPersisted && sessionWorktreeId === workspace.id) {
return { kind: 'replay', validated, currentRevision }
}
if (currentRevision !== request.expectedTopologyRevision) {
throw new Error('terminal_topology_conflict')
}
const { topologyTabsById, topologyGroups } = validateRuntimeTerminalOrphanTopology(
request,
validated
)
for (const { claim, paneKey } of validated) {
const existingBinding = persistedBinding(claim.ptyId)
if (
existingBinding &&
(!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) ||
existingBinding.paneKey !== paneKey)
) {
throw new Error('terminal_orphan_competing_owner')
}
const proposedPtyId =
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
throw new Error('terminal_orphan_surface_occupied')
}
const graphOwner = ports.getLeaf(claim.tabId, claim.leafId)
if (
graphOwner &&
(graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id))
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (
Object.entries(session.tabsByWorktree).some(
([ownerWorktreeId, tabs]) =>
!worktreeIdsEqual(ownerWorktreeId, workspace.id) &&
tabs.some((tab) => tab.id === claim.tabId)
)
) {
throw new Error('terminal_orphan_surface_occupied')
}
// Why the close record too: it outlives a host restart, which the client's retirement proofs do not.
if (
session.terminalSurfaceTombstonesByPaneKey?.[paneKey] ||
hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId)
) {
throw new Error('terminal_orphan_surface_retired')
}
for (const snapshot of ports.getMobileSnapshots()) {
const surfaceOwner = snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' && tab.parentTabId === claim.tabId && tab.leafId === claim.leafId
)
if (
surfaceOwner?.type === 'terminal' &&
(snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId)
) {
throw new Error('terminal_orphan_surface_occupied')
}
const owner = snapshot.tabs.find(
(tab) => tab.type === 'terminal' && tab.ptyId === claim.ptyId
)
if (
owner?.type === 'terminal' &&
(snapshot.worktree !== workspace.id ||
owner.parentTabId !== claim.tabId ||
owner.leafId !== claim.leafId)
) {
throw new Error('terminal_orphan_competing_owner')
}
}
}
const persisted = buildRuntimeTerminalOrphanSession({
hostId: args.hostId,
session,
sessionWorktreeId,
worktreeId: workspace.id,
request,
validated,
topologyTabsById,
topologyGroups,
getDisplayTitle: ports.getDisplayTitle
})
return { kind: 'adopt', validated, currentRevision, session, persisted }
}
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalOrphanAdoptionRequest
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
GIT_KEY,
addWorkspace,
emptySession,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
import type { Store } from '../persistence'
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
vi.mock('electron', () => ({
app: { getPath: () => testStateDirRef.dir, getName: () => 'orca', getVersion: () => '0.0.0' },
BrowserWindow: { fromId: () => null, getAllWindows: () => [] },
webContents: { fromId: () => null },
ipcMain: { on: () => {}, handle: () => {}, removeListener: () => {} },
safeStorage: { isEncryptionAvailable: () => false }
}))
const testStateDirRef = vi.hoisted(() => ({ dir: '' }))
const REPO_ID = 'repo-1'
const ORPHAN_TAB = 'tab-orphan'
const ORPHAN_LEAF = leaf(2)
const ORPHAN_PANE = `${ORPHAN_TAB}:${ORPHAN_LEAF}`
function seededSession(revision: number | undefined): WorkspaceSessionState {
const session = addWorkspace(emptySession(), GIT_KEY, [
{ id: 'group-1', tabs: [{ id: 'tab-kept', leaves: [[leaf(1), 'pty-kept']] }] }
])
return revision === undefined
? session
: { ...session, terminalTopologyRevisionByRepoId: { [REPO_ID]: revision } }
}
/** The next profile write fails as a known (not indeterminate) disk failure. */
function failNextProfileWrite(): void {
const fail = (): never => {
throw new Error('disk_full')
}
for (const method of ['writeSerializedDomains', 'writeCompleteSerializedDomains'] as const) {
vi.spyOn(ProfileStateSqliteAuthority.prototype, method).mockImplementationOnce(fail)
}
}
function adoptWithFailingFlush(store: Store, revision: number): Promise<unknown> {
failNextProfileWrite()
return adopt(store, revision, (mutate) => store.runDurableMutation(mutate))
}
function adopt(
store: Store,
revision: number,
runDurableMutation: Store['runDurableMutation'],
host: { hostId: ExecutionHostId; connectionId: string | null } = {
hostId: LOCAL_EXECUTION_HOST_ID,
connectionId: null
}
): Promise<unknown> {
const request: RuntimeTerminalOrphanAdoptionRequest = {
worktree: GIT_KEY,
expectedTopologyRevision: revision,
claims: [
{
terminal: 'term-orphan',
ptyId: 'pty-orphan',
incarnationId: 'inc-orphan',
tabId: ORPHAN_TAB,
leafId: ORPHAN_LEAF
}
]
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption reads only these PTY fields.
const pty = {
ptyId: 'pty-orphan',
incarnationId: 'inc-orphan',
worktreeId: GIT_KEY,
connectionId: host.connectionId,
connected: true,
tabId: null,
paneKey: null
} as RuntimePtyWorktreeRecord
return adoptRuntimeTerminalOrphansFromInventory({
hostId: host.hostId,
request,
workspace: {
id: GIT_KEY,
path: '/Users/dev/orca',
connectionId: host.connectionId,
repo: null,
folderWorkspace: null
},
inventory: {
livePtyIds: new Set(['pty-orphan']),
allLivePtyIds: new Set(['pty-orphan']),
terminalIdentityByPtyId: new Map([
['pty-orphan', { handle: 'term-orphan', incarnationId: 'inc-orphan', wslDistro: null }]
]),
queriedHostIds: new Set([host.hostId])
},
repoId: REPO_ID,
worktreeWslDistro: null,
ports: {
readSession: () => {
const session = store.getWorkspaceSession(host.hostId)
return {
session,
sessionWorktreeId: GIT_KEY,
currentRevision: session.terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0
}
},
getPty: () => pty,
getLeaves: () => [],
getLeaf: () => undefined,
getDisplayTitle: () => null,
replayPersistedSurface: vi.fn(),
recordAdoptedSurface: vi.fn(),
getMobileSnapshots: () => [],
getSession: () => store.getWorkspaceSession(host.hostId),
setSession: (_worktreeId, next) => store.setWorkspaceSession(next, host.hostId),
runDurableMutation,
flushSession: () => store.flushPendingOrThrowAsync(),
hydrateSession: vi.fn(),
notifySessionChanged: vi.fn(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the caller reads only `adopted`.
getSnapshot: () => ({}) as RuntimeMobileSessionTabsResult
}
})
}
describe('orphan adoption whose flush fails', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-rollback-'))
testStateDirRef.dir = testState.dir
})
afterEach(async () => {
vi.restoreAllMocks()
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it.each([
['a repo with no fence yet', undefined],
['a repo whose fence is armed', 3]
])('leaves no adopted tab, layout or incarnation behind in %s', async (_label, revision) => {
const store = createStore()
store.setWorkspaceSession(seededSession(revision))
const before = store.getWorkspaceSession()
await expect(adoptWithFailingFlush(store, revision ?? 0)).rejects.toThrow('disk_full')
const after = store.getWorkspaceSession()
expect(after.tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(['tab-kept'])
expect(after.terminalLayoutsByTabId[ORPHAN_TAB]).toBeUndefined()
expect(after.terminalPtyIncarnationsByPaneKey?.[ORPHAN_PANE]).toBeUndefined()
expect(after.tabGroups?.[GIT_KEY]).toEqual(before.tabGroups?.[GIT_KEY])
})
it('keeps an unrelated edit that landed while it waited to write', async () => {
const store = createStore()
store.setWorkspaceSession(seededSession(3))
const adoption = adopt(store, 3, (mutate) => {
store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'tab-kept' })
return store.runDurableMutation(mutate)
})
await expect(adoption).resolves.toMatchObject({ adopted: true })
const after = store.getWorkspaceSession()
expect(after.tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(['tab-kept', ORPHAN_TAB])
expect(after.activeTabId).toBe('tab-kept')
})
it('refuses when a binding write queued ahead edited the session in place', async () => {
const store = createStore()
store.setWorkspaceSession(seededSession(3))
const adoption = adopt(store, 3, (mutate) => {
void store.persistPtyBinding({
worktreeId: GIT_KEY,
tabId: 'tab-bound',
leafId: leaf(3),
ptyId: 'pty-bound'
})
return store.runDurableMutation(mutate)
})
await expect(adoption).rejects.toThrow('terminal_topology_conflict')
expect(store.getWorkspaceSession().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual([
'tab-kept',
'tab-bound'
])
})
it('adopts into an SSH host partition that was never written', async () => {
const store = createStore()
const host = { hostId: toSshExecutionHostId('target-1'), connectionId: 'target-1' }
const adoption = adopt(store, 0, (mutate) => store.runDurableMutation(mutate), host)
await expect(adoption).resolves.toMatchObject({ adopted: true })
expect(
store.getWorkspaceSession(host.hostId).tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)
).toEqual([ORPHAN_TAB])
})
it('still rebases a renderer replay of the failed adoption away', async () => {
const store = createStore()
store.setWorkspaceSession(seededSession(3))
await expect(adoptWithFailingFlush(store, 3)).rejects.toThrow('disk_full')
const after = store.getWorkspaceSession()
const keptTab = after.tabsByWorktree[GIT_KEY]![0]!
const replay: WorkspaceSessionState = {
...after,
tabsByWorktree: { [GIT_KEY]: [keptTab, { ...keptTab, id: ORPHAN_TAB, ptyId: 'pty-orphan' }] }
}
delete replay.terminalTopologyRevisionByRepoId
store.setWorkspaceSession(replay)
expect(store.getWorkspaceSession().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual([
'tab-kept'
])
})
})
@@ -1,254 +1,114 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeTerminalOrphanAdoptionRequest,
RuntimeTerminalOrphanAdoptionResult
} from '../../shared/runtime-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { makePaneKey } from '../../shared/stable-pane-id'
import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback'
import { worktreeIdsEqual } from '../../shared/worktree/id'
import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state'
import {
planRuntimeTerminalOrphanAdoption,
type RuntimeTerminalOrphanAdoptionPlan,
type RuntimeTerminalOrphanAdoptionPlanArgs,
type RuntimeTerminalOrphanAdoptionPlanPorts,
type RuntimeTerminalOrphanAdoptionSession
} from './runtime-terminal-orphan-adoption-plan'
type RuntimeTerminalOrphanAdoptionPorts = {
getPty: (handle: string) => RuntimePtyWorktreeRecord | null
getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[]
getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined
/** The title display surfaces show for the PTY, which an adopted tab persists. */
getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null
type AdoptPlan = Extract<RuntimeTerminalOrphanAdoptionPlan, { kind: 'adopt' }>
type RuntimeTerminalOrphanAdoptionPorts = RuntimeTerminalOrphanAdoptionPlanPorts & {
/** Replays a binding the session already held: names the pane without claiming the graph holds it. */
replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void
/** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */
recordAdoptedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void
getMobileSnapshots: () => Iterable<RuntimeMobileSessionTabsSnapshot>
/** The session as it is now; throws the refusal when it cannot be read. */
readSession: () => RuntimeTerminalOrphanAdoptionSession
getSession: (worktreeId: string) => WorkspaceSessionState | null
setSession: (worktreeId: string, session: WorkspaceSessionState) => void
/** The store's durable transaction; without one, `flushSession` writes and the rollback is manual. */
runDurableMutation?: <T>(mutate: () => DurableProfileStateMutation<T>) => Promise<T>
flushSession: () => Promise<void>
hydrateSession: (worktreeId: string) => void
notifySessionChanged: (worktreeId: string) => void
getSnapshot: (worktreeId: string) => RuntimeMobileSessionTabsResult
}
export async function adoptRuntimeTerminalOrphansFromInventory(args: {
/** The partition the session is read from and written to. */
hostId: ExecutionHostId
request: RuntimeTerminalOrphanAdoptionRequest
workspace: TerminalWorkspaceLaunchScope
inventory: PtyControllerInventory
session: WorkspaceSessionState
sessionWorktreeId: string
repoId: string
worktreeWslDistro: string | null
currentRevision: number
ports: RuntimeTerminalOrphanAdoptionPorts
}): Promise<RuntimeTerminalOrphanAdoptionResult> {
const { request, workspace, inventory, session, sessionWorktreeId, currentRevision, ports } = args
const seenPtyIds = new Set<string>()
const seenPaneKeys = new Set<string>()
const validated = request.claims.map((claim) => {
const paneKey = makePaneKey(claim.tabId, claim.leafId)
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
throw new Error('terminal_orphan_claim_duplicate')
}
seenPtyIds.add(claim.ptyId)
seenPaneKeys.add(paneKey)
const pty = ports.getPty(claim.terminal)
const controllerIdentity = inventory.terminalIdentityByPtyId.get(claim.ptyId)
if (
!pty ||
pty.ptyId !== claim.ptyId ||
controllerIdentity?.handle !== claim.terminal ||
controllerIdentity?.incarnationId !== claim.incarnationId ||
!inventory.livePtyIds.has(claim.ptyId) ||
!pty.connected ||
!pty.incarnationId ||
pty.incarnationId !== claim.incarnationId
) {
throw new Error('terminal_orphan_stale')
}
if (
!worktreeIdsEqual(pty.worktreeId, workspace.id) ||
!terminalOrphanExecutionOwnersEqual(
{ connectionId: workspace.connectionId, wslDistro: args.worktreeWslDistro },
{
connectionId: pty.connectionId ?? null,
...(controllerIdentity?.wslDistro !== undefined
? { wslDistro: controllerIdentity.wslDistro }
: process.platform === 'win32' && !workspace.connectionId
? {}
: { wslDistro: null })
}
)
) {
throw new Error('terminal_orphan_owner_mismatch')
}
if (
ports
.getLeaves(claim.ptyId)
.some(
(owner) =>
!worktreeIdsEqual(owner.worktreeId, workspace.id) ||
owner.tabId !== claim.tabId ||
owner.leafId !== claim.leafId
)
) {
throw new Error('terminal_orphan_already_visual')
}
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
throw new Error('terminal_orphan_competing_owner')
}
return { claim, pty, paneKey }
})
const persistedBindings = new Map<string, { worktreeId: string; paneKey: string }[]>()
const addBinding = (ptyId: string, worktreeId: string, paneKey: string): void => {
const bindings = persistedBindings.get(ptyId) ?? []
bindings.push({ worktreeId, paneKey })
persistedBindings.set(ptyId, bindings)
export async function adoptRuntimeTerminalOrphansFromInventory(
args: RuntimeTerminalOrphanAdoptionPlanArgs & {
repoId: string
ports: RuntimeTerminalOrphanAdoptionPorts
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
for (const tab of tabs) {
const layout = session.terminalLayoutsByTabId[tab.id]
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (ptyId) {
addBinding(ptyId, worktreeId, makePaneKey(tab.id, leafId))
}
): Promise<RuntimeTerminalOrphanAdoptionResult> {
const { workspace, ports } = args
const plan = (): RuntimeTerminalOrphanAdoptionPlan =>
planRuntimeTerminalOrphanAdoption(args, ports.readSession())
const stage = (planned: AdoptPlan): (() => void) => {
ports.setSession(workspace.id, planned.persisted)
const staged = ports.getSession(workspace.id)
return () => {
const current = ports.getSession(workspace.id)
if (!staged || !current) {
return
}
if (tab.ptyId && !layout) {
addBinding(tab.ptyId, worktreeId, tab.id)
}
}
}
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
const bindings = persistedBindings.get(ptyId) ?? []
if (bindings.length > 1) {
throw new Error('terminal_orphan_competing_owner')
}
return bindings[0] ?? null
}
const isExactPersisted = validated.every(({ claim, paneKey }) => {
const binding = persistedBinding(claim.ptyId)
return (
binding !== null &&
worktreeIdsEqual(binding.worktreeId, workspace.id) &&
binding.paneKey === paneKey &&
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
)
})
if (isExactPersisted && sessionWorktreeId === workspace.id) {
for (const { claim, pty, paneKey } of validated) {
ports.replayPersistedSurface(pty, claim.tabId, paneKey)
}
return {
adopted: false,
topologyRevision: currentRevision,
snapshot: ports.getSnapshot(workspace.id)
}
}
if (currentRevision !== request.expectedTopologyRevision) {
throw new Error('terminal_topology_conflict')
}
const { topologyTabsById, topologyGroups } = validateRuntimeTerminalOrphanTopology(
request,
validated
)
for (const { claim, paneKey } of validated) {
const existingBinding = persistedBinding(claim.ptyId)
if (
existingBinding &&
(!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) ||
existingBinding.paneKey !== paneKey)
) {
throw new Error('terminal_orphan_competing_owner')
}
const proposedPtyId =
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
throw new Error('terminal_orphan_surface_occupied')
}
const graphOwner = ports.getLeaf(claim.tabId, claim.leafId)
if (
graphOwner &&
(graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id))
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (
Object.entries(session.tabsByWorktree).some(
([ownerWorktreeId, tabs]) =>
!worktreeIdsEqual(ownerWorktreeId, workspace.id) &&
tabs.some((tab) => tab.id === claim.tabId)
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(
planned.session,
staged,
current
)
) {
throw new Error('terminal_orphan_surface_occupied')
}
// Why the close record too: it outlives a host restart, which the client's retirement proofs do not.
if (
session.terminalSurfaceTombstonesByPaneKey?.[paneKey] ||
hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId)
) {
throw new Error('terminal_orphan_surface_retired')
}
for (const snapshot of ports.getMobileSnapshots()) {
const surfaceOwner = snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' && tab.parentTabId === claim.tabId && tab.leafId === claim.leafId
)
if (
surfaceOwner?.type === 'terminal' &&
(snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId)
) {
throw new Error('terminal_orphan_surface_occupied')
}
const owner = snapshot.tabs.find(
(tab) => tab.type === 'terminal' && tab.ptyId === claim.ptyId
)
if (
owner?.type === 'terminal' &&
(snapshot.worktree !== workspace.id ||
owner.parentTabId !== claim.tabId ||
owner.leafId !== claim.leafId)
) {
throw new Error('terminal_orphan_competing_owner')
}
}
}
const persisted = buildRuntimeTerminalOrphanSession({
hostId: args.hostId,
session,
sessionWorktreeId,
worktreeId: workspace.id,
request,
validated,
topologyTabsById,
topologyGroups,
getDisplayTitle: ports.getDisplayTitle
})
let staged: WorkspaceSessionState | null = null
try {
ports.setSession(workspace.id, persisted)
staged = ports.getSession(workspace.id)
await ports.flushSession()
} catch (error) {
const current = ports.getSession(workspace.id)
if (staged && current) {
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
if (rolledBack !== current) {
ports.setSession(workspace.id, rolledBack)
}
}
throw error
}
for (const { claim, pty, paneKey } of validated) {
let planned: RuntimeTerminalOrphanAdoptionPlan
if (ports.runDurableMutation) {
// Why checked and written inside the store's transaction: a write queued ahead may change the
// session, and only the transaction's rollback skips the topology-fence rebase, which treats
// a write below the fence (this rollback) as a stale renderer replay and keeps the adoption.
const outcome = await ports.runDurableMutation<RuntimeTerminalOrphanAdoptionPlan | Error>(
() => {
let next: RuntimeTerminalOrphanAdoptionPlan
try {
next = plan()
} catch (error) {
// A throwing callback freezes the store; a refusal must not.
return {
value: error instanceof Error ? error : new Error(String(error)),
persist: false
}
}
return next.kind === 'replay'
? { value: next, persist: false }
: { value: next, rollback: stage(next) }
}
)
if (outcome instanceof Error) {
throw outcome
}
planned = outcome
} else {
planned = plan()
if (planned.kind === 'adopt') {
const rollback = stage(planned)
try {
await ports.flushSession()
} catch (error) {
rollback()
throw error
}
}
}
if (planned.kind === 'replay') {
for (const { claim, pty, paneKey } of planned.validated) {
ports.replayPersistedSurface(pty, claim.tabId, paneKey)
}
return {
adopted: false,
topologyRevision: planned.currentRevision,
snapshot: ports.getSnapshot(workspace.id)
}
}
for (const { claim, pty, paneKey } of planned.validated) {
ports.recordAdoptedSurface(pty, claim.tabId, paneKey)
}
ports.hydrateSession(workspace.id)
@@ -256,7 +116,8 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: {
return {
adopted: true,
topologyRevision:
persisted.terminalTopologyRevisionByRepoId?.[args.repoId] ?? currentRevision + 1,
planned.persisted.terminalTopologyRevisionByRepoId?.[args.repoId] ??
planned.currentRevision + 1,
snapshot: ports.getSnapshot(workspace.id)
}
}
@@ -33,18 +33,27 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
inventory: PtyControllerInventory
): Promise<RuntimeTerminalOrphanAdoptionResult> {
const store = this.store
const session = this.getWorkspaceSessionForWorktree(workspace.id)
if (
!store?.setWorkspaceSession ||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow) ||
!session
) {
if (!store?.setWorkspaceSession || (!store.flushPendingOrThrowAsync && !store.flushOrThrow)) {
throw new Error('workspace_session_unavailable')
}
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
if (!sessionWorktreeId) {
throw new Error('terminal_orphan_competing_owner')
const readSession = () => {
const session = this.getWorkspaceSessionForWorktree(workspace.id)
if (!session) {
throw new Error('workspace_session_unavailable')
}
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
if (!sessionWorktreeId) {
throw new Error('terminal_orphan_competing_owner')
}
return {
session,
sessionWorktreeId,
currentRevision: this.getTerminalTopologyRevision(workspace.id)
}
}
// Refuse an unreadable session before any owner lookup, as before.
readSession()
const runDurableMutation = store.runDurableMutation?.bind(store)
const worktreeConnectionId = workspace.connectionId
let worktreeWslDistro: string | null = null
if (!worktreeConnectionId && workspace.repo) {
@@ -60,12 +69,10 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
request,
workspace,
inventory,
session,
sessionWorktreeId,
repoId: getRepoIdFromWorktreeId(workspace.id),
worktreeWslDistro,
currentRevision: this.getTerminalTopologyRevision(workspace.id),
ports: {
readSession,
getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null,
getLeaves: (ptyId) => this.getLeavesForPty(ptyId),
getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)),
@@ -77,6 +84,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(),
getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next),
...(runDurableMutation ? { runDurableMutation } : {}),
flushSession: () => this.flushWorkspaceSessionOrThrowAsync(),
hydrateSession: (worktreeId) =>
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {