diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 57153e31fef..71a76c67e20 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3295,6 +3295,47 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('removeProject prunes the repo worktrees from workspace session state', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) + + store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + store.setWorktreeMeta('r2::/other', { displayName: 'other' }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 111, 'r2::/other': 222 } + }) + + store.removeProject('r1') + + const session = store.getWorkspaceSession() + expect(session.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() + expect(session.lastVisitedAtByWorktreeId?.['r2::/other']).toBe(222) + }) + + it('removeProject prunes the repo worktrees from per-host workspace session partitions', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + + store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + + const hostId = 'ssh:host-a' + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 333 } + }, + hostId + ) + + store.removeProject('r1') + + const hostSession = store.getWorkspaceSession(hostId) + expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() + }) + it('removeProject removes the derived project host setup compatibility record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -3370,6 +3411,124 @@ describe('Store', () => { expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() }) + it('removeProjectForHost keeps the surviving host session for a shared repo id + path', async () => { + const store = await createStore() + // Same repo id AND same path on both local and an SSH host, so the owner key + // `shared::/repo` is identical across hosts. The host-scoped prune must only + // touch the removed host's session partition. + store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } + }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } + }, + 'ssh:ssh-a' + ) + + store.removeProjectForHost('shared', 'ssh:ssh-a') + + // The removed SSH host's session is pruned; the surviving local session stays. + expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) + }) + + it('removeProjectForHost on the local host keeps a surviving SSH host session', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } + }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } + }, + 'ssh:ssh-a' + ) + + store.removeProjectForHost('shared', 'local') + + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() + expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(222) + }) + + it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => { + const store = await createStore() + // Same repo id + path on local and two SSH hosts, so the owner key + // `shared::/repo` is identical across all three. Removing one non-local host + // must prune only that host's partition and leave both the local session and + // the other surviving SSH host intact. + store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-b', + executionHostId: 'ssh:ssh-b' + }) + ) + store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } + }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } + }, + 'ssh:ssh-a' + ) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { 'shared::/repo': 333 } + }, + 'ssh:ssh-b' + ) + + store.removeProjectForHost('shared', 'ssh:ssh-a') + + // Only the removed host's partition is pruned; local and the other SSH host survive. + expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) + expect(store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(333) + }) + it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/shared' })) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0a2d7821f91..ad306ba57ea 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4365,11 +4365,67 @@ export class Store { hostMembership.set(key, result) return result } + // Why: session state (legacy blob + per-host partitions) references worktrees + // by the same `${repoId}::${path}` owner key; if it is not pruned here, a + // deleted project's worktrees stay in lastVisitedAtByWorktreeId / + // sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on + // the next launch, surfacing as an orphaned "unknown" workspace. + // worktreeMeta is host-classified via belongsToHost, but session partitions + // are keyed by host directly. A session owner key carries no host, and the + // same key can exist in multiple partitions (shared repo id/path across + // hosts). So for session cleanup we collect every prefix-matching owner key + // regardless of belongsToHost, and let the per-partition host gating below + // decide which partition to touch. (belongsToHost still governs + // worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.) + const ownerKeysToPrune = new Set() + const collectPrefixedKeys = (keys: Iterable): void => { + for (const key of keys) { + if (key.startsWith(prefix)) { + ownerKeysToPrune.add(key) + } + } + } + collectPrefixedKeys(Object.keys(this.state.worktreeMeta)) + collectPrefixedKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) + for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { + collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + } + for (const key of Object.keys(this.state.worktreeMeta)) { if (belongsToHost(key)) { delete this.state.worktreeMeta[key] } } + // Why: owner keys are `${repoId}::${path}` and do not carry a host, so a + // host-scoped prune (hostId != null) must only touch that host's session: + // the legacy blob is the local host's session, and each + // workspaceSessionsByHostId partition is one non-local host. Pruning every + // partition here would wipe a surviving host's tabs, sleeping-agent state, + // and active-worktree pointer for a shared repo id/path. A full removal + // (hostId === null) still clears every host. + const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID + const pruneAllHostPartitions = hostId === null + for (const ownerKey of ownerKeysToPrune) { + if (pruneLegacyLocalSession) { + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + ownerKey + )! + } + if (this.state.workspaceSessionsByHostId) { + for (const [partitionHostId, session] of Object.entries( + this.state.workspaceSessionsByHostId + )) { + if (!pruneAllHostPartitions && partitionHostId !== hostId) { + continue + } + const pruned = removeWorkspaceSessionOwner(session, ownerKey) + if (pruned) { + this.state.workspaceSessionsByHostId[partitionHostId] = pruned + } + } + } + } for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { delete this.state.worktreeLineageById[childId]