Skip to content

fix(kiro): stop Kiro CLI token totals inflating on every sync (#65)#70

Merged
pitimon merged 5 commits into
mainfrom
fix/65-kiro-watermark
Jul 20, 2026
Merged

fix(kiro): stop Kiro CLI token totals inflating on every sync (#65)#70
pitimon merged 5 commits into
mainfrom
fix/65-kiro-watermark

Conversation

@pitimon

@pitimon pitimon commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Fixes the unbounded inflation in #65.

The bug

readKiroCliRequests reads the whole conversations_v2 table with no age filter, while cursors.kiroCli.requests was pruned at 90 days and capped at 20,000 entries. The memory window was strictly smaller than the read window, so a request evicted by either cap was re-ingested on every subsequent sync, forever. Four syncs of one 91-day-old conversation: 100 → 200 → 300 → 400.

The fix

Keep that same per-request record, stop pruning it, and only ever add the growth — delta = max(0, current - recorded), per request.

Two rules carry the design:

  1. Never prune. Anything dropped from the map is re-added in full next sync — that is this bug. Dropping only the age cap while keeping the count cap merely converts an age-triggered recount into a volume-triggered one, which is why the issue's "rejected options" list did not actually cover this.
  2. The delta is clamped at zero — this parser never subtracts. Attribution is only ever needed to justify a decrease, so never decreasing removes the need for it: a shrinking read (deleted session, corrupt file, unreadable or vanished DB, partial parse) yields a zero delta, so no health gate is needed; and since we only add, the Kiro IDE's share of a shared source="kiro" bucket never needs to be known, so cursors.hourly — shared by 11 parsers — is untouched.

Identity is message_id || request_id, which stays stable when kiro-cli flushes a live session into SQLite under a new request_id. That is what the TASK-007 cross-source retraction pass reconciled by hand; it walked the age-capped cursor whose eviction caused this bug, and is removed.

Behavior changes worth knowing

  • A genuine downward correction never propagates.
  • A request that moves to another bucket leaves its earlier contribution in the old one (a mislabel, not a double count).
  • Upgrading freezes any inflation already accumulated rather than repairing it. Repair would need the CLI's share of a shared kiro bucket, which the hourly state does not record.
  • State grows with Kiro's own history instead of being bounded: ~194 bytes/entry, so roughly 20 MiB/year at 300 requests/day. cursors.json is read and rewritten whole on every sync, so that is parse+serialize cost on every sync, not just resting footprint. This is the deliberate trade for correctness — pruning is the bug.
  • A degraded SQLite read now emits an ungated stderr line. There is no gate (a degraded read is already safe), but "no gate needed" is not "no signal needed", and warnSqliteUnavailable fires only for a missing backend.

Open question — please check before merging

Keying on message_id silently halves two requests that share one (reproduced: 3000/1500/2 counted as 2000/1000/1, unrecoverable). The mechanism is proven; whether real kiro-cli emits multiple entries per turn sharing a message_id is not — the repo has no schema doc and every fixture is one-request-per-turn. request_id-first is collapse-safe but migration-unsafe; message_id-first is the reverse. Worth one query against a real conversations_v2 before merge.

Test plan

  • npm test806 pass, 0 fail
  • npm run ci:local → exit 0
  • npm run docs:openwiki:check → 0 findings
  • Eleven boundary tests added. Nine were verified RED against the 6c04040 baseline or against the previous commit in this branch; the two that also pass on baseline are labelled in-code as regression guards, not proof — they pin behavior that a rejected earlier design broke.
  • Reviewer: confirm the message_id question above against a real database.
  • Version lockstep (package.json, both project.yml MARKETING_VERSION, TokenTrackerWin.csproj) is deliberately not bumped, pending the release decision — same as the other open PRs.

How this was arrived at

Three designs were built for this. Two were rejected under review and the second is archived on fix/65-kiro-ledger with its findings, because it is useful evidence rather than a useful solution.

Green gates were not evidence for any of them. The rejected ledger was 796/796 with ci:local passing while carrying a CRITICAL and three HIGH defects, because its new branches had no coverage. Across the whole effort, four tests were caught passing for the wrong reason — one asserted >= where inflation had to be caught, one asserted <= where erasure had to be caught, one stopped discriminating its own fix after a later refactor, and one took an early-return path that skipped the hazard it targeted. Every behavioral claim in this PR was re-verified fail-before after the final design change; the earlier evidence was discarded.

itarun.p added 5 commits July 20, 2026 05:25
`readSqliteJsonRows` returns `[]` for five materially different outcomes:
bad arguments, an unstattable path, an absent database, a database that
read cleanly and holds no rows, and a database that exists but could not
be read at all. Callers that only ADD what they find may treat all five
alike — the worst case is doing nothing.

A caller that RECONCILES cannot. Applying the delta between what it reads
now and what it recorded before turns "I could not read it" into "every
row was deleted", and subtracts real accounting data. The upcoming Kiro CLI
ledger reconciles, so it needs to tell those apart and bail out.

Adds `readSqliteJsonRowsWithStatus` returning `{ ok, reason, rows }`.
`ok` is true only when `rows` faithfully represents the database.
`readSqliteJsonRows` becomes a one-line wrapper over it, so the seven
existing call sites keep their exact prior behavior — the six pre-existing
tests pass untouched, and a new test pins that the rows-only API still
cannot distinguish empty from missing from unreadable.

`unreadable` (no sqlite backend at all) and `query-failed` (a readable
database, an unsatisfiable query) are separate reasons. The module already
made that distinction internally to decide whether to warn; collapsing it
would have discarded it. Every consumer today treats both as "do not
reconcile", but they call for different remedies.

No production behavior changes in this commit.
Independent QA (Codex, xhigh) found three defects in the first cut.

`stat-failed` could never occur. `fs.existsSync` swallows every error and
answers false, so a directory we lack +x on was indistinguishable from a
path that was never there — and the `catch` guarding it was unreachable.
Verified by running it: number, object, boolean, symbol, function and an
ENOTDIR path all return false, none throw. Switched to `statSync` with
`throwIfNoEntry: false`, mapping ENOTDIR to `missing` (a path component is
a file, so the target genuinely cannot exist) and everything else to
`stat-failed`. The reason is now reachable, and a test drives it through a
real chmod-000 parent directory.

`query-failed` was assigned to every failure that was not a missing
backend — so a corrupt image, `SQLITE_BUSY`, `SQLITE_CANTOPEN` or a timeout
were all reported as "your statement did not apply", contradicting the
comment justifying the split. Classification is now positive: only an
identifiable statement error (no such table/column/function, syntax or
parse error) is `query-failed`; anything unrecognized stays `unreadable`.
Warning eligibility is untouched and remains independent of this.

The header comment still said an empty array meant five situations. It
means six.

Test gaps QA identified, now closed: invalid-args asserts the whole result
object rather than just `reason`; the warn-once test exercises two distinct
label/path pairs, so collapsing the keyed cache into one global flag would
now fail it; corrupt-or-locked is pinned to `unreadable` with stderr
asserted silent.

16/16 in this file. Full suite 791/795, failing names byte-identical to
main.
`readKiroCliRequests` reads the whole `conversations_v2` table with no age
filter, while `cursors.kiroCli.requests` was pruned at 90 days AND capped at
20,000 entries. The memory window was strictly smaller than the read window,
so a request evicted by either cap was re-ingested on every subsequent sync,
forever. Four syncs of one 91-day-old conversation gave 100 -> 200 -> 300 -> 400.

Fix: keep that same per-request record, stop pruning it, and only ever add the
growth — `delta = max(0, current - recorded)`, per request.

Two rules carry the whole design, and every defect found while getting here
came from breaking one of them:

1. Never prune. Anything dropped from the map is re-added in full next sync;
   that IS this bug. Dropping only the age cap while keeping the count cap
   just converts an age-triggered recount into a volume-triggered one, which
   is why the issue's "rejected options" list did not actually cover this.

2. The delta is clamped at zero — this parser never subtracts. Attribution is
   only ever needed to justify a DECREASE, so never decreasing removes the
   need for it:
   - a shrinking read (deleted session, corrupt file, unreadable or vanished
     database, partial parse) yields a zero delta, so no health gate is needed;
   - `source="kiro"` buckets are shared with the Kiro IDE parser, and since we
     only add we never need to know the IDE's share, so `cursors.hourly` —
     shared by 11 parsers — is untouched.

Keying on `message_id` rather than `request_id` keeps identity stable when
kiro-cli flushes a live session into SQLite under a new request_id. That is
what the TASK-007 cross-source retraction pass used to reconcile by hand; it
walked the age-capped cursor whose eviction caused this bug, and is removed.

Behavior changes worth knowing:
- A genuine downward correction never propagates.
- A request that moves to another bucket leaves its earlier contribution in
  the old one (a mislabel, not a double count).
- Upgrading freezes any inflation already accumulated rather than repairing
  it; repair would need the CLI's share of a shared kiro bucket, which the
  hourly state does not record.
- State grows with Kiro's own history instead of being bounded. That is the
  deliberate trade for correctness and belongs in the release notes.
- A degraded SQLite read now emits an ungated stderr line. There is no gate —
  a degraded read is already safe — but "no gate needed" is not "no signal
  needed", and `warnSqliteUnavailable` fires only for a missing backend.

Tests: 803 pass. Eight boundary tests were added and six were verified to fail
against the 6c04040 baseline; the two that also pass on baseline are labelled
in-code as regression guards, not as proof, because they pin behavior that a
rejected subtracting design broke rather than behavior this change introduces.

Two earlier approaches were built and rejected under review before this one;
the second is archived on `fix/65-kiro-ledger` with its findings. Green gates
were not evidence for either: one was 796/796 with ci:local passing while
carrying a CRITICAL and three HIGH defects.

Refs #65
Review of 955ea5c reproduced three defects, two of them permanent data loss.
All three share one root: the code INFERRED whether a request had already been
counted instead of recording it.

1. Adoption was a single latch for the whole sync, so it froze every request it
   saw — including ones that had never reached a bucket. Everything created
   between the last pre-upgrade sync and the first post-upgrade data sync was
   lost permanently. It had no choice: the pre-watermark cursor keys
   `request_id || message_id` while this code keys `message_id || request_id`,
   so `prior` missed essentially every legacy entry and the blanket freeze was
   the fallback.

   Now decided per request, probing BOTH key schemes, and the old cursor's own
   prune policy resolves the ambiguous case: no record and a bucket older than
   the legacy 90-day cap means it was pruned (counted but forgotten) so freeze
   it; no record and a newer bucket means the old cursor would still be holding
   it, so it is genuinely new and is counted in full.

2. `conversation_count` drifted permanently below the token totals. A request
   first seen with zero tokens recorded no conversation, and when Kiro later
   rewrote it with real data `prior` existed, so it never contributed one. The
   pre-#65 comment documented that exact sequence as normal Kiro behavior.
   `counted` is now stored on the record rather than inferred from `!prior`.

3. The main path stamped `watermarkVersion` unconditionally while the
   early-return path deliberately refused to, with a comment naming the hazard.
   One degraded read — a locked database while a live session file keeps the
   main path running — therefore ended the migration early, and the next
   healthy sync re-counted the entire corpus and emitted it as an absolute
   total. Stamping now requires the migration to have actually completed.

Also removes `clampAndCapKiroCliState` and its two cap constants, which had no
callers left. No test asserted capping, so nothing was encoding the bug.

Tests: 806 pass. Three regression tests added, each verified RED against
955ea5c. The third initially passed against the commit it was meant to catch
because it had no session file and so took the empty-flat early return instead
of the main path — it now carries one, and a comment saying why.

Known and unresolved, for the PR body: keying on `message_id` collapses two
requests that share one, which would silently halve them. The mechanism is
proven; whether real kiro-cli emits such rows is not, and the repo has no
schema documentation. Verify against a real database before merge.

Refs #65
Closes the one open question this PR was carrying. `user_turn_metadata.requests`
is an ARRAY per turn while `message_id` plausibly identifies the turn, so two
distinct requests can share one identity. Last-write-wins silently halved them,
and the loss was unrecoverable because the watermark then kept only the
survivor: 3000/1500 of real usage counted as 2000/1000.

It could not be settled by inspection — the repo has no schema documentation,
every fixture is one-request-per-turn, and there is no Kiro CLI install to
query. So rather than ship a documented maybe, this accumulates per-identity
while keeping each underlying request's own share, so a repeated row replaces
rather than adds. Cross-tier duplicates of the same turn are already removed by
the turn-granular filter, so anything still sharing an identity here is
genuinely distinct work.

Safe under both hypotheses: with one request per turn it reduces to exactly the
previous single-entry behavior, which is why the other 806 tests are unchanged.

Regression test verified RED against deaa292 (2000/1000 vs 3000/1500) and also
asserts stability on re-sync, so accumulation cannot itself become a re-add.

Refs #65
@pitimon
pitimon merged commit 7dc7e96 into main Jul 20, 2026
1 check passed
@pitimon
pitimon deleted the fix/65-kiro-watermark branch July 20, 2026 22:55
pitimon pushed a commit that referenced this pull request Jul 20, 2026
The fact file records evidence as file:line, so merging #70 shifted the line
numbers this branch's snapshot was pinned to and docs:openwiki:check failed on
a gate unrelated to this branch's change. Regenerated; no source change.
pitimon added a commit that referenced this pull request Jul 20, 2026
* perf: cache Codex context parses per file instead of per scan

The Codex Context Breakdown cache keyed on the GLOBAL maximum session-file
mtime, so appending to whichever session is currently active invalidated
the aggregate for every unchanged historical file and forced a full
rescan. The reporter measured 2.19s cold against 2,066 files, and 9.1s
under concurrent dashboard load, for what was a single-line append.

Adds a per-file parse cache consulted when the aggregate cache misses, so
that miss now costs one file rather than all of them. The aggregate cache
is deliberately kept as the fast path: replacing it outright would turn
every no-change request into "stat every file and recombine", regressing
the case that matters most under dashboard load.

`top` is deliberately absent from the per-file key. Truncation to the top
N happens at merge time, so one cached parse serves every `top` value; a
test pins this so it does not get "fixed" into the key later.

File identity reuses the predicate rollout.js already relies on in 19
places — inode, size, mtimeMs, plus a sha1 of the first 256 bytes to catch
a rotator that reuses the inode. Moved to src/lib/file-identity.js and
imported back into rollout.js under the same name, so those 19 call sites
are untouched. The comparison is equality, never ordering: a clock moving
backwards or a restored mtime must read as changed.

Verified the merge helpers build fresh targets and only read from their
source rows, so a cached parse cannot be mutated by a later merge — the
aliasing hazard that per-file caching would otherwise introduce.

Tests were negative-controlled: with the cache lookup disabled, the two
behavioral tests fail and the three correctness tests still pass, which is
the correct split.

Refs #62

* perf: cache Claude category parses per file instead of per scan

Same fix as the Codex side, but this one had a real obstacle: the scan
kept a single `seenHashes` dedup Set shared across every file, so whether
a message in one file counted depended on what another file had already
contributed. Summing independent per-file results would double count
every cross-file duplicate — and #62's own acceptance criterion is that
the aggregate stays identical to a clean full scan.

categorizeSessionFile now parses into its own accumulators and returns
them, with a file-local dedup set plus the list of hashes it claimed. The
caller merges in `files` order and inserts every hash into one global Set;
on any collision it discards the merge and re-runs a full sequential scan
with a shared dedup set, which is exactly the old behavior. A scan of
6,529 real session files found zero cross-file duplicates, so the guard is
expected to be dead code — it exists so correctness does not depend on
that measurement being true of everyone's corpus. A test forces the
collision and asserts the duplicate is counted once.

Merging in `files` order rather than worker-completion order also makes
the result deterministic, which the old shared-accumulator scan was not.

Measured on a copy of 800 real session files (108 MB):

  cold scan          292 ms -> 347 ms
  no change (L1)       2 ms ->   2 ms
  after one append   274 ms ->  16 ms

Cold is ~19% slower — one extra stat and 256-byte head read per file, plus
building per-file structures. That is the trade: the append path, which is
what #62 reports and what the dashboard actually hits while a session is
live, gets 17x faster.

Verified the refactor changes no real-world number: the full breakdown
over ~6,500 local session files is byte-identical to main across a fixed
date range. An earlier comparison appeared to differ, but that was the
live corpus being appended to between the two runs, not the code.

Parse instrumentation is opt-in — left always-on it would grow one Map
entry per session file forever on a long-running server, purely to serve
tests.

Fixes #62

* chore: refresh openwiki source facts after #70

The fact file records evidence as file:line, so merging #70 shifted the line
numbers this branch's snapshot was pinned to and docs:openwiki:check failed on
a gate unrelated to this branch's change. Regenerated; no source change.

---------

Co-authored-by: itarun.p <itarun.p@somapait.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant