diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 60926fc873c..95f7ea51751 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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((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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b8196482870..b7ba14791e5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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' }) }) } 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 8d24cfca7f7..4b259a21285 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,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() 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 new file mode 100644 index 00000000000..f31938bdea1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-confirmed-agent-exit-resume.test.ts @@ -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 + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType + agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + clearSleepingAgentSession: ReturnType +} + +let mockStoreState: MockStoreState + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification: vi.fn() +})) + +vi.mock('./parked-terminal-command-status', async (importOriginal) => ({ + ...(await importOriginal()), + 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() + }) +}) 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 25b4027c545..5dd2fa9f1cc 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 @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts index a577488b3e6..02501989964 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts @@ -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') 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 527fbb46b95..7088649de0f 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,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) 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 new file mode 100644 index 00000000000..f3c17f4af31 --- /dev/null +++ b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts new file mode 100644 index 00000000000..26336810d2b --- /dev/null +++ b/src/renderer/src/lib/confirmed-agent-exit-resume-retirement.ts @@ -0,0 +1,103 @@ +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) +} + +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() +} diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts index b6a2bab7b4a..0a7b7d43dfd 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -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()