From b8fbfce3a6fbd17cf22847578ae77242d4d5b00f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:02:00 -0700 Subject: [PATCH] Main repairs a terminal stored in two panes once at startup (dark, layout-owner build only) (#30178) * fix(layout-owner): main repairs a terminal stored in two panes once at startup, after moving the dropped pane's owners (dark, layout-owner build only) * fix(layout-owner): the load repair moves the dropped pane's records and skips the write when an owner move fails (dark, layout-owner build only) * fix(layout-owner): the load repair leaves a paused WSL move's workspace untouched (dark, layout-owner build only) --- .../server/server-authority-aliases.ts | 5 + .../duplicate-pty-pane-repair.test.ts | 365 ++++++++++++++++++ .../duplicate-pty-pane-repair.ts | 162 ++++++++ .../layout-command-persistence.ts | 21 +- .../terminal-topology/terminal-leaf-move.ts | 48 ++- .../terminal-topology-commit.ts | 2 +- src/main/runtime/moved-pane-owner-rekey.ts | 93 +++-- ...-session-tabs-duplicate-pty-repair.spec.ts | 48 +++ src/main/runtime/orca-runtime.test.ts | 1 + ...inal-orphan-adoption-duplicate-pty.test.ts | 116 ++++++ .../startup/main-process-runtime-service.ts | 6 + 11 files changed, 829 insertions(+), 38 deletions(-) create mode 100644 src/main/persistence/terminal-topology/duplicate-pty-pane-repair.test.ts create mode 100644 src/main/persistence/terminal-topology/duplicate-pty-pane-repair.ts create mode 100644 src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts create mode 100644 src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.test.ts diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 8fd04c33cb4..30d09811eed 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -16,6 +16,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.paneKeyAliasPersistenceListener = listener } + /** The pane key that owns `paneKey`'s agent status once aliases are followed. */ + resolvePaneAuthorityOwner(paneKey: string): string { + return this.resolvePaneKeyAlias(paneKey) + } + protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => entry.ptyId diff --git a/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.test.ts b/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.test.ts new file mode 100644 index 00000000000..4ede4b1e57b --- /dev/null +++ b/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.test.ts @@ -0,0 +1,365 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { repairDuplicatePtyPanesOnStart } from '../../runtime/moved-pane-owner-rekey' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { planDuplicatePtyPaneRepair } from './duplicate-pty-pane-repair' +import { leafIds, WindowSession } from './terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const TAB = 'tab-1' +const KEPT = '11111111-1111-4111-8111-111111111111' +const DUPLICATE = '22222222-2222-4222-8222-222222222222' +const directories: string[] = [] +const stores: Store[] = [] + +afterEach(async () => { + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +/** The stored shape a window save leaves behind: one terminal drawn in two panes of one tab. */ +function duplicateSession(): WorkspaceSessionState { + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(FIXTURE_GIT_WORKTREE_ID, TAB, KEPT) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: KEPT }, + second: { type: 'leaf', leafId: DUPLICATE } + }, + { [KEPT]: 'pty-1', [DUPLICATE]: 'pty-1' } + ) + return window.session +} + +function seededDirectory( + extend: (state: Record, session: WorkspaceSessionState) => void = () => {} +): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-duplicate-pty-repair-')) + directories.push(directory) + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + const state = { + ...JSON.parse(emptyTerminalSessionProfile()), + workspaceSession: duplicateSession() + } + extend(state, state.workspaceSession) + inner.writeSerializedState(Buffer.from(JSON.stringify(state))) + inner.close() + return directory +} + +const DROPPED_KEY = `${TAB}:${DUPLICATE}` +const KEPT_KEY = `${TAB}:${KEPT}` + +/** Records the window files under the dropped pane: its sleep record, read marks and SSH lease. */ +function withDroppedPaneRecords(state: Record, session: WorkspaceSessionState) { + session.terminalPtyIncarnationsByPaneKey = { [DROPPED_KEY]: 'inc-1' } + session.sleepingAgentSessionsByPaneKey = { + [DROPPED_KEY]: { + paneKey: DROPPED_KEY, + tabId: TAB, + worktreeId: FIXTURE_GIT_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'continue', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'quit' + } + } + state.ui = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture profile always carries a ui object. + ...(state.ui as object), + acknowledgedAgentsByPaneKey: { [DROPPED_KEY]: 11 }, + activityClearedAtByPaneKey: { [DROPPED_KEY]: 12 }, + manuallyUnreadTurnsByPaneKey: { [DROPPED_KEY]: 13 } + } + state.sshRemotePtyLeases = [ + { + targetId: 'target-1', + ptyId: 'pty-1', + worktreeId: FIXTURE_GIT_WORKTREE_ID, + tabId: TAB, + leafId: DUPLICATE, + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] +} + +function storedState(directory: string): PersistedState { + const reader = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test wrote this profile as PersistedState. + return JSON.parse(reader.readSerializedState() ?? '{}') as PersistedState + } finally { + reader.close() + } +} + +async function load(directory: string): Promise<{ store: Store; authority: DelayedAuthority }> { + const authority = new DelayedAuthority( + new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + ) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + stores.push(store) + await store.flushPendingOrThrowAsync() + return { store, authority } +} + +async function quit(store: Store): Promise { + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + stores.splice(stores.indexOf(store), 1) +} + +function storedLayouts(directory: string): string { + return JSON.stringify(storedState(directory).workspaceSession?.terminalLayoutsByTabId ?? null) +} + +function storedLeafIds(directory: string): string[] { + return leafIds(JSON.parse(storedLayouts(directory))?.[TAB]?.root) +} + +function fakeRuntime() { + const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the repair reads only the orchestration DB accessor. + const runtime = { + getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey }) + } as unknown as OrcaRuntimeService + return { runtime, rekeyWorkerTerminalResourcePaneKey } +} + +function workspaceSessionWrites(authority: DelayedAuthority): number { + return authority.captures.filter((writes) => + writes.some((write) => JSON.stringify(write).includes('workspaceSession')) + ).length +} + +describe('duplicate-terminal pane repair at load (layout-owner builds)', () => { + it('moves the dropped pane’s agent status and orchestration row, then writes one pane', async () => { + const directory = seededDirectory() + const { store } = await load(directory) + const diskAtTransfer: string[][] = [] + const transfer = vi + .spyOn(agentHookServer, 'transferPaneAuthority') + .mockImplementation(() => diskAtTransfer.push(storedLeafIds(directory))) + const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime() + + await repairDuplicatePtyPanesOnStart(store, runtime) + + expect(transfer).toHaveBeenCalledExactlyOnceWith( + `${TAB}:${DUPLICATE}`, + `${TAB}:${KEPT}`, + 'pty-1', + expect.any(Number), + { authorityVerified: true } + ) + expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledExactlyOnceWith({ + fromPaneKey: `${TAB}:${DUPLICATE}`, + toPaneKey: `${TAB}:${KEPT}` + }) + // The status moved while disk still held both panes. + expect(diskAtTransfer).toEqual([[KEPT, DUPLICATE]]) + await quit(store) + expect(storedLeafIds(directory)).toEqual([KEPT]) + expect(JSON.parse(storedLayouts(directory))[TAB].ptyIdsByLeafId).toEqual({ [KEPT]: 'pty-1' }) + }) + + it('leaves disk alone with no runtime, then a runtime start transfers and writes once', async () => { + const directory = seededDirectory() + const seeded = storedLayouts(directory) + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + + // A load no runtime starts on (a CLI-only read): it reads the repair, never writes it. + const cliOnly = await load(directory) + await quit(cliOnly.store) + expect(storedLayouts(directory)).toBe(seeded) + expect(transfer).not.toHaveBeenCalled() + + const started = await load(directory) + started.authority.captures.length = 0 + const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime() + await repairDuplicatePtyPanesOnStart(started.store, runtime) + + expect(transfer).toHaveBeenCalledOnce() + expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledOnce() + expect(workspaceSessionWrites(started.authority)).toBe(1) + expect(storedLeafIds(directory)).toEqual([KEPT]) + }) + + it('neither transfers nor writes on a frozen or imported Store', async () => { + const directory = seededDirectory() + const seeded = storedLayouts(directory) + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store } = await load(directory) + await store.freezeWritesAsync() + const imported = new Store({ + serializedState: JSON.stringify({ + ...JSON.parse(emptyTerminalSessionProfile()), + workspaceSession: duplicateSession() + }) + }) + + await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime) + await repairDuplicatePtyPanesOnStart(imported, fakeRuntime().runtime) + + expect(transfer).not.toHaveBeenCalled() + expect(storedLayouts(directory)).toBe(seeded) + expect(leafIds(imported.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root)).toEqual([ + KEPT, + DUPLICATE + ]) + }) + + it('is a no-op on the next start', async () => { + const directory = seededDirectory() + const first = await load(directory) + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + await repairDuplicatePtyPanesOnStart(first.store, fakeRuntime().runtime) + await quit(first.store) + transfer.mockClear() + const repaired = storedLayouts(directory) + + const second = await load(directory) + second.authority.captures.length = 0 + await expect(second.store.repairDuplicatePtyPanes(() => true)).resolves.toBe(0) + + expect(transfer).not.toHaveBeenCalled() + expect(workspaceSessionWrites(second.authority)).toBe(0) + expect(storedLayouts(directory)).toBe(repaired) + }) + + it('moves the dropped pane’s sleep record, read marks, incarnation and SSH lease to the kept pane', async () => { + const directory = seededDirectory(withDroppedPaneRecords) + const { store } = await load(directory) + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + + await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime) + await quit(store) + + const state = storedState(directory) + const session = state.workspaceSession + expect(Object.keys(session?.sleepingAgentSessionsByPaneKey ?? {})).toEqual([KEPT_KEY]) + expect(session?.sleepingAgentSessionsByPaneKey?.[KEPT_KEY]?.paneKey).toBe(KEPT_KEY) + expect(session?.terminalPtyIncarnationsByPaneKey).toEqual({ [KEPT_KEY]: 'inc-1' }) + expect(state.ui?.acknowledgedAgentsByPaneKey).toEqual({ [KEPT_KEY]: 11 }) + expect(state.ui?.activityClearedAtByPaneKey).toEqual({ [KEPT_KEY]: 12 }) + expect(state.ui?.manuallyUnreadTurnsByPaneKey).toEqual({ [KEPT_KEY]: 13 }) + expect(state.sshRemotePtyLeases?.map(({ tabId, leafId }) => ({ tabId, leafId }))).toEqual([ + { tabId: TAB, leafId: KEPT } + ]) + }) + + it('keeps the kept pane’s own incarnation over the dropped pane’s', async () => { + const directory = seededDirectory((_state, session) => { + session.terminalPtyIncarnationsByPaneKey = { [DROPPED_KEY]: 'inc-old', [KEPT_KEY]: 'inc-1' } + }) + const { store } = await load(directory) + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + + await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime) + await quit(store) + + expect(storedState(directory).workspaceSession?.terminalPtyIncarnationsByPaneKey).toEqual({ + [KEPT_KEY]: 'inc-1' + }) + }) + + it('leaves a status owner that already moved to another tab, and its records, where they are', async () => { + const directory = seededDirectory(withDroppedPaneRecords) + const { store } = await load(directory) + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + vi.spyOn(agentHookServer, 'resolvePaneAuthorityOwner').mockImplementation((paneKey) => + paneKey === DROPPED_KEY ? `tab-2:${KEPT}` : paneKey + ) + const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime() + + await repairDuplicatePtyPanesOnStart(store, runtime) + await quit(store) + + expect(transfer).not.toHaveBeenCalled() + expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() + const state = storedState(directory) + expect(storedLeafIds(directory)).toEqual([KEPT]) + expect(Object.keys(state.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {})).toEqual([ + DROPPED_KEY + ]) + expect(state.ui?.acknowledgedAgentsByPaneKey).toEqual({ [DROPPED_KEY]: 11 }) + }) + + it('writes nothing when a pane owner move fails, so the next start retries', async () => { + const directory = seededDirectory() + const seeded = storedLayouts(directory) + const { store } = await load(directory) + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the repair reads only the orchestration DB accessor. + const runtime = { + getExistingOrchestrationDb: () => ({ + rekeyWorkerTerminalResourcePaneKey: () => { + throw new Error('database is locked') + } + }) + } as unknown as OrcaRuntimeService + + await repairDuplicatePtyPanesOnStart(store, runtime) + await quit(store) + + expect(storedLayouts(directory)).toBe(seeded) + }) +}) + +describe('planDuplicatePtyPaneRepair', () => { + it('repairs the layout but keeps the owner when another tab also binds the terminal', () => { + const session = duplicateSession() + session.terminalLayoutsByTabId['tab-2'] = { + root: { type: 'leaf', leafId: '33333333-3333-4333-8333-333333333333' }, + activeLeafId: '33333333-3333-4333-8333-333333333333', + expandedLeafId: null, + ptyIdsByLeafId: { '33333333-3333-4333-8333-333333333333': 'pty-1' } + } + + const plan = planDuplicatePtyPaneRepair(session) + + expect(leafIds(plan?.session.terminalLayoutsByTabId[TAB]?.root)).toEqual([KEPT]) + expect(plan?.transfers).toEqual([]) + }) +}) + +describe('planDuplicatePtyPaneRepair on a paused WSL move', () => { + it('leaves a frozen workspace’s tree and owners alone, since the write keeps its stored tree', () => { + expect( + planDuplicatePtyPaneRepair(duplicateSession(), (id) => id === FIXTURE_GIT_WORKTREE_ID) + ).toBeNull() + }) +}) diff --git a/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.ts b/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.ts new file mode 100644 index 00000000000..8e9dba2100f --- /dev/null +++ b/src/main/persistence/terminal-topology/duplicate-pty-pane-repair.ts @@ -0,0 +1,162 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import { + normalizeTerminalLayoutPtyOwnership, + resolveTerminalLayoutPtyOwnershipTransfers +} from '../../../shared/terminal-layout-pty-ownership' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { writeRestorable } from './restorable-write' +import { + moveRecordKey, + rekeyPaneProfileRecords, + writeRekeyedProfileRecords +} from './terminal-leaf-move' +import { keepFrozen, type TerminalTopologyCommitContext } from './terminal-topology-commit' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' + +/** A pane the repair drops, and the pane of the same tab that keeps its terminal. */ +export type DuplicatePtyPaneTransfer = { + worktreeId: string + tabId: string + removedLeafId: string + retainedLeafId: string + ptyId: string +} + +function boundByOtherTab(session: WorkspaceSessionState, tabId: string, ptyId: string): boolean { + return Object.entries(session.terminalLayoutsByTabId ?? {}).some( + ([otherTabId, layout]) => + otherTabId !== tabId && Object.values(layout?.ptyIdsByLeafId ?? {}).includes(ptyId) + ) +} + +/** The partition with one pane per terminal, and the owner transfers that implies; null when none. */ +export function planDuplicatePtyPaneRepair( + session: WorkspaceSessionState, + /** A workspace whose stored session must stay as it is (a paused WSL move). */ + isFrozen: (worktreeId: string) => boolean = () => false +): { session: WorkspaceSessionState; transfers: DuplicatePtyPaneTransfer[] } | null { + let layouts: Record | null = null + const transfers: DuplicatePtyPaneTransfer[] = [] + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const repaired = normalizeTerminalLayoutPtyOwnership(layout) + const worktreeId = findWorktreeIdForTab(session, tabId) + // The write keeps a frozen workspace's stored tree, so its owners must not move either. + if (!repaired.changed || (worktreeId && isFrozen(worktreeId))) { + continue + } + layouts ??= { ...session.terminalLayoutsByTabId } + layouts[tabId] = repaired.snapshot + for (const transfer of resolveTerminalLayoutPtyOwnershipTransfers(layout, repaired.snapshot)) { + // The window's transfer guards: only pane keys that parse, and no terminal another tab binds. + if ( + worktreeId && + !tabId.includes(':') && + isTerminalLeafId(transfer.removedLeafId) && + isTerminalLeafId(transfer.retainedLeafId) && + !boundByOtherTab(session, tabId, transfer.ptyId) + ) { + transfers.push({ worktreeId, tabId, ...transfer }) + } + } + } + return layouts ? { session: { ...session, terminalLayoutsByTabId: layouts }, transfers } : null +} + +/** The dropped pane's sleep record and incarnation follow it, as the window's transfer moves them. */ +function rekeyPaneSessionRecords( + session: WorkspaceSessionState, + move: DuplicatePtyPaneTransfer +): WorkspaceSessionState { + const from = makePaneKey(move.tabId, move.removedLeafId) + const to = makePaneKey(move.tabId, move.retainedLeafId) + return { + ...session, + // Both panes name one terminal, so the kept pane's own incarnation wins. + terminalPtyIncarnationsByPaneKey: moveRecordKey( + session.terminalPtyIncarnationsByPaneKey, + from, + to, + undefined, + true + ), + sleepingAgentSessionsByPaneKey: moveRecordKey( + session.sleepingAgentSessionsByPaneKey, + from, + to, + (record) => ({ ...record, paneKey: to }) + ) + } +} + +/** + * Repairs every non-runtime partition holding a terminal in two panes of one tab. Every transfer + * runs before the write, so disk never drops a pane key whose owners have not moved yet. `transfer` + * returns false for a pane whose owner is not this tab's to move (its records stay), and throws + * when a move failed, which skips the write so the next start retries. + */ +export function repairDuplicatePtyPanesMutation( + context: TerminalTopologyCommitContext, + transfer: (move: DuplicatePtyPaneTransfer) => boolean +): () => DurableProfileStateMutation { + return () => { + const plans = context + .hostIds() + // A paired server's partition is a display copy of that server's own layout. + .filter((hostId) => !hostId.startsWith('runtime:')) + .flatMap((hostId: ExecutionHostId) => { + const plan = planDuplicatePtyPaneRepair( + context.getSession(hostId), + (worktreeId) => + hostId === LOCAL_EXECUTION_HOST_ID && isWslMoveWorkspaceSessionFrozen(worktreeId) + ) + return plan ? [{ hostId, ...plan }] : [] + }) + if (plans.length === 0) { + return { value: 0, persist: false } + } + let moved: Set + try { + moved = new Set(plans.flatMap((plan) => plan.transfers.filter(transfer))) + } catch (error) { + console.warn('[persistence] duplicate-terminal pane repair skipped:', error) + return { value: 0, persist: false } + } + const restores = plans.map(({ hostId, session, transfers }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + keepFrozen( + hostId, + transfers.filter((move) => moved.has(move)).reduce(rekeyPaneSessionRecords, session), + context + ) + ) + ) + for (const move of moved) { + restores.push( + ...writeRekeyedProfileRecords( + rekeyPaneProfileRecords( + context.state, + { tabId: move.tabId, leafId: move.removedLeafId }, + { tabId: move.tabId, leafId: move.retainedLeafId } + ), + context + ) + ) + } + return { + value: plans.length, + // Latest write first: a restore only undoes a value no later write replaced. + rollback: () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } + } + } +} diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index ff5648fd187..1b188797de9 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -8,10 +8,14 @@ import { } from '../loading-store/session-host-partitions' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import { applyLayout, topologyCommitContext } from './terminal-topology-commit' +import { + repairDuplicatePtyPanesMutation, + type DuplicatePtyPaneTransfer +} from './duplicate-pty-pane-repair' type LayoutCommandRuntime = Pick< StoreRuntimeState, - 'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' + 'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' | 'writesFrozen' > const layoutCommandPersistenceContext = Symbol('LayoutCommandPersistence') @@ -39,4 +43,19 @@ export class LayoutCommandPersistence { applyLayout(resolved, command, topologyCommitContext(context)) ) } + + /** + * Layout-owner builds, once a runtime starts: one pane per terminal on disk, written only after + * `transfer` moved each dropped pane's owners. Resolves the number of partitions repaired. + */ + repairDuplicatePtyPanes(transfer: (move: DuplicatePtyPaneTransfer) => boolean): Promise { + const context = this[layoutCommandPersistenceContext] + // An imported or frozen profile neither writes nor moves owners. + if (context.runtime.writesFrozen) { + return Promise.resolve(0) + } + return context.runtime.runDurableMutation( + repairDuplicatePtyPanesMutation(topologyCommitContext(context), transfer) + ) + } } diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 5a542b8f434..a18f721fb18 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -1,5 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { makePaneKey } from '../../../shared/stable-pane-id' import { terminalLeafMovePaneKeys, type TerminalLeafMoveRequest, @@ -24,11 +25,13 @@ type PlannedTerminalLeafMove = { sessions: TerminalSessionPartition[] } -function moveRecordKey( +export function moveRecordKey( record: Record | undefined, fromKey: string, toKey: string | null, - remap: (value: T) => T = (value) => value + remap: (value: T) => T = (value) => value, + /** Keep a value already at `toKey`; the moved one is dropped. */ + keepTarget = false ): Record | undefined { if (!record || !Object.hasOwn(record, fromKey)) { return record @@ -36,7 +39,7 @@ function moveRecordKey( const next = { ...record } const value = next[fromKey] delete next[fromKey] - if (toKey !== null && value !== undefined) { + if (toKey !== null && value !== undefined && !(keepTarget && Object.hasOwn(next, toKey))) { next[toKey] = remap(value) } return next @@ -208,12 +211,16 @@ const PANE_KEYED_UI_RECORDS = [ 'manuallyUnreadTurnsByPaneKey' ] as const -/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */ -export function rekeyMovedLeafProfileRecords( +type PaneAddress = { tabId: string; leafId: string } + +/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a pane to its new key. */ +export function rekeyPaneProfileRecords( state: Pick, - move: TerminalLeafMoveRequest + from: PaneAddress, + to: PaneAddress ): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } { - const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(move) + const fromPaneKey = makePaneKey(from.tabId, from.leafId) + const toPaneKey = makePaneKey(to.tabId, to.leafId) const ui = state.ui let nextUi: PersistedState['ui'] | undefined for (const key of PANE_KEYED_UI_RECORDS) { @@ -224,15 +231,15 @@ export function rekeyMovedLeafProfileRecords( } const leases = state.sshRemotePtyLeases ?? [] const leasesChanged = leases.some( - (lease) => lease.tabId === move.sourceTabId && lease.leafId === move.leafId + (lease) => lease.tabId === from.tabId && lease.leafId === from.leafId ) return { ...(nextUi ? { ui: nextUi } : {}), ...(leasesChanged ? { sshRemotePtyLeases: leases.map((lease) => - lease.tabId === move.sourceTabId && lease.leafId === move.leafId - ? { ...lease, tabId: move.targetTabId } + lease.tabId === from.tabId && lease.leafId === from.leafId + ? { ...lease, tabId: to.tabId, leafId: to.leafId } : lease ) } @@ -240,14 +247,33 @@ export function rekeyMovedLeafProfileRecords( } } +/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */ +export function rekeyMovedLeafProfileRecords( + state: Pick, + move: TerminalLeafMoveRequest +): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } { + return rekeyPaneProfileRecords( + state, + { tabId: move.sourceTabId, leafId: move.leafId }, + { tabId: move.targetTabId, leafId: move.leafId } + ) +} + /** Pane-keyed UI marks and SSH leases follow a moved leaf; returns their restores. */ export function writeMovedLeafProfileRecords( request: TerminalLeafMoveRequest, context: TerminalTopologyCommitContext +): (() => void)[] { + return writeRekeyedProfileRecords(rekeyMovedLeafProfileRecords(context.state, request), context) +} + +/** Writes rekeyed UI marks and SSH leases; returns their restores. */ +export function writeRekeyedProfileRecords( + rekeyed: ReturnType, + context: TerminalTopologyCommitContext ): (() => void)[] { const { state } = context const restores: (() => void)[] = [] - const rekeyed = rekeyMovedLeafProfileRecords(state, request) if (rekeyed.ui) { restores.push( writeRestorable( diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index c452bd18ae4..a706d8d50a1 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -200,7 +200,7 @@ export function topologyCommitContext({ } // A paused WSL move keeps its local rows as the quiesce left them. -function keepFrozen( +export function keepFrozen( hostId: ExecutionHostId, session: WorkspaceSessionState, context: TerminalTopologyCommitContext diff --git a/src/main/runtime/moved-pane-owner-rekey.ts b/src/main/runtime/moved-pane-owner-rekey.ts index a74af9b0187..a630954fe90 100644 --- a/src/main/runtime/moved-pane-owner-rekey.ts +++ b/src/main/runtime/moved-pane-owner-rekey.ts @@ -1,35 +1,35 @@ import { agentHookServer } from '../agent-hooks/server' import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment' -import { - terminalLeafMovePaneKeys, - type TerminalLeafMoveRequest -} from '../../shared/terminal-leaf-move' +import type { Store } from '../persistence/loading-store/store' +import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import type { TerminalLeafMoveRequest } from '../../shared/terminal-leaf-move' import type { OrcaRuntimeService } from './orca-runtime' -/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */ -export function rekeyMovedPaneOwners( - runtime: Pick | undefined, - /** `ptyId` is the terminal the committed move bound the pane to. */ - request: TerminalLeafMoveRequest -): void { - const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) - forgetFailedAgentLaunches(request.worktreeId, { - kind: 'pane', - tabId: request.sourceTabId, - leafId: request.leafId - }) +type PaneOwnerRuntime = Pick | undefined +type PaneAddress = { tabId: string; leafId: string } + +/** + * After a committed pane-key change: agent-status and orchestration keys follow the pane. A + * failure is logged and the pane keeps that old key; returns false when either step failed. + */ +function rekeyPaneOwners( + runtime: PaneOwnerRuntime, + worktreeId: string, + from: PaneAddress, + to: PaneAddress, + ptyId: string | null +): boolean { + let moved = true + const fromPaneKey = makePaneKey(from.tabId, from.leafId) + const toPaneKey = makePaneKey(to.tabId, to.leafId) + forgetFailedAgentLaunches(worktreeId, { kind: 'pane', ...from }) try { // The process keeps the pane key baked into its env, so status must alias old to new. - agentHookServer.transferPaneAuthority( - fromPaneKey, - toPaneKey, - request.ptyId ?? undefined, - Date.now(), - { - authorityVerified: true - } - ) + agentHookServer.transferPaneAuthority(fromPaneKey, toPaneKey, ptyId ?? undefined, Date.now(), { + authorityVerified: true + }) } catch (error) { + moved = false console.warn('[pty] moved pane kept its old agent-status key:', error) } try { @@ -38,6 +38,49 @@ export function rekeyMovedPaneOwners( toPaneKey }) } catch (error) { + moved = false console.warn('[pty] moved pane kept its old orchestration resource key:', error) } + return moved +} + +/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */ +export function rekeyMovedPaneOwners( + runtime: PaneOwnerRuntime, + /** `ptyId` is the terminal the committed move bound the pane to. */ + request: TerminalLeafMoveRequest +): void { + rekeyPaneOwners( + runtime, + request.worktreeId, + { tabId: request.sourceTabId, leafId: request.leafId }, + { tabId: request.targetTabId, leafId: request.leafId }, + request.ptyId + ) +} + +/** Layout-owner builds: main's one load-time repair, once this runtime can move pane owners. */ +export async function repairDuplicatePtyPanesOnStart( + store: Pick, + runtime: PaneOwnerRuntime +): Promise { + try { + await store.repairDuplicatePtyPanes((move) => { + const from = { tabId: move.tabId, leafId: move.removedLeafId } + // The window's rule: a status owner that already moved to another tab stays there. + const owner = parsePaneKey( + agentHookServer.resolvePaneAuthorityOwner(makePaneKey(from.tabId, from.leafId)) + ) + if (owner && owner.tabId !== move.tabId) { + return false + } + const to = { tabId: move.tabId, leafId: move.retainedLeafId } + if (!rekeyPaneOwners(runtime, move.worktreeId, from, to, move.ptyId)) { + throw new Error('pane_owner_transfer_failed') + } + return true + }) + } catch (error) { + console.warn('[persistence] duplicate-terminal pane repair was not written:', error) + } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts new file mode 100644 index 00000000000..64618096cdf --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + HEADLESS_SECOND_LEAF_ID, + TEST_WORKTREE_ID, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' +import { planDuplicatePtyPaneRepair } from '../../persistence/terminal-topology/duplicate-pty-pane-repair' +import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' + +// The windowless list reads main's stored session directly, so a terminal stored in two panes +// of one tab is listed twice until main repairs the stored tree. +const duplicate = makeWorkspaceSessionWithHeadlessTerminal({ + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'persisted-pty', + [HEADLESS_SECOND_LEAF_ID]: 'persisted-pty' + }) + } +}) + +async function terminalRows(session: WorkspaceSessionState): Promise { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + const snapshots = await runtime.listAllMobileSessionTabs() + return (snapshots.find((snapshot) => snapshot.worktree === TEST_WORKTREE_ID)?.tabs ?? []).filter( + (tab) => tab.type === 'terminal' + ).length +} + +describe('windowless mobile list of a terminal stored in two panes', () => { + it('lists the stored duplicate twice (the reader that skips the Loader)', async () => { + await expect(terminalRows(duplicate)).resolves.toBe(2) + }) + + it('lists one row once main has repaired the stored tree', async () => { + await expect(terminalRows(planDuplicatePtyPaneRepair(duplicate)!.session)).resolves.toBe(1) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 87bd821276e..600faf8eb5d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -87,6 +87,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec') await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec') await import('./orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec') +await import('./orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec') await import('./orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec') await import('./orca-runtime-tests/mobile-session-tabs-shell-selection.spec') await import('./orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec') 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 new file mode 100644 index 00000000000..25afc2d2513 --- /dev/null +++ b/src/main/runtime/runtime-terminal-orphan-adoption-duplicate-pty.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } 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 { planDuplicatePtyPaneRepair } from '../persistence/terminal-topology/duplicate-pty-pane-repair' +import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +const TAB = 'tab-1' +const KEPT = leaf(1) +const DUPLICATE = leaf(2) + +function duplicateSession(): WorkspaceSessionState { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-1', + tabs: [ + { + id: TAB, + leaves: [ + [KEPT, 'pty-1'], + [DUPLICATE, 'pty-1'] + ] + } + ] + } + ]) + return { ...session, terminalPtyIncarnationsByPaneKey: { [`${TAB}:${KEPT}`]: 'inc-1' } } +} + +function adoptKeptPane(session: WorkspaceSessionState) { + const request: RuntimeTerminalOrphanAdoptionRequest = { + worktree: GIT_KEY, + expectedTopologyRevision: 0, + claims: [ + { terminal: 'term-1', ptyId: 'pty-1', incarnationId: 'inc-1', tabId: TAB, leafId: KEPT } + ] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption reads only these PTY fields before the persisted-binding check. + const pty = { + ptyId: 'pty-1', + incarnationId: 'inc-1', + worktreeId: GIT_KEY, + connectionId: null, + connected: true, + tabId: null, + paneKey: null + } as RuntimePtyWorktreeRecord + const replayPersistedSurface = vi.fn() + const adopted = adoptRuntimeTerminalOrphansFromInventory({ + hostId: LOCAL_EXECUTION_HOST_ID, + request, + workspace: { + id: GIT_KEY, + path: '/Users/dev/orca', + connectionId: null, + repo: null, + folderWorkspace: null + }, + inventory: { + livePtyIds: new Set(['pty-1']), + allLivePtyIds: new Set(['pty-1']), + terminalIdentityByPtyId: new Map([ + ['pty-1', { handle: 'term-1', incarnationId: 'inc-1', wslDistro: null }] + ]), + queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) + }, + session, + sessionWorktreeId: GIT_KEY, + repoId: 'repo-1', + worktreeWslDistro: null, + currentRevision: 0, + ports: { + getPty: () => pty, + getLeaves: () => [], + getLeaf: () => undefined, + getDisplayTitle: () => null, + replayPersistedSurface, + recordAdoptedSurface: vi.fn(), + getMobileSnapshots: () => [], + getSession: () => session, + setSession: vi.fn(), + flushSession: async () => {}, + hydrateSession: vi.fn(), + notifySessionChanged: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the replay path returns this value untouched. + getSnapshot: () => ({}) as RuntimeMobileSessionTabsResult + } + }) + return { adopted, replayPersistedSurface } +} + +describe('orphan adoption of a terminal main repaired at load', () => { + it('refuses the stored duplicate as a competing owner (the reader that skips the Loader)', async () => { + await expect(adoptKeptPane(duplicateSession()).adopted).rejects.toThrow( + 'terminal_orphan_competing_owner' + ) + }) + + it('replays the kept pane once the stored tree holds one pane per terminal', async () => { + const repaired = planDuplicatePtyPaneRepair(duplicateSession())!.session + const { adopted, replayPersistedSurface } = adoptKeptPane(repaired) + + await expect(adopted).resolves.toMatchObject({ adopted: false }) + expect(replayPersistedSurface).toHaveBeenCalledWith(expect.anything(), TAB, `${TAB}:${KEPT}`) + }) +}) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index c41fdce8181..213ff778511 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -42,6 +42,8 @@ import { } from '../runtime/agent-status-observed-pane-identity' import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { repairDuplicatePtyPanesOnStart } from '../runtime/moved-pane-owner-rekey' export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const activation = state.desktopActivationGate @@ -146,6 +148,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { recordDurableCrashBreadcrumb('agent_state_rules_active', rules) ) state.runtime = runtime + // Switch point: main's load repair is written only now that a runtime can move pane owners. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + void repairDuplicatePtyPanesOnStart(store, runtime) + } runtime.sidebarFacts?.attachSidebarAgentStatusStore(agentHookServer) agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)