mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
fix(worktrees): removing a worktree drops its sleeping-agent records (#29449)
* fix(worktrees): drop sleeping-agent records of removed worktrees A sleeping-agent record can outlive its tab. The bulk purge matched records only by the pane key's tab, so such a record survived, was persisted, and drew a sidebar row for the deleted worktree until the runtime host connected. The single removal path also awaits after terminal shutdown clears the records, leaving a window where a provider-session event routed by worktree id writes one back. Drop records (and their launch configs) by record.worktreeId in both paths. * test(worktrees): type the sleeping-record fixtures instead of casting --------- Co-authored-by: Nawapat Buakoet <nawapat.b@covest.finance>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AppState } from '../types'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
@@ -69,6 +70,23 @@ function liveEntry(paneKey: string): AgentStatusEntry {
|
||||
}
|
||||
}
|
||||
|
||||
function launchEntry(): AppState['agentLaunchConfigByPaneKey'][string] {
|
||||
return { launchConfig: { agentArgs: '', agentEnv: {} }, registeredAt: 0, identity: {} }
|
||||
}
|
||||
|
||||
function sleepingRecord(paneKey: string, worktreeId: string): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey,
|
||||
worktreeId,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: paneKey },
|
||||
prompt: '',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function seedPaneState(store: ReturnType<typeof createTestStore>): void {
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
@@ -146,6 +164,37 @@ describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak r
|
||||
expect(s.lastTerminalInputAtByPaneKey[OTHER_PANE]).toBe(2)
|
||||
})
|
||||
|
||||
it('drops sleeping records of the removed worktree whose tab is already gone (#24784)', () => {
|
||||
const store = createTestStore()
|
||||
const OTHER = 'repo1::/path/wt2'
|
||||
// Why no tab: a record can outlive its tab, so the tab-prefix purge alone cannot reach it.
|
||||
const ORPHAN_PANE = 'gone-tab:leaf-z'
|
||||
const OTHER_PANE = 'other-tab:leaf-y'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: OTHER, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { [WT]: [], [OTHER]: [] },
|
||||
agentLaunchConfigByPaneKey: {
|
||||
[ORPHAN_PANE]: launchEntry(),
|
||||
[OTHER_PANE]: launchEntry()
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[ORPHAN_PANE]: sleepingRecord(ORPHAN_PANE, WT),
|
||||
[OTHER_PANE]: sleepingRecord(OTHER_PANE, OTHER)
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT])
|
||||
|
||||
const s = store.getState()
|
||||
expect(Object.keys(s.sleepingAgentSessionsByPaneKey)).toEqual([OTHER_PANE])
|
||||
expect(Object.keys(s.agentLaunchConfigByPaneKey)).toEqual([OTHER_PANE])
|
||||
})
|
||||
|
||||
it('purgeWorktreeTerminalState forgets pane-authority aliases for the removed tabs', () => {
|
||||
resetAgentPaneAuthorityAliasesForTests()
|
||||
const store = createTestStore()
|
||||
|
||||
+25
-2
@@ -7,6 +7,7 @@ const SURVIVING_ID = 'repo-1::/repos/one/kept'
|
||||
|
||||
/** Only the maps this test asserts on; the cleanup reads them defensively. */
|
||||
function buildState(): AppState {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup reads only the maps listed here and tolerates the rest being absent.
|
||||
return {
|
||||
worktreesByRepo: { 'repo-1': [] },
|
||||
tabsByWorktree: { [REMOVED_ID]: [], [SURVIVING_ID]: [] },
|
||||
@@ -27,7 +28,11 @@ function buildState(): AppState {
|
||||
// Tab-keyed maps with no entry for the removed worktree's tabs.
|
||||
terminalLayoutsByTabId: { 'other-tab': 'single' },
|
||||
ptyIdsByTabId: {},
|
||||
expandedPaneByTabId: {}
|
||||
expandedPaneByTabId: {},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'other-tab:leaf': { paneKey: 'other-tab:leaf', worktreeId: SURVIVING_ID }
|
||||
},
|
||||
agentLaunchConfigByPaneKey: { 'other-tab:leaf': {} }
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
@@ -62,7 +67,9 @@ describe('removeWorktree map identity', () => {
|
||||
'unifiedTabsByWorktree',
|
||||
'terminalLayoutsByTabId',
|
||||
'ptyIdsByTabId',
|
||||
'expandedPaneByTabId'
|
||||
'expandedPaneByTabId',
|
||||
'sleepingAgentSessionsByPaneKey',
|
||||
'agentLaunchConfigByPaneKey'
|
||||
] as const) {
|
||||
expect(after[field], field).toBe(before[field])
|
||||
}
|
||||
@@ -82,4 +89,20 @@ describe('removeWorktree map identity', () => {
|
||||
expect(after.terminalLayoutsByTabId).toEqual({ 'other-tab': 'single' })
|
||||
expect(after.tabsByWorktree).toEqual({ [SURVIVING_ID]: [] })
|
||||
})
|
||||
|
||||
it('drops sleeping records written for the removed worktree after its terminals stopped (#24784)', () => {
|
||||
const before = buildState()
|
||||
Object.assign(before, {
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'late-tab:leaf': { paneKey: 'late-tab:leaf', worktreeId: REMOVED_ID },
|
||||
'other-tab:leaf': { paneKey: 'other-tab:leaf', worktreeId: SURVIVING_ID }
|
||||
},
|
||||
agentLaunchConfigByPaneKey: { 'late-tab:leaf': {}, 'other-tab:leaf': {} }
|
||||
})
|
||||
|
||||
const after = removeWorktree(before)
|
||||
|
||||
expect(Object.keys(after.sleepingAgentSessionsByPaneKey)).toEqual(['other-tab:leaf'])
|
||||
expect(Object.keys(after.agentLaunchConfigByPaneKey)).toEqual(['other-tab:leaf'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
|
||||
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
|
||||
import { omitRecordKeys } from './record-key-omission'
|
||||
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
|
||||
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
|
||||
|
||||
/** Clears worktree-owned renderer state after successful removal so a reused identity cannot inherit stale preferences. */
|
||||
export function applyRemoveWorktreeSuccessState(
|
||||
@@ -120,6 +121,9 @@ export function applyRemoveWorktreeSuccessState(
|
||||
new Set(worktreeIds),
|
||||
executionHostId
|
||||
),
|
||||
// Why: terminal shutdown clears these earlier, but teardown awaits after that and a
|
||||
// provider-session event routed by worktree id can write a record in between (#24784).
|
||||
...omitSleepingAgentSessionsForWorktrees(s, new Set(worktreeIds)),
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AppState } from '../../../types'
|
||||
import { omitRecordKeys } from './record-key-omission'
|
||||
|
||||
type SleepingAgentSessionPatch = Partial<
|
||||
Pick<AppState, 'sleepingAgentSessionsByPaneKey' | 'agentLaunchConfigByPaneKey'>
|
||||
>
|
||||
|
||||
/**
|
||||
* Drops sleeping-agent records that belong to removed worktrees, plus their launch configs.
|
||||
*
|
||||
* Why match on `record.worktreeId` and not the pane key's tab: a record can outlive its tab
|
||||
* (a provider-session event routed by worktree id writes one without a tab), so tab-based
|
||||
* omission misses it. A missed record is persisted and drawn as a sidebar row for the deleted
|
||||
* worktree until the host connects (#24784).
|
||||
*
|
||||
* Returns an empty patch when nothing matches, so callers keep both maps' identity.
|
||||
*/
|
||||
export function omitSleepingAgentSessionsForWorktrees(
|
||||
s: Pick<AppState, 'sleepingAgentSessionsByPaneKey' | 'agentLaunchConfigByPaneKey'>,
|
||||
worktreeIds: ReadonlySet<string>,
|
||||
extraPaneKeys: Iterable<string> = []
|
||||
): SleepingAgentSessionPatch {
|
||||
const sleeping = s.sleepingAgentSessionsByPaneKey
|
||||
// Null-tolerant: some worktree-isolation callers hand over states with this slice omitted.
|
||||
if (!sleeping) {
|
||||
return {}
|
||||
}
|
||||
const doomedPaneKeys = new Set(extraPaneKeys)
|
||||
for (const [paneKey, record] of Object.entries(sleeping)) {
|
||||
if (worktreeIds.has(record.worktreeId)) {
|
||||
doomedPaneKeys.add(paneKey)
|
||||
}
|
||||
}
|
||||
const nextSleeping = omitRecordKeys(sleeping, doomedPaneKeys)
|
||||
const nextLaunch = s.agentLaunchConfigByPaneKey
|
||||
? omitRecordKeys(s.agentLaunchConfigByPaneKey, doomedPaneKeys)
|
||||
: s.agentLaunchConfigByPaneKey
|
||||
return {
|
||||
...(nextSleeping !== sleeping ? { sleepingAgentSessionsByPaneKey: nextSleeping } : {}),
|
||||
...(nextLaunch !== s.agentLaunchConfigByPaneKey
|
||||
? { agentLaunchConfigByPaneKey: nextLaunch }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
|
||||
import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids'
|
||||
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
|
||||
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
|
||||
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
|
||||
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
|
||||
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
|
||||
@@ -137,6 +138,15 @@ export function buildWorktreePurgeState(
|
||||
return next
|
||||
})()
|
||||
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
|
||||
const tabPrefixedSleeping = {
|
||||
sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey),
|
||||
agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey)
|
||||
}
|
||||
// Why both: tab-prefix omission misses a sleeping record whose tab is already gone.
|
||||
const nextSleeping = {
|
||||
...tabPrefixedSleeping,
|
||||
...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, worktreeIdSet)
|
||||
}
|
||||
|
||||
return {
|
||||
...removeWorktreeLayout(s, {
|
||||
@@ -175,12 +185,12 @@ export function buildWorktreePurgeState(
|
||||
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
|
||||
? { agentStatusEpoch: s.agentStatusEpoch + 1 }
|
||||
: {}),
|
||||
agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey),
|
||||
agentLaunchConfigByPaneKey: nextSleeping.agentLaunchConfigByPaneKey,
|
||||
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
|
||||
activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey),
|
||||
manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey),
|
||||
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
|
||||
sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey),
|
||||
sleepingAgentSessionsByPaneKey: nextSleeping.sleepingAgentSessionsByPaneKey,
|
||||
...removeTabsAttention(s, doomed.doomedTabIds),
|
||||
lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey),
|
||||
// Delete state
|
||||
|
||||
Reference in New Issue
Block a user