diff --git a/src/main/ipc/pty/pty-account-restart.test.ts b/src/main/ipc/pty/pty-account-restart.test.ts index 1d492279362..ac1be31f17c 100644 --- a/src/main/ipc/pty/pty-account-restart.test.ts +++ b/src/main/ipc/pty/pty-account-restart.test.ts @@ -1,3 +1,7 @@ +import { agentSessionOwners } from './pane/agent-session-owners' +import { accountRestartErrorReceipt } from './pane/account-restart-owner' +import { terminalStartError } from '../../../shared/terminal/pty/lifecycle/terminal-start-failure' +import { mapRuntimeError } from '../../runtime/rpc/responses/rpc-response' import { readAccountRestartReceipt, type AccountRestartCondition @@ -169,7 +173,7 @@ describe('conditional Codex account restart at the IPC boundary', () => { } h.providerSpawn.mockRejectedValueOnce(new Error('launch refused')) const failure = await controller.spawn(args).catch((error: unknown) => error) - expect(readAccountRestartReceipt(failure)).toMatchObject({ + expect(accountRestartErrorReceipt(failure)).toMatchObject({ stop: 'confirmed-retired', outcome: 'terminal', current: { kind: 'empty', bindingGeneration: 2 } @@ -201,4 +205,111 @@ describe('conditional Codex account restart at the IPC boundary', () => { await expect(invoke()).rejects.toThrow('terminal_pane_owner_changed') expect(h.leafBinding()).toBe('successor') }) + it('preserves the Windows capacity diagnostic and stop proof through the final IPC formatter', async () => { + const h = installRestartHarness() + h.store.trackPtyBindingGeneration('local', leafId) + h.providerSpawn.mockRejectedValueOnce( + terminalStartError( + 'provider stopped', + { kind: 'exited', exitCode: 1, initialOutputDiagnostic: 'git-bash-console-capacity' }, + 'inc-new' + ) + ) + registerWithFakes(mainWindow, h.runtime, h.store) + const error = await invoke().catch((failure: unknown) => failure) + expect(error).toBeInstanceOf(Error) + expect(error instanceof Error && error.message).toContain('128 open terminals') + expect(readAccountRestartReceipt(error)).toMatchObject({ + requestId: 'account-request', + attemptId: 'account-attempt', + stop: 'confirmed-retired', + outcome: 'terminal', + current: { kind: 'empty', bindingGeneration: 2 } + }) + }) + + it('preserves the same proof in the RPC error data after Windows formatting', async () => { + const h = installRestartHarness() + h.store.trackPtyBindingGeneration('local', leafId) + h.providerSpawn.mockRejectedValueOnce( + terminalStartError( + 'provider stopped', + { kind: 'exited', exitCode: 1, initialOutputDiagnostic: 'git-bash-console-capacity' }, + 'inc-new' + ) + ) + registerWithFakes(mainWindow, h.runtime, h.store) + const controller = h.runtime.setPtyController.mock.lastCall?.[0] + if (!controller?.spawn) { + throw new Error('runtime controller not installed') + } + const error = await controller + .spawn({ + ...restartSpawnArgs(), + accountRestart: condition(), + launchAgent: 'codex', + commandDelivery: 'provider', + persistHostSessionBinding: true, + spawnIntent: 'user-resume' + }) + .catch((failure: unknown) => failure) + const response = mapRuntimeError('rpc-create', { runtimeId: 'runtime-local' }, error) + expect(response.error.code).toBe('runtime_error') + expect(response.error.message).toContain('128 open terminals') + expect(readAccountRestartReceipt({ response })).toMatchObject({ + requestId: 'account-request', + attemptId: 'account-attempt', + stop: 'confirmed-retired', + outcome: 'terminal', + current: { kind: 'empty', bindingGeneration: 2 } + }) + }) + it('refuses an ensure adoption from another provider-session owner without touching that owner', async () => { + const h = installRestartHarness() + h.store.trackPtyBindingGeneration('local', leafId) + registerWithFakes(mainWindow, h.runtime, h.store) + const controller = h.runtime.setPtyController.mock.lastCall?.[0] + if (!controller?.spawn) { + throw new Error('runtime controller not installed') + } + const claim = { + digestVersion: 1 as const, + keyId: 'f2-key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + agentSessionOwners.register({ + claim, + ptyId: 'provider-owner-elsewhere', + phase: 'live', + generation: 'foreign-generation', + surface: { + worktreeId, + tabId: 'another-tab', + leafId: '00000000-0000-4000-8000-000000000003', + terminalHandle: 'foreign-handle' + } + }) + try { + await expect( + controller.spawn({ + ...restartSpawnArgs(), + accountRestart: condition(), + launchAgent: 'codex', + commandDelivery: 'provider', + persistHostSessionBinding: true, + spawnIntent: 'user-resume', + agentSessionEnsure: { + claim, + surface: { worktreeId, tabId, leafId, terminalHandle: 'requested-handle' } + } + }) + ).rejects.toThrow('agent_session_conflict') + expect(h.providerSpawn).not.toHaveBeenCalled() + expect(h.leafBinding()).toBeUndefined() + } finally { + agentSessionOwners.release('provider-owner-elsewhere', 'foreign-generation') + } + }) }) diff --git a/src/main/ipc/pty/spawn-execute.ts b/src/main/ipc/pty/spawn-execute.ts index 47d480f7c27..5c8a8a511fb 100644 --- a/src/main/ipc/pty/spawn-execute.ts +++ b/src/main/ipc/pty/spawn-execute.ts @@ -100,6 +100,9 @@ export async function executePtySpawn( // Claims can outlive this controller; import authoritative owner listings before deciding absence. await reconcileAgentSessionOwnerListings() const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) + if (ctx.accountRestartOperation && recoveredOwner) { + throw new Error('agent_session_conflict') + } if (recoveredOwner && ctx.pendingRegistrationPtyId !== recoveredOwner.ptyId) { if (ctx.pendingRegistrationPtyId) { ctx.deps.runtime?.cancelPendingPtyRegistration?.( @@ -183,6 +186,10 @@ export async function executePtySpawn( if (ensured) { ctx.result.agentSessionEnsure = ensured } + if (ctx.accountRestartOperation && ensured?.disposition === 'adopted') { + ctx.result.isReattach = true + throw new Error('agent_session_conflict') + } } else { if (!facts.attachOnlyView) { await chooseFresh() diff --git a/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts b/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts index d86dff234f5..b5307656bc7 100644 --- a/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts +++ b/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts @@ -1,3 +1,4 @@ +import type { AccountRestartCondition } from '../../../../shared/terminal/pty/lifecycle/account-restart-contract' import type { DaemonLaunchNoteChange } from '../../../daemon/protocol/daemon-launch-note-protocol' import type { TerminalLimitStatus, @@ -70,6 +71,7 @@ export type RuntimePtyController = { materialized?: true } | null> spawn?(opts: { + accountRestart?: AccountRestartCondition resolvedWorkspace?: TerminalWorkspaceLaunchScope cols: number rows: number diff --git a/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-execution.ts b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-execution.ts index b24812658e5..43284bd1cfd 100644 --- a/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-execution.ts +++ b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-execution.ts @@ -1,3 +1,4 @@ +import { getTerminalNoticeLifetime } from '../provider-notices/terminal-notice-lifetime' import { waitForPromiseWithSignal } from '../../../../../shared/abort-signal-reason' import { useAppStore } from '@/store' import { runtimeEnvironmentIdUnlessTwin } from '@/lib/workspace/ownership/resolve-owner' @@ -19,6 +20,19 @@ import { } from '@/store/terminals/codex-account-restart-continuation' import type { CodexAccountRestart } from '@/store/terminals/terminal-contracts' +export function isObsoleteCodexRestartResult( + request: CodexAccountRestart, + result: { id: string; incarnationId?: string | null } +): boolean { + const current = getTerminalNoticeLifetime(result.id)?.incarnationId + return Boolean( + current && + result.incarnationId && + current !== result.incarnationId && + !(result.id === request.oldPtyId && current === request.oldIncarnationId) + ) +} + export function prepareCodexAccountRestart( request: CodexAccountRestart ): Promise { @@ -70,6 +84,9 @@ async function prepareCodexAccountRestartOwner( ) { throw new Error('terminal_account_restart_unsupported') } + if (!currentCodexAccountRestart(useAppStore.getState(), token)) { + return null + } } const hostPane = parsePaneKey(toHostSessionPaneKey(makePaneKey(request.tabId, request.leafId))) if (!hostPane) { @@ -94,7 +111,7 @@ async function prepareCodexAccountRestartOwner( 'accountRestart' in terminal ? terminal.accountRestart : undefined ) const latest = currentCodexAccountRestart(useAppStore.getState(), token) - if (!latest) { + if (!latest || latest.phase !== 'claimed') { return null } if ( @@ -103,6 +120,9 @@ async function prepareCodexAccountRestartOwner( ) { throw new Error('terminal_pane_owner_changed') } + if (snapshot.tabCreatedAt !== request.tabCreatedAt) { + throw new Error('terminal_pane_owner_changed') + } const previous = request.condition if ( previous && @@ -160,14 +180,13 @@ export function failCodexAccountRestart( state.updateCodexAccountRestart(token, null) return } - const receipt = readAccountRestartReceipt(error) + const parsedReceipt = readAccountRestartReceipt(error) + const receipt = + parsedReceipt?.requestId === token.requestId && parsedReceipt.attemptId === token.attemptId + ? parsedReceipt + : undefined let condition = request.condition - if ( - receipt?.requestId === token.requestId && - receipt.attemptId === token.attemptId && - condition && - receipt.current - ) { + if (receipt && condition && receipt.current) { if (receipt.current.kind === 'empty' && receipt.stop === 'confirmed-retired') { condition = { ...condition, @@ -193,72 +212,76 @@ export function failCodexAccountRestart( }) } -export async function retryCodexAccountRestarts( - requests: readonly CodexAccountRestart[] +export function retryCodexAccountRestarts(requests: readonly CodexAccountRestart[]): Promise { + const identities = requests.map( + ({ startup: _startup, startupInput: _startupInput, ...identity }) => identity + ) + return Promise.all(identities.map(retryCodexAccountRestartIdentity)).then(() => undefined) +} + +async function retryCodexAccountRestartIdentity( + request: Omit ): Promise { - await Promise.all( - requests.map(async (request) => { - const token = codexAccountRestartToken(request), - state = useAppStore.getState() - if (!currentCodexAccountRestart(state, token)) { - return - } - if (request.phase === 'failed') { - if (!request.leafId) { - state.updateCodexAccountRestart(token, { phase: 'claimed' }) - try { - await waitForPromiseWithSignal( - callRuntimeRpc( - request.environmentId - ? { kind: 'environment', environmentId: request.environmentId } - : { kind: 'local' }, - 'session.tabs.list', - { worktree: `id:${request.worktreeId}` }, - { - timeoutMs: 15000, - expectedEnvironmentPairingRevision: request.pairingRevision, - expectedEnvironmentRuntimeId: request.runtimeId - } - ), - AbortSignal.timeout(15000) - ) - } catch (error) { - failCodexAccountRestart(token, error) - } finally { - useAppStore.getState().updateCodexAccountRestart(token, { phase: 'failed' }) - } - } - try { - useAppStore.getState().retryCodexAccountRestart(token) - } catch (error) { - failCodexAccountRestart(token, error) - } - return - } - if (request.phase !== 'unresolved') { - return - } + const token = codexAccountRestartToken(request), + state = useAppStore.getState() + const current = currentCodexAccountRestart(state, token) + if (!current || current.phase !== request.phase) { + return + } + if (request.phase === 'failed') { + if (!request.leafId) { state.updateCodexAccountRestart(token, { phase: 'claimed' }) try { - await prepareCodexAccountRestart(request) + await waitForPromiseWithSignal( + callRuntimeRpc( + request.environmentId + ? { kind: 'environment', environmentId: request.environmentId } + : { kind: 'local' }, + 'session.tabs.list', + { worktree: `id:${request.worktreeId}` }, + { + timeoutMs: 15000, + expectedEnvironmentPairingRevision: request.pairingRevision, + expectedEnvironmentRuntimeId: request.runtimeId + } + ), + AbortSignal.timeout(15000) + ) } catch (error) { - const latest = currentCodexAccountRestart(useAppStore.getState(), token) - if (latest) { - if (error instanceof Error && error.message.includes('terminal_pane_owner_changed')) { - useAppStore.getState().updateCodexAccountRestart(token, null) - } else { - useAppStore.getState().updateCodexAccountRestart(token, { - error: error instanceof Error ? error.message : String(error) - }) - } - } + failCodexAccountRestart(token, error) } finally { - // Reconciliation never creates a second process while the original outcome is unknown. - const latest = currentCodexAccountRestart(useAppStore.getState(), token) - if (latest && (latest.phase === 'claimed' || latest.phase === 'dispatched')) { - useAppStore.getState().updateCodexAccountRestart(token, { phase: 'unresolved' }) - } + useAppStore.getState().updateCodexAccountRestart(token, { phase: 'failed' }) } - }) - ) + } + try { + useAppStore.getState().retryCodexAccountRestart(token) + } catch (error) { + failCodexAccountRestart(token, error) + } + return + } + if (request.phase !== 'unresolved') { + return + } + state.updateCodexAccountRestart(token, { phase: 'claimed' }) + try { + await prepareCodexAccountRestartOwner(request) + } catch (error) { + const latest = currentCodexAccountRestart(useAppStore.getState(), token) + if (latest && (latest.phase === 'claimed' || latest.phase === 'dispatched')) { + if (error instanceof Error && error.message.includes('terminal_pane_owner_changed')) { + useAppStore.getState().updateCodexAccountRestart(token, null) + } else { + useAppStore.getState().updateCodexAccountRestart(token, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } finally { + // Reconciliation never creates a second process while the original outcome is unknown. + const latest = currentCodexAccountRestart(useAppStore.getState(), token) + if (latest && (latest.phase === 'claimed' || latest.phase === 'dispatched')) { + useAppStore.getState().updateCodexAccountRestart(token, { phase: 'unresolved' }) + } + } } diff --git a/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-input.test.ts b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-input.test.ts new file mode 100644 index 00000000000..cf154890dd8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart-input.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { selectCodexStaleSessionCount } from '@/components/status-bar/codex-restart-status-summary' +import { codexAccountRestartToken } from '@/store/terminals/codex-account-restart-continuation' +import { installPtyInputForward } from '../pty-connection/input/pty-input-forward' + +vi.mock('@/lib/agent-session/accounts/agent-account-restart-startup', () => ({ + captureAgentAccountRestartResumeInput: () => null, + buildPaneAccountRestartStartup: () => ({ command: 'codex', launchAgent: 'codex' }) +})) +const leaf = '00000000-0000-4000-8000-000000000001' +const sibling = '00000000-0000-4000-8000-000000000002' + +function input(ptyId: string, leafId = leaf, incarnationId = 'inc-old') { + const writes: string[] = [], + replies: string[] = [] + const noop = () => {} + const session = { + remotePtyIncarnationId: incarnationId, + pane: { id: 1, leafId, terminal: { onData: () => ({ dispose: noop }) } }, + deps: { tabId: 'tab', worktreeId: 'wt', replayingPanesRef: { current: new Map() } }, + transport: { + getPtyId: () => ptyId, + sendInput: (data: string) => { + writes.push(data) + return true + } + }, + sendDesktopQueryReplyImmediate: (data: string) => replies.push(data), + clearPendingTerminalInputIntent: noop, + noteTerminalInputForPty: noop, + pendingTerminalInputIntentFor: () => undefined, + inferIntentFromExactTerminalInput: () => undefined, + claimViewportForUserActivity: noop, + markAcceptedTerminalInputSent: noop, + observeAcceptedShellCommandInput: noop, + observeAcceptedTerminalInput: noop, + observeSentTerminalInputIntent: noop, + requestRecoveryForUndeliverableInput: noop, + forwardPtyInput: (_data: string, _wasUserInput?: boolean) => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the ordinary input and protocol-reply routes used here. + installPtyInputForward(session as never) + return { type: (data: string) => session.forwardPtyInput(data, true), writes, replies } +} +function failCurrent() { + useAppStore.getState().queuePaneAccountRestarts(['old'], 'codex') + const request = useAppStore.getState().claimCodexAccountRestart(`tab:${leaf}`) + if (!request) { + throw new Error('request not accepted') + } + useAppStore + .getState() + .updateCodexAccountRestart(codexAccountRestartToken(request), { phase: 'failed' }) + useAppStore.getState().clearCodexRestartNotice('old') +} +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + vi.stubGlobal('window', { api: { pty: { noteUserInput: vi.fn() } } }) + useAppStore.setState({ + tabsByWorktree: { + wt: [ + { + id: 'tab', + worktreeId: 'wt', + createdAt: 1, + ptyId: 'old', + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0 + } + ] + }, + ptyIdsByTabId: { tab: ['old', 'sibling'] }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: leaf }, + second: { type: 'leaf', leafId: sibling } + }, + activeLeafId: leaf, + expandedLeafId: null, + ptyIdsByLeafId: { [leaf]: 'old', [sibling]: 'sibling' } + } + }, + codexRestartNoticeByPtyId: { + old: { previousAccountLabel: 'old', nextAccountLabel: 'new', incarnationId: 'inc-old' } + } + }) +}) +afterEach(() => vi.unstubAllGlobals()) + +it('blocks failed old-endpoint typing after remount while preserving replies and sibling input', () => { + failCurrent() + const old = input('old'), + other = input('sibling', sibling) + old.type('must not reach the old account\r') + old.type('\x1b[1;1R') + other.type('sibling command\r') + expect(old.writes).toEqual([]) + expect(old.replies).toEqual(['\x1b[1;1R']) + expect(other.writes).toEqual(['sibling command\r']) +}) + +it('leaves a same-ID successor and its input untouched and retires the obsolete failure at binding', () => { + failCurrent() + const successor = input('old', leaf, 'inc-successor') + successor.type('successor command\r') + useAppStore.getState().retireCodexRestartEndpoint('old', 'inc-successor') + expect(successor.writes).toEqual(['successor command\r']) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) +}) + +it('keeps rootless recovery failure guarded without borrowing a sibling binding', () => { + useAppStore.setState({ + ptyIdsByTabId: { tab: ['old'] }, + terminalLayoutsByTabId: { + tab: { + root: null, + activeLeafId: 'legacy', + expandedLeafId: null, + ptyIdsByLeafId: { legacy: 'old' } + } + } + }) + useAppStore.getState().queuePaneAccountRestarts(['old'], 'codex') + useAppStore.getState().clearCodexRestartNotice('old') + const old = input('old') + old.type('must remain blocked\r') + expect(old.writes).toEqual([]) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) +}) diff --git a/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart.test.ts b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart.test.ts index b91aeda3166..adc8baebb60 100644 --- a/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/account-restart/codex-account-restart.test.ts @@ -1,3 +1,16 @@ +import { + admitTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '../provider-notices/terminal-notice-lifetime' +import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host' +import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture' +import { TEST_REPO } from '@/store/repos/repo.test-fixture' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/rpc/testing/runtime-compatibility-test-fixture' +import type { PublicKnownRuntimeEnvironment } from '../../../../../shared/remote-runtime/host-catalog/runtime-environments' +import { bindStartFreshSpawn } from '../pty-connection/spawn/fresh-spawn-start' +import { settleFreshPaneSpawn } from '../pty-connection/spawn/fresh-pane-spawn-settlement' +import type { PtyTransport } from '../pty-transport/pty-transport-types' +type PtyConnectOptions = Parameters[0] import { getPaneSpawnDeferral } from '../session-restore/spawn-deferral/pane-spawn-deferral-registry' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' @@ -96,19 +109,70 @@ function receipt( }) ) } -function snapshot(owner: { ptyId: string; incarnationId: string } | null, generation = 0) { +function snapshot( + owner: { ptyId: string; incarnationId: string } | null, + generation = 0, + tabCreatedAt = 1 +) { return { ok: true, id: 'resolve', result: { terminal: { handle: 'handle-old', - accountRestart: { hostEpoch: 'host', tabCreatedAt: 1, bindingGeneration: generation, owner } + accountRestart: { hostEpoch: 'host', tabCreatedAt, bindingGeneration: generation, owner } } } } } +function mountedSpawnSession( + request: ReturnType, + scrollback: Map, + connect: (options: PtyConnectOptions) => Promise, + incarnationId?: string +) { + const noop = () => {} + return { + deps: { + tabId: 'tab', + worktreeId: 'wt', + accountRestartToken: codexAccountRestartToken(request), + accountRestart: request.condition, + isVisibleRef: { current: false }, + invalidateRetainedInlineScrollback: (leafId: string) => scrollback.delete(leafId) + }, + pane: { id: 1, leafId: leaf }, + paneStartup: { command: 'codex resume conversation-1' }, + remotePtyIncarnationId: incarnationId, + disposed: false, + tabGeneration: 0, + transportStreamGeneration: 1, + authoritativeReattachGeneration: 0, + pendingSpawnKey: key, + cacheKey: key, + userRestart: false, + runtimeEnvironmentId: null, + transport: { connect, getPtyId: (): string | null => null }, + clearPaneMode2031State: noop, + clearHiddenOutputRestoreState: noop, + resetFreshSpawnFollowOutput: noop, + groundInputModesForNewProcess: noop, + prepareFreshShellViewportForSpawn: noop, + shouldDeclareHiddenAtSpawn: () => true, + captureTransportOutputCallbacks: (onError: (message: string) => void) => ({ + generation: 1, + callbacks: { onError } + }), + reportError: noop, + finishReattachLiveDataDeferral: noop, + noteBoundProcessIncarnation: noop, + settlePaneAttachAttempt: noop, + startFreshSpawn: (_startup?: { command: string }): Promise => + Promise.resolve(null) + } +} + beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) resetDetachedCodexPaneRestartClaimsForTests() @@ -116,6 +180,8 @@ beforeEach(() => { call.mockReset().mockResolvedValue(snapshot({ ptyId: 'old', incarnationId: 'inc-old' })) spawn.mockReset().mockResolvedValue({ id: 'replacement' }) vi.stubGlobal('window', { api: { pty: { spawn, kill: vi.fn() }, runtime: { call } } }) + window.api.pty.declarePendingPaneSerializer = vi.fn().mockResolvedValue(1) + window.api.pty.clearPendingPaneSerializer = vi.fn().mockResolvedValue(undefined) seed() }) afterEach(() => { @@ -277,4 +343,356 @@ describe('Codex account continuation', () => { expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe('resumed') expect(getPaneSpawnDeferral(key)).toBeUndefined() }) + it.each(['callback', 'rejection'] as const)( + 'settles the mounted %s failure after userRestart was cleared without clearing scrollback', + async (mode) => { + const request = accepted() + await prepareCodexAccountRestart(request) + const failure = receipt(request, 'confirmed-retired', 'empty') + const scrollback = new Map([ + [leaf, 'old transcript'], + [sibling, 'sibling transcript'] + ]) + const session = mountedSpawnSession(request, scrollback, async (options) => { + if (mode === 'callback') { + options.callbacks?.onError?.(failure.message) + return undefined + } + throw failure + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every member used by these failed-spawn paths. + bindStartFreshSpawn(session as never) + await session.startFreshSpawn({ command: 'codex resume conversation-1' }) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId).toEqual({ + [sibling]: 'sibling' + }) + expect([...scrollback]).toEqual([ + [leaf, 'old transcript'], + [sibling, 'sibling transcript'] + ]) + } + ) + + it('clears only the completed mounted replacement scrollback and ignores an obsolete result', async () => { + const request = accepted() + await prepareCodexAccountRestart(request) + const token = codexAccountRestartToken(request) + const scrollback = new Map([ + [leaf, 'old transcript'], + [sibling, 'sibling transcript'] + ]) + const session = mountedSpawnSession(request, scrollback, async () => undefined) + session.transport.getPtyId = () => 'replacement' + useAppStore.getState().updateCodexAccountRestart(token, { resultPtyId: 'replacement' }) + useAppStore.getState().updateTabPtyId('tab', 'replacement', 'old') + useAppStore.getState().replaceTerminalLayoutPanePtyId('tab', leaf, 'replacement') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the account settlement and transport members exercised here. + settleFreshPaneSpawn(session as never, { + spawnedPtyId: 'replacement', + accountError: null, + accountDeferred: false, + ownerRefused: false, + generation: 1, + startupOverride: null, + options: {} + }) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + expect([...scrollback]).toEqual([[sibling, 'sibling transcript']]) + scrollback.set(leaf, 'successor transcript') + useAppStore.getState().replaceTerminalLayoutPanePtyId('tab', leaf, 'successor') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the account settlement and transport members exercised here. + settleFreshPaneSpawn(session as never, { + spawnedPtyId: 'replacement', + accountError: null, + accountDeferred: false, + ownerRefused: false, + generation: 1, + startupOverride: null, + options: {} + }) + expect(scrollback.get(leaf)).toBe('successor transcript') + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe( + 'successor' + ) + }) + it.each(['main frame', 'runtime mirror'] as const)( + 'retires a failed split leaf inside the accepted %s commit and ignores its late catch', + async (source) => { + const request = accepted() + await prepareCodexAccountRestart(request) + const token = codexAccountRestartToken(request) + failCodexAccountRestart(token, receipt(request, 'not-started', 'original')) + const patch = { + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf' as const, leafId: sibling }, + activeLeafId: sibling, + expandedLeafId: null, + ptyIdsByLeafId: { [sibling]: 'sibling' } + } + } + } + if (source === 'main frame') { + const { showFramePatch } = await import('@/store/slices/tabs/moved-tab-selection') + showFramePatch(useAppStore, patch) + } else { + const { applyWebSessionTabsStorePatch } = + await import('@/runtime/web-session-tabs-sync/store-patch') + applyWebSessionTabsStorePatch(() => patch, { frames: [] }) + } + failCodexAccountRestart(token, receipt(request, 'confirmed-retired', 'empty')) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId).toEqual({ + [sibling]: 'sibling' + }) + expect(spawn).not.toHaveBeenCalled() + } + ) + + it('reconciles a receipt from another attempt as unknown and never dispatches a duplicate', async () => { + const request = accepted() + await prepareCodexAccountRestart(request) + failCodexAccountRestart( + codexAccountRestartToken(request), + new Error( + formatAccountRestartFailure('launch refused', { + v: 1, + requestId: 'another-request', + attemptId: 'another-attempt', + stop: 'confirmed-retired', + outcome: 'terminal', + current: { kind: 'empty', bindingGeneration: 1 } + }) + ) + ) + const current = useAppStore.getState().paneAccountRestarts[key] + if (typeof current !== 'object') { + throw new Error('unknown attempt was lost') + } + await retryCodexAccountRestarts([current]) + await sweepUnclaimedCodexPaneRestarts() + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) + expect(spawn).not.toHaveBeenCalled() + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe('old') + }) + it.each(['pairing', 'runtime'] as const)( + 'retires the captured request atomically when its %s identity changes', + async (change) => { + const environment: PublicKnownRuntimeEnvironment = { + id: 'env-account', + name: 'Test host', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + pairingRevision: 1, + hostKeyFingerprint: 'same-host', + endpoints: [ + { id: 'ws-account', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://test-host' } + ], + preferredEndpointId: 'ws-account' + } + useAppStore.getState().setRuntimeEnvironments([environment]) + const hostId = toRuntimeExecutionHostId('env-account') + useAppStore.setState({ + repos: [{ ...TEST_REPO, id: 'remote-repo', executionHostId: hostId }], + worktreesByRepo: { + 'remote-repo': [ + makeWorktree({ + id: 'wt', + repoId: 'remote-repo', + hostId, + runtimeOwnerEnvironmentId: 'env-account' + }) + ] + } + }) + useAppStore.getState().setRuntimeEnvironmentStatus('env-account', { + checkedAt: 1, + status: createCompatibleRuntimeStatusResponse('runtime-before').result + }) + const request = accepted() + useAppStore + .getState() + .updateCodexAccountRestart(codexAccountRestartToken(request), { phase: 'failed' }) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) + if (change === 'pairing') { + useAppStore.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + } else { + useAppStore.getState().setRuntimeEnvironmentStatus('env-account', { + checkedAt: 2, + status: createCompatibleRuntimeStatusResponse('runtime-after').result + }) + } + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + if (change === 'pairing') { + useAppStore.getState().setRuntimeEnvironments([environment]) + } else { + useAppStore.getState().setRuntimeEnvironmentStatus('env-account', { + checkedAt: 3, + status: createCompatibleRuntimeStatusResponse('runtime-before').result + }) + } + await retryCodexAccountRestarts([request]) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe('old') + expect(spawn).not.toHaveBeenCalled() + } + ) + it('keeps a late terminal failure authoritative over an older Retry read and then restarts its empty leaf', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers<{ id: string }>() + spawn.mockReturnValueOnce(pending.promise) + useAppStore.getState().queuePaneAccountRestarts(['old'], 'codex') + const running = sweepUnclaimedCodexPaneRestarts() + await vi.advanceTimersByTimeAsync(STALLED_DETACHED_RESTART_MS) + const request = useAppStore.getState().paneAccountRestarts[key] + if (typeof request !== 'object') { + throw new Error('stalled attempt was lost') + } + call.mockRejectedValueOnce(new Error('rpc_timeout')) + await retryCodexAccountRestarts([request]) + const read = Promise.withResolvers>() + call.mockReturnValueOnce(read.promise) + const reconciling = retryCodexAccountRestarts([request]) + pending.reject(receipt(request, 'confirmed-retired', 'empty')) + await running + read.resolve(snapshot({ ptyId: 'old', incarnationId: 'inc-old' })) + await reconciling + const failed = useAppStore.getState().paneAccountRestarts[key] + if (typeof failed !== 'object') { + throw new Error('terminal failure was discarded by an obsolete read') + } + await retryCodexAccountRestarts([failed]) + call.mockResolvedValue(snapshot(null, 1)) + spawn.mockResolvedValue({ id: 'after-late-failure' }) + await sweepUnclaimedCodexPaneRestarts() + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[leaf]).toBe( + 'after-late-failure' + ) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + expect(spawn).toHaveBeenCalledTimes(2) + }) + it('reopens a legacy Codex queue prompt without entering the unguarded restart route', async () => { + useAppStore.setState({ + paneAccountRestarts: { old: 'codex' }, + codexRestartNoticeByPtyId: { old: { ...notice, restartRequested: true } } + }) + await sweepUnclaimedCodexPaneRestarts() + expect(spawn).not.toHaveBeenCalled() + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) + }) + it.each(['mounted', 'detached'] as const)( + 'preserves a known same-ID successor scrollback after a late %s result', + async (mode) => { + vi.useFakeTimers() + const pending = Promise.withResolvers<{ id: string; incarnationId: string }>() + let request: ReturnType + let running: Promise | undefined + if (mode === 'mounted') { + request = accepted() + await prepareCodexAccountRestart(request) + } else { + spawn.mockReturnValueOnce(pending.promise) + useAppStore.getState().queuePaneAccountRestarts(['old'], 'codex') + running = sweepUnclaimedCodexPaneRestarts() + await vi.advanceTimersByTimeAsync(0) + const current = useAppStore.getState().paneAccountRestarts[key] + if (typeof current !== 'object') { + throw new Error('attempt was not dispatched') + } + request = current + } + const lifetime = admitTerminalNoticeLifetime('old', { + incarnationId: 'inc-successor', + fresh: true + }) + try { + const layout = useAppStore.getState().terminalLayoutsByTabId.tab + useAppStore + .getState() + .setTabLayout('tab', { ...layout, buffersByLeafId: { [leaf]: 'successor transcript' } }) + if (mode === 'mounted') { + const scrollback = new Map([[leaf, 'successor transcript']]) + const session = mountedSpawnSession( + request, + scrollback, + async () => undefined, + 'inc-own-replacement' + ) + session.transport.getPtyId = () => 'old' + useAppStore + .getState() + .updateCodexAccountRestart(codexAccountRestartToken(request), { resultPtyId: 'old' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the settlement and captured incarnation members read here. + settleFreshPaneSpawn(session as never, { + spawnedPtyId: 'old', + accountError: null, + accountDeferred: false, + ownerRefused: false, + generation: 1, + startupOverride: null, + options: {} + }) + expect(scrollback.get(leaf)).toBe('successor transcript') + } else { + pending.resolve({ id: 'old', incarnationId: 'inc-own-replacement' }) + await running + } + expect(useAppStore.getState().terminalLayoutsByTabId.tab.buffersByLeafId?.[leaf]).toBe( + 'successor transcript' + ) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + } finally { + retireTerminalNoticeLifetime(lifetime) + } + } + ) + + it('reopens the live moved endpoint without carrying the old request into its new pane', async () => { + const request = accepted() + await prepareCodexAccountRestart(request) + failCodexAccountRestart( + codexAccountRestartToken(request), + receipt(request, 'not-started', 'original') + ) + const { showFramePatch } = await import('@/store/slices/tabs/moved-tab-selection') + const state = useAppStore.getState(), + tab = state.tabsByWorktree.wt[0] + showFramePatch(useAppStore, { + tabsByWorktree: { + wt: [ + { ...tab, ptyId: 'sibling' }, + { ...tab, id: 'tab-moved', createdAt: 2 } + ] + }, + ptyIdsByTabId: { tab: ['sibling'], 'tab-moved': ['old'] }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf', leafId: sibling }, + activeLeafId: sibling, + expandedLeafId: null, + ptyIdsByLeafId: { [sibling]: 'sibling' } + }, + 'tab-moved': { + root: { type: 'leaf', leafId: leaf }, + activeLeafId: leaf, + expandedLeafId: null, + ptyIdsByLeafId: { [leaf]: 'old' } + } + } + }) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(1) + useAppStore.getState().queuePaneAccountRestarts(['old'], 'codex') + call.mockResolvedValue(snapshot({ ptyId: 'old', incarnationId: 'inc-old' }, 0, 2)) + await sweepUnclaimedCodexPaneRestarts() + expect(useAppStore.getState().terminalLayoutsByTabId['tab-moved'].ptyIdsByLeafId?.[leaf]).toBe( + 'replacement' + ) + expect(useAppStore.getState().terminalLayoutsByTabId.tab.ptyIdsByLeafId?.[sibling]).toBe( + 'sibling' + ) + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + }) }) diff --git a/src/renderer/src/components/terminal-pane/account-restart/detached/codex-detached-account-continuation.ts b/src/renderer/src/components/terminal-pane/account-restart/detached/codex-detached-account-continuation.ts index 1468b5e90e2..1e8cda4894d 100644 --- a/src/renderer/src/components/terminal-pane/account-restart/detached/codex-detached-account-continuation.ts +++ b/src/renderer/src/components/terminal-pane/account-restart/detached/codex-detached-account-continuation.ts @@ -18,6 +18,7 @@ import type { RuntimeTerminalCreate } from '../../../../../../shared/runtime-ter import type { AccountRestartCondition } from '../../../../../../shared/terminal/pty/lifecycle/account-restart-contract' import { prepareCodexAccountRestart, + isObsoleteCodexRestartResult, failCodexAccountRestart } from '../codex-account-restart-execution' import { clearRetainedInlineScrollback } from '../../snapshots/terminal-scrollback-ref-hydration' @@ -52,7 +53,7 @@ function prepareDetachedRequest( function dispatchDetachedRequest( token: CodexAccountRestartToken, condition: AccountRestartCondition -): Promise | null { +): Promise<{ id: string; incarnationId?: string | null }> | null { const state = useAppStore.getState(), request = currentCodexAccountRestart(state, token) if (!request?.startup || !request.leafId) { @@ -88,7 +89,10 @@ function dispatchDetachedRequest( expectedEnvironmentPairingRevision: request.pairingRevision, expectedEnvironmentRuntimeId: request.runtimeId } - ).then((result) => toRemoteRuntimePtyId(result.terminal.handle, environmentId)) + ).then((result) => ({ + id: toRemoteRuntimePtyId(result.terminal.handle, environmentId), + incarnationId: result.terminal.incarnationId + })) } return window.api.pty .spawn({ @@ -110,7 +114,7 @@ function dispatchDetachedRequest( ...(request.projectRuntime ? { projectRuntime: request.projectRuntime } : {}), initiallyHidden: true }) - .then((result) => result.id) + .then((result) => ({ id: result.id, incarnationId: result.incarnationId })) } export async function restartDetachedAccountRequest( @@ -137,12 +141,17 @@ export async function restartDetachedAccountRequest( if (!pending) { return } - const id = await pending + const result = await pending + const { id } = result const state = useAppStore.getState(), request = currentCodexAccountRestart(state, token) if (!request?.leafId) { return } + if (isObsoleteCodexRestartResult(request, result)) { + state.updateCodexAccountRestart(token, null) + return + } state.updateCodexAccountRestart(token, { resultPtyId: id }) state.updateTabPtyId(request.tabId, id, request.oldPtyId) state.replaceTerminalLayoutPanePtyId(request.tabId, request.leafId, id) diff --git a/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts b/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts index 73dd783070f..317afb22ce9 100644 --- a/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts +++ b/src/renderer/src/components/terminal-pane/account-restart/use-ready-pane-account-restarts.ts @@ -57,21 +57,18 @@ export function useReadyPaneAccountRestarts({ const token = codexAccountRestartToken(request) void prepareCodexAccountRestart(request) .then((condition) => { - if ( - !condition || - !request.startup || - !currentCodexAccountRestart(useAppStore.getState(), token) - ) { + const latest = currentCodexAccountRestart(useAppStore.getState(), token) + if (!condition || !latest?.startup) { return } if ( managerRef.current !== manager || - !manager.getPanes().some((p) => p.id === pane.id && p.leafId === request.leafId) + !manager.getPanes().some((p) => p.id === pane.id && p.leafId === latest.leafId) ) { useAppStore.getState().updateCodexAccountRestart(token, { phase: 'accepted' }) return } - handleRestartPane(pane.id, request.startup, { + handleRestartPane(pane.id, latest.startup, { focus: true, accountRestart: condition, accountRestartToken: token diff --git a/src/renderer/src/components/terminal-pane/errors/terminal-account-restart-error.test.ts b/src/renderer/src/components/terminal-pane/errors/terminal-account-restart-error.test.ts new file mode 100644 index 00000000000..93d9ae62478 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/errors/terminal-account-restart-error.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { humanizeTerminalError } from './terminal-error-display' +import { formatAccountRestartFailure } from '../../../../../shared/terminal/pty/lifecycle/account-restart-contract' + +it('keeps diagnostics readable while the transport carries restart proof for each failed attempt', () => { + const input = [ + formatAccountRestartFailure('First launch refused.', { + v: 1, + requestId: 'request-1', + attemptId: 'attempt-1', + stop: 'not-started', + outcome: 'terminal' + }), + formatAccountRestartFailure('Second launch refused.', { + v: 1, + requestId: 'request-2', + attemptId: 'attempt-2', + stop: 'confirmed-retired', + outcome: 'terminal' + }) + ].join('\n') + expect(humanizeTerminalError(input, undefined, (_key, fallback) => fallback)).toBe( + 'First launch refused.\nSecond launch refused.' + ) +}) diff --git a/src/renderer/src/components/terminal-pane/errors/terminal-error-display.ts b/src/renderer/src/components/terminal-pane/errors/terminal-error-display.ts index a32726e614d..34371efa95d 100644 --- a/src/renderer/src/components/terminal-pane/errors/terminal-error-display.ts +++ b/src/renderer/src/components/terminal-pane/errors/terminal-error-display.ts @@ -1,3 +1,4 @@ +import { withoutAccountRestartReceipt } from '../../../../../shared/terminal/pty/lifecycle/account-restart-contract' import { unwrapHostRefusalIpcLines } from './terminal-error-ipc-unwrap' import { translate } from '../../../i18n/i18n' import type { Translate } from '../../../i18n/use-translate' @@ -157,7 +158,10 @@ export function humanizeTerminalError( return describeSshSignInRequiredTerminalError(error, t) } // Hosts send a held start as its code (older) or English sentence (newer); either reads in the user's language. - const unwrapped = localizeWslDistroHoldText(unwrapHostRefusalIpcLines(error, t), t) + const unwrapped = localizeWslDistroHoldText( + unwrapHostRefusalIpcLines(withoutAccountRestartReceipt(error), t), + t + ) let humanized = humanizeFolderWorkspacePathErrors( localizeTerminalSpawnHints(agentLaunchErrorText(unwrapped, agentId, t) ?? unwrapped, t) ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/input/codex-pane-stale.ts b/src/renderer/src/components/terminal-pane/pty-connection/input/codex-pane-stale.ts index 97ea3ed5cd9..a6b417f7683 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/input/codex-pane-stale.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/input/codex-pane-stale.ts @@ -24,19 +24,29 @@ export function hasCodexRestartNotices( export function isCodexPaneStale(args: { tabId: string worktreeId: string + leafId: string panePtyId: string | null + paneIncarnationId?: string | null }): boolean { const state = useAppStore.getState() const { codexRestartNoticeByPtyId } = state if (args.panePtyId) { - const layout = state.terminalLayoutsByTabId[args.tabId] - const leafId = Object.keys(layout?.ptyIdsByLeafId ?? {}).find( - (leaf) => layout.ptyIdsByLeafId?.[leaf] === args.panePtyId - ) - const request = leafId ? state.paneAccountRestarts[`${args.tabId}:${leafId}`] : undefined + const request = + state.paneAccountRestarts[`${args.tabId}:${args.leafId}`] ?? + state.paneAccountRestarts[`${args.tabId}:unresolved`] + const expectedIncarnation = isCodexAccountRestart(request) + ? (request.oldIncarnationId ?? + (request.condition?.expected.kind === 'endpoint' + ? request.condition.expected.incarnationId + : undefined)) + : undefined if ( isCodexAccountRestart(request) && request.oldPtyId === args.panePtyId && + request.worktreeId === args.worktreeId && + (!args.paneIncarnationId || + !expectedIncarnation || + args.paneIncarnationId === expectedIncarnation) && isCurrentCodexAccountRestart(state, request) ) { return true @@ -47,7 +57,13 @@ export function isCodexPaneStale(args: { } // The bound PTY owns this pane’s input; tab.ptyId may name a sibling whose notice must not block this keyboard. if (args.panePtyId) { - return blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId]) + const notice = codexRestartNoticeByPtyId[args.panePtyId] + return ( + blocksCodexPaneInput(notice) && + (!args.paneIncarnationId || + !notice?.incarnationId || + notice.incarnationId === args.paneIncarnationId) + ) } return false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts index 44b27342945..209c18a00ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts @@ -36,12 +36,14 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.sendDesktopQueryReplyImmediate(data) return } - // Account switches change runtime auth; block stale-pane typing until restart, using persisted IDs during unbound reconnect windows. + // Account switches change runtime auth; only this bound endpoint can own its input guard. if ( isCodexPaneStale({ tabId: session.deps.tabId, worktreeId: session.deps.worktreeId, - panePtyId: currentPtyId + leafId: session.pane.leafId, + panePtyId: currentPtyId, + paneIncarnationId: session.remotePtyIncarnationId }) ) { session.clearPendingTerminalInputIntent() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts index c3cb5c88c9a..cf8a5c5501f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts @@ -1,3 +1,4 @@ +import { useAppStore } from '@/store' import { PROCESS_BOUNDARY_GROUND } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' import type { ConnectPanePtySession } from '../connect-pane-pty-session' @@ -18,6 +19,16 @@ export function bindReplacedProcessInputModes(session: ConnectPanePtySession): v if (!incarnationId || incarnationId === session.inputModeOwnerIncarnationId) { return } + const ptyId = session.transport.getPtyId() + if ( + ptyId && + !session.disposed && + session.deps.paneTransportsRef?.current.get(session.pane.id) === session.transport + ) { + useAppStore + .getState() + .retireCodexRestartEndpoint?.(ptyId, incarnationId, session.deps.accountRestartToken) + } if (session.inputModeOwnerIncarnationId !== null) { session.groundInputModesForNewProcess() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn/fresh-pane-spawn-settlement.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn/fresh-pane-spawn-settlement.ts index 7bce7f6d342..bf1d80795ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn/fresh-pane-spawn-settlement.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn/fresh-pane-spawn-settlement.ts @@ -1,4 +1,7 @@ -import { failCodexAccountRestart } from '../../account-restart/codex-account-restart-execution' +import { + isObsoleteCodexRestartResult, + failCodexAccountRestart +} from '../../account-restart/codex-account-restart-execution' import { currentCodexAccountRestart } from '@/store/terminals/codex-account-restart-continuation' import { useAppStore } from '@/store' import { armPaneOwnerRetry } from '../host-ownership/pane-owner-recovery' @@ -34,6 +37,15 @@ export function settleFreshPaneSpawn( if (!request) { return } + if ( + isObsoleteCodexRestartResult(request, { + id: spawnedPtyId, + incarnationId: session.remotePtyIncarnationId + }) + ) { + useAppStore.getState().updateCodexAccountRestart(accountToken, null) + return + } if (session.disposed || generation !== session.transportStreamGeneration) { useAppStore.getState().updateCodexAccountRestart(accountToken, { phase: 'unresolved', diff --git a/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart-shell-flap.test.ts b/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart-shell-flap.test.ts index fecb00749d8..c24dbbdbd0d 100644 --- a/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart-shell-flap.test.ts +++ b/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart-shell-flap.test.ts @@ -1,3 +1,8 @@ +import { + admitTerminalNoticeLifetime, + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/layout/terminal-layout-normalization' import { selectCodexStaleSessionCount } from '@/components/status-bar/codex-restart-status-summary' import { sweepUnclaimedCodexPaneRestarts } from '@/components/terminal-pane/account-restart/detached/codex-detached-pane-restart' @@ -166,4 +171,28 @@ describe('spurious shell readings on Codex-launched panes', () => { expect(window.api.pty.spawn).not.toHaveBeenCalled() expect(window.api.pty.kill).not.toHaveBeenCalled() }) + it('drops a delayed same-ID scan when its admitted process incarnation was replaced', async () => { + const initial = admitTerminalNoticeLifetime('pty-1', { incarnationId: 'inc-old', fresh: true }) + const inspection = Promise.withResolvers<{ + foregroundProcess: string + hasChildProcesses: boolean + }>() + vi.mocked(window.api.pty.inspectProcess).mockReturnValue(inspection.promise) + const scanning = markLiveCodexSessionsForRestart({ + owner: focusedOwner(), + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + admitTerminalNoticeLifetime('pty-1', { incarnationId: 'inc-successor', fresh: true }) + inspection.resolve({ foregroundProcess: 'codex', hasChildProcesses: true }) + await scanning + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + useAppStore.getState().queuePaneAccountRestarts(['pty-1'], 'codex') + expect(selectCodexStaleSessionCount(useAppStore.getState())).toBe(0) + const current = getTerminalNoticeLifetime('pty-1') + if (current) { + retireTerminalNoticeLifetime(current) + } + retireTerminalNoticeLifetime(initial) + }) }) diff --git a/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart.ts b/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart.ts index 2864d185e13..bd0646f9649 100644 --- a/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart.ts +++ b/src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart.ts @@ -1,3 +1,4 @@ +import { getTerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import { currentCodexRestartPanes } from '@/store/terminals/codex-account-restart-continuation' import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' import { useAppStore } from '@/store/index' @@ -30,12 +31,7 @@ import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui export type CodexPaneScanResult = { ptyId: string - origin?: { - key: string - tabCreatedAt: number - hostId: string - layout: AppState['terminalLayoutsByTabId'][string] | undefined - } + origin?: Parameters[0][number]['origin'] incarnationId?: string /** The pane may be shown a restart prompt (see isCodexRestartEligiblePane). */ eligible: boolean @@ -100,20 +96,21 @@ async function scanCodexPanes( (ptyId) => eligiblePanes.has(ptyId) && (args.ptyIdFilter === null || args.ptyIdFilter.has(ptyId)) ) - .map((ptyId) => ({ tab, ptyId })) + .map((ptyId) => ({ tab, ptyId, lifetime: getTerminalNoticeLifetime(ptyId) })) ) const recordedLanes = await readRecordedCodexPaneLanes(panes.map((pane) => pane.ptyId)) // Labels can remain fallback names; use live process evidence plus recorded launchAgent to find Codex. return Promise.all( - panes.map(async ({ tab, ptyId }) => { + panes.map(async ({ tab, ptyId, lifetime }) => { const pane = eligiblePanes.get(ptyId) const origin = pane ? { key: pane.key, tabCreatedAt: pane.tabCreatedAt, hostId: getExecutionHostIdForWorktree(state, pane.worktreeId), - layout: state.terminalLayoutsByTabId[pane.tabId] + layout: state.terminalLayoutsByTabId[pane.tabId], + lifetime } : undefined const lane = resolveCodexPaneSelectionLane({ diff --git a/src/renderer/src/store/slices/runtime-status/account-restart-runtime-retirement.ts b/src/renderer/src/store/slices/runtime-status/account-restart-runtime-retirement.ts new file mode 100644 index 00000000000..f9c4802be1d --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status/account-restart-runtime-retirement.ts @@ -0,0 +1,83 @@ +import { lastVerifiedRuntimeStatus } from '../../../../../shared/remote-runtime/host-status/runtime-host-status' +import { isCodexAccountRestart } from '@/store/terminals/codex-account-restart-continuation' +import { releaseRetiredAccountRestartSlots } from '@/components/terminal-pane/account-restart/detached/detached-pane-restart-slots' +import type { RuntimeStatusStoreSetState, RuntimeStatusStoreState } from './runtime-status-types' + +function retireChangedRuntimeRequests( + state: RuntimeStatusStoreState, + patch: Partial +): Partial { + if ( + !state.paneAccountRestarts || + (!patch.runtimeEnvironments && !patch.runtimeStatusByEnvironmentId) + ) { + return patch + } + const catalog = patch.runtimeEnvironments + ? new Map(patch.runtimeEnvironments.map((e) => [e.id, e])) + : null + let requests = state.paneAccountRestarts + const originalNotices = patch.codexRestartNoticeByPtyId ?? state.codexRestartNoticeByPtyId + let notices = originalNotices + for (const [key, request] of Object.entries(requests)) { + if (!isCodexAccountRestart(request) || !request.environmentId) { + continue + } + const environment = catalog?.get(request.environmentId) + const previous = state.runtimeEnvironments.find((e) => e.id === request.environmentId) + const expectedRevision = + request.pairingRevision ?? previous?.pairingRevision ?? previous?.createdAt + const revisionChanged = + catalog && + (!environment || (environment.pairingRevision ?? environment.createdAt) !== expectedRevision) + const runtimeId = + patch.runtimeStatusByEnvironmentId && + lastVerifiedRuntimeStatus(patch.runtimeStatusByEnvironmentId.get(request.environmentId)) + ?.runtimeId + if (!revisionChanged && !(request.runtimeId && runtimeId && runtimeId !== request.runtimeId)) { + continue + } + if (requests === state.paneAccountRestarts) { + requests = { ...requests } + } + delete requests[key] + const notice = notices?.[request.oldPtyId] + if ( + notices && + notice?.restartRequested && + (!notice.incarnationId || notice.incarnationId === request.oldIncarnationId) + ) { + if (notices === originalNotices) { + notices = { ...notices } + } + delete notices[request.oldPtyId] + } + } + return requests === state.paneAccountRestarts + ? patch + : { + ...patch, + paneAccountRestarts: requests, + ...(notices !== originalNotices ? { codexRestartNoticeByPtyId: notices } : {}) + } +} + +export function withAccountRestartRuntimeRetirement( + set: RuntimeStatusStoreSetState, + get: () => RuntimeStatusStoreState +): RuntimeStatusStoreSetState { + return (update) => { + const before = get().paneAccountRestarts + try { + set((state) => { + const patch = typeof update === 'function' ? update(state) : update + return patch === state ? state : retireChangedRuntimeRequests(state, patch) + }) + } finally { + const after = get().paneAccountRestarts + if (before && after) { + releaseRetiredAccountRestartSlots(before, after) + } + } + } +} diff --git a/src/renderer/src/store/slices/runtime-status/create-runtime-status-slice.ts b/src/renderer/src/store/slices/runtime-status/create-runtime-status-slice.ts index 74fb5df56c4..e68314f5d54 100644 --- a/src/renderer/src/store/slices/runtime-status/create-runtime-status-slice.ts +++ b/src/renderer/src/store/slices/runtime-status/create-runtime-status-slice.ts @@ -1,3 +1,4 @@ +import { withAccountRestartRuntimeRetirement } from './account-restart-runtime-retirement' import { createRuntimeEnvironmentStatusRetention } from './runtime-environment-status-retention' import type { RuntimeStatusSlice, @@ -43,257 +44,263 @@ export const createRuntimeStatusSlice: ( set: RuntimeStatusStoreSetState, get: () => RuntimeStatusStoreState, store?: unknown -) => RuntimeStatusSlice = (set, get) => ({ - runtimeEnvironments: [], - runtimeEnvironmentCatalogHydrated: false, - runtimeEnvironmentCatalogSettled: false, - runtimeStatusByEnvironmentId: new Map(), - removedRuntimeEnvironmentIds: new Set(), +) => RuntimeStatusSlice = (setState, get) => { + const set = withAccountRestartRuntimeRetirement(setState, get) + return { + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: false, + runtimeEnvironmentCatalogSettled: false, + runtimeStatusByEnvironmentId: new Map(), + removedRuntimeEnvironmentIds: new Set(), - readRuntimeHostStatusSnapshots: async () => { - try { - const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() - snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) - } catch (error) { - console.error('Failed to read runtime host status:', error) - } - }, + readRuntimeHostStatusSnapshots: async () => { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) + } catch (error) { + console.error('Failed to read runtime host status:', error) + } + }, - setRuntimeEnvironments: (environments) => { - const previousEnvironments = get().runtimeEnvironments - const replacedEnvironmentIds = replacedRuntimeEnvironmentIds(previousEnvironments, environments) - const peerReplacement = classifyPeerReplacements( - previousEnvironments, - environments, - replacedEnvironmentIds - ) - // Fence replaced-peer transports before publishing revisions; only proven same-host rotations may continue. - replaceRuntimeEnvironmentRevisions(environments, peerReplacement) - // Diff accumulated in-memory state so main-initiated removals reach the purge on the next list read (#8881). - const nextIds = new Set(environments.map((environment) => environment.id)) - const removedIds = get() - .runtimeEnvironments.map((environment) => environment.id) - .filter((id) => !nextIds.has(id)) - set((s) => { - const keep = new Set(environments.map((environment) => environment.id)) - const removedStatusIds = new Set() - for (const id of s.runtimeStatusByEnvironmentId.keys()) { - if (!keep.has(id)) { - removedStatusIds.add(id) + setRuntimeEnvironments: (environments) => { + const previousEnvironments = get().runtimeEnvironments + const replacedEnvironmentIds = replacedRuntimeEnvironmentIds( + previousEnvironments, + environments + ) + const peerReplacement = classifyPeerReplacements( + previousEnvironments, + environments, + replacedEnvironmentIds + ) + // Fence replaced-peer transports before publishing revisions; only proven same-host rotations may continue. + replaceRuntimeEnvironmentRevisions(environments, peerReplacement) + // Diff accumulated in-memory state so main-initiated removals reach the purge on the next list read (#8881). + const nextIds = new Set(environments.map((environment) => environment.id)) + const removedIds = get() + .runtimeEnvironments.map((environment) => environment.id) + .filter((id) => !nextIds.has(id)) + set((s) => { + const keep = new Set(environments.map((environment) => environment.id)) + const removedStatusIds = new Set() + for (const id of s.runtimeStatusByEnvironmentId.keys()) { + if (!keep.has(id)) { + removedStatusIds.add(id) + runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(id) + } + } + for (const id of replacedEnvironmentIds) { + if (s.runtimeStatusByEnvironmentId.has(id)) { + removedStatusIds.add(id) + } runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(id) } - } - for (const id of replacedEnvironmentIds) { - if (s.runtimeStatusByEnvironmentId.has(id)) { - removedStatusIds.add(id) + const statusesChanged = removedStatusIds.size > 0 + // Tombstone removed IDs, clearing re-added ones, to reject late catalog merges without mistaking pre-hydration absence for removal (#8881). + const nextRemoved = new Set(s.removedRuntimeEnvironmentIds) + let removedChanged = false + for (const id of removedIds) { + if (!nextRemoved.has(id)) { + nextRemoved.add(id) + removedChanged = true + } } - runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(id) - } - const statusesChanged = removedStatusIds.size > 0 - // Tombstone removed IDs, clearing re-added ones, to reject late catalog merges without mistaking pre-hydration absence for removal (#8881). - const nextRemoved = new Set(s.removedRuntimeEnvironmentIds) - let removedChanged = false - for (const id of removedIds) { - if (!nextRemoved.has(id)) { - nextRemoved.add(id) - removedChanged = true + for (const id of nextIds) { + if (nextRemoved.delete(id)) { + removedChanged = true + } } - } - for (const id of nextIds) { - if (nextRemoved.delete(id)) { - removedChanged = true + // IPC clones and endpoint redaction allocate equal rows; reconcile to preserve subscriber identity. + const reconciled = reconcileCatalogRows( + s.runtimeEnvironments, + environments, + (environment) => environment.id + ) + const catalogUnchanged = reconciled === s.runtimeEnvironments + if ( + catalogUnchanged && + s.runtimeEnvironmentCatalogHydrated && + s.runtimeEnvironmentCatalogSettled && + !statusesChanged && + !removedChanged + ) { + return s + } + return { + runtimeEnvironments: catalogUnchanged ? s.runtimeEnvironments : reconciled, + runtimeEnvironmentCatalogHydrated: true, + runtimeEnvironmentCatalogSettled: true, + ...(statusesChanged + ? { + runtimeStatusByEnvironmentId: replaceMapEntries( + s.runtimeStatusByEnvironmentId, + [...removedStatusIds].map((id) => [id, null] as const) + ) + } + : {}), + ...(removedChanged ? { removedRuntimeEnvironmentIds: nextRemoved } : {}) } - } - // IPC clones and endpoint redaction allocate equal rows; reconcile to preserve subscriber identity. - const reconciled = reconcileCatalogRows( - s.runtimeEnvironments, - environments, - (environment) => environment.id - ) - const catalogUnchanged = reconciled === s.runtimeEnvironments - if ( - catalogUnchanged && - s.runtimeEnvironmentCatalogHydrated && - s.runtimeEnvironmentCatalogSettled && - !statusesChanged && - !removedChanged - ) { - return s - } - return { - runtimeEnvironments: catalogUnchanged ? s.runtimeEnvironments : reconciled, - runtimeEnvironmentCatalogHydrated: true, - runtimeEnvironmentCatalogSettled: true, - ...(statusesChanged - ? { - runtimeStatusByEnvironmentId: replaceMapEntries( - s.runtimeStatusByEnvironmentId, - [...removedStatusIds].map((id) => [id, null] as const) - ) - } - : {}), - ...(removedChanged ? { removedRuntimeEnvironmentIds: nextRemoved } : {}) - } - }) - // Prune removed environments’ agent/command caches; partial test stores may omit these slices. - get().retainRuntimeDetectedAgents?.(environments.map((environment) => environment.id)) - get().retainRuntimeTerminalQuickCommands?.(environments.map((environment) => environment.id)) - // A detached environment's mirrored SSH state must not outlive it. - get().retainEnvironmentSshState?.(environments.map((environment) => environment.id)) - for (const id of replacedEnvironmentIds) { - clearRuntimeCompatibilityCache(id) - get().clearRuntimeDetectedAgents?.(id) - get().markEnvironmentSshStateStale?.(id) - } - // Why: a same-id re-pair to another peer retires it as surely as a removal. - const retiredEnvironmentIds = [...new Set([...removedIds, ...peerReplacement.retired])] - if (retiredEnvironmentIds.length > 0) { - evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) - get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) - get().clearHostWorkspaceStatuses?.(retiredEnvironmentIds) - retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) - } - reconcileWebSessionCloseReplaysWithCatalog(pairedTerminalTabCloseIntentStoreOf(get), { - listed: nextIds, - ...peerReplacement - }) - }, - - applyRuntimeHostStatusSnapshot: (snapshot) => { - const apply = (): void => - applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { - set((s) => ({ - runtimeStatusByEnvironmentId: replaceMapEntries(s.runtimeStatusByEnvironmentId, [ - [snapshot.environmentId, entry] - ]) - })) }) - if (snapshotOutrunsCatalog(snapshot, get())) { - // Applied once after the re-read; a catalog that is still behind drops it as before. - void refreshRuntimeEnvironmentCatalog().then(apply) - return - } - apply() - }, + // Prune removed environments’ agent/command caches; partial test stores may omit these slices. + get().retainRuntimeDetectedAgents?.(environments.map((environment) => environment.id)) + get().retainRuntimeTerminalQuickCommands?.(environments.map((environment) => environment.id)) + // A detached environment's mirrored SSH state must not outlive it. + get().retainEnvironmentSshState?.(environments.map((environment) => environment.id)) + for (const id of replacedEnvironmentIds) { + clearRuntimeCompatibilityCache(id) + get().clearRuntimeDetectedAgents?.(id) + get().markEnvironmentSshStateStale?.(id) + } + // Why: a same-id re-pair to another peer retires it as surely as a removal. + const retiredEnvironmentIds = [...new Set([...removedIds, ...peerReplacement.retired])] + if (retiredEnvironmentIds.length > 0) { + evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) + get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) + get().clearHostWorkspaceStatuses?.(retiredEnvironmentIds) + retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) + } + reconcileWebSessionCloseReplaysWithCatalog(pairedTerminalTabCloseIntentStoreOf(get), { + listed: nextIds, + ...peerReplacement + }) + }, - setRuntimeEnvironmentStatus: (environmentId, status, options) => { - const previous = get().runtimeStatusByEnvironmentId.get(environmentId) - if (previous?.snapshot && !status.snapshot) { - return - } - const previousVerifiedStatus = lastVerifiedRuntimeStatus(previous) - const pairedDeviceId = status.status?.pairedDeviceId?.trim() - // Changed runtime ID means restart: reattach to return our client-hosted browsers to the replacement runtime. - const runtimeRestarted = Boolean( - status.status !== null && - previousVerifiedStatus != null && - previousVerifiedStatus.runtimeId !== status.status.runtimeId - ) - // An outage past the browser-host lease grace drops this desktop's hosting, so regained contact - // must re-claim it just like a restart does. - const contactRegained = previous?.status === null && status.status !== null - // Reachable status clears offline compatibility failures before reuse-flagged refetches can pin them. - if (status.status !== null) { - clearRecentRuntimeCompatibilityFailure(environmentId, status.status) - } - set((s) => { - const sessionEnded = status.status === null && previous?.status != null - // A reachable answer where we held none (never asked, or recorded unreachable) or - // where the runtime id moved starts a runtime session. - const runtimeSessionStarted = + applyRuntimeHostStatusSnapshot: (snapshot) => { + const apply = (): void => + applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { + set((s) => ({ + runtimeStatusByEnvironmentId: replaceMapEntries(s.runtimeStatusByEnvironmentId, [ + [snapshot.environmentId, entry] + ]) + })) + }) + if (snapshotOutrunsCatalog(snapshot, get())) { + // Applied once after the re-read; a catalog that is still behind drops it as before. + void refreshRuntimeEnvironmentCatalog().then(apply) + return + } + apply() + }, + + setRuntimeEnvironmentStatus: (environmentId, status, options) => { + const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + if (previous?.snapshot && !status.snapshot) { + return + } + const previousVerifiedStatus = lastVerifiedRuntimeStatus(previous) + const pairedDeviceId = status.status?.pairedDeviceId?.trim() + // Changed runtime ID means restart: reattach to return our client-hosted browsers to the replacement runtime. + const runtimeRestarted = Boolean( status.status !== null && - (previousVerifiedStatus == null || - previousVerifiedStatus.runtimeId !== status.status.runtimeId) - // First publication has no prior connection; advancing its fence would discard same-connection startup scans (#19241). - const connectionChanged = previous !== undefined && runtimeSessionStarted - const activeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim() - const connectionGeneration = connectionChanged - ? runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration( - environmentId - ) - : (previous?.connectionGeneration ?? - status.connectionGeneration ?? - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration( - environmentId - )) - // Same-runtime recovery preserves mirror generation; advance a separate epoch to reinstall streams that ended with transport loss. - const hostContactEpoch = - (previous?.hostContactEpoch ?? status.hostContactEpoch ?? 0) + (contactRegained ? 1 : 0) - // Provider readiness tracks session start, including first publication, rather than connection changes alone. - if (activeEnvironmentId === environmentId && (sessionEnded || runtimeSessionStarted)) { - bumpProviderRuntimeSessionGeneration() - } - const nextEntry = { ...status, connectionGeneration, hostContactEpoch } - const currentEntry = s.runtimeStatusByEnvironmentId.get(environmentId) - // Preserve map identity for unchanged probes; real status/counter transitions still publish. - const statusUnchanged = Boolean( - currentEntry && runtimeEnvironmentStatusesEqual(currentEntry, nextEntry) + previousVerifiedStatus != null && + previousVerifiedStatus.runtimeId !== status.status.runtimeId ) - const environmentIndex = pairedDeviceId - ? s.runtimeEnvironments.findIndex((environment) => environment.id === environmentId) - : -1 - const runtimeEnvironments = - environmentIndex >= 0 && - s.runtimeEnvironments[environmentIndex].pairedDeviceId !== pairedDeviceId - ? s.runtimeEnvironments.map((environment, index) => - index === environmentIndex ? { ...environment, pairedDeviceId } : environment + // An outage past the browser-host lease grace drops this desktop's hosting, so regained contact + // must re-claim it just like a restart does. + const contactRegained = previous?.status === null && status.status !== null + // Reachable status clears offline compatibility failures before reuse-flagged refetches can pin them. + if (status.status !== null) { + clearRecentRuntimeCompatibilityFailure(environmentId, status.status) + } + set((s) => { + const sessionEnded = status.status === null && previous?.status != null + // A reachable answer where we held none (never asked, or recorded unreachable) or + // where the runtime id moved starts a runtime session. + const runtimeSessionStarted = + status.status !== null && + (previousVerifiedStatus == null || + previousVerifiedStatus.runtimeId !== status.status.runtimeId) + // First publication has no prior connection; advancing its fence would discard same-connection startup scans (#19241). + const connectionChanged = previous !== undefined && runtimeSessionStarted + const activeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim() + const connectionGeneration = connectionChanged + ? runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration( + environmentId ) - : s.runtimeEnvironments - const environmentsChanged = runtimeEnvironments !== s.runtimeEnvironments - if (statusUnchanged && !environmentsChanged) { - return s - } - return { - runtimeStatusByEnvironmentId: statusUnchanged - ? s.runtimeStatusByEnvironmentId - : replaceMapEntries(s.runtimeStatusByEnvironmentId, [[environmentId, nextEntry]]), - ...(environmentsChanged ? { runtimeEnvironments } : {}) - } - }) - if (runtimeRestarted || contactRegained) { - void ensureBrowserClientHostOnRuntimeContact(get(), environmentId) - } - if (options?.suppressDisconnectToast) { - dismissRuntimeDisconnectedToast(environmentId) - } else if (contactRegained) { - dismissRuntimeDisconnectedToast(environmentId) - } else if (previous && previous.status !== null && status.status === null) { - showRuntimeDisconnectedToast(environmentId, get) - } - }, - - ...createRuntimeEnvironmentStatusRetention(set, get), - - refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => - refreshRuntimeEnvironmentStatus( - environmentId, - timeoutMs, - (entry) => { - get().setRuntimeEnvironmentStatus(environmentId, entry) - if (entry.status) { - // Early hydration may precede reachability; restored client pages need this desktop to attach as host. - void ensureBrowserClientHostsForRestoredPages(get()) - // Replay closes missed during downtime before restored runtime records can reopen those tabs. - const { - clientHostedBrowserCloseIntentsByEnvironment, - clearClientHostedBrowserCloseIntents - } = get() - void replayClientHostedBrowserCloseIntents(environmentId, { - clientHostedBrowserCloseIntentsByEnvironment, - clearClientHostedBrowserCloseIntents - }) + : (previous?.connectionGeneration ?? + status.connectionGeneration ?? + runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration( + environmentId + )) + // Same-runtime recovery preserves mirror generation; advance a separate epoch to reinstall streams that ended with transport loss. + const hostContactEpoch = + (previous?.hostContactEpoch ?? status.hostContactEpoch ?? 0) + (contactRegained ? 1 : 0) + // Provider readiness tracks session start, including first publication, rather than connection changes alone. + if (activeEnvironmentId === environmentId && (sessionEnded || runtimeSessionStarted)) { + bumpProviderRuntimeSessionGeneration() } - }, - (snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot) - ), + const nextEntry = { ...status, connectionGeneration, hostContactEpoch } + const currentEntry = s.runtimeStatusByEnvironmentId.get(environmentId) + // Preserve map identity for unchanged probes; real status/counter transitions still publish. + const statusUnchanged = Boolean( + currentEntry && runtimeEnvironmentStatusesEqual(currentEntry, nextEntry) + ) + const environmentIndex = pairedDeviceId + ? s.runtimeEnvironments.findIndex((environment) => environment.id === environmentId) + : -1 + const runtimeEnvironments = + environmentIndex >= 0 && + s.runtimeEnvironments[environmentIndex].pairedDeviceId !== pairedDeviceId + ? s.runtimeEnvironments.map((environment, index) => + index === environmentIndex ? { ...environment, pairedDeviceId } : environment + ) + : s.runtimeEnvironments + const environmentsChanged = runtimeEnvironments !== s.runtimeEnvironments + if (statusUnchanged && !environmentsChanged) { + return s + } + return { + runtimeStatusByEnvironmentId: statusUnchanged + ? s.runtimeStatusByEnvironmentId + : replaceMapEntries(s.runtimeStatusByEnvironmentId, [[environmentId, nextEntry]]), + ...(environmentsChanged ? { runtimeEnvironments } : {}) + } + }) + if (runtimeRestarted || contactRegained) { + void ensureBrowserClientHostOnRuntimeContact(get(), environmentId) + } + if (options?.suppressDisconnectToast) { + dismissRuntimeDisconnectedToast(environmentId) + } else if (contactRegained) { + dismissRuntimeDisconnectedToast(environmentId) + } else if (previous && previous.status !== null && status.status === null) { + showRuntimeDisconnectedToast(environmentId, get) + } + }, - hydrateRuntimeEnvironmentStatuses: createRuntimeStatusHydration({ - listEnvironments: () => window.api.runtimeEnvironments.list(), - getCurrentEnvironments: () => get().runtimeEnvironments, - publishEnvironments: (environments) => get().setRuntimeEnvironments(environments), - refreshEnvironmentStatus: (environmentId) => - get().refreshRuntimeEnvironmentStatus(environmentId), - // Why: failed reads release catalog waiters without claiming routing is safe. - markCatalogSettled: () => set({ runtimeEnvironmentCatalogSettled: true }) - }) -}) + ...createRuntimeEnvironmentStatusRetention(set, get), + + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => + refreshRuntimeEnvironmentStatus( + environmentId, + timeoutMs, + (entry) => { + get().setRuntimeEnvironmentStatus(environmentId, entry) + if (entry.status) { + // Early hydration may precede reachability; restored client pages need this desktop to attach as host. + void ensureBrowserClientHostsForRestoredPages(get()) + // Replay closes missed during downtime before restored runtime records can reopen those tabs. + const { + clientHostedBrowserCloseIntentsByEnvironment, + clearClientHostedBrowserCloseIntents + } = get() + void replayClientHostedBrowserCloseIntents(environmentId, { + clientHostedBrowserCloseIntentsByEnvironment, + clearClientHostedBrowserCloseIntents + }) + } + }, + (snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot) + ), + + hydrateRuntimeEnvironmentStatuses: createRuntimeStatusHydration({ + listEnvironments: () => window.api.runtimeEnvironments.list(), + getCurrentEnvironments: () => get().runtimeEnvironments, + publishEnvironments: (environments) => get().setRuntimeEnvironments(environments), + refreshEnvironmentStatus: (environmentId) => + get().refreshRuntimeEnvironmentStatus(environmentId), + // Why: failed reads release catalog waiters without claiming routing is safe. + markCatalogSettled: () => set({ runtimeEnvironmentCatalogSettled: true }) + }) + } +} diff --git a/src/renderer/src/store/slices/runtime-status/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status/runtime-status-types.ts index 776e8cce961..42a8e562e7f 100644 --- a/src/renderer/src/store/slices/runtime-status/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status/runtime-status-types.ts @@ -34,6 +34,8 @@ export type RuntimeStatusStoreState = RuntimeStatusSlice & Pick< AppState, | 'settings' + | 'paneAccountRestarts' + | 'codexRestartNoticeByPtyId' | 'remoteBrowserPageHandlesByPageId' | 'retainRuntimeDetectedAgents' | 'retainRuntimeTerminalQuickCommands' diff --git a/src/renderer/src/store/terminals/codex-account-restart-actions.ts b/src/renderer/src/store/terminals/codex-account-restart-actions.ts index fdefd0c9b08..d7a3cbe9ee2 100644 --- a/src/renderer/src/store/terminals/codex-account-restart-actions.ts +++ b/src/renderer/src/store/terminals/codex-account-restart-actions.ts @@ -1,3 +1,4 @@ +import { getTerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import { createNonSecureContextUuid as createBrowserUuid } from '../../../../shared/non-secure-context-uuid' import { getConnectionIdFromState } from '@/lib/workspace/ownership/connection-owner-resolution' import { getLocalProjectExecutionRuntimeContext } from '@/runtime/hosts/preflight/local-preflight-context' @@ -76,7 +77,8 @@ export function createCodexAccountRestartActions( tabCreatedAt: pane.tabCreatedAt, leafId: pane.leafId, oldPtyId: ptyId, - oldIncarnationId: notices[ptyId]?.incarnationId, + oldIncarnationId: + notices[ptyId]?.incarnationId ?? getTerminalNoticeLifetime(ptyId)?.incarnationId, environmentId, ...(environmentId ? { @@ -136,11 +138,20 @@ export function createCodexAccountRestartActions( if (!request) { return s } + const oldIncarnationId = + request.oldIncarnationId ?? + (request.condition?.expected.kind === 'endpoint' + ? request.condition.expected.incarnationId + : undefined) ?? + (patch?.condition?.expected.kind === 'endpoint' + ? patch.condition.expected.incarnationId + : undefined) const next = { ...s.paneAccountRestarts } if (patch) { next[token.key] = { ...request, ...patch, + oldIncarnationId, ...(patch.phase === 'accepted' ? { executorId: undefined, @@ -151,8 +162,24 @@ export function createCodexAccountRestartActions( } else { delete next[token.key] } + const notices = s.codexRestartNoticeByPtyId + const notice = notices[request.oldPtyId] + const retireNotice = + (!patch || patch.condition?.expected.kind === 'empty') && + notice?.restartRequested && + (!notice.incarnationId || notice.incarnationId === oldIncarnationId) + const nextNotices = retireNotice ? { ...notices } : notices + if (retireNotice) { + delete nextNotices[request.oldPtyId] + } + const common = { + paneAccountRestarts: next, + ...(nextNotices !== notices ? { codexRestartNoticeByPtyId: nextNotices } : {}) + } if ( - (patch?.phase === 'failed' || patch?.phase === 'deferred') && + (patch?.phase === 'failed' || + patch?.phase === 'deferred' || + patch?.phase === 'unresolved') && patch.condition?.expected.kind === 'empty' && request.leafId ) { @@ -164,7 +191,7 @@ export function createCodexAccountRestartActions( (id) => id !== request.oldPtyId ) return { - paneAccountRestarts: next, + ...common, terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [request.tabId]: { ...layout, ptyIdsByLeafId: bindings } @@ -181,7 +208,7 @@ export function createCodexAccountRestartActions( } } } - return { paneAccountRestarts: next } + return common }) }, retryCodexAccountRestart: (token) => { @@ -197,18 +224,17 @@ export function createCodexAccountRestartActions( if (!pane?.leafId) { return s } + const input = request.leafId + ? request.startupInput + : captureAgentAccountRestartResumeInput('codex', { ...pane, leafId: pane.leafId }) const startup = structuredClone( - buildPaneAccountRestartStartup( - 'codex', - { ...pane, leafId: pane.leafId }, - request.startupInput - ) + buildPaneAccountRestartStartup('codex', { ...pane, leafId: pane.leafId }, input) ) if (!startup) { return s } key = pane.key - nextRequest = { ...request, leafId: pane.leafId, startup } + nextRequest = { ...request, leafId: pane.leafId, startup, startupInput: null } } const next = { ...s.paneAccountRestarts } delete next[token.key] diff --git a/src/renderer/src/store/terminals/codex-account-restart-continuation.ts b/src/renderer/src/store/terminals/codex-account-restart-continuation.ts index edb7c624231..b2f601b9af0 100644 --- a/src/renderer/src/store/terminals/codex-account-restart-continuation.ts +++ b/src/renderer/src/store/terminals/codex-account-restart-continuation.ts @@ -1,3 +1,5 @@ +import { getTerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' +import { collectLeafIds } from '../../../../shared/workspace-layout/terminal-topology/terminal-pane-layout-tree' import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state' import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state' @@ -37,12 +39,10 @@ export function currentCodexRestartPanes( for (const tab of tabs) { const layout = state.terminalLayoutsByTabId[tab.id] const ids = state.ptyIdsByTabId[tab.id] ?? [] + const bound = new Set(ids) + const leaves = new Set(collectLeafIds(layout?.root)) for (const [leaf, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { - if ( - !ids.includes(ptyId) || - !isTerminalLeafId(leaf) || - !layoutContainsLeafId(layout.root, leaf) - ) { + if (!bound.has(ptyId) || !isTerminalLeafId(leaf) || !leaves.has(leaf)) { continue } panes.set(ptyId, { @@ -81,7 +81,7 @@ export function isCodexAccountRestart( export function isCurrentCodexAccountRestart( state: CodexRestartPopulationState, request: CodexAccountRestart, - tabs?: ReadonlyMap + tabs?: ReadonlyMap ): boolean { if (getExecutionHostIdForWorktree(state, request.worktreeId) !== request.hostId) { return false @@ -97,17 +97,21 @@ export function isCurrentCodexAccountRestart( return false } } - const tab = tabs - ? tabs.get(`${request.worktreeId}\0${request.tabId}`) - : state.tabsByWorktree[request.worktreeId]?.find((t) => t.id === request.tabId) - if (!tab || tab.createdAt !== request.tabCreatedAt) { + const scope = tabs?.get(`${request.worktreeId}\0${request.tabId}`) + const createdAt = tabs + ? scope?.createdAt + : state.tabsByWorktree[request.worktreeId]?.find((t) => t.id === request.tabId)?.createdAt + if (createdAt === undefined || createdAt !== request.tabCreatedAt) { return false } if (!request.leafId) { return (state.ptyIdsByTabId[request.tabId] ?? []).includes(request.oldPtyId) } const layout = state.terminalLayoutsByTabId[request.tabId] - if (!layout || !layoutContainsLeafId(layout.root, request.leafId)) { + if ( + !layout || + !(tabs ? scope?.leaves.has(request.leafId) : layoutContainsLeafId(layout.root, request.leafId)) + ) { return false } const current = layout.ptyIdsByLeafId?.[request.leafId] @@ -167,6 +171,43 @@ export function currentCodexAccountRestart( : null } +function reconcileRetiredCodexNotices( + before: AppState, + next: AppState +): AppState['codexRestartNoticeByPtyId'] { + let notices = next.codexRestartNoticeByPtyId + let panes: ReturnType | undefined + for (const [key, request] of Object.entries(before.paneAccountRestarts)) { + if (!isCodexAccountRestart(request) || isCodexAccountRestart(next.paneAccountRestarts[key])) { + continue + } + const notice = notices[request.oldPtyId] + if ( + !notice?.restartRequested || + (notice.incarnationId && notice.incarnationId !== request.oldIncarnationId) + ) { + continue + } + panes ??= currentCodexRestartPanes(next) + const pane = panes.get(request.oldPtyId) + const incarnation = getTerminalNoticeLifetime(request.oldPtyId)?.incarnationId + const originalStillCurrent = + pane && + getExecutionHostIdForWorktree(next, pane.worktreeId) === request.hostId && + (!incarnation || !request.oldIncarnationId || incarnation === request.oldIncarnationId) + if (notices === next.codexRestartNoticeByPtyId) { + notices = { ...notices } + } + if (originalStillCurrent) { + const { restartRequested: _answered, ...unanswered } = notice + notices[request.oldPtyId] = unanswered + } else { + delete notices[request.oldPtyId] + } + } + return notices +} + export function withCodexAccountRestartPruning( state: AppState, patch: Partial @@ -181,15 +222,28 @@ export function withCodexAccountRestartPruning( } const next = { ...state, ...patch } const requests = pruneCodexAccountRestarts(next) - return requests === next.paneAccountRestarts ? patch : { ...patch, paneAccountRestarts: requests } + const notices = reconcileRetiredCodexNotices(state, { ...next, paneAccountRestarts: requests }) + return requests === next.paneAccountRestarts && notices === next.codexRestartNoticeByPtyId + ? patch + : { ...patch, paneAccountRestarts: requests, codexRestartNoticeByPtyId: notices } } +type CodexRestartTabScope = { createdAt: number; leaves: ReadonlySet } export function codexRestartTabIndex( - state: Pick -): ReadonlyMap { + state: Pick +): ReadonlyMap { return new Map( Object.entries(state.tabsByWorktree).flatMap(([worktreeId, tabs]) => - tabs.map((tab) => [`${worktreeId}\0${tab.id}`, tab] as const) + tabs.map( + (tab) => + [ + `${worktreeId}\0${tab.id}`, + { + createdAt: tab.createdAt, + leaves: new Set(collectLeafIds(state.terminalLayoutsByTabId[tab.id]?.root)) + } + ] as const + ) ) ) } diff --git a/src/renderer/src/store/terminals/pty/terminal-restart-state.ts b/src/renderer/src/store/terminals/pty/terminal-restart-state.ts index a552fe91398..5dab06fcc1c 100644 --- a/src/renderer/src/store/terminals/pty/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/pty/terminal-restart-state.ts @@ -1,6 +1,11 @@ +import { getTerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import { createCodexAccountRestartActions } from '../codex-account-restart-actions' import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' -import { currentCodexRestartPanes } from '../codex-account-restart-continuation' +import { + isCodexAccountRestart, + currentCodexAccountRestart, + currentCodexRestartPanes +} from '../codex-account-restart-continuation' import type { PaneAccountRestart, AccountRestartAgent } from '../terminal-contracts' import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from '../terminal-state' @@ -27,6 +32,7 @@ export function createTerminalRestartActions( | 'retryCodexAccountRestart' | 'markCodexRestartNotices' | 'clearCodexRestartNotice' + | 'retireCodexRestartEndpoint' | 'dismissCodexRestartNotices' | 'reopenCodexRestartPrompt' > { @@ -68,6 +74,9 @@ export function createTerminalRestartActions( })) }, consumePendingPaneAccountRestart: (ptyId) => { + if (get().paneAccountRestarts[ptyId] === 'codex') { + get().reopenCodexRestartPrompt(ptyId) + } let queuedAgent: AccountRestartAgent | null = null set((s) => { const agent = s.paneAccountRestarts[ptyId] @@ -77,7 +86,7 @@ export function createTerminalRestartActions( if (typeof agent !== 'string') { return s } - queuedAgent = agent + queuedAgent = agent === 'claude' ? agent : null const next = { ...s.paneAccountRestarts } delete next[ptyId] return { paneAccountRestarts: next } @@ -103,10 +112,15 @@ export function createTerminalRestartActions( (notice.origin.key !== pane.key || notice.origin.tabCreatedAt !== pane.tabCreatedAt || notice.origin.layout !== s.terminalLayoutsByTabId[pane.tabId] || - notice.origin.hostId !== getExecutionHostIdForWorktree(s, pane.worktreeId)) + notice.origin.hostId !== getExecutionHostIdForWorktree(s, pane.worktreeId) || + notice.origin.lifetime !== getTerminalNoticeLifetime(notice.ptyId)) ) { continue } + const incarnation = getTerminalNoticeLifetime(notice.ptyId)?.incarnationId + if (incarnation && notice.incarnationId && incarnation !== notice.incarnationId) { + continue + } const existing = next[notice.ptyId] // Read launch account ID/label from one source or they can describe different accounts. const launch = existing ?? notice @@ -123,7 +137,9 @@ export function createTerminalRestartActions( continue } next[notice.ptyId] = { - ...(notice.incarnationId ? { incarnationId: notice.incarnationId } : {}), + ...(notice.incarnationId || incarnation + ? { incarnationId: notice.incarnationId ?? incarnation } + : {}), previousAccountLabel: launch.previousAccountLabel, nextAccountLabel: notice.nextAccountLabel, ...(launch.previousAccountId === undefined @@ -151,6 +167,40 @@ export function createTerminalRestartActions( }) return noticedPtyIds }, + retireCodexRestartEndpoint: (ptyId, incarnationId, currentAttempt) => { + set((s) => { + let notices = s.codexRestartNoticeByPtyId + let requests = s.paneAccountRestarts + const notice = notices[ptyId] + if (notice?.incarnationId && notice.incarnationId !== incarnationId) { + notices = { ...notices } + delete notices[ptyId] + } + for (const [key, request] of Object.entries(requests)) { + if (!isCodexAccountRestart(request) || request.oldPtyId !== ptyId) { + continue + } + if (currentAttempt && currentCodexAccountRestart(s, currentAttempt) === request) { + continue + } + const oldIncarnation = + request.oldIncarnationId ?? + (request.condition?.expected.kind === 'endpoint' + ? request.condition.expected.incarnationId + : undefined) + if (!oldIncarnation || oldIncarnation === incarnationId) { + continue + } + if (requests === s.paneAccountRestarts) { + requests = { ...requests } + } + delete requests[key] + } + return notices === s.codexRestartNoticeByPtyId && requests === s.paneAccountRestarts + ? s + : { codexRestartNoticeByPtyId: notices, paneAccountRestarts: requests } + }) + }, clearCodexRestartNotice: (ptyId) => { set((s) => { if (!s.codexRestartNoticeByPtyId[ptyId]) { diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 739725a8b27..6ed39bfeef5 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,3 +1,4 @@ +import type { TerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import type { CodexAccountRestartToken } from './codex-account-restart-continuation' import type { FreshWorkspaceAgentLaunch } from '@/lib/workspace/creation/worktree-startup-payload' import type { TerminalState } from './terminal-state' @@ -209,9 +210,15 @@ export type TerminalActions = { tabCreatedAt: number hostId: string layout: TerminalLayoutSnapshot | undefined + lifetime?: TerminalNoticeLifetime } })[] ) => string[] + retireCodexRestartEndpoint: ( + ptyId: string, + incarnationId: string, + currentAttempt?: CodexAccountRestartToken + ) => void clearCodexRestartNotice: (ptyId: string) => void dismissCodexRestartNotices: (ptyIds: string[]) => void reopenCodexRestartPrompt: (ptyId: string) => void diff --git a/src/shared/terminal/pty/lifecycle/account-restart-contract.ts b/src/shared/terminal/pty/lifecycle/account-restart-contract.ts index 85599eb5dc5..c8d405ff5c9 100644 --- a/src/shared/terminal/pty/lifecycle/account-restart-contract.ts +++ b/src/shared/terminal/pty/lifecycle/account-restart-contract.ts @@ -80,6 +80,11 @@ export function formatAccountRestartFailure( return `${message} ${PREFIX}${encoded}` } +const receiptSuffix = new RegExp(`(?:^|[\\t ]+)${PREFIX}[\\w-]+[\\t ]*$`, 'gm') +export function withoutAccountRestartReceipt(message: string): string { + return message.replace(receiptSuffix, '').trimEnd() +} + export function readAccountRestartReceipt(error: unknown): AccountRestartReceipt | undefined { if ( error &&