Skip to content

Kiro CLI token totals inflate without bound once conversations pass 90 days #65

Description

@pitimon

Summary

Kiro CLI token totals grow on every sync, forever, for any conversation older than 90 days. Confirmed by reproduction outside the test harness, not inferred.

Mechanism

readKiroCliRequests (src/lib/rollout.js:3694) reads conversations_v2 with no age filter — its SQL ends at WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL.

clampAndCapKiroCliState (src/lib/rollout.js:4054-4058) prunes every per-request cursor older than KIRO_CLI_CURSOR_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000 (:4038).

That cursor is the sole idempotency guard for the SQLite path — src/lib/rollout.js:3780 notes "Per-request state replaces the old seenIds set". The read window is unbounded while the memory window is 90 days, so once a request ages past 90 days it is re-ingested on every subsequent sync.

Reproduction

Four consecutive syncs against a 91-day-old fixture: eventsAggregated=1 every time, bucket totals input = 100 → 200 → 300 → 400. An identical fixture dated 5 days ago: eventsAggregated = 1,0,0,0, totals stay at 100.

Downstream makes it worse rather than better: dedupe on (source, model, hour_start) keeps the latest row, so the inflated snapshot wins.

How it surfaced

Four parseKiroCliIncremental tests in test/rollout-parser.test.js went red with no code change. They pin fixtures to 2026-04-20T10:05:00.000Z, whose half-hour bucket crossed the 90-day cutoff on 2026-07-19T10:00Z — between a green CI run on 07-10 and today. Re-running with the clock faked back to 07-10 gives 147 pass / 0 fail. Bisecting the fake clock lands on exactly that hour.

This is why main is currently red and why #64 cannot go green.

Why the obvious fixes were rejected

  • Drop the age cap, keep the 20k count cap — converts an age-triggered double-count into a volume-triggered one. Above 20,000 in-window requests the overflow re-counts every sync. Also breaks test/rollout-parser.test.js:4910, which asserts the age prune.
  • Age-filter the reader to match the cursor — fail-safe and small, but makes Kiro the only source with a 90-day ingestion horizon. The dashboard offers a total period (dashboard/src/pages/DashboardPage.jsx:53) and nothing ever prunes cursors.hourly, so full history is a product commitment. A fresh install importing an older database, or any resync after cursor loss, would silently lose Kiro history beyond 90 days.

Direction

Per-bucket high-watermark ledger: cursors.kiroCli.ledger keyed by (model, bucketStart) records what this parser has already applied to each hourly bucket. Ingest recomputes the desired contribution from the full reader output and applies only the delta. State is then bounded by buckets (~48/day/model) rather than by requests, so no prune policy — age, count, or corruption — can cause a recount. The per-request cursor stays for retraction bookkeeping and eventsAggregated, demoted off the correctness path.

Writes must stay delta-only: source="kiro" is shared by three writers (:3019, :3978, :4191), so absolute assignment would erase Kiro IDE tokens.

Switching from accumulate to reconcile makes an unreadable database destructive — it reads as "everything was deleted". That prerequisite ships first (#66).

Known limitation

Adoption is net-zero: existing inflated totals are frozen, not repaired. Repairing them needs the current Kiro-CLI portion of each shared bucket, which is exactly what is unknown. An opt-in full resync is the only clean remedy and is out of scope here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions