diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dde03b58..d1713314e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **proxy:** run a cold-start fast pass before background-compression deferral so byte-identical freeze doesn't lock sessions to the uncompressed transcript. Since #1850, a session's provider-cached prefix is frozen in whatever form its cold start forwarded; deferring the WHOLE pipeline (`HEADROOM_BACKGROUND_COMPRESSION=1`, frozen=0, ≥50k tokens) therefore cached the raw transcript and forfeited the session's compression savings for its lifetime — including sub-second lossless wins like `read_lifecycle` stale-read drops, observed in the field as sessions permanently stuck at 0 savings. The deferral branch now runs the pipeline synchronously with the new `skip_kompress=True` kwarg (everything except the Kompress ML stage — the only stage that can blow the request budget per #1171) under a bounded fast-pass budget (`HEADROOM_COLD_START_FAST_PASS_TIMEOUT_SECONDS`, default 10s), forwards the pruned form, and defers only Kompress to the background job (tagged `deferred:kompress_background`). Fail-open: on fast-pass timeout/error the request forwards uncompressed exactly as before. Units routed to Kompress under `skip_kompress` take the same fallback as when the model isn't ready. * **init/codex:** don't overwrite the user's `hooks.json`. `_ensure_codex_hooks` wrote a fresh payload containing only Headroom's two hooks, wholesale-replacing `~/.codex/hooks.json` — so any user-managed Codex hooks (and other top-level keys) were silently destroyed on `headroom init codex`. It now read-merges: existing entries are preserved, Headroom's are deduped on the `headroom-init-codex` marker and appended, matching `_ensure_claude_hooks` / `_ensure_copilot_hooks`. * **ccr:** detect `read_lifecycle` stale/superseded markers in the retrieve-tool injector so they stay redeemable. Those markers (`[Read content stale: … Retrieve original: hash=]`) store the original bytes in the CCR store under a valid hash, but none of `CCRToolInjector`'s patterns matched them — every pattern required the word "compressed" or the `< 0`, so a genuinely free model — where `_estimate_compression_savings_usd` correctly returns `0.0` — was treated as "unpriced" and fell through to the blended fallback rate, writing phantom cost-avoided into the JSONL ledger and surfacing it in `headroom savings`. The ledger now trusts the estimate verbatim for known models (it already falls back internally for models litellm can't price and returns `0.0` for free ones), fixing the same defect at this call site that was already fixed inside the helper. * **init/codex:** stop `headroom init codex` from deleting per-profile provider settings. `_ensure_codex_provider` removed the root-level `model_provider`/`openai_base_url` (which init owns) with a multiline regex that matched those keys in **every** table, so a user's `[profiles.*]` overrides (e.g. `[profiles.work] model_provider = "azure"`) were silently stripped and those profiles fell through to the injected `"headroom"` default — config corruption. The strip is now scoped to the document root (everything before the first table header), so per-profile overrides are preserved while init still replaces a root-level assignment. diff --git a/headroom/cli/install.py b/headroom/cli/install.py index 8ba43ca52..77aa18ab3 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -337,7 +337,15 @@ def install_status(profile: str) -> None: click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}") if payload and isinstance(payload, dict): click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}") - click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}") + # `config` may be a non-dict (null / string / list) if a different or + # older service is answering on the port. `payload.get('config', {})` + # only defaults on a MISSING key, so a present-but-non-dict value would + # reach `.get('backend', ...)` and crash with AttributeError. Guard on + # isinstance, mirroring wrap.py's _proxy_health_config. + config = payload.get("config") + if not isinstance(config, dict): + config = {} + click.echo(f"Backend: {config.get('backend', manifest.backend)}") @install.command("start") diff --git a/tests/test_cli/test_install_cli.py b/tests/test_cli/test_install_cli.py index f52cc0b85..95c05046f 100644 --- a/tests/test_cli/test_install_cli.py +++ b/tests/test_cli/test_install_cli.py @@ -137,6 +137,33 @@ def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None: assert "Backend: anthropic" in result.output +def test_install_status_survives_non_dict_config(monkeypatch) -> None: + """A health payload whose `config` is a non-dict (e.g. a different service + answering on the port returns config: null) must not crash the command.""" + runner = CliRunner() + + class Manifest: + profile = "default" + preset = "persistent-service" + runtime_kind = "python" + supervisor_kind = "service" + scope = "user" + port = 8787 + backend = "anthropic" + health_url = "http://127.0.0.1:8787/readyz" + + monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest()) + monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running") + monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True) + monkeypatch.setattr("headroom.cli.install.probe_json", lambda url: {"config": None}) + + result = runner.invoke(main, ["install", "status"]) + + # No AttributeError; Backend falls back to the manifest value. + assert result.exit_code == 0, result.output + assert "Backend: anthropic" in result.output + + def test_install_restart_uses_internal_helpers(monkeypatch) -> None: runner = CliRunner() calls: list[str] = []