mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
tokenUsage: {
|
||||
provider: 'claude',
|
||||
|
||||
@@ -68,7 +68,9 @@ export function createWorktreeUsageSession(worktreeId: string) {
|
||||
export function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
|
||||
const store = new CodexUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({})
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
|
||||
;(store as unknown as { state: CodexUsagePersistedState }).state = {
|
||||
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
tokenUsage: {
|
||||
provider: 'codex',
|
||||
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
logTag: '[muse-usage]',
|
||||
resolveCacheFile: getMuseUsageFile,
|
||||
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
tokenUsage: {
|
||||
provider: 'opencode',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<typeof createCodexSessionMigrationScheduler> | null,
|
||||
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>,
|
||||
private readonly store: UsageWorktreeSource,
|
||||
private readonly config: UsageProviderStoreLifecycleConfig<SourceKey, State, DataPresenceKey>
|
||||
) {
|
||||
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. */
|
||||
|
||||
@@ -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<string, { displayName: string }>) {
|
||||
let listener: ((write: { worktreeId: string; next: { displayName: string } }) => void) | null =
|
||||
null
|
||||
return {
|
||||
getRepos: () => [repo],
|
||||
getAllWorktreeMeta: () => meta,
|
||||
onWorktreeMetaWritten: (next: NonNullable<typeof listener>) => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<UsageWorktreeSource, 'getRepos' | 'getAllWorktreeMeta'> & {
|
||||
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<string, string>()
|
||||
private readonly writer: UsageCacheSnapshotWriter
|
||||
private readonly loaded: Promise<void>
|
||||
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<string, Pick<WorktreeMeta, 'displayName'>> {
|
||||
return this.store.getAllWorktreeMeta()
|
||||
}
|
||||
|
||||
getRememberedWorktreeDisplayNames(): ReadonlyMap<string, string> {
|
||||
return this.displayNames
|
||||
}
|
||||
|
||||
whenLoaded(): Promise<void> {
|
||||
return this.loaded
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
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<Map<string, string> | 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
|
||||
}
|
||||
@@ -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<string, Pick<WorktreeMeta, 'displayName'>>
|
||||
/** Worktrees seen before, removed ones included, keyed by id with their display names. */
|
||||
getRememberedWorktreeDisplayNames: () => ReadonlyMap<string, string>
|
||||
/** Resolves once remembered worktrees are read; attribution before that would drop removed ones. */
|
||||
whenLoaded: () => Promise<void>
|
||||
}
|
||||
|
||||
export function loadKnownUsageWorktreesByRepo(
|
||||
store: Pick<Store, 'getAllWorktreeMeta'>,
|
||||
store: Pick<UsageWorktreeSource, 'getAllWorktreeMeta' | 'getRememberedWorktreeDisplayNames'>,
|
||||
repos: Repo[]
|
||||
): Map<string, UsageWorktreeRef[]> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ export async function createUsageWorktreeResolver(
|
||||
worktrees: readonly UsageScanWorktreeRef[]
|
||||
): Promise<UsageWorktreeResolver> {
|
||||
const canonicalized = await canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
||||
// Most sessions run at a worktree root; skip the linear containment scan for those.
|
||||
const worktreeByPath = new Map<string, UsageScanWorktreeRef>()
|
||||
for (const worktree of canonicalized) {
|
||||
if (!worktreeByPath.has(worktree.canonicalPath)) {
|
||||
worktreeByPath.set(worktree.canonicalPath, worktree)
|
||||
}
|
||||
}
|
||||
const worktreeByCwd = new Map<string, UsageScanWorktreeRef | null>()
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user