From 87d09a270181b6269b5bc1cf655c2971b54aa3e4 Mon Sep 17 00:00:00 2001 From: Kyle Seaman Date: Fri, 7 Aug 2026 15:37:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20make=20the=20/usage=20text=20?= =?UTF-8?q?scrape=20opt-in=20=E2=80=94=20the=20credit=20meter=20must=20not?= =?UTF-8?q?=20spend=20credits=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config-baseline.json | 17 +- src/kiro_crew/config/loader.py | 16 ++ src/kiro_crew/dashboard/handlers/sessions.py | 144 +++++++++++ test/test_session_usage.py | 236 ++++++++++++++++++- 4 files changed, 411 insertions(+), 2 deletions(-) diff --git a/config-baseline.json b/config-baseline.json index e43dba6789d..0f239b7ab5e 100644 --- a/config-baseline.json +++ b/config-baseline.json @@ -1621,7 +1621,7 @@ "sensitive": false, "tags": [], "label": "Max Triggered", - "help": "Maximum number of skills to load per message (≥0). Defaults to 0 (disabled); set to a positive integer to re-enable per-turn trigger matching.", + "help": "Maximum number of skills a single message may flag as relevant (≥0). Each match injects that skill's full content, unless the skill sets inject_on_trigger: false (pointer-only; requires max_triggered > 0 to have any effect). Defaults to 0 (disabled): the agent discovers skills from the Available Skills index and reads them on demand via cat, $skillname, or skill_search. Set to a positive integer to re-enable per-turn word-overlap trigger matching.", "hasChildren": false, "enumValues": null, "defaultValue": 0 @@ -3965,6 +3965,7 @@ "widget_density": "more", "verbosity": "default", "link_previews": false, + "usage_text_scrape_enabled": false, "tail_fork_enabled": false, "auto_open_browser": true, "prevent_sleep": false, @@ -4201,6 +4202,20 @@ "enumValues": null, "defaultValue": false }, + { + "path": "dashboard.usage_text_scrape_enabled", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "label": "Spend Credits To Read The Credit Meter", + "help": "Let the credit pill fall back to a `kiro-cli /usage` chat turn when the free usage API returns no plan. That fallback is a REAL billed LLM turn on whichever model the lite agent resolves, and it repeats on every refresh interval for as long as any dashboard tab is open, so it is off by default: a meter that reports spending must not itself spend. While it is off the pill shows whatever the free API returned and hides when the API has nothing to show.", + "hasChildren": false, + "enumValues": null, + "defaultValue": false + }, { "path": "dashboard.tail_fork_enabled", "kind": "core", diff --git a/src/kiro_crew/config/loader.py b/src/kiro_crew/config/loader.py index 8d4c9c33f7c..70eb44fab80 100644 --- a/src/kiro_crew/config/loader.py +++ b/src/kiro_crew/config/loader.py @@ -1939,6 +1939,19 @@ class DashboardConfig: "returns 403.", ), ) + usage_text_scrape_enabled: bool = field( + default=False, + metadata=_meta( + "Spend Credits To Read The Credit Meter", + "Let the credit pill fall back to a `kiro-cli /usage` chat turn when " + "the free usage API returns no plan. That fallback is a REAL billed " + "LLM turn on whichever model the lite agent resolves, and it repeats " + "on every refresh interval for as long as any dashboard tab is open, " + "so it is off by default: a meter that reports spending must not " + "itself spend. While it is off the pill shows whatever the free API " + "returned and hides when the API has nothing to show.", + ), + ) tail_fork_enabled: bool = field( default=False, metadata=_meta( @@ -4908,6 +4921,9 @@ def load(cls) -> KiroCrewConfig: widget_density=dashboard_data.get("widget_density", "more"), verbosity=dashboard_data.get("verbosity", "default"), link_previews=_safe_bool(dashboard_data.get("link_previews"), False), + usage_text_scrape_enabled=_safe_bool( + dashboard_data.get("usage_text_scrape_enabled"), False + ), tail_fork_enabled=dashboard_data.get("tail_fork_enabled", False), terminal=dashboard_data.get("terminal", {"enabled": True}), default_project=dashboard_data.get("default_project", ""), diff --git a/src/kiro_crew/dashboard/handlers/sessions.py b/src/kiro_crew/dashboard/handlers/sessions.py index 39212ab8014..3d05a904568 100644 --- a/src/kiro_crew/dashboard/handlers/sessions.py +++ b/src/kiro_crew/dashboard/handlers/sessions.py @@ -117,6 +117,123 @@ async def api_sessions_health(request: web.Request) -> web.Response: _USAGE_REFRESH_SECS = 600 # background refresh every 10 min _usage_fetching = False +# --- Text-scrape gate ------------------------------------------------------ +# The `/usage` text scrape is a REAL billed kiro-cli chat turn, unlike the +# GetUsageLimits API read the primary path uses. It runs on a timer for as long +# as a dashboard tab is open, so an ungated fallback bills the user forever just +# to render a credit meter. Hence: opt-in via config, logged once when it is +# skipped, and backed off when it repeatedly fails. + +#: True once the "scrape is disabled" notice has been logged. The refresh runs +#: every _USAGE_REFRESH_SECS forever, so logging per cycle would fill the log +#: with a message that never changes. +_usage_scrape_disabled_logged = False +#: Consecutive scrape attempts that produced no usable credit plan. +_usage_scrape_failures = 0 +#: monotonic deadline before which no further scrape is attempted. +_usage_scrape_backoff_until = 0.0 +#: Consecutive failures tolerated before the scrape is parked. Two refresh +#: intervals of bad luck stay within normal retry; a third means the scrape is +#: broken (kiro-cli format change, wedged CLI, revoked auth), and every further +#: attempt spends credits for output that cannot be parsed. +_USAGE_SCRAPE_FAILURE_THRESHOLD = 3 +#: How long a broken scrape is parked. Long relative to the 10-minute refresh so +#: a persistent breakage costs a handful of turns per day, not one per interval. +_USAGE_SCRAPE_BACKOFF_SECS = 6 * 3600 + + +def _text_scrape_enabled() -> bool: + """True when the user has opted in to the credit-spending `/usage` scrape. + + Fails CLOSED: any error reading config means the scrape does not run, so a + malformed config can never silently start billing chat turns. Blocking I/O + (stat + parse), so callers offload it. + """ + try: + from kiro_crew.config.loader import KiroCrewConfig + + return bool(KiroCrewConfig.load().dashboard.usage_text_scrape_enabled) + except Exception: + logger.debug("usage text-scrape gate unreadable; treating as disabled", exc_info=True) + return False + + +def _log_scrape_disabled_once() -> None: + """Announce the skipped scrape exactly once per process.""" + global _usage_scrape_disabled_logged + if _usage_scrape_disabled_logged: + return + _usage_scrape_disabled_logged = True + logger.info( + "Kiro usage: the API returned no credit plan and the /usage text scrape " + "is disabled, so the credit pill stays unavailable. The scrape is a " + "billed kiro-cli chat turn every %ds; enable it with " + "dashboard.usage_text_scrape_enabled = true in config.json if you want " + "to pay for the readout.", + _USAGE_REFRESH_SECS, + ) + + +def _scrape_in_backoff() -> bool: + """True while a repeatedly-failing scrape is parked.""" + return time.monotonic() < _usage_scrape_backoff_until + + +def _record_scrape_outcome(success: bool) -> None: + """Track consecutive scrape failures and park the scrape once they pile up. + + Every attempt costs credits, so a scrape that cannot produce a usable plan + must stop retrying on each TTL expiry. Any success clears the counter, so a + transient hiccup does not accumulate toward the ceiling. + """ + global _usage_scrape_failures, _usage_scrape_backoff_until + if success: + _usage_scrape_failures = 0 + _usage_scrape_backoff_until = 0.0 + return + _usage_scrape_failures += 1 + if _usage_scrape_failures >= _USAGE_SCRAPE_FAILURE_THRESHOLD: + _usage_scrape_backoff_until = time.monotonic() + _USAGE_SCRAPE_BACKOFF_SECS + logger.warning( + "Kiro usage: %d consecutive /usage text scrapes yielded no credit " + "plan; pausing the scrape for %ds so it stops spending credits on " + "unusable output.", + _usage_scrape_failures, + _USAGE_SCRAPE_BACKOFF_SECS, + ) + + +def _cache_without_scrape(api_usage: object, identity: dict[str, object]) -> None: + """Cache the best available value when the scrape is not going to run. + + Degrades rather than erroring: keep a previously-good value (dimmed + ``stale``) so the pill does not blink out, otherwise surface whatever + partial fields the API did return alongside ``available: False`` — the + frontend's existing signal to hide the pill instead of rendering blanks. + + Preserving is gated on ``_same_identity``: with the scrape disabled, a + plan-less API answer recurs every refresh forever, so an unguarded preserve + would serve the PREVIOUS account's balance and email indefinitely after a + switch A->B. An unproven identity (missing or mismatched email / start_url, + including an account that never carried one) therefore reports unavailable + instead — hiding the pill is a cosmetic loss, attributing one account's + spend to another is not. + + ``identity`` is this refresh's whoami, resolved before the API attempt, so a + switch landing inside that attempt is caught on the following refresh rather + than this one. + """ + global _usage_cache, _usage_cache_ts + if _usage_cache.get("credits_plan") is not None and _same_identity(_usage_cache, identity): + _usage_cache = {**_usage_cache, "stale": True} + else: + partial = {k: _redact_strings(v) for k, v in api_usage.items()} if ( + isinstance(api_usage, dict) + ) else {} + partial.pop("_profile_arn", None) + _usage_cache = {**partial, "available": False} + _usage_cache_ts = time.time() + def _safe_float(text: str) -> float | None: """Parse a float, returning None on malformed input instead of raising.""" @@ -476,6 +593,10 @@ async def _fetch_usage_bg() -> None: proc = None sandbox_cleanup = None kiro_bin: str | None = None + # Only a refresh that actually SPAWNED the billed scrape feeds the failure + # backoff — an API-path error or a missing kiro-cli says nothing about + # whether the scrape works. + scrape_attempted = False try: kiro_bin = await _resolve_kiro_bin_for_spawn() if not kiro_bin: @@ -542,6 +663,20 @@ async def _fetch_usage_bg() -> None: # Fallback: scrape kiro-cli /usage stdout. Lossy for org-managed accounts # on recent kiro-cli (no overage line), but the only source when the API # path is unavailable (no token / non-Kiro build). + # + # This is a BILLED chat turn, not a free read, and this refresh runs on a + # timer whenever a dashboard tab is open — so it only happens when the + # user has explicitly opted in, and stops entirely once it has failed + # enough times to look broken. Both checks are before the spawn, so a + # disabled or parked scrape costs nothing at all. + if not await asyncio.to_thread(_text_scrape_enabled): + _log_scrape_disabled_once() + _cache_without_scrape(api_usage, identity) + return + if _scrape_in_backoff(): + _cache_without_scrape(api_usage, identity) + return + scrape_attempted = True # Route through the OS-level sandbox, consistent with how the main agent # kiro-cli process is spawned (AcpClient._spawn -> wrap_argv). argv, sandbox_cleanup = wrap_argv( @@ -558,6 +693,10 @@ async def _fetch_usage_bg() -> None: raw = (out or err or b"").decode(errors="replace") parsed = _parse_usage(raw) if parsed.get("credits_plan") is not None: + # A parseable plan means the scrape itself works, so clear any + # accumulated failures even on the preservation path below (which + # discards the value for being overage-blind, not for being broken). + _record_scrape_outcome(True) # Converge on the canonical shape (credits_used = total, explicit # credits_overage) so the dashboard never branches on source, then # redact credentials / exfil URLs from every string leaf before the @@ -613,13 +752,18 @@ async def _fetch_usage_bg() -> None: # No parseable credit plan this cycle (unrecognized /usage output, # or transient garbage). Keep the last good value (stale) rather than # blanking the pill; only hide when we have nothing to show. + _record_scrape_outcome(False) _cache_transient_failure() except asyncio.TimeoutError: # Transient hang — keep the last good value (stale) instead of blanking. logger.debug("Background usage fetch timed out") + if scrape_attempted: + _record_scrape_outcome(False) _cache_transient_failure() except Exception: logger.debug("Background usage fetch failed", exc_info=True) + if scrape_attempted: + _record_scrape_outcome(False) _cache_transient_failure() finally: # Always reap the subprocess on any exit path (timeout, error, or task diff --git a/test/test_session_usage.py b/test/test_session_usage.py index 5903dd7df7f..ca558189197 100644 --- a/test/test_session_usage.py +++ b/test/test_session_usage.py @@ -126,6 +126,18 @@ def _reset_usage_globals(): sessions_mod._usage_cache = {} sessions_mod._usage_cache_ts = 0.0 sessions_mod._usage_fetching = False + sessions_mod._usage_scrape_disabled_logged = False + sessions_mod._usage_scrape_failures = 0 + sessions_mod._usage_scrape_backoff_until = 0.0 + + +def _enable_text_scrape(monkeypatch): + """Opt in to the billed /usage text scrape for tests that exercise it. + + The knob defaults to FALSE in production, so any test that expects the + scrape to run must say so explicitly. + """ + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) def _mock_proc(stdout: bytes): @@ -140,6 +152,7 @@ class TestFetchUsageBg: @pytest.fixture(autouse=True) def _reset(self, monkeypatch): _reset_usage_globals() + _enable_text_scrape(monkeypatch) # Bypass OS-sandbox wrap — macOS 26 has no sandbox backend and wrap_argv # raises before the subprocess is spawned, making proc=None and skipping # the reap path that several tests assert on. @@ -457,6 +470,7 @@ class TestFetchUsageBgApi: @pytest.fixture(autouse=True) def _reset(self, monkeypatch): _reset_usage_globals() + _enable_text_scrape(monkeypatch) monkeypatch.setattr( "kiro_crew.dashboard.handlers.sessions.wrap_argv", lambda argv, **k: (list(argv), None), @@ -771,8 +785,9 @@ class TestCredentialSelectionIsAnchored: ARN = "arn:aws:codewhisperer:us-east-1:1:profile/A" @pytest.fixture(autouse=True) - def _reset(self): + def _reset(self, monkeypatch): _reset_usage_globals() + _enable_text_scrape(monkeypatch) yield _reset_usage_globals() @@ -895,3 +910,222 @@ async def test_private_coupling_key_never_reaches_the_cache(self): return_value=api_dict): await sessions_mod._fetch_usage_bg() assert "_profile_arn" not in sessions_mod._usage_cache + + +class TestTextScrapeIsOptIn: + """The `/usage` text scrape is a BILLED chat turn, so it only runs on request. + + The refresh fires every ``_USAGE_REFRESH_SECS`` for as long as a dashboard tab + is open, so an ungated fallback spends credits forever merely to render the + credit meter. + """ + + @pytest.fixture(autouse=True) + def _reset(self, monkeypatch): + _reset_usage_globals() + monkeypatch.setattr( + "kiro_crew.dashboard.handlers.sessions.wrap_argv", + lambda argv, **k: (list(argv), None), + ) + # The API path yields no plan, which is exactly what used to fall through + # to the billed scrape. + monkeypatch.setattr( + sessions_mod.kiro_usage_api, "fetch_usage_limits", lambda **k: None + ) + monkeypatch.setattr( + sessions_mod, "_resolve_kiro_bin_for_spawn", AsyncMock(return_value="/bin/kiro") + ) + monkeypatch.setattr(sessions_mod, "_fetch_whoami", AsyncMock(return_value={})) + yield + _reset_usage_globals() + + def _spawn_mock(self, stdout: bytes = b""): + return AsyncMock(return_value=_mock_proc(stdout)) + + @pytest.mark.asyncio + async def test_disabled_knob_never_spawns_the_billed_scrape(self, monkeypatch): + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + spawn = self._spawn_mock(SAMPLE_USAGE.encode()) + with patch("asyncio.create_subprocess_exec", spawn): + await sessions_mod._fetch_usage_bg() + assert spawn.await_count == 0, spawn.await_args_list + + @pytest.mark.asyncio + async def test_enabled_knob_spawns_the_scrape_and_caches_it(self, monkeypatch): + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) + spawn = self._spawn_mock(SAMPLE_USAGE.encode()) + with patch("asyncio.create_subprocess_exec", spawn): + await sessions_mod._fetch_usage_bg() + assert spawn.await_count == 1 + assert "/usage" in list(spawn.await_args.args) + assert sessions_mod._usage_cache.get("credits_plan") == 10000.0 + + @pytest.mark.asyncio + async def test_disabled_degrades_to_unavailable_instead_of_erroring(self, monkeypatch): + # Nothing to show: the pill hides on `available: False` rather than + # rendering blanks, and the refresh does not raise. The spawn mock holds + # PARSEABLE output, so a cache carrying a plan would prove the gate leaked. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + with patch("asyncio.create_subprocess_exec", self._spawn_mock(SAMPLE_USAGE.encode())): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_cache.get("available") is False + assert "credits_plan" not in sessions_mod._usage_cache + + @pytest.mark.asyncio + async def test_disabled_keeps_partial_api_fields(self, monkeypatch): + # The API answered but carried no plan (e.g. plan name + reset date only). + # Keep what it gave alongside the unavailable marker instead of discarding it. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + monkeypatch.setattr( + sessions_mod.kiro_usage_api, + "fetch_usage_limits", + lambda **k: {"plan": "KIRO POWER", "resets": "2026-09-01", + "_profile_arn": "arn:aws:codewhisperer:us-east-1:1:profile/A"}, + ) + with patch("asyncio.create_subprocess_exec", self._spawn_mock()): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_cache.get("plan") == "KIRO POWER" + assert sessions_mod._usage_cache.get("resets") == "2026-09-01" + assert sessions_mod._usage_cache.get("available") is False + # The private coupling key is stripped on this path too. + assert "_profile_arn" not in sessions_mod._usage_cache + + @pytest.mark.asyncio + async def test_disabled_preserves_a_prior_good_value_as_stale(self, monkeypatch): + # A previously-good reading for THIS SAME account is dimmed, not blanked — + # and not replaced by the scrape's own (parseable) numbers, which the gate + # must never fetch. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + monkeypatch.setattr( + sessions_mod, + "_fetch_whoami", + AsyncMock(return_value={"email": "a@corp.com", + "start_url": "https://a.awsapps.com/start"}), + ) + sessions_mod._usage_cache = {"credits_used": 500.0, "credits_plan": 1000.0, + "source": "api", "email": "a@corp.com", + "start_url": "https://a.awsapps.com/start"} + with patch("asyncio.create_subprocess_exec", self._spawn_mock(SAMPLE_USAGE.encode())): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_cache["credits_plan"] == 1000.0 + assert sessions_mod._usage_cache["stale"] is True + assert "available" not in sessions_mod._usage_cache + + @pytest.mark.asyncio + async def test_disabled_never_serves_a_different_accounts_balance(self, monkeypatch): + # Account A's reading is cached; the user switches to account B, whose API + # returns no plan. With the scrape disabled that answer recurs on every + # refresh forever, so preserving A would pin A's balance and email on + # screen indefinitely under B's session. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + monkeypatch.setattr( + sessions_mod, + "_fetch_whoami", + AsyncMock(return_value={"email": "b@corp.com", + "start_url": "https://b.awsapps.com/start"}), + ) + sessions_mod._usage_cache = {"credits_used": 9999.0, "credits_plan": 10000.0, + "source": "api", "email": "a@corp.com", + "start_url": "https://a.awsapps.com/start"} + with patch("asyncio.create_subprocess_exec", self._spawn_mock()): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_cache.get("available") is False + assert sessions_mod._usage_cache.get("credits_used") != 9999.0 + assert sessions_mod._usage_cache.get("credits_plan") != 10000.0 + assert sessions_mod._usage_cache.get("email") != "a@corp.com" + + @pytest.mark.asyncio + async def test_disabled_never_preserves_an_unproven_identity(self, monkeypatch): + # The cached reading carries no identity, so it cannot be proven to belong + # to whoever is signed in now. Unproven means unavailable. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + monkeypatch.setattr( + sessions_mod, + "_fetch_whoami", + AsyncMock(return_value={"email": "b@corp.com", + "start_url": "https://b.awsapps.com/start"}), + ) + sessions_mod._usage_cache = {"credits_used": 500.0, "credits_plan": 1000.0, + "source": "api"} + with patch("asyncio.create_subprocess_exec", self._spawn_mock()): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_cache.get("available") is False + + @pytest.mark.asyncio + async def test_disabled_notice_is_logged_once_not_per_cycle(self, monkeypatch, caplog): + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: False) + caplog.set_level("INFO", logger=sessions_mod.logger.name) + with patch("asyncio.create_subprocess_exec", self._spawn_mock()): + for _ in range(4): + await sessions_mod._fetch_usage_bg() + hits = [r for r in caplog.records if "text scrape is disabled" in r.getMessage()] + assert len(hits) == 1, [r.getMessage() for r in hits] + assert hits[0].levelname == "INFO" + + @pytest.mark.asyncio + async def test_repeated_failures_back_off_instead_of_retrying_every_ttl( + self, monkeypatch + ): + # Unparseable output costs a billed turn each time, so the scrape stops + # after the failure threshold rather than firing on every refresh. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) + spawn = self._spawn_mock(b"not a usage block") + with patch("asyncio.create_subprocess_exec", spawn): + for _ in range(sessions_mod._USAGE_SCRAPE_FAILURE_THRESHOLD + 3): + await sessions_mod._fetch_usage_bg() + assert spawn.await_count == sessions_mod._USAGE_SCRAPE_FAILURE_THRESHOLD + assert sessions_mod._scrape_in_backoff() is True + + @pytest.mark.asyncio + async def test_a_timeout_counts_toward_the_backoff(self, monkeypatch): + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) + proc = MagicMock() + proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError) + proc.kill = MagicMock() + proc.wait = AsyncMock(return_value=0) + spawn = AsyncMock(return_value=proc) + with patch("asyncio.create_subprocess_exec", spawn): + for _ in range(sessions_mod._USAGE_SCRAPE_FAILURE_THRESHOLD + 2): + await sessions_mod._fetch_usage_bg() + assert spawn.await_count == sessions_mod._USAGE_SCRAPE_FAILURE_THRESHOLD + assert sessions_mod._scrape_in_backoff() is True + + @pytest.mark.asyncio + async def test_an_api_path_failure_does_not_count_toward_the_backoff( + self, monkeypatch + ): + # A refresh that never reached the scrape says nothing about whether the + # scrape works, so it must not consume the failure budget. + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) + monkeypatch.setattr( + sessions_mod, "_resolve_kiro_bin_for_spawn", AsyncMock(side_effect=OSError("boom")) + ) + for _ in range(sessions_mod._USAGE_SCRAPE_FAILURE_THRESHOLD + 2): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_scrape_failures == 0 + assert sessions_mod._scrape_in_backoff() is False + + @pytest.mark.asyncio + async def test_a_success_clears_accumulated_failures(self, monkeypatch): + monkeypatch.setattr(sessions_mod, "_text_scrape_enabled", lambda: True) + with patch("asyncio.create_subprocess_exec", self._spawn_mock(b"garbage")): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_scrape_failures == 1 + with patch("asyncio.create_subprocess_exec", + self._spawn_mock(SAMPLE_USAGE.encode())): + await sessions_mod._fetch_usage_bg() + assert sessions_mod._usage_scrape_failures == 0 + + def test_the_gate_fails_closed_when_config_is_unreadable(self, monkeypatch): + # A malformed config must never silently start billing chat turns. + import kiro_crew.config.loader as loader_mod + + monkeypatch.setattr( + loader_mod.KiroCrewConfig, "load", staticmethod(lambda *a, **k: 1 / 0) + ) + assert sessions_mod._text_scrape_enabled() is False + + def test_the_knob_defaults_to_off(self): + from kiro_crew.config.loader import DashboardConfig + + assert DashboardConfig().usage_text_scrape_enabled is False