From 877bbdebf8d6000b85a6a2673005d8d5a78171d9 Mon Sep 17 00:00:00 2001 From: Shahar Mor Date: Fri, 24 Jul 2026 09:42:35 +0300 Subject: [PATCH] fix(agents): stop forking a duplicate Pi tab for a live background session (#9729) --- .../pi-live-session-no-duplicate-tab.test.ts | 82 +++++++++++++++++++ .../src/lib/sleeping-agent-pane-ownership.ts | 36 ++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts diff --git a/src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts b/src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts new file mode 100644 index 000000000..8d369760c --- /dev/null +++ b/src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts @@ -0,0 +1,82 @@ +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' + +const initialAppStoreState = useAppStore.getState() +const LEAF_ID = '11111111-1111-1111-8111-111111111111' +const PANE_KEY = `pi-tab:${LEAF_ID}` + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +describe('Pi live session does not spawn a duplicate resume tab', () => { + it('keeps a done-but-alive Pi pane instead of forking a new tab', () => { + const providerSession = { + key: 'session_id' as const, + id: 'pi-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + } + const record: SleepingAgentSessionRecord = { + paneKey: PANE_KEY, + tabId: 'pi-tab', + worktreeId: 'wt-1', + agent: 'pi', + providerSession, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'editor', + activeTabId: null, + tabsByWorktree: { + 'wt-1': [ + { + id: 'pi-tab', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'pi', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'pi-tab': ['pty-1'] }, + terminalLayoutsByTabId: { + 'pi-tab': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [PANE_KEY]: { + state: 'done', + prompt: '', + updatedAt: 10, + stateStartedAt: 10, + agentType: 'pi', + paneKey: PANE_KEY, + worktreeId: 'wt-1', + tabId: 'pi-tab', + providerSession + } + }, + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBe(record) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 90b154cb7..a1e0f7b74 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -78,6 +78,30 @@ function hasRestorableStablePanePty( ) } +// Why: a pane whose PTY is live *right now* already owns its running session +// — e.g. a Pi TUI that finished a turn but stays alive in a background tab. +// Resume must never fork such a pane into a duplicate tab, even when it isn't +// 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( + tabId: string, + leafId: string, + ptyIdsByTabId: Record, + layout: TerminalLayoutSnapshot | undefined +): boolean { + const livePtyIds = ptyIdsByTabId[tabId] ?? [] + if (livePtyIds.length === 0) { + return false + } + const leafPtyId = layout?.ptyIdsByLeafId?.[leafId] + if (leafPtyId) { + return livePtyIds.includes(leafPtyId) + } + // Single-leaf tabs have no per-leaf binding; the tab's live PTY is this leaf's. + return layout?.root?.type === 'leaf' && layout.root.leafId === leafId +} + function paneWillConnectOnActivation( worktreeId: string, tabId: string, @@ -119,6 +143,18 @@ export function recordPaneIsOwnedByPreservedPane( if (isPassiveCompletedHibernationEvidence(record)) { return true } + // Why: a pane with a live PTY owns its running session regardless of which + // pane reconnects on activation; forking it would duplicate the session. + if ( + stablePaneHasLivePty( + tabId, + stable.leafId, + state.ptyIdsByTabId, + state.terminalLayoutsByTabId[tabId] + ) + ) { + return true + } // Why: active sessions rely on pane-level cold restore. A preserved leaf // without a PTY/session id can repaint scrollback but cannot resume. return (