diff --git a/src/main/ipc/workspace-cleanup-process-preflight.test.ts b/src/main/ipc/workspace-cleanup-process-preflight.test.ts index dce5abfaf12..1c6b8e78c30 100644 --- a/src/main/ipc/workspace-cleanup-process-preflight.test.ts +++ b/src/main/ipc/workspace-cleanup-process-preflight.test.ts @@ -26,6 +26,12 @@ vi.mock('../workspace-cleanup-scan-snapshot', () => ({ readWorkspaceCleanupScanSnapshot: vi.fn(async () => null) })) +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(), + finishWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(async () => undefined), + recordWorkspaceCleanupRemovalSnapshotPrune: vi.fn() +})) + import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' function makeEmptyStore(): Store { diff --git a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts index ca5d05ee49c..79636d25029 100644 --- a/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts +++ b/src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts @@ -7,6 +7,12 @@ const { persistScanResultMock, readScanSnapshotMock } = vi.hoisted(() => ({ readScanSnapshotMock: vi.fn() })) +const { beginPruneBatchMock, finishPruneBatchMock, recordPruneMock } = vi.hoisted(() => ({ + beginPruneBatchMock: vi.fn(), + finishPruneBatchMock: vi.fn().mockResolvedValue(undefined), + recordPruneMock: vi.fn() +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -19,6 +25,12 @@ vi.mock('../workspace-cleanup-scan-snapshot', () => ({ readWorkspaceCleanupScanSnapshot: readScanSnapshotMock })) +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + beginWorkspaceCleanupRemovalSnapshotPruneBatch: beginPruneBatchMock, + finishWorkspaceCleanupRemovalSnapshotPruneBatch: finishPruneBatchMock, + recordWorkspaceCleanupRemovalSnapshotPrune: recordPruneMock +})) + import { registerWorkspaceCleanupHandlers } from './workspace-cleanup' const NOW = 1_700_000_000_000 @@ -38,6 +50,9 @@ describe('workspace cleanup snapshot IPC', () => { vi.mocked(ipcMain.handle).mockClear() persistScanResultMock.mockReset().mockResolvedValue(undefined) readScanSnapshotMock.mockReset() + beginPruneBatchMock.mockReset() + finishPruneBatchMock.mockReset().mockResolvedValue(undefined) + recordPruneMock.mockReset() }) it('persists the completed scan result after replying', async () => { @@ -75,4 +90,23 @@ describe('workspace cleanup snapshot IPC', () => { expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a') expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan') }) + + 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) + const batch = { batchId: 'batch-1' } + const target = { + ...batch, + worktreeId: 'repo-ssh::/remote/feature', + executionHostId: 'ssh:ssh-1' + } + + await handlers['workspaceCleanup:beginRemovalSnapshotPruneBatch']?.({} as never, batch) + await handlers['workspaceCleanup:recordRemovalSnapshotPrune']?.({} as never, target) + await handlers['workspaceCleanup:finishRemovalSnapshotPruneBatch']?.({} as never, batch) + + expect(beginPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + expect(recordPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', target) + expect(finishPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch) + }) }) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 1868eed22bf..91b7189cb67 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -10,13 +10,21 @@ import { type WorkspaceCleanupLocalProcessArgs, type WorkspaceCleanupLocalProcessResult, type WorkspaceCleanupScanArgs, - type WorkspaceCleanupScanResult + type WorkspaceCleanupScanResult, + type WorkspaceCleanupSnapshotPruneBatchArgs, + type WorkspaceCleanupSnapshotPruneRecordArgs } from '../../shared/workspace-cleanup' +import { parseExecutionHostId } from '../../shared/execution-host' import { scanWorkspaceCleanup } from './workspace-cleanup-scan' import { persistWorkspaceCleanupScanResult, readWorkspaceCleanupScanSnapshot } from '../workspace-cleanup-scan-snapshot' +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune +} from '../workspace-cleanup-removal-snapshot-prune' export { scanWorkspaceCleanup } @@ -35,6 +43,9 @@ export function registerWorkspaceCleanupHandlers( ipcMain.removeHandler('workspaceCleanup:dismiss') ipcMain.removeHandler('workspaceCleanup:clearDismissals') ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses') + ipcMain.removeHandler('workspaceCleanup:beginRemovalSnapshotPruneBatch') + ipcMain.removeHandler('workspaceCleanup:recordRemovalSnapshotPrune') + ipcMain.removeHandler('workspaceCleanup:finishRemovalSnapshotPruneBatch') ipcMain.handle( 'workspaceCleanup:scan', @@ -87,6 +98,43 @@ export function registerWorkspaceCleanupHandlers( hasKillableProcesses: await hasKillableProcesses(args, deps) }) ) + + ipcMain.handle( + 'workspaceCleanup:beginRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:recordRemovalSnapshotPrune', + (_event, args: WorkspaceCleanupSnapshotPruneRecordArgs) => { + if ( + isSnapshotPruneBatchId(args?.batchId) && + typeof args?.worktreeId === 'string' && + args.worktreeId.length > 0 && + (args.executionHostId === undefined || parseExecutionHostId(args.executionHostId)) + ) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, args) + } + } + ) + + ipcMain.handle( + 'workspaceCleanup:finishRemovalSnapshotPruneBatch', + (_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => { + if (isSnapshotPruneBatchId(args?.batchId)) { + return finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args) + } + return undefined + } + ) +} + +function isSnapshotPruneBatchId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 128 } async function hasKillableProcesses( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 0a9ba12072e..2de436f5255 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -234,17 +234,34 @@ vi.mock('../ports/advertised-url-watcher', () => ({ } })) -const { pruneCleanupScanSnapshotMock, pruneSpaceAnalysisSnapshotMock } = vi.hoisted(() => ({ +const { + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock +} = vi.hoisted(() => ({ pruneCleanupScanSnapshotMock: vi.fn().mockResolvedValue(undefined), - pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined) + pruneCleanupScanSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceAnalysisSnapshotsMock: vi.fn().mockResolvedValue(undefined) })) vi.mock('../workspace-cleanup-scan-snapshot', () => ({ - pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock + pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock })) vi.mock('../workspace-space-analysis-snapshot', () => ({ - pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock + pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock +})) + +const { recordRemovalSnapshotPruneMock } = vi.hoisted(() => ({ + recordRemovalSnapshotPruneMock: vi.fn() +})) + +vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({ + recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock })) const { @@ -420,6 +437,11 @@ describe('registerWorktreeHandlers', () => { getSshPtyProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, + pruneCleanupScanSnapshotMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotMock, + pruneSpaceAnalysisSnapshotsMock, + recordRemovalSnapshotPruneMock, findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPathsMock ]) { @@ -2721,6 +2743,10 @@ describe('registerWorktreeHandlers', () => { const metaById: Record> = { 'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }), 'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }), + 'repo-1::/remote/deleted-too': makeWorktreeMeta({ + displayName: 'deleted too', + hostId: sshHostId + }), 'repo-1::/remote/other-host': makeWorktreeMeta({ displayName: 'other host', hostId: toSshExecutionHostId('target-b') @@ -2738,18 +2764,33 @@ describe('registerWorktreeHandlers', () => { const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { repoId: sshRepo.id, executionHostId: sshHostId, - // Only the first is this host's row; the rest must survive an over-broad request. + // Only the first two are this host's rows; the rest must survive an over-broad request. worktreeIds: [ 'repo-1::/remote/deleted', + 'repo-1::/remote/deleted-too', 'repo-1::/remote/other-host', 'repo-2::/remote/other-repo', 'repo-1::/remote/never-persisted' ] }) - expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] }) - expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) + expect(forgotten).toEqual({ + forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too'] + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2) expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId) + const pruneTargets = [ + { worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId }, + { worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId } + ] + expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) + expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledExactlyOnceWith( + '/profile-a', + pruneTargets + ) // Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row. const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, { @@ -8327,6 +8368,25 @@ describe('registerWorktreeHandlers', () => { ) }) + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + snapshotPruneBatchId: 'cleanup-batch-1' + }) + + expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', { + batchId: 'cleanup-batch-1', + worktreeId: 'repo-1::/workspace/feature-wt', + executionHostId: 'local' + }) + expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled() + }) + it('traces the removal as worktree.remove with a stage sub-span tree', async () => { const records: RedactableSpan[] = [] setActiveSink({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 180affdaedf..1b504715c4f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,8 +17,15 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { pruneWorkspaceCleanupScanSnapshot } from '../workspace-cleanup-scan-snapshot' -import { pruneWorkspaceSpaceAnalysisSnapshot } from '../workspace-space-analysis-snapshot' +import { + pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots +} from '../workspace-cleanup-scan-snapshot' +import { + pruneWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from '../workspace-space-analysis-snapshot' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' import type { AutomationWorkspaceProvenance, CliWorkspaceProvenance, @@ -158,6 +165,7 @@ type RemoveWorktreeArgs = { /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string } type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs @@ -322,7 +330,8 @@ function resolveWorktreeRemovalOwnerHostId( function removeWorktreeMetadataAndTransientState( store: Store, worktreeId: string, - hostId?: ExecutionHostId + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string ): void { // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. if (hostId) { @@ -339,6 +348,14 @@ function removeWorktreeMetadataAndTransientState( pruneWorktreePRRefreshAliases(worktreeId) // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) } @@ -2065,6 +2082,15 @@ export function registerWorktreeHandlers( store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) forgottenWorktreeIds.push(worktreeId) } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } return { forgottenWorktreeIds } } ) @@ -2423,7 +2449,12 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) @@ -2535,7 +2566,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2585,7 +2621,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2622,7 +2663,12 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2682,7 +2728,12 @@ export function registerWorktreeHandlers( removedPushTarget ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2781,7 +2832,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2927,7 +2983,12 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) preservedBranchCleanupByScope.delete( preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, @@ -2964,7 +3025,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() @@ -2996,7 +3062,7 @@ export function registerWorktreeHandlers( 'worktrees:forgetLocal', async ( _event, - args: Pick + args: Pick ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) @@ -3058,7 +3124,12 @@ export function registerWorktreeHandlers( runtime.clearOptimisticReconcileToken(args.worktreeId) // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() if (ownerHost?.id) { diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.test.ts b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts new file mode 100644 index 00000000000..466476b10c3 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + finalizeCleanupSnapshotsMock, + finalizeSpaceSnapshotsMock, + pruneCleanupSnapshotsMock, + pruneSpaceSnapshotsMock, + registerCleanupTombstonesMock, + registerSpaceTombstonesMock +} = vi.hoisted(() => ({ + finalizeCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + finalizeSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneCleanupSnapshotsMock: vi.fn().mockResolvedValue(undefined), + pruneSpaceSnapshotsMock: vi.fn().mockResolvedValue(undefined), + registerCleanupTombstonesMock: vi.fn(), + registerSpaceTombstonesMock: vi.fn() +})) + +vi.mock('./workspace-cleanup-scan-snapshot', () => ({ + finalizeWorkspaceCleanupScanSnapshotPrunes: finalizeCleanupSnapshotsMock, + pruneWorkspaceCleanupScanSnapshots: pruneCleanupSnapshotsMock, + registerWorkspaceCleanupScanSnapshotPruneTombstones: registerCleanupTombstonesMock +})) + +vi.mock('./workspace-space-analysis-snapshot', () => ({ + finalizeWorkspaceSpaceAnalysisSnapshotPrunes: finalizeSpaceSnapshotsMock, + pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceSnapshotsMock, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones: registerSpaceTombstonesMock +})) + +import { + beginWorkspaceCleanupRemovalSnapshotPruneBatch, + finishWorkspaceCleanupRemovalSnapshotPruneBatch, + recordWorkspaceCleanupRemovalSnapshotPrune, + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests +} from './workspace-cleanup-removal-snapshot-prune' + +describe('workspace cleanup removal snapshot prune batch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers every sequential tombstone immediately and flushes once per sidecar', async () => { + const snapshotDirectory = '/profile-a' + const batch = { batchId: 'batch-1' } + const targets = Array.from({ length: 100 }, (_, index) => ({ + worktreeId: `repo-${index % 10}::/workspace-${index}`, + executionHostId: index % 2 === 0 ? ('local' as const) : (`ssh:ssh-${index % 3}` as const) + })) + beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + for (const target of targets) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...target }) + } + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { ...batch, ...targets[0] }) + + expect(registerCleanupTombstonesMock).toHaveBeenCalledTimes(100) + expect(registerSpaceTombstonesMock).toHaveBeenCalledTimes(100) + expect(finalizeCleanupSnapshotsMock).not.toHaveBeenCalled() + expect(finalizeSpaceSnapshotsMock).not.toHaveBeenCalled() + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith(snapshotDirectory, targets) + }) + + it('falls back to immediate best-effort pruning when no batch is active', () => { + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'runtime:vm-1' as const } + + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { + batchId: 'missing-batch', + ...target + }) + + expect(pruneCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(pruneSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + }) + + it('finalizes only the matching profile and batch scope', async () => { + const batch = { batchId: 'shared-id' } + const profileATarget = { worktreeId: 'repo-a::/workspace', executionHostId: 'local' as const } + const profileBTarget = { + worktreeId: 'repo-b::/workspace', + executionHostId: 'ssh:ssh-1' as const + } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...profileATarget }) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-b', { ...batch, ...profileBTarget }) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [ + profileATarget + ]) + + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-b', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenNthCalledWith(2, '/profile-b', [profileBTarget]) + }) + + it('expires and finalizes an unfinished idle batch', async () => { + vi.useFakeTimers() + const batch = { batchId: 'abandoned-batch' } + const target = { worktreeId: 'repo-1::/workspace', executionHostId: 'local' as const } + beginWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + recordWorkspaceCleanupRemovalSnapshotPrune('/profile-a', { ...batch, ...target }) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledExactlyOnceWith('/profile-a', [target]) + await finishWorkspaceCleanupRemovalSnapshotPruneBatch('/profile-a', batch) + expect(finalizeCleanupSnapshotsMock).toHaveBeenCalledTimes(1) + expect(finalizeSpaceSnapshotsMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/workspace-cleanup-removal-snapshot-prune.ts b/src/main/workspace-cleanup-removal-snapshot-prune.ts new file mode 100644 index 00000000000..9b4244400c6 --- /dev/null +++ b/src/main/workspace-cleanup-removal-snapshot-prune.ts @@ -0,0 +1,138 @@ +import type { + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs +} from '../shared/workspace-cleanup' +import { + finalizeWorkspaceCleanupScanSnapshotPrunes, + pruneWorkspaceCleanupScanSnapshots, + registerWorkspaceCleanupScanSnapshotPruneTombstones +} from './workspace-cleanup-scan-snapshot' +import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, + pruneWorkspaceSpaceAnalysisSnapshots, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones +} from './workspace-space-analysis-snapshot' + +type SnapshotPruneTarget = Omit + +type SnapshotPruneBatch = { + targets: Map + expirationTimer?: ReturnType +} + +const batches = new Map() +const SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS = 5 * 60_000 + +function batchKey(snapshotDirectory: string, batchId: string): string { + return `${snapshotDirectory}\0${batchId}` +} + +function targetKey(target: SnapshotPruneTarget): string { + return `${target.executionHostId ?? '*'}\0${target.worktreeId}` +} + +function scheduleBatchExpiration( + snapshotDirectory: string, + batchId: string, + batch: SnapshotPruneBatch +): ReturnType { + const timer = setTimeout(() => { + const expired = takeBatch(snapshotDirectory, batchId, batch) + if (expired) { + void finalizeBatch(snapshotDirectory, expired) + } + }, SNAPSHOT_PRUNE_BATCH_IDLE_TIMEOUT_MS) + timer.unref() + return timer +} + +function takeBatch( + snapshotDirectory: string, + batchId: string, + expected?: SnapshotPruneBatch +): SnapshotPruneBatch | undefined { + const key = batchKey(snapshotDirectory, batchId) + const batch = batches.get(key) + if (!batch || (expected && batch !== expected)) { + return undefined + } + batches.delete(key) + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + return batch +} + +async function finalizeBatch(snapshotDirectory: string, batch: SnapshotPruneBatch): Promise { + const targets = [...batch.targets.values()] + await Promise.all([ + finalizeWorkspaceCleanupScanSnapshotPrunes(snapshotDirectory, targets), + finalizeWorkspaceSpaceAnalysisSnapshotPrunes(snapshotDirectory, targets) + ]) +} + +export function beginWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): void { + const key = batchKey(snapshotDirectory, args.batchId) + const existing = batches.get(key) + if (existing) { + if (existing.expirationTimer) { + clearTimeout(existing.expirationTimer) + } + existing.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, existing) + return + } + const batch: SnapshotPruneBatch = { targets: new Map() } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) + batches.set(key, batch) +} + +/** Tombstone now; defer the two durable sidecar rewrites until the batch closes. */ +export function recordWorkspaceCleanupRemovalSnapshotPrune( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneRecordArgs +): void { + const target = { + worktreeId: args.worktreeId, + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}) + } + const batch = batches.get(batchKey(snapshotDirectory, args.batchId)) + if (!batch) { + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [target]) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [target]) + return + } + + const key = targetKey(target) + if (!batch.targets.has(key)) { + registerWorkspaceCleanupScanSnapshotPruneTombstones(snapshotDirectory, [target]) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(snapshotDirectory, [target]) + batch.targets.set(key, target) + } + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + batch.expirationTimer = scheduleBatchExpiration(snapshotDirectory, args.batchId, batch) +} + +export async function finishWorkspaceCleanupRemovalSnapshotPruneBatch( + snapshotDirectory: string, + args: WorkspaceCleanupSnapshotPruneBatchArgs +): Promise { + const batch = takeBatch(snapshotDirectory, args.batchId) + if (!batch) { + return + } + await finalizeBatch(snapshotDirectory, batch) +} + +export function resetWorkspaceCleanupRemovalSnapshotPruneBatchesForTests(): void { + for (const batch of batches.values()) { + if (batch.expirationTimer) { + clearTimeout(batch.expirationTimer) + } + } + batches.clear() +} diff --git a/src/main/workspace-cleanup-scan-snapshot.test.ts b/src/main/workspace-cleanup-scan-snapshot.test.ts index 2398fcef7ab..ca5fa7edffe 100644 --- a/src/main/workspace-cleanup-scan-snapshot.test.ts +++ b/src/main/workspace-cleanup-scan-snapshot.test.ts @@ -7,12 +7,33 @@ import type { WorkspaceCleanupScanResult } from '../shared/workspace-cleanup' -const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } })) +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) import { + finalizeWorkspaceCleanupScanSnapshotPrunes, persistWorkspaceCleanupScanResult, pruneWorkspaceCleanupScanSnapshot, + pruneWorkspaceCleanupScanSnapshots, readWorkspaceCleanupScanSnapshot, + registerWorkspaceCleanupScanSnapshotPruneTombstones, workspaceCleanupScanSnapshotFingerprint } from './workspace-cleanup-scan-snapshot' @@ -56,6 +77,7 @@ function makeBroadResult(candidates: WorkspaceCleanupCandidate[]): WorkspaceClea describe('workspace cleanup scan snapshot', () => { beforeEach(async () => { + snapshotWriteSpy.mockClear() userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-cleanup-snapshot-')) }) @@ -242,14 +264,67 @@ describe('workspace cleanup scan snapshot', () => { makeBroadResult([makeCandidate(), kept]) ) + snapshotWriteSpy.mockClear() await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local') const snapshot = await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir) expect(snapshot?.candidates).toEqual([kept]) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(snapshot) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces host-scoped local and remote prunes into one write', async () => { + const localCollision = makeCandidate() + const remoteCollision = makeCandidate({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + const localRemoved = makeCandidate({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed' + }) + const remoteRemoved = makeCandidate({ + worktreeId: 'repo-1::/remote-removed', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-removed' + }) + const kept = makeCandidate({ worktreeId: 'repo-1::/kept', path: '/kept' }) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + result: expect.objectContaining({ candidates: [remoteCollision, kept] }) + }) + ) + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + candidates: [remoteCollision, kept] + }) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() }) it('keeps profile snapshots isolated', async () => { @@ -267,12 +342,22 @@ describe('workspace cleanup scan snapshot', () => { } }) - it('does not let a scan started before removal restore the pruned row', async () => { + it('does not let a scan started before a bulk removal restore pruned rows', async () => { + const local = makeCandidate() + const remote = makeCandidate({ + worktreeId: 'repo-1::/remote-feature', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + path: '/remote-feature' + }) const staleResult = { - ...makeBroadResult([makeCandidate()]), + ...makeBroadResult([local, remote]), scannedAt: Date.now() - 1 } - await pruneWorkspaceCleanupScanSnapshot(userDataDirHolder.dir, 'repo-1::/repo-feature', 'local') + await pruneWorkspaceCleanupScanSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) await persistWorkspaceCleanupScanResult( userDataDirHolder.dir, @@ -288,10 +373,47 @@ describe('workspace cleanup scan snapshot', () => { recreated ) expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ - makeCandidate() + local, + remote ]) }) + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleResult = { ...makeBroadResult([makeCandidate()]), scannedAt: Date.now() - 1 } + + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + staleResult + ) + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const candidate = makeCandidate() + const target = { worktreeId: candidate.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceCleanupScanSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceCleanupScanSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + { ...makeBroadResult([candidate]), scannedAt: 150 } + ) + + expect((await readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir))?.candidates).toEqual([ + candidate + ]) + now.mockRestore() + }) + it('patches and prunes host-colliding workspace ids independently', async () => { const local = makeCandidate() const remote = makeCandidate({ diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts index a0cd91e3bfd..2f7e50e0fe4 100644 --- a/src/main/workspace-cleanup-scan-snapshot.ts +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -11,17 +11,21 @@ import { writeSidecarSnapshot } from './sidecar-snapshot-file' import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' const SNAPSHOT_FILE_NAME = 'orca-workspace-cleanup-scan.json' const SNAPSHOT_VERSION = 2 -type PrunedWorkspace = { - worktreeId: string - executionHostId?: ExecutionHostId - prunedAt: number -} +export type WorkspaceCleanupScanSnapshotPruneTarget = WorkspaceSnapshotPruneTarget -const prunedWorkspacesByFile = new Map>() +const prunedWorkspacesByFile = new Map>() type PersistedWorkspaceCleanupScanSnapshot = { version: number @@ -123,17 +127,18 @@ function candidateSnapshotKey( return `${candidate.executionHostId ?? 'local'}\0${candidate.worktreeId}` } -function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string { - return `${executionHostId ?? '*'}\0${worktreeId}` -} - -function matchesPrunedWorkspace( - candidate: WorkspaceCleanupCandidate, - pruned: PrunedWorkspace -): boolean { - return ( - candidate.worktreeId === pruned.worktreeId && - (!pruned.executionHostId || candidate.executionHostId === pruned.executionHostId) +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceCleanupScanSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets ) } @@ -141,15 +146,17 @@ function excludeRowsPrunedDuringScan( file: string, result: WorkspaceCleanupScanResult ): WorkspaceCleanupScanResult { - const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])] - if (pruned.length === 0) { + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + result.scannedAt + ) + if (prunedKeys.size === 0) { return result } const candidates = result.candidates.filter( (candidate) => - !pruned.some( - (entry) => entry.prunedAt >= result.scannedAt && matchesPrunedWorkspace(candidate, entry) - ) + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId)) && + !prunedKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) ) return candidates.length === result.candidates.length ? result : { ...result, candidates } } @@ -163,11 +170,16 @@ function clearSupersededPrunes( if (!pruned) { return } + const candidateKeys = broad + ? undefined + : new Set( + result.candidates.flatMap((candidate) => [ + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId), + workspaceSnapshotPruneKey(candidate.worktreeId) + ]) + ) for (const [key, entry] of pruned) { - if ( - entry.prunedAt < result.scannedAt && - (broad || result.candidates.some((candidate) => matchesPrunedWorkspace(candidate, entry))) - ) { + if (entry.prunedAt < result.scannedAt && (broad || candidateKeys?.has(key))) { pruned.delete(key) } } @@ -215,30 +227,37 @@ export async function persistWorkspaceCleanupScanResult( } } -/** Drop a removed workspace so it never resurrects from cache. Never throws. */ -export async function pruneWorkspaceCleanupScanSnapshot( +async function pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( snapshotDirectory: string, - worktreeId: string, - executionHostId?: ExecutionHostId + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[], + registerTombstones: boolean ): Promise { + if (targets.length === 0) { + return + } const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) - const pruned = prunedWorkspacesByFile.get(file) ?? new Map() - pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), { - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - prunedAt: Date.now() - }) - prunedWorkspacesByFile.set(file, pruned) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } try { await withSidecarSnapshotQueue(file, async () => { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { + return + } const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) if (!existing) { return } const candidates = existing.candidates.filter( (candidate) => - candidate.worktreeId !== worktreeId || - (executionHostId !== undefined && candidate.executionHostId !== executionHostId) + !coalescedTargetKeys.has( + workspaceSnapshotPruneKey(candidate.worktreeId, candidate.executionHostId) + ) && !coalescedTargetKeys.has(workspaceSnapshotPruneKey(candidate.worktreeId)) ) if (candidates.length === existing.candidates.length) { return @@ -249,3 +268,32 @@ export async function pruneWorkspaceCleanupScanSnapshot( console.warn('[workspace-cleanup] failed to prune scan snapshot:', error) } } + +/** Drop removed workspaces in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones(snapshotDirectory, targets, true) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceCleanupScanSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceCleanupScanSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceCleanupScanSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop a removed workspace so it never resurrects from cache. Never throws. */ +export async function pruneWorkspaceCleanupScanSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/main/workspace-snapshot-prune-index.ts b/src/main/workspace-snapshot-prune-index.ts new file mode 100644 index 00000000000..56b4289335b --- /dev/null +++ b/src/main/workspace-snapshot-prune-index.ts @@ -0,0 +1,57 @@ +import type { ExecutionHostId } from '../shared/execution-host' + +export type WorkspaceSnapshotPruneTarget = { + worktreeId: string + executionHostId?: ExecutionHostId +} + +export type WorkspaceSnapshotPruneTombstone = WorkspaceSnapshotPruneTarget & { + prunedAt: number +} + +export function workspaceSnapshotPruneKey( + worktreeId: string, + executionHostId?: ExecutionHostId +): string { + return `${executionHostId ?? '*'}\0${worktreeId}` +} + +export function workspaceSnapshotPruneTargetKeys( + targets: readonly WorkspaceSnapshotPruneTarget[] +): Set { + return new Set( + targets.map(({ worktreeId, executionHostId }) => + workspaceSnapshotPruneKey(worktreeId, executionHostId) + ) + ) +} + +export function activeWorkspaceSnapshotPruneKeys( + tombstones: ReadonlyMap | undefined, + scannedAt: number +): Set { + const keys = new Set() + for (const [key, entry] of tombstones ?? []) { + if (entry.prunedAt >= scannedAt) { + keys.add(key) + } + } + return keys +} + +export function registerWorkspaceSnapshotPrunesForFile( + tombstonesByFile: Map>, + file: string, + targets: readonly WorkspaceSnapshotPruneTarget[] +): void { + const tombstones = tombstonesByFile.get(file) ?? new Map() + const prunedAt = Date.now() + for (const { worktreeId, executionHostId } of targets) { + tombstones.set(workspaceSnapshotPruneKey(worktreeId, executionHostId), { + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + prunedAt + }) + } + tombstonesByFile.set(file, tombstones) +} diff --git a/src/main/workspace-snapshot-pruning.bench.test.ts b/src/main/workspace-snapshot-pruning.bench.test.ts new file mode 100644 index 00000000000..0523894265b --- /dev/null +++ b/src/main/workspace-snapshot-pruning.bench.test.ts @@ -0,0 +1,153 @@ +// Opt in: ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/workspace-snapshot-pruning.bench.test.ts +import { performance } from 'node:perf_hooks' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { WorkspaceCleanupCandidate } from '../shared/workspace-cleanup' +import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { + persistWorkspaceCleanupScanResult, + pruneWorkspaceCleanupScanSnapshots +} from './workspace-cleanup-scan-snapshot' +import { + persistWorkspaceSpaceAnalysisSnapshot, + pruneWorkspaceSpaceAnalysisSnapshots +} from './workspace-space-analysis-snapshot' + +const describeBench = process.env.ORCA_WORKSPACE_SNAPSHOT_PRUNE_BENCH ? describe : describe.skip +const ROW_COUNT = 610 +const DELETE_COUNT = 100 + +describeBench('workspace snapshot bulk pruning', () => { + let snapshotDirectory = '' + + beforeAll(async () => { + snapshotDirectory = await mkdtemp(join(tmpdir(), 'orca-snapshot-prune-bench-')) + }) + + afterAll(async () => { + await rm(snapshotDirectory, { recursive: true, force: true }) + }) + + it('prunes the measured 610-row/100-delete shape with one write per sidecar', async () => { + const candidates = Array.from({ length: ROW_COUNT }, (_, index) => makeCandidate(index)) + const worktrees = Array.from({ length: ROW_COUNT }, (_, index) => makeWorktree(index)) + const targets = candidates.slice(0, DELETE_COUNT).map((candidate) => ({ + worktreeId: candidate.worktreeId, + executionHostId: candidate.executionHostId + })) + await persistWorkspaceCleanupScanResult( + snapshotDirectory, + { includeAllWorkspaces: true }, + { scannedAt: 1, candidates, errors: [] } + ) + await persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, { + scannedAt: 1, + totalSizeBytes: ROW_COUNT * 1_000, + reclaimableBytes: ROW_COUNT * 1_000, + worktreeCount: ROW_COUNT, + scannedWorktreeCount: ROW_COUNT, + unavailableWorktreeCount: 0, + repos: makeRepoSummaries(worktrees), + worktrees + }) + + const startedAt = performance.now() + await Promise.all([ + pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets), + pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + ]) + const pruneMs = performance.now() - startedAt + const cleanupPayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-cleanup-scan.json'), 'utf-8') + ) as { result: { candidates: unknown[] } } + const spacePayload = JSON.parse( + await readFile(join(snapshotDirectory, 'orca-workspace-space-analysis.json'), 'utf-8') + ) as { analysis: { worktrees: unknown[] } } + + console.log( + `[bench] rows=${ROW_COUNT} deleted=${DELETE_COUNT} bulkPrune=${pruneMs.toFixed(2)}ms` + ) + expect(cleanupPayload.result.candidates).toHaveLength(ROW_COUNT - DELETE_COUNT) + expect(spacePayload.analysis.worktrees).toHaveLength(ROW_COUNT - DELETE_COUNT) + }) +}) + +function executionHostId(index: number): 'local' | `ssh:${string}` { + return index % 3 === 0 ? `ssh:ssh-${index % 7}` : 'local' +} + +function makeCandidate(index: number): WorkspaceCleanupCandidate { + const hostId = executionHostId(index) + return { + worktreeId: `repo-${index % 20}::/workspace-${index}`, + repoId: `repo-${index % 20}`, + repoName: `Repo ${index % 20}`, + connectionId: hostId === 'local' ? null : hostId.slice('ssh:'.length), + executionHostId: hostId, + displayName: `Workspace ${index}`, + branch: `branch-${index}`, + path: `/workspace-${index}`, + 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: 0 }, + fingerprint: `fingerprint-${index}` + } +} + +function makeWorktree(index: number): WorkspaceSpaceWorktree { + const candidate = makeCandidate(index) + return { + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + executionHostId: candidate.executionHostId, + repoDisplayName: candidate.repoName, + repoPath: `/repo-${index % 20}`, + displayName: candidate.displayName, + path: candidate.path, + branch: candidate.branch, + isMainWorktree: false, + isRemote: candidate.executionHostId !== 'local', + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 1, + sizeBytes: 1_000, + reclaimableBytes: 1_000, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +function makeRepoSummaries(worktrees: WorkspaceSpaceWorktree[]) { + const groups = Map.groupBy(worktrees, (row) => `${row.executionHostId ?? 'local'}\0${row.repoId}`) + return [...groups.values()].map((rows) => ({ + repoId: rows[0].repoId, + executionHostId: rows[0].executionHostId, + displayName: rows[0].repoDisplayName, + path: rows[0].repoPath, + isRemote: rows[0].isRemote, + worktreeCount: rows.length, + scannedWorktreeCount: rows.length, + unavailableWorktreeCount: 0, + totalSizeBytes: rows.length * 1_000, + reclaimableBytes: rows.length * 1_000, + error: null + })) +} diff --git a/src/main/workspace-space-analysis-snapshot.test.ts b/src/main/workspace-space-analysis-snapshot.test.ts index 1a5f7e30902..a6d870674e3 100644 --- a/src/main/workspace-space-analysis-snapshot.test.ts +++ b/src/main/workspace-space-analysis-snapshot.test.ts @@ -7,12 +7,33 @@ import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' -const { userDataDirHolder } = vi.hoisted(() => ({ userDataDirHolder: { dir: '' } })) +const { snapshotWriteSpy, userDataDirHolder } = vi.hoisted(() => ({ + snapshotWriteSpy: vi.fn(), + userDataDirHolder: { dir: '' } +})) + +vi.mock('./sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal>() + const writeSidecarSnapshot = actual.writeSidecarSnapshot as ( + file: string, + payload: unknown + ) => Promise + return { + ...actual, + writeSidecarSnapshot: async (file: string, payload: unknown) => { + snapshotWriteSpy(file, payload) + await writeSidecarSnapshot(file, payload) + } + } +}) import { + finalizeWorkspaceSpaceAnalysisSnapshotPrunes, persistWorkspaceSpaceAnalysisSnapshot, pruneWorkspaceSpaceAnalysisSnapshot, - readWorkspaceSpaceAnalysisSnapshot + pruneWorkspaceSpaceAnalysisSnapshots, + readWorkspaceSpaceAnalysisSnapshot, + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones } from './workspace-space-analysis-snapshot' const SNAPSHOT_FILE = 'orca-workspace-space-analysis.json' @@ -78,6 +99,7 @@ function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalys describe('workspace space analysis snapshot', () => { beforeEach(async () => { + snapshotWriteSpy.mockClear() userDataDirHolder.dir = await mkdtemp(join(tmpdir(), 'orca-space-snapshot-')) }) @@ -178,6 +200,7 @@ describe('workspace space analysis snapshot', () => { makeAnalysis([removed, kept]) ) + snapshotWriteSpy.mockClear() await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, removed.worktreeId, 'local') const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) @@ -196,10 +219,86 @@ describe('workspace space analysis snapshot', () => { totalSizeBytes: 1000, reclaimableBytes: 0 }) + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) // Unknown ids are a no-op. + snapshotWriteSpy.mockClear() await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, 'repo-1::/never-existed') await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toEqual(cached) + expect(snapshotWriteSpy).not.toHaveBeenCalled() + }) + + it('coalesces local and remote row rebalancing into one write', async () => { + const localCollision = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remoteCollision = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 2000, + reclaimableBytes: 2000 + }) + const localRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/local-removed', + path: '/local-removed', + status: 'missing', + sizeBytes: 3000, + reclaimableBytes: 0 + }) + const remoteRemoved = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-removed', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-removed', + sizeBytes: 4000, + reclaimableBytes: 4000 + }) + const kept = makeWorktreeRow({ + worktreeId: 'repo-1::/kept', + path: '/kept', + sizeBytes: 500, + reclaimableBytes: 500 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([localCollision, remoteCollision, localRemoved, remoteRemoved, kept]) + ) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: localCollision.worktreeId, executionHostId: 'local' }, + { worktreeId: localRemoved.worktreeId, executionHostId: 'local' }, + { worktreeId: remoteRemoved.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) + + expect(snapshotWriteSpy).toHaveBeenCalledTimes(1) + expect(snapshotWriteSpy).toHaveBeenCalledWith( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + expect.objectContaining({ + analysis: expect.objectContaining({ + worktrees: [remoteCollision, kept], + worktreeCount: 2, + scannedWorktreeCount: 2, + unavailableWorktreeCount: 0, + totalSizeBytes: 2500, + reclaimableBytes: 2500 + }) + }) + ) + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.repos).toEqual([ + expect.objectContaining({ executionHostId: 'local', worktreeCount: 1, totalSizeBytes: 500 }), + expect.objectContaining({ + executionHostId: 'ssh:ssh-1', + worktreeCount: 1, + totalSizeBytes: 2000 + }) + ]) + + snapshotWriteSpy.mockClear() + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/missing-local', executionHostId: 'local' }, + { worktreeId: 'repo-1::/missing-remote', executionHostId: 'ssh:ssh-1' } + ]) + expect(snapshotWriteSpy).not.toHaveBeenCalled() }) it('keeps profile snapshots isolated', async () => { @@ -216,16 +315,22 @@ describe('workspace space analysis snapshot', () => { } }) - it('does not let an analysis started before removal restore the pruned row', async () => { + it('does not let an analysis started before bulk removal restore pruned rows', async () => { + const local = makeWorktreeRow() + const remote = makeWorktreeRow({ + worktreeId: 'repo-1::/remote-feature', + executionHostId: 'ssh:ssh-1', + isRemote: true, + path: '/remote-feature' + }) const staleAnalysis = { - ...makeAnalysis([makeWorktreeRow()]), + ...makeAnalysis([local, remote]), scannedAt: Date.now() - 1 } - await pruneWorkspaceSpaceAnalysisSnapshot( - userDataDirHolder.dir, - 'repo-1::/repo-feature', - 'local' - ) + await pruneWorkspaceSpaceAnalysisSnapshots(userDataDirHolder.dir, [ + { worktreeId: local.worktreeId, executionHostId: 'local' }, + { worktreeId: remote.worktreeId, executionHostId: 'ssh:ssh-1' } + ]) await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) @@ -233,10 +338,42 @@ describe('workspace space analysis snapshot', () => { const recreated = { ...staleAnalysis, scannedAt: Date.now() + 1 } await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, recreated) expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ - makeWorktreeRow() + local, + remote ]) }) + it('registers a tombstone immediately without rewriting the sidecar', async () => { + const staleAnalysis = { ...makeAnalysis([makeWorktreeRow()]), scannedAt: Date.now() - 1 } + + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [ + { worktreeId: 'repo-1::/repo-feature', executionHostId: 'local' } + ]) + + expect(snapshotWriteSpy).not.toHaveBeenCalled() + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, staleAnalysis) + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([]) + }) + + it('keeps the original tombstone time when a removal batch flushes', async () => { + const row = makeWorktreeRow() + const target = { worktreeId: row.worktreeId, executionHostId: 'local' as const } + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + registerWorkspaceSpaceAnalysisSnapshotPruneTombstones(userDataDirHolder.dir, [target]) + now.mockReturnValue(200) + + await finalizeWorkspaceSpaceAnalysisSnapshotPrunes(userDataDirHolder.dir, [target]) + await persistWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, { + ...makeAnalysis([row]), + scannedAt: 150 + }) + + expect((await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir))?.worktrees).toEqual([ + { ...row, topLevelItems: [] } + ]) + now.mockRestore() + }) + it('prunes host-colliding workspace ids without corrupting surviving totals', async () => { const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) const remote = makeWorktreeRow({ diff --git a/src/main/workspace-space-analysis-snapshot.ts b/src/main/workspace-space-analysis-snapshot.ts index e1210865079..8118095d396 100644 --- a/src/main/workspace-space-analysis-snapshot.ts +++ b/src/main/workspace-space-analysis-snapshot.ts @@ -9,17 +9,21 @@ import { writeSidecarSnapshot } from './sidecar-snapshot-file' import type { ExecutionHostId } from '../shared/execution-host' +import { + activeWorkspaceSnapshotPruneKeys, + registerWorkspaceSnapshotPrunesForFile, + workspaceSnapshotPruneKey, + workspaceSnapshotPruneTargetKeys, + type WorkspaceSnapshotPruneTarget, + type WorkspaceSnapshotPruneTombstone +} from './workspace-snapshot-prune-index' const SNAPSHOT_FILE_NAME = 'orca-workspace-space-analysis.json' const SNAPSHOT_VERSION = 2 -type PrunedWorkspace = { - worktreeId: string - executionHostId?: ExecutionHostId - prunedAt: number -} +export type WorkspaceSpaceAnalysisSnapshotPruneTarget = WorkspaceSnapshotPruneTarget -const prunedWorkspacesByFile = new Map>() +const prunedWorkspacesByFile = new Map>() type PersistedWorkspaceSpaceAnalysisSnapshot = { version: number @@ -117,50 +121,103 @@ export async function persistWorkspaceSpaceAnalysisSnapshot( } } -function withoutWorktreeRow( +function withoutWorktreeRows( analysis: WorkspaceSpaceAnalysis, - removed: WorkspaceSpaceWorktree + shouldRemove: (row: WorkspaceSpaceWorktree) => boolean ): WorkspaceSpaceAnalysis { - const scannedDelta = removed.status === 'ok' ? 1 : 0 - const unavailableDelta = removed.status === 'ok' ? 0 : 1 + const worktrees: WorkspaceSpaceWorktree[] = [] + const removedByRepo = new Map< + string, + { + worktreeCount: number + scannedWorktreeCount: number + unavailableWorktreeCount: number + totalSizeBytes: number + reclaimableBytes: number + } + >() + let removedCount = 0 + let scannedDelta = 0 + let unavailableDelta = 0 + let totalSizeDelta = 0 + let reclaimableDelta = 0 + + for (const row of analysis.worktrees) { + if (!shouldRemove(row)) { + worktrees.push(row) + continue + } + const scanned = row.status === 'ok' ? 1 : 0 + const unavailable = row.status === 'ok' ? 0 : 1 + removedCount += 1 + scannedDelta += scanned + unavailableDelta += unavailable + totalSizeDelta += row.sizeBytes + reclaimableDelta += row.reclaimableBytes + const key = analysisRepoKey(row) + const delta = removedByRepo.get(key) ?? { + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + } + delta.worktreeCount += 1 + delta.scannedWorktreeCount += scanned + delta.unavailableWorktreeCount += unavailable + delta.totalSizeBytes += row.sizeBytes + delta.reclaimableBytes += row.reclaimableBytes + removedByRepo.set(key, delta) + } + if (removedCount === 0) { + return analysis + } return { ...analysis, - worktrees: analysis.worktrees.filter((row) => !isSameWorkspace(row, removed)), - worktreeCount: Math.max(0, analysis.worktreeCount - 1), + worktrees, + worktreeCount: Math.max(0, analysis.worktreeCount - removedCount), scannedWorktreeCount: Math.max(0, analysis.scannedWorktreeCount - scannedDelta), unavailableWorktreeCount: Math.max(0, analysis.unavailableWorktreeCount - unavailableDelta), - totalSizeBytes: Math.max(0, analysis.totalSizeBytes - removed.sizeBytes), - reclaimableBytes: Math.max(0, analysis.reclaimableBytes - removed.reclaimableBytes), - repos: analysis.repos.map((repo) => - repo.repoId === removed.repoId && repo.executionHostId === removed.executionHostId + totalSizeBytes: Math.max(0, analysis.totalSizeBytes - totalSizeDelta), + reclaimableBytes: Math.max(0, analysis.reclaimableBytes - reclaimableDelta), + repos: analysis.repos.map((repo) => { + const delta = removedByRepo.get(analysisRepoKey(repo)) + return delta ? { ...repo, - worktreeCount: Math.max(0, repo.worktreeCount - 1), - scannedWorktreeCount: Math.max(0, repo.scannedWorktreeCount - scannedDelta), - unavailableWorktreeCount: Math.max(0, repo.unavailableWorktreeCount - unavailableDelta), - totalSizeBytes: Math.max(0, repo.totalSizeBytes - removed.sizeBytes), - reclaimableBytes: Math.max(0, repo.reclaimableBytes - removed.reclaimableBytes) + worktreeCount: Math.max(0, repo.worktreeCount - delta.worktreeCount), + scannedWorktreeCount: Math.max( + 0, + repo.scannedWorktreeCount - delta.scannedWorktreeCount + ), + unavailableWorktreeCount: Math.max( + 0, + repo.unavailableWorktreeCount - delta.unavailableWorktreeCount + ), + totalSizeBytes: Math.max(0, repo.totalSizeBytes - delta.totalSizeBytes), + reclaimableBytes: Math.max(0, repo.reclaimableBytes - delta.reclaimableBytes) } : repo - ) + }) } } -function isSameWorkspace( - left: Pick, - right: Pick -): boolean { - return left.worktreeId === right.worktreeId && left.executionHostId === right.executionHostId +function analysisRepoKey(entry: { repoId: string; executionHostId?: ExecutionHostId }): string { + return JSON.stringify([entry.executionHostId, entry.repoId]) } -function prunedWorkspaceKey(worktreeId: string, executionHostId?: ExecutionHostId): string { - return `${executionHostId ?? '*'}\0${worktreeId}` -} - -function matchesPrunedWorkspace(row: WorkspaceSpaceWorktree, pruned: PrunedWorkspace): boolean { - return ( - row.worktreeId === pruned.worktreeId && - (!pruned.executionHostId || row.executionHostId === pruned.executionHostId) +/** Register anti-resurrection tombstones without scheduling a sidecar rewrite. */ +export function registerWorkspaceSpaceAnalysisSnapshotPruneTombstones( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): void { + if (targets.length === 0) { + return + } + registerWorkspaceSnapshotPrunesForFile( + prunedWorkspacesByFile, + sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME), + targets ) } @@ -168,15 +225,15 @@ function excludeRowsPrunedDuringScan( file: string, analysis: WorkspaceSpaceAnalysis ): WorkspaceSpaceAnalysis { - const pruned = [...(prunedWorkspacesByFile.get(file)?.values() ?? [])] - return analysis.worktrees.reduce( - (current, row) => - pruned.some( - (entry) => entry.prunedAt >= analysis.scannedAt && matchesPrunedWorkspace(row, entry) - ) - ? withoutWorktreeRow(current, row) - : current, - analysis + const prunedKeys = activeWorkspaceSnapshotPruneKeys( + prunedWorkspacesByFile.get(file), + analysis.scannedAt + ) + return withoutWorktreeRows( + analysis, + (row) => + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + prunedKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) ) } @@ -195,37 +252,77 @@ function clearSupersededPrunes(file: string, analysis: WorkspaceSpaceAnalysis): } } -/** Drop a removed workspace's row and rebalance the totals it contributed. Never throws. */ -export async function pruneWorkspaceSpaceAnalysisSnapshot( +async function pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( snapshotDirectory: string, - worktreeId: string, - executionHostId?: ExecutionHostId + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[], + registerTombstones: boolean ): Promise { + if (targets.length === 0) { + return + } const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME) - const pruned = prunedWorkspacesByFile.get(file) ?? new Map() - pruned.set(prunedWorkspaceKey(worktreeId, executionHostId), { - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - prunedAt: Date.now() - }) - prunedWorkspacesByFile.set(file, pruned) + const targetKeys = workspaceSnapshotPruneTargetKeys(targets) + if (registerTombstones) { + registerWorkspaceSnapshotPrunesForFile(prunedWorkspacesByFile, file, targets) + } try { await withSidecarSnapshotQueue(file, async () => { - const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) - const removed = existing?.worktrees.filter( - (row) => - row.worktreeId === worktreeId && - (executionHostId === undefined || row.executionHostId === executionHostId) - ) - if (!existing || !removed || removed.length === 0) { + const registered = prunedWorkspacesByFile.get(file) + const coalescedTargetKeys = registerTombstones + ? targetKeys + : new Set([...targetKeys].filter((key) => registered?.has(key))) + if (coalescedTargetKeys.size === 0) { return } - await writeSnapshot( - file, - removed.reduce((analysis, row) => withoutWorktreeRow(analysis, row), existing) + const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) + if (!existing) { + return + } + const next = withoutWorktreeRows( + existing, + (row) => + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId, row.executionHostId)) || + coalescedTargetKeys.has(workspaceSnapshotPruneKey(row.worktreeId)) ) + if (next === existing) { + return + } + await writeSnapshot(file, next) }) } catch (error) { console.warn('[workspace-space] failed to prune analysis snapshot:', error) } } + +/** Drop removed workspace rows and rebalance their totals in one sidecar transaction. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshots( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + true + ) +} + +/** Flush only tombstones still active for this batch, preserving their original prune time. */ +export async function finalizeWorkspaceSpaceAnalysisSnapshotPrunes( + snapshotDirectory: string, + targets: readonly WorkspaceSpaceAnalysisSnapshotPruneTarget[] +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshotsWithRegisteredTombstones( + snapshotDirectory, + targets, + false + ) +} + +/** Drop one removed workspace row and rebalance the totals it contributed. Never throws. */ +export async function pruneWorkspaceSpaceAnalysisSnapshot( + snapshotDirectory: string, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + await pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }]) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 364bc2d95b2..1d1bfa783c4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -536,7 +536,9 @@ import type { WorkspaceCleanupLocalProcessResult, WorkspaceCleanupScanArgs, WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult + WorkspaceCleanupScanResult, + WorkspaceCleanupSnapshotPruneBatchArgs, + WorkspaceCleanupSnapshotPruneRecordArgs } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' @@ -1404,11 +1406,13 @@ export type PreloadApi = { // may waive the proof that every PTY stopped. allowUnverifiedPtyStop?: boolean skipArchive?: boolean + snapshotPruneBatchId?: string }) => Promise // Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host. forgetLocal: (args: { worktreeId: string hostId?: ExecutionHostId + snapshotPruneBatchId?: string }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string @@ -1453,6 +1457,11 @@ export type PreloadApi = { hasKillableLocalProcesses: ( args: WorkspaceCleanupLocalProcessArgs ) => Promise + beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise + recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise + finishRemovalSnapshotPruneBatch?: ( + args: WorkspaceCleanupSnapshotPruneBatchArgs + ) => Promise } workspaceSpace: { analyze: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f94666a65d..3d1deff7e60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -909,7 +909,13 @@ const api = { dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) } satisfies PreloadApi['workspaceCleanup'], workspaceSpace: { 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 8e8bb328af2..2379be86965 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 @@ -146,9 +146,26 @@ export function useWorkspaceCleanupRemoval({ removalInFlightRef.current = false } try { + const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + const snapshotPruneBatch = + typeof beginSnapshotPruneBatch === 'function' && + typeof recordSnapshotPrune === 'function' && + typeof finishSnapshotPruneBatch === 'function' + ? (() => { + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => beginSnapshotPruneBatch({ batchId }), + finish: () => finishSnapshotPruneBatch({ batchId }) + } + })() + : undefined startWorkspaceCleanupBackgroundRemoval({ candidates: removableCandidates, removeCandidates, + snapshotPruneBatch, onProgress: (progress) => { if (mountedRef.current) { setRemovalProgress(progress) 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 new file mode 100644 index 00000000000..04b088e43d9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +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() + } +} + +it('holds one snapshot prune batch across the sequential cleanup removals', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const events: string[] = [] + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => { + events.push(`remove:${worktreeIds[0]}`) + return { removedIds: [...worktreeIds], failures: [] } + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress: vi.fn(), + snapshotPruneBatch: { + batchId: 'batch-1', + begin: async () => { + events.push('begin') + }, + finish: async () => { + events.push('finish') + } + } + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first], + snapshotPruneBatchId: 'batch-1' + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second], + snapshotPruneBatchId: 'batch-1' + }) + expect(events).toEqual([ + 'begin', + `remove:${first.worktreeId}`, + `remove:${second.worktreeId}`, + 'finish' + ]) +}) 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 2384e4749b7..9e98435faac 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 @@ -50,6 +50,11 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { onRowFailed?: (failure: WorkspaceCleanupFailure) => void removalTimeoutMs?: number removalSettlementGraceMs?: number + snapshotPruneBatch?: { + batchId: string + begin: () => Promise + finish: () => Promise + } } export function startWorkspaceCleanupBackgroundRemoval({ @@ -61,7 +66,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onError, onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, - removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS + removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, + snapshotPruneBatch }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -166,71 +172,95 @@ export function startWorkspaceCleanupBackgroundRemoval({ } void (async () => { - while (queue.length > 0) { - const candidate = queue.shift() - if (!candidate) { - break - } - const blockers = findBlockingDescendants(candidate) - if (blockers.length > 0) { - skipBlockedAncestor(candidate, blockers) - continue - } + let snapshotPruneBatchActive = false + if (snapshotPruneBatch) { try { - const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( - removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), - removalTimeoutMs, - removalSettlementGraceMs - ) - if (outcome.status === 'rejected') { - throw outcome.error + await snapshotPruneBatch.begin() + snapshotPruneBatchActive = true + } catch (error) { + console.warn('Failed to begin workspace cleanup snapshot prune batch:', error) + } + } + try { + while (queue.length > 0) { + const candidate = queue.shift() + if (!candidate) { + break } - if (outcome.status === 'unresolved') { - const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) - failedCandidates.push(candidate) - provisionallyBlocked.add(candidate) - pendingSettlementFailures.add(timeoutFailure) - reportFailures([timeoutFailure]) - // Why: renderer IPC cannot be cancelled at the timeout boundary; keep - // its authoritative settlement without unblocking ancestors early. - // After the batch ends, detach switches to the post-batch path which - // still holds skip state so ancestors can reclassify. - lateSettlementTrackers.push( - trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { - removeArrayEntry(failures, timeoutFailure) - pendingSettlementFailures.delete(timeoutFailure) - provisionallyBlocked.delete(candidate) - removedIds.push(...lateResult.removedIds) - preservedBranches.push(...(lateResult.preservedBranches ?? [])) - reportFailures(lateResult.failures) - if (lateResult.failures.length === 0) { - removeArrayEntry(failedCandidates, candidate) - } - resettleSkippedAncestors() - emitProgress() - }) - ) + const blockers = findBlockingDescendants(candidate) + if (blockers.length > 0) { + skipBlockedAncestor(candidate, blockers) continue } - const result = outcome.result - removedIds.push(...result.removedIds) - preservedBranches.push(...(result.preservedBranches ?? [])) - reportFailures(result.failures) - if (result.failures.length > 0) { - failedCandidates.push(candidate) - } - } catch (error: unknown) { - failedCandidates.push(candidate) - reportFailures([ - { - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, - message: error instanceof Error ? error.message : String(error) + try { + const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( + removeCandidates([candidate.worktreeId], { + approvedCandidates: [candidate], + ...(snapshotPruneBatchActive && snapshotPruneBatch + ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } + : {}) + }), + removalTimeoutMs, + removalSettlementGraceMs + ) + if (outcome.status === 'rejected') { + throw outcome.error } - ]) - } finally { - processedCount += 1 - emitProgress() + if (outcome.status === 'unresolved') { + const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) + failedCandidates.push(candidate) + provisionallyBlocked.add(candidate) + pendingSettlementFailures.add(timeoutFailure) + reportFailures([timeoutFailure]) + // Why: renderer IPC cannot be cancelled at the timeout boundary; keep + // its authoritative settlement without unblocking ancestors early. + // After the batch ends, detach switches to the post-batch path which + // still holds skip state so ancestors can reclassify. + lateSettlementTrackers.push( + trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { + removeArrayEntry(failures, timeoutFailure) + pendingSettlementFailures.delete(timeoutFailure) + provisionallyBlocked.delete(candidate) + removedIds.push(...lateResult.removedIds) + preservedBranches.push(...(lateResult.preservedBranches ?? [])) + reportFailures(lateResult.failures) + if (lateResult.failures.length === 0) { + removeArrayEntry(failedCandidates, candidate) + } + resettleSkippedAncestors() + emitProgress() + }) + ) + continue + } + const result = outcome.result + removedIds.push(...result.removedIds) + preservedBranches.push(...(result.preservedBranches ?? [])) + reportFailures(result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + } + ]) + } finally { + processedCount += 1 + emitProgress() + } + } + } finally { + if (snapshotPruneBatchActive && snapshotPruneBatch) { + try { + await snapshotPruneBatch.finish() + } catch (error) { + console.warn('Failed to finish workspace cleanup snapshot prune batch:', error) + } } } diff --git a/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts b/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts new file mode 100644 index 00000000000..c036863fcc8 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts @@ -0,0 +1,406 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY } from './workspace-cleanup' +import { + NOW, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +type GlobalCollectionScanCounts = { + openFiles: number + retainedAgents: number + agentStatuses: number +} + +const ALPHA_ID = 'repo-alpha::/workspace/alpha' +const BETA_ID = 'repo-beta::/workspace/beta' +const PROJECTION_CANDIDATE_COUNT = 1_000 + +function countOpenFileScans( + openFiles: AppState['openFiles'], + counts: GlobalCollectionScanCounts +): AppState['openFiles'] { + return new Proxy(openFiles, { + get(target, property, receiver) { + if (property === Symbol.iterator) { + return () => { + counts.openFiles += 1 + return target[Symbol.iterator]() + } + } + if (property === 'filter') { + return (predicate: (file: AppState['openFiles'][number]) => boolean) => { + counts.openFiles += 1 + return target.filter(predicate) + } + } + return Reflect.get(target, property, receiver) + } + }) +} + +function countRecordScans(record: T, onScan: () => void): T { + return new Proxy(record, { + ownKeys(target) { + onScan() + return Reflect.ownKeys(target) + } + }) +} + +function resetGlobalCollectionScanCounts(counts: GlobalCollectionScanCounts): void { + counts.openFiles = 0 + counts.retainedAgents = 0 + counts.agentStatuses = 0 +} + +function makePerformanceCandidate(index: number, fingerprintSuffix = 'stream') { + return makeCandidate({ + worktreeId: `repo-${index}::/workspace/${index}`, + repoId: `repo-${index}`, + displayName: `workspace-${index}`, + path: `/workspace/${index}`, + fingerprint: `fingerprint-${index}-${fingerprintSuffix}` + }) +} + +describe('workspace cleanup enrichment performance', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('scans global collections once per progress and final pass without changing results', async () => { + vi.spyOn(Date, 'now').mockReturnValue(NOW) + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const scan = vi.fn( + ( + _args?: WorkspaceCleanupScanArgs, + progressCallback?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + onProgress = progressCallback + return pending.promise + } + ) + installWorkspaceCleanupApi(scan) + + const alphaTab = { id: 'tab-alpha', title: 'shell' } + const betaTab = { id: 'tab-beta', title: 'shell' } + const alphaWorking = { + paneKey: 'tab-alpha:leaf-alpha', + state: 'working', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [] + } + const betaDone = { + paneKey: 'tab-beta:leaf-beta', + state: 'done', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [] + } + const counts: GlobalCollectionScanCounts = { + openFiles: 0, + retainedAgents: 0, + agentStatuses: 0 + } + const openFiles = countOpenFileScans( + [ + { + id: 'alpha-dirty', + worktreeId: ALPHA_ID, + filePath: '/workspace/alpha/dirty.ts', + relativePath: 'dirty.ts', + language: 'typescript', + isDirty: true + }, + { + id: 'alpha-clean', + worktreeId: ALPHA_ID, + filePath: '/workspace/alpha/clean.ts', + relativePath: 'clean.ts', + language: 'typescript', + isDirty: false + }, + { + id: 'beta-clean', + worktreeId: BETA_ID, + filePath: '/workspace/beta/clean.ts', + relativePath: 'clean.ts', + language: 'typescript', + isDirty: false + } + ] as AppState['openFiles'], + counts + ) + const retainedAgentsByPaneKey = countRecordScans( + { + 'tab-alpha:retained': { + entry: { ...alphaWorking, paneKey: 'tab-alpha:retained', state: 'done' }, + worktreeId: ALPHA_ID, + tab: alphaTab, + agentType: 'codex', + startedAt: NOW + }, + 'tab-beta:retained': { + entry: { ...betaDone, paneKey: 'tab-beta:retained', state: 'working' }, + worktreeId: BETA_ID, + tab: betaTab, + agentType: 'codex', + startedAt: NOW + } + } as unknown as AppState['retainedAgentsByPaneKey'], + () => { + counts.retainedAgents += 1 + } + ) + const agentStatusByPaneKey = countRecordScans( + { + [alphaWorking.paneKey]: alphaWorking, + [betaDone.paneKey]: betaDone + } as AppState['agentStatusByPaneKey'], + () => { + counts.agentStatuses += 1 + } + ) + const candidates = [ + makeCandidate({ + worktreeId: ALPHA_ID, + repoId: 'repo-alpha', + displayName: 'alpha', + path: '/workspace/alpha', + blockers: ['dismissed'], + fingerprint: 'alpha-fingerprint' + }), + makeCandidate({ + worktreeId: BETA_ID, + repoId: 'repo-beta', + displayName: 'beta', + path: '/workspace/beta', + fingerprint: 'beta-fingerprint' + }), + ...Array.from({ length: PROJECTION_CANDIDATE_COUNT - 2 }, (_, index) => + makePerformanceCandidate(index + 2, 'projection') + ) + ] + const tabsByWorktree = Object.fromEntries( + candidates.map((candidate, index) => { + if (index === 0) { + return [candidate.worktreeId, [alphaTab]] + } + if (index === 1) { + return [candidate.worktreeId, [betaTab]] + } + return [candidate.worktreeId, [{ id: `tab-projection-${index}`, title: 'shell' }]] + }) + ) as unknown as AppState['tabsByWorktree'] + const store = createCleanupTestStore() + store.setState({ + activeWorktreeId: BETA_ID, + tabsByWorktree, + openFiles, + browserTabsByWorktree: { + [ALPHA_ID]: [{}] + } as unknown as AppState['browserTabsByWorktree'], + retainedAgentsByPaneKey, + agentStatusByPaneKey, + lastVisitedAtByWorktreeId: { [ALPHA_ID]: NOW - 1 } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + expect(counts).toEqual({ openFiles: 1, retainedAgents: 0, agentStatuses: 1 }) + resetGlobalCollectionScanCounts(counts) + onProgress?.({ + scanId: 'performance-scan', + scannedAt: NOW, + scannedWorktreeCount: candidates.length, + totalWorktreeCount: candidates.length, + candidates, + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe( + candidates.length + ) + }) + expect(counts).toEqual({ openFiles: 1, retainedAgents: 1, agentStatuses: 1 }) + const progressCandidates = store.getState().workspaceCleanupScan?.candidates + expect(progressCandidates?.[0]).toMatchObject({ + blockers: ['dirty-editor-buffer', 'live-agent', 'recent-visible-context'], + localContext: { + terminalTabCount: 1, + cleanEditorTabCount: 1, + browserTabCount: 1, + retainedDoneAgentCount: 1 + } + }) + expect(progressCandidates?.[1]).toMatchObject({ + blockers: ['active-workspace'], + localContext: { + terminalTabCount: 1, + cleanEditorTabCount: 1, + browserTabCount: 0, + retainedDoneAgentCount: 0 + } + }) + + resetGlobalCollectionScanCounts(counts) + pending.resolve({ scannedAt: NOW, candidates, errors: [] }) + const finalScan = await scanPromise + + expect(counts).toEqual({ openFiles: 1, retainedAgents: 1, agentStatuses: 1 }) + expect(finalScan.candidates).toEqual(progressCandidates) + }) + + it('caps terminal candidate probes for streamed and final enrichment', async () => { + const candidateCount = WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY + 3 + const streamCandidates = Array.from({ length: candidateCount }, (_, index) => + makePerformanceCandidate(index) + ) + const finalCandidates = streamCandidates.map((candidate, index) => ({ + ...candidate, + fingerprint: `fingerprint-${index}-final` + })) + const tabsByWorktree = Object.fromEntries( + streamCandidates.map((candidate, index) => [ + candidate.worktreeId, + [{ id: `tab-${index}`, title: 'shell' }] + ]) + ) as AppState['tabsByWorktree'] + const ptyIdsByTabId = Object.fromEntries( + streamCandidates.map((_, index) => [`tab-${index}`, [`pty-${index}`]]) + ) + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const scan = vi.fn( + ( + _args?: WorkspaceCleanupScanArgs, + progressCallback?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + onProgress = progressCallback + return pending.promise + } + ) + installWorkspaceCleanupApi(scan) + + type ProbePhase = 'stream' | 'final' + let phase: ProbePhase = 'stream' + let probeGate = deferred() + const active: Record = { stream: 0, final: 0 } + const peak: Record = { stream: 0, final: 0 } + const hasChildProcesses = vi.fn(async () => { + const callPhase = phase + const callGate = probeGate + active[callPhase] += 1 + peak[callPhase] = Math.max(peak[callPhase], active[callPhase]) + await callGate.promise + active[callPhase] -= 1 + return false + }) + ;( + globalThis.window as unknown as { + api: { + pty: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: ReturnType + } + } + } + ).api.pty = { + hasChildProcesses, + getForegroundProcess: vi.fn().mockResolvedValue('zsh') + } + const store = createCleanupTestStore() + store.setState({ tabsByWorktree, ptyIdsByTabId } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'bounded-scan', + scannedAt: NOW, + scannedWorktreeCount: candidateCount, + totalWorktreeCount: candidateCount, + candidates: streamCandidates, + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(active.stream).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY) + }) + probeGate.resolve() + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(candidateCount) + }) + expect(peak.stream).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY) + + phase = 'final' + probeGate = deferred() + pending.resolve({ scannedAt: NOW, candidates: finalCandidates, errors: [] }) + await vi.waitFor(() => { + expect(active.final).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY) + }) + probeGate.resolve() + const finalScan = await scanPromise + + expect(peak.final).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY) + expect(hasChildProcesses).toHaveBeenCalledTimes(candidateCount * 2) + expect(finalScan.candidates.map((candidate) => candidate.worktreeId)).toEqual( + finalCandidates.map((candidate) => candidate.worktreeId) + ) + }) + + it('rebuilds focused removal enrichment from state changed after the broad scan', async () => { + const candidate = makeCandidate() + const focusedScan = deferred() + const scan = vi.fn((args?: WorkspaceCleanupScanArgs) => + args?.worktreeId + ? focusedScan.promise + : Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + ) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockResolvedValue(false) + ;( + globalThis.window as unknown as { + api: { + pty: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: ReturnType + } + } + } + ).api.pty = { + hasChildProcesses, + getForegroundProcess: vi.fn().mockResolvedValue('zsh') + } + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await store.getState().scanWorkspaceCleanup() + const removal = store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + store.setState({ + tabsByWorktree: { + [candidate.worktreeId]: [ + { id: 'post-scan-tab', title: 'shell' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'post-scan-tab': ['post-scan-pty'] } + } as Partial) + focusedScan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + + await expect(removal).resolves.toMatchObject({ removedIds: [candidate.worktreeId] }) + expect(hasChildProcesses).toHaveBeenCalledWith('post-scan-pty') + }) +}) 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 e3366977376..43dc2e8f564 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 @@ -121,6 +121,24 @@ describe('workspace cleanup removal and protection', () => { }) }) + it('forwards the snapshot batch through each successful removal', async () => { + const candidate = makeCandidate({ executionHostId: 'ssh:ssh-1' }) + installWorkspaceCleanupApi( + vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], { + snapshotPruneBatchId: 'batch-1' + }) + + expect(removeWorktree).toHaveBeenCalledWith(candidate.worktreeId, false, { + suppressPreservedBranchToast: true, + snapshotPruneBatchId: 'batch-1' + }) + }) + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { const [candidate] = await enrichWorkspaceCleanupCandidates( [makeCandidate()], diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index f4b5d66337d..651d688f58c 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -43,6 +43,7 @@ export type WorkspaceCleanupRemoveOptions = { // Why: rows are removed long after the confirm click; the confirm-time // candidate records how much git risk the user actually approved. approvedCandidates?: readonly WorkspaceCleanupCandidate[] + snapshotPruneBatchId?: string } type WorkspaceCleanupViewedCandidate = { @@ -82,8 +83,15 @@ type WorkspaceCleanupEnrichmentCacheEntry = { candidate: WorkspaceCleanupCandidate } +type WorkspaceCleanupEnrichmentProjection = { + openFilesByWorktreeId: Map + retainedDoneAgentPaneKeysByWorktreeId: Map + agentStatusesByTabId: Map +} + 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 const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4 // 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 @@ -348,7 +356,12 @@ export const createWorkspaceCleanupSlice: StateCreator file.worktreeId)) + const agentStatusesByTabId = buildWorkspaceCleanupAgentStatusIndex(state) for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { const tabIds = new Set(tabs.map((tab) => tab.id)) if (tabs.some((tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0)) { ids.add(worktreeId) } - if (hasFreshLiveAgent(state, tabIds) || hasWorkingTitleAgent(state, tabs)) { + if ( + hasFreshIndexedLiveAgent(agentStatusesByTabId, tabIds) || + hasWorkingTitleAgent(state, tabs) + ) { ids.add(worktreeId) } } @@ -634,8 +651,9 @@ export async function enrichWorkspaceCleanupCandidates( state: AppState, options: EnrichOptions = {} ): Promise { - return Promise.all( - candidates.map((candidate) => enrichWorkspaceCleanupCandidate(candidate, state, options)) + const projection = buildWorkspaceCleanupEnrichmentProjection(candidates, state) + return mapWithConcurrency(candidates, WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, (candidate) => + enrichWorkspaceCleanupCandidate(candidate, state, projection, options) ) } @@ -645,12 +663,16 @@ async function enrichWorkspaceCleanupCandidatesWithCache( cache: Map, options: EnrichOptions = {} ): Promise { - return Promise.all( - candidates.map(async (candidate) => { + const projection = buildWorkspaceCleanupEnrichmentProjection(candidates, state) + return mapWithConcurrency( + candidates, + WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, + async (candidate) => { const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate) const localSignature = getWorkspaceCleanupLocalStateSignature( candidate.worktreeId, state, + projection, options ) const cached = cache.get(candidate.worktreeId) @@ -658,17 +680,58 @@ async function enrichWorkspaceCleanupCandidatesWithCache( return cached.candidate } - const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, options) + const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, projection, options) cache.set(candidate.worktreeId, { inputSignature, localSignature, candidate: enriched }) return enriched - }) + } ) } +function buildWorkspaceCleanupEnrichmentProjection( + candidates: readonly WorkspaceCleanupCandidate[], + state: AppState +): WorkspaceCleanupEnrichmentProjection { + const worktreeIds = new Set(candidates.map((candidate) => candidate.worktreeId)) + const tabIds = new Set() + for (const worktreeId of worktreeIds) { + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + tabIds.add(tab.id) + } + } + + const openFilesByWorktreeId = new Map() + for (const file of state.openFiles) { + if (!worktreeIds.has(file.worktreeId)) { + continue + } + const files = openFilesByWorktreeId.get(file.worktreeId) ?? [] + files.push(file) + openFilesByWorktreeId.set(file.worktreeId, files) + } + + const retainedDoneAgentPaneKeysByWorktreeId = new Map() + for (const [paneKey, retained] of Object.entries(state.retainedAgentsByPaneKey)) { + if (!worktreeIds.has(retained.worktreeId) || retained.entry.state !== 'done') { + continue + } + const paneKeys = retainedDoneAgentPaneKeysByWorktreeId.get(retained.worktreeId) ?? [] + paneKeys.push(paneKey) + retainedDoneAgentPaneKeysByWorktreeId.set(retained.worktreeId, paneKeys) + } + + const agentStatusesByTabId = buildWorkspaceCleanupAgentStatusIndex(state, tabIds) + + return { + openFilesByWorktreeId, + retainedDoneAgentPaneKeysByWorktreeId, + agentStatusesByTabId + } +} + function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupCandidate): string { return JSON.stringify({ fingerprint: candidate.fingerprint, @@ -685,24 +748,22 @@ function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupC function getWorkspaceCleanupLocalStateSignature( worktreeId: string, state: AppState, + projection: WorkspaceCleanupEnrichmentProjection, options: EnrichOptions ): string { const tabs = state.tabsByWorktree[worktreeId] ?? [] const tabIds = tabs.map((tab) => tab.id) const tabIdSet = new Set(tabIds) - const openFiles = state.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => ({ - id: file.id, - isDirty: file.isDirty, - hasDraft: state.editorDrafts[file.id] !== undefined - })) - const retainedDoneAgentPaneKeys = Object.entries(state.retainedAgentsByPaneKey) - .filter(([, entry]) => entry.worktreeId === worktreeId && entry.entry.state === 'done') - .map(([paneKey]) => paneKey) - .sort() - const agentStatuses = Object.values(state.agentStatusByPaneKey) - .filter((entry) => tabIdSet.has(getPaneKeyTabId(entry.paneKey))) + const openFiles = (projection.openFilesByWorktreeId.get(worktreeId) ?? []).map((file) => ({ + id: file.id, + isDirty: file.isDirty, + hasDraft: state.editorDrafts[file.id] !== undefined + })) + const retainedDoneAgentPaneKeys = [ + ...(projection.retainedDoneAgentPaneKeysByWorktreeId.get(worktreeId) ?? []) + ].sort() + const agentStatuses = [...tabIdSet] + .flatMap((tabId) => projection.agentStatusesByTabId.get(tabId) ?? []) .map((entry) => ({ paneKey: entry.paneKey, state: entry.state, @@ -742,19 +803,19 @@ function getWorkspaceCleanupLocalStateSignature( async function enrichWorkspaceCleanupCandidate( candidate: WorkspaceCleanupCandidate, state: AppState, + projection: WorkspaceCleanupEnrichmentProjection, options: EnrichOptions ): Promise { const tabs = state.tabsByWorktree[candidate.worktreeId] ?? [] const tabIds = new Set(tabs.map((tab) => tab.id)) - const openFiles = state.openFiles.filter((file) => file.worktreeId === candidate.worktreeId) + const openFiles = projection.openFilesByWorktreeId.get(candidate.worktreeId) ?? [] const dirtyEditorBuffers = openFiles.filter( (file) => file.isDirty || state.editorDrafts[file.id] !== undefined ) const cleanEditorTabCount = openFiles.length - dirtyEditorBuffers.length const browserTabCount = (state.browserTabsByWorktree[candidate.worktreeId] ?? []).length - const retainedDoneAgentCount = Object.values(state.retainedAgentsByPaneKey).filter( - (entry) => entry.worktreeId === candidate.worktreeId && entry.entry.state === 'done' - ).length + const retainedDoneAgentCount = + projection.retainedDoneAgentPaneKeysByWorktreeId.get(candidate.worktreeId)?.length ?? 0 const blockers = candidate.blockers.filter((blocker) => blocker !== 'dismissed') const preserveCleanupInspection = shouldPreserveCleanupInspection(candidate, state) @@ -764,7 +825,7 @@ async function enrichWorkspaceCleanupCandidate( if (dirtyEditorBuffers.length > 0) { blockers.push('dirty-editor-buffer') } - if (hasFreshLiveAgent(state, tabIds)) { + if (hasFreshIndexedLiveAgent(projection.agentStatusesByTabId, tabIds)) { blockers.push('live-agent') } if (hasWorkingTitleAgent(state, tabs)) { @@ -841,7 +902,8 @@ async function preflightWorkspaceCleanupCandidate( | { ok: false; failure: WorkspaceCleanupFailure } > { const scan = await window.api.workspaceCleanup.scan({ worktreeId }) - const [candidate] = await enrichWorkspaceCleanupCandidates(scan.candidates, getState(), { + const currentState = getState() + const [candidate] = await enrichWorkspaceCleanupCandidates(scan.candidates, currentState, { applyDismissals: false }) if (!candidate) { @@ -899,14 +961,39 @@ async function preflightWorkspaceCleanupCandidate( return { ok: true, candidate } } -function hasFreshLiveAgent(state: AppState, tabIds: Set): boolean { +function buildWorkspaceCleanupAgentStatusIndex( + state: AppState, + includedTabIds?: ReadonlySet +): Map { + const agentStatusesByTabId = new Map() + for (const entry of Object.values(state.agentStatusByPaneKey)) { + const tabId = getPaneKeyTabId(entry.paneKey) + if (includedTabIds && !includedTabIds.has(tabId)) { + continue + } + const entries = agentStatusesByTabId.get(tabId) ?? [] + entries.push(entry) + agentStatusesByTabId.set(tabId, entries) + } + return agentStatusesByTabId +} + +function hasFreshIndexedLiveAgent( + agentStatusesByTabId: ReadonlyMap, + tabIds: Set +): boolean { const now = Date.now() - return Object.values(state.agentStatusByPaneKey).some( - (entry) => - tabIds.has(getPaneKeyTabId(entry.paneKey)) && - isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) && - (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') - ) + for (const tabId of tabIds) { + for (const entry of agentStatusesByTabId.get(tabId) ?? []) { + if ( + isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) && + (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') + ) { + return true + } + } + } + return false } function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string }[]): boolean { diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index c2980357ee8..1698a4cd805 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -244,6 +244,7 @@ export type WorktreeSlice = { // 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 } ) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6077204ba51..ce4ced3b5aa 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -143,6 +143,9 @@ const mockApi = { hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) }, + workspaceCleanup: { + recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined) + }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, @@ -6428,7 +6431,9 @@ describe('worktree remote runtime mutations', () => { worktreesByRepo: { repo1: [wt] } } as Partial) - const result = await store.getState().removeWorktree(wt.id) + const result = await store.getState().removeWorktree(wt.id, false, { + snapshotPruneBatchId: 'batch-1' + }) expect(result).toEqual({ ok: true }) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -6437,14 +6442,20 @@ describe('worktree remote runtime mutations', () => { params: { worktree: `id:${wt.id}`, hostId: 'runtime:env-1', - force: undefined, + force: false, allowUnverifiedPtyStop: false, runHooks: true }, timeoutMs: 60_000, - expectedEnvironmentPairingRevision: undefined + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: undefined }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(mockApi.workspaceCleanup.recordRemovalSnapshotPrune).toHaveBeenCalledExactlyOnceWith({ + batchId: 'batch-1', + worktreeId: wt.id, + executionHostId: 'runtime:env-1' + }) expect(store.getState().shutdownWorktreeTerminals).toHaveBeenCalledWith(wt.id, { shutdownReason: 'remove-worktree', backendOwnsPtyTeardown: true diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 00e421cb7df..6429f6a38aa 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -4254,9 +4254,16 @@ export const createWorktreeSlice: StateCreator : { activeRuntimeEnvironmentId: null } ) let removalResult: RemoveWorktreeResult + let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local' try { removalResult = await (forgetLocalOnly - ? window.api.worktrees.forgetLocal({ worktreeId, hostId }) + ? window.api.worktrees.forgetLocal({ + worktreeId, + hostId, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + }) : target.kind === 'local' ? (removalGenerationGuard?.assertCurrent(), window.api.worktrees.remove({ @@ -4264,7 +4271,10 @@ export const createWorktreeSlice: StateCreator hostId, force, allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, - skipArchive + skipArchive, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) })) : (removalGenerationGuard?.assertCurrent(), callRuntimeRpc( @@ -4294,7 +4304,14 @@ export const createWorktreeSlice: StateCreator removalGenerationGuard?.assertCurrent() } try { - removalResult = await window.api.worktrees.forgetLocal({ worktreeId, hostId }) + removalResult = await window.api.worktrees.forgetLocal({ + worktreeId, + hostId, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + }) + snapshotPruneHandledByLocalMain = true } catch (fallbackError) { // Preserve the remote verdict as fallback failure context. throw new Error( @@ -4307,6 +4324,18 @@ export const createWorktreeSlice: StateCreator } } + if (options?.snapshotPruneBatchId && !snapshotPruneHandledByLocalMain) { + try { + await window.api.workspaceCleanup?.recordRemovalSnapshotPrune?.({ + batchId: options.snapshotPruneBatchId, + worktreeId, + ...(hostId ? { executionHostId: hostId } : {}) + }) + } catch (error) { + console.warn('Failed to record workspace cleanup snapshot prune:', error) + } + } + // 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.ts b/src/shared/workspace-cleanup.ts index 1c3480aa7e6..c7d287f3ae4 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -93,6 +93,15 @@ export type WorkspaceCleanupLocalProcessArgs = { worktreePath?: string } +export type WorkspaceCleanupSnapshotPruneBatchArgs = { + batchId: string +} + +export type WorkspaceCleanupSnapshotPruneRecordArgs = WorkspaceCleanupSnapshotPruneBatchArgs & { + worktreeId: string + executionHostId?: ExecutionHostId +} + export type WorkspaceCleanupScanError = { repoId: string repoName: string