fix(codex): resume account switches without blanking the terminal (#25485)

fix(codex): restart account switches without blanking or repinning the pane

Integrate the atomic pane replacement from #24350 and adapt the explicit
conversation resume from #14877. Keep terminal protocol replies flowing
while account notices block user input. Ordinary restores retain provenance.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: itisvincent <vincentcgamer@gmail.com>
Co-authored-by: rayim <rayim@fxy.global>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-05 01:51:49 -07:00
committed by GitHub
co-authored by Brennan Benson itisvincent rayim Claude Fable 5
parent b3328390f9
commit 40f9e7a3fc
26 changed files with 1752 additions and 95 deletions
@@ -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<string> {
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.
+374 -20
View File
@@ -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<typeof installRestartHarness>
type FakeSession = {
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
terminalLayoutsByTabId: Record<
string,
{
root: { type: 'leaf'; leafId: string }
activeLeafId: string
expandedLeafId: null
ptyIdsByLeafId: Record<string, string>
}
>
terminalPtyIncarnationsByPaneKey: Record<string, string>
}
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<void> } = {}
) {
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<typeof setLocalPtyProvider>[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 <T>(mutate: () => { value: T }) => mutate().value),
persistPtyBinding: vi.fn(),
runDurableMutation: vi.fn(
async <T>(
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<string, FakeSession>
} = { 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<unknown>
) => Promise<void>
): Promise<void> {
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<typeof registerSshPtyProvider>[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<typeof setLocalPtyProvider>[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
})
}
)
})
+3 -1
View File
@@ -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 } : {})
})
}
}
+1
View File
@@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => Promise<CodexSessionResumePreparation | null>
export type CodexHomePtySpawnedLifecycleArgs = {
+20 -13
View File
@@ -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
+8 -3
View File
@@ -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<P
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
origin: spawnCommitBindingOrigin(ctx.result)
}
const persisted = args.connectionId
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
: await ctx.deps.store.persistPtyBinding(binding)
const hostId = args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
const input = ctx.replacedPaneOwner
? swapReplacedPaneBinding(ctx.deps.store, binding, ctx.replacedPaneOwner, hostId)
: binding
const persisted = hostId
? await ctx.deps.store.persistPtyBinding(input, hostId)
: await ctx.deps.store.persistPtyBinding(input)
if (persisted === false) {
throw new Error('terminal_pane_owner_changed')
}
+2 -1
View File
@@ -39,7 +39,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
launchAgent: args.launchAgent,
providerSession: args.resumeProviderSession,
target: ctx.codexSelectionTarget,
launchEnv: ctx.baseEnv
launchEnv: ctx.baseEnv,
...(args.replacesPtyId ? { useSelectedAccount: true } : {})
})
ctx.codexResumeLaunch = codexResumePreparation
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
+15 -15
View File
@@ -10,6 +10,7 @@ import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import { agentKindSchema } from '../../../../shared/telemetry-events'
import { normalizeNodePtySpawnError } from '../provider/liveness'
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
import { deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
@@ -23,13 +24,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
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<void> {
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
+15 -4
View File
@@ -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
+3
View File
@@ -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,
+1
View File
@@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = {
providerSession?: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => PreparedCodexResumeHome | null
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
resolveCodexResumeLaunch: (
@@ -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<ReturnType<typeof fixture>>['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<void> {
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')
})
@@ -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<void>
},
args: {
replacesPtyId: string
paneKey: string | null
worktreeId: string | undefined
connectionId: string | null | undefined
}
): Promise<ReplacedPaneOwner> {
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<void> {
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)
}
}
@@ -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<PersistPtyBindingArgs, 'worktreeId' | 'tabId' | 'leafId' | 'ptyId'>,
hostId?: string | null
): Promise<boolean> {
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
@@ -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)
})
@@ -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
)
}
}
}
@@ -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()
})
@@ -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<CodexSessionResumePreparation | null> {
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)
@@ -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,
@@ -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')
@@ -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
@@ -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
@@ -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,
@@ -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<typeof localPreflightContext>()),
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<AgentStatusEntry> | 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<typeof buildCodexAccountRestartStartup> =>
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()
})
})
@@ -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<string, string>
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
}
}
@@ -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<typeof installRestartHarness>
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<typeof registerPtyHandlers>
registerPtyHandlers(...args)
}
function installRestartHarness(
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
) {
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<typeof setLocalPtyProvider>[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 <T>(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<string, typeof session>
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<typeof React>()),
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<Record<string, unknown>>()),
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<typeof window.api.pty.spawn>
>
} 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<number, PtyTransport>([[1, oldTransport]])
const bindings = new Map<number, { dispose: () => 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<typeof useTerminalPaneProcessExitActions>[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<string, unknown> }) => {
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?.()
}
}
)
})