fix(dashboard): make the /usage text scrape opt-in — the credit meter must not spend credits by default - #2039
Conversation
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of 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
[DESIGN-REVIEWED] 87d09a2 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/handlers/sessions.py:153 -- in-function False positive or not applicable? A repository writer can comment: |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
f7dbec5 to
1fb519a
Compare
GPT 5.6 Review — round 1 dispositionReviewed SHA BLOCKING —
|
… 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.
1fb519a to
87d09a2
Compare
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
… 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>
Open PR relationship auditThis 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
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
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-liteagent resolves modelauto, 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/usageevery 30s, butapi_sessions_usageonly kicks a background refresh once the cache TTL expires, so the spend cadence is 10 minutes, not 30 seconds._fetch_usage_bgtries the freeGetUsageLimitsHTTPS read first (logsKiro usage refreshed (api)). That path is fine.credits_plan, it fell straight through to spawningkiro-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 logsKiro 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
GetUsageLimitsnever 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, defaultfalse, declared alongside the otherdashboard.*knobs insrc/kiro_crew/config/loader.pyand wired throughDashboardConfigconstruction 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 itstale(the pill does not blink out); with nothing prior it caches whatever partial fields the API did return plusavailable: False, stripping the private_profile_arnand 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 missingkiro-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 === falsereturns'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 nodistrebuild is needed.Tests
test/test_session_usage.py::TestTextScrapeIsOptIn— 12 new tests, every one revert-verified (fix patched out ⇒ test fails):test_disabled_knob_never_spawns_the_billed_scrapetest_enabled_knob_spawns_the_scrape_and_caches_ittest_disabled_degrades_to_unavailable_instead_of_erroringtest_disabled_keeps_partial_api_fields_profile_arnstriptest_disabled_preserves_a_prior_good_value_as_staletest_disabled_notice_is_logged_once_not_per_cycletest_repeated_failures_back_off_instead_of_retrying_every_ttltest_a_timeout_counts_toward_the_backofftest_an_api_path_failure_does_not_count_toward_the_backoffscrape_attemptedguardtest_a_success_clears_accumulated_failurestest_the_gate_fails_closed_when_config_is_unreadabletest_the_knob_defaults_to_offdefault=FalseitselfExisting 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
pytestfull suite: 33,294 passed, 8 failures — all 8 reproduce identically on a cleanorigin/mainworktree 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 thepyproject.tomlpin, nofaissinstalled).config-baseline.jsonregenerated byscripts/generate_config_baseline.py(336 entries).distnot rebuilt: zero frontend files changed.