fix(aggregator): attribute a straddling turn's category to today on the all-provider view

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).
This commit is contained in:
AgentSeal 2026-08-03 22:33:20 +02:00
parent 55bf65ef6a
commit dbdbf466a0
3 changed files with 44 additions and 3 deletions

View file

@ -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 <x>` 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)

View file

@ -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 {

View file

@ -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 {