From d46cf4b2956f6b18dcd398c57ef034a8ada170d7 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:07:05 +0000 Subject: [PATCH] fix(usage): keep deleted worktrees' sessions in the Orca usage totals (#41218) Usage attribution only knew live worktrees, so deleting one moved its sessions out of the Orca scope on the next scan and Stats & Usage totals went down. Remember every worktree Orca has had in a small usage-owned history and attribute against live plus remembered worktrees, with live rows claiming reused paths first. The history loads off the main thread, scans wait for it, and an unreadable or newer file is never overwritten. --- .../claude-usage-benchmark-persistence.mjs | 7 +- .../persisted-usage-test-fixture.ts | 8 +- src/main/claude-usage/store.ts | 4 +- src/main/codex-usage/store-test-harness.ts | 4 +- src/main/codex-usage/store.ts | 4 +- src/main/muse-usage/store.ts | 4 +- src/main/opencode-usage/store.ts | 4 +- src/main/startup/main-process-observers.ts | 14 ++- src/main/startup/main-process-quit.ts | 3 +- src/main/startup/main-process-state.ts | 3 + .../usage-provider-store-lifecycle.ts | 10 +- .../usage-worktree-history.test.ts | 97 ++++++++++++++ .../usage-worktree-history.ts | 119 ++++++++++++++++++ .../usage-worktree-metadata.ts | 42 +++++-- .../usage-worktree-resolver.ts | 10 +- 15 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 src/main/usage/worktree-attribution/usage-worktree-history.test.ts create mode 100644 src/main/usage/worktree-attribution/usage-worktree-history.ts diff --git a/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs b/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs index 6235bdbe039..e0df9730978 100644 --- a/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs +++ b/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs @@ -255,7 +255,12 @@ export function createClaudeBenchmarkStore(arm, cacheFile, splitCacheFile, parse class BenchmarkStore extends implementation.UsageProviderStoreLifecycle { constructor() { super( - { getRepos: () => [], getAllWorktreeMeta: () => ({}) }, + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}), + getRememberedWorktreeDisplayNames: () => new Map(), + whenLoaded: async () => {} + }, { logTag: '[claude-persistence-benchmark]', resolveCacheFile: () => cacheFile, diff --git a/src/main/claude-usage/persisted-usage-test-fixture.ts b/src/main/claude-usage/persisted-usage-test-fixture.ts index 32b1f4ca370..fb7ecb3bfe5 100644 --- a/src/main/claude-usage/persisted-usage-test-fixture.ts +++ b/src/main/claude-usage/persisted-usage-test-fixture.ts @@ -118,7 +118,13 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string project, state, writeResumable, - createStore: () => new ClaudeUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) }), + createStore: () => + new ClaudeUsageStore({ + getRepos: () => [], + getAllWorktreeMeta: () => ({}), + getRememberedWorktreeDisplayNames: () => new Map(), + whenLoaded: async () => {} + }), decodedSources: async () => { const read = await readClaudeUsageSourceCache(sourceRef) return normalizeClaudeUsageSourceFiles(read.sources, read.verifiedSources).processedFiles diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 5272a268e19..da663d189de 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -12,7 +12,7 @@ import type { ClaudeUsageSummary } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations/history/automation-run' -import type { Store } from '../persistence/loading-store/store' +import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' import { scanClaudeUsageFilesViaWorker, @@ -79,7 +79,7 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< ClaudeUsagePersistedState, 'hasAnyClaudeData' > { - constructor(store: Pick) { + constructor(store: UsageWorktreeSource) { super(store, { tokenUsage: { provider: 'claude', diff --git a/src/main/codex-usage/store-test-harness.ts b/src/main/codex-usage/store-test-harness.ts index 8267abf6909..2cdd3f7937a 100644 --- a/src/main/codex-usage/store-test-harness.ts +++ b/src/main/codex-usage/store-test-harness.ts @@ -68,7 +68,9 @@ export function createWorktreeUsageSession(worktreeId: string) { export function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}) + getAllWorktreeMeta: () => ({}), + getRememberedWorktreeDisplayNames: () => new Map(), + whenLoaded: async () => {} }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 7e5f5515c4b..35e98288382 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -12,7 +12,7 @@ import type { CodexUsageSummary } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations/history/automation-run' -import type { Store } from '../persistence/loading-store/store' +import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' import type { AutomationUsageLookupInput } from './codex-automation-run-attribution' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' @@ -79,7 +79,7 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle< CodexUsagePersistedState, 'hasAnyCodexData' > { - constructor(store: Pick) { + constructor(store: UsageWorktreeSource) { super(store, { tokenUsage: { provider: 'codex', diff --git a/src/main/muse-usage/store.ts b/src/main/muse-usage/store.ts index da2ab662aff..1fba5514dcc 100644 --- a/src/main/muse-usage/store.ts +++ b/src/main/muse-usage/store.ts @@ -10,7 +10,7 @@ import type { MuseUsageSnapshot, MuseUsageSummary } from '../../shared/muse-usage-types' -import type { Store } from '../persistence/loading-store/store' +import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata' import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle' import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn' import { @@ -43,7 +43,7 @@ export class MuseUsageStore extends UsageProviderStoreLifecycle< MuseUsagePersistedState, 'hasAnyMuseData' > { - constructor(store: Pick) { + constructor(store: UsageWorktreeSource) { super(store, { logTag: '[muse-usage]', resolveCacheFile: getMuseUsageFile, diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index 790c6d9325b..47393511a4e 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -11,7 +11,7 @@ import type { OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' -import type { Store } from '../persistence/loading-store/store' +import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState, @@ -50,7 +50,7 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< OpenCodeUsagePersistedState, 'hasAnyOpenCodeData' > { - constructor(store: Pick) { + constructor(store: UsageWorktreeSource) { super(store, { tokenUsage: { provider: 'opencode', diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index b1e9fef30f0..e2f383b66ce 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -25,6 +25,7 @@ import { ClaudeUsageStore } from '../claude-usage/store' import { CodexUsageStore } from '../codex-usage/store' import { OpenCodeUsageStore } from '../opencode-usage/store' import { MuseUsageStore } from '../muse-usage/store' +import { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history' import { installRepoMaintenanceIdleGate } from '../workspaces/catalog/repo-maintenance-idle-gate' import { mainProcessState as state } from './main-process-state' import { runAfterFirstWindowShown } from './first-window-deferral' @@ -146,8 +147,13 @@ export function initializeMainProcessObservers(): void { initOnboardingCohortClassifier(store) state.stats = new StatsCollector() feedAgentSessionStats(state.stats, agentHookServer) - state.claudeUsage = new ClaudeUsageStore(store) - state.codexUsage = new CodexUsageStore(store) - state.openCodeUsage = new OpenCodeUsageStore(store) - state.museUsage = new MuseUsageStore(store) + const usageWorktrees = new UsageWorktreeHistory( + store, + join(getCanonicalUserDataPath(), 'orca-usage-worktree-history.json') + ) + state.usageWorktreeHistory = usageWorktrees + state.claudeUsage = new ClaudeUsageStore(usageWorktrees) + state.codexUsage = new CodexUsageStore(usageWorktrees) + state.openCodeUsage = new OpenCodeUsageStore(usageWorktrees) + state.museUsage = new MuseUsageStore(usageWorktrees) } diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 8a9aa70f2e7..1b5a696a187 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -240,7 +240,8 @@ function installWillQuitHandler(): void { state.claudeUsage?.flush(), state.codexUsage?.flush(), state.openCodeUsage?.flush(), - state.museUsage?.flush() + state.museUsage?.flush(), + state.usageWorktreeHistory?.flush() ]).then(() => {}) disposeDesktopBrowserBridge() const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index dc665ad1b1b..e2dd75c66aa 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -5,6 +5,7 @@ import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { MuseUsageStore } from '../muse-usage/store' +import type { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history' import type { CodexAccountService } from '../codex-accounts/service' import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' import type { ClaudeAccountService } from '../claude-accounts/service' @@ -84,6 +85,8 @@ export const mainProcessState = { openCodeUsage: null as OpenCodeUsageStore | null, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the store type assigned by main-process-observers. museUsage: null as MuseUsageStore | null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the history assigned by main-process-observers. + usageWorktreeHistory: null as UsageWorktreeHistory | null, codexAccounts: null as CodexAccountService | null, codexRuntimeHome: null as CodexRuntimeHomeService | null, codexSessionMigration: null as ReturnType | null, diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts index fe3ed77846b..23c12df6227 100644 --- a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts +++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts @@ -8,9 +8,11 @@ import { AnalyticsSessionIdStore, type AnalyticsSessionId } from '../reporting/token-usage/analytics-session-id-store' -import type { Store } from '../../persistence/loading-store/store' import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer' -import { loadKnownUsageWorktreesByRepo } from '../worktree-attribution/usage-worktree-metadata' +import { + loadKnownUsageWorktreesByRepo, + type UsageWorktreeSource +} from '../worktree-attribution/usage-worktree-metadata' import { createWorktreeRefs, @@ -44,14 +46,14 @@ export abstract class UsageProviderStoreLifecycle< private readonly inlineCache = new UsageInlineCachePreservation() constructor( - private readonly store: Pick, + private readonly store: UsageWorktreeSource, private readonly config: UsageProviderStoreLifecycleConfig ) { this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) const defaults = config.createDefaultState() this.schemaVersion = defaults.schemaVersion this.state = defaults - this.loaded = this.load() + this.loaded = Promise.all([this.load(), store.whenLoaded()]).then(() => {}) } /** Resolves once persisted state is in memory; synchronous readers must wait for it first. */ diff --git a/src/main/usage/worktree-attribution/usage-worktree-history.test.ts b/src/main/usage/worktree-attribution/usage-worktree-history.test.ts new file mode 100644 index 00000000000..e4f7c8f3d4b --- /dev/null +++ b/src/main/usage/worktree-attribution/usage-worktree-history.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { UsageWorktreeHistory } from './usage-worktree-history' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeSource } from './usage-worktree-metadata' + +const repo: Repo = { + id: 'repo-1', + path: '/src/app', + displayName: 'app', + badgeColor: '#000000', + addedAt: 0 +} + +function createStore(meta: Record) { + let listener: ((write: { worktreeId: string; next: { displayName: string } }) => void) | null = + null + return { + getRepos: () => [repo], + getAllWorktreeMeta: () => meta, + onWorktreeMetaWritten: (next: NonNullable) => { + listener = next + return () => {} + }, + write(worktreeId: string, displayName: string) { + meta[worktreeId] = { displayName } + listener?.({ worktreeId, next: meta[worktreeId] }) + }, + remove(worktreeId: string) { + delete meta[worktreeId] + } + } +} + +function knownWorktrees(source: UsageWorktreeSource) { + return loadKnownUsageWorktreesByRepo(source, [repo]) + .get('repo-1') + ?.map(({ path, displayName }) => [path, displayName]) +} + +describe('UsageWorktreeHistory', () => { + let dir: string | null = null + afterEach(() => { + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('keeps removed worktrees attributed, across restarts', async () => { + dir = mkdtempSync(join(tmpdir(), 'usage-worktree-history-')) + const file = join(dir, 'history.json') + const store = createStore({ 'repo-1::/ws/existing': { displayName: 'existing' } }) + const history = new UsageWorktreeHistory(store, file) + store.write('repo-1::/ws/short-lived', 'short-lived') + store.remove('repo-1::/ws/existing') + store.remove('repo-1::/ws/short-lived') + + const expected = [ + ['/src/app', 'app'], + ['/ws/existing', 'existing'], + ['/ws/short-lived', 'short-lived'] + ] + expect(knownWorktrees(history)).toEqual(expected) + + await history.flush() + const restarted = new UsageWorktreeHistory(createStore({}), file) + await restarted.whenLoaded() + expect(knownWorktrees(restarted)).toEqual(expected) + }) + + it('leaves a history file from a newer build untouched', async () => { + dir = mkdtempSync(join(tmpdir(), 'usage-worktree-history-')) + const file = join(dir, 'history.json') + const newer = '{"schemaVersion":2,"worktrees":[]}' + writeFileSync(file, newer) + const store = createStore({ 'repo-1::/ws/live': { displayName: 'live' } }) + const history = new UsageWorktreeHistory(store, file) + store.write('repo-1::/ws/new', 'new') + await history.flush() + expect(readFileSync(file, 'utf8')).toBe(newer) + }) + + it('lets a live worktree in another repo own a path a removed one used', () => { + const other: Repo = { ...repo, id: 'repo-2', path: '/src/other' } + const source: UsageWorktreeSource = { + getRepos: () => [repo, other], + getAllWorktreeMeta: () => ({ 'repo-2::/ws/feat': { displayName: 'live' } }), + getRememberedWorktreeDisplayNames: () => new Map([['repo-1::/ws/feat', 'removed']]), + whenLoaded: async () => {} + } + const known = loadKnownUsageWorktreesByRepo(source, [repo, other]) + expect(known.get('repo-1')?.map(({ path }) => path)).toEqual(['/src/app']) + expect(known.get('repo-2')?.map(({ path }) => path)).toEqual(['/src/other', '/ws/feat']) + }) +}) diff --git a/src/main/usage/worktree-attribution/usage-worktree-history.ts b/src/main/usage/worktree-attribution/usage-worktree-history.ts new file mode 100644 index 00000000000..f2fe58913f6 --- /dev/null +++ b/src/main/usage/worktree-attribution/usage-worktree-history.ts @@ -0,0 +1,119 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer' +import type { UsageWorktreeSource } from './usage-worktree-metadata' + +type HistoryStore = Pick & { + onWorktreeMetaWritten: (listener: (write: { worktreeId: string }) => void) => () => void +} + +const historyFileSchema = z.object({ + schemaVersion: z.literal(1), + worktrees: z.array(z.tuple([z.string(), z.string()])) +}) + +/** Remembers every worktree Orca has had, so removing one keeps its sessions in the Orca usage scope. */ +export class UsageWorktreeHistory implements UsageWorktreeSource { + private readonly displayNames = new Map() + private readonly writer: UsageCacheSnapshotWriter + private readonly loaded: Promise + private writable = true + + constructor( + private readonly store: HistoryStore, + file: string + ) { + this.writer = new UsageCacheSnapshotWriter('[usage-worktree-history]', () => file) + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + this.remember(worktreeId, meta.displayName) + } + // Record on every metadata write: worktrees are often created and deleted between usage scans. + // Read the live row, not the event: another host's write leaves the local row unchanged. + store.onWorktreeMetaWritten(({ worktreeId }) => { + const live = store.getAllWorktreeMeta()[worktreeId] + if (live && this.remember(worktreeId, live.displayName)) { + this.persist() + } + }) + this.loaded = readHistoryFile(file).then((saved) => { + if (!saved) { + this.writable = false + return + } + for (const [worktreeId, displayName] of saved) { + // Names recorded since startup are newer than the file's. + if (!this.displayNames.has(worktreeId)) { + this.displayNames.set(worktreeId, displayName) + } + } + if ([...this.displayNames].some(([worktreeId, name]) => saved.get(worktreeId) !== name)) { + this.write() + } + }) + } + + getRepos(): Repo[] { + return this.store.getRepos() + } + + getAllWorktreeMeta(): Record> { + return this.store.getAllWorktreeMeta() + } + + getRememberedWorktreeDisplayNames(): ReadonlyMap { + return this.displayNames + } + + whenLoaded(): Promise { + return this.loaded + } + + async flush(): Promise { + await this.loaded + await this.writer.flush() + } + + private remember(worktreeId: string, displayName: string | undefined): boolean { + const name = displayName ?? '' + if (this.displayNames.get(worktreeId) === name) { + return false + } + this.displayNames.set(worktreeId, name) + return true + } + + private persist(): void { + // Wait for the saved file, or this write would drop the worktrees only it remembers. + void this.loaded.then(() => this.write()) + } + + private write(): void { + if (!this.writable) { + return + } + void this.writer + .write(() => JSON.stringify({ schemaVersion: 1, worktrees: [...this.displayNames] })) + .catch(() => {}) + } +} + +/** Null when the file exists but can't be read or is from a newer build, so it is never overwritten. */ +async function readHistoryFile(file: string): Promise | null> { + let text: string + try { + text = await readFile(file, 'utf8') + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null + } + let json: unknown + try { + json = JSON.parse(text) + } catch { + // Unparseable: start over so the history can heal. + return new Map() + } + const parsed = historyFileSchema.safeParse(json) + return parsed.success ? new Map(parsed.data.worktrees) : null +} diff --git a/src/main/usage/worktree-attribution/usage-worktree-metadata.ts b/src/main/usage/worktree-attribution/usage-worktree-metadata.ts index 91d040da81d..62a994a194e 100644 --- a/src/main/usage/worktree-attribution/usage-worktree-metadata.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-metadata.ts @@ -6,8 +6,9 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' import { isFolderRepo } from '../../../shared/repo-kind' -import type { Store } from '../../persistence/loading-store/store' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import { getRepoRoutingConnectionId } from '../../../shared/execution-host' +import { canonicalWorktreePath } from '../../git/worktrees/catalog/worktree-path-comparison' export type UsageWorktreeRef = { worktreeId: string @@ -19,8 +20,17 @@ function getDefaultUsageWorktreeLabel(pathValue: string): string { return basename(pathValue) } +export type UsageWorktreeSource = { + getRepos: () => Repo[] + getAllWorktreeMeta: () => Record> + /** Worktrees seen before, removed ones included, keyed by id with their display names. */ + getRememberedWorktreeDisplayNames: () => ReadonlyMap + /** Resolves once remembered worktrees are read; attribution before that would drop removed ones. */ + whenLoaded: () => Promise +} + export function loadKnownUsageWorktreesByRepo( - store: Pick, + store: Pick, repos: Repo[] ): Map { const localRepos = repos.filter((repo) => !getRepoRoutingConnectionId(repo)) @@ -46,30 +56,44 @@ export function loadKnownUsageWorktreesByRepo( seenPathsByRepo.set(repo.id, new Set([repo.path])) } - // Background analytics must not spawn git scans that re-touch macOS protected folders. - for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + const claimedPaths = new Set(localRepos.map((repo) => canonicalWorktreePath(repo.path))) + const addWorktree = (worktreeId: string, displayName: string, remembered: boolean): void => { const parsed = splitWorktreeId(worktreeId) if (!parsed) { - continue + return } const repo = localReposById.get(parsed.repoId) if (!repo) { - continue + return } const worktreePath = isFolderRepo(repo) ? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath) : parsed.worktreePath const seenPaths = seenPathsByRepo.get(parsed.repoId) - if (seenPaths?.has(worktreePath)) { - continue + const canonicalPath = canonicalWorktreePath(worktreePath) + // Live rows claim paths first, so a reused path is never credited to a removed worktree. + if (seenPaths?.has(worktreePath) || (remembered && claimedPaths.has(canonicalPath))) { + return } seenPaths?.add(worktreePath) + claimedPaths.add(canonicalPath) worktreesByRepo.get(parsed.repoId)?.push({ worktreeId, path: worktreePath, - displayName: meta.displayName || getDefaultUsageWorktreeLabel(worktreePath) + displayName: displayName || getDefaultUsageWorktreeLabel(worktreePath) }) } + // Background analytics must not spawn git scans that re-touch macOS protected folders. + const liveMeta = store.getAllWorktreeMeta() + for (const [worktreeId, meta] of Object.entries(liveMeta)) { + addWorktree(worktreeId, meta.displayName, false) + } + for (const [worktreeId, displayName] of store.getRememberedWorktreeDisplayNames()) { + if (!Object.hasOwn(liveMeta, worktreeId)) { + addWorktree(worktreeId, displayName, true) + } + } + return worktreesByRepo } diff --git a/src/main/usage/worktree-attribution/usage-worktree-resolver.ts b/src/main/usage/worktree-attribution/usage-worktree-resolver.ts index 7d10a04a6e9..f1cfa70c065 100644 --- a/src/main/usage/worktree-attribution/usage-worktree-resolver.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-resolver.ts @@ -61,6 +61,13 @@ export async function createUsageWorktreeResolver( worktrees: readonly UsageScanWorktreeRef[] ): Promise { const canonicalized = await canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) + // Most sessions run at a worktree root; skip the linear containment scan for those. + const worktreeByPath = new Map() + for (const worktree of canonicalized) { + if (!worktreeByPath.has(worktree.canonicalPath)) { + worktreeByPath.set(worktree.canonicalPath, worktree) + } + } const worktreeByCwd = new Map() return (cwd) => { const memoized = worktreeByCwd.get(cwd) @@ -68,7 +75,8 @@ export async function createUsageWorktreeResolver( if (memoized !== undefined) { return memoized } - const resolved = findContainingWorktree(cwd, canonicalized) + const resolved = + worktreeByPath.get(normalizeFsPath(cwd)) ?? findContainingWorktree(cwd, canonicalized) worktreeByCwd.set(cwd, resolved) return resolved }