diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index fbdc56bb5..f938dd815 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -476,6 +476,34 @@ describe('scanOpenCodeUsageDatabases', () => { expect(sessionOne?.eventCount).toBe(1) }) + it('reuses a fully-duplicate backup instead of reparsing it when the live db changes', async () => { + // The backup only holds stale copies of sessions the canonical db already + // owns, so it owns nothing. When the live db changes it must not be + // demoted back into the parse set — there is no claim to reclaim. + const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]]) + writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]]) + + const first = await scanOpenCodeUsageDatabases([], []) + const firstBackup = first.processedDatabases.find((database) => + database.path.endsWith('opencode-backup.db') + ) + expect(firstBackup?.ownedSessionIds).toEqual([]) + + const db = new Database(canonicalPath) + insertSessionTotalsRow(db, 'session-2', 200) + db.close() + + const second = await scanOpenCodeUsageDatabases([], first.processedDatabases) + const secondBackup = second.processedDatabases.find((database) => + database.path.endsWith('opencode-backup.db') + ) + // A reused cache entry is the same object; a reparse would produce a new one. + expect(secondBackup).toBe(firstBackup) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1200) + }) + it('lets the live database reclaim sessions after a sticky backup-only claim', async () => { // First scan only has the backup (e.g. live db temporarily missing), so it // owns session-1 at the stale snapshot. When opencode.db reappears with diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 4e2a39635..57a95df3a 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -935,11 +935,16 @@ export async function scanOpenCodeUsageDatabases( // Why: a sticky backup claim from a scan where opencode.db was missing would // otherwise freeze a still-growing session at the backup snapshot when the - // live db reappears. Reparse lower-priority siblings whenever a higher- - // priority path is being re-evaluated so the live db can reclaim sessions - // without double-counting cached backup aggregates. + // live db reappears. Reparse a lower-priority sibling only when it still owns + // sessions a higher-priority path could reclaim; a sibling that owns nothing + // (the common case once the live db has claimed every shared session) has no + // claim to give back, so reparsing it every time the live db changes is pure + // work. const demotedReusePaths: string[] = [] - for (const dbPath of reusedByPath.keys()) { + for (const [dbPath, reused] of reusedByPath) { + if ((reused.ownedSessionIds?.length ?? 0) === 0) { + continue + } const higherPriorityParsing = pathsToParse.some( (candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0 )