diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index 29112c38d03..a072383a660 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -86,6 +86,32 @@ export async function prepareLegacySharedCodexSessionResume( return { useRealCodexHome: true } } +/** Explicit account restarts must move the verified rollout before changing credentials. */ +export async function prepareCodexAccountRestartResume(args: { + sourceHome: string + transcriptPath: string + targetHome: string + systemCodexHomePath: string +}): Promise { + if (sameRuntimePath(args.sourceHome, args.targetHome)) { + return args.targetHome + } + const relativePath = relative( + resolve(join(args.sourceHome, 'sessions')), + resolve(args.transcriptPath) + ) + if (!isDatedRolloutRelativePath(relativePath)) { + throw new Error(RETRYABLE_RESUME_ERROR) + } + const paths = resolveCodexSessionBackfillPaths(args.systemCodexHomePath) + await materializeLegacyRollout( + args.transcriptPath, + join(args.targetHome, 'sessions', relativePath), + paths.auditLogPath + ) + return args.targetHome +} + /** * Repins a per-account resume to the selected account's home, or null to keep * the session's own home. diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 50558a0f870..5fc47deac66 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness' -import { SessionNotFoundError } from '../daemon/daemon-errors' +import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry' +import { toAppSshPtyId } from '../providers/ssh-pty-id' +import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' @@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212' const paneKey = makePaneKey(tabId, leafId) type RestartHarness = ReturnType +type FakeSession = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: { type: 'leaf'; leafId: string } + activeLeafId: string + expandedLeafId: null + ptyIdsByLeafId: Record + } + > + terminalPtyIncarnationsByPaneKey: Record +} + +function seedSession(ptyId: string): FakeSession { + return { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } + } +} function registerWithFakes( mainWindow: PtyIpcSuiteFixtures['mainWindow'], @@ -78,13 +109,15 @@ function installRestartHarness( options: { shutdownFails?: boolean; shutdownGate?: Promise } = {} ) { let oldSessionAlive = true - const control = { shutdownFails: options.shutdownFails ?? false } + const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false } + const boundAtFreshLaunch: (string | undefined)[] = [] const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { if (!spawnOptions.attachOnly) { + boundAtFreshLaunch.push(leafBinding()) return { id: 'pty-new', incarnationId: 'inc-new' } } if (!oldSessionAlive) { - throw new SessionNotFoundError('pty-old') + throw new TerminalKilledError('pty-old') } return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } }) @@ -120,31 +153,75 @@ function installRestartHarness( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. setLocalPtyProvider(provider as unknown as Parameters[0]) - let session = { - tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: 'pty-old' } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } - } + let session = seedSession('pty-old') const store = { - getWorkspaceSession: vi.fn(() => session), + getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), setWorkspaceSession: vi.fn((next) => { session = next }), flushOrThrow: vi.fn(), - runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), - persistPtyBinding: vi.fn(), + runDurableMutation: vi.fn( + async ( + mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void } + ) => { + const mutation = mutate() + if (mutation.persist !== false) { + if (control.failNextWrite) { + control.failNextWrite = false + mutation.rollback?.() + throw new Error('save failed') + } + durableBindings.push(leafBinding()) + } + return mutation.value + } + ), + getWorkspaceSessionHostIds: vi.fn(() => [ + 'local', + ...Object.keys(state.workspaceSessionsByHostId) + ]), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) } + const state: { + workspaceSession: FakeSession + workspaceSessionsByHostId: Record + } = { workspaceSession: session, workspaceSessionsByHostId: {} } + const durableBindings: (string | undefined)[] = [] + function leafBinding(hostId?: string): string | undefined { + return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + } + // Like the store, a missing host partition reads as an empty session, never the local one. + store.getWorkspaceSession.mockImplementation((hostId?: string) => + hostId && hostId !== 'local' + ? (state.workspaceSessionsByHostId[hostId] ?? { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + terminalPtyIncarnationsByPaneKey: {} + }) + : state.workspaceSession + ) + const bindingRuntime = { + state, + dirtyProfileStateDomains: new Set(), + runDurableMutation: store.runDurableMutation, + writeTimer: null, + pendingWrite: null, + quitFlushStarted: false, + writeGeneration: 0, + lastDurableWriteGeneration: 0 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< + typeof PtyBindingPersistenceOperations + > + const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) + const storeWithRetirement = Object.assign(store, { + persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)), + retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations) + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -165,7 +242,18 @@ function installRestartHarness( onPtyData: vi.fn(), intentionalPtyStops: new TerminalIntentionalStops() } - return { providerSpawn, shutdown, store, runtime, control } + return { + providerSpawn, + shutdown, + store: storeWithRetirement, + runtime, + control, + provider, + leafBinding, + durableBindings, + boundAtFreshLaunch, + partitions: state.workspaceSessionsByHostId + } } function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) { @@ -283,4 +371,270 @@ describe('pty:spawn replacing a pane owner', () => { expect(exitPayloads('pty-old')).toHaveLength(1) expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart') }) + it('swaps the stopped binding for the replacement in one write, never unbinding the pane', async () => { + const { providerSpawn, store, runtime, durableBindings, boundAtFreshLaunch } = + installRestartHarness() + const session = store.getWorkspaceSession() + const layout = structuredClone(session.terminalLayoutsByTabId[tabId]) + registerWithFakes(mainWindow, runtime, store) + await handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + const swapped = store.getWorkspaceSession() + expect(boundAtFreshLaunch).toEqual(['pty-old']) + expect(durableBindings).toEqual(['pty-new']) + expect(swapped.tabsByWorktree[worktreeId]).toEqual([ + { ...session.tabsByWorktree[worktreeId][0], ptyId: 'pty-new' } + ]) + expect(swapped.terminalLayoutsByTabId[tabId]).toEqual({ + ...layout, + ptyIdsByLeafId: { [leafId]: 'pty-new' } + }) + expect(swapped.terminalPtyIncarnationsByPaneKey).toEqual({ [paneKey]: 'inc-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('launches fresh when a stale snapshot re-publishes the stopped binding mid-spawn', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A debounced renderer layout patch still carrying the old id lands after the stop. + const current = store.getWorkspaceSession() + current.terminalLayoutsByTabId[tabId] = { + ...current.terminalLayoutsByTabId[tabId], + ptyIdsByLeafId: { [leafId]: 'pty-old' } + } + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalled() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('swaps a binding the renderer withdrew before connecting the replacement', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A split tab's partial layout map, or an SSH terminated lease, lets the renderer clear land. + delete store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('clears the stopped binding when the replacement fails, so the remount starts fresh', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('spawn failed') + const boundAfterFailure = leafBinding() + // The renderer's recovery remount sends no replacesPtyId. + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('reaps the replacement when its binding save fails, and the remount starts fresh', async () => { + const { provider, providerSpawn, store, runtime, control, leafBinding } = + installRestartHarness() + control.failNextWrite = true + vi.spyOn(console, 'error').mockImplementation(() => {}) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + const boundAfterFailure = leafBinding() + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('refuses an old restart after another owner has already claimed the pane', async () => { + const { shutdown, providerSpawn, store, runtime } = installRestartHarness() + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(shutdown).not.toHaveBeenCalled() + expect(providerSpawn).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('reaps the replacement when a successor binds the pane during its spawn', async () => { + const { provider, providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockImplementationOnce(async () => { + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + return { id: 'pty-new', incarnationId: 'inc-new' } + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + expect(leafBinding()).toBe('successor') + }) + + /** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */ + async function withSshRestart( + run: ( + host: RestartHarness & { oldId: string; newId: string; hostId: string }, + restart: (extra?: { replacesPtyId?: string }) => Promise + ) => Promise + ): Promise { + const harness = installRestartHarness() + const connectionId = 'restart-ssh' + const hostId = 'ssh:restart-ssh' + const oldId = toAppSshPtyId(connectionId, 'pty-old') + const newId = toAppSshPtyId(connectionId, 'pty-new') + harness.partitions[hostId] = seedSession(oldId) + Object.assign(harness.store, { + markSshRemotePtyLease: vi.fn(), + upsertSshRemotePtyLease: vi.fn(), + removeSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn() + }) + let oldAlive = true + harness.shutdown.mockImplementation(async () => { + oldAlive = false + }) + harness.providerSpawn.mockImplementation(async (options) => { + if (!options.attachOnly) { + return { id: newId, incarnationId: 'inc-new' } + } + if (!oldAlive) { + throw new TerminalKilledError(oldId) + } + return { id: oldId, incarnationId: 'inc-old', isReattach: true } + }) + registerSshPtyProvider( + connectionId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture. + harness.provider as unknown as Parameters[1] + ) + const localSpawn = vi.fn(() => { + throw new Error('wrong execution host') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider. + setLocalPtyProvider({ + ...harness.provider, + spawn: localSpawn, + shutdown: localSpawn + } as unknown as Parameters[0]) + registerWithFakes(mainWindow, harness.runtime, harness.store) + try { + await run({ ...harness, oldId, newId, hostId }, async (extra = {}) => + handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId }) + ) + expect(localSpawn).not.toHaveBeenCalled() + expect(harness.leafBinding()).toBe('pty-old') + } finally { + unregisterSshPtyProvider(connectionId) + } + } + + it('swaps the direct-SSH host binding without touching the local provider', async () => { + await withSshRestart(async (host, restart) => { + await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({ + id: host.newId + }) + expect(host.shutdown).toHaveBeenCalledWith( + host.oldId, + expect.objectContaining({ immediate: true }) + ) + expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId) + expect(host.leafBinding(host.hostId)).toBe(host.newId) + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockImplementationOnce(async () => { + host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = + 'successor' + return { id: host.newId, incarnationId: 'inc-new' } + }) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(host.shutdown).toHaveBeenCalledWith( + host.newId, + expect.objectContaining({ immediate: true }) + ) + expect(host.leafBinding(host.hostId)).toBe('successor') + }) + }) + + it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed') + const boundAfterFailure = host.leafBinding(host.hostId) + await expect(restart()).resolves.toMatchObject({ id: host.newId }) + expect(boundAfterFailure).toBeUndefined() + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it.each(['darwin', 'linux', 'win32'])( + 'restarts a folder workspace on %s without deleting its pane', + async (platform) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + onTestFinished(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + const { store, runtime, leafBinding } = installRestartHarness() + const folderId = 'folder:restart-folder' + const session = store.getWorkspaceSession() + session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({ + ...tab, + worktreeId: folderId + })) + session.tabsByWorktree[worktreeId] = [] + const folder = { + id: 'restart-folder', + folderPath: process.cwd(), + name: 'Restart', + projectGroupId: null + } + Object.assign(store, { + getFolderWorkspaces: vi.fn(() => [folder]), + getFolderWorkspace: vi.fn(() => folder) + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, { + ...restartSpawnArgs({ replacesPtyId: 'pty-old' }), + worktreeId: folderId, + cwd: process.cwd() + }) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1) + expect(leafBinding()).toBe('pty-new') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({ + type: 'leaf', + leafId + }) + } + ) }) diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index a7118cb08b8..463660e00d7 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -30,6 +30,7 @@ export type PrepareCodexResumeHomeArgs = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean } export function prepareCodexResumeHome( @@ -48,7 +49,8 @@ export function prepareCodexResumeHome( preparation: prepareCodexSessionResume({ providerSession, target: args.target, - launchEnv: args.launchEnv + launchEnv: args.launchEnv, + ...(args.useSelectedAccount ? { useSelectedAccount: true } : {}) }) } } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 5e42310a45b..c3dfe1ae6e9 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index bc0e86ccd2d..a7ae10bc036 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -13,11 +13,12 @@ import { pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' +import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' /** The pane key a spawn names before preflight; null when it names no stable pane. */ -function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { +export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { const leafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null return typeof args.worktreeId === 'string' && @@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn( const initialStablePanePtyId = (() => { try { return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner )?.ptyId : undefined } catch { @@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn( } ctx.earlyStablePaneOwner = initialPaneKey && args.worktreeId - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner ) : null ctx.earlyWorktreeId = args.worktreeId diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 46fd8e714bb..41752886ab9 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, @@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) } ctx.spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + // Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach. + const resolveOwner = () => + excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner + ) + const stablePaneOwnerCandidate = resolveOwner() const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId if (expectedPtyId) { @@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { owner: stablePaneOwnerCandidate, worktreeId: args.worktreeId, connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + resolveOwner }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index d6abc77edce..83e8a77841c 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,6 +1,11 @@ import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation' import { ptySizes } from '../delivery/visibility-state' -import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin' +import { + beginPtyIpcSpawn, + resolveEarlyPaneKey, + resolveEarlyPaneSpawnReservationKey +} from './spawn-begin' +import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' import { preparePtyIpcQoderCommand } from './spawn-qoder-command' @@ -43,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } try { if (args.replacesPtyId !== undefined) { - // Why: stop before resolving the pane owner, so the spawn below finds a dead owner and - // launches fresh instead of reattaching the process this restart exists to replace. - await deps.stopReplacedPty(args.replacesPtyId) + ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, { + replacesPtyId: args.replacesPtyId, + paneKey: resolveEarlyPaneKey(args), + worktreeId: args.worktreeId, + connectionId: args.connectionId + }) } triggerPtySpawnPushTargetMaterialization(deps, args) const early = await beginPtyIpcSpawn(ctx) @@ -81,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg ) ctx.pendingRegistrationPtyId = null } + if (ctx.replacedPaneOwner) { + await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner) + } // Why: once the reservation is created, any later throw — // spawn failure, persist failure, or a post-spawn helper such as // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index b1240c4ec6e..d753ac4a6fd 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -8,6 +8,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing' import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' @@ -25,6 +26,7 @@ export type PtyIpcSpawnState = { earlyWorktreeId: string | undefined paneSpawnReservationKey: string | null paneSpawnReservation: PaneSpawnReservation | null + replacedPaneOwner: ReplacedPaneOwner | null finishTerminalInstall: () => void result: PtySpawnResult stablePaneOwner: StablePaneOwner | null @@ -103,6 +105,7 @@ export function createPtyIpcSpawnState( earlyWorktreeId: undefined, paneSpawnReservationKey: null, paneSpawnReservation: null, + replacedPaneOwner: null, finishTerminalInstall: () => {}, result: { id: '' }, stablePaneOwner: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index d1f340be17d..8e91799c429 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.test.ts b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts new file mode 100644 index 00000000000..681a48ba616 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' +import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement' + +vi.mock('../../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const worktreeId = 'repo-local::/fixture/local' +const tabId = 'restart-tab' + +function replaced(ptyId: string, hostId?: string): ReplacedPaneOwner { + return { ptyId, pane: { worktreeId, tabId, leafId: TEST_LEAF_1, hostId } } +} + +type FixtureStore = Awaited>['store'] + +/** The binding swap must survive the renderer's own pre-connect clear, which strict fences refuse. */ +async function expectSwapAfterRendererClear( + store: FixtureStore, + oldId: string, + newId: string, + hostId?: string +): Promise { + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBeUndefined() + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: newId, incarnationId: 'inc-new' } + expect( + await store.persistPtyBinding( + { ...binding, expectedBinding: { ptyId: oldId, incarnationId: 'inc-old' } }, + hostId + ) + ).toBe(false) + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced(oldId, hostId), hostId), + hostId + ) + ).toBe(true) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe(newId) +} + +it('swaps a local split pane whose renderer layout patch already dropped the stopped binding', async () => { + const { store } = await fixture() + const pane = { worktreeId, tabId } + await store.persistPtyBinding({ + ...pane, + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'inc-old' + }) + await store.persistPtyBinding({ ...pane, leafId: TEST_LEAF_2, ptyId: 'sibling' }) + const layouts = structuredClone(store.getWorkspaceSession().terminalLayoutsByTabId) + // The mounted restart clears only its own leaf before connecting; a partial map is honored. + delete layouts[tabId].ptyIdsByLeafId![TEST_LEAF_1] + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }) + await expectSwapAfterRendererClear(store, 'old', 'new') + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_2] + ).toBe('sibling') +}) + +it('swaps an SSH pane whose terminated lease let the renderer clear withdraw the binding', async () => { + const { store } = await fixture() + const hostId = 'ssh:restart' + const oldId = 'ssh:restart@@remote-old' + await store.persistPtyBinding( + { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: oldId, incarnationId: 'inc-old' }, + hostId + ) + store.upsertSshRemotePtyLease({ + targetId: 'restart', + ptyId: 'remote-old', + worktreeId, + tabId, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // What the replacement stop records (finishPtyShutdown). + store.markSshRemotePtyLease('restart', 'remote-old', 'terminated') + const layouts = structuredClone(store.getWorkspaceSession(hostId).terminalLayoutsByTabId) + layouts[tabId].ptyIdsByLeafId = {} + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }, hostId) + await expectSwapAfterRendererClear(store, oldId, 'ssh:restart@@remote-new', hostId) +}) + +it('refuses the swap when another owner holds the leaf', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'successor' }) + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'new' } + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced('old'), undefined) + ) + ).toBe(false) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe('successor') +}) diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.ts b/src/main/ipc/pty/pane/pane-owner-replacement.ts new file mode 100644 index 00000000000..0130ae8b702 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.ts @@ -0,0 +1,96 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner' + +/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */ +export type ReplacedPaneOwner = { + ptyId: string + pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null +} + +/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */ +export async function stopReplacedPaneOwner( + deps: { + runtime?: OrcaRuntimeService + store?: Store + stopReplacedPty: (id: string) => Promise + }, + args: { + replacesPtyId: string + paneKey: string | null + worktreeId: string | undefined + connectionId: string | null | undefined + } +): Promise { + const owner = resolveStablePaneOwner( + deps.runtime, + deps.store, + args.paneKey, + args.worktreeId, + args.connectionId + ) + if (owner && owner.ptyId !== args.replacesPtyId) { + throw new Error('terminal_pane_owner_changed') + } + await deps.stopReplacedPty(args.replacesPtyId) + const pane = args.paneKey ? parsePaneKey(args.paneKey) : null + return { + ptyId: args.replacesPtyId, + pane: + pane && args.worktreeId + ? { + worktreeId: args.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + hostId: args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + } + : null + } +} + +/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */ +export function excludeReplacedPaneOwner( + owner: StablePaneOwner | null, + replaced: ReplacedPaneOwner | null +): StablePaneOwner | null { + return owner && owner.ptyId === replaced?.ptyId ? null : owner +} + +/** + * The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf + * still naming the stopped id, or one the renderer already cleared; any other owner wins. + */ +export function swapReplacedPaneBinding( + store: Store, + binding: PersistPtyBindingArgs, + replaced: ReplacedPaneOwner, + hostId: string | undefined +): () => PersistPtyBindingArgs | null { + return () => { + const bound = + store.getWorkspaceSession(hostId).terminalLayoutsByTabId?.[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + // Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab, SSH lease ended). + return bound === undefined || bound === replaced.ptyId ? binding : null + } +} + +/** Best effort: a failed replacement must not leave the stopped id bound for the remount to reattach. */ +export async function releaseStoppedPaneBinding( + store: Store | undefined, + replaced: ReplacedPaneOwner +): Promise { + if (!store || !replaced.pane) { + return + } + const { hostId, ...pane } = replaced.pane + try { + await store.retirePtyBinding({ ...pane, ptyId: replaced.ptyId }, hostId) + } catch (error) { + console.warn('[pty] could not clear the stopped pane binding after a failed restart:', error) + } +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c90544f7502..c75d238dee8 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -4,6 +4,8 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' +import { clearReplacedPaneBinding } from './replaced-pane-binding' import type { PtyBindingSourceExpectation } from './store' @@ -67,6 +69,59 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } + /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ + async retirePtyBinding( + binding: Pick, + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] + const resolved = resolveHostId(hostId) + const publish = (session: WorkspaceSessionState): void => { + if (resolved === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + runtime.dirtyProfileStateDomains?.add( + resolved === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' + ) + } + return runtime.runDurableMutation(() => { + const session = sessions.getWorkspaceSession(resolved) + const currentId = + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + if (!currentId) { + return { value: true, persist: 'if-dirty' } + } + if (currentId !== binding.ptyId) { + return { value: false, persist: false } + } + if (!session.tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId)) { + return { value: false, persist: false } + } + const before = cloneWorkspaceSessionState(session) + const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + // Host retirement must not run renderer snapshot repair, which would put the old binding back. + publish(retired) + const staged = cloneWorkspaceSessionState(retired) + return { + value: true, + rollback: () => { + publish( + rollbackWorkspaceSessionAfterFailedAsyncWrite( + before, + staged, + sessions.getWorkspaceSession(resolved) + ) + ) + } + } + }) + } + async persistPtyBinding( input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), hostId?: string | null diff --git a/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts new file mode 100644 index 00000000000..2f34af1fb0d --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts @@ -0,0 +1,83 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'restart-tab', + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'old-incarnation' +} + +it.each([undefined, 'ssh:restart'])( + 'durably retires only the process binding on %s', + async (hostId) => { + const { store, readState } = await fixture() + await store.persistPtyBinding(binding, hostId) + const before = structuredClone(store.getWorkspaceSession(hostId)) + expect(await store.retirePtyBinding(binding, hostId)).toBe(true) + const retired = store.getWorkspaceSession(hostId) + expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({ + ...before.terminalLayoutsByTabId[binding.tabId], + ptyIdsByLeafId: {} + }) + expect(retired.tabsByWorktree[binding.worktreeId]).toEqual( + before.tabsByWorktree[binding.worktreeId].map((tab) => + tab.id === binding.tabId ? { ...tab, ptyId: null } : tab + ) + ) + const durable = hostId + ? readState().workspaceSessionsByHostId?.[hostId] + : readState().workspaceSession + expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({}) + await store.persistPtyBinding( + { ...binding, ptyId: 'new', incarnationId: 'new-incarnation' }, + hostId + ) + expect(await store.retirePtyBinding(binding, hostId)).toBe(false) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('new') + } +) + +it('clears the stopped id whatever incarnation the binding carries', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' }) + expect(await store.retirePtyBinding(binding)).toBe(true) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual( + {} + ) +}) + +it('restores the old binding after a known save failure and allows another attempt', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('old') + expect(await store.retirePtyBinding(binding)).toBe(true) +}) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts new file mode 100644 index 00000000000..5cefc332677 --- /dev/null +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -0,0 +1,33 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { RetiredTerminalSurface } from '../../runtime/mobile-session-terminal-retirement' + +/** The caller fences the old owner; a restart keeps the pane and removes only its process binding. */ +export function clearReplacedPaneBinding( + session: WorkspaceSessionState, + surface: RetiredTerminalSurface +): WorkspaceSessionState { + const layout = session.terminalLayoutsByTabId[surface.parentTabId] + if (!layout || layout.ptyIdsByLeafId?.[surface.leafId] !== surface.ptyId) { + return session + } + const ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } + delete ptyIdsByLeafId[surface.leafId] + const terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey } + delete terminalPtyIncarnationsByPaneKey[`${surface.parentTabId}:${surface.leafId}`] + return { + ...session, + terminalPtyIncarnationsByPaneKey, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [surface.parentTabId]: { ...layout, ptyIdsByLeafId } + }, + tabsByWorktree: { + ...session.tabsByWorktree, + [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => + tab.id === surface.parentTabId && tab.ptyId === surface.ptyId + ? { ...tab, ptyId: null } + : tab + ) + } + } +} diff --git a/src/main/startup/codex-account-restart-resume.test.ts b/src/main/startup/codex-account-restart-resume.test.ts new file mode 100644 index 00000000000..197c9281edb --- /dev/null +++ b/src/main/startup/codex-account-restart-resume.test.ts @@ -0,0 +1,117 @@ +import { dirname, join } from 'node:path' +import { linkSync, readFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const homes = vi.hoisted(() => ({ original: '', selected: '', system: '' })) +vi.mock('electron', () => ({ app: { getPath: () => homes.system } })) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [homes.original, homes.selected], + resolveSelectedHostAccountCodexHomePathForResume: () => homes.selected || null + }, + store: { getSettings: () => ({}) } + } +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn() + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: vi.fn(), + awaitRealHomeCodexHookTrust: vi.fn() +})) +vi.mock('../codex/codex-home-paths', () => ({ + getCodexSessionBackfillStateDirPath: () => join(homes.system, 'backfill'), + getSystemCodexHomePath: () => homes.system, + getOrcaManagedCodexHomePath: () => join(homes.system, 'legacy') +})) +vi.mock('../codex/codex-config-mirror', () => ({ ensureCodexDaemonSocketGuard: vi.fn() })) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +let root: string +let transcriptPath: string +const sessionId = 'abcdef00-1234-4321-9999-cafecafecafe' +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-account-restart-')) + homes.original = join(root, 'codex-accounts', 'old', 'home') + homes.selected = join(root, 'codex-accounts', 'new', 'home') + homes.system = join(root, 'system') + const relativePath = join('sessions', '2026', '10', '05', `rollout-${sessionId}.jsonl`) + transcriptPath = join(homes.original, relativePath) + const bridged = join(homes.selected, relativePath) + mkdirSync(dirname(transcriptPath), { recursive: true }) + mkdirSync(dirname(bridged), { recursive: true }) + writeFileSync(transcriptPath, '{"type":"session_meta"}\n') + linkSync(transcriptPath, bridged) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('resumes an explicit account restart in the selected home using the bridged transcript', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.selected }) +}) + +it('keeps ordinary automatic restores pinned to their original account', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' } + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.original }) +}) + +it('materializes a rollout when the account bridge has not caught up', async () => { + rmSync(join(homes.selected, 'sessions'), { recursive: true }) + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.selected }) + expect(readFileSync(transcriptPath.replace(homes.original, homes.selected), 'utf8')).toBe( + readFileSync(transcriptPath, 'utf8') + ) +}) + +it('refuses a conflicting target rollout instead of restarting the old account', async () => { + const target = transcriptPath.replace(homes.original, homes.selected) + rmSync(target) + writeFileSync(target, 'another session') + await expect( + prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + ).rejects.toThrow('different rollout') + expect(readFileSync(target, 'utf8')).toBe('another session') +}) + +it('uses the system home when an explicit restart follows deselection', async () => { + homes.selected = '' + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.system }) +}) + +it('leaves WSL preparation on the execution host', async () => { + expect( + await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + useSelectedAccount: true + }) + ).toBeNull() +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index f01d17610d7..5feb61df417 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -3,7 +3,10 @@ import type { AgentProviderSessionMetadata } from '../../shared/agent-session-re import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' -import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { + prepareCodexAccountRestartResume, + prepareLegacySharedCodexSessionResume +} from '../codex/codex-legacy-session-resume' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { @@ -20,6 +23,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store @@ -66,12 +70,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { } ) } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. + // A credential-read refusal must never fall back to the old account. if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { throw error } @@ -81,7 +80,16 @@ export async function prepareCodexSessionResumeForLaunch(args: { error ) } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + const resumeHome = args.useSelectedAccount + ? await prepareCodexAccountRestartResume({ + sourceHome: sessionSource.homePath, + transcriptPath: sessionSource.transcriptPath, + targetHome: selectedAccountCodexHome ?? systemHomePath, + systemCodexHomePath: systemHomePath + }) + : migrated.useRealCodexHome + ? systemHomePath + : sessionSource.homePath const isSystemHome = normalizeRuntimePathForComparison(resumeHome) === normalizeRuntimePathForComparison(systemHomePath) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 1578a09fc24..6c3228ae10b 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -16,6 +16,7 @@ import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { buildCodexAccountRestartStartup } from '@/lib/codex-account-restart-startup' import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart' import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -127,13 +128,10 @@ function locateCodexPane(state: AppState, ptyId: string): LocatedCodexPane | nul function getWorkspacePath(state: AppState, worktreeId: string): string | null { const parsed = parseWorkspaceKey(worktreeId) - if (parsed?.type === 'folder') { - return ( - (state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId) - ?.folderPath ?? null - ) - } - return getWorktreeMapFromState(state).get(worktreeId)?.path ?? null + return parsed?.type === 'folder' + ? (state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId) + ?.folderPath ?? null) + : (getWorktreeMapFromState(state).get(worktreeId)?.path ?? null) } function buildPaneIdentityEnv( @@ -183,6 +181,12 @@ async function executeDetachedCodexPaneRestart( return } const { worktreeId, tab, leafId } = located + const startup = buildCodexAccountRestartStartup({ + worktreeId, + tabId: tab.id, + leafId, + shellOverride: tab.shellOverride + }) const workspacePath = getWorkspacePath(state, worktreeId) const cwd = tab.startupCwd ?? workspacePath ?? undefined @@ -214,10 +218,8 @@ async function executeDetachedCodexPaneRestart( rows: 24, ...(cwd ? { cwd } : {}), cwdFallback: 'worktree', - env: buildPaneIdentityEnv(state, worktreeId, tab.id, leafId), - command: CODEX_ACCOUNT_RESTART_STARTUP.command, - startupCommandDelivery: CODEX_ACCOUNT_RESTART_STARTUP.startupCommandDelivery, - launchAgent: CODEX_ACCOUNT_RESTART_STARTUP.launchAgent, + ...startup, + env: { ...startup.env, ...buildPaneIdentityEnv(state, worktreeId, tab.id, leafId) }, worktreeId, tabId: tab.id, leafId, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index d5722b214a5..c57c1bd9e9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -254,6 +254,30 @@ describe('connectPanePty', () => { expect(resetWriteCall as number).toBeLessThan(tailWriteCall as number) }) + it('answers cursor queries while an account notice blocks user input', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState.codexRestartNoticeByPtyId = { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + const pane = createPane(1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixtures implement the connection's pane, manager and dependency contract. + const args = [pane, createManager(1), createDeps()] as unknown as Parameters< + typeof connectPanePty + > + const binding = connectPanePty(...args) + transport.getPtyId.mockReturnValue('pty-live') + mockStoreState.codexRestartNoticeByPtyId = { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + sendTerminalInputThroughPane(pane, '\x1b[1;1R') + sendTerminalInputThroughPane(pane, 'do work\r') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[1;1R') + expect(transport.sendInput).not.toHaveBeenCalledWith('do work\r', expect.anything()) + binding.dispose() + }) + it('routes native onData query replies through sendInputImmediate, typed input through sendInput (#7329)', async () => { // Why this test: the mock aliases sendInputImmediate to sendInput, so other tests can't tell them apart; this pins the routing decision. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts index 6d045232a36..7ee801dddfb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -18,6 +18,11 @@ import { startDeferredSessionReattach } from './deferred-session-reattach-connec import type { ConnectPanePtySession } from './connect-pane-pty-session' export function runDeferredSessionReattachChoice(session: ConnectPanePtySession): void { + // An explicit replacement owns its startup; saved recovery state must not substitute another agent. + if (session.pendingReplacedPtyId) { + session.startFreshSpawn() + return + } // Why: re-read session IDs here rather than at connect scheduling — cleanup during the caller's one-frame gap could otherwise reattach a dead session. const restoredPtyId = session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index aad2384bb27..cbaf010940c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -46,6 +46,11 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { return } const currentPtyId = session.transport.getPtyId() + // Protocol replies keep the TUI responsive while an account notice blocks typing. + if (isTerminalQueryReply(data)) { + session.sendDesktopQueryReplyImmediate(data) + return + } // Why: after a Codex account switch, the runtime auth has already moved to // the newly selected account. Stale panes must not keep sending input until // they restart, or work can execute under the wrong account while the UI @@ -78,19 +83,6 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { // disabling the mode would permanently silence focus events on resume. return } - // Why: xterm answers CPR/DSR/DA queries natively through this same onData - // stream (mixed with keystrokes). Those replies are latency-critical — a - // querying program reads them in raw mode with a short timeout — so send - // them immediately, skipping the remote input debounce that would corrupt - // them (#7329). They are not user input, so they bypass intent inference and - // activity recording below. No pending-intent guard: the only intents are - // plain-escape (`\x1b`) and ctrl-c (`\x03`), neither of which can satisfy - // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), - // so a real keystroke never reaches this branch. - if (isTerminalQueryReply(data)) { - session.sendDesktopQueryReplyImmediate(data) - return - } // Why after the query-reply branch: device replies are not user input and // must always reach the shell, or a program querying during reattach hangs. // Why at all: a replaced endpoint reattaches to a fresh shell, so the tail diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts index 0d3a8c5f382..f8c105bf971 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect } from 'react' import { useAppStore } from '../../store' -import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart' +import { buildCodexAccountRestartStartup } from '@/lib/codex-account-restart-startup' import { makePaneKey } from '../../../../shared/stable-pane-id' import { connectPanePty } from './pty-connection' import { bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' @@ -59,15 +59,22 @@ export function useTerminalPaneProcessExitActions(controller: TerminalPaneCloseC } = controller const handleRestartCodexPane = useCallback( - ( - paneId: number, - restartStartup: PtyConnectionDeps['startup'] = CODEX_ACCOUNT_RESTART_STARTUP - ) => { + (paneId: number, restartStartup?: PtyConnectionDeps['startup']) => { const manager = managerRef.current const pane = manager?.getPanes().find((candidate) => candidate.id === paneId) if (!manager || !pane) { return } + const startup = + restartStartup ?? + buildCodexAccountRestartStartup({ + tabId, + worktreeId, + leafId: pane.leafId, + shellOverride: useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.shellOverride + }) const transport = paneTransportsRef.current.get(paneId) const panePtyBinding = panePtyBindingsRef.current.get(paneId) const existingPtyId = transport?.getPtyId() @@ -88,7 +95,7 @@ export function useTerminalPaneProcessExitActions(controller: TerminalPaneCloseC tabId, worktreeId, cwd, - startup: restartStartup, + startup, ...(replacesPtyId ? { replacesPtyId } : {}), mountFollowsTerminalPark: false, paneTransportsRef, diff --git a/src/renderer/src/lib/codex-account-restart-startup.test.ts b/src/renderer/src/lib/codex-account-restart-startup.test.ts new file mode 100644 index 00000000000..dd0a7ffb78a --- /dev/null +++ b/src/renderer/src/lib/codex-account-restart-startup.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type * as localPreflightContext from '@/lib/local-preflight-context' +import { buildCodexAccountRestartStartup } from './codex-account-restart-startup' + +let projectRuntimeContext: ProjectExecutionRuntimeResolution | undefined + +vi.mock('@/lib/local-preflight-context', async (importOriginal) => ({ + ...(await importOriginal()), + getLocalProjectExecutionRuntimeContext: () => projectRuntimeContext +})) + +const TAB_ID = 'tab-1' +const LEAF_ID = '0195f2ce-1111-4000-8000-000000000001' +const WORKTREE_ID = 'wt1' +const SESSION_ID = '01a006a6-1d07-70a1-bad5-f9110d5845c0' + +function seedAgentStatus(entry: Partial | null): void { + const paneKey = makePaneKey(TAB_ID, LEAF_ID) + useAppStore.setState({ + agentStatusByPaneKey: entry + ? { + [paneKey]: { + paneKey, + tabId: TAB_ID, + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + ...entry + } + } + : {}, + agentLaunchConfigByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + }) +} + +const build = (): ReturnType => + buildCodexAccountRestartStartup({ tabId: TAB_ID, leafId: LEAF_ID, worktreeId: WORKTREE_ID }) + +describe('buildCodexAccountRestartStartup', () => { + beforeEach(() => { + seedAgentStatus(null) + projectRuntimeContext = undefined + }) + + it('names the session so the relaunch continues the conversation', () => { + seedAgentStatus({ + agentType: 'codex', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + + const startup = build() + + expect(startup.command).toContain('resume') + expect(startup.command).toContain(SESSION_ID) + expect(startup.resumeProviderSession?.id).toBe(SESSION_ID) + }) + + it('keeps the account-switch marks that make main repin the launch home', () => { + seedAgentStatus({ + agentType: 'codex', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + + const startup = build() + + expect(startup.launchAgent).toBe('codex') + expect(startup.startupCommandDelivery).toBe('shell-ready') + }) + + it('falls back to a bare relaunch when the pane has no Codex session to name', () => { + const startup = build() + + expect(startup.command).toBe('codex') + expect(startup.resumeProviderSession).toBeUndefined() + }) + + it('uses the persisted record when the live status entry is gone', () => { + seedAgentStatus(null) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [makePaneKey(TAB_ID, LEAF_ID)]: { + paneKey: makePaneKey(TAB_ID, LEAF_ID), + worktreeId: WORKTREE_ID, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID } + } + } + }) + + const startup = build() + + expect(startup.command).toContain(SESSION_ID) + expect(startup.resumeProviderSession?.id).toBe(SESSION_ID) + }) + + it('keeps the bare relaunch for a resolved WSL runtime', () => { + seedAgentStatus({ + agentType: 'codex', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + projectRuntimeContext = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + + const startup = build() + + expect(startup.command).toBe('codex') + expect(startup.resumeProviderSession).toBeUndefined() + }) + + it('falls back when the pane is running another agent', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + + expect(build().command).toBe('codex') + }) + it('does not resume another agent’s sleeping session when Codex has no session yet', () => { + seedAgentStatus({ agentType: 'codex' }) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [makePaneKey(TAB_ID, LEAF_ID)]: { + paneKey: makePaneKey(TAB_ID, LEAF_ID), + worktreeId: WORKTREE_ID, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + agent: 'claude', + providerSession: { key: 'session_id', id: SESSION_ID } + } + } + }) + expect(build().resumeProviderSession).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/codex-account-restart-startup.ts b/src/renderer/src/lib/codex-account-restart-startup.ts new file mode 100644 index 00000000000..cf2d09a180d --- /dev/null +++ b/src/renderer/src/lib/codex-account-restart-startup.ts @@ -0,0 +1,98 @@ +import { useAppStore } from '@/store' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../shared/tui-agent-launch-defaults' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../shared/agent-session-resume' +import { CODEX_ACCOUNT_RESTART_STARTUP } from './codex-session-restart' + +export type CodexAccountRestartStartup = { + command: string + startupCommandDelivery: 'shell-ready' + launchAgent: 'codex' + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata +} + +// Capture the session before restart clears the old pane’s status. +export function buildCodexAccountRestartStartup(args: { + tabId: string + leafId: string + worktreeId: string + shellOverride?: string +}): CodexAccountRestartStartup { + const state = useAppStore.getState() + const paneKey = makePaneKey(args.tabId, args.leafId) + const entry = state.agentStatusByPaneKey[paneKey] + const sleeping = state.sleepingAgentSessionsByPaneKey[paneKey] + const agentType = entry?.agentType ?? sleeping?.agent + if (agentType !== 'codex') { + return CODEX_ACCOUNT_RESTART_STARTUP + } + const providerSession = + normalizeAgentProviderSession(entry?.providerSession) ?? + normalizeAgentProviderSession( + sleeping?.agent === 'codex' ? sleeping.providerSession : undefined + ) + if (!providerSession) { + return CODEX_ACCOUNT_RESTART_STARTUP + } + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, args.worktreeId) + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return CODEX_ACCOUNT_RESTART_STARTUP + } + const worktree = state.getKnownWorktreeById(args.worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const launchConfig = + (entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? + (sleeping?.agent === 'codex' ? sleeping.launchConfig : undefined) + const resumeTarget = resolveAgentResumeLaunchTarget({ + projectRuntime, + connectionId: repo?.connectionId, + executionHostId: getExecutionHostIdForWorktree(state, args.worktreeId), + worktreePath: worktree?.path, + terminalWindowsShell: state.settings?.terminalWindowsShell, + tabShellOverride: args.shellOverride + }) + const startupPlan = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + launchConfig !== undefined + ? launchConfig.agentArgs + : resolveTuiAgentLaunchArgs('codex', state.settings?.agentDefaultArgs), + agentEnv: + launchConfig !== undefined + ? launchConfig.agentEnv + : resolveTuiAgentLaunchEnv('codex', state.settings?.agentDefaultEnv), + ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + ...(launchConfig?.ompResumeFilePath + ? { ompResumeFilePath: launchConfig.ompResumeFilePath } + : {}), + platform: resumeTarget.platform, + shell: resumeTarget.shell + }) + if (!startupPlan) { + return CODEX_ACCOUNT_RESTART_STARTUP + } + return { + ...CODEX_ACCOUNT_RESTART_STARTUP, + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + // Why it rides along: main only repins the launch home for a spawn that + // names the session it is resuming, so dropping this drops the account move. + resumeProviderSession: providerSession + } +} diff --git a/tests/e2e/terminal-account-restart.unit.test.ts b/tests/e2e/terminal-account-restart.unit.test.ts new file mode 100644 index 00000000000..23bc83dd480 --- /dev/null +++ b/tests/e2e/terminal-account-restart.unit.test.ts @@ -0,0 +1,458 @@ +import { PtyBindingPersistenceOperations } from '../../src/main/persistence/loading-store/pty-binding-persistence' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from '../../src/main/ipc/pty-ipc-test-harness' +import { TerminalKilledError } from '../../src/main/daemon/daemon-pty-lifecycle-errors' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { registerPtyHandlers, setLocalPtyProvider } from '../../src/main/ipc/pty' +import { TerminalIntentionalStops } from '../../src/main/runtime/terminal-intentional-stops' + +vi.mock('electron', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.electronModuleMock()) +) +vi.mock('fs', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.fsModuleMock()) +) +vi.mock('node-pty', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()) +) +vi.mock('node:child_process', async (importOriginal) => + (await import('../../src/main/ipc/pty-ipc-mock-registry')).childProcessModuleMock( + await importOriginal() + ) +) +vi.mock('../../src/main/opencode/hook-service', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../../src/main/mimo/hook-service', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../../src/main/agent-hooks/server', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../../src/main/pi/titlebar-extension-service', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../../src/main/pwsh', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.pwshModuleMock()) +) +vi.mock('../../src/main/wsl', async (importOriginal) => + (await import('../../src/main/ipc/pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../../src/main/telemetry/client', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../../src/main/telemetry/classify-error', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../../src/main/cli/linux-terminal-orca-cli-shim', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../../src/main/memory/pty-registry', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../../src/main/agent-hooks/migration-unsupported-pty-state', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => + m.migrationUnsupportedPtyModuleMock() + ) +) +vi.mock('../../src/main/codex/codex-pane-account-registry', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => + m.codexPaneAccountRegistryModuleMock() + ) +) +vi.mock('../../src/main/codex/codex-state-db-backfill-recovery', () => + import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => + m.codexBackfillRecoveryModuleMock() + ) +) + +const worktreeId = 'wt-1' +const cwd = '/tmp/restart' +const tabId = 'tab-1' +const leafId = '11111111-1111-4111-8111-111111111111' +const paneKey = makePaneKey(tabId, leafId) + +type RestartHarness = ReturnType + +function registerWithFakes( + mainWindow: PtyIpcSuiteFixtures['mainWindow'], + runtime: RestartHarness['runtime'], + store: RestartHarness['store'] +): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn and kill read only the window, runtime and store members these fakes define. + const args = [ + mainWindow, + runtime, + undefined, + undefined, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...args) +} + +function installRestartHarness( + options: { shutdownFails?: boolean; shutdownGate?: Promise } = {} +) { + let oldSessionAlive = true + const control = { shutdownFails: options.shutdownFails ?? false } + const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { + if (!spawnOptions.attachOnly) { + return { id: 'pty-new', incarnationId: 'inc-new' } + } + if (!oldSessionAlive) { + throw new TerminalKilledError('pty-old') + } + return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } + }) + const shutdown = vi.fn(async () => { + await options.shutdownGate + if (control.shutdownFails) { + throw new Error('daemon unreachable') + } + oldSessionAlive = false + }) + const provider = { + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. + setLocalPtyProvider(provider as unknown as Parameters[0]) + let session = { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-old' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const state = { workspaceSession: session, workspaceSessionsByHostId: {} } + store.getWorkspaceSession.mockImplementation((hostId?: string) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each fake partition has the same session shape. + const partitions = state.workspaceSessionsByHostId as Record + return (hostId && partitions[hostId]) || state.workspaceSession + }) + const bindingRuntime = { + state, + dirtyProfileStateDomains: new Set(), + runDurableMutation: store.runDurableMutation, + writeTimer: null, + pendingWrite: null, + quitFlushStarted: false, + writeGeneration: 0, + lastDurableWriteGeneration: 0 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< + typeof PtyBindingPersistenceOperations + > + const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) + const storeWithRetirement = Object.assign(store, { + persistPtyBinding: bindingOperations.persistPtyBinding.bind(bindingOperations), + retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations) + }) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + markPtyStopRequested: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), + preAllocateHandleForPty: vi.fn(() => 'term-restart'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() + } + return { providerSpawn, shutdown, store: storeWithRetirement, runtime, control } +} + +import { + createPane, + createManager +} from '../../src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from '../../src/renderer/src/components/terminal-pane/pty-connection-test-deps' +import { createInitialStoreState } from '../../src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from '../../src/renderer/src/components/terminal-pane/pty-connection-test-environment' +import { installIpcPtyWindow } from '../../src/renderer/src/components/terminal-pane/pty-transport-test-harness' +import type { StoreState } from '../../src/renderer/src/components/terminal-pane/pty-connection-test-store-state' +import type * as React from 'react' +import type { PtyTransport } from '../../src/renderer/src/components/terminal-pane/pty-transport-types' + +let rendererState: StoreState +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => rendererState, + subscribe: () => () => {} + } +})) +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void) => effect(), + useLayoutEffect: (effect: () => void) => effect() +})) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) +const { requestTerminalPaneRecovery } = vi.hoisted(() => ({ + requestTerminalPaneRecovery: vi.fn(async () => true) +})) +vi.mock( + '../../src/renderer/src/components/terminal-pane/terminal-pane-recovery', + async (importOriginal) => ({ + ...(await importOriginal>()), + requestTerminalPaneRecovery + }) +) + +describe('account restart through renderer connection and host spawn', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('replaces a tombstoned owner while its persisted pane binding has not yet been cleared', async () => { + const host = installRestartHarness() + registerWithFakes(mainWindow, host.runtime, host.store) + rendererState = createInitialStoreState(() => rendererState) + rendererState.tabsByWorktree[worktreeId][0].ptyId = 'pty-old' + rendererState.ptyIdsByTabId[tabId] = ['pty-old'] + rendererState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: 'another-agents-session' }, + state: 'waiting', + prompt: '', + capturedAt: 1, + updatedAt: 1 + } + rendererState.terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'pty-old' + await installTerminalTestGlobals() + installIpcPtyWindow(window, {}) + window.api.pty.claimViewport = vi.fn() + const spawnErrors: unknown[] = [] + vi.mocked(window.api.pty.spawn).mockImplementation(async (args) => { + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registered pty:spawn returns the preload's spawn response. + return (await handlers.get('pty:spawn')!(null, args)) as Awaited< + ReturnType + > + } catch (error) { + spawnErrors.push(error) + throw error + } + }) + const { createIpcPtyTransport } = + await import('../../src/renderer/src/components/terminal-pane/pty-transport') + const { useTerminalPaneProcessExitActions } = + await import('../../src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions') + const oldTransport = createIpcPtyTransport({ worktreeId, tabId, leafId }) + oldTransport.attach({ existingPtyId: 'pty-old', callbacks: {} }) + const pane = createPane(1) + const manager = createManager(1) + manager.getPanes.mockReturnValue([pane]) + const transports = new Map([[1, oldTransport]]) + const bindings = new Map void }>() + const deps = buildPaneConnectionDeps(() => rendererState, { + tabId, + worktreeId, + cwd, + paneTransportsRef: { current: transports }, + clearTabPtyId: vi.fn(() => { + rendererState.tabsByWorktree[worktreeId][0].ptyId = null + rendererState.ptyIdsByTabId[tabId] = [] + }) + }) + const controller = { + ...deps, + managerRef: { current: manager }, + panePtyBindingsRef: { current: bindings }, + savedLayout: { ptyIdsByLeafId: { [leafId]: 'pty-old' } }, + pendingCodexPaneRestartIds: { 'pty-old': true }, + consumePendingCodexPaneRestart: vi.fn(() => true), + clearCodexRestartNotice: vi.fn(), + suppressPtyExit: vi.fn(), + setTerminalError: vi.fn(), + setTerminalErrorsByPaneId: vi.fn(), + setPaneProcessExitsByPaneId: vi.fn(), + executeClosePane: vi.fn(), + handlePaneProcessDied: vi.fn(), + showRestoredSessionBanner: vi.fn(), + onPtyErrorClearedRef: { current: vi.fn() }, + onPtyRecoveryStateRef: { current: vi.fn() } + } + useTerminalPaneProcessExitActions( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: existing connection fixture supplies restart callbacks; DOM/terminal rendering is simulated. + controller as unknown as Parameters[0] + ) + try { + await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + host.shutdown, + JSON.stringify(vi.mocked(window.api.pty.spawn).mock.calls) + ).toHaveBeenCalled() + ) + await vi.waitFor(() => + expect(transports.get(1)?.getPtyId(), String(spawnErrors[0])).toBe('pty-new') + ) + expect(window.api.pty.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + replacesPtyId: 'pty-old', + command: 'codex', + launchAgent: 'codex', + startupCommandDelivery: 'shell-ready' + }) + ) + expect(vi.mocked(window.api.pty.spawn).mock.calls[0][0]).not.toHaveProperty( + 'resumeProviderSession' + ) + expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() + // The replacement's own bind swapped the host's persisted pane binding. + expect( + host.store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] + ).toBe('pty-new') + } finally { + for (const binding of bindings.values()) { + binding.dispose() + } + for (const transport of transports.values()) { + transport.detach?.({ preserveExitObserver: false }) + } + } + }) + it.each([false, true])( + 'paired restart retains the old owner (host authority: %s)', + async (hostAuthority) => { + const host = installRestartHarness() + registerWithFakes(mainWindow, host.runtime, host.store) + rendererState = createInitialStoreState(() => rendererState) + await installTerminalTestGlobals() + const runtimeCall = vi.fn( + async (args: { method: string; params?: Record }) => { + if (args.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: hostAuthority ? ['agent-session.host-authority.v1'] : [] + }, + _meta: { runtimeId: 'fake-host' } + } + } + if (args.method !== 'terminal.create' && args.method !== 'terminal.createAgentSession') { + throw new Error(`Unexpected host method: ${args.method}`) + } + // Both host create routes ultimately use stable-pane adoption; keep that real here. + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + tabId, + leafId, + worktreeId, + command: 'codex', + launchAgent: 'codex', + startupCommandDelivery: 'shell-ready' + }) + return { + id: 'create', + ok: true, + result: { + terminal: { handle: 'term-old', worktreeId, title: null, surface: 'background' } + }, + _meta: { runtimeId: 'fake-host' } + } + } + ) + const subscribe = vi.fn( + async (_args: unknown, callbacks: { onResponse: (value: unknown) => void }) => { + queueMicrotask(() => + callbacks.onResponse({ + id: 'stream', + ok: true, + result: { type: 'ready' }, + _meta: { runtimeId: 'fake-host' } + }) + ) + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + } + ) + Object.assign(window.api, { runtimeEnvironments: { call: runtimeCall, subscribe } }) + const { createRemoteRuntimePtyTransport } = + await import('../../src/renderer/src/components/terminal-pane/remote-runtime-pty-transport') + const { releasePaneTransportForRestart } = + await import('../../src/renderer/src/components/terminal-pane/pane-restart-transport-handoff') + const { CODEX_ACCOUNT_RESTART_STARTUP } = + await import('../../src/renderer/src/lib/codex-session-restart') + const options = { worktreeId, tabId, leafId, ...CODEX_ACCOUNT_RESTART_STARTUP } + const old = createRemoteRuntimePtyTransport('fake-host', options) + await old.connect({ url: '', callbacks: {} }) + expect(old.getPtyId()).toBe('remote:fake-host@@term-old') + const replacesPtyId = releasePaneTransportForRestart(old) + expect(replacesPtyId).toBeNull() + const replacement = createRemoteRuntimePtyTransport('fake-host', options) + try { + await replacement.connect({ url: '', callbacks: {} }) + expect(replacement.getPtyId()).toBe('remote:fake-host@@term-old') + expect(host.shutdown).not.toHaveBeenCalled() + expect(host.providerSpawn.mock.calls.every(([options]) => options.attachOnly)).toBe(true) + expect( + runtimeCall.mock.calls.filter(([args]) => args.method.startsWith('terminal.create')) + ).toHaveLength(2) + } finally { + replacement.detach?.() + } + } + ) +})