Skip to content

fix(dashboard): make the /usage text scrape opt-in — the credit meter must not spend credits by default - #2039

Merged
iamwhatever merged 1 commit into
mainfrom
fix/usage-scrape-gate
Aug 7, 2026
Merged

fix(dashboard): make the /usage text scrape opt-in — the credit meter must not spend credits by default#2039
iamwhatever merged 1 commit into
mainfrom
fix/usage-scrape-gate

Conversation

@kyleseaman

Copy link
Copy Markdown
Collaborator

Symptom

Merely having a dashboard tab open bills a real LLM chat turn every 10 minutes, forever, on some accounts — and the thing doing the spending is the credit meter itself. There is no config knob, no user opt-out, and no ceiling. The kirocrew-lite agent resolves model auto, so the turn can land on an expensive model.

Root cause

src/kiro_crew/dashboard/handlers/sessions.py

  • _USAGE_REFRESH_SECS = 600 (line 107) is the real clock. The frontend polls /api/sessions/usage every 30s, but api_sessions_usage only kicks a background refresh once the cache TTL expires, so the spend cadence is 10 minutes, not 30 seconds.
  • _fetch_usage_bg tries the free GetUsageLimits HTTPS read first (logs Kiro usage refreshed (api)). That path is fine.
  • When the API returns no credits_plan, it fell straight through to spawning kiro-cli chat --no-interactive --agent kirocrew-lite /usage — a billed chat turn (the source's own comments already called it "the credit-consuming text scrape"; it logs Kiro usage refreshed (text)). Nothing gated that spawn, and nothing stopped it from repeating on every TTL expiry indefinitely, including when the scrape's output was unparseable and the turn bought nothing.

Accounts where GetUsageLimits never returns a plan therefore paid a chat turn every 10 minutes for the privilege of seeing a credit number.

Fix

1. The scrape is opt-in. New knob dashboard.usage_text_scrape_enabled, default false, declared alongside the other dashboard.* knobs in src/kiro_crew/config/loader.py and wired through DashboardConfig construction with _safe_bool. _text_scrape_enabled() fails closed: any error reading config means no scrape, so a malformed config can never silently start billing.

2. Graceful degradation when it is off. _cache_without_scrape() keeps a previously-good reading and dims it stale (the pill does not blink out); with nothing prior it caches whatever partial fields the API did return plus available: False, stripping the private _profile_arn and running everything through the existing redaction. No error, no exception, no blank-field render.

3. Logged once, not per cycle. _log_scrape_disabled_once() emits a single INFO naming the knob and the interval. The refresh runs forever, so a per-cycle log would be pure noise.

4. Failure backoff even when enabled. Three consecutive scrapes that yield no usable plan park the scrape for six hours (_record_scrape_outcome / _scrape_in_backoff). Any success clears the counter. Refreshes that never reached the scrape — an API-path error, a missing kiro-cli — do not consume the failure budget, since they say nothing about whether the scrape works.

Frontend: untouched, deliberately. App.tsx's usage query already handles the absent-plan case correctly: Number.isFinite(u.credits_plan) fails, u.available === false returns 'none', and the pill is omitted from the capsule entirely (an effect also auto-closes the credits modal). It renders no garbage and no half-filled meter, so there was nothing minimal to change and no dist rebuild is needed.

Tests

test/test_session_usage.py::TestTextScrapeIsOptIn — 12 new tests, every one revert-verified (fix patched out ⇒ test fails):

Test Reverted mechanism
test_disabled_knob_never_spawns_the_billed_scrape the opt-in gate
test_enabled_knob_spawns_the_scrape_and_caches_it positive control — the scrape still works when asked for
test_disabled_degrades_to_unavailable_instead_of_erroring the gate (spawn mock holds parseable output, so a leak is visible)
test_disabled_keeps_partial_api_fields partial-field preservation + _profile_arn strip
test_disabled_preserves_a_prior_good_value_as_stale stale-preservation branch
test_disabled_notice_is_logged_once_not_per_cycle four refreshes ⇒ exactly one INFO
test_repeated_failures_back_off_instead_of_retrying_every_ttl backoff check + failure counter
test_a_timeout_counts_toward_the_backoff backoff on the timeout path
test_an_api_path_failure_does_not_count_toward_the_backoff the scrape_attempted guard
test_a_success_clears_accumulated_failures success resets the counter
test_the_gate_fails_closed_when_config_is_unreadable fail-closed on config error
test_the_knob_defaults_to_off the default=False itself

Existing tests that exercise the scrape now opt in explicitly via _enable_text_scrape(), which documents at each call site that the production default is off.

Gates

  • pytest full suite: 33,294 passed, 8 failures — all 8 reproduce identically on a clean origin/main worktree with its own venv (host-environment: ps-path planting, sandbox cancel, IPv6 URL parse, search cost budget). None are in usage, config, or dashboard-handler code.
  • isort --check-only, flake8: clean.
  • mypy src/kiro_crew/: clean, 801 files — run on a CI-parity venv (mypy 1.14.1 matching the pyproject.toml pin, no faiss installed).
  • config-baseline.json regenerated by scripts/generate_config_baseline.py (336 entries).
  • Frontend gates not run and dist not rebuilt: zero frontend files changed.

@kyleseaman
kyleseaman requested a review from a team as a code owner August 7, 2026 15:38
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Advisory design-level review of 87d09a270181b6269b5bc1cf655c2971b54aa3e4 — updated in place on each push; does not block merge.

Design-Verdict: PASS

A real recurring-billing defect, fixed at the exact spawn site with a fail-closed opt-in, backoff, and graceful degradation — proportionate and fully reversible.

Suggestions

  • Affected users' credit pill now vanishes with only a log-file INFO to explain why; adding a machine-readable reason field (e.g. scrape_disabled: true) to the cached payload would let the dashboard explain the missing pill later without another backend change.
  • A user-triggered one-shot scrape (fetch on opening the credits modal) would give opted-out users an occasional readout without timer spend — frontend work, so a follow-up PR, not this one.

[DESIGN-REVIEWED] 87d09a2

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 87d09a270181b6269b5bc1cf655c2971b54aa3e4 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- src/kiro_crew/dashboard/handlers/sessions.py:153 -- in-function "from kiro_crew.config.loader import KiroCrewConfig" violates the top-level-imports rule -> Fix: use the existing _h.KiroCrewConfig binding.
[GPT-REVIEWED] 87d09a2

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 87d09a270181b6269b5bc1cf655c2971b54aa3e4: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Opus 5 Review — ✅ no blocking findings

Reviewed 87d09a270181b6269b5bc1cf655c2971b54aa3e4 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 87d09a2

Verdict parsed from the review's SHA-scoped output markers for commit 87d09a270181b6269b5bc1cf655c2971b54aa3e4.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 87d09a270181b6269b5bc1cf655c2971b54aa3e4: <one-sentence reason>

@kyleseaman
kyleseaman force-pushed the fix/usage-scrape-gate branch from f7dbec5 to 1fb519a Compare August 7, 2026 15:59
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 7, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author

GPT 5.6 Review — round 1 disposition

Reviewed SHA f7dbec5a9 → fixed in 1fb519a8f.

BLOCKING — sessions.py:205, disabled-scrape fallback preserves the previous account's usage — FIXED

Legitimate, and worse than a transient: with the scrape disabled, a plan-less API answer recurs on every refresh, so the unguarded preserve pinned account A's balance and email on screen indefinitely under account B's session. Nothing would ever clear it.

Fixed as suggested — _cache_without_scrape now takes the refresh's identity and preserves the prior reading only when _same_identity proves it belongs to the current account:

if _usage_cache.get("credits_plan") is not None and _same_identity(_usage_cache, identity):
    _usage_cache = {**_usage_cache, "stale": True}
else:
    ...  # partial API fields + available: False

This reuses the existing mechanism rather than a parallel comparison, so both preserve paths (this one and _text_scrape_regresses_api_value) judge identity by the same rule: email and SSO start_url both present, non-empty and equal, with anything missing or mismatched treated as unproven.

Two consequences worth stating explicitly:

  • An account that never carried an identity is now unproven, not preserved — Builder ID and any reading where whoami could not be resolved fall to available: False (pill hides) instead of showing a dimmed prior value. Deliberate: hiding the pill is cosmetic, attributing one account's spend to another is not.
  • Residual window, bounded. The identity passed in is this refresh's whoami, resolved before the API attempt (≤30s). A switch landing inside that attempt is caught on the following refresh rather than this one. I did not re-resolve adjacently here (as the scrape path does) because that path is the default path and would double its whoami spawns every interval, and the unbounded-persistence bug — the actual harm — is closed either way. Documented in the docstring.

Tests

test/test_session_usage.py::TestTextScrapeIsOptIn, all revert-verified:

  • test_disabled_never_serves_a_different_accounts_balance — caches A (9999/10000, a@corp.com), refreshes under B with a plan-less API, asserts A's balance, plan and email are all not served and the state is available: False.
  • test_disabled_never_preserves_an_unproven_identity — cached reading carries no identity at all ⇒ unavailable.
  • test_disabled_preserves_a_prior_good_value_as_stale — updated to seed a matching identity, so the same-account preserve is still pinned rather than silently lost to the new guard.

Reverting the guard to the unconditional if fails both new tests and leaves test_disabled_degrades_to_unavailable_instead_of_erroring passing — confirming the tests pin the identity check specifically, not a blanket disable of the preserve path.

Gates on 1fb519a8f

isort, flake8 clean; mypy clean on 801 files (CI-parity venv, mypy 1.14.1 matching the pyproject.toml pin, no faiss); 413 passed across test_session_usage.py, test_kiro_usage_api.py, test_config_loader.py, test_config_baseline.py, test_kiro_spawn_readiness_gate.py. No frontend files changed.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
… must not spend credits by default

The credit pill's background refresh falls back to a real billed kiro-cli
chat turn whenever the free GetUsageLimits read returns no plan. That
refresh runs every 10 minutes for as long as any dashboard tab is open,
so the meter that reports spending was itself spending, with no knob and
no opt-out.

Gate the fallback behind dashboard.usage_text_scrape_enabled (default
false), degrade to the API's partial fields plus available:false when it
is off (the frontend already hides the pill on that signal), log the
skip once per process instead of per cycle, and park the scrape for six
hours after three consecutive failures so a broken scrape stops billing
on every TTL expiry.
@kyleseaman
kyleseaman force-pushed the fix/usage-scrape-gate branch from 1fb519a to 87d09a2 Compare August 7, 2026 16:58
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 7, 2026
@iamwhatever
iamwhatever merged commit 5be76d8 into main Aug 7, 2026
48 checks passed
@iamwhatever
iamwhatever deleted the fix/usage-scrape-gate branch August 7, 2026 17:30
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 7, 2026
darko-mesaros added a commit that referenced this pull request Aug 9, 2026
The kiro_usage_api module's _SQLITE_TOKEN_KEYS tuple only contained the
OIDC and legacy CodeWhisperer keys. Users signed in via GitHub social
login store their bearer token under kirocli:social:token, which was
never searched -- leaving the free GetUsageLimits path non-functional for
this login class. Since #2039 made the text scrape opt-in (default off),
these users see no credit pill at all.

Add kirocli:social:token to the key list. The token blob has the same
{access_token, expires_at} shape the existing parser expects; no format
change is needed. Verified end-to-end on a real social-login host.

Closes #2291
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
… must not spend credits by default (kirodotdev#2039)

The credit pill's background refresh falls back to a real billed kiro-cli
chat turn whenever the free GetUsageLimits read returns no plan. That
refresh runs every 10 minutes for as long as any dashboard tab is open,
so the meter that reports spending was itself spending, with no knob and
no opt-out.

Gate the fallback behind dashboard.usage_text_scrape_enabled (default
false), degrade to the API's partial fields plus available:false when it
is off (the frontend already hides the pill on that signal), log the
skip once per process instead of per cycle, and park the scrape for six
hours after three consecutive failures so a broken scrape stops billing
on every TTL expiry.

Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #7628 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7628: KEEP. The merged opt-in is the cause 7628 explains rather than reverts; it covers none of 7628's behavior. Files: src/kiro_crew/dashboard/handlers/sessions.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

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.

3 participants