mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Fence Codex account restart settlement and retry ownership
This commit is contained in:
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+93
-70
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -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)
|
||||
})
|
||||
+420
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+13
-4
@@ -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)
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+11
@@ -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()
|
||||
}
|
||||
|
||||
+13
-1
@@ -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',
|
||||
|
||||
+29
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user