perf(vault): dedupe scope paths by key instead of rescanning (#11314)
* perf(vault): dedupe scope paths by key instead of rescanning #11303 took the session maps off the workspace-switch path, but scope path derivation still follows the active worktree and stayed quadratic. addAiVaultWorkspaceScopePath deduped by re-normalizing every already accepted path on each insert, so accepting K paths cost O(K^2) normalize('NFC') calls — ~632k at 1124 workspaces. isAiVaultWorkspaceScopePathClaimed separately rescanned every live worktree per prior id, and runs twice per switch via activeWorktreePaths and scopePaths. - carry a Set of comparison keys alongside the paths, so each insert is one normalize plus one Set lookup - thread that accumulator from the workspace pass into the project pass rather than restarting deduplication against a plain array - build one comparison-path -> worktree id map for the claim check, keeping first-writer-wins to match the previous some() short-circuit deriveAiVaultScopeSessionPaths on a real 1124-workspace profile: 189.7ms -> 0.8ms. Output is unchanged, including ordering: verified against the previous implementation across 117 scenario/option combinations covering monorepo and mixed-repo layouts, priors both claimed and unclaimed, duplicate paths, NFD/NFC, WSL UNC, trailing and doubled separators, relative and blank paths, and four project-key shapes. Adds the first test file for this module: scope semantics (priors, claimed priors, cross-repo rejection, dedupe, NFD/NFC) plus a timing guard. Verified fail-first — the guard reports 220ms on the previous implementation. Path length is chosen deliberately, since normalize() cost scales with it and short synthetic paths understate the old shape. * fix(vault): make the claim check independent of worktree ordering Review catch on the first pass: keying claims by comparison path meant a duplicate path had to pick one owner, and picking the active worktree masked a real claimant later in the list. Concretely, with the active worktree also listed at its own prior path, the prior was reported unclaimed where the previous some() reported it claimed. Excludes the active worktree while building the set instead of comparing ids at read time, so any surviving entry is a claim by construction and ordering cannot decide the result. Adds a test over four orderings, verified fail-first against the previous commit. Also adds a timing guard for deriveAiVaultWorkspaceScopePaths, which the session-scope guard did not cover. Equivalence rerun against the pre-optimization implementation: 156 scenario/option combinations, identical paths and ordering.
This commit is contained in:
parent
d07931c4c2
commit
6cc579a48c
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
deriveAiVaultScopeSessionPaths,
|
||||
deriveAiVaultWorkspaceScopePaths
|
||||
} from './ai-vault-scope-paths'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/orca',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'orca',
|
||||
path: '/repo/orca',
|
||||
head: 'abc123',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
isMainWorktree: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveAiVaultWorkspaceScopePaths', () => {
|
||||
it('returns the active workspace path', () => {
|
||||
const active = makeWorktree()
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
|
||||
})
|
||||
|
||||
it('returns nothing without an active workspace', () => {
|
||||
expect(deriveAiVaultWorkspaceScopePaths(null, [makeWorktree()])).toEqual([])
|
||||
})
|
||||
|
||||
it('includes prior paths so renamed workspaces keep their transcripts', () => {
|
||||
const active = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-renamed',
|
||||
path: '/repo/orca-renamed',
|
||||
priorWorktreeIds: ['repo-1::/repo/orca']
|
||||
})
|
||||
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([
|
||||
'/repo/orca-renamed',
|
||||
'/repo/orca'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a prior path another live workspace now owns', () => {
|
||||
// Sessions are keyed by cwd alone, so claiming a path a live workspace
|
||||
// occupies would show that workspace's transcripts under this one.
|
||||
const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' })
|
||||
const active = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-renamed',
|
||||
path: '/repo/orca-renamed',
|
||||
priorWorktreeIds: ['repo-1::/repo/orca']
|
||||
})
|
||||
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [claimant, active])).toEqual([
|
||||
'/repo/orca-renamed'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a prior path the active workspace itself still owns', () => {
|
||||
const active = makeWorktree({ priorWorktreeIds: ['repo-1::/repo/orca'] })
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
|
||||
})
|
||||
|
||||
it('drops a claimed prior path regardless of where the claimant sits in the list', () => {
|
||||
// Ordering must not decide the claim: a lookup keyed by path has to
|
||||
// exclude the active workspace up front, or listing it before a claimant
|
||||
// that shares the path would mask the claim.
|
||||
const active = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-renamed',
|
||||
path: '/repo/orca-renamed',
|
||||
priorWorktreeIds: ['repo-1::/repo/orca']
|
||||
})
|
||||
const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' })
|
||||
// The active workspace also listed at the prior path — the only shape where
|
||||
// a first-writer-wins map would name the active workspace the owner and so
|
||||
// report the path unclaimed.
|
||||
const activeAtPriorPath = makeWorktree({ id: active.id, path: '/repo/orca' })
|
||||
|
||||
for (const liveWorktrees of [
|
||||
[active, claimant],
|
||||
[claimant, active],
|
||||
[activeAtPriorPath, claimant],
|
||||
[claimant, activeAtPriorPath]
|
||||
]) {
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, liveWorktrees)).toEqual([
|
||||
'/repo/orca-renamed'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('derives workspace scope paths at scale', () => {
|
||||
// Separate from the session-scope guard: a quadratic dedupe reintroduced
|
||||
// only in the workspace pass would not surface there.
|
||||
const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-'
|
||||
const worktrees = Array.from({ length: 1200 }, (_, i) =>
|
||||
makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` })
|
||||
)
|
||||
const active = makeWorktree({
|
||||
id: `repo-1::${prefix}0`,
|
||||
path: `${prefix}0`,
|
||||
// Priors drive the claim check, which is the other per-call scan here.
|
||||
priorWorktreeIds: Array.from({ length: 25 }, (_, i) => `repo-1::${prefix}prior-${i}`)
|
||||
})
|
||||
|
||||
const startedAt = performance.now()
|
||||
const paths = deriveAiVaultWorkspaceScopePaths(active, worktrees)
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
|
||||
expect(paths).toHaveLength(1 + 25)
|
||||
expect(elapsedMs).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('ignores prior ids belonging to another repo', () => {
|
||||
const active = makeWorktree({ priorWorktreeIds: ['repo-2::/repo/other'] })
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
|
||||
})
|
||||
|
||||
it('skips relative and blank paths', () => {
|
||||
const active = makeWorktree({ path: 'relative/path' })
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([])
|
||||
expect(deriveAiVaultWorkspaceScopePaths(makeWorktree({ path: ' ' }), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveAiVaultScopeSessionPaths', () => {
|
||||
it('covers the active workspace plus the rest of its repo', () => {
|
||||
const active = makeWorktree()
|
||||
const sibling = makeWorktree({ id: 'repo-1::/repo/feature', path: '/repo/feature' })
|
||||
const otherRepo = makeWorktree({
|
||||
id: 'repo-2::/repo/other',
|
||||
repoId: 'repo-2',
|
||||
path: '/repo/other'
|
||||
})
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, sibling, otherRepo])).toEqual([
|
||||
'/repo/orca',
|
||||
'/repo/feature'
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates paths that differ only by separators or trailing slash', () => {
|
||||
// The dedupe compares normalized keys; the first spelling is what ships.
|
||||
const active = makeWorktree()
|
||||
const trailing = makeWorktree({ id: 'repo-1::a', path: '/repo/orca/' })
|
||||
const doubled = makeWorktree({ id: 'repo-1::b', path: '/repo//orca' })
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, trailing, doubled])).toEqual([
|
||||
'/repo/orca'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats NFD and NFC spellings of one path as the same scope entry', () => {
|
||||
// macOS yields NFD on disk while agents record NFC cwds (#10832).
|
||||
const nfc = '/repo/프로젝트'
|
||||
const active = makeWorktree({ id: `repo-1::${nfc}`, path: nfc })
|
||||
const nfd = makeWorktree({ id: 'repo-1::nfd', path: nfc.normalize('NFD') })
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, nfd])).toEqual([nfc])
|
||||
})
|
||||
|
||||
it('derives scope paths at scale without re-normalizing the accumulator', () => {
|
||||
// Regression guard: deduping by rescanning the accumulated paths made this
|
||||
// O(n^2) in normalize('NFC') and cost ~190ms on a 1124-workspace profile —
|
||||
// on the workspace-switch path, since these paths follow the active
|
||||
// worktree. Times the real fan-out so it fails on a return to that shape.
|
||||
// Path length matters as much as count: normalize() cost scales with it,
|
||||
// so short synthetic paths would understate the old shape. ~50 chars
|
||||
// matches the real profile this was measured on.
|
||||
const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-'
|
||||
const worktrees = Array.from({ length: 1200 }, (_, i) =>
|
||||
makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` })
|
||||
)
|
||||
|
||||
const startedAt = performance.now()
|
||||
const paths = deriveAiVaultScopeSessionPaths(worktrees[0], worktrees)
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
|
||||
expect(paths).toHaveLength(worktrees.length)
|
||||
// ~190ms before, ~1ms after; loose enough for a slow CI box.
|
||||
expect(elapsedMs).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,21 +14,33 @@ export function deriveAiVaultWorkspaceScopePaths(
|
|||
return []
|
||||
}
|
||||
|
||||
const paths: string[] = []
|
||||
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
|
||||
return collectWorkspaceScopePaths(activeWorktree, liveWorktrees).paths
|
||||
}
|
||||
|
||||
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
|
||||
function collectWorkspaceScopePaths(
|
||||
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'>,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[]
|
||||
): ScopePathAccumulator {
|
||||
const accumulator = createScopePathAccumulator()
|
||||
addAiVaultWorkspaceScopePath(accumulator, activeWorktree.path)
|
||||
|
||||
const priorWorktreeIds = activeWorktree.priorWorktreeIds ?? []
|
||||
// Built once instead of rescanning every live worktree per prior id.
|
||||
const claimedComparisonPaths =
|
||||
priorWorktreeIds.length > 0 ? buildClaimedComparisonPaths(liveWorktrees, activeWorktree) : null
|
||||
|
||||
for (const priorWorktreeId of priorWorktreeIds) {
|
||||
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
|
||||
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
|
||||
continue
|
||||
}
|
||||
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
|
||||
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, claimedComparisonPaths)) {
|
||||
continue
|
||||
}
|
||||
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
|
||||
addAiVaultWorkspaceScopePath(accumulator, parsed.worktreePath)
|
||||
}
|
||||
|
||||
return paths
|
||||
return accumulator
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,10 +60,12 @@ export function deriveAiVaultScopeSessionPaths(
|
|||
projectHostSetupProjection?: ProjectHostSetupProjection
|
||||
} = {}
|
||||
): string[] {
|
||||
const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees)
|
||||
if (!activeWorktree) {
|
||||
return paths
|
||||
return []
|
||||
}
|
||||
// Carries the workspace pass's dedupe keys forward, so the project pass does
|
||||
// not restart deduplication against a plain array.
|
||||
const accumulator = collectWorkspaceScopePaths(activeWorktree, liveWorktrees)
|
||||
const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection)
|
||||
for (const worktree of liveWorktrees) {
|
||||
if (
|
||||
|
|
@ -61,15 +75,15 @@ export function deriveAiVaultScopeSessionPaths(
|
|||
(setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey
|
||||
)
|
||||
) {
|
||||
addAiVaultWorkspaceScopePath(paths, worktree.path)
|
||||
addAiVaultWorkspaceScopePath(accumulator, worktree.path)
|
||||
}
|
||||
}
|
||||
for (const setup of options.projectHostSetupProjection?.setups ?? []) {
|
||||
if (worktreeProjectKey(setup, setup) === options.activeProjectKey) {
|
||||
addAiVaultWorkspaceScopePath(paths, setup.path)
|
||||
addAiVaultWorkspaceScopePath(accumulator, setup.path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
return accumulator.paths
|
||||
}
|
||||
|
||||
function buildProjectSetupsByRepoId(
|
||||
|
|
@ -95,34 +109,71 @@ function worktreeProjectKey(
|
|||
return entry.repoId ? `repo:${entry.repoId}` : null
|
||||
}
|
||||
|
||||
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
|
||||
/**
|
||||
* Paths plus their comparison keys.
|
||||
*
|
||||
* Why the key set: deduping by rescanning the accumulated paths re-normalized
|
||||
* every accepted path on every insert, which is O(n^2) `normalize('NFC')` calls
|
||||
* and cost ~190ms on a 1124-workspace profile — on the workspace-switch path,
|
||||
* since these paths are derived from the active worktree.
|
||||
*/
|
||||
type ScopePathAccumulator = {
|
||||
paths: string[]
|
||||
comparisonKeys: Set<string>
|
||||
}
|
||||
|
||||
function createScopePathAccumulator(): ScopePathAccumulator {
|
||||
return { paths: [], comparisonKeys: new Set() }
|
||||
}
|
||||
|
||||
function addAiVaultWorkspaceScopePath(accumulator: ScopePathAccumulator, pathValue: string): void {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
if (
|
||||
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
|
||||
) {
|
||||
if (accumulator.comparisonKeys.has(comparisonPath)) {
|
||||
return
|
||||
}
|
||||
paths.push(trimmedPath)
|
||||
accumulator.comparisonKeys.add(comparisonPath)
|
||||
accumulator.paths.push(trimmedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison paths owned by a worktree *other than* the active one.
|
||||
*
|
||||
* Why exclude the active worktree while building rather than when reading: a
|
||||
* path→id map would otherwise have to pick one owner among duplicates, and
|
||||
* picking the active worktree would mask a real claimant sitting later in the
|
||||
* list. Excluding it up front means any surviving entry is a claim by
|
||||
* definition, which matches the previous `some()` regardless of ordering.
|
||||
*/
|
||||
function buildClaimedComparisonPaths(
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[],
|
||||
activeWorktree: Pick<Worktree, 'id'>
|
||||
): Set<string> {
|
||||
const claimedPaths = new Set<string>()
|
||||
for (const worktree of liveWorktrees) {
|
||||
if (worktree.id === activeWorktree.id) {
|
||||
continue
|
||||
}
|
||||
const trimmedPath = worktree.path.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
continue
|
||||
}
|
||||
claimedPaths.add(normalizeRuntimePathForComparison(trimmedPath))
|
||||
}
|
||||
return claimedPaths
|
||||
}
|
||||
|
||||
function isAiVaultWorkspaceScopePathClaimed(
|
||||
pathValue: string,
|
||||
activeWorktree: Pick<Worktree, 'id'>,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
|
||||
claimedComparisonPaths: Set<string> | null
|
||||
): boolean {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath) || !claimedComparisonPaths) {
|
||||
return false
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
|
||||
return liveWorktrees.some(
|
||||
(worktree) =>
|
||||
worktree.id !== activeWorktree.id &&
|
||||
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
|
||||
)
|
||||
return claimedComparisonPaths.has(normalizeRuntimePathForComparison(trimmedPath))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue