mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 03:05:24 +00:00
fix: one timezone per daily cache, re-derive settling days on seal (#1696)
Cache adoption keeps one timezone and prefers complete parses. Sealing a day re-derives the last seven, and a sealed day only takes a fresh parse with at least as many calls.
This commit is contained in:
parent
eb51aba46a
commit
b875d9ca1d
3 changed files with 134 additions and 16 deletions
|
|
@ -798,7 +798,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
} catch {
|
||||
return emptyCache()
|
||||
}
|
||||
const candidates: { parsed: AdoptableCache; mtimeMs: number }[] = []
|
||||
let candidates: { parsed: AdoptableCache; mtimeMs: number }[] = []
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('daily-cache') || !name.includes('.json')) continue
|
||||
if (name === DAILY_CACHE_FILENAME) continue
|
||||
|
|
@ -815,9 +815,24 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
}
|
||||
}
|
||||
if (candidates.length === 0) return emptyCache()
|
||||
// Priority: newer schema first, then most recently written. Higher priority
|
||||
// wins per (day, provider); lower priority only fills what is missing.
|
||||
candidates.sort((a, b) => (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs))
|
||||
// Priority: files finalized by a complete parse first (an interrupted
|
||||
// backfill can hold under-read days), then newer schema, then most recently
|
||||
// written. Higher priority wins per (day, provider); lower priority only
|
||||
// fills what is missing. Not "more calls wins": older generations hold both
|
||||
// days a later re-derive truncated and calls a later dedup removed, and a
|
||||
// call count cannot tell the two apart.
|
||||
candidates.sort((a, b) => Number(b.parsed.complete === true) - Number(a.parsed.complete === true) || (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs))
|
||||
// A date is a local-midnight bucket, so two files written under different
|
||||
// timezones hold different hours under the same date and any per-slice union
|
||||
// of them counts the hours between the two midnights twice. Adopt one
|
||||
// timezone only: the machine's when some file has it, else the top file's,
|
||||
// and tag the result with the zone it was bucketed in. Files from before
|
||||
// tzKey existed cannot be told apart and stay in.
|
||||
const machineTz = currentTzKey()
|
||||
const adoptTz = candidates.some(c => c.parsed.tzKey === machineTz)
|
||||
? machineTz
|
||||
: candidates.find(c => c.parsed.tzKey !== undefined)?.parsed.tzKey ?? machineTz
|
||||
candidates = candidates.filter(c => c.parsed.tzKey === undefined || c.parsed.tzKey === adoptTz)
|
||||
|
||||
let base: DailyCache
|
||||
let rest = candidates
|
||||
|
|
@ -848,6 +863,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
}
|
||||
const adopted: DailyCache = {
|
||||
...base,
|
||||
tzKey: adoptTz,
|
||||
lastComputedDate,
|
||||
days,
|
||||
// Anything adopted out of an OLDER file was derived under an older
|
||||
|
|
@ -1413,9 +1429,14 @@ export function mergeDayEntries(
|
|||
/// a slice it could not produce at all is carried by the branch above,
|
||||
/// exactly as before.
|
||||
pendingRederive?: ReadonlySet<string>,
|
||||
/// Days before this date count as settled for the guard even inside the
|
||||
/// settle window: the gap path passes its first unsealed day, so a day it
|
||||
/// re-derives after sealing it before can grow or re-price but never shrink.
|
||||
sealedBefore?: string,
|
||||
): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
const settleCutoff = settleCutoffDate(new Date())
|
||||
const windowCutoff = settleCutoffDate(new Date())
|
||||
const settleCutoff = sealedBefore && sealedBefore > windowCutoff ? sealedBefore : windowCutoff
|
||||
for (const day of primary) byDate.set(day.date, structuredClone(day))
|
||||
for (const day of secondary) {
|
||||
const existing = byDate.get(day.date)
|
||||
|
|
@ -1784,7 +1805,16 @@ export async function ensureCacheHydrated(
|
|||
: new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||||
|
||||
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||||
// Sealing a new day also re-derives the still-settling ones before it. A
|
||||
// day sealed once is otherwise frozen, while every surface that parses
|
||||
// that date live (report --day, a week period) prefers the live parse
|
||||
// whenever it finds more calls: a call that landed after the seal, or a
|
||||
// price change since, then made history.daily disagree with report for
|
||||
// the same date. The merge below lets the fresh parse win there when it
|
||||
// explains at least as many calls; a sealed day never shrinks, since a
|
||||
// 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)
|
||||
const gapDays = daysInRange(aggregateDays(gapProjects), gapRange)
|
||||
const parseWasComplete = sessionComplete()
|
||||
|
|
@ -1798,7 +1828,7 @@ export async function ensureCacheHydrated(
|
|||
// partial one only fills days and slices the baseline lacks, so the gap
|
||||
// merge is strictly additive and no cached data can shrink.
|
||||
const merged = parseWasComplete
|
||||
? mergeDayEntries(gapDays, c.days, false, undefined, true)
|
||||
? mergeDayEntries(gapDays, c.days, false, undefined, true, undefined, toDateString(gapStart))
|
||||
: mergeDayEntries(c.days, gapDays, false)
|
||||
// Finalize as complete ONLY when the session parse that produced these days
|
||||
// was itself complete. If it was partial, leave `complete: false` so the
|
||||
|
|
|
|||
|
|
@ -449,6 +449,46 @@ describe('adoption union across older cache files', () => {
|
|||
expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json.v9.bak'), 'utf-8'))).toEqual(JSON.parse(JSON.stringify(v9bak)))
|
||||
})
|
||||
|
||||
it('never mixes days from a file written under another timezone', async () => {
|
||||
const foreignTz = currentTzKey() === 'UTC' ? 'America/Los_Angeles' : 'UTC'
|
||||
const local = {
|
||||
version: 27, tzKey: currentTzKey(), complete: true, lastComputedDate: '2026-07-28',
|
||||
days: [day('2026-07-27', { claude: slice(307.02, 900) })],
|
||||
}
|
||||
const foreign = {
|
||||
version: 28, tzKey: foreignTz, complete: true, lastComputedDate: '2026-07-28',
|
||||
days: [day('2026-07-27', { claude: slice(625.71, 1500) }), day('2026-07-28', { codex: slice(40, 30) })],
|
||||
}
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v27.json'), JSON.stringify(local), 'utf-8')
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(foreign), 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.days).toHaveLength(1)
|
||||
expect(cache.days[0]).toMatchObject({ date: '2026-07-27', cost: 307.02, calls: 900 })
|
||||
expect(cache.tzKey).toBe(currentTzKey())
|
||||
})
|
||||
|
||||
it('adopts a foreign-timezone history whole and tagged when it is all there is', async () => {
|
||||
const foreignTz = currentTzKey() === 'UTC' ? 'America/Los_Angeles' : 'UTC'
|
||||
const onlyFile = {
|
||||
version: 28, tzKey: foreignTz, complete: true, lastComputedDate: '2026-07-28',
|
||||
days: [day('2026-07-27', { claude: slice(625.71, 1500) })],
|
||||
}
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(onlyFile), 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.days[0]).toMatchObject({ date: '2026-07-27', cost: 625.71 })
|
||||
// Tagged with the zone it was bucketed in, so hydration re-buckets it.
|
||||
expect(cache.tzKey).toBe(foreignTz)
|
||||
})
|
||||
|
||||
it('a file finalized by a complete parse outranks a newer interrupted one', async () => {
|
||||
const complete = { version: 27, complete: true, days: [day('2026-07-27', { claude: slice(300, 900) })] }
|
||||
const interrupted = { version: 28, complete: false, days: [day('2026-07-27', { claude: slice(20, 40) })] }
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v27.json'), JSON.stringify(complete), 'utf-8')
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(interrupted), 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.days[0]).toMatchObject({ cost: 300, calls: 900 })
|
||||
})
|
||||
|
||||
it('skips malformed candidates without failing the adoption', async () => {
|
||||
const good = {
|
||||
version: 12,
|
||||
|
|
|
|||
|
|
@ -263,10 +263,10 @@ describe('daily cache: a residue-only day is re-derived, not served', () => {
|
|||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
// The watermark moved back to daysAgoStr(4), so the gap parse started on
|
||||
// the residue day itself instead of skipping it forever.
|
||||
// The watermark moved back to daysAgoStr(4), so the gap parse covers the
|
||||
// residue day instead of skipping it forever.
|
||||
expect(ranges).toHaveLength(1)
|
||||
expect(toDateString(ranges[0]!.start)).toBe(daysAgoStr(3))
|
||||
expect(toDateString(ranges[0]!.start) <= daysAgoStr(3)).toBe(true)
|
||||
const healed = out.days.find(d => d.date === daysAgoStr(3))!
|
||||
expect(healed.providers['claude']).toMatchObject({ cost: 33, calls: 210 })
|
||||
expect(healed.cost).toBe(33)
|
||||
|
|
@ -277,22 +277,70 @@ describe('daily cache: a residue-only day is re-derived, not served', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('daily cache: sealing a day re-derives the still-settling ones', () => {
|
||||
it('a day sealed before a late call or a re-price reads the same as a live parse of it', async () => {
|
||||
// The field case: Oct 4 sealed at $376.71 / 5563 calls; a live parse of it
|
||||
// later finds 5564 calls at a new price ($356.17). report --day used the
|
||||
// live parse, history.daily the sealed day.
|
||||
await seed({
|
||||
lastComputedDate: daysAgoStr(2),
|
||||
watermarkTrusted: true,
|
||||
days: [day(daysAgoStr(10), { claude: slice(80, 700) }), day(daysAgoStr(4), { claude: slice(376.71, 5563) })],
|
||||
})
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => [],
|
||||
() => [day(daysAgoStr(4), { claude: slice(356.17, 5564) }), day(daysAgoStr(1), { claude: slice(10, 50) })],
|
||||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
expect(out.days.find(d => d.date === daysAgoStr(4))).toMatchObject({ cost: 356.17, calls: 5564 })
|
||||
expect(out.days.find(d => d.date === daysAgoStr(1))).toMatchObject({ cost: 10 })
|
||||
expect(out.days.find(d => d.date === daysAgoStr(10))).toMatchObject({ cost: 80, calls: 700 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily cache: a re-derived sealed day never shrinks', () => {
|
||||
it('keeps a sealed day whose fresh parse lost calls, re-prices one with the same calls', async () => {
|
||||
await seed({
|
||||
lastComputedDate: daysAgoStr(2),
|
||||
watermarkTrusted: true,
|
||||
days: [
|
||||
day(daysAgoStr(4), { claude: slice(200, 1000) }),
|
||||
day(daysAgoStr(3), { claude: slice(376.71, 5563) }),
|
||||
],
|
||||
})
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => [],
|
||||
() => [
|
||||
day(daysAgoStr(4), { claude: slice(15, 40) }),
|
||||
day(daysAgoStr(3), { claude: slice(356.17, 5563) }),
|
||||
day(daysAgoStr(1), { claude: slice(10, 50) }),
|
||||
],
|
||||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
expect(out.days.find(d => d.date === daysAgoStr(4))).toMatchObject({ cost: 200, calls: 1000 })
|
||||
expect(out.days.find(d => d.date === daysAgoStr(3))).toMatchObject({ cost: 356.17, calls: 5563 })
|
||||
expect(out.days.find(d => d.date === daysAgoStr(1))).toMatchObject({ cost: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily cache: out-of-range residue days never reach the gap merge', () => {
|
||||
it('a straddling turn anchored before the gap leaves the cached day untouched', async () => {
|
||||
// Issue #1130: the gap range starts on D+1, but a turn anchored on day D
|
||||
// survives range slicing whole, so the aggregator emits a residue day for
|
||||
// D — outside the parsed range. Previously the merge guard had to defuse
|
||||
// it; now the range filter drops it before the merge runs at all.
|
||||
const populatedD = day(daysAgoStr(4), { claude: slice(120, 900) })
|
||||
await seed({ days: [VANISHED, populatedD] })
|
||||
const before = (await loadDailyCache()).days.find(d => d.date === daysAgoStr(4))!
|
||||
const residue = residueDay(daysAgoStr(4))
|
||||
const populatedD = day(daysAgoStr(9), { claude: slice(120, 900) })
|
||||
await seed({ days: [VANISHED, populatedD], lastComputedDate: daysAgoStr(9) })
|
||||
const before = (await loadDailyCache()).days.find(d => d.date === daysAgoStr(9))!
|
||||
const residue = residueDay(daysAgoStr(9))
|
||||
const inRange = day(daysAgoStr(2), { claude: slice(30, 300) })
|
||||
const out = await ensureCacheHydrated(noSessions, () => [residue, inRange], 'cfg-A', () => true)
|
||||
// Day D persisted untouched: no residue categories leaked in.
|
||||
const kept = out.days.find(d => d.date === daysAgoStr(4))!
|
||||
const kept = out.days.find(d => d.date === daysAgoStr(9))!
|
||||
expect(kept).toEqual(before)
|
||||
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4), daysAgoStr(2)])
|
||||
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(9), daysAgoStr(2)])
|
||||
expect(out.lastComputedDate).toBe(daysAgoStr(1))
|
||||
expect(out.complete).toBe(true)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue