diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 51614029a99..d3ba0328840 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -277,8 +277,20 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree rm': async ({ flags, client, cwd, json }) => { + const worktree = await getRequiredWorktreeSelector(flags, 'worktree', cwd, client) + const resolved = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree + }) + const hostId = resolved.result.worktree.hostId + if (!hostId) { + throw new RuntimeClientError( + 'worktree_host_unresolved', + 'Orca cannot tell which host owns this workspace. Refresh projects and try again.' + ) + } const result = await client.call('worktree.rm', { - worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), + worktree, + hostId, force: flags.get('force') === true, // Why (#11960): --force is explicit here, so it may also waive PTY-stop proof. allowUnverifiedPtyStop: flags.get('force') === true, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index cb55e02b535..186415d4a01 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -88,27 +88,52 @@ describe('command aliases dispatch to the canonical handler', () => { }) it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => { - queueFixtures(callMock, okFixture('req', { removed: true })) + queueFixtures( + callMock, + okFixture('req_show', { worktree: { hostId: 'local' } }), + okFixture('req', { removed: true }) + ) await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenCalledWith( + expect(callMock).toHaveBeenNthCalledWith( + 2, 'worktree.rm', - expect.objectContaining({ worktree: 'id:wt-1', force: true }) + expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true }) ) }) it('runs `worktree delete` as the canonical `worktree rm`', async () => { - queueFixtures(callMock, okFixture('req', { removed: true })) + queueFixtures( + callMock, + okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }), + okFixture('req', { removed: true }) + ) await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenCalledWith( + expect(callMock).toHaveBeenNthCalledWith( + 2, 'worktree.rm', - expect.objectContaining({ worktree: 'id:wt-1' }) + expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' }) ) }) + it('fails closed when worktree removal cannot resolve a host', async () => { + queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } })) + const priorExitCode = process.exitCode + + try { + await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' }) + } finally { + process.exitCode = priorExitCode + } + }) + it('still runs `terminal focus` after the handler de-duplication', async () => { queueFixtures(callMock, okFixture('req', { focus: { ok: true } })) diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 122f4bdd014..ab65f6faa22 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -183,6 +183,53 @@ describe('workspace cleanup broad scan opt-in', () => { ).toBe('runtime:env-1') }) + it('does not borrow same-id local metadata for a connected SSH cleanup row', async () => { + const sharedPath = '/shared/workspace' + const worktreeId = `${REPO.id}::${sharedPath}` + const localRepo = { ...REPO, path: '/local/repo' } + const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' } + const sharedWorktree: GitWorktreeInfo = { + path: sharedPath, + head: 'shared123', + branch: 'refs/heads/shared', + isBare: false, + isMainWorktree: false + } + const localMeta = makeWorktreeMeta({ + displayName: 'Local metadata only', + hostId: 'local', + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + listRepoWorktreesMock.mockResolvedValue([sharedWorktree]) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([sharedWorktree]) + }) + + const result = await scanWorkspaceCleanup( + makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }), + { includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] } + ) + + expect(result.candidates).toHaveLength(2) + expect(result.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + worktreeId, + executionHostId: 'local', + displayName: 'Local metadata only', + blockers: ['pinned'] + }), + expect.objectContaining({ + worktreeId, + executionHostId: 'ssh:ssh-1', + displayName: 'shared', + blockers: [] + }) + ]) + ) + }) + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { const result = await scanWorkspaceCleanup(makeStore()) @@ -245,6 +292,31 @@ describe('workspace cleanup broad scan opt-in', () => { ]) }) + it('does not publish a same-id local folder instance on an SSH host', async () => { + const localFolderRepo = { ...FOLDER_REPO } + const sshFolderRepo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` + getSshGitProviderMock.mockReturnValue({}) + + const result = await scanWorkspaceCleanup( + makeStore([localFolderRepo, sshFolderRepo], { + [instanceId]: makeWorktreeMeta({ + displayName: 'Local folder session', + hostId: 'local' + }) + }), + { includeAllWorkspaces: true } + ) + + expect(result.candidates).toHaveLength(3) + expect(result.candidates.filter((candidate) => candidate.worktreeId === instanceId)).toEqual([ + expect.objectContaining({ + executionHostId: 'local', + displayName: 'Local folder session' + }) + ]) + }) + it('does not mark connected SSH folder workspaces as disconnected', async () => { const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } getSshGitProviderMock.mockReturnValue({}) @@ -414,6 +486,34 @@ describe('workspace cleanup broad scan opt-in', () => { }) }) + it('does not synthesize a disconnected SSH row from same-id local metadata', async () => { + const sharedPath = '/shared/workspace' + const worktreeId = `${REPO.id}::${sharedPath}` + const localRepo = { ...REPO, path: '/local/repo' } + const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' } + const sharedWorktree: GitWorktreeInfo = { + path: sharedPath, + head: 'shared123', + branch: 'refs/heads/shared', + isBare: false, + isMainWorktree: false + } + listRepoWorktreesMock.mockResolvedValue([sharedWorktree]) + + const result = await scanWorkspaceCleanup( + makeStore([localRepo, sshRepo], { + [worktreeId]: makeWorktreeMeta({ hostId: 'local' }) + }), + { includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] } + ) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId, + executionHostId: 'local' + }) + }) + it('uses the backing path for disconnected SSH folder instances', async () => { const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555` diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index e01484cb24a..5e3cebf260c 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -14,11 +14,13 @@ import { getWorkspaceCleanupInactivityReasonsForWorkspace, isWorkspaceInactiveForCleanup } from './workspace-cleanup-candidate' +import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership' export function synthesizeDisconnectedSshCleanupCandidates( store: Store, repo: Repo, scannedAt: number, + repoOwnerCount: number, targetWorktreeIds?: ReadonlySet, includeAllWorkspaces = false ): WorkspaceCleanupCandidate[] { @@ -32,7 +34,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( continue } const meta = store.getWorktreeMeta(worktreeId) - if (meta) { + if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) { candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) } } @@ -45,7 +47,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { continue } - const meta = allMeta[worktreeId] + const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) { continue } diff --git a/src/main/ipc/workspace-cleanup-folder-workspaces.ts b/src/main/ipc/workspace-cleanup-folder-workspaces.ts index fc6112460cc..7d57db30ec1 100644 --- a/src/main/ipc/workspace-cleanup-folder-workspaces.ts +++ b/src/main/ipc/workspace-cleanup-folder-workspaces.ts @@ -2,21 +2,36 @@ import type { Store } from '../persistence' import { createFolderWorktree } from '../repo-worktrees' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { mergeWorktree } from './worktree-logic' +import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' -export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] { +export function listWorkspaceCleanupFolderWorkspaces( + store: Store, + repo: Repo, + repoOwnerCount: number +): Worktree[] { const rootId = `${repo.id}::${repo.path}` const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - const allMeta = store.getAllWorktreeMeta() + const allMeta: Readonly> = store.getAllWorktreeMeta() const worktreeIds = [ rootId, - ...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix)) + ...Object.keys(allMeta).filter( + (worktreeId) => + worktreeId.startsWith(instancePrefix) && + getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) !== undefined + ) ] const folderWorktree = createFolderWorktree(repo) return worktreeIds.map((worktreeId) => ({ - ...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName), + ...mergeWorktree( + repo.id, + folderWorktree, + getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount), + repo.displayName + ), id: worktreeId, isMainWorktree: worktreeId === rootId })) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index da6f9821356..278c3ae7ee8 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -43,6 +43,7 @@ import { getTargetWorktreeIdsByRepo, hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets' +import { isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership' const WORKTREE_SCAN_CONCURRENCY = 3 // Why: SSH repos pay a worktree-list round trip each; strictly serial repos @@ -66,10 +67,15 @@ export async function scanWorkspaceCleanup( if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) { return { scannedAt, candidates: [], errors: [] } } + const allRepos = store.getRepos() const repos = targetWorktreeIdsByRepo.size > 0 - ? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id)) - : store.getRepos() + ? allRepos.filter((repo) => targetWorktreeIdsByRepo.has(repo.id)) + : allRepos + const repoOwnerCountById = new Map() + for (const repo of allRepos) { + repoOwnerCountById.set(repo.id, (repoOwnerCountById.get(repo.id) ?? 0) + 1) + } const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options) const errors: WorkspaceCleanupScanResult['errors'] = [] const candidates: WorkspaceCleanupCandidate[] = [] @@ -83,6 +89,7 @@ export async function scanWorkspaceCleanup( return scanRepoWorkspaces({ store, repo, + repoOwnerCount: repoOwnerCountById.get(repo.id) ?? 1, scannedAt, targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id), refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true, @@ -109,6 +116,7 @@ async function scanRepoWorkspaces( args: { store: Store repo: Repo + repoOwnerCount: number scannedAt: number targetWorktreeIds?: ReadonlySet refreshTargetActivity: boolean @@ -120,6 +128,7 @@ async function scanRepoWorkspaces( const { store, repo, + repoOwnerCount, scannedAt, targetWorktreeIds, refreshTargetActivity, @@ -161,6 +170,7 @@ async function scanRepoWorkspaces( store, repo, scannedAt, + repoOwnerCount, targetWorktreeIds, includeAllWorkspaces ) @@ -174,11 +184,12 @@ async function scanRepoWorkspaces( const mergedWorktrees = repoIsFolder && includeAllWorkspaces - ? listWorkspaceCleanupFolderWorkspaces(store, repo) + ? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount) : gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined + return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName) }) // Why: with includeAllWorkspaces the browser shows every workspace and lets // filters narrow it; an age threshold here would hide rows from all views. diff --git a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts index 8bfbc586959..9468cfa8c96 100644 --- a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts +++ b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts @@ -260,6 +260,41 @@ describe('workspace cleanup snapshot IPC', () => { expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan') }) + it('persists same-id dismissals by host and prunes every key by worktree id', async () => { + let dismissals = {} + const store = { + ...makeEmptyStore(), + getUI: () => ({ workspaceCleanup: { dismissals } }), + updateUI: (update: { workspaceCleanup: { dismissals: typeof dismissals } }) => { + dismissals = update.workspaceCleanup.dismissals + } + } as unknown as Store + registerWorkspaceCleanupHandlers(store) + const handler = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)[ + 'workspaceCleanup:dismiss' + ] + const base = { + worktreeId: 'repo-1::/same', + dismissedAt: NOW, + fingerprint: 'fp', + classifierVersion: 2 + } + + await handler?.({} as never, { + dismissals: [ + { ...base, executionHostId: 'local' }, + { ...base, executionHostId: 'ssh:ssh-1' } + ] + }) + + expect(Object.keys(dismissals).sort()).toEqual( + ['local\0repo-1::/same', 'ssh:ssh-1\0repo-1::/same'].sort() + ) + + await handler?.({} as never, { dismissals: [], removedWorktreeIds: [base.worktreeId] }) + expect(dismissals).toEqual({}) + }) + it('routes a validated removal snapshot prune batch through its explicit boundary', async () => { registerWorkspaceCleanupHandlers(makeEmptyStore()) const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index e73dd27cc5e..ef120a837ad 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -15,6 +15,7 @@ import { type WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' import { parseExecutionHostId } from '../../shared/execution-host' +import { getWorkspaceCleanupHostIdentity } from '../../shared/workspace-cleanup-host-identity' import { scanWorkspaceCleanup } from './workspace-cleanup-scan' import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets' import { @@ -135,16 +136,24 @@ export function registerWorkspaceCleanupHandlers( ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { const next = { ...store.getUI().workspaceCleanup?.dismissals } for (const worktreeId of args.removedWorktreeIds ?? []) { - delete next[worktreeId] + for (const [identity, dismissal] of Object.entries(next)) { + if (dismissal.worktreeId === worktreeId) { + delete next[identity] + } + } } for (const dismissal of args.dismissals ?? []) { if ( dismissal && dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION && typeof dismissal.worktreeId === 'string' && - typeof dismissal.fingerprint === 'string' + typeof dismissal.fingerprint === 'string' && + (dismissal.executionHostId === undefined || parseExecutionHostId(dismissal.executionHostId)) ) { - next[dismissal.worktreeId] = dismissal + const identity = dismissal.executionHostId + ? getWorkspaceCleanupHostIdentity(dismissal.executionHostId, dismissal.worktreeId) + : dismissal.worktreeId + next[identity] = dismissal } } store.updateUI({ workspaceCleanup: { dismissals: next } }) diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts index f59b3828b1c..45f1152c61f 100644 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts @@ -140,7 +140,7 @@ describe('registerWorktreeHandlers', () => { ) }) - it('force-deletes an SSH branch that was preserved by safe worktree removal', async () => { + it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -151,6 +151,7 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: null } const worktreeId = 'repo-ssh::/remote/feature-wt' + const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } const provider = { exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), @@ -175,16 +176,21 @@ describe('registerWorktreeHandlers', () => { }), worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([localRepo, repo]) + store.getRepo.mockReturnValue(localRepo) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) + ) getSshGitProviderMock.mockReturnValue(provider) getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) - await handlers['worktrees:remove'](null, { worktreeId }) + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { worktreeId, branchName: 'feature/test', - expectedHead: 'def456' + expectedHead: 'def456', + hostId: 'ssh:conn-1' }) expect(result).toMatchObject({ deleted: true }) diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 41fa1764b4f..8472309261d 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -511,6 +511,9 @@ describe('registerWorktreeHandlers', () => { includeProviderInventory: true, includeLocalRegistry: false }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() }) it('fences a mirrored runtime folder workspace sweep to its environment', async () => { diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts index 9428bdbafb6..48af60bd6ca 100644 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts @@ -338,4 +338,42 @@ describe('registerWorktreeHandlers', () => { finishFirst() await expect(first).resolves.toEqual({}) }) + + // The repos are gone (host removed), so only the single global meta key is left to + // key on — and it names one host. It must not merge two hosts' forgets into one. + it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { + const worktreeId = 'repo-gone::/same/path' + store.getRepos.mockReturnValue([]) + store.getRepo.mockReturnValue(undefined) + store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + }) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index beebaf70058..561ea9c177a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -215,21 +215,15 @@ function getRepoForWorktreeRemoval( repoId: string, hostId?: ExecutionHostId ): Repo | undefined { - const matches = store - .getRepos() - .filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId)) // Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner. - if (matches.length === 1) { - return matches[0] - } - if (matches.length > 1) { - return undefined - } - const legacyMatch = store.getRepo(repoId) - return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId) - ? legacyMatch - : undefined + const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) + return owner.kind === 'resolved' ? owner.repo : undefined } +import { + hasWorktreeRemovalRepoOwnerOnOtherHost, + resolveWorktreeRemovalMetadata, + resolveWorktreeRemovalRepoOwner +} from '../worktree-removal-repo-owner' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' @@ -328,8 +322,8 @@ function resolveWorktreeRemovalOwnerHostId( fallbackHostId?: ExecutionHostId ): ExecutionHostId | undefined { return ( - store.getWorktreeMeta(worktreeId)?.hostId ?? - (repo ? getRepoExecutionHostId(repo) : fallbackHostId) + fallbackHostId ?? + (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) ) } @@ -339,19 +333,28 @@ function removeWorktreeMetadataAndTransientState( hostId?: ExecutionHostId, snapshotPruneBatchId?: string ): void { + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = getRepoIdFromWorktreeId(worktreeId) + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) + ) // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. if (hostId) { store.removeWorktreeMeta(worktreeId, hostId) } else { store.removeWorktreeMeta(worktreeId) } - advertisedUrlWatcher.forgetWorktree(worktreeId) - // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. - localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) - // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. - deleteWorktreeHistoryDir(worktreeId) - // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). - pruneWorktreePRRefreshAliases(worktreeId) + if (!preservesSameIdOwner) { + advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) + // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. + deleteWorktreeHistoryDir(worktreeId) + // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). + pruneWorktreePRRefreshAliases(worktreeId) + } // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. const snapshotDirectory = store.getProfileStorageDirectory() if (snapshotPruneBatchId) { @@ -2549,7 +2552,12 @@ export function registerWorktreeHandlers( : hasLocalWorktreeGitOptions ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) : await listGitWorktreesStrict(repo.path) - const removedMeta = store.getWorktreeMeta(args.worktreeId) + const removedMeta = resolveWorktreeRemovalMetadata( + store, + repoId, + args.worktreeId, + removalHostId + ) const removedPushTarget = removedMeta?.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( repo.path, @@ -3146,7 +3154,7 @@ export function registerWorktreeHandlers( args.worktreeId, repo ? getRepoExecutionHostId(repo) - : (store.getWorktreeMeta(args.worktreeId)?.hostId ?? args.hostId) + : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) ) const optionsKey = 'forget-local' const inFlight = worktreeRemovalsInFlight.get(inFlightKey) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index c9721b68d83..12005db4456 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -12,8 +12,11 @@ import { writeDataFile, readDataFile, makeRepo, - makeTerminalTab + makeTerminalTab, + makeWorktreeLineage, + makeWorkspaceLineage } from './persistence-test-harness' +import { worktreeWorkspaceKey } from '../shared/workspace-scope' import { TEST_LEAF_1 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -516,14 +519,15 @@ describe('Store host-partitioned workspace sessions', () => { expect( store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split'] - ).toBe(1) + ).toBeUndefined() expect( store.getWorkspaceSession('local').terminalTopologyRevisionByRepoId?.['repo-split'] ).toBeUndefined() expect(store.getWorkspaceSession('local').tabsByWorktree[otherWorktreeId]).toHaveLength(1) + expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('runtime:env-a') }) - it('trusts persisted ownership over a stale caller hostId', async () => { + it('preserves a same-id persisted owner when another qualified host is removed', async () => { const store = await createStore() const worktreeId = 'repo-split::/workspace/stale' const session = { @@ -532,16 +536,62 @@ describe('Store host-partitioned workspace sessions', () => { [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] } } - store.setWorkspaceSession(session, 'runtime:env-a') + store.setWorkspaceSession(session, 'local') store.setWorkspaceSession(session, 'runtime:env-b') - store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + store.setWorktreeMeta(worktreeId, { hostId: 'local' }) + const worktreeLineage = makeWorktreeLineage({ worktreeId }) + const workspaceLineage = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(worktreeId) + }) + store.setWorktreeLineage(worktreeId, worktreeLineage) + store.setWorkspaceLineage(workspaceLineage) - // A caller's hostId comes from live routing and can go stale mid-removal; the same - // repoId::path can name a live worktree on env-b, whose tabs must survive. + // The confirmed removal target is env-b. Bare metadata belongs to the same-id + // local owner and must not redirect the post-delete purge back to local. store.removeWorktreeMeta(worktreeId, 'runtime:env-b') - expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() - expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1) + expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('local') + expect(store.getWorktreeLineage(worktreeId)).toEqual(worktreeLineage) + expect(store.getWorkspaceLineage(workspaceLineage.childWorkspaceKey)).toEqual(workspaceLineage) + expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toHaveLength(1) + expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toBeUndefined() + }) + + it('preserves a surviving local session when removed-host metadata owns the shared id', async () => { + const store = await createStore() + const worktreeId = 'repo-split::/workspace/stale' + store.addRepo(makeRepo({ id: 'repo-split', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'repo-split', + path: '/remote/repo', + connectionId: 'ssh-b', + executionHostId: 'ssh:ssh-b' + }) + ) + const localSession = { + ...makeHostSession('repo-split'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'same-id-local-tab', worktreeId })] + } + } + const remoteSession = { + ...makeHostSession('repo-split'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'removed-remote-tab', worktreeId })] + } + } + store.setWorkspaceSession(localSession, 'local') + store.setWorkspaceSession(remoteSession, 'ssh:ssh-b') + store.setWorktreeMeta(worktreeId, { hostId: 'ssh:ssh-b' }) + + store.removeWorktreeMeta(worktreeId, 'ssh:ssh-b') + + expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: 'same-id-local-tab' }) + ]) + expect(store.getWorkspaceSession('ssh:ssh-b').tabsByWorktree[worktreeId]).toBeUndefined() + expect(store.getWorktreeMeta(worktreeId)).toBeUndefined() }) it('falls back to the caller hostId only when no ownership was recorded', async () => { diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 4fd97e3214d..add72a60aa5 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -117,6 +117,7 @@ import { type RetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../../../shared/task-providers' @@ -2484,17 +2485,34 @@ export class Store { } removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // Persisted ownership beats stale live routing; hostId is only an ownerless fallback. - const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId + // A host-qualified removal names the owner; the persisted host is the fallback. + const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId + const owner = hostId ?? persistedOwner + const preservesDifferentPersistedOwner = Boolean( + hostId && persistedOwner && persistedOwner !== hostId + ) + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const preservesSameIdSessionOwner = Boolean( + preservesDifferentPersistedOwner || + (owner && + hasWorktreeRemovalRepoOwnerOnOtherHost( + this, + getRepoIdFromWorktreeId(worktreeId), + ownerPartition + )) + ) // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter((partition) => - this.hasPersistedWorkspaceSession(partition) + workspaceSessionPartitionIdsForHost(owner).filter( + (partition) => + this.hasPersistedWorkspaceSession(partition) && + // The local partition can be a remote spill surface or a same-id owner. + // Preserve it whenever another owner may still use the bare id. + (!preservesSameIdSessionOwner || partition === ownerPartition) ) ) // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill // partition that never held this worktree has no claim on the repo at all. - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) const fencedPartitions = new Set( [...partitions].filter( (partition) => @@ -2503,9 +2521,11 @@ export class Store { !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) ) ) - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] + if (!preservesDifferentPersistedOwner) { + delete this.state.worktreeMeta[worktreeId] + delete this.state.worktreeLineageById[worktreeId] + delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] + } for (const partition of partitions) { this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { advanceTerminalTopologyRevision: fencedPartitions.has(partition) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4c2dd2fbdd7..950e17b2fe4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -17,6 +17,7 @@ import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import { reviewHeadRemoteRefComponent, @@ -2441,6 +2442,97 @@ describe('OrcaRuntimeService', () => { expect(closeTab).toHaveBeenCalledTimes(2) }) + it('preserves bare-id runtime state when removing a different qualified owner', () => { + const removeWorktreeMeta = vi.fn() + const runtimeStore = { + ...store, + getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' }), + removeWorktreeMeta + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const internals = runtime as unknown as { + mobileSessionTabsByWorktree: Map + removeWorktreeMetadataAndHistory: ( + runtimeStore: typeof store, + worktreeId: string, + hostId: string + ) => void + } + const localSession = { tabs: [{ id: 'local-tab' }] } + internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, localSession) + + internals.removeWorktreeMetadataAndHistory( + runtimeStore as typeof store, + TEST_WORKTREE_ID, + 'runtime:env-b' + ) + + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-b') + expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(localSession) + }) + + it('preserves bare-id runtime state when removed-host metadata masks another owner', () => { + const localRepo = store.getRepo(TEST_REPO_ID)! + const remoteRepo = { ...localRepo, connectionId: 'ssh-1' } + const removeWorktreeMeta = vi.fn() + const runtimeStore = { + ...store, + getRepos: () => [localRepo, remoteRepo], + getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'ssh:ssh-1' }), + removeWorktreeMeta + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const internals = runtime as unknown as { + mobileSessionTabsByWorktree: Map + removeWorktreeMetadataAndHistory: ( + runtimeStore: typeof store, + worktreeId: string, + hostId: string + ) => void + } + const survivingSession = { tabs: [{ id: 'same-id-local-tab' }] } + internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, survivingSession) + deleteWorktreeHistoryDirMock.mockClear() + + internals.removeWorktreeMetadataAndHistory( + runtimeStore as typeof store, + TEST_WORKTREE_ID, + 'ssh:ssh-1' + ) + + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:ssh-1') + expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(survivingSession) + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + }) + + it('rejects a qualified removal when only another host has persisted ownership', async () => { + const runtimeStore = { + ...store, + getWorktreeMeta: () => ({ ...store.getWorktreeMeta(TEST_WORKTREE_ID), hostId: 'local' }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const internals = runtime as unknown as { + listResolvedWorktrees: () => Promise + resolveWorktreeRemovalTarget: ( + worktreeSelector: string, + requiredHostId?: string + ) => Promise + } + internals.listResolvedWorktrees = vi.fn().mockResolvedValue([ + { + id: TEST_WORKTREE_ID, + repoId: TEST_REPO_ID, + path: TEST_WORKTREE_PATH, + hostId: 'local' + } + ]) + + await expect( + internals.resolveWorktreeRemovalTarget(TEST_WORKTREE_ID, 'runtime:env-b') + ).rejects.toThrow('selector_not_found') + expect(internals.listResolvedWorktrees).not.toHaveBeenCalled() + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -41072,6 +41164,58 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps colliding folder workspace metadata scoped to its owning host', async () => { + const localRepo = { + id: 'folder-repo', + path: '/workspace/folder', + displayName: 'local-folder', + badgeColor: 'blue' as const, + addedAt: 1, + kind: 'folder' as const + } + const remoteRepo = { + ...localRepo, + displayName: 'remote-folder', + connectionId: 'ssh-1' + } + const rootId = `${localRepo.id}::${localRepo.path}` + const childId = `${rootId}::workspace:local-child` + const metaById: Record = { + [rootId]: makeWorktreeMeta({ + hostId: 'local', + displayName: 'local-root-name', + comment: 'local-only-comment', + isPinned: true + }), + [childId]: makeWorktreeMeta({ + hostId: 'local', + instanceId: 'local-child', + displayName: 'local-child-name' + }) + } + const setWorktreeMeta = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [localRepo, remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta + } as never) + + const result = await runtime.listDetectedManagedWorktrees(`id:${remoteRepo.id}`, 'ssh-1') + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: rootId, + hostId: 'ssh:ssh-1', + displayName: 'remote-folder', + comment: '', + isPinned: false + }) + ]) + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) + it('hides agent scratch created inside a linked checkout from runtime listings', async () => { const linkedCheckoutPath = '/tmp/worktree-a' const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` @@ -41153,6 +41297,135 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).toHaveBeenCalledTimes(1) }) + it('worktree scan cache: keeps colliding local and SSH owners warm independently', async () => { + vi.mocked(listWorktrees).mockClear() + const localRepo = store.getRepo(TEST_REPO_ID)! + const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/worktree')]) + } + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [localRepo, remoteRepo] + } as never) + vi.mocked(listWorktrees).mockResolvedValue([makeWorktreeInfo(TEST_WORKTREE_PATH)]) + + try { + await runtime.listManagedWorktrees() + runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) + await runtime.listManagedWorktrees() + + expect(listWorktrees).toHaveBeenCalledTimes(1) + expect(provider.listWorktrees).toHaveBeenCalledTimes(1) + + unregisterSshGitProvider('ssh-1') + const replacementProvider = { + listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/replacement')]) + } + registerSshGitProvider('ssh-1', replacementProvider as never) + runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) + await runtime.listManagedWorktrees() + + expect(listWorktrees).toHaveBeenCalledTimes(1) + expect(replacementProvider.listWorktrees).toHaveBeenCalledTimes(1) + } finally { + unregisterSshGitProvider('ssh-1') + } + }) + + it('keeps detected metadata scoped to the selected colliding host', async () => { + const localRepo = store.getRepo(TEST_REPO_ID)! + const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } + const worktreePath = '/same/worktree' + const worktreeId = `${TEST_REPO_ID}::${worktreePath}` + const localMeta = makeWorktreeMeta({ + hostId: 'local', + displayName: 'local-only-name', + comment: 'local-only-comment', + isPinned: true + }) + const runtimeStore = { + ...store, + getRepos: () => [localRepo, remoteRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined), + getAllWorktreeMeta: () => ({ [worktreeId]: localMeta }), + getWorktreeMeta: (id: string) => (id === worktreeId ? localMeta : undefined) + } + const provider = { listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo(worktreePath)]) } + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1') + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: worktreeId, + hostId: 'ssh:ssh-1', + comment: '', + isPinned: false + }) + ]) + expect(result.worktrees[0]?.displayName).not.toBe('local-only-name') + } finally { + unregisterSshGitProvider('ssh-1') + } + }) + + it('does not project one colliding host lineage onto another host rows', async () => { + const localRepo = store.getRepo(TEST_REPO_ID)! + const remoteRepo = { ...localRepo, connectionId: 'ssh-1' } + const parentPath = '/same/parent' + const childPath = '/same/child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-parent' }), + [childId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', instanceId: 'remote-child' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'remote-child', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'remote-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtimeStore = { + ...store, + getRepos: () => [localRepo, remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + getAllWorktreeLineage: () => lineageById + } + const rows = [makeWorktreeInfo(childPath), makeWorktreeInfo(parentPath)] + vi.mocked(listWorktrees).mockResolvedValue(rows) + const provider = { listWorktrees: vi.fn().mockResolvedValue(rows) } + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const resolved = await ( + runtime as unknown as { listResolvedWorktrees: () => Promise } + ).listResolvedWorktrees() + const localChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'local' + ) + const remoteChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1' + ) + + expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null }) + expect(remoteChild).toMatchObject({ parentWorktreeId: parentId }) + } finally { + unregisterSshGitProvider('ssh-1') + } + }) + it('worktree scan cache: rescans immediately after worktree invalidation', async () => { vi.mocked(listWorktrees).mockClear() const runtime = createRuntime() @@ -47592,6 +47865,55 @@ describe('OrcaRuntimeService', () => { expect(stopAndWait).not.toHaveBeenCalled() }) + it('fails closed before teardown when a qualified target loses its host owner', async () => { + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'local' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const localProvider = { + listProcesses: vi.fn(async () => [{ id: `${TEST_WORKTREE_ID}@@1` }]), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(orphanStore as never, undefined, { + getLocalProvider: () => localProvider as never + }) + const stopAndWait = vi.fn().mockResolvedValue(true) + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'local-pty-1') + runtime.registerPty('local-pty-1', TEST_WORKTREE_ID) + const internals = runtime as unknown as { + resolveWorktreeRemovalTarget: () => Promise<{ + id: string + repoId: string + path: string + }> + } + internals.resolveWorktreeRemovalTarget = vi.fn().mockResolvedValue({ + id: TEST_WORKTREE_ID, + repoId: TEST_REPO_ID, + path: TEST_WORKTREE_PATH + }) + + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b') + ).rejects.toThrow('no longer belongs to runtime:env-b') + + expect(localProvider.listProcesses).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(stopAndWait).not.toHaveBeenCalled() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled() + }) + it('still sweeps the local host for an ownerless orphan', async () => { const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID) const orphanStore = { @@ -48271,6 +48593,85 @@ describe('OrcaRuntimeService', () => { } }) + it('force-deletes a preserved branch on the qualified host when repo ids collide', async () => { + const localRepo = store.getRepo(TEST_REPO_ID)! + const remoteRepo = { + ...localRepo, + path: '/remote/repo', + connectionId: 'ssh-1' + } + const remoteWorktree = { + path: TEST_WORKTREE_PATH, + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false + } + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ + hostId: 'local', + preserveBranchOnDelete: true + }) + } + const runtimeStore = { + ...store, + getRepos: () => [localRepo, remoteRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + removeWorktreeMeta: (worktreeId: string, hostId?: string) => { + if (!hostId || metaById[worktreeId]?.hostId === hostId) { + delete metaById[worktreeId] + } + } + } + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: remoteRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + remoteWorktree + ]), + removeWorktree: vi.fn().mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + } + registerSshGitProvider('ssh-1', provider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1') + expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false) + expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local') + const result = await runtime.forceDeletePreservedBranch( + TEST_WORKTREE_ID, + 'feature/test', + 'def456', + 'ssh:ssh-1' + ) + + expect(result).toEqual({ deleted: true }) + expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( + remoteRepo.path, + 'feature/test', + 'def456' + ) + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider('ssh-1') + } + }) + it('routes runtime preserved-branch force-delete through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { @@ -48347,6 +48748,44 @@ describe('OrcaRuntimeService', () => { await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) }) + it('does not coalesce concurrent same-id removals on different hosts', async () => { + const runtimeStore = { + ...store, + getRepos: () => [ + { ...store.getRepos()[0], executionHostId: 'local' }, + { ...store.getRepos()[0], executionHostId: 'runtime:env-1' } + ] + } + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) + const bothStarted = deferred() + const finishRemovals = deferred() + let startedCount = 0 + vi.mocked(removeWorktree).mockImplementation(async () => { + startedCount += 1 + if (startedCount === 2) { + bothStarted.resolve() + } + await finishRemovals.promise + return {} + }) + + const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local') + const paired = runtime.removeManagedWorktree( + TEST_WORKTREE_ID, + true, + false, + false, + 'runtime:env-1' + ) + + await bothStarted.promise + expect(removeWorktree).toHaveBeenCalledTimes(2) + + finishRemovals.resolve() + await expect(Promise.all([local, paired])).resolves.toEqual([{}, {}]) + }) + it('rejects concurrent runtime worktree removals for the same id with different options', async () => { const runtime = createWorktreeRemovalRuntime() const removeStarted = deferred() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 179a05e2f2b..38d3fd51b14 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -823,6 +823,7 @@ import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS, type RepoWorktreeRowDeps } from './repo-worktree-row-resolution' +import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import { withTimeout } from '../../shared/promise-timeout-fallback' import { getLocalWorktreePathAccess, @@ -1088,6 +1089,11 @@ import { stripOrcaProvenanceMetaUpdates, UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' +import { + hasWorktreeRemovalRepoOwnerOnOtherHost, + resolveWorktreeRemovalMetadata, + resolveWorktreeRemovalRepoOwner +} from '../worktree-removal-repo-owner' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { @@ -2326,13 +2332,17 @@ function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: strin } function listRuntimeFolderWorkspaces( - store: Pick, + store: Pick, repo: Repo ): Worktree[] { const rootId = getRuntimeFolderWorkspaceRootId(repo) const allMeta = store.getAllWorktreeMeta() - const ids = Object.keys(allMeta).filter((worktreeId) => - isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const ids = Object.keys(allMeta).filter( + (worktreeId) => + isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) && + (repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId) ) if (!ids.includes(rootId)) { ids.unshift(rootId) @@ -2349,14 +2359,19 @@ function listRuntimeFolderWorkspaces( } return ids.map((worktreeId) => { - const existing = allMeta[worktreeId] - const meta = existing?.instanceId + const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) + const meta: Partial = existing?.instanceId ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) - return mergeRuntimeFolderWorkspace(repo, worktreeId, meta) + : existing || repoOwnerCount === 1 + ? store.setWorktreeMeta(worktreeId, { + instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + : {} + return { + ...mergeRuntimeFolderWorkspace(repo, worktreeId, meta), + hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId + } }) } @@ -22259,12 +22274,20 @@ export class OrcaRuntimeService { const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults } if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) + const metaById = store.getAllWorktreeMeta() + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length const matcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) const detected = worktrees.map((worktree) => - this.toRuntimeDetectedWorktree(repo, worktree, matcher, visibilitySettings) + this.toRuntimeDetectedWorktree( + repo, + worktree, + matcher, + visibilitySettings, + getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null + ) ) return { repoId: repo.id, @@ -22286,18 +22309,22 @@ export class OrcaRuntimeService { [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults) ) + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const metaById = store.getAllWorktreeMeta() const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) + const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const worktree = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - hostId: meta?.hostId ?? getRepoExecutionHostId(repo) + hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId } const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, worktreeVisibilitySourceMatcher, - visibilitySettings + visibilitySettings, + meta ?? null ) if (scan.ok) { return detectedWorktree @@ -22402,7 +22429,8 @@ export class OrcaRuntimeService { repo: Repo, worktree: Worktree, worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, - providedSettings?: ReturnType + providedSettings?: ReturnType, + providedMeta?: WorktreeMeta | null ): DetectedWorktree { const settings = providedSettings ?? this.store?.getSettings() if (!settings) { @@ -22416,7 +22444,10 @@ export class OrcaRuntimeService { return toDetectedWorktree({ repo, worktree, - meta: this.store?.getWorktreeMeta(worktree.id), + meta: + providedMeta === undefined + ? this.store?.getWorktreeMeta(worktree.id) + : (providedMeta ?? undefined), settings, knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo), @@ -25523,10 +25554,18 @@ export class OrcaRuntimeService { } private async resolveWorktreeRemovalTarget( - worktreeSelector: string + worktreeSelector: string, + requiredHostId?: ExecutionHostId ): Promise { try { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const exactTarget = parseExactWorktreeIdSelector(worktreeSelector) + const worktree = + exactTarget && requiredHostId + ? ((await this.resolveExplicitWorktreeIdScoped(exactTarget.id, requiredHostId)) ?? + (() => { + throw new Error('selector_not_found') + })()) + : await this.resolveWorktreeSelector(worktreeSelector) const removalTarget = { id: worktree.id, repoId: worktree.repoId, @@ -25541,7 +25580,11 @@ export class OrcaRuntimeService { } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined - if (!removalTarget || !meta) { + if ( + !removalTarget || + !meta || + (requiredHostId !== undefined && meta.hostId !== requiredHostId) + ) { throw error } // Why: delete requests can arrive after Git no longer lists the worktree. @@ -25551,21 +25594,33 @@ export class OrcaRuntimeService { } } - private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void { + private removeWorktreeMetadataAndHistory( + store: RuntimeStore, + worktreeId: string, + hostId?: ExecutionHostId + ): void { // Why: worktree IDs are path-derived and can be recreated, so removal must // purge history and process-local caches before the ID points at new state. - const hostId = store.getWorktreeMeta(worktreeId)?.hostId + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = splitWorktreeId(worktreeId)?.repoId + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + (repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))) + ) if (hostId) { store.removeWorktreeMeta(worktreeId, hostId) } else { store.removeWorktreeMeta(worktreeId) } - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) - this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) - advertisedUrlWatcher.forgetWorktree(worktreeId) - deleteWorktreeHistoryDir(worktreeId) - this.closeHeadlessBrowserPagesForWorktree(worktreeId) + if (!preservesSameIdOwner) { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + advertisedUrlWatcher.forgetWorktree(worktreeId) + deleteWorktreeHistoryDir(worktreeId) + this.closeHeadlessBrowserPagesForWorktree(worktreeId) + } } // Why: headless offscreen browser pages are main-process BrowserWindows that @@ -25662,7 +25717,17 @@ export class OrcaRuntimeService { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } - const repo = this.store.getRepo(removalTarget.repoId) + const repoOwner = resolveWorktreeRemovalRepoOwner( + this.store, + removalTarget.repoId, + cleanupTarget.hostId + ) + if (repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined if (!repo) { throw new Error('repo_not_found') } @@ -25729,13 +25794,13 @@ export class OrcaRuntimeService { } const store = this.store const cleanupHostId = parseExecutionHostId(hostId)?.id - const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) const cleanupScopeKey = preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: cleanupHostId }) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) - const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) + const inFlightRemoval = this.removeManagedWorktreeInFlight.get(cleanupScopeKey) if (inFlightRemoval) { if (inFlightRemoval.optionsKey === optionsKey) { return inFlightRemoval.promise @@ -25744,14 +25809,32 @@ export class OrcaRuntimeService { } // Why: runtime callers can race the same workspace through CLI/mobile - // retries. Share one destructive Git/filesystem operation per worktree ID. + // retries. Share one destructive operation per host-qualified workspace. const removal = (async (): Promise => { // Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without // this span their freezes are as invisible as desktop deletes were before `worktree.remove`. return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { - const repo = store.getRepo(removalTarget.repoId) + // Why (STA-4343): a repo id can exist once per host. Honor the caller's + // host qualifier, and refuse an unqualified delete the runtime cannot + // pin to one owner rather than deleting a same-id workspace elsewhere. + const repoOwner = resolveWorktreeRemovalRepoOwner( + store, + removalTarget.repoId, + cleanupHostId + ) + if (repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined if (!repo) { const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) + if (cleanupHostId && orphanHost?.id !== cleanupHostId) { + throw new Error( + `Workspace identity for ${removalTarget.id} no longer belongs to ${cleanupHostId}. Refresh projects and try again.` + ) + } const sshPtyProvider = orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined const ptyProvider = sshPtyProvider ?? this.getLocalProvider() @@ -25796,7 +25879,11 @@ export class OrcaRuntimeService { .catch(() => {}) } this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory( + store, + removalTarget.id, + cleanupHostId ?? orphanHost?.id + ) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) @@ -25839,7 +25926,7 @@ export class OrcaRuntimeService { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) } - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) @@ -25856,7 +25943,12 @@ export class OrcaRuntimeService { : hasLocalWorktreeGitOptions ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) : await listWorktreesStrict(repo.path) - const removedMeta = store.getWorktreeMeta(removalTarget.id) + const removedMeta = resolveWorktreeRemovalMetadata( + store, + removalTarget.repoId, + removalTarget.id, + cleanupHostId ?? getRepoExecutionHostId(repo) + ) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( repo.path, @@ -25944,7 +26036,7 @@ export class OrcaRuntimeService { ) } this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) @@ -25993,7 +26085,7 @@ export class OrcaRuntimeService { localWorktreeGitOptions ) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) @@ -26029,7 +26121,7 @@ export class OrcaRuntimeService { localWorktreeGitOptions )) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) @@ -26083,7 +26175,7 @@ export class OrcaRuntimeService { removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -26129,7 +26221,7 @@ export class OrcaRuntimeService { removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -26285,7 +26377,7 @@ export class OrcaRuntimeService { localWorktreeGitOptions ) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) @@ -26319,7 +26411,7 @@ export class OrcaRuntimeService { removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, cleanupHostId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -26330,7 +26422,7 @@ export class OrcaRuntimeService { } }) })() - this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal }) + this.removeManagedWorktreeInFlight.set(cleanupScopeKey, { optionsKey, promise: removal }) try { const result = await removal this.emitWorktreeLifecycle({ @@ -26340,8 +26432,8 @@ export class OrcaRuntimeService { }) return result } finally { - if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) { - this.removeManagedWorktreeInFlight.delete(removalTarget.id) + if (this.removeManagedWorktreeInFlight.get(cleanupScopeKey)?.promise === removal) { + this.removeManagedWorktreeInFlight.delete(cleanupScopeKey) } } } @@ -30773,9 +30865,9 @@ export class OrcaRuntimeService { async (repo) => await resolveRepoWorktreeRows(deps, repo, metaById, projectRuntimeByRepoId) ) ) - const worktrees = projectResolvedWorktreeLineage( - perRepoWorktrees.flat(), - this.store?.getAllWorktreeLineage?.() ?? {} + const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} + const worktrees = perRepoWorktrees.flatMap((rows) => + projectResolvedWorktreeLineage(rows, lineageById) ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. // Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an @@ -30802,12 +30894,13 @@ export class OrcaRuntimeService { } private async resolveExplicitWorktreeIdScoped( - worktreeId: string + worktreeId: string, + requiredHostId?: ExecutionHostId ): Promise { if (!this.store) { return null } - return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId) + return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId) } private async listRepoWorktreesForResolution( @@ -30815,12 +30908,13 @@ export class OrcaRuntimeService { projectRuntimeByRepoId?: ReadonlyMap ): Promise { const now = Date.now() - const generation = this.worktreeScanGenerations.get(repo.id) ?? 0 - const projectRuntime = projectRuntimeByRepoId - ? projectRuntimeByRepoId.get(repo.id) - : !repo.connectionId - ? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) - : undefined + const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` + const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0 + const projectRuntime = repo.connectionId + ? undefined + : projectRuntimeByRepoId + ? projectRuntimeByRepoId.get(repo.id) + : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) const runtimeKey = projectRuntime ? projectRuntime.status === 'resolved' ? projectRuntime.runtime.cacheKey @@ -30828,7 +30922,7 @@ export class OrcaRuntimeService { : repo.connectionId ? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}` : 'local:default' - const cached = this.worktreeScanCache.get(repo.id) + const cached = this.worktreeScanCache.get(scanScopeKey) if ( cached?.generation === generation && cached.runtimeKey === runtimeKey && @@ -30836,21 +30930,21 @@ export class OrcaRuntimeService { ) { return cached.result } - const inFlight = this.worktreeScanInFlight.get(repo.id) + const inFlight = this.worktreeScanInFlight.get(scanScopeKey) if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { return (await inFlight.promise).result } const reusableCached = cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) - this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise }) + this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise }) try { const refresh = await promise // Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll. if ( (refresh.result.ok || !repo.connectionId) && - generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && - this.worktreeScanInFlight.get(repo.id)?.promise === promise + generation === (this.worktreeScanGenerations.get(scanScopeKey) ?? 0) && + this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise ) { const entry: RuntimeWorktreeScanCache = { generation, @@ -30860,19 +30954,19 @@ export class OrcaRuntimeService { adminFingerprint: refresh.adminFingerprint, scannedAt: refresh.scannedAt } - this.worktreeScanCache.set(repo.id, entry) + this.worktreeScanCache.set(scanScopeKey, entry) // Why a writeback instead of storing the promise: a probe that never settles must not be // awaited by a later refresh. Identity check keeps a stale probe out of a newer entry. void refresh.adminFingerprintProbe?.then((fingerprint) => { - if (this.worktreeScanCache.get(repo.id) === entry) { + if (this.worktreeScanCache.get(scanScopeKey) === entry) { entry.adminFingerprint = fingerprint } }) } return refresh.result } finally { - if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) { - this.worktreeScanInFlight.delete(repo.id) + if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) { + this.worktreeScanInFlight.delete(scanScopeKey) } } } @@ -30980,22 +31074,43 @@ export class OrcaRuntimeService { } private invalidateWorktreeScanCacheForRepo(repoId: string): void { - this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) - this.worktreeScanCache.delete(repoId) - this.worktreeScanInFlight.delete(repoId) + const prefix = `${repoId}\0` + const scopeKeys = new Set( + this.store + ?.getRepos() + .filter((repo) => repo.id === repoId) + .map((repo) => `${repoId}\0${getRepoExecutionHostId(repo)}`) ?? [] + ) + for (const keys of [ + this.worktreeScanGenerations.keys(), + this.worktreeScanCache.keys(), + this.worktreeScanInFlight.keys() + ]) { + for (const key of keys) { + if (key.startsWith(prefix)) { + scopeKeys.add(key) + } + } + } + for (const key of scopeKeys) { + this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) + this.worktreeScanCache.delete(key) + this.worktreeScanInFlight.delete(key) + } } private invalidateSshWorktreeScanCacheInternal(targetId: string): void { const repos = this.store?.getRepos() ?? [] - const affectedRepoIds = new Set( - repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) + const affectedRepos = repos.filter((repo) => repo.connectionId === targetId) + const affectedScopeKeys = new Set( + affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`) ) - for (const repoId of affectedRepoIds) { - this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) - this.worktreeScanCache.delete(repoId) - this.worktreeScanInFlight.delete(repoId) + for (const key of affectedScopeKeys) { + this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) + this.worktreeScanCache.delete(key) + this.worktreeScanInFlight.delete(key) } - if (affectedRepoIds.size > 0) { + if (affectedScopeKeys.size > 0) { this.resolvedWorktreeGeneration += 1 this.resolvedWorktreeCache = null } diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts new file mode 100644 index 00000000000..fb2c6c41e1e --- /dev/null +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { Store } from '../persistence' +import { + resolveScopedWorktreeIdRow, + type RepoWorktreeRowDeps +} from './repo-worktree-row-resolution' + +function repo( + id: string, + path: string, + options: Pick = {} +): Repo { + return { + id, + path, + displayName: id, + badgeColor: 'blue', + addedAt: 1, + ...options + } +} + +function gitWorktree(path: string): GitWorktreeInfo { + return { + path, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } +} + +function createDeps(repos: Repo[]): RepoWorktreeRowDeps & { + metaById: Record + scanRepo: ReturnType> + listFolderWorkspaces: ReturnType> +} { + const metaById: Record = {} + const store = { + getRepos: () => repos, + getAllWorktreeMeta: () => metaById, + getAllWorktreeLineage: () => ({}), + getProjects: () => [], + getSettings: () => ({}), + setWorktreeMeta: (worktreeId: string, updates: Partial) => { + const next = { ...metaById[worktreeId], ...updates } as WorktreeMeta + metaById[worktreeId] = next + return next + } + } as unknown as Store + const scanRepo = vi.fn(async (owner) => ({ + ok: true, + worktrees: [gitWorktree(owner.id === 'unrelated' ? '/unrelated/worktree' : '/same/worktree')] + })) + const listFolderWorkspaces = vi.fn(() => []) + return { store, metaById, scanRepo, listFolderWorkspaces } +} + +describe('host-qualified scoped worktree resolution', () => { + it('scans only the selected local or SSH owner when repo ids and paths collide', async () => { + const owners = [ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('shared', '/remote/repo', { + connectionId: 'builder', + executionHostId: 'ssh:builder' + }), + repo('unrelated', '/unrelated/repo', { connectionId: 'slow-box' }) + ] + const deps = createDeps(owners) + const worktreeId = 'shared::/same/worktree' + + await expect( + resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder') + ).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' }) + expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[1]]) + + deps.scanRepo.mockClear() + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({ + id: worktreeId, + hostId: 'local' + }) + expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[0]]) + }) + + it('does not project another host metadata onto a colliding scoped row', async () => { + const deps = createDeps([ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('shared', '/remote/repo', { + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ]) + const worktreeId = 'shared::/same/worktree' + deps.metaById[worktreeId] = { + displayName: 'local workspace', + hostId: 'local', + instanceId: 'local-instance', + preserveBranchOnDelete: true + } as WorktreeMeta + + await expect( + resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder') + ).resolves.toMatchObject({ + id: worktreeId, + hostId: 'ssh:builder', + displayName: 'feature' + }) + }) + + it.each([ + ['runtime:windows', String.raw`C:\Users\dev\orca worktree`], + ['local', '/mnt/c/Users/dev/orca worktree'] + ] satisfies [ExecutionHostId, string][])( + 'keeps %s path resolution scoped', + async (hostId, path) => { + const target = repo('shared', path, { executionHostId: hostId }) + const deps = createDeps([ + target, + repo('shared', '/other-host/repo', { executionHostId: 'ssh:unrelated' }), + repo('unrelated', '/unrelated/repo') + ]) + deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] })) + const worktreeId = `shared::${path}` + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, hostId)).resolves.toMatchObject({ + id: worktreeId, + hostId, + path + }) + expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([target]) + } + ) + + it('resolves only the selected folder-workspace owner without scanning Git providers', async () => { + const local = repo('folders', '/local/folders', { + kind: 'folder', + executionHostId: 'local' + }) + const remote = repo('folders', '/remote/folders', { + kind: 'folder', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + const deps = createDeps([local, remote, repo('unrelated', '/unrelated/repo')]) + const worktreeId = 'folders::/shared/folder' + deps.listFolderWorkspaces.mockImplementation((owner) => + owner === remote + ? ([ + { + id: worktreeId, + repoId: owner.id, + path: '/shared/folder', + head: '', + branch: '', + isBare: false, + isMainWorktree: false, + displayName: 'folder', + comment: '', + hostId: 'ssh:builder' + } as unknown as Worktree + ] as Worktree[]) + : [] + ) + + await expect( + resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder') + ).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' }) + expect(deps.listFolderWorkspaces).toHaveBeenCalledExactlyOnceWith(remote) + expect(deps.scanRepo).not.toHaveBeenCalled() + }) + + it('does not scan when the requested host does not own the repo id', async () => { + const deps = createDeps([ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('unrelated', '/unrelated/repo', { executionHostId: 'runtime:other' }) + ]) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree', 'ssh:builder') + ).resolves.toBeNull() + expect(deps.scanRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index 24ca1c36293..98042ebb17d 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,5 +1,5 @@ import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { withTimeout } from '../../shared/promise-timeout-fallback' @@ -11,6 +11,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe import type { Store } from '../persistence' import { areWorktreePathsEqual, mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import { resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -120,15 +121,21 @@ export async function resolveRepoWorktreeRows( if (scan.ok) { pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` // Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp. const existingMeta = metaById[worktreeId] - const meta = - existingMeta && existingMeta.instanceId ? existingMeta : store.setWorktreeMeta(worktreeId, {}) + const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) + const meta = ownedExistingMeta?.instanceId + ? ownedExistingMeta + : ownedExistingMeta || (!existingMeta && repoOwnerCount === 1) + ? store.setWorktreeMeta(worktreeId, {}) + : undefined const merged = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo) + hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId } return { ...merged, @@ -157,16 +164,23 @@ export async function resolveRepoWorktreeRows( */ export async function resolveScopedWorktreeIdRow( deps: RepoWorktreeRowDeps, - worktreeId: string + worktreeId: string, + requiredHostId?: ExecutionHostId ): Promise { const { store } = deps const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!parsed?.repoId || !parsed.worktreePath) { return null } - const owners = store.getRepos().filter((repo) => repo.id === parsed.repoId) + const owners = store + .getRepos() + .filter( + (repo) => + repo.id === parsed.repoId && + (requiredHostId === undefined || getRepoExecutionHostId(repo) === requiredHostId) + ) // Why: one repo id can be registered on several execution hosts, and only the fleet scan decides - // between their rows. Hand those back to the unscoped path rather than guessing. + // between unqualified rows. A host qualifier narrows the same-id set without scanning other owners. if (owners.length !== 1) { return null } diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts index 090a7981147..e69da50f361 100644 --- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts @@ -23,6 +23,13 @@ describe('workspaceCleanup client-ui schema', () => { expect(WorkspaceCleanup.parse({ dismissals: {} })).toEqual({ dismissals: {} }) }) + it('preserves the optional host qualifier on a dismissal', () => { + const qualified = { ...DISMISSAL, executionHostId: 'ssh:ssh-1' } + const parsed = WorkspaceCleanup.parse({ dismissals: { 'ssh:ssh-1\0wt-1': qualified } }) + + expect(parsed.dismissals['ssh:ssh-1\0wt-1']).toEqual(qualified) + }) + // The wire contract: a NEWER client's filter groups must never fail an older // host's schema, or the whole ui.set payload is refused (dismissals included). it('accepts a browse state carrying unknown future fields and narrows it', () => { diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts index 79984b55721..624a63ff44e 100644 --- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -8,7 +8,8 @@ const WorkspaceCleanupDismissal = z.object({ worktreeId: z.string(), dismissedAt: z.number().finite(), fingerprint: z.string(), - classifierVersion: z.number().finite() + classifierVersion: z.number().finite(), + executionHostId: z.string().min(1).optional() }) /** diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts new file mode 100644 index 00000000000..c706c3a05be --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { WORKTREE_METHODS } from './worktree' + +function makeRuntime(): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }), + removeManagedWorktree: vi.fn().mockResolvedValue({}) + } as unknown as OrcaRuntimeService +} + +function makeRequest(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params } +} + +describe('worktree.rm host qualification', () => { + it('routes an explicitly qualified removal to that host', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + true, + false, + false, + 'local' + ) + expect(response).toMatchObject({ ok: true, result: { removed: true } }) + }) + + it('resolves an old-client removal through the ambiguity-aware worktree lookup', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false }) + ) + + expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1') + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + true, + false, + false, + 'local' + ) + expect(response).toMatchObject({ ok: true, result: { removed: true } }) + }) + + it('fails closed when an old-client selector is ambiguous', async () => { + const runtime = makeRuntime() + vi.mocked(runtime.showManagedWorktree).mockRejectedValue(new Error('selector_ambiguous')) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false }) + ) + + expect(response).toMatchObject({ ok: false, error: { message: 'selector_ambiguous' } }) + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + }) + + it('fails closed when an old-client row has no host evidence', async () => { + const runtime = makeRuntime() + vi.mocked(runtime.showManagedWorktree).mockResolvedValue({ id: 'wt-1' } as never) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ worktree: 'id:wt-1', force: true, runHooks: false }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: 'worktree.rm could not resolve the workspace host' } + }) + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts index 3b51a57d054..7137d0a7e8f 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts @@ -25,10 +25,22 @@ describe('worktree.rm PTY-stop waiver', () => { id: 'req-1', authToken: 'tok', method: 'worktree.rm', - params: { worktree: 'id:wt-1', force: true, allowUnverifiedPtyStop: true, runHooks: false } + params: { + worktree: 'id:wt-1', + hostId: 'local', + force: true, + allowUnverifiedPtyStop: true, + runHooks: false + } } satisfies RpcRequest) - expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, true) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + true, + false, + true, + 'local' + ) }) it('does not infer a waiver from force alone', async () => { @@ -39,9 +51,15 @@ describe('worktree.rm PTY-stop waiver', () => { id: 'req-1', authToken: 'tok', method: 'worktree.rm', - params: { worktree: 'id:wt-1', force: true, runHooks: false } + params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false } } satisfies RpcRequest) - expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + true, + false, + false, + 'local' + ) }) }) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 0b5be4da384..1be08978ea9 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -76,27 +76,6 @@ describe('worktree RPC methods', () => { }) }) - it('routes dirty-file force to the runtime server', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - dedupeWorktreeCreate: passthroughDedupe, - removeManagedWorktree: vi.fn().mockResolvedValue({}) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('worktree.rm', { - worktree: 'id:wt-1', - force: true, - runHooks: false - }) - ) - - // Why (#11960): dirty-file force alone must not waive the PTY-stop proof. - expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', true, false, false) - expect(response).toMatchObject({ ok: true, result: { removed: true } }) - }) - it('routes create options to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 88f3b87a324..5630868365b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -201,15 +201,18 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { + // Older mobile clients omit hostId, so resolve through the ambiguity gate before pinning removal. + const hostId = params.hostId ?? (await runtime.showManagedWorktree(params.worktree)).hostId + if (!hostId) { + throw new Error('worktree.rm could not resolve the workspace host') + } const removalArgs = [ params.worktree, params.force === true, params.runHooks === true, params.allowUnverifiedPtyStop === true ] as const - const result = params.hostId - ? await runtime.removeManagedWorktree(...removalArgs, params.hostId) - : await runtime.removeManagedWorktree(...removalArgs) + const result = await runtime.removeManagedWorktree(...removalArgs, hostId) return { removed: true, ...result } } }), diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index 18c226221bd..4ecdbc80a8b 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -24,7 +24,7 @@ export function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string export function mergeRuntimeFolderWorkspace( repo: Repo, worktreeId: string, - meta: WorktreeMeta + meta: Partial ): Worktree { const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance) return { diff --git a/src/main/worktree-metadata-ownership.ts b/src/main/worktree-metadata-ownership.ts new file mode 100644 index 00000000000..053052c925d --- /dev/null +++ b/src/main/worktree-metadata-ownership.ts @@ -0,0 +1,22 @@ +import { getRepoExecutionHostId } from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' + +/** Same repo/worktree ids can exist on several hosts; bare metadata cannot choose between them. */ +export function getRepoOwnedWorktreeMeta( + repo: Repo, + worktreeId: string, + metaById: Readonly>, + repoOwnerCount: number +): WorktreeMeta | undefined { + const existingMeta = metaById[worktreeId] + return isWorktreeMetaOwnedByRepo(repo, existingMeta, repoOwnerCount) ? existingMeta : undefined +} + +export function isWorktreeMetaOwnedByRepo( + repo: Repo, + meta: WorktreeMeta | undefined, + repoOwnerCount: number +): meta is WorktreeMeta { + return Boolean(meta && (repoOwnerCount === 1 || meta.hostId === getRepoExecutionHostId(repo))) +} diff --git a/src/main/worktree-removal-repo-owner.test.ts b/src/main/worktree-removal-repo-owner.test.ts new file mode 100644 index 00000000000..0895e89b307 --- /dev/null +++ b/src/main/worktree-removal-repo-owner.test.ts @@ -0,0 +1,110 @@ +/** + * STA-4343 (host side): a repo id registered on two hosts has no single owner, + * so unqualified legacy resolution is ambiguous and a qualified call picks the + * named host. The destructive RPC boundary rejects missing qualifiers earlier. + */ +import { describe, expect, it } from 'vitest' +import { + hasWorktreeRemovalRepoOwnerOnOtherHost, + resolveWorktreeRemovalMetadata, + resolveWorktreeRemovalRepoOwner +} from './worktree-removal-repo-owner' +import type { Repo } from '../shared/repo-types' + +function makeRepo(overrides: Partial & Pick): Repo { + return { + path: '/repos/shared', + displayName: 'Shared', + badgeColor: '#000', + addedAt: 0, + ...overrides + } as Repo +} + +function makeStore(repos: readonly Repo[]): { + getRepos: () => readonly Repo[] + getRepo: (repoId: string) => Repo | undefined +} { + return { + getRepos: () => repos, + getRepo: (repoId) => repos.find((repo) => repo.id === repoId) + } +} + +describe('resolveWorktreeRemovalRepoOwner', () => { + const localRepo = makeRepo({ id: 'repo1' }) + const sshRepo = makeRepo({ id: 'repo1', connectionId: 'ssh-1' }) + + it('fails closed when an unqualified request matches two hosts', () => { + expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo, sshRepo]), 'repo1')).toEqual({ + kind: 'ambiguous' + }) + }) + + it('selects the named host even when a same-id repo is registered first', () => { + const owner = resolveWorktreeRemovalRepoOwner( + makeStore([localRepo, sshRepo]), + 'repo1', + 'ssh:ssh-1' + ) + expect(owner).toEqual({ kind: 'resolved', repo: sshRepo }) + }) + + it('reports missing when the named host owns no such repo', () => { + expect(resolveWorktreeRemovalRepoOwner(makeStore([localRepo]), 'repo1', 'ssh:ssh-1')).toEqual({ + kind: 'missing' + }) + expect(resolveWorktreeRemovalRepoOwner(makeStore([]), 'repo1')).toEqual({ kind: 'missing' }) + }) + + it('detects a surviving same-id owner only on another host', () => { + const store = makeStore([localRepo, sshRepo]) + + expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'ssh:ssh-1')).toBe(true) + expect(hasWorktreeRemovalRepoOwnerOnOtherHost(store, 'repo1', 'local')).toBe(true) + expect(hasWorktreeRemovalRepoOwnerOnOtherHost(makeStore([sshRepo]), 'repo1', 'ssh:ssh-1')).toBe( + false + ) + }) + + it('does not borrow destructive metadata from a colliding host', () => { + const localMeta = { hostId: 'local', preserveBranchOnDelete: true } + const metadataStore = { + getRepos: () => [localRepo, sshRepo], + getWorktreeMeta: () => localMeta + } + + expect( + resolveWorktreeRemovalMetadata( + metadataStore as never, + 'repo1', + 'repo1::/shared/worktree', + 'ssh:ssh-1' + ) + ).toBeUndefined() + expect( + resolveWorktreeRemovalMetadata( + metadataStore as never, + 'repo1', + 'repo1::/shared/worktree', + 'local' + ) + ).toBe(localMeta) + }) + + it('keeps legacy persisted host metadata when the repo id has one owner', () => { + const runtimeMeta = { hostId: 'runtime:env-1', preserveBranchOnDelete: true } + + expect( + resolveWorktreeRemovalMetadata( + { + getRepos: () => [localRepo], + getWorktreeMeta: () => runtimeMeta + } as never, + 'repo1', + 'repo1::/shared/worktree', + 'local' + ) + ).toBe(runtimeMeta) + }) +}) diff --git a/src/main/worktree-removal-repo-owner.ts b/src/main/worktree-removal-repo-owner.ts new file mode 100644 index 00000000000..86b1c828e94 --- /dev/null +++ b/src/main/worktree-removal-repo-owner.ts @@ -0,0 +1,69 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' + +export type WorktreeRemovalRepoSource = { + getRepos: () => readonly Repo[] + getRepo: (repoId: string) => Repo | undefined +} + +export type WorktreeRemovalRepoOwner = + | { kind: 'resolved'; repo: Repo } + | { kind: 'ambiguous' } + | { kind: 'missing' } + +/** + * Which repo a destructive worktree removal belongs to. + * + * A repo id can be registered once per execution host. Destructive RPC callers + * reject a missing host before reaching this resolver; the unqualified branch + * remains only for direct legacy callers and still refuses multiple owners. + */ +export function resolveWorktreeRemovalRepoOwner( + store: WorktreeRemovalRepoSource, + repoId: string, + hostId?: ExecutionHostId +): WorktreeRemovalRepoOwner { + const matches = store + .getRepos() + .filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId)) + if (matches.length === 1 && matches[0]) { + return { kind: 'resolved', repo: matches[0] } + } + if (matches.length > 1) { + return { kind: 'ambiguous' } + } + const legacyMatch = store.getRepo(repoId) + return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId) + ? { kind: 'resolved', repo: legacyMatch } + : { kind: 'missing' } +} + +export function hasWorktreeRemovalRepoOwnerOnOtherHost( + store: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + return store + .getRepos() + .some((repo) => repo.id === repoId && getRepoExecutionHostId(repo) !== hostId) +} + +export function resolveWorktreeRemovalMetadata( + store: Pick & { + getWorktreeMeta: (worktreeId: string) => WorktreeMeta | undefined + }, + repoId: string, + worktreeId: string, + hostId: ExecutionHostId +): WorktreeMeta | undefined { + const meta = store.getWorktreeMeta(worktreeId) + if (!meta) { + return undefined + } + const repoOwnerCount = store.getRepos().filter((repo) => repo.id === repoId).length + if (repoOwnerCount <= 1) { + return meta + } + return meta.hostId === hostId ? meta : undefined +} diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx index 49656d86e12..b88db08785a 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -17,8 +17,8 @@ vi.mock('./use-workspace-cleanup-facet-rows', () => ({ probes.facets() return { rows: [], - selectableWorktreeIds: [], - facetMatchedWorktreeIds: new Set(), + selectableIdentities: [], + facetMatchedIdentities: new Set(), matchedCount: 0, totalCount: 0, facetCounts: { diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx index 204b28317f1..bd6efd3565d 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -161,7 +161,7 @@ function rowNames(): string[] { } function rowCheckbox(name: string): HTMLElement | null { - return container?.querySelector(`[aria-label="Select ${name}"]`) ?? null + return container?.querySelector(`[aria-label^="Select ${name} on "]`) ?? null } function cachedFleet(): WorkspaceCleanupScanResult { diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index e732584f266..0fa30580ea6 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -11,10 +11,12 @@ import { canQueueWorkspaceCleanupCandidate, type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar' import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header' +import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases' import { WorkspaceCleanupInitialScanBanner, WorkspaceCleanupNotice, @@ -36,7 +38,7 @@ import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evid import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' import { formatVanishedSelectionNotice, - getDefaultSelectedWorkspaceCleanupIds, + getDefaultSelectedWorkspaceCleanupIdentities, toggleSetMember } from './workspace-cleanup-selection-model' @@ -68,7 +70,7 @@ function WorkspaceCleanupDialogContent({ const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace) const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress) - const deletionPhaseByWorktreeId = useAppStore( + const genericDeletionPhaseByWorktreeId = useAppStore( useShallow((s) => { const phases: Record = {} for (const [worktreeId, state] of Object.entries(s.deleteStateByWorktreeId)) { @@ -79,11 +81,6 @@ function WorkspaceCleanupDialogContent({ return phases }) ) - const deletingWorktreeIds = useMemo( - () => new Set(Object.keys(deletionPhaseByWorktreeId)), - [deletionPhaseByWorktreeId] - ) - const browse = useWorkspaceCleanupBrowseState() // Why: facet counts/options are only rendered inside the filter popover; // tracking its open state lets their O(N) passes skip while it is closed. @@ -113,6 +110,19 @@ function WorkspaceCleanupDialogContent({ }, [open]) const { removalInFlightRef } = removal + const deletionPhaseByIdentity = useMemo( + () => + getWorkspaceCleanupDeletionPhaseByIdentity( + scan?.candidates ?? [], + removal.deletionPhaseByIdentity, + genericDeletionPhaseByWorktreeId + ), + [genericDeletionPhaseByWorktreeId, removal.deletionPhaseByIdentity, scan?.candidates] + ) + const deletingIdentities = useMemo( + () => new Set(Object.keys(deletionPhaseByIdentity)), + [deletionPhaseByIdentity] + ) const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) @@ -122,8 +132,8 @@ function WorkspaceCleanupDialogContent({ scannedAt: scan?.scannedAt ?? null }) const evidencedCandidates = useMemo( - () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId), - [candidates, gitEvidence.evidenceByWorktreeId] + () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByIdentity), + [candidates, gitEvidence.evidenceByIdentity] ) // Why: a live clock would re-run every facet on each render; the newer of // scan time and open time is the honest stable "as of" moment — a fresh scan @@ -141,8 +151,11 @@ function WorkspaceCleanupDialogContent({ streaming: loading, sort: browse.sort }) - const candidateIds = useMemo( - () => new Set(candidates.map((candidate) => candidate.worktreeId)), + // Why (STA-4343): rows are keyed by host-qualified identity, not by + // `worktreeId` — two hosts can list the same `repoId::path` workspace, and an + // id-keyed selection would confirm one host's row and delete the other's. + const candidateIdentities = useMemo( + () => new Set(candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))), [candidates] ) @@ -171,16 +184,16 @@ function WorkspaceCleanupDialogContent({ }, [mountedRef, refreshWorkspaceSpace]) const selectedCandidates = useMemo(() => { - const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate])) + const byIdentity = new Map(rows.map((row) => [row.identity, row.candidate])) return [...selectedIds] - .map((id) => byId.get(id)) + .map((identity) => byIdentity.get(identity)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => candidate != null && canQueueWorkspaceCleanupCandidate(candidate) && - !deletingWorktreeIds.has(candidate.worktreeId) + !deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) ) - }, [deletingWorktreeIds, rows, selectedIds]) + }, [deletingIdentities, rows, selectedIds]) const selectedCount = selectedCandidates.length useEffect(() => { @@ -195,14 +208,17 @@ function WorkspaceCleanupDialogContent({ return } defaultsAppliedForOpenRef.current = true - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) - }, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds]) + setSelectedIds( + getDefaultSelectedWorkspaceCleanupIdentities(scan.candidates, deletingIdentities) + ) + }, [deletingIdentities, loading, removalInFlightRef, scan, setSelectedIds]) const pruneSelectionToVisibleRows = useEffectEvent(() => { setSelectedIds((current) => { const next = new Set( [...current].filter( - (id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id) + (identity) => + facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity) ) ) return next.size === current.size ? current : next @@ -221,14 +237,15 @@ function WorkspaceCleanupDialogContent({ }, [browse.filters, open, removal.confirming]) const pruneVanishedSelections = useEffectEvent(() => { + const isDeleting = (identity: string): boolean => deletingIdentities.has(identity) const kept = [...selectedIds].filter( - (id) => candidateIds.has(id) && !deletingWorktreeIds.has(id) + (identity) => candidateIdentities.has(identity) && !isDeleting(identity) ) if (kept.length === selectedIds.size) { return } const vanishedCount = [...selectedIds].filter( - (id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id) + (identity) => !candidateIdentities.has(identity) && !isDeleting(identity) ).length setSelectedIds(new Set(kept)) if (vanishedCount > 0) { @@ -240,10 +257,10 @@ function WorkspaceCleanupDialogContent({ if (loading) { return } - // Why: a settled refresh reconciles by worktreeId — selections survive it, + // Why: a settled refresh reconciles by host-qualified identity — selections survive it, // and only rows that truly no longer exist drop (with a visible reason). pruneVanishedSelections() - }, [candidateIds, deletingWorktreeIds, loading]) + }, [candidateIdentities, deletingIdentities, loading]) const ignoreCandidate = useCallback( (candidate: WorkspaceCleanupCandidate) => { @@ -252,7 +269,7 @@ function WorkspaceCleanupDialogContent({ if (mountedRef.current) { setSelectedIds((current) => { const next = new Set(current) - next.delete(candidate.worktreeId) + next.delete(getWorkspaceCleanupCandidateIdentity(candidate)) return next }) } @@ -272,31 +289,31 @@ function WorkspaceCleanupDialogContent({ [dismissCandidates, mountedRef, setSelectedIds] ) - const toggleExpandedRow = useCallback((worktreeId: string) => { - setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + const toggleExpandedRow = useCallback((identity: string) => { + setExpandedRowIds((current) => toggleSetMember(current, identity)) }, []) const toggleSelectedRow = useCallback( - (worktreeId: string) => { + (identity: string) => { selectionTouchedRef.current = true - setSelectedIds((current) => toggleSetMember(current, worktreeId)) + setSelectedIds((current) => toggleSetMember(current, identity)) }, [setSelectedIds] ) - const selectableWorktreeIds = useMemo( + const selectableIdentities = useMemo( () => removal.removalInFlight ? [] - : facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)), - [deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight] + : facetRows.selectableIdentities.filter((identity) => !deletingIdentities.has(identity)), + [deletingIdentities, facetRows.selectableIdentities, removal.removalInFlight] ) const toggleSelectAll = useCallback( (selectAll: boolean) => { selectionTouchedRef.current = true - setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set()) + setSelectedIds(selectAll ? new Set(selectableIdentities) : new Set()) }, - [selectableWorktreeIds, setSelectedIds] + [selectableIdentities, setSelectedIds] ) // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow @@ -310,7 +327,7 @@ function WorkspaceCleanupDialogContent({ return } selectionTouchedRef.current = true - setSelectedIds(new Set([candidate.worktreeId])) + setSelectedIds(new Set([getWorkspaceCleanupCandidateIdentity(candidate)])) openConfirmRemove([candidate]) }, [openConfirmRemove, removalInFlightRef, setSelectedIds] @@ -372,7 +389,7 @@ function WorkspaceCleanupDialogContent({ facetRows={facetRows} facetPanelOpen={facetPanelOpen} onFacetPanelOpenChange={setFacetPanelOpen} - selectableCount={selectableWorktreeIds.length} + selectableCount={selectableIdentities.length} selectedCount={selectedCount} spaceScanning={workspaceSpaceScanning} spaceProgress={workspaceSpaceProgress} @@ -391,13 +408,12 @@ function WorkspaceCleanupDialogContent({ scannedCount={candidates.length} hasScanned={scan != null} loading={loading} - deletionPhaseByWorktreeId={deletionPhaseByWorktreeId} - deletingWorktreeIds={deletingWorktreeIds} + deletionPhaseByIdentity={deletionPhaseByIdentity} + deletingIdentities={deletingIdentities} expandedRowIds={expandedRowIds} selectedIds={selectedIds} gitPendingWorktreeIds={gitEvidence.pendingWorktreeIds} rowFailures={removal.rowFailures} - removalInFlight={removal.removalInFlight} scrollElement={rowsScrollElement} onClearFilters={browse.clearFilters} onToggleExpanded={toggleExpandedRow} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts index 194ed95240b..02cc919af93 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts @@ -13,6 +13,7 @@ export type WorkspaceCleanupDialogLifecycle = { mountedContent: boolean loading: boolean closeModal: () => void + /** Host-qualified row identities, not worktree ids (STA-4343). */ selectedIds: Set setSelectedIds: Dispatch>> removal: WorkspaceCleanupRemovalController @@ -27,19 +28,22 @@ export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLife const open = activeModal === 'workspace-cleanup' const [selectedIds, setSelectedIds] = useState>(() => new Set()) - const deselectRemovedIds = useCallback((removedIds: readonly string[]) => { - if (removedIds.length === 0) { + const deselectRemovedIdentities = useCallback((removedIdentities: readonly string[]) => { + if (removedIdentities.length === 0) { return } setSelectedIds((current) => { const next = new Set(current) - for (const id of removedIds) { - next.delete(id) + for (const identity of removedIdentities) { + next.delete(identity) } return next }) }, []) - const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal }) + const removal = useWorkspaceCleanupRemoval({ + onDeselect: deselectRemovedIdentities, + closeModal + }) const { removalInFlightRef, resetForReopen, resetRowFailures } = removal const onFreshOpen = useCallback(() => { diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx index 413493216ad..38cc90acd71 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx @@ -208,7 +208,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => { { initialProps: { current: candidates } } ) const initialCounts = { ...counts } - const matched = view.result.current.facetMatchedWorktreeIds + const matched = view.result.current.facetMatchedIdentities const rows = view.result.current.rows // A no-op progress tick delivers a fresh array of the same candidate objects. @@ -216,7 +216,7 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => { expect(counts).toEqual(initialCounts) expect(view.result.current.rows).toBe(rows) - expect(view.result.current.facetMatchedWorktreeIds).toBe(matched) + expect(view.result.current.facetMatchedIdentities).toBe(matched) }) it('keeps facet identity for untouched rows when a tick replaces one candidate', () => { diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts index b957f715eaa..3d006ee518e 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -39,8 +39,8 @@ import { export type WorkspaceCleanupFacetRows = { rows: WorkspaceCleanupFacets[] - selectableWorktreeIds: string[] - facetMatchedWorktreeIds: ReadonlySet + selectableIdentities: string[] + facetMatchedIdentities: ReadonlySet matchedCount: number totalCount: number facetCounts: WorkspaceCleanupFacetCounts @@ -238,9 +238,9 @@ export function useWorkspaceCleanupFacetRows({ ) // Identity churn here is harmless: the only consumer reads the latest set // inside a useEffectEvent body and never keys an effect on it. - const facetMatchedWorktreeIds = useMemo>( + const facetMatchedIdentities = useMemo>( () => - new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)), + new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.identity)), [facetFilters, facets, now] ) const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets]) @@ -275,8 +275,8 @@ export function useWorkspaceCleanupFacetRows({ return { rows: result.rows, - selectableWorktreeIds: result.selectableWorktreeIds, - facetMatchedWorktreeIds, + selectableIdentities: result.selectableIdentities, + facetMatchedIdentities, matchedCount: result.matchedCount, totalCount: result.totalCount, facetCounts, diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx index c4cf958bbcf..5c9f5c8c0d0 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx @@ -85,7 +85,7 @@ describe('useWorkspaceCleanupGitEvidence', () => { }) await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) expect(view.result.current.pendingWorktreeIds.size).toBe(6) - expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + expect(view.result.current.evidenceByIdentity.size).toBe(0) await act(async () => { pending[1]?.onProgress?.({ @@ -98,7 +98,7 @@ describe('useWorkspaceCleanupGitEvidence', () => { candidateMode: 'append' }) }) - await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1)) expect(view.result.current.pendingWorktreeIds.size).toBe(5) }) @@ -177,7 +177,7 @@ describe('useWorkspaceCleanupGitEvidence', () => { }) }) await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) - expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + expect(view.result.current.evidenceByIdentity.size).toBe(0) expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a'])) await act(async () => { @@ -187,6 +187,6 @@ describe('useWorkspaceCleanupGitEvidence', () => { errors: [] }) }) - await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + await waitFor(() => expect(view.result.current.evidenceByIdentity.size).toBe(1)) }) }) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts index 95377c8a5de..9aa9d53a6d7 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -1,21 +1,22 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useMountedRef } from '@/hooks/useMountedRef' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import { selectWorkspaceCleanupGitEvidenceTargets, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS } from './workspace-cleanup-git-evidence' export type WorkspaceCleanupGitEvidenceState = { - /** Focused re-scan results, keyed by worktree id. */ - evidenceByWorktreeId: ReadonlyMap + /** Focused re-scan results, keyed by host-qualified identity. */ + evidenceByIdentity: ReadonlyMap pendingWorktreeIds: ReadonlySet checkedCount: number totalCount: number } const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { - evidenceByWorktreeId: new Map(), + evidenceByIdentity: new Map(), pendingWorktreeIds: new Set(), checkedCount: 0, totalCount: 0 @@ -61,7 +62,7 @@ export function useWorkspaceCleanupGitEvidence({ } publishDirtyRef.current = false setState({ - evidenceByWorktreeId: new Map(evidenceRef.current), + evidenceByIdentity: new Map(evidenceRef.current), pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]), checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size, totalCount: totalRef.current @@ -95,7 +96,7 @@ export function useWorkspaceCleanupGitEvidence({ if (!activeRequestWorktreeIdsRef.current.has(candidate.worktreeId)) { continue } - evidenceRef.current.set(candidate.worktreeId, candidate) + evidenceRef.current.set(getWorkspaceCleanupCandidateIdentity(candidate), candidate) inFlightRef.current.delete(candidate.worktreeId) publishDirtyRef.current = true } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts index 2379be86965..31373f519a0 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -2,6 +2,11 @@ import { useCallback, useRef, useState } from 'react' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity +} from '../../../../shared/workspace-cleanup-host-identity' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' import { startWorkspaceCleanupBackgroundRemoval, type WorkspaceCleanupRemovalProgress @@ -13,8 +18,10 @@ export type WorkspaceCleanupRemovalController = { confirmCandidates: WorkspaceCleanupCandidate[] removalProgress: WorkspaceCleanupRemovalProgress | null removalInFlight: boolean + deletionPhaseByIdentity: Record /** Synchronous guard; state alone lags a second confirm click. */ removalInFlightRef: { current: boolean } + /** Keyed by host-qualified identity so a failure marks only the confirmed host's row. */ rowFailures: Record resetRowFailures: () => void resetForReopen: () => void @@ -32,7 +39,7 @@ export function useWorkspaceCleanupRemoval({ onDeselect, closeModal }: { - onDeselect: (removedIds: readonly string[]) => void + onDeselect: (removedIdentities: readonly string[]) => void closeModal: () => void }): WorkspaceCleanupRemovalController { const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) @@ -48,6 +55,9 @@ export function useWorkspaceCleanupRemoval({ // Why: `removalProgress` only arrives once the batch reports, so rendering // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. const [removalInFlight, setRemovalInFlight] = useState(false) + const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState< + Record + >({}) const [rowFailures, setRowFailures] = useState>({}) const removalInFlightRef = useRef(false) // Why: the dialog stays mounted across cleanup runs, so late settlements from @@ -112,6 +122,7 @@ export function useWorkspaceCleanupRemoval({ setRemovalInFlight(false) setConfirming(false) setConfirmCandidates([]) + setDeletionPhaseByIdentity({}) }, []) const confirmRemove = useCallback(() => { @@ -134,7 +145,11 @@ export function useWorkspaceCleanupRemoval({ // Why: a hung late settlement retains these callbacks for the renderer's // lifetime; capture only ids so it cannot pin the candidate objects. const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) + const removableIdentities = removableCandidates.map(getWorkspaceCleanupCandidateIdentity) setRowFailures({}) + setDeletionPhaseByIdentity( + Object.fromEntries(removableIdentities.map((identity) => [identity, 'queued' as const])) + ) markWorktreesQueuedForDeletion(removableWorktreeIds) const handleRemovalError = (): void => { for (const worktreeId of removableWorktreeIds) { @@ -169,21 +184,33 @@ export function useWorkspaceCleanupRemoval({ onProgress: (progress) => { if (mountedRef.current) { setRemovalProgress(progress) + setDeletionPhaseByIdentity((current) => + Object.fromEntries(Object.keys(current).map((identity) => [identity, 'deleting'])) + ) } }, onRowFailed: (failure) => { clearQueuedDeleteState(failure.worktreeId) + if (!mountedRef.current) { + return + } + const identity = getWorkspaceCleanupFailureIdentity(failure) + setDeletionPhaseByIdentity((current) => { + const next = { ...current } + delete next[identity] + return next + }) }, onResult: (result) => { const nextFailures: Record = {} for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message + nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message // Why: defensively covers failures that never reached onRowFailed. clearQueuedDeleteState(failure.worktreeId) } if (mountedRef.current) { setRowFailures(nextFailures) - onDeselect(result.removedIds) + onDeselect(result.removedIdentities) settle() } removalInFlightRef.current = false @@ -199,15 +226,15 @@ export function useWorkspaceCleanupRemoval({ } setRowFailures((current) => { const next = { ...current } - for (const id of result.removedIds) { - delete next[id] + for (const identity of result.removedIdentities) { + delete next[identity] } for (const failure of result.failures) { - next[failure.worktreeId] = failure.message + next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message } return next }) - onDeselect(result.removedIds) + onDeselect(result.removedIdentities) }, onError: handleRemovalError }) @@ -230,6 +257,7 @@ export function useWorkspaceCleanupRemoval({ confirmCandidates, removalProgress, removalInFlight, + deletionPhaseByIdentity, removalInFlightRef, rowFailures, resetRowFailures, @@ -240,3 +268,10 @@ export function useWorkspaceCleanupRemoval({ backToList } } + +/** Row key for a failure; an id-only failure lands on the local row, as before. */ +function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string { + return failure.executionHostId + ? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId) + : getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-ancestor-skips.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-ancestor-skips.ts index 9451f410861..aa0ee235282 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-ancestor-skips.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-ancestor-skips.ts @@ -5,6 +5,7 @@ import { } from '../../../../shared/cross-platform-path' import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity' // Why: an ancestor skip is provisional while every blocking descendant is still // removing; it hardens or lifts once the blockers settle authoritatively. @@ -31,7 +32,7 @@ export function isStrictWorkspaceCleanupDescendant( child: WorkspaceCleanupCandidate ): boolean { return ( - parent.connectionId === child.connectionId && + getWorkspaceCleanupCandidateHostId(parent) === getWorkspaceCleanupCandidateHostId(child) && isStrictWorkspaceCleanupDescendantPath(parent.path, child.path) ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts index 923d287bb6a..188e1353562 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' +import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' import { makeCandidate } from './workspace-cleanup-presentation-fixtures' @@ -46,23 +47,27 @@ describe('workspace cleanup late settlement reconciliation', () => { branch: 'other', path: '/repo/other' }) - let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} - let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {} + let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} + let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {} const removeCandidates = vi .fn() .mockImplementationOnce( () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + new Promise((resolve) => { resolveChild = resolve }) ) .mockImplementationOnce( () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + new Promise((resolve) => { resolveUnrelated = resolve }) ) - .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) + .mockResolvedValueOnce({ + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) const onResult = vi.fn() startWorkspaceCleanupBackgroundRemoval({ @@ -76,9 +81,17 @@ describe('workspace cleanup late settlement reconciliation', () => { await vi.advanceTimersByTimeAsync(10) expect(removeCandidates).toHaveBeenCalledTimes(2) - resolveChild({ removedIds: [child.worktreeId], failures: [] }) + resolveChild({ + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) await settleBackgroundRemoval() - resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] }) + resolveUnrelated({ + removedIds: [unrelated.worktreeId], + removedIdentities: [unrelated.worktreeId], + failures: [] + }) await settleBackgroundRemoval() expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], { @@ -86,6 +99,7 @@ describe('workspace cleanup late settlement reconciliation', () => { }) expect(onResult).toHaveBeenCalledWith({ removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], + removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], failures: [] }) expect(toast.error).not.toHaveBeenCalled() @@ -116,24 +130,28 @@ describe('workspace cleanup late settlement reconciliation', () => { branch: 'second-child', path: '/repo/bb/c' }) - let resolveFirstChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} - let resolveSecondChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + let resolveFirstChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} + let resolveSecondChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} const removeCandidates = vi .fn() .mockImplementationOnce( () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + new Promise((resolve) => { resolveFirstChild = resolve }) ) .mockImplementationOnce( () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + new Promise((resolve) => { resolveSecondChild = resolve }) ) .mockImplementationOnce(() => new Promise(() => {})) - .mockResolvedValueOnce({ removedIds: [secondParent.worktreeId], failures: [] }) + .mockResolvedValueOnce({ + removedIds: [secondParent.worktreeId], + removedIdentities: [secondParent.worktreeId], + failures: [] + }) const onLateResult = vi.fn() startWorkspaceCleanupBackgroundRemoval({ @@ -148,7 +166,11 @@ describe('workspace cleanup late settlement reconciliation', () => { await vi.advanceTimersByTimeAsync(20) await settleBackgroundRemoval() - resolveFirstChild({ removedIds: [firstChild.worktreeId], failures: [] }) + resolveFirstChild({ + removedIds: [firstChild.worktreeId], + removedIdentities: [firstChild.worktreeId], + failures: [] + }) await settleBackgroundRemoval() expect(removeCandidates).toHaveBeenNthCalledWith(3, [firstParent.worktreeId], { @@ -156,14 +178,20 @@ describe('workspace cleanup late settlement reconciliation', () => { }) expect(onLateResult).toHaveBeenNthCalledWith(1, { removedIds: [firstChild.worktreeId], + removedIdentities: [firstChild.worktreeId], failures: [] }) - resolveSecondChild({ removedIds: [secondChild.worktreeId], failures: [] }) + resolveSecondChild({ + removedIds: [secondChild.worktreeId], + removedIdentities: [secondChild.worktreeId], + failures: [] + }) await settleBackgroundRemoval() expect(onLateResult).toHaveBeenNthCalledWith(2, { removedIds: [secondChild.worktreeId], + removedIdentities: [secondChild.worktreeId], failures: [] }) expect(removeCandidates).toHaveBeenCalledTimes(3) @@ -176,6 +204,7 @@ describe('workspace cleanup late settlement reconciliation', () => { }) expect(onLateResult).toHaveBeenCalledWith({ removedIds: [secondParent.worktreeId], + removedIdentities: [secondParent.worktreeId], failures: [] }) expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1') @@ -194,16 +223,20 @@ describe('workspace cleanup late settlement reconciliation', () => { branch: 'child', path: '/repo/parent/child' }) - let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} const removeCandidates = vi .fn() .mockImplementationOnce( () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + new Promise((resolve) => { resolveChild = resolve }) ) - .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) + .mockResolvedValueOnce({ + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) vi.mocked(toast.success).mockImplementationOnce(() => { throw new Error('toast renderer failed') }) @@ -220,7 +253,11 @@ describe('workspace cleanup late settlement reconciliation', () => { await vi.advanceTimersByTimeAsync(10) await settleBackgroundRemoval() - resolveChild({ removedIds: [child.worktreeId], failures: [] }) + resolveChild({ + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) await settleBackgroundRemoval() expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts index 04b088e43d9..d33b672ff14 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts @@ -23,7 +23,7 @@ it('holds one snapshot prune batch across the sequential cleanup removals', asyn const events: string[] = [] const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => { events.push(`remove:${worktreeIds[0]}`) - return { removedIds: [...worktreeIds], failures: [] } + return { removedIds: [...worktreeIds], removedIdentities: [...worktreeIds], failures: [] } }) startWorkspaceCleanupBackgroundRemoval({ diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index 573e5633dad..725bf78dbc7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -5,6 +5,7 @@ import { type WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal' import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity' vi.mock('sonner', () => ({ toast: { @@ -50,7 +51,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { expect(removeCandidates).not.toHaveBeenCalled() expect(onProgress).not.toHaveBeenCalled() - expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] }) + expect(onResult).toHaveBeenCalledWith({ removedIds: [], removedIdentities: [], failures: [] }) expect(toast.success).not.toHaveBeenCalled() expect(toast.error).not.toHaveBeenCalled() }) @@ -89,7 +90,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) expect(onResult).not.toHaveBeenCalled() - resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] }) + resolveRemoval!({ + removedIds: [candidate.worktreeId], + removedIdentities: [candidate.worktreeId], + failures: [] + }) await settleBackgroundRemoval() expect(onProgress).toHaveBeenLastCalledWith({ @@ -99,7 +104,11 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { failedCount: 0 }) expect(toast.success).not.toHaveBeenCalled() - expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [candidate.worktreeId], + removedIdentities: [candidate.worktreeId], + failures: [] + }) }) it('removes candidates one at a time for per-row progress', async () => { @@ -112,8 +121,16 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) const removeCandidates = vi .fn() - .mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] }) - .mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] }) + .mockResolvedValueOnce({ + removedIds: [first.worktreeId], + removedIdentities: [first.worktreeId], + failures: [] + }) + .mockResolvedValueOnce({ + removedIds: [second.worktreeId], + removedIdentities: [second.worktreeId], + failures: [] + }) const onProgress = vi.fn() startWorkspaceCleanupBackgroundRemoval({ @@ -163,11 +180,13 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { .fn() .mockResolvedValueOnce({ removedIds: [first.worktreeId], + removedIdentities: [first.worktreeId], failures: [], preservedBranches: [firstBranch] }) .mockResolvedValueOnce({ removedIds: [second.worktreeId], + removedIdentities: [second.worktreeId], failures: [], preservedBranches: [secondBranch] }), @@ -178,6 +197,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { expect(onResult).toHaveBeenCalledWith({ removedIds: [first.worktreeId, second.worktreeId], + removedIdentities: [first.worktreeId, second.worktreeId], failures: [], preservedBranches: [firstBranch, secondBranch] }) @@ -200,6 +220,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({ removedIds: [...worktreeIds], + removedIdentities: [...worktreeIds], failures: [] })) @@ -233,6 +254,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) const removeCandidates = vi.fn().mockResolvedValueOnce({ removedIds: [], + removedIdentities: [], failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] }) const onProgress = vi.fn() @@ -258,10 +280,12 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) expect(onResult).toHaveBeenCalledWith({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }, { worktreeId: parent.worktreeId, + executionHostId: 'local', displayName: parent.displayName, message: 'Skipped because a nested workspace could not be removed.' } @@ -284,6 +308,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) const removeCandidates = vi.fn().mockResolvedValueOnce({ removedIds: [], + removedIdentities: [], failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] }) const onRowFailed = vi.fn() @@ -325,15 +350,21 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { .fn() .mockResolvedValueOnce({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: failedChild.worktreeId, + executionHostId: 'local', displayName: failedChild.displayName, message: 'busy' } ] }) - .mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] }) + .mockResolvedValueOnce({ + removedIds: [unrelatedParent.worktreeId], + removedIdentities: [unrelatedParent.worktreeId], + failures: [] + }) const onResult = vi.fn() startWorkspaceCleanupBackgroundRemoval({ @@ -352,7 +383,69 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) expect(onResult).toHaveBeenCalledWith({ removedIds: [unrelatedParent.worktreeId], - failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }] + removedIdentities: [unrelatedParent.worktreeId], + failures: [ + { + worktreeId: failedChild.worktreeId, + executionHostId: 'local', + displayName: 'child', + message: 'busy' + } + ] + }) + }) + + it('does not skip a paired-runtime ancestor after another runtime child fails', async () => { + const failedChild = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child', + executionHostId: 'runtime:hub-a' + }) + const unrelatedParent = makeCandidate({ + worktreeId: 'repo-2::/repo/parent', + repoId: 'repo-2', + repoName: 'Repo 2', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent', + executionHostId: 'runtime:hub-b' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + removedIdentities: [], + failures: [ + { + worktreeId: failedChild.worktreeId, + executionHostId: failedChild.executionHostId, + displayName: failedChild.displayName, + message: 'busy' + } + ] + }) + .mockResolvedValueOnce({ + removedIds: [unrelatedParent.worktreeId], + removedIdentities: [ + getWorkspaceCleanupHostIdentity('runtime:hub-b', unrelatedParent.worktreeId) + ], + failures: [] + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [unrelatedParent, failedChild], + removeCandidates, + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], { + approvedCandidates: [failedChild] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], { + approvedCandidates: [unrelatedParent] }) }) @@ -363,6 +456,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { candidates: [candidate], removeCandidates: vi.fn().mockResolvedValue({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' } ] @@ -377,441 +471,6 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { ) }) - it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::/repo/parent', - displayName: 'parent', - branch: 'parent', - path: '/repo/parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::/repo/parent/child', - displayName: 'child', - branch: 'child', - path: '/repo/parent/child' - }) - let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} - const removeCandidates = vi - .fn() - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { - resolveChild = resolve - }) - ) - .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) - const onResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child], - removeCandidates, - onProgress: vi.fn(), - onResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(5) - resolveChild({ removedIds: [child.worktreeId], failures: [] }) - await settleBackgroundRemoval() - - expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { - approvedCandidates: [parent] - }) - expect(onResult).toHaveBeenCalledWith({ - removedIds: [child.worktreeId, parent.worktreeId], - failures: [] - }) - expect(toast.error).not.toHaveBeenCalled() - }) - - it('reports a definitive late failure and skips its parent', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::C:\\repo\\parent', - displayName: 'parent', - branch: 'parent', - path: 'C:\\repo\\parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::C:\\repo\\parent\\child', - displayName: 'child', - branch: 'child', - path: 'C:\\repo\\parent\\child' - }) - let rejectChild: (error: Error) => void = () => {} - const removeCandidates = vi.fn( - () => - new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { - rejectChild = reject - }) - ) - const onResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child], - removeCandidates, - onProgress: vi.fn(), - onResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(5) - rejectChild(new Error('remote removal failed')) - await settleBackgroundRemoval() - - expect(removeCandidates).toHaveBeenCalledTimes(1) - expect(onResult).toHaveBeenCalledWith({ - removedIds: [], - failures: [ - { - worktreeId: child.worktreeId, - displayName: child.displayName, - message: 'remote removal failed' - }, - { - worktreeId: parent.worktreeId, - displayName: parent.displayName, - message: 'Skipped because a nested workspace could not be removed.' - } - ] - }) - }) - - it('reports a timeout, skips its parent, then reports the authoritative result', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::/repo/parent', - displayName: 'parent', - branch: 'parent', - path: '/repo/parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::/repo/parent/child', - displayName: 'child', - branch: 'child', - path: '/repo/parent/child' - }) - let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} - const removeCandidates = vi - .fn() - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { - resolveChild = resolve - }) - ) - .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) - const onProgress = vi.fn() - const onResult = vi.fn() - const onLateResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child], - removeCandidates, - onProgress, - onResult, - onLateResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(5) - expect(onResult).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(5) - await settleBackgroundRemoval() - - expect(removeCandidates).toHaveBeenCalledTimes(1) - expect(onProgress).toHaveBeenLastCalledWith({ - totalCount: 2, - processedCount: 2, - removedCount: 0, - failedCount: 2 - }) - expect(onResult).toHaveBeenCalledWith({ - removedIds: [], - failures: [ - { - worktreeId: child.worktreeId, - displayName: child.displayName, - message: - 'Removing child is taking longer than expected. It will keep running in the background.' - }, - { - worktreeId: parent.worktreeId, - displayName: parent.displayName, - message: 'Skipped because a nested workspace has not finished removing.' - } - ] - }) - // The still-pending row is reported as still removing, not as a failure. - expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1') - expect(toast.error).toHaveBeenCalledWith( - 'Workspaces not removed: 1', - expect.objectContaining({ - description: 'Skipped because a nested workspace has not finished removing.' - }) - ) - - resolveChild({ removedIds: [child.worktreeId], failures: [] }) - await settleBackgroundRemoval() - - // Why: post-batch child success must reclassify the provisional parent skip - // and retry the parent so rowFailures do not keep the stale skip message. - expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { - approvedCandidates: [parent] - }) - expect(onResult).toHaveBeenCalledTimes(1) - expect(onLateResult).toHaveBeenNthCalledWith(1, { - removedIds: [child.worktreeId], - failures: [] - }) - expect(onLateResult).toHaveBeenNthCalledWith(2, { - removedIds: [parent.worktreeId], - failures: [] - }) - expect(toast.success).not.toHaveBeenCalled() - }) - - it('hardens a provisional parent skip after the child late-fails post-batch', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::/repo/parent', - displayName: 'parent', - branch: 'parent', - path: '/repo/parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::/repo/parent/child', - displayName: 'child', - branch: 'child', - path: '/repo/parent/child' - }) - let rejectChild: (error: Error) => void = () => {} - const onLateResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child], - removeCandidates: vi.fn( - () => - new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { - rejectChild = reject - }) - ), - onProgress: vi.fn(), - onResult: vi.fn(), - onLateResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(10) - await settleBackgroundRemoval() - rejectChild(new Error('remote removal failed')) - await settleBackgroundRemoval() - - expect(onLateResult).toHaveBeenNthCalledWith(1, { - removedIds: [], - failures: [ - expect.objectContaining({ - worktreeId: child.worktreeId, - message: 'remote removal failed' - }) - ] - }) - expect(onLateResult).toHaveBeenNthCalledWith(2, { - removedIds: [], - failures: [ - expect.objectContaining({ - worktreeId: parent.worktreeId, - message: 'Skipped because a nested workspace could not be removed.' - }) - ] - }) - }) - - it('retries a skipped parent after its blocking child succeeds mid-batch', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::/rp/parent', - displayName: 'parent', - branch: 'parent', - path: '/rp/parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::/rp/parent/c', - displayName: 'child', - branch: 'child', - path: '/rp/parent/c' - }) - const unrelated = makeCandidate({ - worktreeId: 'repo-1::/zz', - displayName: 'other', - branch: 'other', - path: '/zz' - }) - let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} - let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {} - const removeCandidates = vi - .fn() - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { - resolveChild = resolve - }) - ) - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { - resolveUnrelated = resolve - }) - ) - .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) - const onResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child, unrelated], - removeCandidates, - onProgress: vi.fn(), - onResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - // Child times out, the parent is provisionally skipped, and the loop is - // held open by the unrelated row when the child's success reconciles. - await vi.advanceTimersByTimeAsync(10) - expect(removeCandidates).toHaveBeenCalledTimes(2) - resolveChild({ removedIds: [child.worktreeId], failures: [] }) - await settleBackgroundRemoval() - resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] }) - await settleBackgroundRemoval() - - expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], { - approvedCandidates: [parent] - }) - expect(onResult).toHaveBeenCalledWith({ - removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], - failures: [] - }) - expect(toast.error).not.toHaveBeenCalled() - expect(toast.info).not.toHaveBeenCalled() - }) - - it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => { - vi.useFakeTimers() - const parent = makeCandidate({ - worktreeId: 'repo-1::/rp/parent', - displayName: 'parent', - branch: 'parent', - path: '/rp/parent' - }) - const child = makeCandidate({ - worktreeId: 'repo-1::/rp/parent/c', - displayName: 'child', - branch: 'child', - path: '/rp/parent/c' - }) - const unrelated = makeCandidate({ - worktreeId: 'repo-1::/zz', - displayName: 'other', - branch: 'other', - path: '/zz' - }) - let rejectChild: (error: Error) => void = () => {} - let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {} - const removeCandidates = vi - .fn() - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { - rejectChild = reject - }) - ) - .mockImplementationOnce( - () => - new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { - resolveUnrelated = resolve - }) - ) - const onResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [parent, child, unrelated], - removeCandidates, - onProgress: vi.fn(), - onResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(10) - rejectChild(new Error('remote removal failed')) - await settleBackgroundRemoval() - resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] }) - await settleBackgroundRemoval() - - expect(removeCandidates).toHaveBeenCalledTimes(2) - expect(onResult).toHaveBeenCalledWith({ - removedIds: [unrelated.worktreeId], - failures: [ - { - worktreeId: parent.worktreeId, - displayName: parent.displayName, - message: 'Skipped because a nested workspace could not be removed.' - }, - { - worktreeId: child.worktreeId, - displayName: child.displayName, - message: 'remote removal failed' - } - ] - }) - expect(toast.error).toHaveBeenCalledWith( - 'Workspaces not removed: 2', - expect.objectContaining({ - description: - 'Skipped because a nested workspace could not be removed.; remote removal failed' - }) - ) - }) - - it('reports an authoritative rejection after the timeout result', async () => { - vi.useFakeTimers() - const candidate = makeCandidate() - let rejectRemoval: (error: Error) => void = () => {} - const onLateResult = vi.fn() - - startWorkspaceCleanupBackgroundRemoval({ - candidates: [candidate], - removeCandidates: vi.fn( - () => - new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { - rejectRemoval = reject - }) - ), - onProgress: vi.fn(), - onLateResult, - removalTimeoutMs: 5, - removalSettlementGraceMs: 5 - }) - - await vi.advanceTimersByTimeAsync(10) - rejectRemoval(new Error('remote removal failed')) - await settleBackgroundRemoval() - - expect(onLateResult).toHaveBeenCalledWith({ - removedIds: [], - failures: [expect.objectContaining({ message: 'remote removal failed' })] - }) - expect(toast.error).toHaveBeenLastCalledWith( - 'Workspaces not removed: 1', - expect.objectContaining({ description: 'remote removal failed' }) - ) - }) - it('keeps removal outcome toasts when the result callback throws', async () => { const candidate = makeCandidate() @@ -819,6 +478,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { candidates: [candidate], removeCandidates: vi.fn().mockResolvedValue({ removedIds: [candidate.worktreeId], + removedIdentities: [candidate.worktreeId], failures: [] }), onProgress: vi.fn(), @@ -851,12 +511,14 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { .fn() .mockResolvedValueOnce({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' } ] }) .mockResolvedValueOnce({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' } ] diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 9e98435faac..0702e40a10f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -6,6 +6,7 @@ import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity' import { getSkippedAncestorMessage, isStrictWorkspaceCleanupDescendant, @@ -71,7 +72,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { - onResult?.({ removedIds: [], failures: [] }) + onResult?.({ removedIds: [], removedIdentities: [], failures: [] }) } catch (callbackError) { console.error('Workspace cleanup result callback failed', callbackError) } @@ -80,6 +81,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const count = candidates.length const removedIds: string[] = [] + const removedIdentities: string[] = [] const failures: WorkspaceCleanupFailure[] = [] const preservedBranches: NonNullable = [] const failedCandidates: WorkspaceCleanupCandidate[] = [] @@ -141,6 +143,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const provisional = blockers.every((blocker) => provisionallyBlocked.has(blocker)) const failure: WorkspaceCleanupFailure = { worktreeId: candidate.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(candidate), displayName: candidate.displayName, message: getSkippedAncestorMessage(provisional) } @@ -222,6 +225,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ pendingSettlementFailures.delete(timeoutFailure) provisionallyBlocked.delete(candidate) removedIds.push(...lateResult.removedIds) + removedIdentities.push(...(lateResult.removedIdentities ?? [])) preservedBranches.push(...(lateResult.preservedBranches ?? [])) reportFailures(lateResult.failures) if (lateResult.failures.length === 0) { @@ -235,6 +239,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ } const result = outcome.result removedIds.push(...result.removedIds) + removedIdentities.push(...(result.removedIdentities ?? [])) preservedBranches.push(...(result.preservedBranches ?? [])) reportFailures(result.failures) if (result.failures.length > 0) { @@ -245,6 +250,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ reportFailures([ { worktreeId: candidate.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(candidate), displayName: candidate.displayName, message: error instanceof Error ? error.message : String(error) } @@ -279,6 +285,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ ) const result: WorkspaceCleanupRemoveResult = { removedIds, + removedIdentities, failures, ...(preservedBranches.length > 0 ? { preservedBranches } : {}) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx index 2f8ecc9fb07..d9deb16e8e9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -3,7 +3,9 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getWorkspaceCleanupCandidateIdentity } from './workspace-cleanup-host-identity' import { CandidateRow } from './workspace-cleanup-candidate-row' +import { getWorkspaceCleanupCandidateAccessibleName } from './workspace-cleanup-host-label' import { makeCandidate } from './workspace-cleanup-presentation-fixtures' vi.mock('@/components/ui/tooltip', () => ({ @@ -37,6 +39,7 @@ describe('CandidateRow', () => { act(() => { root?.render( { act(() => { root?.render( { act(() => { root?.render( { ) }) - const openButton = container?.querySelector(`[aria-label="Open ${candidate.displayName}"]`) + const openButton = container?.querySelector( + `[aria-label="Open ${getWorkspaceCleanupCandidateAccessibleName(candidate)}"]` + ) expect(openButton?.querySelector('.lucide-external-link')).not.toBeNull() }) @@ -134,6 +141,7 @@ describe('CandidateRow', () => { act(() => { root?.render( { ) }) - expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() - expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() + const accessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate) + expect(container?.querySelector(`[aria-label="Select ${accessibleName}"]`)).toBeNull() + expect(container?.querySelector(`[aria-label="Remove ${accessibleName}"]`)).toBeNull() }) it.each([ @@ -169,6 +178,7 @@ describe('CandidateRow', () => { act(() => { root?.render( { expect(container?.textContent).toContain(label) expect(container?.textContent).not.toContain('Ready') }) + + it('visibly and accessibly distinguishes colliding local and SSH rows', () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ connectionId: 'builder', executionHostId: 'ssh:builder' }) + + act(() => { + root?.render( + <> + {[local, remote].map((candidate) => ( + + ))} + + ) + }) + + const localName = getWorkspaceCleanupCandidateAccessibleName(local) + const remoteName = getWorkspaceCleanupCandidateAccessibleName(remote) + expect(localName).not.toBe(remoteName) + expect(container?.querySelector(`[aria-label="Select ${localName}"]`)).not.toBeNull() + expect(container?.querySelector(`[aria-label="Select ${remoteName}"]`)).not.toBeNull() + expect(container?.querySelector(`[aria-label="Host: builder"]`)).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index f8f68b03a9c..d5a7416914e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -12,6 +12,7 @@ import { GitPullRequest, HardDrive, Loader2, + Monitor, SquareTerminal, Trash2 } from 'lucide-react' @@ -41,11 +42,17 @@ import { } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' +import { + getWorkspaceCleanupCandidateAccessibleName, + getWorkspaceCleanupCandidateHostLabel +} from './workspace-cleanup-host-label' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' type CandidateRowProps = { candidate: WorkspaceCleanupCandidate + /** Host-qualified row key; the same `worktreeId` can appear once per host. */ + identity: string deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean failure?: string @@ -62,8 +69,8 @@ type CandidateRowProps = { selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void - onToggleExpanded: (worktreeId: string) => void - onToggleSelected: (worktreeId: string) => void + onToggleExpanded: (identity: string) => void + onToggleSelected: (identity: string) => void onView: (candidate: WorkspaceCleanupCandidate) => void } @@ -74,6 +81,7 @@ type CandidateRowProps = { // prop identity changes); virtualization, not memo, bounds that cost. export const CandidateRow = React.memo(function CandidateRow({ candidate, + identity, deletionPhase, expanded, failure, @@ -104,6 +112,8 @@ export const CandidateRow = React.memo(function CandidateRow({ const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const contextCount = getContextCount(candidate) + const candidateAccessibleName = getWorkspaceCleanupCandidateAccessibleName(candidate) + const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate) const sizeValue = sizeLabel ?? translate('components.workspace.cleanup.browse.notMeasured', 'Not measured') const hasExpandableDetails = @@ -131,9 +141,9 @@ export const CandidateRow = React.memo(function CandidateRow({ aria-label={translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bbb1ab6a6f', 'Select {{value0}}', - { value0: candidate.displayName } + { value0: candidateAccessibleName } )} - onClick={() => onToggleSelected(candidate.worktreeId)} + onClick={() => onToggleSelected(identity)} className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > {selected ? : null} @@ -149,6 +159,13 @@ export const CandidateRow = React.memo(function CandidateRow({ {candidate.displayName} + {deletionPhase ? ( {deletionPhase === 'queued' @@ -272,7 +289,7 @@ export const CandidateRow = React.memo(function CandidateRow({ ) } aria-expanded={expanded} - onClick={() => onToggleExpanded(candidate.worktreeId)} + onClick={() => onToggleExpanded(identity)} > onView(candidate)} > @@ -320,7 +337,7 @@ export const CandidateRow = React.memo(function CandidateRow({ aria-label={translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a9957007eb', 'Ignore {{value0}}', - { value0: candidate.displayName } + { value0: candidateAccessibleName } )} onClick={() => onIgnore(candidate)} > @@ -344,7 +361,7 @@ export const CandidateRow = React.memo(function CandidateRow({ aria-label={translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.3828408538', 'Remove {{value0}}', - { value0: candidate.displayName } + { value0: candidateAccessibleName } )} className="text-destructive hover:text-destructive" onClick={() => onRemove(candidate)} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index f93672103c8..0a7014cf1b2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -4,6 +4,10 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' +import { + getWorkspaceCleanupCandidateAccessibleName, + getWorkspaceCleanupCandidateHostLabel +} from './workspace-cleanup-host-label' const listProbe = vi.hoisted(() => vi.fn()) @@ -31,6 +35,12 @@ vi.mock('@/components/ui/scroll-area', () => ({ ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
})) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + let container: HTMLDivElement let root: Root @@ -73,4 +83,36 @@ describe('WorkspaceCleanupConfirmRemove', () => { expect(listProbe).toHaveBeenCalledWith(candidates.length) expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5) }) + + it('visibly and accessibly identifies each host for colliding confirmations', () => { + const local = makeFacetCandidate({ executionHostId: 'local' }) + const remote = makeFacetCandidate({ + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + + act(() => { + root.render( + + ) + }) + + expect(getWorkspaceCleanupCandidateAccessibleName(local)).not.toBe( + getWorkspaceCleanupCandidateAccessibleName(remote) + ) + for (const candidate of [local, remote]) { + const name = getWorkspaceCleanupCandidateAccessibleName(candidate) + const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate) + expect(container.querySelector(`[role="group"][aria-label="${name}"]`)).not.toBeNull() + expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull() + } + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index 0d946cc14e0..7c0cb8d9ef1 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react' +import { AlertTriangle, Loader2, Monitor, Trash2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Progress } from '@/components/ui/progress' @@ -21,8 +21,14 @@ import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupCandidateIdentity, getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity' +import { + getWorkspaceCleanupCandidateAccessibleName, + getWorkspaceCleanupCandidateHostLabel +} from './workspace-cleanup-host-label' +import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76 @@ -138,7 +144,7 @@ export function WorkspaceCleanupConfirmRemove({ estimatedRowHeight={CONFIRM_REMOVE_ROW_ESTIMATE_PX} renderRow={(candidate, index) => ( +
{candidate.displayName} + {translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts new file mode 100644 index 00000000000..4b5f373e5f2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { getWorkspaceCleanupDeletionPhaseByIdentity } from './workspace-cleanup-deletion-phases' + +describe('workspace cleanup deletion phases', () => { + it('marks only the qualified row during a same-id cleanup removal', () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + worktreeId: local.worktreeId, + executionHostId: 'ssh:ssh-1', + connectionId: 'ssh-1' + }) + const localIdentity = getWorkspaceCleanupCandidateIdentity(local) + const remoteIdentity = getWorkspaceCleanupCandidateIdentity(remote) + + expect( + getWorkspaceCleanupDeletionPhaseByIdentity( + [local, remote], + { [remoteIdentity]: 'deleting' }, + { [local.worktreeId]: 'deleting' } + ) + ).toEqual({ [remoteIdentity]: 'deleting' }) + expect(localIdentity).not.toBe(remoteIdentity) + }) + + it('applies a non-cleanup deletion to every ambiguous same-id row', () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + worktreeId: local.worktreeId, + executionHostId: 'runtime:env-1' + }) + + expect( + getWorkspaceCleanupDeletionPhaseByIdentity( + [local, remote], + {}, + { [local.worktreeId]: 'queued' } + ) + ).toEqual({ + [getWorkspaceCleanupCandidateIdentity(local)]: 'queued', + [getWorkspaceCleanupCandidateIdentity(remote)]: 'queued' + }) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.ts new file mode 100644 index 00000000000..bdae4154028 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.ts @@ -0,0 +1,27 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupIdentityWorktreeId +} from '../../../../shared/workspace-cleanup-host-identity' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' + +export function getWorkspaceCleanupDeletionPhaseByIdentity( + candidates: readonly WorkspaceCleanupCandidate[], + cleanupPhases: Readonly>, + genericPhasesByWorktreeId: Readonly> +): Record { + const cleanupWorktreeIds = new Set( + Object.keys(cleanupPhases).map(getWorkspaceCleanupIdentityWorktreeId) + ) + const phases = { ...cleanupPhases } + for (const candidate of candidates) { + if (cleanupWorktreeIds.has(candidate.worktreeId)) { + continue + } + const phase = genericPhasesByWorktreeId[candidate.worktreeId] + if (phase) { + phases[getWorkspaceCleanupCandidateIdentity(candidate)] = phase + } + } + return phases +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts index 8a5dd1a0ed9..5131f2f28f9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -500,7 +500,9 @@ describe('query pipeline', () => { ) expect(result.totalCount).toBe(1) expect(result.matchedCount).toBe(1) - expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha']) + expect(result.selectableIdentities).toEqual([ + getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/alpha') + ]) }) it('counts per-facet matches independently of the other groups', () => { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index 7207931377f..4f3b075c913 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -37,6 +37,8 @@ export type WorkspaceCleanupFacetSources = { export type WorkspaceCleanupFacets = { candidate: WorkspaceCleanupCandidate worktreeId: string + /** Host-qualified row key; `worktreeId` alone repeats across hosts (STA-4343). */ + identity: string repoId: string repoName: string displayName: string @@ -107,6 +109,7 @@ export function buildWorkspaceCleanupFacets( const facets: Omit = { candidate, worktreeId: candidate.worktreeId, + identity: hostIdentity, repoId: candidate.repoId, repoName: candidate.repoName, displayName: candidate.displayName, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx index bbbeaac5e73..babc5d287e8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -11,6 +11,7 @@ import { import { CandidateRow } from './workspace-cleanup-candidate-row' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity' import { runWorkspaceCleanupQuery } from './workspace-cleanup-query' import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' @@ -41,7 +42,8 @@ function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { scrollElement={null} renderRow={(row, index) => ( { }) it('reports only the rows the user could actually queue for deletion', () => { - expect(query().selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one') + expect(query().selectableIdentities).not.toContain( + getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one') + ) }) it('shows status and disk-size facts without expanding a row', () => { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts index bfcc1a087a4..2f5456cc275 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts @@ -1,3 +1,4 @@ +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import { describe, expect, it } from 'vitest' import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' @@ -102,11 +103,35 @@ describe('applyWorkspaceCleanupGitEvidence', () => { worktreeId: 'a', git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } }) - const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]])) + const [row] = applyWorkspaceCleanupGitEvidence( + [deferred], + new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]]) + ) expect(row.git.clean).toBe(false) expect(row.git.checkedAt).toBe(5) }) + it("never applies one host's git evidence to another host's same-id row", () => { + // STA-4343: a dirty remote row must not mark the local same-id row dirty + // (or vice versa) — the evidence index is keyed by host, not by id. + const localRow = { ...deferredCandidate('a'), executionHostId: 'local' as const } + const remoteEvidence = { + ...makeFacetCandidate({ + worktreeId: 'a', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 } + }), + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' as const + } + const [row] = applyWorkspaceCleanupGitEvidence( + [localRow], + new Map([[getWorkspaceCleanupCandidateIdentity(remoteEvidence), remoteEvidence]]) + ) + expect(row.git).toEqual(localRow.git) + expect(row.blockers).not.toContain('dirty-files') + }) + it('returns the input untouched when nothing was re-scanned', () => { const candidates = [deferredCandidate('a')] expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates) @@ -125,7 +150,10 @@ describe('applyWorkspaceCleanupGitEvidence', () => { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 } }) - const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]])) + const [row] = applyWorkspaceCleanupGitEvidence( + [current], + new Map([[getWorkspaceCleanupCandidateIdentity(refreshed), refreshed]]) + ) expect(row.displayName).toBe('current name') expect(row.fingerprint).toBe('current-fingerprint') diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts index 24fb5de87ff..303040a2dc9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -6,6 +6,7 @@ import { applyWorkspaceCleanupPolicy, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import type { WorkspaceCleanupFilterState, WorkspaceCleanupSortField, @@ -72,16 +73,20 @@ export function selectWorkspaceCleanupGitEvidenceTargets( return targets } -/** Focused re-scan results win over the deferred broad-scan row. */ +/** + * Focused re-scan results win over the deferred broad-scan row. Keyed by + * host-qualified identity: applying one host's git state to another host's + * same-id row would mislabel a dirty workspace as safe to delete (STA-4343). + */ export function applyWorkspaceCleanupGitEvidence( candidates: readonly WorkspaceCleanupCandidate[], - evidenceByWorktreeId: ReadonlyMap + evidenceByIdentity: ReadonlyMap ): readonly WorkspaceCleanupCandidate[] { - if (evidenceByWorktreeId.size === 0) { + if (evidenceByIdentity.size === 0) { return candidates } return candidates.map((candidate) => { - const evidence = evidenceByWorktreeId.get(candidate.worktreeId) + const evidence = evidenceByIdentity.get(getWorkspaceCleanupCandidateIdentity(candidate)) if ( evidence === undefined || (candidate.git.checkedAt !== null && diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts index 5f01b485678..1bd82522829 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts @@ -1,8 +1,6 @@ import { getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, - toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' @@ -12,6 +10,15 @@ import type { WorkspaceSpaceWorktreeMeasurement } from '../../../../shared/workspace-space-types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupHostIdentity } from '../../../../shared/workspace-cleanup-host-identity' + +export { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + getWorkspaceCleanupIdentityWorktreeId, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' /** Structural subset of `Worktree` kept in sync with the persisted source. */ export type WorkspaceCleanupWorktreeFacts = Pick & @@ -35,21 +42,6 @@ export type WorkspaceCleanupWorktreeFacts = Pick & > > -export function getWorkspaceCleanupCandidateHostId( - candidate: Pick -): ExecutionHostId { - return ( - normalizeExecutionHostId(candidate.executionHostId) ?? - (candidate.connectionId - ? toSshExecutionHostId(candidate.connectionId) - : LOCAL_EXECUTION_HOST_ID) - ) -} - -export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string { - return `${hostId}\0${id}` -} - function countIds( rows: readonly Row[], include: (row: Row) => boolean = () => true diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-label.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-label.ts new file mode 100644 index 00000000000..9c3d4bcb5b9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-label.ts @@ -0,0 +1,22 @@ +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { resolveWorkspaceCleanupRemovalHostId } from './workspace-cleanup-host-identity' + +export function getWorkspaceCleanupCandidateHostLabel( + candidate: WorkspaceCleanupCandidate +): string { + const hostId = resolveWorkspaceCleanupRemovalHostId(candidate) + return hostId + ? getExecutionHostLabel(hostId) + : translate('components.workspace.cleanup.host.unknown', 'Unknown host') +} + +export function getWorkspaceCleanupCandidateAccessibleName( + candidate: WorkspaceCleanupCandidate +): string { + return translate('components.workspace.cleanup.host.candidateName', '{{value0}} on {{value1}}', { + value0: candidate.displayName, + value1: getWorkspaceCleanupCandidateHostLabel(candidate) + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts index 280aabd6790..67626a700b1 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts @@ -17,6 +17,10 @@ import { type WorkspaceCleanupLateSettlementReporter } from './workspace-cleanup-removal-settlement' import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupCandidateIdentity +} from '../../../../shared/workspace-cleanup-host-identity' type WorkspaceCleanupRemoveCandidates = ( worktreeIds: readonly string[], @@ -69,7 +73,8 @@ export function createPostBatchLateSettlementReporter({ provisionallyBlocked.has(candidate) && retainedSkippedAncestors.some( (entry) => - entry.candidate.worktreeId === candidate.worktreeId || + getWorkspaceCleanupCandidateIdentity(entry.candidate) === + getWorkspaceCleanupCandidateIdentity(candidate) || isStrictWorkspaceCleanupDescendant(entry.candidate, candidate) ) ) @@ -103,7 +108,9 @@ export function createPostBatchLateSettlementReporter({ }) reportResult(result) } - const retainedCandidateIds = new Set(retainedCandidates.map((candidate) => candidate.worktreeId)) + const retainedCandidateIdentities = new Set( + retainedCandidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate)) + ) const reportWithoutReconciliation: WorkspaceCleanupLateSettlementReporter = ( _candidate, result @@ -111,7 +118,7 @@ export function createPostBatchLateSettlementReporter({ reportResult(result) } return (candidate) => - retainedCandidateIds.has(candidate.worktreeId) + retainedCandidateIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) ? reportLateSettlement : reportWithoutReconciliation } @@ -125,8 +132,9 @@ async function reconcilePostBatchLateSettlement( result: WorkspaceCleanupRemoveResult pendingSettlementFailures?: ReadonlySet }> { + const settledIdentity = getWorkspaceCleanupCandidateIdentity(settledCandidateIdentity) const settledCandidate = state.failedCandidates.find( - (candidate) => candidate.worktreeId === settledCandidateIdentity.worktreeId + (candidate) => getWorkspaceCleanupCandidateIdentity(candidate) === settledIdentity ) if (settledCandidate) { state.provisionallyBlocked.delete(settledCandidate) @@ -136,6 +144,7 @@ async function reconcilePostBatchLateSettlement( } const removedIds: string[] = [] + const removedIdentities: string[] = [] const lateFailures: WorkspaceCleanupFailure[] = [] const pendingSettlementFailures = new Set() const findBlockingDescendants = ( @@ -163,6 +172,7 @@ async function reconcilePostBatchLateSettlement( const provisional = blockers.every((blocker) => state.provisionallyBlocked.has(blocker)) const failure: WorkspaceCleanupFailure = { worktreeId: ancestor.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(ancestor), displayName: ancestor.displayName, message: getSkippedAncestorMessage(provisional) } @@ -189,6 +199,7 @@ async function reconcilePostBatchLateSettlement( state.failedCandidates.push(ancestor) lateFailures.push({ worktreeId: ancestor.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(ancestor), displayName: ancestor.displayName, message: error instanceof Error ? error.message : String(error) }) @@ -214,9 +225,11 @@ async function reconcilePostBatchLateSettlement( ? outcome.result : { removedIds: [], + removedIdentities: [], failures: [ { worktreeId: ancestor.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(ancestor), displayName: ancestor.displayName, message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) @@ -224,6 +237,7 @@ async function reconcilePostBatchLateSettlement( ] } removedIds.push(...result.removedIds) + removedIdentities.push(...(result.removedIdentities ?? [])) if (result.failures.length > 0) { state.failedCandidates.push(ancestor) lateFailures.push(...result.failures) @@ -232,7 +246,7 @@ async function reconcilePostBatchLateSettlement( releaseSettledPostBatchState(state) return { - result: { removedIds, failures: lateFailures }, + result: { removedIds, removedIdentities, failures: lateFailures }, pendingSettlementFailures: pendingSettlementFailures.size > 0 ? pendingSettlementFailures : undefined } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts index 6a722d15ab0..b3145e3ec9e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts @@ -30,8 +30,8 @@ export type WorkspaceCleanupQuery = { export type WorkspaceCleanupQueryResult = { /** Sorted rows that passed every facet. */ rows: WorkspaceCleanupFacets[] - /** Rows the user could actually queue for deletion, for the select-all affordance. */ - selectableWorktreeIds: string[] + /** Host-qualified keys of rows the user could queue, for the select-all affordance. */ + selectableIdentities: string[] totalCount: number matchedCount: number } @@ -88,7 +88,7 @@ export function runWorkspaceCleanupQuery( const rows = sortWorkspaceCleanupFacets(matched, query.sort) return { rows, - selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId), + selectableIdentities: rows.filter((row) => row.isSelectable).map((row) => row.identity), totalCount: facets.length, matchedCount: rows.length } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts index 8a14e9a17a5..ccb408520ce 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts @@ -20,8 +20,10 @@ describe('workspace cleanup late settlement tracking', () => { reconcileBeforeBatchResult ) + // The compact copy keeps the host facts a late failure needs to name its row. expect(tracker.candidate).toEqual({ worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId, displayName: candidate.displayName }) expect(tracker.candidate).not.toBe(candidate) @@ -32,12 +34,18 @@ describe('workspace cleanup late settlement tracking', () => { expect(reconcileBeforeBatchResult).not.toHaveBeenCalled() expect(reportAfterBatchResult).toHaveBeenCalledWith( - { worktreeId: candidate.worktreeId, displayName: candidate.displayName }, + { + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId, + displayName: candidate.displayName + }, { removedIds: [], + removedIdentities: [], failures: [ { worktreeId: candidate.worktreeId, + executionHostId: 'local', displayName: candidate.displayName, message: 'remote removal failed' } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts index d469d2b7486..676a2990854 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts @@ -4,6 +4,7 @@ import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { getWorkspaceCleanupCandidateHostId } from '../../../../shared/workspace-cleanup-host-identity' export type WorkspaceCleanupRemovalSettlement = | { status: 'fulfilled'; result: WorkspaceCleanupRemoveResult } @@ -15,7 +16,7 @@ export type WorkspaceCleanupRemovalWaitResult = export type WorkspaceCleanupLateSettlementCandidate = Pick< WorkspaceCleanupCandidate, - 'worktreeId' | 'displayName' + 'worktreeId' | 'displayName' | 'connectionId' | 'executionHostId' > export type WorkspaceCleanupLateSettlementReporter = ( @@ -51,6 +52,7 @@ export function getWorkspaceCleanupTimeoutFailure( ): WorkspaceCleanupFailure { return { worktreeId: candidate.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(candidate), displayName: candidate.displayName, message: translate( 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', @@ -67,6 +69,8 @@ export function trackWorkspaceCleanupLateSettlement( ): WorkspaceCleanupLateSettlementTracker { const candidateIdentity: WorkspaceCleanupLateSettlementCandidate = { worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId, + ...(candidate.executionHostId ? { executionHostId: candidate.executionHostId } : {}), displayName: candidate.displayName } const state: { @@ -127,9 +131,11 @@ function toWorkspaceCleanupRemoveResult( } return { removedIds: [], + removedIdentities: [], failures: [ { worktreeId: candidate.worktreeId, + executionHostId: getWorkspaceCleanupCandidateHostId(candidate), displayName: candidate.displayName, message: settlement.error instanceof Error ? settlement.error.message : String(settlement.error) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts new file mode 100644 index 00000000000..fac27b2cd2e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts @@ -0,0 +1,518 @@ +/** + * Timeout-boundary behavior of the cleanup removal batch: renderer IPC cannot be + * cancelled, so a row past its deadline stays provisional and its authoritative + * settlement — success or failure — must still reconcile the batch's row state, + * its ancestor skips, and its toasts. Split from the batch-behavior suite. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn() + } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('workspace cleanup removal timeout recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveChild = resolve + }) + ) + .mockResolvedValueOnce({ + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + resolveChild({ + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [child.worktreeId, parent.worktreeId], + removedIdentities: [child.worktreeId, parent.worktreeId], + failures: [] + }) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports a definitive late failure and skips its parent', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + let rejectChild: (error: Error) => void = () => {} + const removeCandidates = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectChild = reject + }) + ) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + rejectChild(new Error('remote removal failed')) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + removedIdentities: [], + failures: [ + { + worktreeId: child.worktreeId, + executionHostId: 'local', + displayName: child.displayName, + message: 'remote removal failed' + }, + { + worktreeId: parent.worktreeId, + executionHostId: 'local', + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('reports a timeout, skips its parent, then reports the authoritative result', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveChild = resolve + }) + ) + .mockResolvedValueOnce({ + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) + const onProgress = vi.fn() + const onResult = vi.fn() + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult, + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + expect(onResult).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 0, + failedCount: 2 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + removedIdentities: [], + failures: [ + { + worktreeId: child.worktreeId, + executionHostId: 'local', + displayName: child.displayName, + message: + 'Removing child is taking longer than expected. It will keep running in the background.' + }, + { + worktreeId: parent.worktreeId, + executionHostId: 'local', + displayName: parent.displayName, + message: 'Skipped because a nested workspace has not finished removing.' + } + ] + }) + // The still-pending row is reported as still removing, not as a failure. + expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1') + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ + description: 'Skipped because a nested workspace has not finished removing.' + }) + ) + + resolveChild({ + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) + await settleBackgroundRemoval() + + // Why: post-batch child success must reclassify the provisional parent skip + // and retry the parent so rowFailures do not keep the stale skip message. + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + expect(onResult).toHaveBeenCalledTimes(1) + expect(onLateResult).toHaveBeenNthCalledWith(1, { + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) + expect(onLateResult).toHaveBeenNthCalledWith(2, { + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) + expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1') + }) + + it('hardens a provisional parent skip after the child late-fails post-batch', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let rejectChild: (error: Error) => void = () => {} + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectChild = reject + }) + ), + onProgress: vi.fn(), + onResult: vi.fn(), + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + await settleBackgroundRemoval() + rejectChild(new Error('remote removal failed')) + await settleBackgroundRemoval() + + expect(onLateResult).toHaveBeenNthCalledWith(1, { + removedIds: [], + removedIdentities: [], + failures: [ + expect.objectContaining({ + worktreeId: child.worktreeId, + message: 'remote removal failed' + }) + ] + }) + expect(onLateResult).toHaveBeenNthCalledWith(2, { + removedIds: [], + removedIdentities: [], + failures: [ + expect.objectContaining({ + worktreeId: parent.worktreeId, + message: 'Skipped because a nested workspace could not be removed.' + }) + ] + }) + }) + + it('retries a skipped parent after its blocking child succeeds mid-batch', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/rp/parent', + displayName: 'parent', + branch: 'parent', + path: '/rp/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/rp/parent/c', + displayName: 'child', + branch: 'child', + path: '/rp/parent/c' + }) + const unrelated = makeCandidate({ + worktreeId: 'repo-1::/zz', + displayName: 'other', + branch: 'other', + path: '/zz' + }) + let resolveChild: (result: WorkspaceCleanupRemoveResult) => void = () => {} + let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveChild = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUnrelated = resolve + }) + ) + .mockResolvedValueOnce({ + removedIds: [parent.worktreeId], + removedIdentities: [parent.worktreeId], + failures: [] + }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child, unrelated], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + // Child times out, the parent is provisionally skipped, and the loop is + // held open by the unrelated row when the child's success reconciles. + await vi.advanceTimersByTimeAsync(10) + expect(removeCandidates).toHaveBeenCalledTimes(2) + resolveChild({ + removedIds: [child.worktreeId], + removedIdentities: [child.worktreeId], + failures: [] + }) + await settleBackgroundRemoval() + resolveUnrelated({ + removedIds: [unrelated.worktreeId], + removedIdentities: [unrelated.worktreeId], + failures: [] + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], { + approvedCandidates: [parent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], + removedIdentities: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], + failures: [] + }) + expect(toast.error).not.toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + }) + + it('hardens a provisional skip into a definitive one after the child late-fails mid-batch', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/rp/parent', + displayName: 'parent', + branch: 'parent', + path: '/rp/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/rp/parent/c', + displayName: 'child', + branch: 'child', + path: '/rp/parent/c' + }) + const unrelated = makeCandidate({ + worktreeId: 'repo-1::/zz', + displayName: 'other', + branch: 'other', + path: '/zz' + }) + let rejectChild: (error: Error) => void = () => {} + let resolveUnrelated: (result: WorkspaceCleanupRemoveResult) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectChild = reject + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUnrelated = resolve + }) + ) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child, unrelated], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + rejectChild(new Error('remote removal failed')) + await settleBackgroundRemoval() + resolveUnrelated({ + removedIds: [unrelated.worktreeId], + removedIdentities: [unrelated.worktreeId], + failures: [] + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(2) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [unrelated.worktreeId], + removedIdentities: [unrelated.worktreeId], + failures: [ + { + worktreeId: parent.worktreeId, + executionHostId: 'local', + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + }, + { + worktreeId: child.worktreeId, + executionHostId: 'local', + displayName: child.displayName, + message: 'remote removal failed' + } + ] + }) + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 2', + expect.objectContaining({ + description: + 'Skipped because a nested workspace could not be removed.; remote removal failed' + }) + ) + }) + + it('reports an authoritative rejection after the timeout result', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + let rejectRemoval: (error: Error) => void = () => {} + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectRemoval = reject + }) + ), + onProgress: vi.fn(), + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + rejectRemoval(new Error('remote removal failed')) + await settleBackgroundRemoval() + + expect(onLateResult).toHaveBeenCalledWith({ + removedIds: [], + removedIdentities: [], + failures: [expect.objectContaining({ message: 'remote removal failed' })] + }) + expect(toast.error).toHaveBeenLastCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ description: 'remote removal failed' }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx index e706c60b486..019260dd384 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -16,17 +16,18 @@ export type WorkspaceCleanupRowListState = { scannedCount: number hasScanned: boolean loading: boolean - deletionPhaseByWorktreeId: Record - deletingWorktreeIds: ReadonlySet + deletionPhaseByIdentity: Record + deletingIdentities: ReadonlySet + /** Host-qualified identities (see WorkspaceCleanupFacets.identity). */ expandedRowIds: ReadonlySet selectedIds: ReadonlySet gitPendingWorktreeIds: ReadonlySet + /** Keyed by host-qualified identity so a failure marks only its own host's row. */ rowFailures: Record - removalInFlight: boolean scrollElement: HTMLDivElement | null onClearFilters: () => void - onToggleExpanded: (worktreeId: string) => void - onToggleSelected: (worktreeId: string) => void + onToggleExpanded: (identity: string) => void + onToggleSelected: (identity: string) => void onView: (candidate: WorkspaceCleanupCandidate) => void onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void @@ -67,24 +68,24 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re scrollElement={props.scrollElement} renderRow={(row, index) => ( 1 && index === rows.length - 1} - expanded={props.expandedRowIds.has(row.worktreeId)} + expanded={props.expandedRowIds.has(row.identity)} lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt, props.now)} sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)} workspaceStatusLabel={row.workspaceStatusLabel} gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)} - deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]} + deletionPhase={props.deletionPhaseByIdentity[row.identity]} // Why: a background rescan must not lock rows; only an actual // removal batch or this row's own deletion disables it. - removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)} + removing={props.deletingIdentities.has(row.identity)} selected={ - props.selectedIds.has(row.worktreeId) && - !props.deletingWorktreeIds.has(row.worktreeId) + props.selectedIds.has(row.identity) && !props.deletingIdentities.has(row.identity) } - failure={props.rowFailures[row.worktreeId]} + failure={props.rowFailures[row.identity]} onToggleExpanded={props.onToggleExpanded} onToggleSelected={props.onToggleSelected} onView={props.onView} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx new file mode 100644 index 00000000000..eb69b692334 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import type { GitStatusResult } from '../../../../shared/git-status-types' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' +import type { + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' + +const { + listRepoWorktreesMock, + getStatusMock, + gitExecFileAsyncMock, + getLocalProjectWorktreeGitOptionsMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + getStatusMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../../../../main/repo-worktrees', () => ({ + listRepoWorktrees: listRepoWorktreesMock, + createFolderWorktree: vi.fn() +})) +vi.mock('../../../../main/git/status', () => ({ getStatus: getStatusMock })) +vi.mock('../../../../main/git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../../../../main/project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) +vi.mock('../../../../main/providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })) + +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/store-test-helpers' +import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from '@/store/slices/worktrees' +import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal' + +const SSH_CONNECTION_ID = 'host-b' +const HOST_A_ID = 'local' +const HOST_B_ID = `ssh:${SSH_CONNECTION_ID}` as const +const REPO_ID = 'colliding-repo' +const DAY_MS = 24 * 60 * 60 * 1000 +const SCANNER_MODULE_PATH = ['../../../../main/ipc/', 'workspace-cleanup-scan'].join('') +const initialState = useAppStore.getInitialState() +const temporaryRoots: string[] = [] + +type ScanWorkspaceCleanup = ( + store: unknown, + args?: WorkspaceCleanupScanArgs +) => Promise + +function makeRepo(overrides: Partial): Repo { + return { + id: REPO_ID, + path: '/repo', + displayName: 'Colliding repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +function toHostRelativePath(worktreeId: string): string { + const workspacePath = worktreeId.slice(worktreeId.indexOf('::') + 2) + return workspacePath + .replace(/^[a-zA-Z]:/, '') + .split(/[/\\]+/) + .filter(Boolean) + .join(path.sep) +} + +function createMarker(root: string, worktreeId: string, name: string): string { + const worktreePath = path.join(root, toHostRelativePath(worktreeId)) + fs.mkdirSync(worktreePath, { recursive: true }) + const markerPath = path.join(worktreePath, name) + fs.writeFileSync(markerPath, name) + return markerPath +} + +describe('workspace cleanup scanned host confirmation removal', () => { + beforeEach(() => { + resetAuthoritativelyRemovedWorktreeMemoryForTests() + getStatusMock.mockReset().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + Reflect.deleteProperty(window, 'api') + vi.clearAllMocks() + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it('deletes only host B after its real scanned candidate crosses the confirmation path', async () => { + const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-scan-')) + const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-a-')) + const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-b-')) + temporaryRoots.push(scanRoot, hostARoot, hostBRoot) + const sharedPath = path.join(scanRoot, 'shared-workspace') + const gitPath = path.join(sharedPath, '.git') + fs.mkdirSync(gitPath, { recursive: true }) + const oldTime = new Date(Date.now() - 40 * DAY_MS) + fs.utimesSync(sharedPath, oldTime, oldTime) + fs.utimesSync(gitPath, oldTime, oldTime) + const worktreeId = `${REPO_ID}::${sharedPath}` + const hostAMarker = createMarker(hostARoot, worktreeId, 'HOST_A_MARKER') + const hostBMarker = createMarker(hostBRoot, worktreeId, 'HOST_B_MARKER') + const localRepo = makeRepo({ executionHostId: HOST_A_ID }) + const sshRepo = makeRepo({ + path: '/remote/repo', + connectionId: SSH_CONNECTION_ID, + executionHostId: HOST_B_ID + }) + const gitWorktree: GitWorktreeInfo = { + path: sharedPath, + head: 'abc123', + branch: 'refs/heads/shared-workspace', + isBare: false, + isMainWorktree: false + } + const sshProvider = { + listWorktrees: vi.fn().mockResolvedValue([gitWorktree]), + getStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult), + exec: vi.fn().mockResolvedValue({ stdout: '0\n', stderr: '' }) + } + listRepoWorktreesMock.mockResolvedValue([gitWorktree]) + getSshGitProviderMock.mockReturnValue(sshProvider) + const scanStore = { + getRepos: () => [localRepo, sshRepo], + getWorktreeMeta: () => undefined, + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }) + } + const { scanWorkspaceCleanup } = await vi.importActual<{ + scanWorkspaceCleanup: ScanWorkspaceCleanup + }>(SCANNER_MODULE_PATH) + const scan = vi.fn((args) => scanWorkspaceCleanup(scanStore, args)) + const routedHostIds: string[] = [] + const remove = vi.fn(async (args: { worktreeId: string; hostId?: string }) => { + routedHostIds.push(args.hostId ?? '') + const root = + args.hostId === HOST_A_ID ? hostARoot : args.hostId === HOST_B_ID ? hostBRoot : null + if (root) { + fs.rmSync(path.join(root, toHostRelativePath(args.worktreeId)), { + recursive: true, + force: true + }) + } + return { ok: true as const } + }) + Object.assign(window, { + api: { + worktrees: { remove, forgetLocal: vi.fn() }, + hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null }) }, + workspaceCleanup: { + scan, + cancelScan: vi.fn().mockResolvedValue(undefined), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn() }, + ephemeralVm: { listRuntimes: vi.fn().mockResolvedValue([]), cleanup: vi.fn() } + } + }) + useAppStore.setState({ + repos: [localRepo, sshRepo], + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_ID, + worktreesByRepo: { + [REPO_ID]: [ + makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_A_ID }), + makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_B_ID }) + ] + } + }) + + expect(fs.existsSync(hostAMarker)).toBe(true) + expect(fs.existsSync(hostBMarker)).toBe(true) + const scanned = await useAppStore.getState().scanWorkspaceCleanup() + const hostBCandidate = scanned.candidates.find( + (candidate) => candidate.executionHostId === HOST_B_ID + ) + expect(hostBCandidate).toMatchObject({ worktreeId, executionHostId: HOST_B_ID }) + expect( + scanned.candidates.filter((candidate) => candidate.worktreeId === worktreeId) + ).toHaveLength(2) + const onDeselect = vi.fn() + const removal = renderHook(() => + useWorkspaceCleanupRemoval({ onDeselect, closeModal: vi.fn() }) + ) + + act(() => removal.result.current.openConfirmRemove([hostBCandidate!])) + expect(removal.result.current.confirmCandidates[0]).toBe(hostBCandidate) + act(() => removal.result.current.confirmRemove()) + await waitFor(() => expect(removal.result.current.removalInFlight).toBe(false)) + + expect(fs.existsSync(hostAMarker), 'host A marker must survive host B confirmation').toBe(true) + expect(fs.existsSync(hostBMarker), 'host B marker must be deleted').toBe(false) + expect(routedHostIds).toEqual([HOST_B_ID]) + expect(remove).toHaveBeenCalledTimes(1) + expect(scan).toHaveBeenCalledTimes(2) + expect(scan.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ worktreeIds: [worktreeId], refreshActivity: true }) + ) + expect(onDeselect).toHaveBeenCalledWith([getWorkspaceCleanupCandidateIdentity(hostBCandidate!)]) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts index b32e789a5b7..d9dc94bb8df 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-selection-model.ts @@ -1,5 +1,6 @@ import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' export function formatVanishedSelectionNotice(count: number): string { return count === 1 @@ -14,16 +15,19 @@ export function formatVanishedSelectionNotice(count: number): string { ) } -export function getDefaultSelectedWorkspaceCleanupIds( +/** Host-qualified identities, so a default selection cannot span two hosts' rows. */ +export function getDefaultSelectedWorkspaceCleanupIdentities( candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet = new Set() + deletingIdentities: ReadonlySet = new Set() ): Set { return new Set( candidates .filter( - (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + (candidate) => + candidate.selectedByDefault && + !deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) ) - .map((candidate) => candidate.worktreeId) + .map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate)) ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bacd04d4ba1..b85d3fabdb1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16123,6 +16123,11 @@ "minutesAgo": "{{value0}}m ago", "hoursAgo": "{{value0}}h ago", "daysAgo": "{{value0}}d ago" + }, + "host": { + "label": "Host: {{value0}}", + "unknown": "Unknown host", + "candidateName": "{{value0}} on {{value1}}" } } }, diff --git a/src/renderer/src/lib/worktree-operation-catalog-route.ts b/src/renderer/src/lib/worktree-operation-catalog-route.ts new file mode 100644 index 00000000000..fceba278e9d --- /dev/null +++ b/src/renderer/src/lib/worktree-operation-catalog-route.ts @@ -0,0 +1,115 @@ +/** + * Owner-catalog half of worktree operation routing: resolve a route from the + * worktree/detected-worktree rows and their repos, with any disagreement + * between owners reported as `ambiguous` rather than silently picked. + */ +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' +import { + findIndexedDetectedWorktrees, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' +// Type-only, so routing's shared vocabulary stays in one place without a runtime cycle. +import type { + WorktreeOperationRoute, + WorktreeOperationRouteResolution, + WorktreeOperationRouteState +} from './worktree-operation-route' + +const repoOperationRouteIndexCache = new WeakMap< + NonNullable, + ReadonlyMap +>() + +export function resolveExplicitWorktreeOperationRouteResult( + state: WorktreeOperationRouteState, + worktreeId: string +): WorktreeOperationRouteResolution { + const exactRoutes = new Map() + const exactRepoIds = new Set() + const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) + if (indexedWorktree.kind === 'ambiguous') { + return { kind: 'ambiguous' } + } + if (indexedWorktree.kind === 'resolved') { + exactRepoIds.add(indexedWorktree.owner.repoId) + const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved') { + addRoute(exactRoutes, resolution.route) + } + } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + exactRepoIds.add(worktree.repoId) + const resolution = resolveExactWorktreeRoute(state, worktree) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved') { + addRoute(exactRoutes, resolution.route) + } + } + if (exactRoutes.size > 0) { + const route = exactRoutes.values().next().value + return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' } + } + if (exactRepoIds.size === 0) { + exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId)) + } + const repoRoutes = new Map() + for (const repoId of exactRepoIds) { + const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved') { + addRoute(repoRoutes, resolution.route) + } + } + const route = repoRoutes.values().next().value + if (repoRoutes.size === 1 && route) { + return { kind: 'resolved', route } + } + if (repoRoutes.size > 1) { + return { kind: 'ambiguous' } + } + return { kind: 'missing' } +} + +function resolveIndexedRepoOperationRoute( + repos: WorktreeOperationRouteState['repos'], + repoId: string +): WorktreeOperationRouteResolution { + if (!repos) { + return { kind: 'missing' } + } + let index = repoOperationRouteIndexCache.get(repos) + if (!index) { + const next = new Map() + for (const repo of repos) { + const repoId = repo.id + if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) { + continue + } + const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) }) + if (!route) { + continue + } + const current = next.get(repoId) + if (!current) { + next.set(repoId, { kind: 'resolved', route }) + } else if ( + current.kind === 'resolved' && + JSON.stringify(current.route) !== JSON.stringify(route) + ) { + next.set(repoId, { kind: 'ambiguous' }) + } + } + index = next + repoOperationRouteIndexCache.set(repos, index) + } + return index.get(repoId) ?? { kind: 'missing' } +} diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 75608bc0c5a..d0660426175 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -1,23 +1,22 @@ import type { AppState } from '@/store/types' -import { - getRepoExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' -import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' +import { resolveExactWorktreeRoute } from './worktree-owner-route' import { findIndexedDetectedWorktrees, hasIndexedDetectedWorktree, resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' +import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, type FolderWorkspaceRuntimeOwnerState } from './folder-workspace-runtime-owner' +export { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' + export type WorktreeOperationRoute = { executionHostId: ExecutionHostId | null runtimeEnvironmentId: string | null @@ -44,11 +43,6 @@ export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { removedRuntimeEnvironmentIds?: ReadonlySet } -const repoOperationRouteIndexCache = new WeakMap< - NonNullable, - ReadonlyMap ->() - function ownerRecordsOnHost( state: WorktreeOperationRouteState, worktreeId: string, @@ -86,18 +80,50 @@ export function resolveActiveWorkspaceRoute( state.activeWorktreeId === worktreeId ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) : null - if (!activeHost) { - return null - } - if (activeHost.kind === 'runtime') { - return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId } + return activeHost ? resolveSelectedHostRoute(state, worktreeId, activeHost) : null +} + +/** + * Route an operation at the host the CALLER named rather than whichever host + * the active workspace happens to select. `repoId::path` ids repeat across + * hosts, so a destructive path that resolves host-blind can delete the same-id + * workspace on the wrong machine (STA-4343); qualified callers resolve here. + */ +export function resolveWorktreeOperationRouteResultForHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationRouteResolution { + const host = parseExecutionHostId(executionHostId) + // Why: an unparseable qualifier is not evidence of an owner — fail closed. + return host + ? { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) } + : { kind: 'missing' } +} + +export function resolveWorktreeOperationRouteForHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationRoute | null { + const resolution = resolveWorktreeOperationRouteResultForHost(state, worktreeId, executionHostId) + return resolution.kind === 'resolved' ? resolution.route : null +} + +function resolveSelectedHostRoute( + state: WorktreeOperationRouteState, + worktreeId: string, + selectedHost: NonNullable> +): WorktreeOperationRoute { + if (selectedHost.kind === 'runtime') { + return { executionHostId: selectedHost.id, runtimeEnvironmentId: selectedHost.environmentId } } // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. - if (activeHost.kind !== 'ssh') { - return { executionHostId: activeHost.id, runtimeEnvironmentId: null } + if (selectedHost.kind !== 'ssh') { + return { executionHostId: selectedHost.id, runtimeEnvironmentId: null } } const environmentIds = new Set() - for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) { + for (const owner of ownerRecordsOnHost(state, worktreeId, selectedHost.id)) { const resolution = resolveExactWorktreeRoute(state, owner) if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { environmentIds.add(resolution.route.runtimeEnvironmentId) @@ -105,12 +131,39 @@ export function resolveActiveWorkspaceRoute( } const environmentId = environmentIds.values().next().value return { - executionHostId: activeHost.id, + executionHostId: selectedHost.id, // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null } } +/** + * Distinct execution hosts the store knows as owners of this id. More than one + * means an unqualified destructive call cannot pick a target without guessing; + * empty means no owner row carries host provenance (legacy hydration). + */ +export function getWorktreeOperationOwnerHostIds( + state: WorktreeOperationRouteState, + worktreeId: string +): ExecutionHostId[] { + const hostIds = new Set() + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + const hostId = worktree.id === worktreeId ? parseExecutionHostId(worktree.hostId)?.id : null + if (hostId) { + hostIds.add(hostId) + } + } + } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + const hostId = parseExecutionHostId(worktree.hostId)?.id + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] +} + export function resolveWorktreeOperationRoute( state: WorktreeOperationRouteState, worktreeId: string @@ -197,98 +250,6 @@ function resolveFolderWorkspaceOperationRoute( } } -export function resolveExplicitWorktreeOperationRouteResult( - state: WorktreeOperationRouteState, - worktreeId: string -): WorktreeOperationRouteResolution { - const exactRoutes = new Map() - const exactRepoIds = new Set() - const indexedWorktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) - if (indexedWorktree.kind === 'ambiguous') { - return { kind: 'ambiguous' } - } - if (indexedWorktree.kind === 'resolved') { - exactRepoIds.add(indexedWorktree.owner.repoId) - const resolution = resolveExactWorktreeRoute(state, indexedWorktree.owner) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved') { - addRoute(exactRoutes, resolution.route) - } - } - for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { - exactRepoIds.add(worktree.repoId) - const resolution = resolveExactWorktreeRoute(state, worktree) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved') { - addRoute(exactRoutes, resolution.route) - } - } - if (exactRoutes.size > 0) { - const route = exactRoutes.values().next().value - return exactRoutes.size === 1 && route ? { kind: 'resolved', route } : { kind: 'ambiguous' } - } - if (exactRepoIds.size === 0) { - exactRepoIds.add(getRepoIdFromWorktreeId(worktreeId)) - } - const repoRoutes = new Map() - for (const repoId of exactRepoIds) { - const resolution = resolveIndexedRepoOperationRoute(state.repos, repoId) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved') { - addRoute(repoRoutes, resolution.route) - } - } - const route = repoRoutes.values().next().value - if (repoRoutes.size === 1 && route) { - return { kind: 'resolved', route } - } - if (repoRoutes.size > 1) { - return { kind: 'ambiguous' } - } - return { kind: 'missing' } -} - -function resolveIndexedRepoOperationRoute( - repos: WorktreeOperationRouteState['repos'], - repoId: string -): WorktreeOperationRouteResolution { - if (!repos) { - return { kind: 'missing' } - } - let index = repoOperationRouteIndexCache.get(repos) - if (!index) { - const next = new Map() - for (const repo of repos) { - const repoId = repo.id - if (!repo.executionHostId?.trim() && !repo.connectionId?.trim()) { - continue - } - const route = routeForOwner({ hostId: getRepoExecutionHostId(repo) }) - if (!route) { - continue - } - const current = next.get(repoId) - if (!current) { - next.set(repoId, { kind: 'resolved', route }) - } else if ( - current.kind === 'resolved' && - JSON.stringify(current.route) !== JSON.stringify(route) - ) { - next.set(repoId, { kind: 'ambiguous' }) - } - } - index = next - repoOperationRouteIndexCache.set(repos, index) - } - return index.get(repoId) ?? { kind: 'missing' } -} - export function settingsForWorktreeOperationRoute( settings: AppState['settings'], route: WorktreeOperationRoute diff --git a/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts b/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts new file mode 100644 index 00000000000..1a1e0e2c426 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts @@ -0,0 +1,173 @@ +/** + * STA-4343 (list state): two hosts publishing the same `repoId::path` row must + * stay two rows. Before the fix the streamed-progress merge, the dismissal + * match, and the post-removal prune all keyed on `worktreeId` alone, so one + * host's row silently replaced — or silently hid, or silently dropped — the + * other's, which is what let a user confirm a row that was not the one shown. + */ +import { describe, expect, it, vi } from 'vitest' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' +import { + shouldHideWorkspaceCleanupCandidate, + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupDismissal +} from '../../../../shared/workspace-cleanup' +import { resolveWorkspaceCleanupRemovalTargets } from './workspace-cleanup-removal-targets' +import type { AppState } from '../types' +import { createTestStore, seedStore } from './store-test-helpers' + +const dismissMock = vi.fn().mockResolvedValue(undefined) + +// @ts-expect-error -- minimal window.api stub for dismissal persistence +globalThis.window = { api: { workspaceCleanup: { dismiss: dismissMock } } } + +const WORKTREE_ID = 'repo1::/shared/workspace' + +function makeCandidate( + executionHostId: WorkspaceCleanupCandidate['executionHostId'], + connectionId: string | null = null +): WorkspaceCleanupCandidate { + return { + worktreeId: WORKTREE_ID, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId, + ...(executionHostId ? { executionHostId } : {}), + displayName: 'shared-workspace', + branch: 'old', + path: '/shared/workspace', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: 0, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 }, + fingerprint: 'fp-1' + } +} + +function makeDismissal( + executionHostId?: WorkspaceCleanupDismissal['executionHostId'] +): WorkspaceCleanupDismissal { + return { + worktreeId: WORKTREE_ID, + dismissedAt: 0, + fingerprint: 'fp-1', + classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + ...(executionHostId ? { executionHostId } : {}) + } +} + +describe('workspace cleanup host-qualified identity', () => { + it('separates two hosts publishing the same worktree id', () => { + const local = makeCandidate('local') + const remote = makeCandidate('ssh:ssh-1', 'ssh-1') + expect(getWorkspaceCleanupCandidateIdentity(local)).not.toBe( + getWorkspaceCleanupCandidateIdentity(remote) + ) + expect(getWorkspaceCleanupCandidateIdentity(remote)).toBe( + getWorkspaceCleanupHostIdentity('ssh:ssh-1', WORKTREE_ID) + ) + }) + + it('reports no removal host for a row with no host evidence', () => { + expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined))).toBeNull() + // A pre-executionHostId remote row still names its connection. + expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined, 'ssh-1'))).toBe( + 'ssh:ssh-1' + ) + }) +}) + +describe('workspace cleanup dismissals', () => { + it('hides only the dismissed host row', () => { + const dismissal = makeDismissal('ssh:ssh-1') + expect( + shouldHideWorkspaceCleanupCandidate(makeCandidate('ssh:ssh-1', 'ssh-1'), dismissal) + ).toBe(true) + expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), dismissal)).toBe(false) + }) + + it('keeps a legacy id-only dismissal hiding its row', () => { + expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), makeDismissal())).toBe(true) + }) + + it('stores same-id dismissals independently and still reads a legacy id-only key', async () => { + const local = makeCandidate('local') + const remote = makeCandidate('ssh:ssh-1', 'ssh-1') + const store = createTestStore() + seedStore(store, { + workspaceCleanupScan: { scannedAt: 1, candidates: [local, remote], errors: [] } + } as Partial) + + await store.getState().dismissWorkspaceCleanupCandidates([local, remote]) + + expect(Object.keys(store.getState().workspaceCleanupDismissals).sort()).toEqual( + [ + getWorkspaceCleanupCandidateIdentity(local), + getWorkspaceCleanupCandidateIdentity(remote) + ].sort() + ) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([ + expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }), + expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }) + ]) + + seedStore(store, { + workspaceCleanupDismissals: { [WORKTREE_ID]: makeDismissal() }, + workspaceCleanupScan: { scannedAt: 1, candidates: [local], errors: [] } + } as Partial) + await store.getState().dismissWorkspaceCleanupCandidates([]) + + expect(store.getState().workspaceCleanupScan?.candidates[0]?.blockers).toContain('dismissed') + }) +}) + +describe('workspace cleanup removal targets', () => { + const emptyState = { worktreesByRepo: {}, detectedWorktreesByRepo: {} } as unknown as AppState + + it('carries the confirmed host through as the removal owner', () => { + const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, { + approvedCandidates: [makeCandidate('ssh:ssh-1', 'ssh-1')] + }) + expect(target).toMatchObject({ kind: 'target', executionHostId: 'ssh:ssh-1' }) + }) + + it('fails closed when one id is confirmed for two hosts at once', () => { + const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, { + approvedCandidates: [makeCandidate('local'), makeCandidate('ssh:ssh-1', 'ssh-1')] + }) + expect(target?.kind).toBe('unresolved') + }) + + it('fails closed for a confirmed row with no host evidence despite one known owner', () => { + const state = { + worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1', hostId: 'ssh:ssh-1' }] }, + detectedWorktreesByRepo: {} + } as unknown as AppState + const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], state, { + approvedCandidates: [makeCandidate(undefined)] + }) + expect(target?.kind).toBe('unresolved') + }) + + it('fails closed for a confirmed row when no owner row carries a host', () => { + const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, { + approvedCandidates: [makeCandidate(undefined)] + }) + expect(target?.kind).toBe('unresolved') + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index fd54f15c0ad..d3947333142 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -1,3 +1,7 @@ +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity +} from '../../../../shared/workspace-cleanup-host-identity' import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' @@ -68,6 +72,9 @@ describe('workspace cleanup removal and protection', () => { .removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId)) ).resolves.toEqual({ removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)), + removedIdentities: expect.arrayContaining( + candidates.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate)) + ), failures: [] }) @@ -103,6 +110,7 @@ describe('workspace cleanup removal and protection', () => { store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) ).resolves.toEqual({ removedIds: [candidate.worktreeId], + removedIdentities: [getWorkspaceCleanupHostIdentity('local', candidate.worktreeId)], failures: [], preservedBranches: [ { @@ -242,6 +250,7 @@ describe('workspace cleanup removal and protection', () => { await expect(removal).resolves.toEqual({ removedIds: [WORKTREE_ID], + removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)], failures: [] }) expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { @@ -355,6 +364,7 @@ describe('workspace cleanup removal and protection', () => { await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( { removedIds: [WORKTREE_ID], + removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)], failures: [] } ) @@ -364,8 +374,9 @@ describe('workspace cleanup removal and protection', () => { }) it('fails a queued removal that now needs a force the user never approved', async () => { - const approvedCandidate = makeCandidate() + const approvedCandidate = makeCandidate({ executionHostId: 'local' }) const dirtySinceConfirmation = makeCandidate({ + executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } @@ -385,9 +396,11 @@ describe('workspace cleanup removal and protection', () => { }) ).resolves.toEqual({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: WORKTREE_ID, + executionHostId: 'local', displayName: 'old-workspace', message: 'Workspace changed after confirmation. Refresh to review it before removing.' } @@ -398,6 +411,7 @@ describe('workspace cleanup removal and protection', () => { it('still force-removes rows whose approved candidate already carried git risk', async () => { const approvedCandidate = makeCandidate({ + executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } @@ -415,19 +429,26 @@ describe('workspace cleanup removal and protection', () => { store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [approvedCandidate] }) - ).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] }) + ).resolves.toEqual({ + removedIds: [WORKTREE_ID], + removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)], + failures: [] + }) expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + requiredExecutionHostId: 'local' }) }) it('fails a removal that reveals concrete git risk after an unverified force approval', async () => { const approvedCandidate = makeCandidate({ + executionHostId: 'local', tier: 'review', blockers: ['git-status-error'], git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } }) const nowRevealsUnpushed = makeCandidate({ + executionHostId: 'local', tier: 'review', blockers: ['unpushed-commits'], git: { clean: true, upstreamAhead: 3, upstreamBehind: 0, checkedAt: NOW } @@ -447,9 +468,11 @@ describe('workspace cleanup removal and protection', () => { }) ).resolves.toEqual({ removedIds: [], + removedIdentities: [], failures: [ { worktreeId: WORKTREE_ID, + executionHostId: 'local', displayName: 'old-workspace', message: 'Workspace changed after confirmation. Refresh to review it before removing.' } diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts new file mode 100644 index 00000000000..726341eb65e --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts @@ -0,0 +1,263 @@ +/** + * Host-qualified removal identity for workspace cleanup (STA-4343). + * + * A cleanup row's `worktreeId` is `repoId::path`, which two execution hosts can + * both own. Selection, confirmation, preflight and removal therefore travel as + * a (worktreeId, executionHostId) pair. Where the owner cannot be pinned — an + * id-only row that the store knows on several hosts, a confirmation naming two + * hosts at once, or a refreshed scan that no longer shows the row on the + * confirmed host — the target resolves to a failure instead of a best guess: + * deleting the wrong host's workspace destroys uncommitted work. + */ +import type { AppState } from '../types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + canQueueWorkspaceCleanupCandidate, + shouldForceWorkspaceCleanupRemoval, + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' +import { getWorktreeOperationOwnerHostIds } from '@/lib/worktree-operation-route' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupFailure } from './workspace-cleanup' + +export type WorkspaceCleanupRemovalTarget = { + kind: 'target' + worktreeId: string + /** + * The host the user confirmed. Null is reserved for legacy internal callers + * that did not supply confirmation candidates. + */ + executionHostId: ExecutionHostId | null + displayName: string + approvedCandidate?: WorkspaceCleanupCandidate +} + +export type WorkspaceCleanupUnresolvedTarget = { + kind: 'unresolved' + failure: WorkspaceCleanupFailure +} + +export type WorkspaceCleanupRemovalTargetResolution = + | WorkspaceCleanupRemovalTarget + | WorkspaceCleanupUnresolvedTarget + +export type WorkspaceCleanupPreflightResult = + | { ok: true; target: WorkspaceCleanupRemovalTarget; candidate: WorkspaceCleanupCandidate } + | { ok: false; failure: WorkspaceCleanupFailure } + +type WorkspaceCleanupRemovalTargetState = Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' +> + +function ambiguousHostFailure( + worktreeId: string, + displayName: string +): WorkspaceCleanupUnresolvedTarget { + return { + kind: 'unresolved', + failure: { + worktreeId, + displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.hostUnresolved', + 'Orca cannot tell which host owns this workspace. Refresh projects and review it again.' + ) + } + } +} + +export function resolveWorkspaceCleanupRemovalTargets( + worktreeIds: readonly string[], + state: WorkspaceCleanupRemovalTargetState, + options: { approvedCandidates?: readonly WorkspaceCleanupCandidate[] } = {} +): WorkspaceCleanupRemovalTargetResolution[] { + const approvedByWorktreeId = new Map() + for (const candidate of options.approvedCandidates ?? []) { + const approved = approvedByWorktreeId.get(candidate.worktreeId) ?? [] + approved.push(candidate) + approvedByWorktreeId.set(candidate.worktreeId, approved) + } + + return worktreeIds.map((worktreeId) => { + const approved = approvedByWorktreeId.get(worktreeId) ?? [] + const displayName = approved[0]?.displayName ?? worktreeId + // Why: one id confirmed for two hosts in a single batch cannot say which + // row the user meant, and picking either one may delete the other's work. + if ( + new Set(approved.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))).size > 1 + ) { + return ambiguousHostFailure(worktreeId, displayName) + } + const confirmedCandidate = approved[0] + const confirmedHostId = confirmedCandidate + ? resolveWorkspaceCleanupRemovalHostId(confirmedCandidate) + : null + if (confirmedCandidate && confirmedHostId) { + return { + kind: 'target', + worktreeId, + executionHostId: confirmedHostId, + displayName, + approvedCandidate: confirmedCandidate + } + } + // A displayed row without host evidence cannot prove where the user + // intended to delete, even if the current catalog happens to list one owner. + if (confirmedCandidate) { + return ambiguousHostFailure(worktreeId, displayName) + } + // No host evidence on the row: accept it only while the store itself knows + // a single owner. This compatibility path is reachable only by internal + // callers that did not provide a confirmed candidate. + const ownerHostIds = getWorktreeOperationOwnerHostIds(state, worktreeId) + if (ownerHostIds.length > 1) { + return ambiguousHostFailure(worktreeId, displayName) + } + return { + kind: 'target', + worktreeId, + executionHostId: ownerHostIds[0] ?? null, + displayName, + ...(confirmedCandidate ? { approvedCandidate: confirmedCandidate } : {}) + } + }) +} + +export async function preflightWorkspaceCleanupCandidates( + targets: readonly WorkspaceCleanupRemovalTarget[], + getState: () => AppState, + enrich: ( + candidates: readonly WorkspaceCleanupCandidate[], + state: AppState + ) => Promise +): Promise { + // Why: one batched scan per chunk replaces a git worktree-list + activity + // read per row; chunks stay under main's silent target truncation limit. + const candidatesByIdentity = new Map() + const identitiesByWorktreeId = new Map>() + const worktreeIds = targets.map((target) => target.worktreeId) + for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) { + const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) + const scan = await window.api.workspaceCleanup.scan({ + worktreeIds: [...chunk], + scanId: crypto.randomUUID(), + refreshActivity: true + }) + const enriched = await enrich(scan.candidates, getState()) + for (const candidate of enriched) { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + candidatesByIdentity.set(identity, candidate) + const identities = identitiesByWorktreeId.get(candidate.worktreeId) ?? new Set() + identities.add(identity) + identitiesByWorktreeId.set(candidate.worktreeId, identities) + } + } + return targets.map((target) => + evaluateWorkspaceCleanupPreflight(target, candidatesByIdentity, identitiesByWorktreeId) + ) +} + +function resolvePreflightCandidate( + target: WorkspaceCleanupRemovalTarget, + candidatesByIdentity: ReadonlyMap, + identitiesByWorktreeId: ReadonlyMap> +): { ok: true; candidate: WorkspaceCleanupCandidate | undefined } | { ok: false } { + const identities = identitiesByWorktreeId.get(target.worktreeId) + if (target.executionHostId) { + // Why: the refreshed row must be the SAME host's row. Another host's + // evidence would decide force/blockers for a workspace it does not own. + return { + ok: true, + candidate: candidatesByIdentity.get( + getWorkspaceCleanupHostIdentity(target.executionHostId, target.worktreeId) + ) + } + } + // An unqualified target can only proceed while the rescan agrees there is one owner. + if ((identities?.size ?? 0) > 1) { + return { ok: false } + } + const identity = identities?.values().next().value + return { ok: true, candidate: identity ? candidatesByIdentity.get(identity) : undefined } +} + +export function evaluateWorkspaceCleanupPreflight( + target: WorkspaceCleanupRemovalTarget, + candidatesByIdentity: ReadonlyMap, + identitiesByWorktreeId: ReadonlyMap> +): WorkspaceCleanupPreflightResult { + const resolved = resolvePreflightCandidate(target, candidatesByIdentity, identitiesByWorktreeId) + if (!resolved.ok) { + return { + ok: false, + failure: ambiguousHostFailure(target.worktreeId, target.displayName).failure + } + } + const candidate = resolved.candidate + if (!candidate) { + return { + ok: false, + failure: { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: target.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.9d6e531da6', + 'Workspace no longer exists.' + ) + } + } + } + const failure = (message: string): WorkspaceCleanupPreflightResult => ({ + ok: false, + failure: { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: candidate.displayName, + message + } + }) + if (!canQueueWorkspaceCleanupCandidate(candidate)) { + return failure( + candidate.blockers.length + ? candidate.blockers.join(', ') + : 'Workspace needs another look before removal.' + ) + } + // Why: this row may be removed minutes after the confirm click. If it now + // needs a force removal the user never approved (new dirt, unpushed work, + // or a git error since confirmation), fail it instead of force-deleting. + const approvedCandidate = target.approvedCandidate + if (approvedCandidate) { + const escalatedToForce = + shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate) + // Why: an approved row that was already force-flagged for an unverifiable + // reason must still fail when real dirt/unpushed work is now visible. + const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( + (blocker) => + candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) + ) + if (escalatedToForce || revealedConcreteRisk) { + return failure( + translate( + 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', + 'Workspace changed after confirmation. Refresh to review it before removing.' + ) + ) + } + } + return { ok: true, target, candidate } +} + +// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base +// and git-status-error only mean "couldn't verify". A row approved while +// unverifiable must still fail if real work becomes visible before removal. +const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts index 87fcd9ec243..7d426a98374 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -114,6 +114,54 @@ describe('workspace cleanup scan progress', () => { }) }) + it('keeps same-id host rows distinct across streamed enrichment frames', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const localCandidate = makeCandidate({ executionHostId: 'local' }) + const remoteCandidate = makeCandidate({ executionHostId: 'ssh:ssh-1', connectionId: 'ssh-1' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [localCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [remoteCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) + }) + + pending.resolve({ scannedAt: NOW, candidates: [localCandidate, remoteCandidate], errors: [] }) + await scanPromise + + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([ + expect.objectContaining({ executionHostId: 'local' }), + expect.objectContaining({ executionHostId: 'ssh:ssh-1' }) + ]) + }) + it('does not re-probe previously enriched rows during cumulative progress updates', async () => { const pending = deferred() let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined diff --git a/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts new file mode 100644 index 00000000000..e5d41ef77a4 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts @@ -0,0 +1,609 @@ +/** + * STA-4343: workspace cleanup must delete on the host whose row was confirmed. + * + * Two hosts can expose cleanup candidates with the SAME `repoId::path` identity + * (the scan composes `worktreeId` from `repo.id` + `::` + the workspace path + * with no host component). Before the fix, selection, confirmation, preflight + * and removal kept only `worktreeId` and removal routing preferred the ACTIVE + * workspace's host, so confirming host B's row issued the destructive IPC + * against host A and destroyed host A's real directory (and uncommitted work). + * + * Rig: two REAL temp directories stand in for the two hosts. The removal + * boundary (`window.api.worktrees.remove` for the local/SSH transport, + * `callRuntimeRpc('worktree.rm')` for a paired runtime) is the only fake, and + * it ACTUALLY deletes the directory on the host it is routed to. The gate is + * filesystem state, not arguments: after confirming host B's row, host A's + * marker must still exist and host B's must be gone. + * + * The soundness controls matter as much as the wrong-host cases: the same rig + * with host B ACTIVE (and with host A confirmed) still deletes exactly the + * confirmed row, so a passing assertion cannot come from a rig that never + * deletes anything. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +const NOW = 1_700_000_000_000 +const HOST_A_HOST_ID: ExecutionHostId = 'local' +const HOST_B_HOST_ID: ExecutionHostId = 'ssh:ssh-1' +const HOST_B_RUNTIME_HOST_ID: ExecutionHostId = 'runtime:hub-1' + +const mockApi = { + worktrees: { + remove: vi.fn(), + forgetLocal: vi.fn().mockResolvedValue({ ok: false, error: 'unused in this scenario' }) + }, + hooks: { + check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + }, + workspaceCleanup: { + scan: vi.fn(), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} } as never) }, + ephemeralVm: { + listRuntimes: vi.fn().mockResolvedValue([]), + cleanup: vi.fn().mockResolvedValue({}) + } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc: runtimeRpc.callRuntimeRpc +})) + +import { createTestStore, seedStore, makeTab, makeWorktree } from './store-test-helpers' +import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees' +import type { AppState } from '../types' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' + +function makeHostCandidate( + worktreeId: string, + executionHostId: ExecutionHostId | undefined, + worktreePath: string, + connectionId: string | null = executionHostId === HOST_B_HOST_ID ? 'ssh-1' : null +): WorkspaceCleanupCandidate { + return { + worktreeId, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId, + ...(executionHostId ? { executionHostId } : {}), + displayName: 'shared-workspace', + branch: 'old-branch', + path: worktreePath, + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + }, + fingerprint: `fingerprint-${executionHostId ?? 'unqualified'}` + } +} + +function parseIdentityPath(worktreeId: string): string { + const separatorIndex = worktreeId.indexOf('::') + return worktreeId.slice(separatorIndex + 2) +} + +/** Maps a workspace id onto a per-host temp directory; Windows ids keep their shape. */ +function toHostRelativePath(worktreeId: string): string { + return parseIdentityPath(worktreeId) + .replace(/^[a-zA-Z]:/, '') + .split(/[/\\]+/) + .filter((segment) => segment.length > 0) + .join(path.sep) +} + +type HostDirectories = { + hostARoot: string + hostBRoot: string + hostAMarkerPath: string + hostBMarkerPath: string +} + +const hostDirCleanup: (() => void)[] = [] + +function createHostDirectories(worktreeId: string): HostDirectories { + const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-a-')) + const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sta4343-host-b-')) + hostDirCleanup.push(() => { + fs.rmSync(hostARoot, { recursive: true, force: true }) + fs.rmSync(hostBRoot, { recursive: true, force: true }) + }) + const relativePath = toHostRelativePath(worktreeId) + const hostAWorktreeDir = path.join(hostARoot, relativePath) + const hostBWorktreeDir = path.join(hostBRoot, relativePath) + fs.mkdirSync(hostAWorktreeDir, { recursive: true }) + fs.mkdirSync(hostBWorktreeDir, { recursive: true }) + const hostAMarkerPath = path.join(hostAWorktreeDir, 'HOST_A_MARKER') + const hostBMarkerPath = path.join(hostBWorktreeDir, 'HOST_B_MARKER') + fs.writeFileSync(hostAMarkerPath, 'uncommitted-data-on-host-a') + fs.writeFileSync(hostBMarkerPath, 'uncommitted-data-on-host-b') + return { hostARoot, hostBRoot, hostAMarkerPath, hostBMarkerPath } +} + +function parseRuntimeSelector(selector: string): string { + return selector.startsWith('id:') ? selector.slice('id:'.length) : selector +} + +/** Deletes for real on whichever host the removal is routed to. */ +function installRemovalTransports( + hostRootsByHostId: Record, + routedHostIds: string[], + options: { preservedBranch?: { branchName: string; head: string } } = {} +): void { + const deleteOnHost = (hostId: string | undefined, worktreeId: string): void => { + routedHostIds.push(hostId ?? '') + const hostRoot = hostId ? hostRootsByHostId[hostId] : undefined + if (hostRoot) { + fs.rmSync(path.join(hostRoot, toHostRelativePath(worktreeId)), { + recursive: true, + force: true + }) + } + } + mockApi.worktrees.remove.mockImplementation( + async (args: { worktreeId: string; hostId?: string }) => { + deleteOnHost(args.hostId, args.worktreeId) + return options.preservedBranch ? { preservedBranch: options.preservedBranch } : { ok: true } + } + ) + runtimeRpc.callRuntimeRpc.mockImplementation( + async (_target: unknown, method: string, params: { hostId?: string; worktree?: string }) => { + if (method !== 'worktree.rm') { + return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + deleteOnHost(params.hostId, parseRuntimeSelector(params.worktree ?? '')) + return { removed: true } + } + ) +} + +function seedCollidingScan(candidates: readonly WorkspaceCleanupCandidate[]): void { + mockApi.workspaceCleanup.scan.mockResolvedValue({ + scannedAt: NOW, + candidates: [...candidates], + errors: [] + } satisfies WorkspaceCleanupScanResult) +} + +beforeEach(() => { + resetAuthoritativelyRemovedWorktreeMemoryForTests() +}) + +afterEach(() => { + vi.clearAllMocks() + for (const cleanup of hostDirCleanup.splice(0)) { + cleanup() + } +}) + +describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => { + it('confirming host B row deletes host B and leaves ACTIVE host A intact', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + + const store = createTestStore() + // Host A owns the ACTIVE workspace, so removal routing used to prefer host A. + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID, + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_A_HOST_ID + }), + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_B_HOST_ID + }) + ] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'host-a-tab', worktreeId })] + } + } as Partial) + + // The user confirms HOST B's cleanup row. + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.removedIds).toEqual([worktreeId]) + expect(removal.failures).toEqual([]) + expect( + fs.existsSync(hosts.hostAMarkerPath), + `HOST A (active) worktree directory must still exist after confirming HOST B's row. worktrees.remove was routed to hostId=${routedHostIds.join(',')}` + ).toBe(true) + expect( + fs.existsSync(hosts.hostBMarkerPath), + 'HOST B (confirmed) worktree directory must be gone' + ).toBe(false) + expect(routedHostIds).toEqual([HOST_B_HOST_ID]) + expect(store.getState().worktreesByRepo.repo1).toEqual([ + expect.objectContaining({ id: worktreeId, hostId: HOST_A_HOST_ID }) + ]) + expect(store.getState().tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: 'host-a-tab' }) + ]) + expect(store.getState().activeWorktreeId).toBe(worktreeId) + }) + + it('soundness control: with host B ACTIVE, confirming host B still deletes only host B', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_B_HOST_ID + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.failures).toEqual([]) + expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false) + expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true) + expect(routedHostIds).toEqual([HOST_B_HOST_ID]) + }) + + it('soundness control: confirming ACTIVE host A deletes host A and leaves host B intact', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostACandidate = makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath) + seedCollidingScan([hostACandidate, makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID, + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_A_HOST_ID + }) + ] + } + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostACandidate] }) + + expect(removal.failures).toEqual([]) + expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false) + expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true) + expect(routedHostIds).toEqual([HOST_A_HOST_ID]) + }) + + it('keeps the confirmed host on the preserved-branch follow-up', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds, + { preservedBranch: { branchName: 'feature/kept', head: 'abc123' } } + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.preservedBranches).toEqual([ + { + worktreeId, + branchName: 'feature/kept', + expectedHead: 'abc123', + hostId: HOST_B_HOST_ID + } + ]) + }) + + it('routes a Windows-path colliding identity to the confirmed host', async () => { + const worktreeId = 'repo1::C:\\Users\\me\\workspaces\\shared' + const worktreePath = 'C:\\Users\\me\\workspaces\\shared' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID, + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_A_HOST_ID + }) + ] + } + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.failures).toEqual([]) + expect(fs.existsSync(hosts.hostAMarkerPath), 'host A (active) must survive').toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false) + expect(routedHostIds).toEqual([HOST_B_HOST_ID]) + }) +}) + +describe('STA-4343 wrong-host cleanup removal (folder workspace identity)', () => { + it('folder workspace identity deletes the confirmed host, not the ACTIVE one', async () => { + const worktreeId = 'repo1::/shared/folder-workspace' + const worktreePath = '/shared/folder-workspace' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + + const store = createTestStore() + // Folder workspaces are not Git worktrees: no worktree owner rows exist. + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'host-a-folder-tab', worktreeId })] + } + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.removedIds).toEqual([worktreeId]) + expect(removal.failures).toEqual([]) + expect( + fs.existsSync(hosts.hostAMarkerPath), + `HOST A (active) folder workspace must still exist. worktrees.remove was routed to hostId=${routedHostIds.join(',')}` + ).toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false) + expect(routedHostIds).toEqual([HOST_B_HOST_ID]) + expect(store.getState().tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: 'host-a-folder-tab' }) + ]) + expect(store.getState().activeWorktreeId).toBe(worktreeId) + }) +}) + +describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => { + it('confirming a paired runtime row deletes over the runtime RPC, not the local host', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_RUNTIME_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_RUNTIME_HOST_ID, worktreePath, null) + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID, + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_A_HOST_ID + }) + ] + } + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.failures).toEqual([]) + expect(fs.existsSync(hosts.hostAMarkerPath), 'local host A must survive').toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false) + expect(routedHostIds).toEqual([HOST_B_RUNTIME_HOST_ID]) + // The destructive call left over the paired transport, not the local IPC. + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'hub-1' }, + 'worktree.rm', + expect.objectContaining({ hostId: HOST_B_RUNTIME_HOST_ID }), + expect.anything() + ) + }) +}) + +describe('STA-4343 cleanup removal fails closed on an unresolved owner', () => { + it('deletes nothing when the confirmed host no longer lists the row', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath) + // The confirm-time list had both hosts; the preflight rescan sees only host A. + seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] }) + + expect(removal.removedIds).toEqual([]) + expect(removal.failures).toHaveLength(1) + expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true) + expect(routedHostIds).toEqual([]) + }) + + it('deletes nothing for an unqualified row the store knows on two hosts', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + // A row published before host-qualified candidates existed: no host evidence. + const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null) + seedCollidingScan([legacyCandidate]) + + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_A_HOST_ID + }), + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: worktreePath, + hostId: HOST_B_HOST_ID + }) + ] + } + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] }) + + expect(removal.removedIds).toEqual([]) + expect(removal.failures).toHaveLength(1) + expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true) + expect(routedHostIds).toEqual([]) + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('deletes nothing for an old-client row with no host qualifier or catalog owner', async () => { + const worktreeId = 'repo1::/shared/workspace/path' + const worktreePath = '/shared/workspace/path' + const hosts = createHostDirectories(worktreeId) + const routedHostIds: string[] = [] + installRemovalTransports( + { [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot }, + routedHostIds + ) + const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null) + seedCollidingScan([legacyCandidate]) + + const store = createTestStore() + seedStore(store, { + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: HOST_A_HOST_ID + } as Partial) + + const removal = await store + .getState() + .removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] }) + + expect(removal.removedIds).toEqual([]) + expect(removal.failures).toHaveLength(1) + expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true) + expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true) + expect(routedHostIds).toEqual([]) + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index d1c930261b6..94c45ff4a75 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -3,15 +3,14 @@ one store state contract. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, - WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, applyWorkspaceCleanupPolicy, - canQueueWorkspaceCleanupCandidate, canSelectWorkspaceCleanupCandidate, shouldForceWorkspaceCleanupRemoval, shouldHideWorkspaceCleanupCandidate, @@ -22,8 +21,17 @@ import { type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateHostId, + getWorkspaceCleanupCandidateIdentity +} from '../../../../shared/workspace-cleanup-host-identity' import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { hydrateWorkspaceCleanupScanFromCache } from './workspace-cleanup-cache-hydration' +import { + preflightWorkspaceCleanupCandidates, + resolveWorkspaceCleanupRemovalTargets, + type WorkspaceCleanupRemovalTarget +} from './workspace-cleanup-removal-targets' import { getInFlightWorkspaceCleanupScan, hasInFlightWorkspaceCleanupScan, @@ -34,21 +42,20 @@ import { throwIfWorkspaceCleanupScanSuperseded } from './workspace-cleanup-broad-scan-registry' import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' -import { translate } from '@/i18n/i18n' -import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity' -import { isWorkspaceCleanupRemovalHostCertain } from './workspace-cleanup-removal-host-guard' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorkspaceCleanupFailure = { worktreeId: string + /** Which host's row failed; absent only for a row with no host evidence. */ + executionHostId?: ExecutionHostId displayName: string message: string } export type WorkspaceCleanupRemoveResult = { removedIds: string[] + /** Host-qualified keys of the rows actually removed, parallel to removedIds. */ + removedIdentities: string[] failures: WorkspaceCleanupFailure[] preservedBranches?: PreservedBranchCleanup[] } @@ -108,11 +115,6 @@ type WorkspaceCleanupEnrichmentProjection = { const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 export const WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY = 8 -// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base -// and git-status-error only mean "couldn't verify". A row approved while -// unverifiable must still fail if real work becomes visible before removal. -const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const - let latestWorkspaceCleanupScanToken = 0 let finalizedWorkspaceCleanupScanToken = 0 let workspaceCleanupProgressQueue: { @@ -130,7 +132,7 @@ let workspaceCleanupProgressCandidateIndex: { scanToken: number scanId: string candidates: WorkspaceCleanupCandidate[] - indexesByWorktreeId: Map + indexesByIdentity: Map } | null = null const SHELL_PROCESS_NAMES = new Set([ @@ -311,6 +313,9 @@ export const createWorkspaceCleanupSlice: StateCreator ({ worktreeId: candidate.worktreeId, + // Why (STA-4343): ignoring one host's row must not hide the same-id row + // on another host; the stored host scopes the match. + executionHostId: getWorkspaceCleanupCandidateHostId(candidate), dismissedAt: now, fingerprint: candidate.fingerprint, classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION @@ -319,7 +324,12 @@ export const createWorkspaceCleanupSlice: StateCreator { const nextDismissals = { ...state.workspaceCleanupDismissals } for (const dismissal of dismissals) { - nextDismissals[dismissal.worktreeId] = dismissal + nextDismissals[ + getWorkspaceCleanupCandidateIdentity({ + worktreeId: dismissal.worktreeId, + executionHostId: dismissal.executionHostId + }) + ] = dismissal } const nextScan = state.workspaceCleanupScan ? { @@ -358,52 +368,51 @@ export const createWorkspaceCleanupSlice: StateCreator { const removedIds: string[] = [] + const removedIdentities = new Set() const failures: WorkspaceCleanupFailure[] = [] const preservedBranches: PreservedBranchCleanup[] = [] - const approvedCandidatesByWorktreeId = new Map( - (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) + + // Why (STA-4343): the confirmed row — not the id — names the host to delete + // on. Everything below carries that owner so nothing re-derives it from the + // active workspace, which owns the same `repoId::path` id on another host. + const targets = resolveWorkspaceCleanupRemovalTargets( + worktreeIds, + get(), + options?.approvedCandidates ? { approvedCandidates: options.approvedCandidates } : {} ) + const removableTargets: WorkspaceCleanupRemovalTarget[] = [] + for (const target of targets) { + if (target.kind === 'unresolved') { + failures.push(target.failure) + continue + } + removableTargets.push(target) + } const preflights = await preflightWorkspaceCleanupCandidates( - worktreeIds, + removableTargets, get, - approvedCandidatesByWorktreeId + (candidates, state) => + enrichWorkspaceCleanupCandidates(candidates, state, { applyDismissals: false }) ) - const candidatesToRemove: WorkspaceCleanupCandidate[] = [] + const targetsToRemove: { + target: WorkspaceCleanupRemovalTarget + candidate: WorkspaceCleanupCandidate + }[] = [] for (const preflight of preflights) { if (!preflight.ok) { failures.push(preflight.failure) continue } - candidatesToRemove.push(preflight.candidate) + targetsToRemove.push({ target: preflight.target, candidate: preflight.candidate }) } // Why: nested workspaces can belong to different repos; parent removal must // not race child cleanup hooks, PTY teardown, or metadata deletion. - for (const candidate of [...candidatesToRemove].sort((a, b) => b.path.length - a.path.length)) { - // Why: earlier rows can await long enough for active-host routing to change - // after the batched preflight; recheck in the same turn as removeWorktree. - const scannedHostId = resolveWorkspaceCleanupRemovalHostId(candidate) - if ( - !isWorkspaceCleanupRemovalHostCertain({ - confirmedCandidate: candidate, - scannedCandidate: candidate, - scannedHostIds: [scannedHostId], - routeHostId: - resolveWorktreeOperationRoute(get(), candidate.worktreeId)?.executionHostId ?? null - }) - ) { - failures.push({ - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: translate( - 'auto.store.slices.workspace.cleanup.hostUnresolved', - 'Orca cannot tell which host owns this workspace. Refresh projects and review it again.' - ) - }) - continue - } + for (const { target, candidate } of [...targetsToRemove].sort( + (a, b) => b.candidate.path.length - a.candidate.path.length + )) { const result = await get().removeWorktree( candidate.worktreeId, shouldForceWorkspaceCleanupRemoval(candidate), @@ -413,21 +422,29 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { invalidateWorkspaceCleanupScanProgress() - const removedIdSet = new Set(removedIds) - set((state) => ({ - workspaceCleanupLoading: false, - workspaceCleanupScan: state.workspaceCleanupScan - ? { - ...state.workspaceCleanupScan, - candidates: state.workspaceCleanupScan.candidates.filter( - (candidate) => !removedIdSet.has(candidate.worktreeId) - ) - } - : state.workspaceCleanupScan, - // Why: dismissals and viewed marks for removed worktrees are dead - // weight in the store and in every persisted-dismissals write. - workspaceCleanupDismissals: pruneWorkspaceCleanupRecord( - state.workspaceCleanupDismissals, - removedIdSet - ), - workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord( - state.workspaceCleanupViewedCandidates, - removedIdSet + let prunableWorktreeIds = new Set(removedIds) + set((state) => { + const remainingCandidates = state.workspaceCleanupScan?.candidates.filter( + (candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) ) - })) - void window.api.workspaceCleanup - .dismiss({ dismissals: [], removedWorktreeIds: removedIds }) - .catch((error: unknown) => { - console.warn('Failed to prune persisted cleanup dismissals', error) - }) + // Why: a same-id row on another host survives this removal, and its + // dismissal/viewed marks are keyed by worktree id alone — dropping them + // would resurrect a row the user already ignored. + const survivingWorktreeIds = new Set( + (remainingCandidates ?? []).map((candidate) => candidate.worktreeId) + ) + prunableWorktreeIds = new Set( + removedIds.filter((worktreeId) => !survivingWorktreeIds.has(worktreeId)) + ) + return { + workspaceCleanupLoading: false, + workspaceCleanupScan: + state.workspaceCleanupScan && remainingCandidates + ? { ...state.workspaceCleanupScan, candidates: remainingCandidates } + : state.workspaceCleanupScan, + // Why: dismissals and viewed marks for removed worktrees are dead + // weight in the store and in every persisted-dismissals write. + workspaceCleanupDismissals: pruneWorkspaceCleanupDismissals( + state.workspaceCleanupDismissals, + prunableWorktreeIds + ), + workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord( + state.workspaceCleanupViewedCandidates, + prunableWorktreeIds + ) + } + }) + if (prunableWorktreeIds.size > 0) { + void window.api.workspaceCleanup + .dismiss({ dismissals: [], removedWorktreeIds: [...prunableWorktreeIds] }) + .catch((error: unknown) => { + console.warn('Failed to prune persisted cleanup dismissals', error) + }) + } } return { removedIds, + removedIdentities: [...removedIdentities], failures, ...(preservedBranches.length > 0 ? { preservedBranches } : {}) } @@ -680,9 +710,12 @@ function mergeWorkspaceCleanupProgressCandidates({ ) const merged = [...indexCache.candidates] for (const candidate of nextCandidates) { - const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId) + // Why (STA-4343): two hosts publish the same `repoId::path` id; keying the + // merge on the id alone made one host's row overwrite the other's. + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const existingIndex = indexCache.indexesByIdentity.get(identity) if (existingIndex === undefined) { - indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length) + indexCache.indexesByIdentity.set(identity, merged.length) merged.push(candidate) continue } @@ -692,7 +725,7 @@ function mergeWorkspaceCleanupProgressCandidates({ scanToken, scanId: progress.scanId, candidates: merged, - indexesByWorktreeId: indexCache.indexesByWorktreeId + indexesByIdentity: indexCache.indexesByIdentity } return merged } @@ -703,7 +736,7 @@ function getWorkspaceCleanupProgressCandidateIndex( scanToken: number ): { candidates: WorkspaceCleanupCandidate[] - indexesByWorktreeId: Map + indexesByIdentity: Map } { if ( workspaceCleanupProgressCandidateIndex?.scanToken === scanToken && @@ -715,8 +748,8 @@ function getWorkspaceCleanupProgressCandidateIndex( return { candidates: [...candidates], - indexesByWorktreeId: new Map( - candidates.map((candidate, index) => [candidate.worktreeId, index]) + indexesByIdentity: new Map( + candidates.map((candidate, index) => [getWorkspaceCleanupCandidateIdentity(candidate), index]) ) } } @@ -792,13 +825,14 @@ async function enrichWorkspaceCleanupCandidatesWithCache( candidates, WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, async (candidate) => { - const cached = cache.get(candidate.worktreeId) + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const cached = cache.get(identity) if (options.localStateUnchanged === true && cached?.candidateRef === candidate) { return cached.candidate } const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate) const localSignature = getWorkspaceCleanupLocalStateSignature( - candidate.worktreeId, + candidate, state, projection, options @@ -809,7 +843,7 @@ async function enrichWorkspaceCleanupCandidatesWithCache( } const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, projection, options) - cache.set(candidate.worktreeId, { + cache.set(identity, { candidateRef: candidate, inputSignature, localSignature, @@ -875,11 +909,12 @@ function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupC } function getWorkspaceCleanupLocalStateSignature( - worktreeId: string, + candidate: WorkspaceCleanupCandidate, state: AppState, projection: WorkspaceCleanupEnrichmentProjection, options: EnrichOptions ): string { + const { worktreeId } = candidate const tabs = state.tabsByWorktree[worktreeId] ?? [] const tabIds = tabs.map((tab) => tab.id) const tabIdSet = new Set(tabIds) @@ -911,7 +946,7 @@ function getWorkspaceCleanupLocalStateSignature( const dismissal = options.applyDismissals === false ? null - : (state.workspaceCleanupDismissals[worktreeId] ?? null) + : (getWorkspaceCleanupDismissal(candidate, state.workspaceCleanupDismissals) ?? null) return JSON.stringify({ active: state.activeWorktreeId === worktreeId, @@ -1013,7 +1048,12 @@ function applyDismissal( candidate: WorkspaceCleanupCandidate, dismissals: Record ): WorkspaceCleanupCandidate { - if (!shouldHideWorkspaceCleanupCandidate(candidate, dismissals[candidate.worktreeId])) { + if ( + !shouldHideWorkspaceCleanupCandidate( + candidate, + getWorkspaceCleanupDismissal(candidate, dismissals) + ) + ) { return candidate } return applyWorkspaceCleanupPolicy({ @@ -1022,9 +1062,26 @@ function applyDismissal( }) } -type WorkspaceCleanupPreflightResult = - | { ok: true; candidate: WorkspaceCleanupCandidate } - | { ok: false; failure: WorkspaceCleanupFailure } +function getWorkspaceCleanupDismissal( + candidate: WorkspaceCleanupCandidate, + dismissals: Record +): WorkspaceCleanupDismissal | undefined { + return ( + dismissals[getWorkspaceCleanupCandidateIdentity(candidate)] ?? dismissals[candidate.worktreeId] + ) +} + +function pruneWorkspaceCleanupDismissals( + dismissals: Record, + removedIds: ReadonlySet +): Record { + if (!Object.values(dismissals).some((dismissal) => removedIds.has(dismissal.worktreeId))) { + return dismissals + } + return Object.fromEntries( + Object.entries(dismissals).filter(([, dismissal]) => !removedIds.has(dismissal.worktreeId)) + ) +} function pruneWorkspaceCleanupRecord( record: Record, @@ -1036,138 +1093,6 @@ function pruneWorkspaceCleanupRecord( return Object.fromEntries(Object.entries(record).filter(([id]) => !removedIds.has(id))) } -async function preflightWorkspaceCleanupCandidates( - worktreeIds: readonly string[], - getState: () => AppState, - approvedCandidatesByWorktreeId: ReadonlyMap -): Promise { - // Why: one batched scan per chunk replaces a git worktree-list + activity - // read per row; chunks stay under main's silent target truncation limit. - const candidatesByWorktreeId = new Map() - // Why: candidatesByWorktreeId collapses a colliding id to one row, so record - // every owner the scan reported before the collapse hides the collision. - const scannedHostIdsByWorktreeId = new Map>() - for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) { - const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) - const scan = await window.api.workspaceCleanup.scan({ - worktreeIds: [...chunk], - scanId: crypto.randomUUID(), - refreshActivity: true - }) - for (const candidate of scan.candidates) { - const hostIds = scannedHostIdsByWorktreeId.get(candidate.worktreeId) ?? new Set() - hostIds.add(resolveWorkspaceCleanupRemovalHostId(candidate)) - scannedHostIdsByWorktreeId.set(candidate.worktreeId, hostIds) - } - const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), { - applyDismissals: false - }) - for (const candidate of enriched) { - candidatesByWorktreeId.set(candidate.worktreeId, candidate) - } - } - return worktreeIds.map((worktreeId) => { - const preflight = evaluateWorkspaceCleanupPreflight( - worktreeId, - candidatesByWorktreeId.get(worktreeId), - approvedCandidatesByWorktreeId.get(worktreeId) - ) - if (!preflight.ok) { - return preflight - } - const scannedHostIds = [...(scannedHostIdsByWorktreeId.get(worktreeId) ?? [])] - const distinctKnownHostCount = new Set( - scannedHostIds.filter((hostId): hostId is ExecutionHostId => hostId !== null) - ).size - // STA-4343: fail closed rather than delete another host's uncommitted work. - const hostIsCertain = isWorkspaceCleanupRemovalHostCertain({ - confirmedCandidate: approvedCandidatesByWorktreeId.get(worktreeId), - scannedCandidate: preflight.candidate, - scannedHostIds, - routeHostId: resolveWorktreeOperationRoute(getState(), worktreeId)?.executionHostId ?? null - }) - return hostIsCertain - ? preflight - : { - ok: false as const, - failure: { - worktreeId, - displayName: preflight.candidate.displayName, - message: - distinctKnownHostCount > 1 - ? translate( - 'auto.store.slices.workspace.cleanup.hostCollision', - 'Error: this workspace exists on multiple hosts at the same path' - ) - : translate( - 'auto.store.slices.workspace.cleanup.hostUnresolved', - 'Orca cannot tell which host owns this workspace. Refresh projects and review it again.' - ) - } - } - }) -} - -function evaluateWorkspaceCleanupPreflight( - worktreeId: string, - candidate: WorkspaceCleanupCandidate | undefined, - approvedCandidate?: WorkspaceCleanupCandidate -): WorkspaceCleanupPreflightResult { - if (!candidate) { - return { - ok: false, - failure: { - worktreeId, - displayName: worktreeId, - message: translate( - 'auto.store.slices.workspace.cleanup.9d6e531da6', - 'Workspace no longer exists.' - ) - } - } - } - if (!canQueueWorkspaceCleanupCandidate(candidate)) { - return { - ok: false, - failure: { - worktreeId, - displayName: candidate.displayName, - message: candidate.blockers.length - ? candidate.blockers.join(', ') - : 'Workspace needs another look before removal.' - } - } - } - // Why: this row may be removed minutes after the confirm click. If it now - // needs a force removal the user never approved (new dirt, unpushed work, - // or a git error since confirmation), fail it instead of force-deleting. - if (approvedCandidate) { - const escalatedToForce = - shouldForceWorkspaceCleanupRemoval(candidate) && - !shouldForceWorkspaceCleanupRemoval(approvedCandidate) - // Why: an approved row that was already force-flagged for an unverifiable - // reason must still fail when real dirt/unpushed work is now visible. - const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( - (blocker) => - candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) - ) - if (escalatedToForce || revealedConcreteRisk) { - return { - ok: false, - failure: { - worktreeId, - displayName: candidate.displayName, - message: translate( - 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', - 'Workspace changed after confirmation. Refresh to review it before removing.' - ) - } - } - } - } - return { ok: true, candidate } -} - function buildWorkspaceCleanupAgentStatusIndex( state: AppState, includedTabIds?: ReadonlySet diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index cd06fc82acc..216f45d8966 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -28,6 +28,7 @@ import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeOptions } from './worktree-removal-options' import type { HostQualifiedDetectedWorktreeResult, SshExecutionHostId @@ -246,17 +247,7 @@ export type WorktreeSlice = { removeWorktree: ( worktreeId: string, force?: boolean, - // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) - // for workspaces pinned to a removed/disconnected SSH host. Reuses the same - // renderer-side teardown/purge as a normal remove. - options?: { - mode?: 'remove' | 'forget-local' - suppressPreservedBranchToast?: boolean - // Why (#11960): only an explicit Force Delete waives the proof that every - // PTY stopped; `force` alone is set by the ordinary delete confirmation. - allowUnverifiedPtyStop?: boolean - snapshotPruneBatchId?: string - } + options?: RemoveWorktreeOptions ) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void diff --git a/src/renderer/src/store/slices/worktree-removal-options.ts b/src/renderer/src/store/slices/worktree-removal-options.ts new file mode 100644 index 00000000000..1ec61493931 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-removal-options.ts @@ -0,0 +1,16 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export type RemoveWorktreeOptions = { + // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) + // for workspaces pinned to a removed/disconnected SSH host. Reuses the same + // renderer-side teardown/purge as a normal remove. + mode?: 'remove' | 'forget-local' + suppressPreservedBranchToast?: boolean + // Why (#11960): only an explicit Force Delete waives the proof that every + // PTY stopped; `force` alone is set by the ordinary delete confirmation. + allowUnverifiedPtyStop?: boolean + snapshotPruneBatchId?: string + // Why (STA-4343): `repoId::path` ids repeat across hosts — pin the removal to + // the confirmed host and fail closed if routing cannot land there. + requiredExecutionHostId?: ExecutionHostId +} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts index 2a04ebae503..dc53bd07e70 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts @@ -2,6 +2,10 @@ export const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 export const WORKTREE_REMOVAL_AMBIGUOUS_ERROR = 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' +// Why (STA-4343): the confirmed row names the host to delete on. If the route +// no longer lands there, deleting anyway destroys another host's workspace. +export const WORKTREE_REMOVAL_HOST_CHANGED_ERROR = + 'This workspace is no longer on the host you confirmed. Refresh and review it again.' export const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 export const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 export const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts new file mode 100644 index 00000000000..3cb8d92ba53 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts @@ -0,0 +1,256 @@ +/** + * STA-4343: a cleanup row is identified by `repoId::path`, which two execution + * hosts can both publish. Once a caller confirms ONE host's row, every step of + * the removal must stay pinned to that host — routing at the ACTIVE workspace's + * host instead deletes another machine's uncommitted work. + */ +import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' +import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' +import type { WorktreeSlice } from '../../worktree-helpers' +import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' +import { resolveWorkspaceCleanupRemovalHostId } from '../../../../../../shared/workspace-cleanup-host-identity' +import { + getWorktreeOperationOwnerHostIds, + resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteForHost, + type WorktreeOperationRoute +} from '@/lib/worktree-operation-route' +import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { getRepoIdFromWorktreeId } from '../../worktree-helpers' +import { + WORKTREE_REMOVAL_AMBIGUOUS_ERROR, + WORKTREE_REMOVAL_HOST_CHANGED_ERROR +} from '../listing/worktree-slice-constants' +import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' +import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' + +import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' +import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory' +import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target' +import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership' + +type PreservedBranchWorktree = Parameters[1] +type RemoveWorktreeSliceResult = Awaited> + +/** Route at the confirmed host when there is one, else the ordinary active-host route. */ +function resolveHostQualifiedRemovalRoute( + get: WorktreeSliceGet, + worktreeId: string, + requiredExecutionHostId: ExecutionHostId | undefined +): WorktreeOperationRoute | null { + return requiredExecutionHostId + ? resolveWorktreeOperationRouteForHost(get(), worktreeId, requiredExecutionHostId) + : resolveWorktreeOperationRoute(get(), worktreeId) +} + +export type HostQualifiedRemovalStart = + | { ok: false; error: string } + | { + ok: true + removalRoute: WorktreeOperationRoute | null + hostId: ExecutionHostId | undefined + removalGenerationGuard: ReturnType | null + sameIdSurvivesOnAnotherHost: boolean + } + +/** + * Resolve the route a removal may run on. A caller that confirmed one host's + * row fails closed unless the route still lands on exactly that host. + */ +export function beginHostQualifiedRemoval( + get: WorktreeSliceGet, + worktreeId: string, + requiredExecutionHostId: ExecutionHostId | undefined, + forgetLocalOnly: boolean +): HostQualifiedRemovalStart { + const resolveRemovalRoute = (): WorktreeOperationRoute | null => + resolveHostQualifiedRemovalRoute(get, worktreeId, requiredExecutionHostId) + const removalRoute = resolveRemovalRoute() + if (!forgetLocalOnly && !removalRoute) { + return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } + } + // Fail closed rather than delete on a host the caller never confirmed. + if ( + requiredExecutionHostId && + removalRoute && + removalRoute.executionHostId !== requiredExecutionHostId + ) { + return { ok: false, error: WORKTREE_REMOVAL_HOST_CHANGED_ERROR } + } + return { + ok: true, + removalRoute, + hostId: removalRoute?.executionHostId ?? requiredExecutionHostId ?? undefined, + removalGenerationGuard: removalRoute + ? captureWorktreeOperationGenerationGuard( + get, + worktreeId, + removalRoute, + () => + new Error( + requiredExecutionHostId + ? WORKTREE_REMOVAL_HOST_CHANGED_ERROR + : WORKTREE_REMOVAL_AMBIGUOUS_ERROR + ), + // Why: every mid-flight re-check must re-resolve at the CONFIRMED host, + // or the active host's route would read as "ownership changed". + requiredExecutionHostId ? resolveRemovalRoute : undefined + ) + : null, + sameIdSurvivesOnAnotherHost: preservesSameIdRendererState( + get(), + worktreeId, + requiredExecutionHostId + ) + } +} + +/** The row on the confirmed host only — a same-id row elsewhere must not stand in for it. */ +export function findWorktreeOnConfirmedHost( + get: WorktreeSliceGet, + worktreeId: string, + requiredExecutionHostId: ExecutionHostId | undefined +): PreservedBranchWorktree { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return get() + .allWorktrees() + .find( + (entry) => + entry.id === worktreeId && + (!requiredExecutionHostId || + worktreeMatchesHost( + entry, + requiredExecutionHostId, + worktreeHostMatchOptions(get(), repoId, requiredExecutionHostId) + )) + ) +} + +/** + * True when some OTHER host still owns this same id, so the shared renderer + * state (tabs, terminals, browsers, editor files) belongs to a workspace that + * is still alive and must survive this removal. + */ +export function preservesSameIdRendererState( + state: ReturnType, + worktreeId: string, + requiredExecutionHostId: ExecutionHostId | undefined +): boolean { + if (requiredExecutionHostId === undefined) { + return false + } + if ( + getWorktreeOperationOwnerHostIds(state, worktreeId).some( + (ownerHostId) => ownerHostId !== requiredExecutionHostId + ) + ) { + return true + } + if ( + state.activeWorktreeId === worktreeId && + state.activeWorkspaceExecutionHostId !== null && + state.activeWorkspaceExecutionHostId !== requiredExecutionHostId + ) { + return true + } + return ( + state.workspaceCleanupScan?.candidates.some((candidate) => { + const candidateHostId = resolveWorkspaceCleanupRemovalHostId(candidate) + return ( + candidate.worktreeId === worktreeId && + candidateHostId !== null && + candidateHostId !== requiredExecutionHostId + ) + }) ?? false + ) +} + +/** Drop only the confirmed host's row, leaving the surviving host's state whole. */ +function dropConfirmedHostRow( + set: WorktreeSliceSet, + worktreeId: string, + requiredExecutionHostId: ExecutionHostId +): void { + requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') + set((state) => { + const nextWorktreesByRepo = { ...state.worktreesByRepo } + for (const [candidateRepoId, worktrees] of Object.entries(nextWorktreesByRepo)) { + const matchOptions = worktreeHostMatchOptions(state, candidateRepoId, requiredExecutionHostId) + nextWorktreesByRepo[candidateRepoId] = worktrees.filter( + (worktree) => + worktree.id !== worktreeId || + !worktreeMatchesHost(worktree, requiredExecutionHostId, matchOptions) + ) + } + const nextDeleteState = { ...state.deleteStateByWorktreeId } + delete nextDeleteState[worktreeId] + return { + worktreesByRepo: nextWorktreesByRepo, + deleteStateByWorktreeId: nextDeleteState, + sortEpoch: state.sortEpoch + 1 + } + }) + if (parseExecutionHostId(requiredExecutionHostId)?.kind === 'ssh') { + rememberAuthoritativelyRemovedWorktrees(requiredExecutionHostId, [worktreeId]) + } +} + +/** + * Finish a removal whose id still exists on another host: prune just the + * confirmed host's row and keep the preserved-branch follow-up pinned to it. + */ +export function completeSameIdHostScopedRemoval(args: { + set: WorktreeSliceSet + get: WorktreeSliceGet + worktreeId: string + requiredExecutionHostId: ExecutionHostId + removalResult: RemoveWorktreeResult | undefined + removalRoute: WorktreeOperationRoute | null + target: ReturnType + worktreeBeforeRemoval: PreservedBranchWorktree + suppressPreservedBranchToast: boolean +}): RemoveWorktreeSliceResult { + const { + set, + get, + worktreeId, + requiredExecutionHostId, + removalResult, + removalRoute, + target, + worktreeBeforeRemoval, + suppressPreservedBranchToast + } = args + dropConfirmedHostRow(set, worktreeId, requiredExecutionHostId) + const preservedBranch = removalResult?.preservedBranch + if (!preservedBranch) { + return { ok: true as const } + } + const runtimeEnvironment = removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {} + const cleanup = { + worktreeId, + branchName: preservedBranch.branchName, + expectedHead: preservedBranch.head, + hostId: requiredExecutionHostId, + ...runtimeEnvironment + } + preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup), { + cleanup, + target + }) + if (!suppressPreservedBranchToast) { + showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { + void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, { + hostId: requiredExecutionHostId, + ...runtimeEnvironment + }) + }) + } + return { + ok: true as const, + preservedBranch: { ...preservedBranch, hostId: requiredExecutionHostId, ...runtimeEnvironment } + } +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index c3934c2c6a4..fcf1179e4f5 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -15,18 +15,21 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { - resolveWorktreeOperationRoute, resolveWorktreeOperationRouteResult, + resolveWorktreeOperationRouteResultForHost, settingsForWorktreeOperationRoute } from '@/lib/worktree-operation-route' -import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { + beginHostQualifiedRemoval, + completeSameIdHostScopedRemoval, + findWorktreeOnConfirmedHost +} from './host-qualified-worktree-removal' import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, isLockedWorktreeRemovalError } from '../../../../../../shared/worktree/removal' import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' -import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants' import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name' import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation' import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory' @@ -44,19 +47,19 @@ export function createRemoveWorktree( ): WorktreeSlice['removeWorktree'] { return async (worktreeId, force, options) => { const forgetLocalOnly = options?.mode === 'forget-local' - const removalRoute = resolveWorktreeOperationRoute(get(), worktreeId) - if (!forgetLocalOnly && !removalRoute) { - return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } + // Why (STA-4343): a qualified caller confirmed ONE host's row; route at that + // host instead of the active workspace's, which owns the same id elsewhere. + const requiredExecutionHostId = options?.requiredExecutionHostId + const start = beginHostQualifiedRemoval( + get, + worktreeId, + requiredExecutionHostId, + forgetLocalOnly + ) + if (!start.ok) { + return { ok: false, error: start.error } } - const hostId = removalRoute?.executionHostId ?? undefined - const removalGenerationGuard = removalRoute - ? captureWorktreeOperationGenerationGuard( - get, - worktreeId, - removalRoute, - () => new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) - ) - : null + const { removalRoute, hostId, removalGenerationGuard, sameIdSurvivesOnAnotherHost } = start set((s) => ({ deleteStateByWorktreeId: { ...s.deleteStateByWorktreeId, @@ -82,9 +85,11 @@ export function createRemoveWorktree( removalRoute?.runtimeEnvironmentId )) === 'skip' - const worktreeBeforeRemoval = get() - .allWorktrees() - .find((entry) => entry.id === worktreeId) + const worktreeBeforeRemoval = findWorktreeOnConfirmedHost( + get, + worktreeId, + requiredExecutionHostId + ) const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap( (tab) => get().ptyIdsByTabId[tab.id] ?? [] ) @@ -142,7 +147,9 @@ export function createRemoveWorktree( (isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error)) ) { // Missing means stale mirror; ambiguous or changed ownership must fail closed. - const currentResolution = resolveWorktreeOperationRouteResult(get(), worktreeId) + const currentResolution = requiredExecutionHostId + ? resolveWorktreeOperationRouteResultForHost(get(), worktreeId, requiredExecutionHostId) + : resolveWorktreeOperationRouteResult(get(), worktreeId) if (currentResolution.kind === 'ambiguous') { throw error } @@ -187,6 +194,23 @@ export function createRemoveWorktree( } } + // Why (STA-4343): another host still owns this id, so the shared renderer + // state (tabs, terminals, browsers) belongs to that live workspace — drop + // only the confirmed host's row instead of tearing all of it down. + if (sameIdSurvivesOnAnotherHost && requiredExecutionHostId) { + return completeSameIdHostScopedRemoval({ + set, + get, + worktreeId, + requiredExecutionHostId, + removalResult, + removalRoute, + target, + worktreeBeforeRemoval, + suppressPreservedBranchToast: options?.suppressPreservedBranchToast === true + }) + } + // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an diff --git a/src/shared/workspace-cleanup-host-identity.ts b/src/shared/workspace-cleanup-host-identity.ts index 545c813192b..36b11baf26b 100644 --- a/src/shared/workspace-cleanup-host-identity.ts +++ b/src/shared/workspace-cleanup-host-identity.ts @@ -1,8 +1,10 @@ import { + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, toSshExecutionHostId, type ExecutionHostId } from './execution-host' +import type { WorkspaceCleanupCandidate } from './workspace-cleanup' /** Host evidence a cleanup row carries; `worktreeId` alone repeats across hosts. */ export type WorkspaceCleanupHostFacts = { @@ -10,6 +12,22 @@ export type WorkspaceCleanupHostFacts = { executionHostId?: ExecutionHostId } +export type WorkspaceCleanupIdentityFacts = WorkspaceCleanupHostFacts & + Pick + +const WORKSPACE_CLEANUP_IDENTITY_SEPARATOR = '\0' + +export function getWorkspaceCleanupCandidateHostId( + candidate: WorkspaceCleanupHostFacts +): ExecutionHostId { + return ( + resolveWorkspaceCleanupRemovalHostId(candidate) ?? + // Why: display/index surfaces still need a bucket for a pre-host row; only + // the destructive path may not guess (see resolveWorkspaceCleanupRemovalHostId). + LOCAL_EXECUTION_HOST_ID + ) +} + /** * The owner a destructive removal is allowed to target, or null when the row * carries no host evidence at all (a snapshot or publisher that predates @@ -26,3 +44,24 @@ export function resolveWorkspaceCleanupRemovalHostId( const connectionId = candidate.connectionId?.trim() return connectionId ? toSshExecutionHostId(connectionId) : null } + +export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string { + return `${hostId}${WORKSPACE_CLEANUP_IDENTITY_SEPARATOR}${id}` +} + +/** Stable key for one row of one host — the identity every cleanup surface keys on. */ +export function getWorkspaceCleanupCandidateIdentity( + candidate: WorkspaceCleanupIdentityFacts +): string { + return getWorkspaceCleanupHostIdentity( + getWorkspaceCleanupCandidateHostId(candidate), + candidate.worktreeId + ) +} + +export function getWorkspaceCleanupIdentityWorktreeId(identity: string): string { + const separatorIndex = identity.indexOf(WORKSPACE_CLEANUP_IDENTITY_SEPARATOR) + return separatorIndex === -1 + ? identity + : identity.slice(separatorIndex + WORKSPACE_CLEANUP_IDENTITY_SEPARATOR.length) +} diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index 8e1945425c9..a1fd9293d8c 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -1,6 +1,7 @@ // Type-only, so the cycle back through workspace-cleanup-filter-model erases at build. import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state' import type { ExecutionHostId } from './execution-host' +import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity' export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2 export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000 @@ -38,6 +39,10 @@ export type WorkspaceCleanupDismissal = { dismissedAt: number fingerprint: string classifierVersion: number + // Why (STA-4343): `repoId::path` ids repeat across hosts, so ignoring one + // host's row must not hide another host's. Optional: a dismissal persisted + // before this field keeps its legacy id-only match. + executionHostId?: ExecutionHostId } export type WorkspaceCleanupUIState = { @@ -266,12 +271,19 @@ export function isWorkspaceOldForCleanup( } export function shouldHideWorkspaceCleanupCandidate( - candidate: Pick, + candidate: Pick< + WorkspaceCleanupCandidate, + 'worktreeId' | 'fingerprint' | 'connectionId' | 'executionHostId' + >, dismissal: WorkspaceCleanupDismissal | undefined ): boolean { return ( dismissal?.worktreeId === candidate.worktreeId && dismissal.fingerprint === candidate.fingerprint && - dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION + dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION && + // A host-qualified dismissal hides only its own host's row; a legacy one + // (no host recorded) keeps hiding every row that matches the fingerprint. + (dismissal.executionHostId === undefined || + dismissal.executionHostId === getWorkspaceCleanupCandidateHostId(candidate)) ) }