mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
co-authored by
Brennan Benson
itisvincent
rayim
Claude Fable 5
parent
b3328390f9
commit
40f9e7a3fc
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
useSelectedAccount?: boolean
|
||||
}) => Promise<CodexSessionResumePreparation | null>
|
||||
|
||||
export type CodexHomePtySpawnedLifecycleArgs = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+13
-6
@@ -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?.()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user