diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 95f7ea51751..aaa734ab9f0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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((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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b7ba14791e5..5c44dca6d18 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index 4b259a21285..4cc803e72fd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-confirmed-agent-exit-resume.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-confirmed-agent-exit-resume.test.ts index 4712f9c4b94..53d8be02dc6 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-confirmed-agent-exit-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-confirmed-agent-exit-resume.test.ts @@ -22,6 +22,7 @@ type MockStoreState = { agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record clearSleepingAgentSession: ReturnType + clearSleepingAgentSessionsByPaneKey: ReturnType } 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 = { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts index 5dd2fa9f1cc..50a6d7e0249 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts @@ -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() }) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 8ad0335179c..d69c055d550 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -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(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 5ba2747d8ff..9a1d1ee3329 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -109,6 +109,7 @@ export type StoreState = { getAgentLaunchConfigForStatusEntry: ReturnType getAgentLaunchConfigForStatusMetadata: ReturnType clearSleepingAgentSession: ReturnType + clearSleepingAgentSessionsByPaneKey: ReturnType registerAgentLaunchConfig: ReturnType clearAgentLaunchConfig: ReturnType markWorktreeUnread: ReturnType diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 7088649de0f..83ad3b247e8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -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 } diff --git a/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.test.ts b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.test.ts index f3c17f4af31..98add7b4cee 100644 --- a/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.test.ts +++ b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts index 26336810d2b..a68e56093fd 100644 --- a/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts +++ b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts @@ -1,57 +1,19 @@ -import { useAppStore } from '@/store' import { agentProviderSessionsEqual, type SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' type ResumeRetirementStore = { - sleepingAgentSessionsByPaneKey?: Record - clearSleepingAgentSession?: (paneKey: string) => void -} - -const paneKeyByPtyId = new Map() -const unbindTimersByPtyId = new Map>() - -/** 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 + 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() -}