From 269537ba0c40f9ca795a3379f5933cb8aab26efc Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:20:17 +0300 Subject: [PATCH] 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. --- docs/lanes/carry-accounting-01.md | 47 +++++++++++++++++++++++++++++++ src/daily-cache.ts | 9 +++++- tests/day-aggregator.test.ts | 42 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/lanes/carry-accounting-01.md b/docs/lanes/carry-accounting-01.md index 02ccbbd..f11ce49 100644 --- a/docs/lanes/carry-accounting-01.md +++ b/docs/lanes/carry-accounting-01.md @@ -286,3 +286,50 @@ src/types.ts:190: skillBreakdown: Record(target: Record, 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 diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index c0ac9cc..24b9c16 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -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 = [] + 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({