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 -- while the completed-turn widening only relaxed the status
term. A cross-workspace record whose peer pane is `done` and still holds
a live PTY therefore matched nothing, and the sweep launched a second
agent onto a transcript the peer is still writing.

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.

Pins both directions: the live peer is not forked, and the same peer
without a live PTY still resumes.
This commit is contained in:
Neil
2026-09-10 17:25:51 -07:00
parent 86f9fa032a
commit 3ec1bfc970
2 changed files with 90 additions and 8 deletions
@@ -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)
})
})
@@ -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
}