Files
orca/src/main/workspace-snapshot-pruning.bench.test.ts
T
Brennan Benson 9e5ee5ef8e feat(workspaces): rework cleanup discovery and dialog (#13413)
* fix(workspaces): support full cleanup scans

* feat(workspaces): persist cleanup snapshots

* feat(workspaces): add cleanup filter model

* refactor(workspaces): remove cleanup presets

* feat(workspaces): rework cleanup dialog

* fix(workspaces): keep cleanup row ordering render-pure

* refactor(workspaces): simplify cleanup browsing

* refactor(workspaces): show cleanup facts

* refactor(workspaces): surface cleanup row facts

* fix(workspaces): remove misleading cleanup count

* fix(workspaces): preserve full scan semantics

* fix(workspaces): scope snapshot persistence

* fix(workspaces): preserve cleanup browse compatibility

* fix(workspaces): reconcile cleanup dialog state

* test(workspaces): update snapshot store fixtures

* test(workspaces): preserve cleanup scan modes

* perf(workspace-cleanup): stream scan progress and size results

* fix(workspace-cleanup): address review feedback

* fix(workspace-cleanup): preserve host-scoped cleanup metadata

* fix(workspace-cleanup): declare review source dependencies

* fix(workspace-cleanup): align size scan banner

* fix(workspace-cleanup): shorten scan action

* perf(workspace-cleanup): avoid redundant scan IO

* perf(workspace-cleanup): bound restarted evidence scans

* fix(workspace-cleanup): satisfy scan queue lint

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

* perf(workspace-cleanup): serialize final enrichment

* test(workspace-cleanup): assert final enrichment drain

* fix(workspace-cleanup): stop progress after renderer teardown

* perf: batch workspace cleanup git evidence scans

* perf(workspace-cleanup): stop redundant snapshot and scan work

* fix(workspace-cleanup): resolve review findings across scan, store, and dialog

Correctness:
- Chunk git-evidence dispatches at the shared 500-target limit and exclude
  queued/in-flight ids from target selection, so fleets past the limit can no
  longer strand rows permanently mislabeled as checked-but-unknown.
- Key destructive selection pruning on the user's filter state instead of the
  per-tick matched-set identity; streaming reclassification no longer silently
  deselects rows.
- Clamp the facet clock to max(scannedAt, open time): a stale hydrated
  snapshot no longer misbuckets idle thresholds or keeps dead agents fresh;
  row labels use the same clock.
- Supersede and cancel the previous broad scan when a new one starts (renderer
  registry and same-sender guard in main) instead of racing two fleet scans.
- Gate snapshot persistence on hasTargetedWorkspaceCleanupScan so
  worktreeIds: [] can never persist an empty fleet snapshot.
- Re-apply dismissals at set-time in progress application so a dismissal
  landing mid-enrichment is not clobbered.
- Record a one-off local snapshot prune for single (unbatched) remote deletes
  so removed workspaces cannot resurrect from cache.
- Strip .exe when normalizing foreground process names so Windows agent
  processes match.

Performance:
- Cache per-candidate facet and review-info objects on candidate identity;
  no-op streaming ticks reuse the previous rows array and skip every
  downstream pass; matched-set identity is stable under equal membership.
- Compute facet counts/options only while the filter popover is open.
- Equality-bail git-evidence publishes; structural (non-stringify) facet-group
  comparison memoized in the toolbar.
- Identity-token fast path for the enrichment cache (cache hits skip both
  JSON.stringify signatures); prune viewed/dismissal records on removal and
  expiry; bound the superseded-scan-id set.
- Restore the no-op bail in removeWorkspaceSpaceWorktrees (regression).
- Abort main-side scans when the renderer is destroyed; module-scope
  controller maps survive handler re-registration.
- Batch removal preflight into one targeted scan (with refreshActivity) per
  500 ids instead of one scan per row.
- Scan repos at concurrency 2, report discovered counts upfront for honest
  progress, share fs-activity probes per path (folder workspaces), read only
  the reflog tail, and skip the snapshot read-before-write via a remembered
  scannedAt.

Split workspace-cleanup-worktree-listing, workspace-cleanup-facet-row-caches,
and workspace-cleanup-selection-model out of files that crossed max-lines.

* fix(workspace-cleanup): address verifier findings

- Fall back to a full reflog read when the newest record exceeds the 8KB
  tail window, so an oversized subject cannot hide recent ref activity.
- Bound the single-removal snapshot prune batch id with a UUID; embedding
  the unbounded worktreeId silently failed main's 128-char validation and
  skipped the prune for long remote ids.
- Key the main-side broad-scan supersession by sender AND scan mode so
  legacy suggestion-only and full-workspace scans stay isolated, matching
  the renderer registry.

* fix(workspace-cleanup): own facet caches with useMemo instead of render-time ref writes

React Doctor (CI changed-lines gate) correctly flagged the three cache refs
written during render. Each per-candidate cache now lives in one memo with
the derived context it is keyed on, so the memo deps are the invalidation
and interior fills stay content-addressed; the matched-set identity
stabilization is dropped since its only consumer reads through a
useEffectEvent and never keys on identity.
2026-08-13 23:47:20 -07:00

154 lines
5.4 KiB
TypeScript

// 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
}))
}