daily-cache: document coverage-widening and rebuild-projects limits; cover empty-call turns

Review addendum: the one-day tz widening assumes an offset delta of at
most 24h (date-line crossings are a known limit); day-level projects of
skinny kept slices are dropped by the coverage rebuild by design. Adds a
regression test pinning the existing skip of empty-call turns.
This commit is contained in:
ozymandiashh 2026-07-20 17:20:17 +03:00
parent e07f3242f8
commit 269537ba0c
3 changed files with 97 additions and 1 deletions

View file

@ -286,3 +286,50 @@ src/types.ts:190: skillBreakdown: Record<string, { turns: number; costUSD: numb
```
STATUS: BLOCKED (`npx vitest run` exits 1: 48 pre-existing discovery/import suite failures plus unrelated 5-second CLI timeouts; tried isolated timeout reproduction, a clean HEAD-plus-lane snapshot with locked app dependencies and one worker, and all clean root tests with a 15-second diagnostic timeout)
## ADDENDUM
### Touched files
| file | added | deleted |
|---|---:|---:|
| `src/daily-cache.ts` | 8 | 1 |
| `tests/day-aggregator.test.ts` | 42 | 0 |
### New test
| file | test |
|---|---|
| `tests/day-aggregator.test.ts` | `skips a timestamped empty-call turn without throwing or counting it at day level` |
### `git diff --exit-code e07f324 -- src/day-aggregator.ts`
Exit code: `0`
```text
```
### `npx vitest run tests/daily-cache-carry-forward.test.ts tests/day-aggregator.test.ts`
Exit code: `0`
```text
RUN v3.2.6 /Volumes/T8/Claude Projects/codeburn
✓ tests/day-aggregator.test.ts (13 tests) 16ms
✓ tests/daily-cache-carry-forward.test.ts (45 tests) 318ms
Test Files 2 passed (2)
Tests 58 passed (58)
Start at 17:14:56
Duration 740ms (transform 99ms, setup 49ms, collect 115ms, tests 334ms, environment 0ms, prepare 158ms)
```
### `npx tsc --noEmit`
Exit code: `0`
```text
```
STATUS: COMPLETE

View file

@ -613,7 +613,10 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
/// provider's oldest fresh day (or the preceding day for timezone changes).
export type SecondaryCoveragePolicy = {
/// A timezone change can move a turn to an adjacent calendar day, so the day
/// immediately before the fresh provider floor is covered as well.
/// immediately before the fresh provider floor is covered as well. This
/// assumes the old/new timezone offset delta is at most 24 hours. Crossing
/// the date line can produce a 26-hour delta (UTC+14 to UTC-12) and move a
/// bucket by two calendar days; that rare case is a known, accepted limit.
widenByOneDay: boolean
}
@ -669,6 +672,10 @@ export function mergeDayEntries(
// Coverage can suppress one provider while another provider on the same
// secondary-only day still needs carrying. Rebuild that day exclusively
// from eligible slices so blocked totals cannot leak through day fields.
// Day-level projects cannot be attributed per provider: when a kept skinny
// slice has no slice.projects, its old day.projects entries are dropped.
// Copying them wholesale could restore the blocked provider's project
// totals, so this conservative loss is intentional.
const copy = emptyDay(day.date)
for (const [provider, slice] of providerEntries) {
if ((!hasSliceData(slice) && !(slice.sessions ?? 0)) || !mayCarry(day.date, provider, slice)) continue

View file

@ -168,6 +168,48 @@ describe('aggregateProjectsIntoDays', () => {
})
})
it('skips a timestamped empty-call turn without throwing or counting it at day level', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 'empty-call-turn',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00',
lastTimestamp: '2026-04-09T10:00:00',
totalCostUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 0,
turns: [{
userMessage: 'no assistant response',
timestamp: '2026-04-09T10:00:00',
sessionId: 'empty-call-turn',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [],
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
let days: ReturnType<typeof aggregateProjectsIntoDays> = []
expect(() => { days = aggregateProjectsIntoDays(projects) }).not.toThrow()
expect(days).toHaveLength(1)
expect(days[0]).toMatchObject({ cost: 0, calls: 0, editTurns: 0, oneShotTurns: 0 })
expect(days[0]!.categories).toEqual({})
expect(days[0]!.providers).toEqual({})
})
it('counts a session under its firstTimestamp date', () => {
const projects: ProjectSummary[] = [
makeProject({