f77687a8b3
Merge resolution - daily-cache: main shipped v20 with #1040, so 21 is now the first FREE number rather than one dodging an unmerged head. Both version notes kept, ours on top. The re-derivation seed stays one below current (20), now the shipped predecessor rather than a draft. - upgrade-path: expected daily cache filename stays v21. The rollup is cumulative across resume legs — already handled, now pinned Measured on a real 3-leg session (CLI 1.0.80): every counter in a leg includes the legs before it, and the last leg of a complete session equals its store-row total to the nano. The parser has always emitted `cumulative - previous cumulative` per model, so the interval arithmetic downstream already consumes per-leg claims; it was the PR body and the provider doc that described the raw journal as per-leg and misled the reader. Doc corrected, and (c3) pins it end to end: a complete 3-leg session serves 900 (its last leg / store total), not 1,400 (the sum of its legs); an uncovered one serves 900 exactly once; and a leg reporting LESS than its predecessor is taken as a fresh epoch rather than clamped to a negative delta, so an older per-leg CLI never loses a leg. initiator = 'compaction' replaces the timestamp heuristic where it exists The summarization request does write its own assistant_usage_events row, and newer stores label it. The label is now read — schema-adaptively, and the enrichment select is a graduated chain so a store carrying the billing columns but not `initiator` keeps its billing metadata instead of falling all the way back to the base select. Two uses: the row is subtracted from the leg it belongs to even though it commits before the compaction stamp, and it is kept out of per-turn pairing since it has no assistant.message to pair with. Optional twice over — absent on older stores, NULL on 1,504 of 2,509 rows on a real one — so (c5) pins the labelled path at 350, the identical UNLABELLED fixture at 400 (the documented one-request-per-compaction over-serve, which no timestamp rule can close: the request that triggered the compaction completes immediately before it too), and a compaction row never stealing a pairing partner. Attribution invariant, standing guard for the 1.8x report (c4) runs `codeburn audit`'s own two numbers — attributed vs recomputed — over all four combinations of the three representations a session can be written in, at the magnitudes of a real reported day (gpt-5.6-terra, 146 rows, input 17,792 / cache write 501,395 / cache read 12,097,364 / output 63,344 / reasoning 24,831, billed $4.47). Every shape reconciles at 1.000, and the covered case lands on $4.4687 = what the tokens price at = what GitHub billed, with output 63,344 rather than the pre-fix 88,175 that re-priced reasoning on top of itself. upgrade-path: a day KEY is not the unit of never-lose A parse change that re-dates a call to its true day legitimately empties one day and fills its neighbour, token for token — observed on a real cache where 2026-08-08's single call moved to 08-07 exactly. The aging step now compares a +/-1 day WINDOW rather than the day alone, so only a window that shrinks is a loss, and an emptied day key is reported rather than failed. RECONCILE_SETTLE_MS keeps 24h, with the measurement that argues against it recorded at the constant and in the provider doc: across 91 real sessions zero rows landed after shutdown (median -0.1s, max -0.0s). One machine, one CLI version — the number to beat is seconds, not hours, once a second machine agrees.
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import {
|
|
currentTzKey,
|
|
ensureCacheHydrated,
|
|
toDateString,
|
|
type DailyEntry,
|
|
} from '../src/daily-cache.js'
|
|
|
|
// One below the current version, so this pins the ADJACENT-version case: v20
|
|
// is the SHIPPED predecessor (#1040, codex model attribution), and its days
|
|
// must be re-derived rather than adopted as finalized under a number that now
|
|
// means different accounting. Anything below MIN_SUPPORTED_VERSION is
|
|
// untrusted, which is what makes the re-derivation global rather than
|
|
// provider-scoped.
|
|
const PRE_FIX_DAILY_VERSION = 20
|
|
const cacheRoot = join(tmpdir(), `codeburn-daily-rederive-${process.pid}-${Date.now()}`)
|
|
|
|
function day(date: string, cost: number): DailyEntry {
|
|
return {
|
|
date,
|
|
cost,
|
|
savingsUSD: 0,
|
|
calls: 1,
|
|
sessions: 1,
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 30,
|
|
cacheWriteTokens: 0,
|
|
editTurns: 0,
|
|
oneShotTurns: 0,
|
|
models: {
|
|
'Grok Build': {
|
|
calls: 1,
|
|
cost,
|
|
savingsUSD: 0,
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 30,
|
|
cacheWriteTokens: 0,
|
|
},
|
|
},
|
|
categories: {},
|
|
providers: {
|
|
grok: {
|
|
calls: 1,
|
|
cost,
|
|
savingsUSD: 0,
|
|
sessions: 1,
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 30,
|
|
cacheWriteTokens: 0,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
|
|
await rm(cacheRoot, { recursive: true, force: true })
|
|
await mkdir(cacheRoot, { recursive: true })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(cacheRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider - the
|
|
// daily cache has no per-provider invalidation. Which provider the seeded day
|
|
// belongs to is incidental; the mechanism under test is version-wide.
|
|
describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => {
|
|
it('re-derives a day from a below-minimum v20 cache while preserving the old file', async () => {
|
|
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
|
const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
|
|
const oldPath = join(cacheRoot, `daily-cache.v${PRE_FIX_DAILY_VERSION}.json`)
|
|
const oldCache = {
|
|
version: PRE_FIX_DAILY_VERSION,
|
|
savingsConfigHash: 'cfg',
|
|
tzKey: currentTzKey(),
|
|
lastComputedDate: yesterday,
|
|
days: [day(date, 99)],
|
|
complete: true,
|
|
watermarkTrusted: true,
|
|
}
|
|
await writeFile(oldPath, JSON.stringify(oldCache))
|
|
|
|
let parseCount = 0
|
|
const corrected = day(date, 2)
|
|
const hydrated = await ensureCacheHydrated(
|
|
async () => {
|
|
parseCount++
|
|
return []
|
|
},
|
|
() => [corrected],
|
|
'cfg',
|
|
() => true,
|
|
)
|
|
|
|
const refreshedDay = hydrated.days.find(entry => entry.date === date)
|
|
expect(parseCount).toBe(1)
|
|
expect(refreshedDay?.providers.grok?.cost).toBe(2)
|
|
expect(refreshedDay?.cost).toBe(2)
|
|
expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
|
|
})
|
|
})
|