Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/main/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand Down Expand Up @@ -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' }))
Expand Down
56 changes: 56 additions & 0 deletions src/main/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
const collectPrefixedKeys = (keys: Iterable<string>): 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]
Expand Down