diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c89d7a150bf..94000972640 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -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', diff --git a/src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.test.ts b/src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.test.ts index 25afc2d2513..91f56b9b03f 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.test.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.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, diff --git a/src/main/runtime/runtime-terminal-orphan-adoption-plan.ts b/src/main/runtime/runtime-terminal-orphan-adoption-plan.ts new file mode 100644 index 00000000000..f313d0ac631 --- /dev/null +++ b/src/main/runtime/runtime-terminal-orphan-adoption-plan.ts @@ -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 +} + +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() + const seenPaneKeys = new Set() + 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() + 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 } +} diff --git a/src/main/runtime/runtime-terminal-orphan-adoption-rollback.test.ts b/src/main/runtime/runtime-terminal-orphan-adoption-rollback.test.ts new file mode 100644 index 00000000000..93cebc7cc00 --- /dev/null +++ b/src/main/runtime/runtime-terminal-orphan-adoption-rollback.test.ts @@ -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 { + 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 { + 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' + ]) + }) +}) diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index a880081a99d..918799d340b 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -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 + +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 + /** 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?: (mutate: () => DurableProfileStateMutation) => Promise flushSession: () => Promise 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 { - const { request, workspace, inventory, session, sessionWorktreeId, currentRevision, ports } = args - const seenPtyIds = new Set() - const seenPaneKeys = new Set() - 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() - 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 { + 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( + () => { + 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) } } diff --git a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts index f2afb185899..5d398569940 100644 --- a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts @@ -33,18 +33,27 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O inventory: PtyControllerInventory ): Promise { 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, {