fix(scanner): keep a turn's final tallies when a scan lands mid-stream - #169
Open
NickAme03 wants to merge 1 commit into
Open
fix(scanner): keep a turn's final tallies when a scan lands mid-stream#169NickAme03 wants to merge 1 commit into
NickAme03 wants to merge 1 commit into
Conversation
A scan that runs while a response is still streaming stores the turn with the partial usage written so far. INSERT OR IGNORE against the unique message_id index then makes that first write permanent: later scans read the final record and drop it, so the turn stays frozen at the partial count, and the end-of-scan recompute sums the frozen value. Upsert on the higher output_tokens instead. The row is updated in place and never deleted, so the append-only history the dashboard relies on stays intact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A turn stored while its response was still streaming never gets corrected. It stays frozen at the partial token counts for the life of the database.
The same transcript gives two different totals depending on when you scanned it:
Why
Dedup happens at two levels, and only the first one is right.
In memory, per file,
seen_messages[message_id] = turn(scanner.py:441, and again at:764on the incremental path). Last record wins, which is correct: on my local corpus, of the 2678message_ids with more than one record, all 2678 have the last record equal to the field-wise maximum.In the database, the unique index on
message_id(scanner.py:127-130) plusINSERT OR IGNORE(scanner.py:561). First write wins, permanently.So a scan that starts mid-stream sees only the partial snapshots written so far, stores those, and every later scan re-reads the final record and drops it on the index. Nothing repairs it afterwards: the end-of-scan recompute (
scanner.py:801-808) re-sums from the frozen turns, and the Rescan button says in its own tooltip that it adds new turns "without affecting existing history".The window is wider than it sounds. Between the first and last record of one
message_id, my corpus shows a median of 3.1s, p90 of 17.9s, max of 246s. 73% of multi-record messages span at least a second.Impact: small, and I'd rather say so
Roughly 1-2% of output tokens in the worst case I measured, and zero on the cache and input fields, which already arrive complete in the first record.
Scans today only run at dashboard startup (
cli.py:423), on the Rescan button, and from the CLI, so in practice this bites the message that happened to be streaming when you opened the dashboard. Simulating a periodic scan against the real timestamps in my corpus, averaged over 12 phase offsets:I'm not going to dress this up as a big number, because it isn't one. The reason to fix it anyway is determinism: the same bytes on disk should give the same total whenever you scan them, and right now they don't. The wrong value is also silent and permanent, which is the part that bothered me.
Worth noting that PR 157 and PR 161 both make scanning periodic, and neither touches this path, so whichever of them lands moves this from the 300s row of that table to the 30s row.
Change
INSERT OR IGNOREbecomes an upsert that wins on the higheroutput_tokens. Same selection rule the in-memory level already implements, applied where it was missing.The row is updated in place and never deleted, so the append-only history stays intact, including sessions whose transcripts Claude Code has since pruned.
Testing
AssertionError: 40 != 900.DELETE FROM processed_filesplus a rescan repairs frozen turns (40 to 900) and preserves sessions whose transcript files no longer exist. Two turns before, two after, nothing dropped. Happy to add that as a one-time migration if you want it, I left it out to keep the diff small.One thing I could not check locally: I'm on SQLite 3.50.4 / Python 3.14. Upsert with a conflict target on a partial index needs SQLite 3.24+ (June 2018), so the 3.9 / 3.11 / 3.12 CI matrix should settle it.
Unrelated, noticed while reading:
AGENTS.md:85still describesPOST /api/rescanas deleting the DB and running a full rescan, which the current handler explicitly does not do. Left alone here, happy to file it separately.