mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +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 }
|
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
|
* Repins a per-account resume to the selected account's home, or null to keep
|
||||||
* the session's own home.
|
* 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 { 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 { makePaneKey } from '../../shared/stable-pane-id'
|
||||||
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
|
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
|
||||||
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
|
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
|
||||||
@@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212'
|
|||||||
const paneKey = makePaneKey(tabId, leafId)
|
const paneKey = makePaneKey(tabId, leafId)
|
||||||
|
|
||||||
type RestartHarness = ReturnType<typeof installRestartHarness>
|
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(
|
function registerWithFakes(
|
||||||
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
|
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
|
||||||
@@ -78,13 +109,15 @@ function installRestartHarness(
|
|||||||
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
|
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
|
||||||
) {
|
) {
|
||||||
let oldSessionAlive = true
|
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 }) => {
|
const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => {
|
||||||
if (!spawnOptions.attachOnly) {
|
if (!spawnOptions.attachOnly) {
|
||||||
|
boundAtFreshLaunch.push(leafBinding())
|
||||||
return { id: 'pty-new', incarnationId: 'inc-new' }
|
return { id: 'pty-new', incarnationId: 'inc-new' }
|
||||||
}
|
}
|
||||||
if (!oldSessionAlive) {
|
if (!oldSessionAlive) {
|
||||||
throw new SessionNotFoundError('pty-old')
|
throw new TerminalKilledError('pty-old')
|
||||||
}
|
}
|
||||||
return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true }
|
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.
|
// 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])
|
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
|
||||||
let session = {
|
let session = seedSession('pty-old')
|
||||||
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 = {
|
const store = {
|
||||||
getWorkspaceSession: vi.fn(() => session),
|
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
|
||||||
setWorkspaceSession: vi.fn((next) => {
|
setWorkspaceSession: vi.fn((next) => {
|
||||||
session = next
|
session = next
|
||||||
}),
|
}),
|
||||||
flushOrThrow: vi.fn(),
|
flushOrThrow: vi.fn(),
|
||||||
runDurableMutation: vi.fn(async <T>(mutate: () => { value: T }) => mutate().value),
|
runDurableMutation: vi.fn(
|
||||||
persistPtyBinding: 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),
|
getFolderWorkspace: vi.fn(() => undefined),
|
||||||
getFolderWorkspaces: vi.fn(() => []),
|
getFolderWorkspaces: vi.fn(() => []),
|
||||||
getProjectGroups: vi.fn(() => []),
|
getProjectGroups: vi.fn(() => []),
|
||||||
getRepos: 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 = {
|
const runtime = {
|
||||||
setPtyController: vi.fn(),
|
setPtyController: vi.fn(),
|
||||||
resolveTerminalPane: vi.fn(() => {
|
resolveTerminalPane: vi.fn(() => {
|
||||||
@@ -165,7 +242,18 @@ function installRestartHarness(
|
|||||||
onPtyData: vi.fn(),
|
onPtyData: vi.fn(),
|
||||||
intentionalPtyStops: new TerminalIntentionalStops()
|
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 } = {}) {
|
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')).toHaveLength(1)
|
||||||
expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart')
|
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
|
providerSession?: AgentProviderSessionMetadata
|
||||||
target: CodexAccountSelectionTarget
|
target: CodexAccountSelectionTarget
|
||||||
launchEnv?: NodeJS.ProcessEnv
|
launchEnv?: NodeJS.ProcessEnv
|
||||||
|
useSelectedAccount?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareCodexResumeHome(
|
export function prepareCodexResumeHome(
|
||||||
@@ -48,7 +49,8 @@ export function prepareCodexResumeHome(
|
|||||||
preparation: prepareCodexSessionResume({
|
preparation: prepareCodexSessionResume({
|
||||||
providerSession,
|
providerSession,
|
||||||
target: args.target,
|
target: args.target,
|
||||||
launchEnv: args.launchEnv
|
launchEnv: args.launchEnv,
|
||||||
|
...(args.useSelectedAccount ? { useSelectedAccount: true } : {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
|
|||||||
providerSession: AgentProviderSessionMetadata
|
providerSession: AgentProviderSessionMetadata
|
||||||
target: CodexAccountSelectionTarget
|
target: CodexAccountSelectionTarget
|
||||||
launchEnv?: NodeJS.ProcessEnv
|
launchEnv?: NodeJS.ProcessEnv
|
||||||
|
useSelectedAccount?: boolean
|
||||||
}) => Promise<CodexSessionResumePreparation | null>
|
}) => Promise<CodexSessionResumePreparation | null>
|
||||||
|
|
||||||
export type CodexHomePtySpawnedLifecycleArgs = {
|
export type CodexHomePtySpawnedLifecycleArgs = {
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import {
|
|||||||
pendingRuntimePaneCreatesByOwnerKey
|
pendingRuntimePaneCreatesByOwnerKey
|
||||||
} from '../pane/spawn-reservation'
|
} from '../pane/spawn-reservation'
|
||||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||||
|
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||||
import type { PtyIpcSpawnState } from './spawn-state'
|
import type { PtyIpcSpawnState } from './spawn-state'
|
||||||
import type { PtySpawnIpcArgs } from './spawn-types'
|
import type { PtySpawnIpcArgs } from './spawn-types'
|
||||||
|
|
||||||
/** The pane key a spawn names before preflight; null when it names no stable pane. */
|
/** 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 =
|
const leafId =
|
||||||
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
||||||
return typeof args.worktreeId === 'string' &&
|
return typeof args.worktreeId === 'string' &&
|
||||||
@@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn(
|
|||||||
const initialStablePanePtyId = (() => {
|
const initialStablePanePtyId = (() => {
|
||||||
try {
|
try {
|
||||||
return !args.connectionId && initialPaneKey
|
return !args.connectionId && initialPaneKey
|
||||||
? resolveStablePaneOwner(
|
? excludeReplacedPaneOwner(
|
||||||
ctx.deps.runtime,
|
resolveStablePaneOwner(
|
||||||
ctx.deps.store,
|
ctx.deps.runtime,
|
||||||
initialPaneKey,
|
ctx.deps.store,
|
||||||
args.worktreeId,
|
initialPaneKey,
|
||||||
args.connectionId
|
args.worktreeId,
|
||||||
|
args.connectionId
|
||||||
|
),
|
||||||
|
ctx.replacedPaneOwner
|
||||||
)?.ptyId
|
)?.ptyId
|
||||||
: undefined
|
: undefined
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn(
|
|||||||
}
|
}
|
||||||
ctx.earlyStablePaneOwner =
|
ctx.earlyStablePaneOwner =
|
||||||
initialPaneKey && args.worktreeId
|
initialPaneKey && args.worktreeId
|
||||||
? resolveStablePaneOwner(
|
? excludeReplacedPaneOwner(
|
||||||
ctx.deps.runtime,
|
resolveStablePaneOwner(
|
||||||
ctx.deps.store,
|
ctx.deps.runtime,
|
||||||
initialPaneKey,
|
ctx.deps.store,
|
||||||
args.worktreeId,
|
initialPaneKey,
|
||||||
args.connectionId
|
args.worktreeId,
|
||||||
|
args.connectionId
|
||||||
|
),
|
||||||
|
ctx.replacedPaneOwner
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
ctx.earlyWorktreeId = args.worktreeId
|
ctx.earlyWorktreeId = args.worktreeId
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg
|
|||||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
||||||
import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home'
|
import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home'
|
||||||
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
|
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
|
||||||
|
import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement'
|
||||||
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
|
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
|
||||||
import {
|
import {
|
||||||
pendingByPaneKey,
|
pendingByPaneKey,
|
||||||
@@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<P
|
|||||||
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
|
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
|
||||||
origin: spawnCommitBindingOrigin(ctx.result)
|
origin: spawnCommitBindingOrigin(ctx.result)
|
||||||
}
|
}
|
||||||
const persisted = args.connectionId
|
const hostId = args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||||
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
|
const input = ctx.replacedPaneOwner
|
||||||
: await ctx.deps.store.persistPtyBinding(binding)
|
? 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) {
|
if (persisted === false) {
|
||||||
throw new Error('terminal_pane_owner_changed')
|
throw new Error('terminal_pane_owner_changed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
|
|||||||
launchAgent: args.launchAgent,
|
launchAgent: args.launchAgent,
|
||||||
providerSession: args.resumeProviderSession,
|
providerSession: args.resumeProviderSession,
|
||||||
target: ctx.codexSelectionTarget,
|
target: ctx.codexSelectionTarget,
|
||||||
launchEnv: ctx.baseEnv
|
launchEnv: ctx.baseEnv,
|
||||||
|
...(args.replacesPtyId ? { useSelectedAccount: true } : {})
|
||||||
})
|
})
|
||||||
ctx.codexResumeLaunch = codexResumePreparation
|
ctx.codexResumeLaunch = codexResumePreparation
|
||||||
? await ctx.deps.resolveCodexResumeLaunch(args.command, 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 { agentKindSchema } from '../../../../shared/telemetry-events'
|
||||||
import { normalizeNodePtySpawnError } from '../provider/liveness'
|
import { normalizeNodePtySpawnError } from '../provider/liveness'
|
||||||
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
|
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
|
||||||
|
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||||
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
|
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
|
||||||
import { deletePtyOwnership } from '../provider/ownership-state'
|
import { deletePtyOwnership } from '../provider/ownership-state'
|
||||||
import { ptySizes } from '../delivery/visibility-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.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle)
|
||||||
}
|
}
|
||||||
ctx.spawnTiming.mark('options')
|
ctx.spawnTiming.mark('options')
|
||||||
const stablePaneOwnerCandidate = resolveStablePaneOwner(
|
// Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach.
|
||||||
ctx.deps.runtime,
|
const resolveOwner = () =>
|
||||||
ctx.deps.store,
|
excludeReplacedPaneOwner(
|
||||||
ctx.reservationPaneKey,
|
resolveStablePaneOwner(
|
||||||
args.worktreeId,
|
ctx.deps.runtime,
|
||||||
args.connectionId
|
ctx.deps.store,
|
||||||
)
|
ctx.reservationPaneKey,
|
||||||
|
args.worktreeId,
|
||||||
|
args.connectionId
|
||||||
|
),
|
||||||
|
ctx.replacedPaneOwner
|
||||||
|
)
|
||||||
|
const stablePaneOwnerCandidate = resolveOwner()
|
||||||
const expectedPtyId =
|
const expectedPtyId =
|
||||||
stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
|
stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
|
||||||
if (expectedPtyId) {
|
if (expectedPtyId) {
|
||||||
@@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
|||||||
owner: stablePaneOwnerCandidate,
|
owner: stablePaneOwnerCandidate,
|
||||||
worktreeId: args.worktreeId,
|
worktreeId: args.worktreeId,
|
||||||
connectionId: args.connectionId,
|
connectionId: args.connectionId,
|
||||||
resolveOwner: () =>
|
resolveOwner
|
||||||
resolveStablePaneOwner(
|
|
||||||
ctx.deps.runtime,
|
|
||||||
ctx.deps.store,
|
|
||||||
ctx.reservationPaneKey,
|
|
||||||
args.worktreeId,
|
|
||||||
args.connectionId
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
ctx.result = stablePaneSpawn.result
|
ctx.result = stablePaneSpawn.result
|
||||||
ctx.stablePaneOwner = stablePaneSpawn.owner
|
ctx.stablePaneOwner = stablePaneSpawn.owner
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation'
|
import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation'
|
||||||
import { ptySizes } from '../delivery/visibility-state'
|
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 { preparePtyIpcSpawnPreflight } from './spawn-preflight'
|
||||||
import { assemblePtyIpcSpawnEnv } from './spawn-env'
|
import { assemblePtyIpcSpawnEnv } from './spawn-env'
|
||||||
import { preparePtyIpcQoderCommand } from './spawn-qoder-command'
|
import { preparePtyIpcQoderCommand } from './spawn-qoder-command'
|
||||||
@@ -43,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (args.replacesPtyId !== undefined) {
|
if (args.replacesPtyId !== undefined) {
|
||||||
// Why: stop before resolving the pane owner, so the spawn below finds a dead owner and
|
ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, {
|
||||||
// launches fresh instead of reattaching the process this restart exists to replace.
|
replacesPtyId: args.replacesPtyId,
|
||||||
await deps.stopReplacedPty(args.replacesPtyId)
|
paneKey: resolveEarlyPaneKey(args),
|
||||||
|
worktreeId: args.worktreeId,
|
||||||
|
connectionId: args.connectionId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
triggerPtySpawnPushTargetMaterialization(deps, args)
|
triggerPtySpawnPushTargetMaterialization(deps, args)
|
||||||
const early = await beginPtyIpcSpawn(ctx)
|
const early = await beginPtyIpcSpawn(ctx)
|
||||||
@@ -81,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
|||||||
)
|
)
|
||||||
ctx.pendingRegistrationPtyId = null
|
ctx.pendingRegistrationPtyId = null
|
||||||
}
|
}
|
||||||
|
if (ctx.replacedPaneOwner) {
|
||||||
|
await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner)
|
||||||
|
}
|
||||||
// Why: once the reservation is created, any later throw —
|
// Why: once the reservation is created, any later throw —
|
||||||
// spawn failure, persist failure, or a post-spawn helper such as
|
// spawn failure, persist failure, or a post-spawn helper such as
|
||||||
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
|
// 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 { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume'
|
||||||
import type { StablePaneOwner } from '../pane/stable-owner'
|
import type { StablePaneOwner } from '../pane/stable-owner'
|
||||||
import type { PaneSpawnReservation } from '../pane/spawn-reservation'
|
import type { PaneSpawnReservation } from '../pane/spawn-reservation'
|
||||||
|
import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||||
import { localProvider } from '../provider/registry'
|
import { localProvider } from '../provider/registry'
|
||||||
import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export type PtyIpcSpawnState = {
|
|||||||
earlyWorktreeId: string | undefined
|
earlyWorktreeId: string | undefined
|
||||||
paneSpawnReservationKey: string | null
|
paneSpawnReservationKey: string | null
|
||||||
paneSpawnReservation: PaneSpawnReservation | null
|
paneSpawnReservation: PaneSpawnReservation | null
|
||||||
|
replacedPaneOwner: ReplacedPaneOwner | null
|
||||||
finishTerminalInstall: () => void
|
finishTerminalInstall: () => void
|
||||||
result: PtySpawnResult
|
result: PtySpawnResult
|
||||||
stablePaneOwner: StablePaneOwner | null
|
stablePaneOwner: StablePaneOwner | null
|
||||||
@@ -103,6 +105,7 @@ export function createPtyIpcSpawnState(
|
|||||||
earlyWorktreeId: undefined,
|
earlyWorktreeId: undefined,
|
||||||
paneSpawnReservationKey: null,
|
paneSpawnReservationKey: null,
|
||||||
paneSpawnReservation: null,
|
paneSpawnReservation: null,
|
||||||
|
replacedPaneOwner: null,
|
||||||
finishTerminalInstall: () => {},
|
finishTerminalInstall: () => {},
|
||||||
result: { id: '' },
|
result: { id: '' },
|
||||||
stablePaneOwner: null,
|
stablePaneOwner: null,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = {
|
|||||||
providerSession?: AgentProviderSessionMetadata
|
providerSession?: AgentProviderSessionMetadata
|
||||||
target: CodexAccountSelectionTarget
|
target: CodexAccountSelectionTarget
|
||||||
launchEnv?: NodeJS.ProcessEnv
|
launchEnv?: NodeJS.ProcessEnv
|
||||||
|
useSelectedAccount?: boolean
|
||||||
}) => PreparedCodexResumeHome | null
|
}) => PreparedCodexResumeHome | null
|
||||||
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
|
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
|
||||||
resolveCodexResumeLaunch: (
|
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 type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||||
import { rollbackFailedPtyBinding } from './pty-binding-write-rollback'
|
import { rollbackFailedPtyBinding } from './pty-binding-write-rollback'
|
||||||
import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields'
|
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'
|
import type { PtyBindingSourceExpectation } from './store'
|
||||||
|
|
||||||
@@ -67,6 +69,59 @@ export class PtyBindingPersistenceOperations {
|
|||||||
this[ptyBindingPersistenceOperationsContext] = { runtime, sessions }
|
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(
|
async persistPtyBinding(
|
||||||
input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null),
|
input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null),
|
||||||
hostId?: string | 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 { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||||
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
|
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
|
||||||
import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation'
|
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 { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
|
||||||
import { codexHookService } from '../codex/hook-service'
|
import { codexHookService } from '../codex/hook-service'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +23,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
|||||||
providerSession: AgentProviderSessionMetadata
|
providerSession: AgentProviderSessionMetadata
|
||||||
target: CodexAccountSelectionTarget
|
target: CodexAccountSelectionTarget
|
||||||
launchEnv?: NodeJS.ProcessEnv
|
launchEnv?: NodeJS.ProcessEnv
|
||||||
|
useSelectedAccount?: boolean
|
||||||
}): Promise<CodexSessionResumePreparation | null> {
|
}): Promise<CodexSessionResumePreparation | null> {
|
||||||
const runtimeHome = state.codexRuntimeHome
|
const runtimeHome = state.codexRuntimeHome
|
||||||
const store = state.store
|
const store = state.store
|
||||||
@@ -66,12 +70,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Why: this launch path pins CODEX_HOME to the account that OWNS the
|
// A credential-read refusal must never fall back to the old account.
|
||||||
// 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.
|
|
||||||
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
|
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,16 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
|||||||
error
|
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 =
|
const isSystemHome =
|
||||||
normalizeRuntimePathForComparison(resumeHome) ===
|
normalizeRuntimePathForComparison(resumeHome) ===
|
||||||
normalizeRuntimePathForComparison(systemHomePath)
|
normalizeRuntimePathForComparison(systemHomePath)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useAppStore } from '@/store'
|
|||||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||||
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
|
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 { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
|
||||||
import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane'
|
import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane'
|
||||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
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 {
|
function getWorkspacePath(state: AppState, worktreeId: string): string | null {
|
||||||
const parsed = parseWorkspaceKey(worktreeId)
|
const parsed = parseWorkspaceKey(worktreeId)
|
||||||
if (parsed?.type === 'folder') {
|
return parsed?.type === 'folder'
|
||||||
return (
|
? (state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId)
|
||||||
(state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId)
|
?.folderPath ?? null)
|
||||||
?.folderPath ?? null
|
: (getWorktreeMapFromState(state).get(worktreeId)?.path ?? null)
|
||||||
)
|
|
||||||
}
|
|
||||||
return getWorktreeMapFromState(state).get(worktreeId)?.path ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPaneIdentityEnv(
|
function buildPaneIdentityEnv(
|
||||||
@@ -183,6 +181,12 @@ async function executeDetachedCodexPaneRestart(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { worktreeId, tab, leafId } = located
|
const { worktreeId, tab, leafId } = located
|
||||||
|
const startup = buildCodexAccountRestartStartup({
|
||||||
|
worktreeId,
|
||||||
|
tabId: tab.id,
|
||||||
|
leafId,
|
||||||
|
shellOverride: tab.shellOverride
|
||||||
|
})
|
||||||
|
|
||||||
const workspacePath = getWorkspacePath(state, worktreeId)
|
const workspacePath = getWorkspacePath(state, worktreeId)
|
||||||
const cwd = tab.startupCwd ?? workspacePath ?? undefined
|
const cwd = tab.startupCwd ?? workspacePath ?? undefined
|
||||||
@@ -214,10 +218,8 @@ async function executeDetachedCodexPaneRestart(
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
cwdFallback: 'worktree',
|
cwdFallback: 'worktree',
|
||||||
env: buildPaneIdentityEnv(state, worktreeId, tab.id, leafId),
|
...startup,
|
||||||
command: CODEX_ACCOUNT_RESTART_STARTUP.command,
|
env: { ...startup.env, ...buildPaneIdentityEnv(state, worktreeId, tab.id, leafId) },
|
||||||
startupCommandDelivery: CODEX_ACCOUNT_RESTART_STARTUP.startupCommandDelivery,
|
|
||||||
launchAgent: CODEX_ACCOUNT_RESTART_STARTUP.launchAgent,
|
|
||||||
worktreeId,
|
worktreeId,
|
||||||
tabId: tab.id,
|
tabId: tab.id,
|
||||||
leafId,
|
leafId,
|
||||||
|
|||||||
@@ -254,6 +254,30 @@ describe('connectPanePty', () => {
|
|||||||
expect(resetWriteCall as number).toBeLessThan(tailWriteCall as number)
|
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 () => {
|
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.
|
// 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')
|
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'
|
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||||
|
|
||||||
export function runDeferredSessionReattachChoice(session: ConnectPanePtySession): void {
|
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.
|
// 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 =
|
const restoredPtyId =
|
||||||
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
|
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentPtyId = session.transport.getPtyId()
|
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
|
// 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
|
// 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
|
// 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.
|
// disabling the mode would permanently silence focus events on resume.
|
||||||
return
|
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
|
// 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.
|
// 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
|
// 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 { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
import { useAppStore } from '../../store'
|
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 { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||||
import { connectPanePty } from './pty-connection'
|
import { connectPanePty } from './pty-connection'
|
||||||
import { bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
|
import { bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||||
@@ -59,15 +59,22 @@ export function useTerminalPaneProcessExitActions(controller: TerminalPaneCloseC
|
|||||||
} = controller
|
} = controller
|
||||||
|
|
||||||
const handleRestartCodexPane = useCallback(
|
const handleRestartCodexPane = useCallback(
|
||||||
(
|
(paneId: number, restartStartup?: PtyConnectionDeps['startup']) => {
|
||||||
paneId: number,
|
|
||||||
restartStartup: PtyConnectionDeps['startup'] = CODEX_ACCOUNT_RESTART_STARTUP
|
|
||||||
) => {
|
|
||||||
const manager = managerRef.current
|
const manager = managerRef.current
|
||||||
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
|
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
|
||||||
if (!manager || !pane) {
|
if (!manager || !pane) {
|
||||||
return
|
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 transport = paneTransportsRef.current.get(paneId)
|
||||||
const panePtyBinding = panePtyBindingsRef.current.get(paneId)
|
const panePtyBinding = panePtyBindingsRef.current.get(paneId)
|
||||||
const existingPtyId = transport?.getPtyId()
|
const existingPtyId = transport?.getPtyId()
|
||||||
@@ -88,7 +95,7 @@ export function useTerminalPaneProcessExitActions(controller: TerminalPaneCloseC
|
|||||||
tabId,
|
tabId,
|
||||||
worktreeId,
|
worktreeId,
|
||||||
cwd,
|
cwd,
|
||||||
startup: restartStartup,
|
startup,
|
||||||
...(replacesPtyId ? { replacesPtyId } : {}),
|
...(replacesPtyId ? { replacesPtyId } : {}),
|
||||||
mountFollowsTerminalPark: false,
|
mountFollowsTerminalPark: false,
|
||||||
paneTransportsRef,
|
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