diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts index 7a79e239eb0..8741a465e24 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts @@ -141,4 +141,104 @@ describe('resume sleeping agent provider claims', () => { expect(state.tabsByWorktree['wt-1']).toHaveLength(1) expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) + + // Why a peer in another workspace is reachable at all: adopting an orphaned terminal re-keys + // `tabsByWorktree` onto the canonical worktree id and leaves the sleeping records that named the + // old one untouched (workspace-session-worktree-id.ts). A provider session id names one + // transcript, so the live pane owns it wherever it sits; resuming here forks the agent the user + // is watching. `done` is the cell that had no cover: a finished turn on a still-live pane. + // The same-workspace half of the same rule, pinned here so this file covers both cells whether or + // not #19736 (which fixes this one in `activeOrQueuedResumeClaimsProviderSession` too) has landed. + it('does not fork a provider session a live pane in this workspace already finished a turn on', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID) + const record = makeRecord(paneKey) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-peer')] }, + terminalLayoutsByTabId: { + 'tab-peer': { + root: { type: 'leaf', leafId: OTHER_LEAF_ID }, + activeLeafId: OTHER_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' } + } + }, + ptyIdsByTabId: { 'tab-peer': ['pty-peer'] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + agentStatusByPaneKey: { + [peerPaneKey]: { ...makeWorkingStatus(peerPaneKey, 'tab-peer', record), state: 'done' } + } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('does not fork a provider session a live pane in another workspace is running', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID) + const record = makeRecord(paneKey) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + // The record's own pane is gone, so nothing local can own its recovery. + tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] }, + terminalLayoutsByTabId: { + 'tab-peer': { + root: { type: 'leaf', leafId: OTHER_LEAF_ID }, + activeLeafId: OTHER_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' } + } + }, + ptyIdsByTabId: { 'tab-peer': ['pty-peer'] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + agentStatusByPaneKey: { + [peerPaneKey]: { + ...makeWorkingStatus(peerPaneKey, 'tab-peer', record), + worktreeId: 'wt-2', + state: 'done' + } + } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0) + + const state = useAppStore.getState() + expect(state.tabsByWorktree['wt-1']).toHaveLength(0) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + // The same peer without a live PTY is history, not a claim: the session must still come back. + it('still resumes when the other workspace peer finished and holds no live PTY', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID) + const record = makeRecord(paneKey) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] }, + terminalLayoutsByTabId: { + 'tab-peer': { + root: { type: 'leaf', leafId: OTHER_LEAF_ID }, + activeLeafId: OTHER_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' } + } + }, + ptyIdsByTabId: {}, + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + agentStatusByPaneKey: { + [peerPaneKey]: { + ...makeWorkingStatus(peerPaneKey, 'tab-peer', record), + worktreeId: 'wt-2', + state: 'done' + } + } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + }) }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index de0163bd0b6..9b9279b4f0b 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, @@ -100,12 +102,37 @@ function activeOrQueuedResumeClaimsProviderSession( if (samePaneOwnsRecovery && entry.paneKey === record.paneKey) { continue } + const tabId = getAgentStatusTabId(entry) + const pane = parsePaneKey(entry.paneKey) + if ( + entry.agentType !== record.agent || + !agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession) + ) { + continue + } + // Why this arm carries no workspace scope: a provider session id names one transcript, so a + // pane whose exact PTY is live right now already owns it wherever that pane happens to sit, and + // resuming forks the agent the user is watching. The scoped arm below still needs its scope — + // a status row with no live PTY is a claim about the past. The two ids do drift: adopting an + // orphaned terminal re-keys `tabsByWorktree` without re-keying the sleeping records that name + // the old id (workspace-session-worktree-id.ts), and a completed turn on a live pane is exactly + // where the drift stops being caught. + if ( + pane && + tabId === pane.tabId && + stablePaneHasLivePty( + pane.tabId, + pane.leafId, + state.ptyIdsByTabId, + state.terminalLayoutsByTabId[pane.tabId] + ) + ) { + return true + } if ( - worktreeTabIds.has(getAgentStatusTabId(entry) ?? '') && - entry.worktreeId === record.worktreeId && - entry.agentType === record.agent && entry.state !== 'done' && - agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession) + worktreeTabIds.has(tabId ?? '') && + entry.worktreeId === record.worktreeId ) { 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,