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:
Neil
2026-10-15 23:24:27 -07:00
committed by GitHub
co-authored by Nawapat Buakoet
parent e9548691ed
commit 3140b1ed88
5 changed files with 134 additions and 4 deletions
@@ -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()
@@ -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