diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts index c67d1a71528..4aee76c86b8 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts @@ -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): 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() diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts index 74752b8a719..673d2760bd2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts @@ -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']) + }) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 4355a889e2d..6ec6a14e8b1 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -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 } }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/sleeping-agent-session-omission.ts b/src/renderer/src/store/slices/worktrees/teardown/sleeping-agent-session-omission.ts new file mode 100644 index 00000000000..0175bb87c71 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/sleeping-agent-session-omission.ts @@ -0,0 +1,44 @@ +import type { AppState } from '../../../types' +import { omitRecordKeys } from './record-key-omission' + +type SleepingAgentSessionPatch = Partial< + Pick +> + +/** + * 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, + worktreeIds: ReadonlySet, + extraPaneKeys: Iterable = [] +): 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 } + : {}) + } +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 6470ffcb550..53672d2725e 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -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