fix(terminal): retire resume authority after a confirmed agent exit

A clean Claude/shell exit left sleepingAgentSessionsByPaneKey in
state working, so relaunch treated it as a crash and spawned
claude --resume. Retire that pane's record when the host confirms
the agent returned to the shell, keep unexpected PTY loss for
crash recovery, and do not treat an unverifiable remote as exited.

Co-authored-by: erishforG <erish2150@gmail.com>
This commit is contained in:
Brennan Benson
2026-08-26 20:50:11 -07:00
co-authored by erishforG
parent d60a3c900b
commit bd26ac9f34
10 changed files with 588 additions and 3 deletions
+45
View File
@@ -10420,6 +10420,51 @@ describe('OrcaRuntimeService', () => {
expect(getForegroundProcess).toHaveBeenCalledOnce()
})
it('does not confirm an agent exit when the SSH host is unverifiable', async () => {
const { runtime, batches } = createSideEffectRuntime()
runtime.registerPty('pty-1', TEST_WORKTREE_ID, 'conn-1')
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
const getForegroundProcess = vi.fn(() => {
throw new TypeError('getForegroundProcess is unavailable')
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce())
await Promise.resolve()
expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' })
})
it('still confirms a local agent exit if the PTY disconnects during the foreground read', async () => {
const { runtime, batches } = createSideEffectRuntime()
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
let resolveForeground!: (process: string) => void
const foregroundRead = new Promise<string>((resolve) => {
resolveForeground = resolve
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: vi.fn(() => foregroundRead)
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
runtime.onPtyExit('pty-1', 0)
resolveForeground('zsh')
await vi.waitFor(() =>
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' })
)
})
it('aligns a restored session and pre-response bytes to the provider sequence', async () => {
const { runtime } = createSideEffectRuntime()
+29 -1
View File
@@ -19660,17 +19660,42 @@ export class OrcaRuntimeService {
return entry.promise
}
private canConfirmAgentExitFromHost(
pty: RuntimePtyWorktreeRecord | undefined,
ptyId: string
): boolean {
if (this.getPtyLivenessVerdict(ptyId)?.status === 'unverifiable') {
return false
}
return !(pty?.connectionId && !pty.connected)
}
private confirmPtyAgentExit(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (!this.canConfirmAgentExitFromHost(pty, ptyId)) {
return
}
const titleObservedAt = pty?.lastOscTitleAt ?? null
const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0)
if (!pty?.connected || !foregroundRead) {
// Why: a local PTY that is already gone after a shell-title exit is
// `exited`. A remote PTY that we cannot observe is `unverifiable`.
if (pty?.connectionId) {
return
}
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
return
}
void foregroundRead.then((result) => {
const current = this.ptysById.get(ptyId)
if (current !== pty || !current.connected) {
if (current !== pty) {
return
}
if (!this.canConfirmAgentExitFromHost(current, ptyId)) {
return
}
if (!current.connected) {
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
return
}
if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) {
@@ -19699,6 +19724,9 @@ export class OrcaRuntimeService {
}
return
}
if (!result.available && current.connectionId) {
return
}
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
})
}
@@ -27,6 +27,11 @@ import {
import { dispatchTerminalNotification } from './use-notification-dispatch'
import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import {
bindConfirmedAgentExitResumePane,
retireConfirmedAgentExitResumeAuthority,
scheduleUnbindConfirmedAgentExitResumePane
} from '@/lib/confirmed-agent-exit-resume-retirement'
// Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted.
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
@@ -69,6 +74,7 @@ export function startParkedTerminalByteWatcher(
// Why: one watcher per PTY — a stale watcher from a previous park cycle would double-fire bell/completion for the same bytes.
parkedWatcherDisposersByPtyId.get(ptyId)?.()
bindConfirmedAgentExitResumePane(ptyId, paneKey)
let disposed = false
let pendingBellNotification = false
@@ -183,7 +189,9 @@ export function startParkedTerminalByteWatcher(
},
onAgentExited: (): void => {
// Why: title reverting to a plain shell means the agent session ended; clear the countdown so it doesn't survive in the sidebar while parked.
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
const state = useAppStore.getState()
state.setCacheTimerStartedAt(paneKey, null)
retireConfirmedAgentExitResumeAuthority(state, paneKey)
}
}
@@ -281,6 +289,7 @@ export function startParkedTerminalByteWatcher(
return
}
disposed = true
scheduleUnbindConfirmedAgentExitResumePane(ptyId)
// Why first: each park/reveal cycle owns a distinct processor gauge, and the store/IPC
// teardown below must not be able to throw its way past the census drop.
processor?.disposePendingSideEffectGauge()
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
import type * as ParkedTerminalCommandStatus from './parked-terminal-command-status'
const PTY_ID = 'pty-parked-exit'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
type MockStoreState = {
settings: { terminalMainSideEffectAuthority?: boolean } | null
setRuntimePaneTitle: ReturnType<typeof vi.fn>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
markTerminalTabUnread: ReturnType<typeof vi.fn>
markTerminalPaneUnread: ReturnType<typeof vi.fn>
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
agentStatusByPaneKey: Record<string, never>
sleepingAgentSessionsByPaneKey: Record<string, { paneKey: string }>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification: vi.fn()
}))
vi.mock('./parked-terminal-command-status', async (importOriginal) => ({
...(await importOriginal<typeof ParkedTerminalCommandStatus>()),
createParkedTerminalCommandStatusPolicy: vi.fn(() => ({
onCommandFinished: vi.fn(),
onCommandCodeWorking: vi.fn(),
onCommandCodeDone: vi.fn(),
dispose: vi.fn()
}))
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState
}
}))
describe('parked terminal confirmed agent-exit resume retirement', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
vi.resetModules()
mockStoreState = {
settings: { terminalMainSideEffectAuthority: true },
setRuntimePaneTitle: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
updateTabTitle: vi.fn(),
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
})
}
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
pty: {
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
ackData: vi.fn()
}
}
} as unknown as typeof window
})
afterEach(() => {
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
}
})
it('retires the parked pane resume record on a confirmed agent-exited fact', async () => {
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const handler = await import('./terminal-side-effect-facts-handler')
mockStoreState.sleepingAgentSessionsByPaneKey = {
[PANE_KEY]: {
paneKey: PANE_KEY,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
agent: 'claude',
providerSession: { key: 'session_id', id: 'parked-session' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live'
}
}
const dispose = startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: 1
} satisfies ParkedTerminalByteWatcherOptions)
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: 1,
facts: [{ kind: 'agent-exited' }]
})
expect(mockStoreState.sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined()
dispose()
})
})
@@ -5,6 +5,7 @@ import { flushAsyncTicks } from './pty-connection-test-async'
import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants'
import {
LEAF_1,
LEAF_2,
createMockTransport,
createPane,
createManager,
@@ -391,6 +392,105 @@ describe('connectPanePty', () => {
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
it('retires only the confirmed agent-exit resume record before immediate PTY teardown', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport('pty-agent-exit')
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const duplicatePaneKey = 'tab-1:1'
const siblingPaneKey = makePaneKey('tab-1', LEAF_2)
const record = {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude' as const,
providerSession: { key: 'session_id' as const, id: 'session-1' },
prompt: 'finish the task',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
const duplicateRecord = {
...record,
paneKey: duplicatePaneKey,
capturedAt: 2,
updatedAt: 2
}
const siblingRecord = {
...record,
paneKey: siblingPaneKey,
providerSession: { key: 'session_id' as const, id: 'session-2' }
}
mockStoreState.sleepingAgentSessionsByPaneKey = {
[paneKey]: record,
[duplicatePaneKey]: duplicateRecord,
[siblingPaneKey]: siblingRecord
}
const deps = createDeps()
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-agent-exit')
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-agent-exit',
seq: 1,
facts: [{ kind: 'agent-exited' }]
})
const onPtyExit = createdTransportOptions[0]?.onPtyExit as (ptyId: string) => void
onPtyExit('pty-agent-exit')
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(duplicatePaneKey)
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(mockStoreState.sleepingAgentSessionsByPaneKey[duplicatePaneKey]).toBeUndefined()
expect(mockStoreState.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingRecord)
expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1)
const clearCallOrders = mockStoreState.clearSleepingAgentSession.mock.invocationCallOrder
expect(clearCallOrders.at(-1)).toBeLessThan(
deps.onAgentExitedRef.current.mock.invocationCallOrder[0]
)
})
it('retires resume authority when the agent-exited fact arrives after PTY teardown', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport('pty-late-exit')
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const record = {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude' as const,
providerSession: { key: 'session_id' as const, id: 'session-late' },
prompt: 'finish the task',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
mockStoreState.sleepingAgentSessionsByPaneKey = { [paneKey]: record }
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-late-exit')
const onPtyExit = createdTransportOptions[0]?.onPtyExit as (ptyId: string) => void
onPtyExit('pty-late-exit')
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-late-exit',
seq: 1,
facts: [{ kind: 'agent-exited' }]
})
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('routes pr-link facts to the worktree PR observer', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
@@ -201,6 +201,35 @@ describe('connectPanePty', () => {
expect(manager.closePane).not.toHaveBeenCalled()
})
it('keeps resume authority after an unexpected PTY loss without a confirmed agent exit', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-crash')
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const record = {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude' as const,
providerSession: { key: 'session_id' as const, id: 'session-crash' },
prompt: 'finish the task',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
mockStoreState.sleepingAgentSessionsByPaneKey = { [paneKey]: record }
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-crash')
const onPtyExit = createdTransportOptions[0]?.onPtyExit as (ptyId: string) => void
onPtyExit('pty-crash')
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBe(record)
})
it('defers all exit-side state mutation while worktree shutdown verification is pending', async () => {
const { connectPanePty } = await import('./pty-connection')
const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral')
@@ -19,6 +19,7 @@ import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../../../shared/terminal-side-effect-facts'
import { retireConfirmedAgentExitResumeForPty } from '@/lib/confirmed-agent-exit-resume-retirement'
// Why: cached once per session — the blocking read should only ever run on
// the pre-hydration startup path, never per pane bind.
@@ -258,6 +259,16 @@ function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
}
export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void {
// Why: the pane consumer unregisters on PTY teardown, but a confirmed
// agent-exited fact can still arrive after that. Resume retirement must
// not depend on the pane still being mounted.
if (!batch.replay) {
for (const fact of batch.facts) {
if (fact.kind === 'agent-exited') {
retireConfirmedAgentExitResumeForPty(batch.ptyId)
}
}
}
const entry = consumersByPtyId.get(batch.ptyId)
if (!entry) {
bufferHandoffFactBatch(batch)
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import {
bindConfirmedAgentExitResumePane,
retireConfirmedAgentExitResumeAuthority,
retireConfirmedAgentExitResumeForPty,
_resetConfirmedAgentExitResumeBindingsForTest
} from './confirmed-agent-exit-resume-retirement'
const initialState = useAppStore.getState()
afterEach(() => {
_resetConfirmedAgentExitResumeBindingsForTest()
useAppStore.setState(initialState, true)
})
function makeRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live',
...overrides
}
}
function seedOrphanRecord(record: SleepingAgentSessionRecord): void {
useAppStore.setState({
tabsByWorktree: {
'wt-1': [
{
id: 'other-tab',
ptyId: null,
worktreeId: 'wt-1',
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
}
describe('confirmed agent-exit resume retirement', () => {
it('retires only the confirmed session and keeps a sibling provider session', () => {
const record = makeRecord()
const duplicate = makeRecord({
paneKey: 'tab-1:1',
capturedAt: 2,
updatedAt: 2
})
const sibling = makeRecord({
paneKey: 'tab-1:leaf-2',
providerSession: { key: 'session_id', id: 'sess-2' }
})
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: {
[record.paneKey]: record,
[duplicate.paneKey]: duplicate,
[sibling.paneKey]: sibling
}
} as never)
retireConfirmedAgentExitResumeAuthority(useAppStore.getState(), record.paneKey)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[duplicate.paneKey]).toBeUndefined()
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[sibling.paneKey]).toEqual(sibling)
})
it('retires a clean-exit record so the next launch does not resume it', () => {
seedOrphanRecord(makeRecord())
retireConfirmedAgentExitResumeAuthority(useAppStore.getState(), 'tab-1:leaf-1')
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
})
it('still offers resume when a working record was never retired after a crash', () => {
const record = makeRecord()
seedOrphanRecord(record)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
it('does not retire an unverifiable remote record that never received a confirmed exit', () => {
const record = makeRecord({ connectionId: 'ssh-conn-1' })
seedOrphanRecord(record)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
it('retires by PTY binding so a late confirmed-exit fact still finds the pane', () => {
const record = makeRecord()
seedOrphanRecord(record)
bindConfirmedAgentExitResumePane('pty-late', record.paneKey)
retireConfirmedAgentExitResumeForPty('pty-late')
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
})
})
@@ -0,0 +1,103 @@
import { useAppStore } from '@/store'
import {
agentProviderSessionsEqual,
type SleepingAgentSessionRecord
} from '../../../shared/agent-session-resume'
type ResumeRetirementStore = {
sleepingAgentSessionsByPaneKey?: Record<string, SleepingAgentSessionRecord>
clearSleepingAgentSession?: (paneKey: string) => void
}
const paneKeyByPtyId = new Map<string, string>()
const unbindTimersByPtyId = new Map<string, ReturnType<typeof setTimeout>>()
/** Matches the side-effect handoff buffer so a late confirmed-exit fact still finds the pane. */
const UNBIND_GRACE_MS = 15_000
function clearUnbindTimer(ptyId: string): void {
const timer = unbindTimersByPtyId.get(ptyId)
if (!timer) {
return
}
clearTimeout(timer)
unbindTimersByPtyId.delete(ptyId)
}
export function bindConfirmedAgentExitResumePane(ptyId: string, paneKey: string): void {
clearUnbindTimer(ptyId)
paneKeyByPtyId.set(ptyId, paneKey)
}
export function scheduleUnbindConfirmedAgentExitResumePane(ptyId: string): void {
if (!paneKeyByPtyId.has(ptyId)) {
return
}
clearUnbindTimer(ptyId)
const timer = setTimeout(() => {
unbindTimersByPtyId.delete(ptyId)
paneKeyByPtyId.delete(ptyId)
}, UNBIND_GRACE_MS)
timer.unref?.()
unbindTimersByPtyId.set(ptyId, timer)
}
export function retireConfirmedAgentExitResumeRecord(
state: ResumeRetirementStore,
consumed: { paneKey: string; record: SleepingAgentSessionRecord }
): void {
if (typeof state.clearSleepingAgentSession !== 'function') {
return
}
const records = state.sleepingAgentSessionsByPaneKey ?? {}
const duplicatePaneKeys: string[] = []
for (const [paneKey, record] of Object.entries(records)) {
if (
paneKey !== consumed.paneKey &&
record.worktreeId === consumed.record.worktreeId &&
record.agent === consumed.record.agent &&
agentProviderSessionsEqual(
record.agent,
record.providerSession,
consumed.record.providerSession
)
) {
duplicatePaneKeys.push(paneKey)
}
}
state.clearSleepingAgentSession(consumed.paneKey)
for (const paneKey of duplicatePaneKeys) {
state.clearSleepingAgentSession(paneKey)
}
}
export function retireConfirmedAgentExitResumeAuthority(
state: ResumeRetirementStore,
paneKey: string
): void {
const record = state.sleepingAgentSessionsByPaneKey?.[paneKey]
if (!record) {
return
}
retireConfirmedAgentExitResumeRecord(state, { paneKey, record })
}
export function retireConfirmedAgentExitResumeForPty(ptyId: string): void {
const paneKey = paneKeyByPtyId.get(ptyId)
if (!paneKey) {
return
}
const state = useAppStore.getState()
if (typeof state.clearSleepingAgentSession !== 'function') {
return
}
retireConfirmedAgentExitResumeAuthority(state, paneKey)
}
export function _resetConfirmedAgentExitResumeBindingsForTest(): void {
for (const timer of unbindTimersByPtyId.values()) {
clearTimeout(timer)
}
unbindTimersByPtyId.clear()
paneKeyByPtyId.clear()
}
@@ -169,7 +169,7 @@ describe('terminal tab retirement store boundary', () => {
expect(store.getState().pendingColdRestoreByPtyId['pty-shared']).toBe(coldRestore)
})
it('reconciles natural exit without issuing teardown or revoking resume authority', async () => {
it('preserves resume authority after an unexpected PTY loss', async () => {
const store = createRetirementStore()
const record = sleepingRecord('tab-1:leaf-1', 'tab-1')
seedStore(store, {
@@ -188,6 +188,27 @@ describe('terminal tab retirement store boundary', () => {
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(record)
})
it('does not retire an unverifiable remote pane on PTY-exit tab close', async () => {
const store = createRetirementStore()
const record: SleepingAgentSessionRecord = {
...sleepingRecord('tab-1:leaf-1', 'tab-1'),
connectionId: 'ssh-conn-1',
origin: 'live'
}
seedStore(store, {
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'ssh:conn-1@@relay-1' })]
},
ptyIdsByTabId: { 'tab-1': ['ssh:conn-1@@relay-1'] },
sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': record }
})
store.getState().closeTab('tab-1', { reason: 'pty-exit' })
await Promise.resolve()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toEqual(record)
})
it('does not recreate PTY indexes for a tab that no longer exists', () => {
const store = createRetirementStore()