mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 02:04:57 +00:00
fix(cache): drop stale cursor-agent slices on days a Cursor import covers (#1710)
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / vscode (push) Waiting to run
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / vscode (push) Waiting to run
* fix(cache): drop stale cursor-agent slices on days a Cursor import covers On a day the import fully covers, the parse drops local cursor-agent (and Grok Bot, when the store has its rows) calls, so the fresh day has no slice for them and the merge carried the cached one on top of the imported rows. Adoption and the complete re-derive now drop those slices. Bump to v72 so caches that already carry them get repaired. * test(cache): keep Grok Bot slices an import holds no Grok Bot rows for
This commit is contained in:
parent
ef58a17b81
commit
b62e4a30ee
3 changed files with 153 additions and 4 deletions
|
|
@ -339,7 +339,7 @@ export async function removeCursorImport(): Promise<CoverageRange[] | null> {
|
|||
return store?.ranges ?? []
|
||||
}
|
||||
|
||||
type ImportCoverage = { ranges: Array<[number, number]>; providers: ReadonlySet<string> }
|
||||
export type ImportCoverage = { ranges: Array<[number, number]>; providers: ReadonlySet<string> }
|
||||
|
||||
export function coverageFor(store: CursorImportStore): ImportCoverage {
|
||||
const providers = new Set(REPLACED_PROVIDERS)
|
||||
|
|
@ -369,6 +369,15 @@ export function dropImportCoveredCalls<T extends { timestamp: string; calls: Arr
|
|||
return { ...turn, calls: kept, timestamp: kept[0]!.timestamp }
|
||||
}
|
||||
|
||||
/// Whether the import covers the whole local day `date` (YYYY-MM-DD), so no
|
||||
/// local call of a replaced provider on it is served.
|
||||
export function coversLocalDay(coverage: ImportCoverage, date: string): boolean {
|
||||
const [y, m, d] = date.split('-').map(Number) as [number, number, number]
|
||||
const start = new Date(y, m - 1, d).getTime()
|
||||
const end = new Date(y, m - 1, d + 1).getTime() - 1
|
||||
return coverage.ranges.some(([s, e]) => s <= start && e >= end)
|
||||
}
|
||||
|
||||
/// Cursor's own ids, mapped to the catalog ids CodeBurn prices and labels:
|
||||
/// Auto is the same `cursor-auto` the local parser reports, and Cursor-hosted
|
||||
/// Grok drops its `cursor-` prefix.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'path'
|
|||
|
||||
import { getCodeburnCacheDir, RETIRED_PROVIDER_NAMES } from './cache-dir.js'
|
||||
import { sweepSupersededCacheFiles } from './cache-sweep.js'
|
||||
import { coverageFor, coversLocalDay, loadCursorImport } from './cursor-import.js'
|
||||
import { projectOriginKey } from './git-origin.js'
|
||||
import type { ProjectFilterTarget } from './parser.js'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
|
@ -345,7 +346,11 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// daily-cache file of its own skipped its invalidation, so adoption carried the
|
||||
// local Cursor estimates back and the guard kept them over the imported events,
|
||||
// which are fewer calls. cursor joins PENDING_REDERIVE_PROVIDER_VERSIONS at 71.
|
||||
export const DAILY_CACHE_VERSION = 71
|
||||
// v72: on days a Cursor import fully covers, the parse produces no local
|
||||
// cursor-agent (or Grok Bot, when the import holds its rows) slice, so the
|
||||
// merge carried the old one on top of the imported rows. Adoption and the
|
||||
// complete re-derive now drop those slices; the bump repairs carried days.
|
||||
export const DAILY_CACHE_VERSION = 72
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
@ -848,7 +853,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
}
|
||||
let days = base.days
|
||||
for (const { parsed } of rest) {
|
||||
days = mergeDayEntries(days, migrateDays(parsed.days), true)
|
||||
days = mergeDayEntries(days, await withoutImportReplacedSlices(migrateDays(parsed.days)), true)
|
||||
}
|
||||
// loadDailyCache has standalone readers, so the adopted result must already
|
||||
// satisfy the cache's own invariants: no today/future entries (they would be
|
||||
|
|
@ -1357,6 +1362,25 @@ function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDa
|
|||
}
|
||||
}
|
||||
|
||||
/// A Cursor usage import stands in for the replaced providers on the days it
|
||||
/// covers, and the parse drops their local calls there. A cached slice of one
|
||||
/// of them on such a day is an old local estimate no parse produces again, so
|
||||
/// merging it would stack it on the imported rows. Days before the backfill
|
||||
/// window stay: the re-derive cannot rebuild their imported rows.
|
||||
async function withoutImportReplacedSlices(days: DailyEntry[]): Promise<DailyEntry[]> {
|
||||
const coverage = await loadCursorImport().then(s => s && coverageFor(s), () => null)
|
||||
if (!coverage) return days
|
||||
const now = new Date()
|
||||
const backfillStart = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
|
||||
return days.flatMap(day => {
|
||||
const replaced = Object.keys(day.providers).filter(p => coverage.providers.has(p))
|
||||
if (replaced.length === 0 || day.date < backfillStart || !coversLocalDay(coverage, day.date)) return [day]
|
||||
const copy = structuredClone(day)
|
||||
for (const provider of replaced) subtractSliceFromDay(copy, provider, copy.providers[provider]!)
|
||||
return hasPositiveDayContent(copy) ? [copy] : []
|
||||
})
|
||||
}
|
||||
|
||||
/// Did the tz subtraction leave any positive data on a carried baseline day?
|
||||
/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the
|
||||
/// day level, extended to the day's other scalar and nested content.
|
||||
|
|
@ -1838,7 +1862,7 @@ export async function ensureCacheHydrated(
|
|||
// Without the subtraction an old-zone slice is never a partial survivor
|
||||
// of the fresh one, so the guard would swap a shifted day back in.
|
||||
const merged = parseWasComplete
|
||||
? mergeDayEntries(freshDays, carriedBaseline, true, tzSubtraction, !tzChanged || tzSubtraction !== undefined, pendingRederive)
|
||||
? mergeDayEntries(freshDays, await withoutImportReplacedSlices(carriedBaseline), true, tzSubtraction, !tzChanged || tzSubtraction !== undefined, pendingRederive)
|
||||
: mergeDayEntries(baseline, freshDays, false)
|
||||
// Only the complete re-derive re-parses the whole window, so freshDays is
|
||||
// the authoritative record set and every non-carried merged day should be
|
||||
|
|
|
|||
116
tests/daily-cache-cursor-import.test.ts
Normal file
116
tests/daily-cache-cursor-import.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'fs/promises'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
|
||||
import { CURSOR_CSV_HEADER, importCursorCsv, replacedProviders } from '../src/cursor-import.js'
|
||||
import { DAILY_CACHE_VERSION, dailyCachePath, ensureCacheHydrated, invalidateProviderDays, toDateString, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
|
||||
const DAY = 86_400_000
|
||||
const base = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()) - 20 * DAY
|
||||
const iso = (offsetDays: number, hour = 12) => new Date(base + offsetDays * DAY + hour * 3_600_000).toISOString()
|
||||
|
||||
const HOME = mkdtempSync(join(tmpdir(), 'daily-cache-cursor-import-home-'))
|
||||
let root: string
|
||||
|
||||
async function writeAgentTranscript(id: string, mtimeMs: number): Promise<void> {
|
||||
const dir = join(homedir(), '.cursor', 'projects', 'proj', 'agent-transcripts')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, `${id}.txt`)
|
||||
await writeFile(path, `user:\n<user_query>question ${id}</user_query>\nA:\nanswer ${'x'.repeat(400)}\n`)
|
||||
await utimes(path, mtimeMs / 1000, mtimeMs / 1000)
|
||||
}
|
||||
|
||||
async function writeCsv(): Promise<string> {
|
||||
const q = (v: string | number) => `"${v}"`
|
||||
const row = [iso(1, 9), '', '', 'Included', 'auto', 'No', 0, 100, 0, 50, 150, 'Included'].map(q).join(',')
|
||||
const path = join(root, 'usage.csv')
|
||||
await writeFile(path, [CURSOR_CSV_HEADER.join(','), row].join('\n') + '\n')
|
||||
const savedAt = (base + 30 * DAY) / 1000
|
||||
await utimes(path, savedAt, savedAt)
|
||||
return path
|
||||
}
|
||||
|
||||
const hydrate = () => {
|
||||
clearSessionCache()
|
||||
return ensureCacheHydrated((range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays)
|
||||
}
|
||||
const slice = (days: DailyEntry[], date: string, provider: string) => days.find(d => d.date === date)?.providers[provider]
|
||||
|
||||
// What a version bump finds: the current file's days under an older version
|
||||
// number, and no current file.
|
||||
async function demoteCurrentFileTo(version: number): Promise<void> {
|
||||
const current = JSON.parse(await readFile(dailyCachePath(), 'utf-8'))
|
||||
await writeFile(join(process.env['CODEBURN_CACHE_DIR']!, `daily-cache.v${version}.json`), JSON.stringify({ ...current, version }))
|
||||
await rm(dailyCachePath())
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'daily-cache-cursor-import-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
|
||||
process.env['HOME'] = HOME
|
||||
process.env['USERPROFILE'] = HOME
|
||||
await rm(HOME, { recursive: true, force: true })
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
describe('cursor-agent slices on days a Cursor import covers', () => {
|
||||
const coveredDay = toDateString(new Date(base + DAY + 5 * 3_600_000))
|
||||
|
||||
it('a re-derive after an import this build never invalidated drops the old cursor-agent slice', async () => {
|
||||
await writeAgentTranscript('inside', base + DAY + 5 * 3_600_000)
|
||||
const sealed = await hydrate()
|
||||
expect(slice(sealed.days, coveredDay, 'cursor-agent')?.calls).toBe(1)
|
||||
|
||||
await demoteCurrentFileTo(34)
|
||||
await importCursorCsv(await writeCsv())
|
||||
clearSessionCache()
|
||||
const live = (await parseAllSessions({ start: new Date(base - 10 * DAY), end: new Date(base + 10 * DAY) }, 'all'))
|
||||
.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
||||
expect(live.filter(c => c.provider === 'cursor-agent')).toHaveLength(0)
|
||||
|
||||
const after = await hydrate()
|
||||
expect(slice(after.days, coveredDay, 'cursor')?.calls).toBe(1)
|
||||
expect(slice(after.days, coveredDay, 'cursor-agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a Grok Bot slice the import holds no Grok Bot rows for', async () => {
|
||||
await writeAgentTranscript('inside', base + DAY + 5 * 3_600_000)
|
||||
await hydrate()
|
||||
await demoteCurrentFileTo(34)
|
||||
const oldPath = join(process.env['CODEBURN_CACHE_DIR']!, 'daily-cache.v34.json')
|
||||
const old = JSON.parse(await readFile(oldPath, 'utf-8'))
|
||||
const day = old.days.find((d: DailyEntry) => d.date === coveredDay)
|
||||
day.providers.grokbot = structuredClone(day.providers['cursor-agent'])
|
||||
day.calls += day.providers.grokbot.calls
|
||||
day.cost += day.providers.grokbot.cost
|
||||
await writeFile(oldPath, JSON.stringify(old))
|
||||
|
||||
await importCursorCsv(await writeCsv())
|
||||
const after = await hydrate()
|
||||
expect(slice(after.days, coveredDay, 'grokbot')?.calls).toBe(1)
|
||||
expect(slice(after.days, coveredDay, 'cursor-agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the next version bump does not adopt the cursor-agent slice back from an older file', async () => {
|
||||
await writeAgentTranscript('inside', base + DAY + 5 * 3_600_000)
|
||||
await hydrate()
|
||||
await demoteCurrentFileTo(34)
|
||||
|
||||
const s = await importCursorCsv(await writeCsv())
|
||||
expect(s.changed).toBe(true)
|
||||
await invalidateProviderDays(replacedProviders(), toDateString(new Date(s.coverage.start)), toDateString(new Date(s.coverage.end)))
|
||||
const firstRun = await hydrate()
|
||||
expect(slice(firstRun.days, coveredDay, 'cursor-agent')).toBeUndefined()
|
||||
expect(slice(firstRun.days, coveredDay, 'cursor')?.calls).toBe(1)
|
||||
|
||||
await demoteCurrentFileTo(DAILY_CACHE_VERSION - 1)
|
||||
const nextBump = await hydrate()
|
||||
expect(slice(nextBump.days, coveredDay, 'cursor')?.calls).toBe(1)
|
||||
expect(slice(nextBump.days, coveredDay, 'cursor-agent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue