perf(workspace-cleanup): bound scan and snapshot work

This commit is contained in:
Brennan Benson
2026-08-13 03:41:01 -07:00
parent 8d59a587ce
commit 4ebd8eaff4
25 changed files with 2022 additions and 243 deletions
@@ -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 {
@@ -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)
})
})
+49 -1
View File
@@ -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(
+67 -7
View File
@@ -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<string, Record<string, unknown>> = {
'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({
+84 -13
View File
@@ -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<RemoveWorktreeArgs, 'worktreeId' | 'hostId'>
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId' | 'snapshotPruneBatchId'>
): Promise<RemoveWorktreeResult> => {
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) {
@@ -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)
})
})
@@ -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<WorkspaceCleanupSnapshotPruneRecordArgs, 'batchId'>
type SnapshotPruneBatch = {
targets: Map<string, SnapshotPruneTarget>
expirationTimer?: ReturnType<typeof setTimeout>
}
const batches = new Map<string, SnapshotPruneBatch>()
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<typeof setTimeout> {
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<void> {
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<void> {
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()
}
@@ -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<Record<string, unknown>>()
const writeSidecarSnapshot = actual.writeSidecarSnapshot as (
file: string,
payload: unknown
) => Promise<void>
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({
+87 -39
View File
@@ -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<string, Map<string, PrunedWorkspace>>()
const prunedWorkspacesByFile = new Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>()
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<void> {
if (targets.length === 0) {
return
}
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
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<void> {
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<void> {
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<void> {
await pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }])
}
@@ -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<string> {
return new Set(
targets.map(({ worktreeId, executionHostId }) =>
workspaceSnapshotPruneKey(worktreeId, executionHostId)
)
)
}
export function activeWorkspaceSnapshotPruneKeys(
tombstones: ReadonlyMap<string, WorkspaceSnapshotPruneTombstone> | undefined,
scannedAt: number
): Set<string> {
const keys = new Set<string>()
for (const [key, entry] of tombstones ?? []) {
if (entry.prunedAt >= scannedAt) {
keys.add(key)
}
}
return keys
}
export function registerWorkspaceSnapshotPrunesForFile(
tombstonesByFile: Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>,
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)
}
@@ -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
}))
}
@@ -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<Record<string, unknown>>()
const writeSidecarSnapshot = actual.writeSidecarSnapshot as (
file: string,
payload: unknown
) => Promise<void>
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({
+162 -65
View File
@@ -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<string, Map<string, PrunedWorkspace>>()
const prunedWorkspacesByFile = new Map<string, Map<string, WorkspaceSnapshotPruneTombstone>>()
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<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>,
right: Pick<WorkspaceSpaceWorktree, 'executionHostId' | 'worktreeId'>
): 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<void> {
if (targets.length === 0) {
return
}
const file = sidecarSnapshotFile(snapshotDirectory, SNAPSHOT_FILE_NAME)
const pruned = prunedWorkspacesByFile.get(file) ?? new Map<string, PrunedWorkspace>()
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<void> {
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<void> {
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<void> {
await pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, [{ worktreeId, executionHostId }])
}
+10 -1
View File
@@ -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<RemoveWorktreeResult>
// 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<RemoveWorktreeResult>
forceDeletePreservedBranch: (args: {
worktreeId: string
@@ -1453,6 +1457,11 @@ export type PreloadApi = {
hasKillableLocalProcesses: (
args: WorkspaceCleanupLocalProcessArgs
) => Promise<WorkspaceCleanupLocalProcessResult>
beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise<void>
finishRemovalSnapshotPruneBatch?: (
args: WorkspaceCleanupSnapshotPruneBatchArgs
) => Promise<void>
}
workspaceSpace: {
analyze: () => Promise<WorkspaceSpaceAnalyzeResult>
+7 -1
View File
@@ -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: {
@@ -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)
@@ -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<void> {
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'
])
})
@@ -50,6 +50,11 @@ export type WorkspaceCleanupBackgroundRemovalArgs = {
onRowFailed?: (failure: WorkspaceCleanupFailure) => void
removalTimeoutMs?: number
removalSettlementGraceMs?: number
snapshotPruneBatch?: {
batchId: string
begin: () => Promise<void>
finish: () => Promise<void>
}
}
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)
}
}
}
@@ -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<T extends object>(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<WorkspaceCleanupScanResult>()
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<AppState>)
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<WorkspaceCleanupScanResult>()
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<void>()
const active: Record<ProbePhase, number> = { stream: 0, final: 0 }
const peak: Record<ProbePhase, number> = { 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<typeof vi.fn>
}
}
}
).api.pty = {
hasChildProcesses,
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
}
const store = createCleanupTestStore()
store.setState({ tabsByWorktree, ptyIdsByTabId } as Partial<AppState>)
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<void>()
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<WorkspaceCleanupScanResult>()
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<typeof vi.fn>
}
}
}
).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<AppState>)
focusedScan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] })
await expect(removal).resolves.toMatchObject({ removedIds: [candidate.worktreeId] })
expect(hasChildProcesses).toHaveBeenCalledWith('post-scan-pty')
})
})
@@ -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()],
@@ -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<string, AppState['openFiles']>
retainedDoneAgentPaneKeysByWorktreeId: Map<string, string[]>
agentStatusesByTabId: Map<string, AgentStatusEntry[]>
}
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<AppState, [], [], Workspa
shouldForceWorkspaceCleanupRemoval(candidate),
// Why: cleanup reports outcomes in its own summary toasts; per-row
// preserved-branch warnings would stack one toast per removed row.
{ suppressPreservedBranchToast: true }
{
suppressPreservedBranchToast: true,
...(options?.snapshotPruneBatchId
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
: {})
}
)
if (result.ok) {
removedIds.push(candidate.worktreeId)
@@ -597,12 +610,16 @@ function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] {
}
const openEditorWorktreeIds = new Set(state.openFiles.map((file) => 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<WorkspaceCleanupCandidate[]> {
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<string, WorkspaceCleanupEnrichmentCacheEntry>,
options: EnrichOptions = {}
): Promise<WorkspaceCleanupCandidate[]> {
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<string>()
for (const worktreeId of worktreeIds) {
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
tabIds.add(tab.id)
}
}
const openFilesByWorktreeId = new Map<string, AppState['openFiles']>()
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<string, string[]>()
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<WorkspaceCleanupCandidate> {
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<string>): boolean {
function buildWorkspaceCleanupAgentStatusIndex(
state: AppState,
includedTabIds?: ReadonlySet<string>
): Map<string, AgentStatusEntry[]> {
const agentStatusesByTabId = new Map<string, AgentStatusEntry[]>()
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<string, readonly AgentStatusEntry[]>,
tabIds: Set<string>
): 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 {
@@ -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
@@ -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<AppState>)
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
+32 -3
View File
@@ -4254,9 +4254,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: { 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<AppState, [], [], WorktreeSlice>
hostId,
force,
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
skipArchive
skipArchive,
...(options?.snapshotPruneBatchId
? { snapshotPruneBatchId: options.snapshotPruneBatchId }
: {})
}))
: (removalGenerationGuard?.assertCurrent(),
callRuntimeRpc<RemoveWorktreeResult>(
@@ -4294,7 +4304,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
}
}
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
+9
View File
@@ -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