From 4b05fa0bb1d26cbedc5a75be87f179f49dc9aadd Mon Sep 17 00:00:00 2001 From: PannenetsF Date: Thu, 16 Jul 2026 21:43:58 +0800 Subject: [PATCH 1/3] fix: prune workspace session state when a project is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeProject → pruneWorktreeStateForRepo cleared worktreeMeta, lineage, and workspace lineage, but left the removed repo's worktrees behind in workspaceSession (lastVisitedAtByWorktreeId, sleepingAgentSessionsByPaneKey, tab state, …) and in every workspaceSessionsByHostId partition. Those dangling references caused the runtime to treat the deleted repo's worktrees as recently-active on the next launch and re-materialize their worktreeMeta. With no owning project left, the UI rendered them as an orphaned "unknown" workspace that returned after every restart. Reuse the existing removeWorkspaceSessionOwner cleanup (already used by removeFolderWorkspace and deleteProjectGroup) against the legacy session blob and each per-host partition, collecting owner keys before the worktreeMeta delete loop so host classification still works. Fixes #9024 Co-Authored-By: Claude Fable 5 --- src/main/persistence.test.ts | 41 ++++++++++++++++++++++++++++++++++++ src/main/persistence.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 57153e31fef..7624d782116 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' })) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0a2d7821f91..661a14fed0f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4365,11 +4365,48 @@ export class Store { hostMembership.set(key, result) return result } + // Why: collect the owner keys before deleting worktreeMeta below, because + // belongsToHost reads meta.hostId to classify host-scoped keys. Workspace + // 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. + const ownerKeysToPrune = new Set() + const collectOwnerKeys = (keys: Iterable): void => { + for (const key of keys) { + if (belongsToHost(key)) { + ownerKeysToPrune.add(key) + } + } + } + collectOwnerKeys(Object.keys(this.state.worktreeMeta)) + collectOwnerKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) + for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { + collectOwnerKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + } + for (const key of Object.keys(this.state.worktreeMeta)) { if (belongsToHost(key)) { delete this.state.worktreeMeta[key] } } + for (const ownerKey of ownerKeysToPrune) { + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + ownerKey + )! + if (this.state.workspaceSessionsByHostId) { + for (const [partitionHostId, session] of Object.entries( + this.state.workspaceSessionsByHostId + )) { + 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] From 53e7f18827361063cb42bf1adb3e45a558dface3 Mon Sep 17 00:00:00 2001 From: PannenetsF Date: Fri, 17 Jul 2026 14:45:08 +0800 Subject: [PATCH 2/3] fix: scope session cleanup to the removed host in removeProjectForHost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace-session cleanup applied every collected owner key to the legacy (local) session blob AND every workspaceSessionsByHostId partition. Owner keys are `${repoId}::${path}` and carry no host, so a host-scoped removal (removeProjectForHost) of one host wrongly wiped a surviving host's session for a shared repo id/path — discarding its lastVisitedAt, tabs, sleeping-agent state, and active-worktree pointer. Scope the session prune to the removed host: prune the legacy blob only when the target host is local (or on a full removeProject where hostId is null), and prune only the matching workspaceSessionsByHostId partition for a non-local host; a full removal still clears every partition. Collect session owner keys by `${repoId}::` prefix (not belongsToHost, which is worktreeMeta-host-classified) so a partition-only key is still prunable. Adds regression tests for a shared repo id + path across local and an SSH host, covering removeProjectForHost on the SSH host (local session survives) and on the local host (SSH session survives). Addresses review feedback on #9024. Co-Authored-By: Claude Fable 5 --- src/main/persistence.test.ts | 66 ++++++++++++++++++++++++++++++++++++ src/main/persistence.ts | 45 +++++++++++++++++------- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 7624d782116..d259d21c92b 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3411,6 +3411,72 @@ 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('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 661a14fed0f..ad306ba57ea 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4365,25 +4365,30 @@ export class Store { hostMembership.set(key, result) return result } - // Why: collect the owner keys before deleting worktreeMeta below, because - // belongsToHost reads meta.hostId to classify host-scoped keys. Workspace - // session state (legacy blob + per-host partitions) references worktrees by - // the same `${repoId}::${path}` owner key; if it is not pruned here, a + // 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 collectOwnerKeys = (keys: Iterable): void => { + const collectPrefixedKeys = (keys: Iterable): void => { for (const key of keys) { - if (belongsToHost(key)) { + if (key.startsWith(prefix)) { ownerKeysToPrune.add(key) } } } - collectOwnerKeys(Object.keys(this.state.worktreeMeta)) - collectOwnerKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys(Object.keys(this.state.worktreeMeta)) + collectPrefixedKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { - collectOwnerKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) } for (const key of Object.keys(this.state.worktreeMeta)) { @@ -4391,15 +4396,29 @@ export class Store { 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) { - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - ownerKey - )! + 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 From 9136e9b40c3cacdfb65214b22cf0c512b04ee923 Mon Sep 17 00:00:00 2001 From: PannenetsF Date: Fri, 17 Jul 2026 22:23:42 +0800 Subject: [PATCH 3/3] test: cover third surviving host in removeProjectForHost session prune Add a regression case where a shared repo id + path exists on local and two SSH hosts. Removing one non-local host must prune only that host's session partition, leaving both the legacy/local session and the other surviving SSH host's partition intact. Locks in that the host-scoped cleanup never touches a non-targeted third partition (per review feedback on #9024). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/persistence.test.ts | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d259d21c92b..71a76c67e20 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3477,6 +3477,58 @@ describe('Store', () => { 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' }))