fix(terminal): a live pane owns its transcript in any workspace

The resume dedup was scoped to the record's own workspace on both terms
-- the entry's tab had to be in worktreeTabIds AND entry.worktreeId had
to match -- and additionally required entry.state !== 'done'. A record
whose peer pane has finished a turn and still holds a live PTY therefore
matched nothing, and the sweep launched a second agent onto a transcript
the peer is still writing. Cross-workspace, it matched nothing even while
the peer was mid-turn.

The two ids really do drift. canonicalizeTerminalSessionWorktreeId
re-keys tabsByWorktree, tabGroups, tabGroupLayouts, activeTabIdByWorktree
and activeGroupIdByWorktree onto the canonical worktree id, and does NOT
re-key sleepingAgentSessionsByPaneKey, whose records carry worktreeId
inside them. So adopting an orphaned terminal is a direct producer of a
record naming one workspace while its pane and status row name another.

Split into two arms rather than widening the existing condition. The new
arm carries no workspace scope but demands hard evidence: a provider
session id names one transcript, so a pane whose exact PTY is live right
now already owns it wherever that pane sits, and no workspace boundary
makes a live PTY less live. The scoped arm keeps its scope and its
state !== 'done' term, because a status row with no live PTY is a claim
about the past and must not reach across workspaces.

Relationship to #19736: that PR fixes the SAME-workspace half of this in
the same function, by relaxing only the status term. This arm covers that
cell too -- measured both ways on this branch, which does not carry
#19736: its thirty `checks exact live ownership before resuming` cases
all pass with this change alone, and ten of them fail without it. So this
supersedes #19736 rather than sitting beside it, and #19736's one-line
`export` of stablePaneHasLivePty is carried here because this arm needs
it. If #19736 lands first this becomes a pure widening and its tests
should be kept. Both cells are pinned here either way.
This commit is contained in:
Neil
2026-09-11 01:17:10 -07:00
parent 1be4dd9b3f
commit cc1d1357c2
3 changed files with 133 additions and 6 deletions
@@ -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)
})
})
@@ -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
}
@@ -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<string, string[]>,