From cd046db1e029ad4facb2340346f2a20a8093b170 Mon Sep 17 00:00:00 2001 From: mmarabel Date: Wed, 9 Sep 2026 15:55:38 +0200 Subject: [PATCH] fix(terminal): avoid resuming completed live agent sessions --- .../host-mirrored-pane-resume-replay.test.ts | 70 +++++++++++++++++++ .../src/lib/resume-sleeping-agent-session.ts | 22 +++++- .../src/lib/sleeping-agent-pane-ownership.ts | 2 +- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts index d5fca82d19b..988eb25edf9 100644 --- a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts +++ b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts @@ -4,6 +4,10 @@ import { useAppStore, type AppState } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' +import { + RESUMABLE_TUI_AGENTS, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' import { hasHostSessionMirrorHydrated, markHostSessionMirrorHydrated, @@ -194,6 +198,72 @@ describe('parked mirrored-pane resume replay', () => { expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) + it.each( + RESUMABLE_TUI_AGENTS.flatMap((agent) => + (['working', 'done'] as const).flatMap((checkpointState) => + [ + { livePtyIds: ['pty-host-old-1'], expectedLaunches: 0 }, + { livePtyIds: [], expectedLaunches: 1 }, + { livePtyIds: ['other-pane-pty'], expectedLaunches: 1 } + ].map((scenario) => ({ agent, checkpointState, ...scenario })) + ) + ) + )( + 'checks exact live ownership before resuming $agent $checkpointState with $livePtyIds', + ({ agent, checkpointState, livePtyIds, expectedLaunches }) => { + const worktree = makeRuntimeOwnedWorktree() + seedState(worktree) + const paneKey = seedSleepingRecord(worktree.id, 'completed-session') + const providerSession: AgentProviderSessionMetadata = { + key: agent === 'antigravity' ? 'conversation_id' : 'session_id', + id: 'completed-session', + ...(agent === 'pi' || agent === 'prime-agent' + ? { transcriptPath: `/remote/${agent}/session.jsonl` } + : {}) + } + const record = { + ...useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]!, + agent, + providerSession + } + const stalePaneKey = makePaneKey('old-tab', LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { [WEB_TAB_ID]: [...livePtyIds] }, + sleepingAgentSessionsByPaneKey: { + [stalePaneKey]: { + ...record, + paneKey: stalePaneKey, + tabId: 'old-tab', + state: checkpointState, + origin: 'quit' + } + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + tabId: WEB_TAB_ID, + worktreeId: worktree.id, + agentType: record.agent, + providerSession: record.providerSession, + state: 'done', + prompt: '', + updatedAt: 2000, + stateStartedAt: 2000, + stateHistory: [] + } + } + }) + markHostSessionMirrorHydrated(RUNTIME_ENV_ID) + + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(expectedLaunches) + const after = useAppStore.getState() + expect(after.tabsByWorktree[worktree.id]).toHaveLength(1 + expectedLaunches) + expect(after.tabsByWorktree[worktree.id]?.[0]?.id).toBe(WEB_TAB_ID) + expect(after.sleepingAgentSessionsByPaneKey[stalePaneKey]).toBeUndefined() + expect(Object.keys(after.pendingStartupByTabId)).toHaveLength(expectedLaunches) + } + ) + it('drops the hydrated verdict when the host reconnects under a new runtime id', () => { connectRuntime('runtime-a') markHostSessionMirrorHydrated(RUNTIME_ENV_ID) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index e0b5b79ac6e..27b8f67e0ef 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -4,10 +4,12 @@ import { type SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' import { getProviderSessionClaimKey, isPassiveCompletedHibernationEvidence, - recordPaneIsOwnedByPreservedPane + recordPaneIsOwnedByPreservedPane, + stablePaneHasLivePty } from './sleeping-agent-pane-ownership' import { launchSleepingAgentSession, @@ -96,11 +98,25 @@ function activeOrQueuedResumeClaimsProviderSession( if (samePaneOwnsRecovery && entry.paneKey === record.paneKey) { continue } + const tabId = getAgentStatusTabId(entry) + const pane = parsePaneKey(entry.paneKey) + // A completed turn still owns its transcript while its exact PTY is live. + const completedPaneIsLive = Boolean( + entry.state === 'done' && + pane && + tabId === pane.tabId && + stablePaneHasLivePty( + pane.tabId, + pane.leafId, + state.ptyIdsByTabId, + state.terminalLayoutsByTabId[pane.tabId] + ) + ) if ( - worktreeTabIds.has(getAgentStatusTabId(entry) ?? '') && + worktreeTabIds.has(tabId ?? '') && entry.worktreeId === record.worktreeId && entry.agentType === record.agent && - entry.state !== 'done' && + (entry.state !== 'done' || completedPaneIsLive) && agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession) ) { return true diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 95a6c988b31..8dac858af02 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -94,7 +94,7 @@ function hasRestorableStablePanePty( // the pane that reconnects on activation. Liveness comes from the runtime // live-PTY map (ptyIdsByTabId), not the layout's ptyIdsByLeafId snapshot, which // persists stale across sleep/restart. -function stablePaneHasLivePty( +export function stablePaneHasLivePty( tabId: string, leafId: string, ptyIdsByTabId: Record,