fix(terminal): use fact attribution for resume retirement

This commit is contained in:
Brennan Benson
2026-08-26 20:50:32 -07:00
parent f8fa070eed
commit ada1a89904
10 changed files with 171 additions and 106 deletions
+25
View File
@@ -10441,6 +10441,31 @@ describe('OrcaRuntimeService', () => {
expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' })
})
it('does not confirm when the SSH host becomes unverifiable during the foreground read', 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')
let resolveForeground!: (process: string) => void
const foregroundRead = new Promise<string>((resolve) => {
resolveForeground = resolve
})
const getForegroundProcess = vi.fn(() => foregroundRead)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce())
runtime.markPtyLivenessUnverifiable('pty-1', 'the SSH provider disconnected')
resolveForeground('zsh')
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)
+5 -5
View File
@@ -19660,19 +19660,19 @@ export class OrcaRuntimeService {
return entry.promise
}
private canConfirmAgentExitFromHost(
private isPtyAgentExitUnverifiable(
pty: RuntimePtyWorktreeRecord | undefined,
ptyId: string
): boolean {
if (this.getPtyLivenessVerdict(ptyId)?.status === 'unverifiable') {
return false
return true
}
return !(pty?.connectionId && !pty.connected)
return Boolean(pty?.connectionId && !pty.connected)
}
private confirmPtyAgentExit(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (!this.canConfirmAgentExitFromHost(pty, ptyId)) {
if (this.isPtyAgentExitUnverifiable(pty, ptyId)) {
return
}
const titleObservedAt = pty?.lastOscTitleAt ?? null
@@ -19691,7 +19691,7 @@ export class OrcaRuntimeService {
if (current !== pty) {
return
}
if (!this.canConfirmAgentExitFromHost(current, ptyId)) {
if (this.isPtyAgentExitUnverifiable(current, ptyId)) {
return
}
if (!current.connected) {
@@ -27,11 +27,7 @@ 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'
import { retireConfirmedAgentExitResumeAuthority } 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
@@ -74,7 +70,6 @@ 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
@@ -289,7 +284,6 @@ 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()
@@ -22,6 +22,7 @@ type MockStoreState = {
agentStatusByPaneKey: Record<string, never>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
clearSleepingAgentSessionsByPaneKey: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
@@ -65,6 +66,11 @@ describe('parked terminal confirmed agent-exit resume retirement', () => {
sleepingAgentSessionsByPaneKey: {},
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
}),
clearSleepingAgentSessionsByPaneKey: vi.fn((paneKeys: readonly string[]) => {
for (const paneKey of paneKeys) {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
}
})
}
;(globalThis as { window: typeof window }).window = {
@@ -443,16 +443,17 @@ describe('connectPanePty', () => {
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.clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledWith([
paneKey,
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]
)
const clearCallOrder =
mockStoreState.clearSleepingAgentSessionsByPaneKey.mock.invocationCallOrder[0]
expect(clearCallOrder).toBeLessThan(deps.onAgentExitedRef.current.mock.invocationCallOrder[0])
})
it('retires resume authority when the agent-exited fact arrives after PTY teardown', async () => {
@@ -485,6 +486,7 @@ describe('connectPanePty', () => {
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-late-exit',
seq: 1,
paneKey,
facts: [{ kind: 'agent-exited' }]
})
@@ -776,6 +778,32 @@ describe('connectPanePty', () => {
const onBell = createdTransportOptions[0]?.onBell as () => void
onBell()
expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1)
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-byte-mode' },
prompt: 'finish the task',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
mockStoreState.sleepingAgentSessionsByPaneKey = { [paneKey]: record }
const onPtyExit = createdTransportOptions[0]?.onPtyExit as (ptyId: string) => void
onPtyExit('pty-prehydration')
// Why: main still emits the confirmed fact after the byte-mode pane consumer is gone.
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-prehydration',
seq: 2,
paneKey,
facts: [{ kind: 'agent-exited' }]
})
expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
})
})
@@ -64,6 +64,12 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete getState().sleepingAgentSessionsByPaneKey[paneKey]
}),
clearSleepingAgentSessionsByPaneKey: vi.fn((paneKeys: readonly string[]) => {
for (const paneKey of paneKeys) {
delete getState().sleepingAgentSessionsByPaneKey[paneKey]
delete getState().agentLaunchConfigByPaneKey[paneKey]
}
}),
registerAgentLaunchConfig: vi.fn(),
clearAgentLaunchConfig: vi.fn(),
markWorktreeUnread: vi.fn(),
@@ -109,6 +109,7 @@ export type StoreState = {
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
getAgentLaunchConfigForStatusMetadata: ReturnType<typeof vi.fn>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
clearSleepingAgentSessionsByPaneKey: ReturnType<typeof vi.fn>
registerAgentLaunchConfig: ReturnType<typeof vi.fn>
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
@@ -19,7 +19,8 @@ import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../../../shared/terminal-side-effect-facts'
import { retireConfirmedAgentExitResumeForPty } from '@/lib/confirmed-agent-exit-resume-retirement'
import { useAppStore } from '@/store'
import { retireConfirmedAgentExitResumeAuthority } 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.
@@ -259,18 +260,17 @@ 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) {
// Why: a confirmed exit can race pane teardown or arrive for byte-mode
// panes that never register here; main's fact attribution outlives both.
if (
!batch.replay &&
batch.paneKey &&
batch.facts.some((fact) => fact.kind === 'agent-exited')
) {
retireConfirmedAgentExitResumeAuthority(useAppStore.getState(), batch.paneKey)
}
bufferHandoffFactBatch(batch)
return
}
@@ -2,17 +2,16 @@ 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 { retireConfirmedAgentExitResumeAuthority } from './confirmed-agent-exit-resume-retirement'
import {
bindConfirmedAgentExitResumePane,
retireConfirmedAgentExitResumeAuthority,
retireConfirmedAgentExitResumeForPty,
_resetConfirmedAgentExitResumeBindingsForTest
} from './confirmed-agent-exit-resume-retirement'
_dispatchTerminalSideEffectBatchForTest,
_resetTerminalSideEffectFactConsumersForTest
} from '@/components/terminal-pane/terminal-side-effect-facts-handler'
const initialState = useAppStore.getState()
afterEach(() => {
_resetConfirmedAgentExitResumeBindingsForTest()
_resetTerminalSideEffectFactConsumersForTest()
useAppStore.setState(initialState, true)
})
@@ -82,6 +81,48 @@ describe('confirmed agent-exit resume retirement', () => {
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[sibling.paneKey]).toEqual(sibling)
})
it('uses the Prime session file to isolate provider-session aliases', () => {
const record = makeRecord({
agent: 'prime-agent',
providerSession: {
key: 'session_id',
id: 'prime-session',
transcriptPath: 'prime-session-a.jsonl'
}
})
const duplicate = makeRecord({
paneKey: 'tab-1:legacy',
agent: 'prime-agent',
providerSession: {
key: 'session_id',
id: 'prime-session',
transcriptPath: 'prime-session-a.jsonl'
}
})
const sibling = makeRecord({
paneKey: 'tab-1:leaf-2',
agent: 'prime-agent',
providerSession: {
key: 'session_id',
id: 'prime-session',
transcriptPath: 'prime-session-b.jsonl'
}
})
useAppStore.setState({
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')
@@ -104,14 +145,39 @@ describe('confirmed agent-exit resume retirement', () => {
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
it('retires by PTY binding so a late confirmed-exit fact still finds the pane', () => {
it('retires from fact attribution when no pane consumer is registered', () => {
const record = makeRecord()
seedOrphanRecord(record)
bindConfirmedAgentExitResumePane('pty-late', record.paneKey)
retireConfirmedAgentExitResumeForPty('pty-late')
_dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-late',
seq: 1,
paneKey: record.paneKey,
facts: [{ kind: 'agent-exited' }]
})
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
})
it('keeps resume authority for unattributed or replayed exit facts', () => {
const record = makeRecord()
seedOrphanRecord(record)
_dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-unattributed',
seq: 1,
facts: [{ kind: 'agent-exited' }]
})
_dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-replay',
seq: 2,
paneKey: record.paneKey,
replay: true,
facts: [{ kind: 'agent-exited' }]
})
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
})
@@ -1,57 +1,19 @@
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)
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void
}
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)) {
const paneKeys = [consumed.paneKey]
for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) {
if (
paneKey !== consumed.paneKey &&
record.worktreeId === consumed.record.worktreeId &&
@@ -62,42 +24,19 @@ export function retireConfirmedAgentExitResumeRecord(
consumed.record.providerSession
)
) {
duplicatePaneKeys.push(paneKey)
paneKeys.push(paneKey)
}
}
state.clearSleepingAgentSession(consumed.paneKey)
for (const paneKey of duplicatePaneKeys) {
state.clearSleepingAgentSession(paneKey)
}
state.clearSleepingAgentSessionsByPaneKey(paneKeys)
}
export function retireConfirmedAgentExitResumeAuthority(
state: ResumeRetirementStore,
paneKey: string
): void {
const record = state.sleepingAgentSessionsByPaneKey?.[paneKey]
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()
}