fix(cache): keep midnight-straddling turns whole on the settle reseal (#1709)

The seal-time re-derive parsed from today-7, so a turn crossing that midnight was cut and its later part re-anchored to the window start, while the day before kept the whole turn. Category cost, turn and session counts on the window start were counted twice.
Parse one day earlier when re-deriving already sealed days and keep only the window. A stale watermark still parses from its first unsealed day, as in 0.9.25.
This commit is contained in:
Resham Joshi 2026-10-10 02:56:08 +02:00 • committed by GitHub
parent 18c4d4043f
commit 5253b89286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 96 additions and 1 deletions

View file

@ -1820,7 +1820,14 @@ export async function ensureCacheHydrated(
// transcript cleaned up or a partial provider sync looks the same.
const settleStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - SETTLE_DAYS)
const gapRange: DateRange = { start: settleStart < gapStart ? settleStart : gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
// A re-derived window starts on a day sealed together with the day before
// it, which already holds any turn crossing that midnight. Parse from that
// day so the turn stays whole there instead of being cut and its later
// part counted again on the window start.
const parseStart = settleStart < gapStart
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - SETTLE_DAYS - 1)
: gapStart
const gapProjects = await parseSessions({ start: parseStart, end: yesterdayEnd })
const gapDays = daysInRange(aggregateDays(gapProjects), gapRange)
const parseWasComplete = sessionComplete()
const priorWatermark = c.lastComputedDate

View file

@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { DAILY_CACHE_VERSION, currentTzKey, ensureCacheHydrated, saveDailyCache, 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'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'reseal-straddle-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
clearSessionCache()
await loadPricing()
})
afterEach(async () => {
clearSessionCache()
await rm(tmpDir, { recursive: true, force: true })
})
const now = new Date()
const local = (daysAgo: number, h = 0, m = 0) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, h, m)
const CWD = '/work/app'
const user = (ts: Date) => JSON.stringify({
type: 'user', sessionId: 's1', timestamp: ts.toISOString(), cwd: CWD, message: { role: 'user', content: 'refactor the parser' },
})
const assistant = (id: string, ts: Date, input: number) => JSON.stringify({
type: 'assistant', sessionId: 's1', timestamp: ts.toISOString(), cwd: CWD,
message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: input, output_tokens: 0 } },
})
const catCost = (d: DailyEntry | undefined) => Object.values(d?.categories ?? {}).reduce((s, c) => s + c.cost, 0)
const catTurns = (d: DailyEntry | undefined) => Object.values(d?.categories ?? {}).reduce((s, c) => s + c.turns, 0)
// One turn: user at D 23:58, m1 at 23:59 ($3), m2 at D+1 00:01 ($9).
// D = today-8, D+1 = today-7 (the first day of the settle window).
async function sealThenHydrate(sealedThroughDaysAgo: number) {
const dir = join(tmpDir, 'projects', '-work-app')
await mkdir(dir, { recursive: true })
const file = join(dir, 's1.jsonl')
const lastCall = local(7, 0, 1)
await writeFile(file, [
user(local(8, 23, 58)),
assistant('m1', local(8, 23, 59), 1_000_000),
assistant('m2', lastCall, 3_000_000),
].join('\n') + '\n')
await utimes(file, lastCall, lastCall)
const sealedThrough = toDateString(local(sealedThroughDaysAgo))
const firstSealDays = aggregateProjectsIntoDays(await parseAllSessions({ start: local(30), end: new Date(local(sealedThroughDaysAgo - 1).getTime() - 1) }, 'all'))
.filter(d => d.date <= sealedThrough)
await saveDailyCache({
version: DAILY_CACHE_VERSION, savingsConfigHash: '', tzKey: currentTzKey(),
lastComputedDate: sealedThrough, complete: true, watermarkTrusted: true, days: firstSealDays,
})
clearSessionCache()
const c = await ensureCacheHydrated((range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays)
const sealed = firstSealDays.filter(d => d.date === toDateString(local(8)) || d.date === toDateString(local(7)))
const after = c.days.filter(d => d.date === toDateString(local(8)) || d.date === toDateString(local(7)))
return { sealed, after }
}
const sum = (days: DailyEntry[], f: (d: DailyEntry) => number) => days.reduce((s, d) => s + f(d), 0)
describe('settle-window reseal of a midnight-straddling turn', () => {
it('does not count the post-midnight part again when D+1 becomes the window start', async () => {
const { sealed, after } = await sealThenHydrate(2)
expect(sum(sealed, catCost)).toBeCloseTo(12, 10)
expect(sum(after, d => d.cost)).toBeCloseTo(12, 10)
expect(sum(after, d => d.calls)).toBe(2)
expect(sum(after, catCost)).toBeCloseTo(12, 10)
expect(sum(after, catTurns)).toBe(sum(sealed, catTurns))
})
it('still gives D+1 its part when D was sealed alone and D+1 is the first unsealed day', async () => {
const { sealed, after } = await sealThenHydrate(8)
expect(sum(sealed, catCost)).toBeCloseTo(3, 10)
expect(sum(after, d => d.cost)).toBeCloseTo(12, 10)
expect(sum(after, catCost)).toBeCloseTo(12, 10)
})
})