mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
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:
@@ -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[]>,
|
||||
|
||||
Reference in New Issue
Block a user