mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +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 -- 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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user