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..1339403c118 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,75 @@ 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. + 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 cb2a0311873..9b9279b4f0b 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -104,9 +104,20 @@ function activeOrQueuedResumeClaimsProviderSession( } 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' && + 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( @@ -115,13 +126,13 @@ function activeOrQueuedResumeClaimsProviderSession( state.ptyIdsByTabId, state.terminalLayoutsByTabId[pane.tabId] ) - ) + ) { + return true + } if ( + entry.state !== 'done' && worktreeTabIds.has(tabId ?? '') && - entry.worktreeId === record.worktreeId && - entry.agentType === record.agent && - (entry.state !== 'done' || completedPaneIsLive) && - agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession) + entry.worktreeId === record.worktreeId ) { return true }