fix(codex): execute accepted account-switch restarts for unmounted panes (#12146)

* fix(codex): execute accepted account-switch restarts for unmounted panes

Accepting the Codex account-switch restart prompt queued every awaiting pane
but only mounted TerminalPane instances executed the queue, so background-tab
and parked panes stayed input-blocked on the old account with no prompt.

- Add a detached store-level driver (codex-detached-pane-restart) that
  kill-and-respawns any queued pane no mounted transport claims, rebinding
  tab/layout state so a later mount reattaches to the replacement PTY.
- Re-offer the prompt when a detached execution fails, and clear the notice
  when the pane is gone, so input is never silently blocked.
- Sweep restored PTY ids at startup so stale panes in never-mounted tabs are
  re-offered after an app restart.
- Carry launchAgent codex on the restart respawn so it waits for managed-auth
  readiness and records the pane's launch account.

* fix(codex): fence detached restart ownership races

* fix(codex): unblock detached restart handoffs

* fix(codex): contain detached restart cleanup

* fix(codex): detach restart progress from cleanup

* fix(codex): avoid detached restart size wait

* fix(terminal): scope Codex restart prompt to pane

* fix(codex): contain detached restart sweep failures
This commit is contained in:
Brennan Benson 2026-08-02 18:54:31 -07:00 committed by GitHub
parent a20165a43d
commit bcf7d1df1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1377 additions and 720 deletions

View File

@ -123,6 +123,8 @@ import {
shouldPersistWorkspaceSession
} from './lib/workspace-session'
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
import { sweepRestoredCodexPanesForStaleAccounts } from './lib/codex-stale-pane-sweep'
import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler'
import { buildActiveViewUnloadPatch } from './lib/active-view-persist'
import {
buildWorkspaceSessionHostSnapshots,
@ -1090,6 +1092,9 @@ function App(): React.JSX.Element {
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
// Why here: reconnect just published restored PTY ids; sweeping them now
// re-offers stale Codex panes whose tabs never mount this session.
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
syncZoomCSSVar()
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
@ -1210,6 +1215,8 @@ function App(): React.JSX.Element {
}
}, [])
useEffect(() => installCodexDetachedPaneRestartExecutor(), [])
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {

View File

@ -1,70 +1,11 @@
import { useEffect, useId, useMemo, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useEffect, useId, useRef } from 'react'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '../store'
import { selectCodexRestartInputs } from './codex-restart-chip-inputs'
import { translate } from '@/i18n/i18n'
import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus'
import { buildCodexRestartNoticeKey } from './codex-restart-notice-key'
import { awaitsCodexRestartAnswer } from './codex-restart-notice-state'
import type { CodexRestartNotice } from '../store/slices/terminals'
const EMPTY_TABS: { id: string }[] = []
export function collectStalePtyIdsForTabs({
tabs,
ptyIdsByTabId,
codexRestartNoticeByPtyId
}: {
tabs: { id: string }[]
ptyIdsByTabId: Record<string, string[]>
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice | undefined>
}): string[] {
// Why: an already-requested restart runs when its pane next mounts. Keeping it
// out of the prompt is what stops the panel from sticking on a worktree whose
// stale pane is parked or deferred and cannot answer the request yet. A
// dismissed notice is likewise answered — it only survives as launch-account
// memory.
return tabs.flatMap((tab) =>
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) =>
awaitsCodexRestartAnswer(codexRestartNoticeByPtyId[ptyId])
)
)
}
export function collectStaleWorktreePtyIds({
tabsByWorktree,
ptyIdsByTabId,
codexRestartNoticeByPtyId,
worktreeId
}: {
tabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
codexRestartNoticeByPtyId: Record<string, CodexRestartNotice | undefined>
worktreeId: string
}): string[] {
return collectStalePtyIdsForTabs({
tabs: tabsByWorktree[worktreeId] ?? EMPTY_TABS,
ptyIdsByTabId,
codexRestartNoticeByPtyId
})
}
export function dismissStaleWorktreePtyIds(
staleWorktreePtyIds: string[],
dismissCodexRestartNotices: (ptyIds: string[]) => void,
forgetLaunchAccounts: (ptyIds: string[]) => void
): void {
// Why: restart notices are stored per PTY, but the workspace host presents
// one shared prompt. Dismissing all matching PTY notices keeps every pane in
// that worktree consistent with the dismissal.
dismissCodexRestartNotices(staleWorktreePtyIds)
// Why: notices are renderer-only, so without dropping the on-disk launch
// record the startup sweep re-raises this exact prompt — and re-blocks the
// pane's input — after every app restart the user already answered.
forgetLaunchAccounts(staleWorktreePtyIds)
}
function isInsideHiddenTree(element: HTMLElement): boolean {
return element.closest('[aria-hidden="true"], [hidden], [inert]') !== null
@ -78,58 +19,39 @@ type RestartNotice = {
export default function CodexRestartChip({
isVisible = true,
worktreeId
ptyId,
shouldFocus = false
}: {
isVisible?: boolean
worktreeId: string
ptyId: string
shouldFocus?: boolean
}): React.JSX.Element | null {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
// Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId
// on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on
// pty teardown), so subscribe to them only while a restart notice actually
// exists. Otherwise this per-worktree chip re-rendered on every pty event to
// compute "no notice → render nothing". See codex-restart-chip-inputs.
const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore(
useShallow(selectCodexRestartInputs)
)
const staleWorktreePtyIds = useMemo(
() =>
collectStalePtyIdsForTabs({
tabs,
ptyIdsByTabId,
codexRestartNoticeByPtyId
}),
[codexRestartNoticeByPtyId, ptyIdsByTabId, tabs]
)
const restartNotice = staleWorktreePtyIds[0]
? codexRestartNoticeByPtyId[staleWorktreePtyIds[0]]
: undefined
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
const dismissCodexRestartNotices = useAppStore((s) => s.dismissCodexRestartNotices)
const noticeKey = restartNotice ? buildCodexRestartNoticeKey(restartNotice) : null
if (staleWorktreePtyIds.length === 0 || !restartNotice) {
// Why: one O(1) selector per mounted pane stays idle when unrelated PTY maps
// churn and prevents a worktree-wide scan for every split pane.
const restartNotice = useAppStore((state) => state.codexRestartNoticeByPtyId[ptyId])
if (!restartNotice || !awaitsCodexRestartAnswer(restartNotice)) {
return null
}
const handleRestart = (): void => {
queueCodexPaneRestarts(staleWorktreePtyIds)
useAppStore.getState().queueCodexPaneRestarts([ptyId])
}
const handleDismiss = (): void => {
dismissStaleWorktreePtyIds(staleWorktreePtyIds, dismissCodexRestartNotices, (ptyIds) => {
void window.api.codexAccounts.forgetStalePanes({ ptyIds }).catch((err: unknown) => {
console.warn('Failed to forget dismissed Codex pane accounts:', err)
})
useAppStore.getState().dismissCodexRestartNotices([ptyId])
// Why: notices are renderer-only, so the persisted launch record must be
// cleared for this pane or the startup sweep re-raises its answered prompt.
void window.api.codexAccounts.forgetStalePanes({ ptyIds: [ptyId] }).catch((err: unknown) => {
console.warn('Failed to forget dismissed Codex pane account:', err)
})
}
return (
<LoudRestartOverlay
isVisible={isVisible}
noticeKey={noticeKey}
noticeKey={`${ptyId}:${buildCodexRestartNoticeKey(restartNotice)}`}
restartNotice={restartNotice}
shouldFocus={shouldFocus}
onDismiss={handleDismiss}
onRestart={handleRestart}
/>
@ -140,12 +62,14 @@ function LoudRestartOverlay({
isVisible,
noticeKey,
restartNotice,
shouldFocus,
onDismiss,
onRestart
}: {
isVisible: boolean
noticeKey: string | null
restartNotice: RestartNotice
shouldFocus: boolean
onDismiss: () => void
onRestart: () => void
}): React.JSX.Element {
@ -156,15 +80,10 @@ function LoudRestartOverlay({
// Why: move focus to the card only when the user isn't typing elsewhere;
// unconditional autoFocus would steal keys from an active composer.
//
// The target is the dialog itself, never Restart. This card is mounted per
// WORKTREE (a sibling of the split layout), so its focus scope is every
// terminal in the worktree — a notice for one pane lands while the user may
// be typing in a different, perfectly healthy pane. With Restart focused, the
// next Space/Enter of their prose queued a restart of every stale pane here
// and destroyed those sessions. Focus must not land on a destructive action
// the user never aimed at. See #10863.
// The target is the dialog itself, never Restart, so the next Space/Enter
// cannot destroy a session the user did not explicitly choose. See #10863.
useEffect(() => {
if (!isVisible) {
if (!isVisible || !shouldFocus) {
return
}
const root = rootRef.current
@ -175,7 +94,7 @@ function LoudRestartOverlay({
if (shouldFocusMobileDriverAction(document.activeElement, document.body, paneScope)) {
root.focus()
}
}, [isVisible, noticeKey])
}, [isVisible, noticeKey, shouldFocus])
return (
<div

View File

@ -119,7 +119,6 @@ import {
import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
import CodexRestartChip from './CodexRestartChip'
import {
findActivityTerminalPortal,
useActivityTerminalPortals,
@ -2400,7 +2399,6 @@ function Terminal(): React.JSX.Element | null {
}
aria-hidden={!isVisible}
>
<CodexRestartChip isVisible={isVisible} worktreeId={workspace.id} />
{(tabsByWorktree[workspace.id] ?? [])
.filter((tab) =>
shouldMountBackgroundWorktreeTab(
@ -2656,7 +2654,6 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
inert={!isVisible}
aria-hidden={!isVisible}
>
<CodexRestartChip isVisible={isVisible} worktreeId={worktreeId} />
<TabGroupSplitLayout
layout={layout}
worktreeId={worktreeId}

View File

@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import { shallow } from 'zustand/shallow'
import {
EMPTY_CODEX_RESTART_INPUTS,
selectCodexRestartInputs,
type CodexRestartInputsState
} from './codex-restart-chip-inputs'
describe('selectCodexRestartInputs', () => {
it('returns the frozen empty bundle while no restart notice exists', () => {
const state: CodexRestartInputsState = {
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {}
}
expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS)
// Churning EITHER map while no notice exists must NOT change the selected
// reference, so a useShallow subscription skips the re-render. This covers
// the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty.
const churnedPty: CodexRestartInputsState = {
...state,
ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] }
}
const churnedNotice: CodexRestartInputsState = {
ptyIdsByTabId: state.ptyIdsByTabId,
codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread
}
expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS)
expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS)
expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe(
true
)
})
it('re-idles once every surviving notice is answered', () => {
// Why: answered notices linger as launch-account memory for the pty's whole
// life, so existence alone would keep every chip subscribed to pty churn.
const dismissed: CodexRestartInputsState = {
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', dismissed: true }
}
}
expect(selectCodexRestartInputs(dismissed)).toBe(EMPTY_CODEX_RESTART_INPUTS)
const alsoUnanswered: CodexRestartInputsState = {
...dismissed,
codexRestartNoticeByPtyId: {
...dismissed.codexRestartNoticeByPtyId,
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
}
expect(selectCodexRestartInputs(alsoUnanswered)).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
})
it('exposes both live maps the instant a restart notice exists', () => {
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const codexRestartNoticeByPtyId = {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId }
const selected = selectCodexRestartInputs(state)
// Live references pass straight through so the stale-pty memo + notice lookup derive fully.
expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId)
expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId)
expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
})
it('shallow-changes only when a live map reference changes while a notice exists', () => {
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const s1: CodexRestartInputsState = {
ptyIdsByTabId,
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
}
const r1 = selectCodexRestartInputs(s1)
expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true)
const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } }
expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false)
})
})

View File

@ -1,41 +0,0 @@
import { awaitsCodexRestartAnswer } from './codex-restart-notice-state'
import type { AppState } from '@/store/types'
export type CodexRestartInputsState = Pick<AppState, 'ptyIdsByTabId' | 'codexRestartNoticeByPtyId'>
export type CodexRestartInputs = CodexRestartInputsState
// Why: shared frozen bundle returned while no Codex restart notice exists — the
// overwhelmingly common case. CodexRestartChip is mounted once per worktree
// Terminal (visible AND hidden-measurable) and once per split group, and reads
// these two maps only to find panes carrying a restart notice, which appear only
// on a Codex account switch. BOTH churn on unrelated pty lifecycle:
// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and
// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the
// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either
// re-rendered every mounted chip on that churn. Gate both behind notice-existence
// so idle chips keep the same reference and stop reacting; behavior is identical
// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be
// mutated.
export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({
ptyIdsByTabId: {},
codexRestartNoticeByPtyId: {}
})
/**
* Expose the pty and restart-notice maps to the chip only while at least one
* Codex restart notice is live; otherwise return a stable frozen bundle so a
* `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn.
*/
export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs {
// Why: answered notices linger as launch-account memory for the life of the
// pty, so gating on mere existence would leave every chip permanently
// subscribed to that churn after a single dismissal.
if (!Object.values(s.codexRestartNoticeByPtyId).some(awaitsCodexRestartAnswer)) {
return EMPTY_CODEX_RESTART_INPUTS
}
return {
ptyIdsByTabId: s.ptyIdsByTabId,
codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId
}
}

View File

@ -1,486 +0,0 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import CodexRestartChip, {
collectStalePtyIdsForTabs,
collectStaleWorktreePtyIds,
dismissStaleWorktreePtyIds
} from './CodexRestartChip'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
useAppStore.setState(useAppStore.getInitialState(), true)
})
describe('CodexRestartChip helpers', () => {
it('collects all stale PTY ids for tabs in a worktree', () => {
expect(
collectStaleWorktreePtyIds({
tabsByWorktree: {
wt1: [{ id: 'tab-1' }, { id: 'tab-2' }],
wt2: [{ id: 'tab-3' }]
},
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2'],
'tab-2': ['pty-3'],
'tab-3': ['pty-4']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-3': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-4': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
},
worktreeId: 'wt1'
})
).toEqual(['pty-1', 'pty-3'])
})
it('returns an empty list when a worktree has no stale PTYs', () => {
expect(
collectStaleWorktreePtyIds({
tabsByWorktree: {
wt1: [{ id: 'tab-1' }]
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
},
codexRestartNoticeByPtyId: {},
worktreeId: 'wt1'
})
).toEqual([])
})
it('collects from one worktree tab slice without scanning the whole tab map', () => {
expect(
collectStalePtyIdsForTabs({
tabs: [{ id: 'tab-1' }],
ptyIdsByTabId: {
'tab-1': ['pty-1'],
'tab-2': ['pty-2']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
})
).toEqual(['pty-1'])
})
it('drops PTYs whose restart is already requested', () => {
expect(
collectStalePtyIdsForTabs({
tabs: [{ id: 'tab-1' }],
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', restartRequested: true },
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
})
).toEqual(['pty-2'])
})
it('dismisses every stale PTY notice in the worktree prompt', () => {
const dismissCodexRestartNotices = vi.fn()
const forgetLaunchAccounts = vi.fn()
dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], dismissCodexRestartNotices, forgetLaunchAccounts)
expect(dismissCodexRestartNotices).toHaveBeenCalledWith(['pty-1', 'pty-3'])
expect(forgetLaunchAccounts).toHaveBeenCalledWith(['pty-1', 'pty-3'])
})
it('drops dismissed PTYs from the prompt while keeping their launch account', () => {
expect(
collectStalePtyIdsForTabs({
tabs: [{ id: 'tab-1' }],
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b', dismissed: true },
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
})
).toEqual(['pty-2'])
})
it('renders only account-resolution actions without an external-store update loop', async () => {
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
},
codexRestartNoticeByPtyId: {
'pty-1': {
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
}
})
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
expect(container.textContent).toContain('Codex is still signed in as old@example.com')
expect(
Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim())
).toEqual(['Keep old account', 'Restart'])
})
it('uses configuration wording for a home-route restart', async () => {
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {
'pty-1': {
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
}
}
})
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
expect(container.textContent).toContain('Codex setup changed')
expect(container.textContent).toContain('This Codex session is using an outdated configuration')
expect(container.textContent).toContain(
'Restart this session to load your current Codex configuration.'
)
expect(
Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim())
).toEqual(['Keep current session', 'Restart'])
})
it('forgets the launch record when the user keeps the old account', async () => {
const forgetStalePanes = vi.fn(() => Promise.resolve())
Object.defineProperty(window, 'api', {
configurable: true,
value: { codexAccounts: { forgetStalePanes } }
})
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {
'pty-1': {
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
}
})
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
const dismissButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Keep old account'
)
await act(async () => {
dismissButton?.click()
})
// Why: without this the startup sweep re-raises the prompt the user just answered.
expect(forgetStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] })
expect(container.textContent).toBe('')
// Why: the record survives as the pane's launch-account memory, but marked
// answered so it neither prompts again nor blocks the pane's keyboard.
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com',
dismissed: true
})
})
it('stays closed when the user re-selects the account the pane launched under', async () => {
const forgetStalePanes = vi.fn(() => Promise.resolve())
Object.defineProperty(window, 'api', {
configurable: true,
value: { codexAccounts: { forgetStalePanes } }
})
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
})
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
const dismissButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Keep old account'
)
await act(async () => {
dismissButton?.click()
})
// Re-select the pane's original account: it never left it, so there is
// nothing to restart and nothing to prompt about.
await act(async () => {
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'new@example.com',
nextAccountLabel: 'old@example.com'
}
])
})
expect(container.textContent).toBe('')
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined()
})
it('reopens the prompt when a dismissed pane is out of date against a third account', async () => {
const forgetStalePanes = vi.fn(() => Promise.resolve())
Object.defineProperty(window, 'api', {
configurable: true,
value: { codexAccounts: { forgetStalePanes } }
})
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
})
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
const dismissButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Keep old account'
)
await act(async () => {
dismissButton?.click()
})
expect(container.textContent).toBe('')
await act(async () => {
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'new@example.com',
nextAccountLabel: 'third@example.com'
}
])
})
// Why: the dismissal answered "keep old instead of new", not "never prompt
// again"; the pane really is out of date against third@example.com.
expect(container.textContent).toContain('Codex is still signed in as old@example.com')
expect(container.textContent).toContain('Restart this session to use third@example.com')
})
it('closes the prompt after Restart even when no mounted pane can run it yet', async () => {
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {
'pty-1': {
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
}
})
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
const restartButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Restart'
)
await act(async () => {
restartButton?.click()
})
expect(container.textContent).toBe('')
// Why: the pane still has to restart; only the prompt is answered.
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'pty-1': true })
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']?.restartRequested).toBe(true)
})
})
describe('CodexRestartChip focus target', () => {
function renderWithFocusedSiblingPane(): HTMLTextAreaElement {
// Mirrors Terminal.tsx, which mounts the chip as a SIBLING of the split
// layout — so the chip's parentElement is the whole worktree surface and
// its focus scope covers every pane in it, not just the stale one.
const splitLayout = document.createElement('div')
const healthyPaneInput = document.createElement('textarea')
healthyPaneInput.className = 'xterm-helper-textarea'
splitLayout.appendChild(healthyPaneInput)
container.appendChild(splitLayout)
healthyPaneInput.focus()
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
})
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
return healthyPaneInput
}
it('never parks focus on Restart, so a stray keystroke cannot kill the session', async () => {
renderWithFocusedSiblingPane()
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
// Regression (#10863): focus used to land on Restart. The card is worktree
// scoped, so it fires while the user is typing in a DIFFERENT, healthy pane
// — and the next Space/Enter of their prose restarted every stale pane here.
const restartButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Restart'
)
expect(restartButton).toBeDefined()
expect(document.activeElement).not.toBe(restartButton)
expect((document.activeElement as HTMLElement | null)?.getAttribute('role')).toBe('dialog')
// The keystroke that used to destroy the session now does nothing.
await act(async () => {
;(document.activeElement as HTMLElement | null)?.click()
})
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
})
it('still moves focus off the terminal so the card is reachable by keyboard', async () => {
const healthyPaneInput = renderWithFocusedSiblingPane()
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
// Why: the fix must not turn into "never take focus" — assistive tech and
// keyboard users still need to land on the dialog and Tab to its actions.
expect(document.activeElement).not.toBe(healthyPaneInput)
expect(document.activeElement).toBe(container.querySelector('[role="dialog"]'))
})
})

View File

@ -0,0 +1,295 @@
// @vitest-environment happy-dom
import React, { act, Profiler } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import CodexRestartChip from './CodexRestartChip'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const PTY_ONE = 'worktree-1@@pty-1'
const PTY_TWO = 'worktree-1@@pty-2'
let container: HTMLDivElement
let forgetStalePanes: ReturnType<typeof vi.fn>
let root: Root
function notice(previousAccountLabel: string, nextAccountLabel: string) {
return { previousAccountLabel, nextAccountLabel }
}
function button(scope: ParentNode, label: string): HTMLButtonElement {
const match = Array.from(scope.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label
)
if (!match) {
throw new Error(`missing ${label} button`)
}
return match
}
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
forgetStalePanes = vi.fn(() => Promise.resolve())
Object.defineProperty(window, 'api', {
configurable: true,
value: { codexAccounts: { forgetStalePanes } }
})
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
useAppStore.setState(useAppStore.getInitialState(), true)
})
describe('CodexRestartChip pane ownership', () => {
it('renders the notice owned by its exact PTY', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
}
})
await act(async () => {
root.render(<CodexRestartChip ptyId={PTY_TWO} />)
})
expect(container.textContent).toContain('Codex is still signed in as old-two@example.com')
expect(container.textContent).toContain('Restart this session to use new-two@example.com')
expect(container.textContent).not.toContain('old-one@example.com')
})
it('uses configuration wording for a home-route restart', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: {
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
}
}
})
await act(async () => {
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
})
expect(container.textContent).toContain('Codex setup changed')
expect(container.textContent).toContain('This Codex session is using an outdated configuration')
expect(container.textContent).toContain(
'Restart this session to load your current Codex configuration.'
)
})
it('restarts only the pane whose action was clicked', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
}
})
await act(async () => {
root.render(
<>
<section data-pane="one">
<CodexRestartChip ptyId={PTY_ONE} />
</section>
<section data-pane="two">
<CodexRestartChip ptyId={PTY_TWO} />
</section>
</>
)
})
const firstPane = container.querySelector('[data-pane="one"]')!
const secondPane = container.querySelector('[data-pane="two"]')!
await act(async () => {
button(firstPane, 'Restart').click()
})
const state = useAppStore.getState()
expect(state.pendingCodexPaneRestartIds).toEqual({ [PTY_ONE]: true })
expect(state.codexRestartNoticeByPtyId[PTY_ONE]?.restartRequested).toBe(true)
expect(state.codexRestartNoticeByPtyId[PTY_TWO]).toEqual(
notice('old-two@example.com', 'new-two@example.com')
)
expect(firstPane.textContent).toBe('')
expect(secondPane.textContent).toContain('old-two@example.com')
})
it('dismisses and forgets only the pane whose action was clicked', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: notice('old-one@example.com', 'new-one@example.com'),
[PTY_TWO]: notice('old-two@example.com', 'new-two@example.com')
}
})
await act(async () => {
root.render(
<>
<section data-pane="one">
<CodexRestartChip ptyId={PTY_ONE} />
</section>
<section data-pane="two">
<CodexRestartChip ptyId={PTY_TWO} />
</section>
</>
)
})
const firstPane = container.querySelector('[data-pane="one"]')!
const secondPane = container.querySelector('[data-pane="two"]')!
await act(async () => {
button(firstPane, 'Keep old account').click()
})
expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] })
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]?.dismissed).toBe(true)
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_TWO]?.dismissed).toBeUndefined()
expect(firstPane.textContent).toBe('')
expect(secondPane.textContent).toContain('old-two@example.com')
})
it('follows a pane replacement id without acting on the retired PTY', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: notice('retired@example.com', 'target@example.com')
}
})
await act(async () => {
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
})
await act(async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_TWO]: notice('replacement@example.com', 'target@example.com')
}
})
root.render(<CodexRestartChip ptyId={PTY_TWO} />)
})
await act(async () => {
button(container, 'Restart').click()
})
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [PTY_TWO]: true })
expect(useAppStore.getState().pendingCodexPaneRestartIds[PTY_ONE]).toBeUndefined()
})
it('stays hidden after this pane answers while a sibling remains unanswered', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: {
[PTY_ONE]: { ...notice('a', 'b'), restartRequested: true },
[PTY_TWO]: notice('c', 'd')
}
})
await act(async () => {
root.render(<CodexRestartChip ptyId={PTY_ONE} />)
})
expect(container.textContent).toBe('')
})
it('does not re-render for unrelated PTY and notice churn', async () => {
const onRender = vi.fn()
const ownedNotice = notice('a', 'b')
useAppStore.setState({ codexRestartNoticeByPtyId: { [PTY_ONE]: ownedNotice } })
await act(async () => {
root.render(
<Profiler id="chip" onRender={onRender}>
<CodexRestartChip ptyId={PTY_ONE} />
</Profiler>
)
})
const commits = onRender.mock.calls.length
await act(async () => {
useAppStore.setState({
ptyIdsByTabId: { unrelated: [PTY_TWO] },
codexRestartNoticeByPtyId: {
[PTY_ONE]: ownedNotice,
[PTY_TWO]: notice('c', 'd')
}
})
})
expect(onRender).toHaveBeenCalledTimes(commits)
})
it('does not mutate restart state during a StrictMode replay', async () => {
useAppStore.setState({
codexRestartNoticeByPtyId: { [PTY_ONE]: notice('old@example.com', 'new@example.com') }
})
await act(async () => {
root.render(
<React.StrictMode>
<CodexRestartChip ptyId={PTY_ONE} shouldFocus />
</React.StrictMode>
)
})
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]).toEqual(
notice('old@example.com', 'new@example.com')
)
})
})
describe('CodexRestartChip pane focus', () => {
async function renderPane(shouldFocus: boolean, isVisible = true): Promise<HTMLTextAreaElement> {
await act(async () => {
root.render(
<div data-pane="stale">
<textarea className="xterm-helper-textarea" />
<CodexRestartChip isVisible={isVisible} ptyId={PTY_ONE} shouldFocus={shouldFocus} />
</div>
)
})
const terminalInput = container.querySelector('textarea')!
terminalInput.focus()
await act(async () => {
useAppStore.getState().markCodexRestartNotices([
{
ptyId: PTY_ONE,
previousAccountLabel: 'old@example.com',
nextAccountLabel: 'new@example.com'
}
])
})
return terminalInput
}
it('focuses the dialog itself for the active stale pane', async () => {
const terminalInput = await renderPane(true)
expect(document.activeElement).not.toBe(terminalInput)
expect(document.activeElement).toBe(container.querySelector('[role="dialog"]'))
expect(document.activeElement).not.toBe(button(container, 'Restart'))
})
it('preserves focus when this stale pane is inactive', async () => {
const terminalInput = await renderPane(false)
expect(document.activeElement).toBe(terminalInput)
})
it('preserves focus while this pane is hidden', async () => {
const terminalInput = await renderPane(true, false)
expect(document.activeElement).toBe(terminalInput)
})
})

View File

@ -64,6 +64,7 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy'
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import { useTerminalFontZoom } from './useTerminalFontZoom'
import CloseTerminalDialog, { type CloseTerminalDialogCopyKind } from './CloseTerminalDialog'
import CodexRestartChip from '../CodexRestartChip'
import { MobileDriverOverlay } from './MobileDriverOverlay'
import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
@ -1709,6 +1710,9 @@ function TerminalPane(
// transport, so a queued restart has no ptyId to match on the mount pass. The
// reconnected PTY rewrites this map when it binds — `ptyIdsByTabId` does not,
// because a restored id is already listed there before the pane ever mounts.
// Panes with no mounted TerminalPane at all are executed by the detached
// driver instead (codex-detached-pane-restart), which leaves anything a live
// transport owns to this effect.
const panePtyLayoutBindings = savedLayout.ptyIdsByLeafId
useEffect(() => {
const manager = managerRef.current
@ -2963,6 +2967,24 @@ function TerminalPane(
})
}}
/>
{managedPanes.map((pane) => {
const ptyId =
paneTransportsRef.current.get(pane.id)?.getPtyId() ??
savedLayout.ptyIdsByLeafId?.[pane.leafId]
if (!ptyId) {
return null
}
return createPortal(
<CodexRestartChip
key={`codex-restart-${pane.id}-${ptyId}`}
isVisible={isVisible}
ptyId={ptyId}
shouldFocus={isActive && isVisible && activePane?.id === pane.id}
/>,
pane.container,
`codex-restart-${pane.id}`
)
})}
{/* Why: the reconnect banner already owns SSH recovery UX; the z-50 error
toast was painting over it (same bottom strip) with the raw ssh:connect failure. */}
{terminalError && isActive && !showSshReconnectOverlay ? (

View File

@ -0,0 +1,94 @@
import { useAppStore } from '@/store'
import {
resetDetachedCodexPaneRestartClaimsForTests,
sweepUnclaimedCodexPaneRestarts
} from './codex-detached-pane-restart'
let executorInstalled = false
let executorGeneration = 0
let sweepQueued = false
let sweepRunning = false
let sweepRequestedAfterRun = false
export function hasAddedPendingCodexPaneRestart(
current: Record<string, true>,
previous: Record<string, true>
): boolean {
if (current === previous) {
return false
}
return Object.keys(current).some((ptyId) => !previous[ptyId])
}
/** Installed once at app startup; returns the uninstaller (tests). */
export function installCodexDetachedPaneRestartExecutor(): () => void {
executorInstalled = true
const generation = ++executorGeneration
const unsubscribe = useAppStore.subscribe((state, previousState) => {
if (
hasAddedPendingCodexPaneRestart(
state.pendingCodexPaneRestartIds,
previousState.pendingCodexPaneRestartIds
)
) {
scheduleClaimSweep()
}
})
scheduleClaimSweep()
return () => {
unsubscribe()
if (executorGeneration === generation) {
executorInstalled = false
executorGeneration += 1
sweepQueued = false
sweepRequestedAfterRun = false
}
}
}
export function resetCodexDetachedPaneRestartExecutorForTests(): void {
executorInstalled = false
executorGeneration += 1
sweepQueued = false
sweepRunning = false
sweepRequestedAfterRun = false
resetDetachedCodexPaneRestartClaimsForTests()
}
function scheduleClaimSweep(): void {
if (!executorInstalled) {
return
}
if (sweepRunning) {
sweepRequestedAfterRun = true
return
}
if (sweepQueued) {
return
}
sweepQueued = true
const generation = executorGeneration
// Exact mounted-owner checks fence the claim; a microtask only exits the store write.
queueMicrotask(() => {
if (!executorInstalled || executorGeneration !== generation) {
return
}
sweepQueued = false
if (sweepRunning) {
sweepRequestedAfterRun = true
return
}
sweepRunning = true
void sweepUnclaimedCodexPaneRestarts()
.catch((err) => {
console.warn('[codex-restart] detached restart sweep failed:', err)
})
.finally(() => {
sweepRunning = false
if (executorInstalled && sweepRequestedAfterRun) {
sweepRequestedAfterRun = false
scheduleClaimSweep()
}
})
})
}

View File

@ -0,0 +1,441 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { registerRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
import { awaitsCodexRestartAnswer, blocksCodexPaneInput } from '../codex-restart-notice-state'
import { ptyDataHandlers } from './pty-dispatcher'
import { sweepUnclaimedCodexPaneRestarts } from './codex-detached-pane-restart'
import {
hasAddedPendingCodexPaneRestart,
installCodexDetachedPaneRestartExecutor,
resetCodexDetachedPaneRestartExecutorForTests
} from './codex-detached-pane-restart-scheduler'
const ACCOUNT_A = 'a@example.com'
const ACCOUNT_B = 'b@example.com'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OLD_PTY = 'wt1@@old'
const NEW_PTY = 'wt1@@new'
const UNLOCATED_PTY = 'wt1@@unlocated'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
return { promise: new Promise<T>((done) => (resolve = done)), resolve }
}
function seedQueuedRestart(
opts: { leafId?: string | null; layoutRoot?: 'leaf' | 'none' } = {}
): void {
const leafId = opts.leafId === undefined ? LEAF_ID : opts.leafId
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
worktreesByRepo: {
repo1: [{ id: 'wt1', path: '/Users/dev/code/orca' }]
} as never,
tabsByWorktree: {
wt1: [
{
id: 'tab-1',
ptyId: OLD_PTY,
worktreeId: 'wt1',
title: 'codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
launchAgent: 'codex' as const
}
]
} as never,
ptyIdsByTabId: { 'tab-1': [OLD_PTY] },
terminalLayoutsByTabId: leafId
? {
'tab-1': {
root: opts.layoutRoot === 'none' ? null : { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: OLD_PTY }
}
}
: {}
})
useAppStore
.getState()
.markCodexRestartNotices([
{ ptyId: OLD_PTY, previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
])
useAppStore.getState().queueCodexPaneRestarts([OLD_PTY])
}
describe('codex detached pane restart executor', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
resetCodexDetachedPaneRestartExecutorForTests()
useAppStore.setState(useAppStore.getInitialState(), true)
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
getSize: vi.fn().mockResolvedValue({ cols: 120, rows: 30 }),
spawn: vi.fn().mockResolvedValue({ id: NEW_PTY }),
kill: vi.fn().mockResolvedValue(undefined)
}
}
} as unknown as typeof window
})
afterEach(() => {
resetCodexDetachedPaneRestartExecutorForTests()
ptyDataHandlers.delete(OLD_PTY)
vi.useRealTimers()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('kill-and-respawns an accepted restart no mounted transport claims', async () => {
seedQueuedRestart()
await sweepUnclaimedCodexPaneRestarts()
expect(window.api.pty.spawn).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
cols: 80,
rows: 24,
cwd: '/Users/dev/code/orca',
command: 'codex',
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex',
worktreeId: 'wt1',
tabId: 'tab-1',
leafId: LEAF_ID,
initiallyHidden: true
})
)
expect(window.api.pty.getSize).not.toHaveBeenCalled()
expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]?.env).toEqual(
expect.objectContaining({
ORCA_PANE_KEY: `tab-1:${LEAF_ID}`,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt1',
ORCA_WORKSPACE_ID: 'wt1'
})
)
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
const state = useAppStore.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY])
expect(state.tabsByWorktree.wt1?.[0]?.ptyId).toBe(NEW_PTY)
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY })
expect(state.pendingCodexPaneRestartIds).toEqual({})
expect(state.suppressedPtyExitIds[OLD_PTY]).toBeUndefined()
// The whole point: the restart completed, so nothing may block input anymore.
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(false)
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[NEW_PTY])).toBe(false)
})
it('executes via the store subscription without a lifecycle timeout', async () => {
const uninstall = installCodexDetachedPaneRestartExecutor()
try {
seedQueuedRestart()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY))
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
} finally {
uninstall()
}
})
it('reference-gates unrelated store writes before scanning pending ids', () => {
const unchanged = new Proxy<Record<string, true>>(
{},
{
ownKeys: () => {
throw new Error('pending ids scanned')
}
}
)
expect(hasAddedPendingCodexPaneRestart(unchanged, unchanged)).toBe(false)
expect(hasAddedPendingCodexPaneRestart({ [OLD_PTY]: true }, {})).toBe(true)
})
it('contains one claim failure so later queued panes still restart', async () => {
useAppStore
.getState()
.markCodexRestartNotices([
{ ptyId: UNLOCATED_PTY, previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
])
useAppStore.getState().queueCodexPaneRestarts([UNLOCATED_PTY])
seedQueuedRestart()
const consumePendingCodexPaneRestart = useAppStore.getState().consumePendingCodexPaneRestart
useAppStore.setState({
consumePendingCodexPaneRestart: (ptyId) => {
if (ptyId === UNLOCATED_PTY) {
throw new Error('corrupt restored claim')
}
return consumePendingCodexPaneRestart(ptyId)
}
})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const uninstall = installCodexDetachedPaneRestartExecutor()
try {
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
const state = useAppStore.getState()
expect(state.pendingCodexPaneRestartIds).toEqual({})
expect(awaitsCodexRestartAnswer(state.codexRestartNoticeByPtyId[UNLOCATED_PTY])).toBe(true)
expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY])
expect(warn).toHaveBeenCalledWith(
'[codex-restart] detached pane restart failed:',
expect.objectContaining({ message: 'corrupt restored claim' })
)
} finally {
uninstall()
warn.mockRestore()
}
})
it('leaves a PTY owned by a mounted transport to the pane effect', async () => {
seedQueuedRestart()
ptyDataHandlers.set(OLD_PTY, () => {})
await sweepUnclaimedCodexPaneRestarts()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(window.api.pty.kill).not.toHaveBeenCalled()
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
})
it('leaves foreign-machine PTYs queued for their mounted pane path', async () => {
useAppStore.setState({ pendingCodexPaneRestartIds: { 'remote:term-1': true } })
await sweepUnclaimedCodexPaneRestarts()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'remote:term-1': true })
})
it('clears the notice when the queued pane no longer exists', async () => {
useAppStore
.getState()
.markCodexRestartNotices([
{ ptyId: 'wt1@@gone', previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }
])
useAppStore.getState().queueCodexPaneRestarts(['wt1@@gone'])
await sweepUnclaimedCodexPaneRestarts()
const state = useAppStore.getState()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(state.pendingCodexPaneRestartIds).toEqual({})
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId['wt1@@gone'])).toBe(false)
})
it('rebuilds a rootless single-pane layout so the mount replays this leaf', async () => {
// Regression guard: replayTerminalLayout mints a fresh leaf when the root
// doesn't name this one, silently orphaning the respawned PTY on reveal.
seedQueuedRestart({ layoutRoot: 'none' })
await sweepUnclaimedCodexPaneRestarts()
expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']).toEqual(
expect.objectContaining({
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
ptyIdsByLeafId: { [LEAF_ID]: NEW_PTY }
})
)
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
})
it('rebinds only the codex leaf of a split and keeps the sibling', async () => {
const SIBLING_LEAF = '22222222-2222-4222-8222-222222222222'
seedQueuedRestart()
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': [OLD_PTY, 'wt1@@sibling'] },
terminalLayoutsByTabId: {
'tab-1': {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SIBLING_LEAF }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: OLD_PTY, [SIBLING_LEAF]: 'wt1@@sibling' }
}
}
})
await sweepUnclaimedCodexPaneRestarts()
const layout = useAppStore.getState().terminalLayoutsByTabId['tab-1']
expect(layout?.root?.type).toBe('split')
expect(layout?.ptyIdsByLeafId).toEqual({
[LEAF_ID]: NEW_PTY,
[SIBLING_LEAF]: 'wt1@@sibling'
})
// Split-pane safety: only the codex pane's PTY dies.
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([NEW_PTY, 'wt1@@sibling'])
})
it('leaves a tab with a mounted TerminalPane to its own restart effect', async () => {
seedQueuedRestart()
const unregister = registerRuntimeTerminalTab({
tabId: 'tab-1',
worktreeId: 'wt1',
getManager: () => null,
getContainer: () => null,
getPtyIdForPane: () => null
})
try {
await sweepUnclaimedCodexPaneRestarts()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(window.api.pty.kill).not.toHaveBeenCalled()
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
} finally {
unregister()
}
})
it('reaps a detached spawn and requeues when a pane mounts during the spawn', async () => {
seedQueuedRestart()
const pendingSpawn = deferred<{ id: string }>()
const pendingKill = deferred<void>()
vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise)
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
const restart = sweepUnclaimedCodexPaneRestarts()
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
const unregister = registerRuntimeTerminalTab({
tabId: 'tab-1',
worktreeId: 'wt1',
getManager: () => null,
getContainer: () => null,
getPtyIdForPane: () => OLD_PTY
})
try {
pendingSpawn.resolve({ id: NEW_PTY })
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY))
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([OLD_PTY])
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ [OLD_PTY]: true })
await restart
pendingKill.resolve()
} finally {
unregister()
}
})
it('rejects a detached spawn after the tab generation and leaf owner change', async () => {
seedQueuedRestart()
const pendingSpawn = deferred<{ id: string }>()
const pendingKill = deferred<void>()
vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise)
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
const restart = sweepUnclaimedCodexPaneRestarts()
await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalledTimes(1))
const state = useAppStore.getState()
const notice = state.codexRestartNoticeByPtyId[OLD_PTY]
useAppStore.setState({
tabsByWorktree: {
wt1: [{ ...state.tabsByWorktree.wt1![0]!, generation: 1, ptyId: 'wt1@@successor' }]
},
ptyIdsByTabId: { 'tab-1': ['wt1@@successor'] },
terminalLayoutsByTabId: {
'tab-1': {
...state.terminalLayoutsByTabId['tab-1']!,
ptyIdsByLeafId: { [LEAF_ID]: 'wt1@@successor' }
}
},
codexRestartNoticeByPtyId: { 'wt1@@successor': notice! }
})
pendingSpawn.resolve({ id: NEW_PTY })
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY))
const after = useAppStore.getState()
expect(after.ptyIdsByTabId['tab-1']).toEqual(['wt1@@successor'])
expect(after.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe('wt1@@successor')
expect(awaitsCodexRestartAnswer(after.codexRestartNoticeByPtyId['wt1@@successor'])).toBe(true)
await restart
pendingKill.resolve()
})
it('leaves a sleep-retained pending id alone so wake can migrate it', async () => {
// Why: hibernation unbinds a pane's PTY but keeps its pending restart, and
// wake moves that entry onto the respawned PTY. No notice means nothing is
// blocked, so consuming here would silently lose the accepted restart.
useAppStore.setState({ pendingCodexPaneRestartIds: { 'wt1@@sleeping': true } })
await sweepUnclaimedCodexPaneRestarts()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(window.api.pty.kill).not.toHaveBeenCalled()
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({ 'wt1@@sleeping': true })
})
it('re-offers the prompt instead of leaving a silent block when the respawn fails', async () => {
seedQueuedRestart()
vi.mocked(window.api.pty.spawn).mockRejectedValue(new Error('managed auth unavailable'))
await sweepUnclaimedCodexPaneRestarts()
const state = useAppStore.getState()
expect(window.api.pty.kill).not.toHaveBeenCalled()
expect(state.ptyIdsByTabId['tab-1']).toEqual([OLD_PTY])
expect(state.pendingCodexPaneRestartIds).toEqual({})
// The question is back on screen; input stays blocked but never silently.
expect(awaitsCodexRestartAnswer(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(true)
})
it('kills now and defers the Codex respawn to mount when the layout leaf is unknown', async () => {
seedQueuedRestart({ leafId: null })
await sweepUnclaimedCodexPaneRestarts()
const state = useAppStore.getState()
expect(window.api.pty.spawn).not.toHaveBeenCalled()
expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY)
expect(state.ptyIdsByTabId['tab-1']).toEqual([])
expect(state.pendingStartupByTabId['tab-1']).toEqual(
expect.objectContaining({
command: 'codex',
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
})
)
expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[OLD_PTY])).toBe(false)
})
it('publishes a rootless replacement startup before waiting for old PTY teardown', async () => {
seedQueuedRestart({ leafId: null })
const pendingKill = deferred<void>()
vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise)
const restart = sweepUnclaimedCodexPaneRestarts()
await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(OLD_PTY))
expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([])
expect(useAppStore.getState().pendingStartupByTabId['tab-1']).toMatchObject({
command: 'codex',
launchAgent: 'codex'
})
expect(useAppStore.getState().suppressedPtyExitIds[OLD_PTY]).toBeUndefined()
await restart
pendingKill.resolve()
})
})

View File

@ -0,0 +1,342 @@
/**
* Detached executor for accepted Codex account-switch restarts.
*
* Why: queued restarts used to execute only inside mounted TerminalPane
* instances, so accepting the prompt stranded every unmounted pane prompt
* gone, keyboard blocked, Codex still running under the old account until the
* tab was next revealed. This driver watches pendingCodexPaneRestartIds and
* kill-and-respawns any pane no mounted transport claims, rebinding the store
* so a later mount reattaches to the replacement PTY like a restored session.
*/
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types'
import type { AppState } from '@/store'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
getCachedWindowsTerminalCapabilities,
hasCachedWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry'
const inFlightPtyIds = new Set<string>()
export function resetDetachedCodexPaneRestartClaimsForTests(): void {
inFlightPtyIds.clear()
}
export async function sweepUnclaimedCodexPaneRestarts(): Promise<void> {
for (const ptyId of Object.keys(useAppStore.getState().pendingCodexPaneRestartIds)) {
await sweepUnclaimedCodexPaneRestart(ptyId)
}
}
async function sweepUnclaimedCodexPaneRestart(ptyId: string): Promise<void> {
let located: LocatedCodexPane | null = null
let claimed = false
try {
// Why: remote-runtime spawns need that machine's transport assembly, which
// only the mounted pane path carries today; leave those queued for mount.
if (isForeignMachineCodexPtyId(ptyId)) {
return
}
// Why: a live primary handler means a mounted pane owns this PTY, and its
// restart effect re-runs on both the queue write and the transport bind —
// it is guaranteed to claim, and only it can reconnect the xterm in place.
if (ptyDataHandlers.has(ptyId) || inFlightPtyIds.has(ptyId)) {
return
}
const state = useAppStore.getState()
located = locateCodexPane(state, ptyId)
if (!located) {
// Why not consume: a sleep-retained pending id is unbound on purpose and
// wake migrates it onto the respawned PTY — taking it here would lose
// that restart. Only a notice still muting input forces a resolution.
if (state.codexRestartNoticeByPtyId[ptyId]) {
if (state.consumePendingCodexPaneRestart(ptyId)) {
state.clearCodexRestartNotice(ptyId)
}
}
return
}
// Why the registry check too: a revealed tab reads its layout into a ref at
// mount, before its transports bind (and register a primary handler). A
// takeover in that window would kill the PTY the pane is attaching to.
if (hasRegisteredRuntimeTerminalTab(located.tab.id)) {
return
}
if (!useAppStore.getState().consumePendingCodexPaneRestart(ptyId)) {
return
}
inFlightPtyIds.add(ptyId)
claimed = true
await executeDetachedCodexPaneRestart(located, ptyId)
} catch (err) {
console.warn('[codex-restart] detached pane restart failed:', err)
// Why: one malformed claim must not abort later panes or leave this one with
// an answered prompt whose restart never executed.
if (located) {
reopenCurrentCodexRestartPrompt(located, ptyId)
} else {
useAppStore.getState().reopenCodexRestartPrompt(ptyId)
}
} finally {
if (claimed) {
inFlightPtyIds.delete(ptyId)
}
}
}
type LocatedCodexPane = {
worktreeId: string
tab: TerminalTab
leafId: string | null
generation: number
}
function locateCodexPane(state: AppState, ptyId: string): LocatedCodexPane | null {
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
for (const tab of tabs) {
if (tab.ptyId !== ptyId && !(state.ptyIdsByTabId[tab.id] ?? []).includes(ptyId)) {
continue
}
const leafId =
Object.entries(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).find(
([, boundPtyId]) => boundPtyId === ptyId
)?.[0] ?? null
// Why the format check: pre-UUID layouts carry numeric leaf ids, which the
// pane-key env and main's binding flush both reject.
return {
worktreeId,
tab,
leafId: leafId !== null && isTerminalLeafId(leafId) ? leafId : null,
generation: tab.generation ?? 0
}
}
}
return null
}
function getWorkspacePath(state: AppState, worktreeId: string): string | null {
const parsed = parseWorkspaceKey(worktreeId)
if (parsed?.type === 'folder') {
return (
(state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId)
?.folderPath ?? null
)
}
return getWorktreeMapFromState(state).get(worktreeId)?.path ?? null
}
function buildPaneIdentityEnv(
state: AppState,
worktreeId: string,
tabId: string,
leafId: string
): Record<string, string> {
const parsed = parseWorkspaceKey(worktreeId)
const folderWorkspace =
parsed?.type === 'folder'
? state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId)
: null
return {
ORCA_WORKSPACE_ID: worktreeId,
...(folderWorkspace
? {
ORCA_PROJECT_GROUP_ID: folderWorkspace.projectGroupId,
ORCA_WORKSPACE_ROOT: folderWorkspace.folderPath
}
: {}),
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
}
async function executeDetachedCodexPaneRestart(
located: LocatedCodexPane,
ptyId: string
): Promise<void> {
const state = useAppStore.getState()
if (!located.leafId) {
// Why: without a usable layout leaf the replacement cannot be bound in
// place, so kill now and let the tab's next mount run the Codex startup.
if (!isLocatedCodexPaneCurrent(state, located, ptyId)) {
reopenCurrentCodexRestartPrompt(located, ptyId)
return
}
const store = useAppStore.getState()
store.suppressPtyExit(ptyId)
store.clearTabPtyId(located.tab.id, ptyId)
store.consumeSuppressedPtyExit(ptyId)
store.queueTabStartupCommand(located.tab.id, { ...CODEX_ACCOUNT_RESTART_STARTUP })
store.clearCodexRestartNotice(ptyId)
killReplacedCodexPanePty(ptyId)
return
}
const { worktreeId, tab, leafId } = located
const workspacePath = getWorkspacePath(state, worktreeId)
const cwd = tab.startupCwd ?? workspacePath ?? undefined
const capabilities = hasCachedWindowsTerminalCapabilities()
? getCachedWindowsTerminalCapabilities()
: null
// Why: same runtime context the mounted spawn ships (pty-connection.ts), so a
// WSL-defaulted project respawns into the same distro it launched from.
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, undefined, {
wslAvailable: capabilities?.wslAvailable,
availableWslDistros: capabilities?.wslDistros ?? null
})
const currentState = useAppStore.getState()
if (!isLocatedCodexPaneCurrent(currentState, located, ptyId)) {
reopenCurrentCodexRestartPrompt(located, ptyId)
return
}
if (hasRegisteredRuntimeTerminalTab(tab.id) || ptyDataHandlers.has(ptyId)) {
currentState.queueCodexPaneRestarts([ptyId])
return
}
// Hidden replacements converge on mount; provider sizing must not delay ownership transfer.
const spawned = await window.api.pty.spawn({
cols: 80,
rows: 24,
...(cwd ? { cwd } : {}),
cwdFallback: 'worktree',
env: buildPaneIdentityEnv(state, worktreeId, tab.id, leafId),
command: CODEX_ACCOUNT_RESTART_STARTUP.command,
startupCommandDelivery: CODEX_ACCOUNT_RESTART_STARTUP.startupCommandDelivery,
launchAgent: CODEX_ACCOUNT_RESTART_STARTUP.launchAgent,
worktreeId,
tabId: tab.id,
leafId,
...(tab.shellOverride ? { shellOverride: tab.shellOverride } : {}),
...(projectRuntime ? { projectRuntime } : {}),
initiallyHidden: true
})
const store = useAppStore.getState()
if (!isLocatedCodexPaneCurrent(store, located, ptyId)) {
reopenCurrentCodexRestartPrompt(located, ptyId)
reapUnboundCodexPty(spawned.id, 'stale detached spawn')
return
}
if (hasRegisteredRuntimeTerminalTab(tab.id) || ptyDataHandlers.has(ptyId)) {
store.queueCodexPaneRestarts([ptyId])
reapUnboundCodexPty(spawned.id, 'mounted-owner handoff spawn')
return
}
store.updateTabPtyId(tab.id, spawned.id, ptyId)
if (!useAppStore.getState().ptyIdsByTabId[tab.id]?.includes(spawned.id)) {
// Why: the tab was retired while the spawn was in flight; without a binding
// the fresh PTY would idle in the daemon forever, so reap it and stand down.
store.clearCodexRestartNotice(ptyId)
reapUnboundCodexPty(spawned.id, 'retired-tab spawn')
return
}
rebindCodexPaneLayoutLeaf(tab.id, leafId, spawned.id)
// Why both ids: updateTabPtyId migrates the replaced pane's notice onto the
// new PTY; the restart it recorded is now done, so the block must lift.
store.clearCodexRestartNotice(spawned.id)
store.clearCodexRestartNotice(ptyId)
killReplacedCodexPanePty(ptyId)
}
function isLocatedCodexPaneCurrent(
state: AppState,
located: LocatedCodexPane,
ptyId: string
): boolean {
const currentTab = state.tabsByWorktree[located.worktreeId]?.find(
(candidate) => candidate.id === located.tab.id
)
if (
!currentTab ||
currentTab.worktreeId !== located.worktreeId ||
(currentTab.generation ?? 0) !== located.generation ||
!(state.ptyIdsByTabId[located.tab.id] ?? []).includes(ptyId)
) {
return false
}
return (
located.leafId === null ||
state.terminalLayoutsByTabId[located.tab.id]?.ptyIdsByLeafId?.[located.leafId] === ptyId
)
}
function reopenCurrentCodexRestartPrompt(located: LocatedCodexPane, replacedPtyId: string): void {
const state = useAppStore.getState()
const currentTab = state.tabsByWorktree[located.worktreeId]?.find(
(candidate) => candidate.id === located.tab.id
)
const currentPtyId = located.leafId
? state.terminalLayoutsByTabId[located.tab.id]?.ptyIdsByLeafId?.[located.leafId]
: currentTab?.ptyId
for (const candidate of [currentPtyId, replacedPtyId]) {
if (candidate && state.codexRestartNoticeByPtyId[candidate]?.restartRequested) {
state.reopenCodexRestartPrompt(candidate)
return
}
}
}
function layoutRootContainsLeaf(
node: TerminalPaneLayoutNode | null | undefined,
leafId: string
): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutRootContainsLeaf(node.first, leafId) || layoutRootContainsLeaf(node.second, leafId)
}
function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void {
const store = useAppStore.getState()
const layout = store.terminalLayoutsByTabId[tabId]
const boundLeafIds = Object.keys(layout?.ptyIdsByLeafId ?? {})
// Why: mount replays panes from the root — a root that doesn't name this leaf
// mints a fresh one and silently orphans the replacement PTY. Rewriting is
// only safe when this is the tab's sole bound pane; a split keeps its root.
if (!layoutRootContainsLeaf(layout?.root, leafId) && boundLeafIds.every((id) => id === leafId)) {
store.setTabLayout(
tabId,
singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null)
)
return
}
store.replaceTerminalLayoutPanePtyId(tabId, leafId, newPtyId)
}
function reapUnboundCodexPty(ptyId: string, reason: string): void {
try {
void window.api.pty.kill(ptyId).catch((err) => {
console.warn(`[codex-restart] failed to reap ${reason}:`, err)
})
} catch (err) {
console.warn(`[codex-restart] failed to reap ${reason}:`, err)
}
discardPreHandlerPtyState(ptyId)
}
function killReplacedCodexPanePty(ptyId: string): void {
// Why the disposal: a parked tab's exit sidecar treats any exit as the pane
// dying — it would collapse the just-rebound leaf or close the whole tab.
disposeParkedTerminalWatchersForPtyIds([ptyId])
for (const snapshot of unregisterPtyDataHandlers([ptyId])) {
snapshot.commit()
}
reapUnboundCodexPty(ptyId, 'replaced Codex pane PTY')
}

View File

@ -27,9 +27,14 @@ function setLaunchAgentOnFirstTab(launchAgent: TuiAgent): void {
describe('CODEX_ACCOUNT_RESTART_STARTUP', () => {
it('waits for shell readiness before relaunching Codex after an account switch', () => {
// Why launchAgent is load-bearing: pty:spawn runs the managed-auth
// readiness gate and Codex launch prep only for launchAgent 'codex', so
// dropping it would let a restart respawn race the account handoff and
// record a launch account the pane does not actually read.
expect(CODEX_ACCOUNT_RESTART_STARTUP).toEqual({
command: 'codex',
startupCommandDelivery: 'shell-ready'
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
})
expect(shouldUseShellReadyStartupDelivery(CODEX_ACCOUNT_RESTART_STARTUP)).toBe(true)
})

View File

@ -22,9 +22,14 @@ import type { TuiAgent } from '../../../shared/types'
// Why: prompt integrations such as Starship can outlast the daemon's 300ms
// Codex fast-path timeout; account restarts must wait until the shell accepts input.
// Why launchAgent: pty:spawn runs the managed-auth readiness gate and Codex
// launch prep (project trust pre-mark) only for launchAgent 'codex', so without
// it a restart respawn could race the account handoff and record a launch
// account the pane does not actually read.
export const CODEX_ACCOUNT_RESTART_STARTUP = {
command: 'codex',
startupCommandDelivery: 'shell-ready'
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
} as const
export type CodexPaneScanResult = {

View File

@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import {
notifyCodexPaneBoundForStaleSweep,
resetCodexStalePaneSweepForTests
resetCodexStalePaneSweepForTests,
sweepRestoredCodexPanesForStaleAccounts
} from './codex-stale-pane-sweep'
const ACCOUNT_A = 'account-a@example.com'
@ -443,4 +444,26 @@ describe('notifyCodexPaneBoundForStaleSweep', () => {
nextAccountId: 'account-b'
})
})
it('re-raises the prompt for a restored pane whose tab never mounts', async () => {
// Regression: the bind-driven sweep needed a pane mount to fire, so a stale
// pane restored into a background tab stayed on the old account silently.
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
sweepRestoredCodexPanesForStaleAccounts({
ptyIdsByTabId: { 'tab-1': ['pty-1', 'remote:env-1@@term-1'] }
})
await vi.advanceTimersByTimeAsync(300)
expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({
ptyIds: ['pty-1']
})
expect(inspectCallCountFor('remote:env-1@@term-1')).toBe(0)
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B,
previousAccountId: 'account-a',
nextAccountId: 'account-b'
})
})
})

View File

@ -47,6 +47,25 @@ export function notifyCodexPaneBoundForStaleSweep(ptyId: string): void {
armForEarliestDue()
}
/**
* Queues every restored PTY for the stale-account check without waiting for a
* pane to mount.
*
* Why: the bind-driven sweep above only fires when a pane's transport binds, so
* a stale Codex pane restored into a never-revealed tab kept running under the
* old account with no prompt and an accepted-then-quit restart was never
* re-offered. Foreign-machine ids are dropped by the notify guard.
*/
export function sweepRestoredCodexPanesForStaleAccounts(state: {
ptyIdsByTabId: Record<string, string[]>
}): void {
for (const ptyIds of Object.values(state.ptyIdsByTabId)) {
for (const ptyId of ptyIds) {
notifyCodexPaneBoundForStaleSweep(ptyId)
}
}
}
export function resetCodexStalePaneSweepForTests(): void {
if (flushTimer !== null) {
clearTimeout(flushTimer)

View File

@ -222,4 +222,63 @@ describe('codex restart notices with colliding account labels', () => {
nextAccountId: 'account-c'
})
})
it('puts a failed accepted restart back to the unanswered prompt', () => {
switchAccount('pty-1', A, B)
useAppStore.getState().queueCodexPaneRestarts(['pty-1'])
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
// Why: the executor could not run the restart, so the pane must show the
// question again rather than stay muted behind an answered prompt.
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: A,
nextAccountLabel: B
})
expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({})
})
it('leaves unanswered and dismissed notices alone on a reopen request', () => {
switchAccount('pty-1', A, B)
const unanswered = useAppStore.getState().codexRestartNoticeByPtyId
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(unanswered)
useAppStore.getState().dismissCodexRestartNotices(['pty-1'])
const dismissed = useAppStore.getState().codexRestartNoticeByPtyId
useAppStore.getState().reopenCodexRestartPrompt('pty-1')
expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(dismissed)
})
})
describe('replaceTerminalLayoutPanePtyId', () => {
const LEAF = '11111111-1111-4111-8111-111111111111'
it('rebinds one leaf and leaves split siblings alone', () => {
const OTHER_LEAF = '22222222-2222-4222-8222-222222222222'
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': {
root: null,
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: 'pty-old', [OTHER_LEAF]: 'pty-sibling' }
}
}
})
useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-1', LEAF, 'pty-new')
expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF]: 'pty-new',
[OTHER_LEAF]: 'pty-sibling'
})
})
it('does nothing for a tab with no layout', () => {
const before = useAppStore.getState().terminalLayoutsByTabId
useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-none', LEAF, 'pty-new')
expect(useAppStore.getState().terminalLayoutsByTabId).toBe(before)
})
})

View File

@ -748,6 +748,11 @@ export type TerminalSlice = {
) => string[]
clearCodexRestartNotice: (ptyId: string) => void
dismissCodexRestartNotices: (ptyIds: string[]) => void
/** Puts an accepted-but-unexecuted restart back to the unanswered prompt, so a
* failed execution never leaves a pane input-blocked with nothing visible. */
reopenCodexRestartPrompt: (ptyId: string) => void
/** Rebinds one layout leaf to a replacement PTY without a mounted pane. */
replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
@ -3586,6 +3591,40 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
reopenCodexRestartPrompt: (ptyId) => {
set((s) => {
const notice = s.codexRestartNoticeByPtyId[ptyId]
if (!notice?.restartRequested) {
return {}
}
const { restartRequested: _restartRequested, ...kept } = notice
const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds }
delete nextPendingCodexPaneRestartIds[ptyId]
return {
codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept },
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds
}
})
},
replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => {
set((s) => {
const layout = s.terminalLayoutsByTabId[tabId]
if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) {
return {}
}
return {
terminalLayoutsByTabId: {
...s.terminalLayoutsByTabId,
[tabId]: {
...layout,
ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId }
}
}
}
})
},
setTabPaneExpanded: (tabId, expanded) => {
set((s) => ({
expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded }