fix(agents): stop forking a duplicate Pi tab for a live background session (#9729)

This commit is contained in:
Shahar Mor 2026-07-24 09:42:35 +03:00 committed by GitHub
parent 92696558c3
commit 877bbdebf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 118 additions and 0 deletions

View File

@ -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)
})
})

View File

@ -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<string, string[]>,
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 (