From dbdbf466a04499feec6457249922622b8e8233a6 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Mon, 3 Aug 2026 22:33:20 +0200 Subject: [PATCH] fix(aggregator): attribute a straddling turn's category to today on the all-provider view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildDurablePeriod derived the today slice of the multi-day, all-provider headline from the unsliced whole-range parse, so a turn spanning local midnight kept its category and turn count anchored on its yesterday start. The per-call cost and calls bucketed onto today correctly, but By Activity and the JSON daily turn count lost the post-midnight half — categories summed to only the pre-midnight cost while the headline, By Model and By Project were right. Slice the today parse with filterProjectsByDays first, which re-anchors the straddling turn to its surviving today calls, so today's category cost lands on today. Category cost is the sum of the slice's own calls, so day-N + day-N+1 still equals the whole-range total (no over-count); the per-day turn-count split matches the cache side and the documented per-day semantics. Adds a regression test in the straddling-turn conservation suite (mutation-checked: fails on the pre-fix code). Also fills in the CHANGELOG Unreleased entries for the batch (#853, #856, #872, #846/#859, #866/#867, #833). --- CHANGELOG.md | 8 +++++++- src/usage-aggregator.ts | 9 +++++++-- tests/cli-durable-totals.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d846966..9fff006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## Unreleased ### Added -- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar. +- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar. (#833) +- **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds) ### Added (CLI) - **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo) @@ -13,6 +14,11 @@ - **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864) - **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805) +- **Midnight-straddling turns keep both halves.** A turn whose calls span local midnight was attributed whole to its start day, so `codeburn today` under-reported until the turn ended and multi-day totals mis-split it. Calls are now range-filtered inside the turn so each day gets the calls that belong to it, and By Activity and the daily turn counts reconcile with the headline. (#853, thanks @KENSHI601) +- **`--provider ` no longer leaks Claude spend into the detail panels.** A provider-filtered run still ran the Claude scan, whose orphan pass re-injected every cached Claude session, so By Project / By Model / By Activity showed Claude usage under, e.g., `--provider cursor` while the headline was correct. (#872, thanks @ozymandiashh) +- **A degraded session parse no longer freezes daily history.** A read-only parse that served a stale or missing session file was treated as complete and finalized days it never covered, freezing warm-cache ingestion; a corrupt refresh lock is now recovered rather than ending ingestion, and a legitimately idle tail is no longer re-derived on every launch. (#856, thanks @avs-io) +- **Pi / Oh My Pi transcripts with a leading title record are discovered.** OMP writes a `type: "title"` line before the session header; discovery now scans a bounded number of leading lines for the first session record instead of requiring it on the first physical line. (#846, #859, thanks @jbspeakr, @avs-io) + ### Fixed - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 86d0e2e..3f70d34 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -422,9 +422,14 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw scanRange = periodInfo.range // A period that reaches today contains today's turns already, so derive the - // today slice from the same parse instead of scanning today again. + // today slice from the same parse instead of scanning today again. Slice it + // to today first (filterProjectsByDays re-anchors a midnight-straddling + // turn to its surviving today calls), so today's category / turn count + // lands on today rather than staying anchored on the turn's yesterday + // start. Otherwise the post-midnight half vanishes from By Activity and the + // JSON daily turn count while the per-call cost/calls still bucket to today. todayAllDays = rangeEndStr >= todayStr - ? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr) + ? aggregateProjectsIntoDays(filterProjectsByDays(raw, new Set([todayStr]))).filter(d => d.date === todayStr) : aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr) } } else { diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index 8a19b65..f6b51f3 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -379,6 +379,36 @@ describe('midnight-straddling turn conservation (issue #852)', () => { } }, 60_000) + it('reconciles By Activity (categories) and daily turns with the headline on the multi-day all-provider view', async () => { + fakeNow() + try { + await seedStraddlingClaudeTurn() + const [costN, costN1] = await truthCosts('claude') + + const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() } + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' }) + + // By Activity must sum to the whole-range headline. Before this fix the + // today slice came from the unsliced whole-range parse, so the straddling + // turn's category stayed anchored on its yesterday start and the + // post-midnight half vanished from By Activity while cost / calls / By + // Model were already correct; categories summed to only the pre-midnight + // cost. + const categoryTotal = durable.data.categories.reduce((s, c) => s + c.cost, 0) + expect(categoryTotal).toBeCloseTo(costN + costN1, 8) + expect(categoryTotal).toBeCloseTo(durable.data.cost, 8) + + // Today's day entry carries its turn's category, not calls>0 with an empty + // category map (which rendered as `turns: 0` in the JSON daily rows). + const dayN1 = durable.days.find(d => d.date === DAY_N1) + expect(dayN1?.calls).toBe(1) + expect(Object.keys(dayN1?.categories ?? {}).length).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }, 60_000) + it('shows the post-midnight call in the today-only view on the Claude Code path', async () => { fakeNow() try {