Fence Codex account restart settlement and retry ownership

This commit is contained in:
Jinjing
2026-11-13 19:14:30 -08:00
parent 2379fb56f7
commit 7d06ffd5ff
24 changed files with 1404 additions and 372 deletions
+112 -1
View File
@@ -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')
}
})
})
+7
View File
@@ -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()
@@ -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
@@ -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<AccountRestartCondition | null> {
@@ -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<void> {
const identities = requests.map(
({ startup: _startup, startupInput: _startupInput, ...identity }) => identity
)
return Promise.all(identities.map(retryCodexAccountRestartIdentity)).then(() => undefined)
}
async function retryCodexAccountRestartIdentity(
request: Omit<CodexAccountRestart, 'startup' | 'startupInput'>
): Promise<void> {
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' })
}
}
}
@@ -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)
})
@@ -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<PtyTransport['connect']>[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<typeof accepted>,
scrollback: Map<string, string>,
connect: (options: PtyConnectOptions) => Promise<unknown>,
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<string | null> =>
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<ReturnType<typeof snapshot>>()
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<typeof accepted>
let running: Promise<void> | 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)
})
})
@@ -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<string> | 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)
@@ -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
@@ -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.'
)
})
@@ -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)
)
@@ -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
@@ -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()
@@ -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()
}
@@ -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',
@@ -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)
})
})
@@ -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<AppState['markCodexRestartNotices']>[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({
@@ -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<RuntimeStatusStoreState>
): Partial<RuntimeStatusStoreState> {
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)
}
}
}
}
@@ -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<string>()
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<string>()
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 })
})
}
}
@@ -34,6 +34,8 @@ export type RuntimeStatusStoreState = RuntimeStatusSlice &
Pick<
AppState,
| 'settings'
| 'paneAccountRestarts'
| 'codexRestartNoticeByPtyId'
| 'remoteBrowserPageHandlesByPageId'
| 'retainRuntimeDetectedAgents'
| 'retainRuntimeTerminalQuickCommands'
@@ -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]
@@ -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<string, AppState['tabsByWorktree'][string][number]>
tabs?: ReadonlyMap<string, CodexRestartTabScope>
): 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<typeof currentCodexRestartPanes> | 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<AppState>
@@ -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<string> }
export function codexRestartTabIndex(
state: Pick<CodexRestartPopulationState, 'tabsByWorktree'>
): ReadonlyMap<string, AppState['tabsByWorktree'][string][number]> {
state: Pick<CodexRestartPopulationState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>
): ReadonlyMap<string, CodexRestartTabScope> {
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
)
)
)
}
@@ -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]) {
@@ -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
@@ -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 &&