2026-04-23 07:39:52 -05:00
|
|
|
from __future__ import annotations
|
2026-04-24 15:33:30 +02:00
|
|
|
|
feat: add deterministic runtime rollout controls (#1490)
## Description
Establish one centrally resolved, observable, deterministic, versioned
runtime rollout-control mechanism for Headroom. Runtime rollout controls
which behaviors an already-built artifact may expose; it does not select
or qualify a Headroom release/version.
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Bug fix (non-breaking change that fixes rollout enforcement
regressions)
- [x] Documentation update
- [x] Code refactoring (no functional changes)
## Changes Made
- Added `RolloutChannel`, `HEADROOM_ROLLOUT_CHANNEL`,
`--rollout-channel`, and a versioned immutable `RolloutSnapshot` shared
by Python configuration boundaries.
- Added schema/policy versions, canonical registry and snapshot SHA-256
identities, per-feature decision reasons, disable precedence, unsafe
qualification poisoning, strict CLI validation, and fail-closed
environment handling.
- Added `headroom rollout status --json`, Python `/stats.rollout`, and
Rust `/rollout/status` runtime provenance.
- Added equivalent Rust snapshot semantics and shared Python/Rust policy
vectors while retaining language-specific feature registries.
- Enforced rollout policy at alternate Python server composition roots
so `HEADROOM_READ_MATURATION=1` cannot bypass its beta gate.
- Preserved typed rollout snapshots across multi-worker serialization
with schema, policy, registry, snapshot-digest, type, and feature-name
validation.
- Made loopback runtime output-shaper updates replace the immutable
snapshot atomically for request readers, retain explicit request/disable
provenance, preserve channel and kill-switch precedence, invalidate
cached stats, and return the effective rollout decision.
- Made `headroom learn --verbosity --apply` report a channel-blocked
update instead of claiming the shaper is live.
- Made explicit CLI feature flags fail loudly when their current channel
blocks them.
- Made persistent interceptor installation select canary automatically,
or reject an explicitly insufficient channel unless the break-glass
override is set.
- Updated architecture, proxy, rollout, learn, and output-shaper
documentation with required channels and hot-reload semantics.
## Testing
- [x] Unit tests pass
- [x] Linting passes (`ruff check .` and `ruff format --check .`)
- [x] Type checking passes (`mypy headroom --ignore-missing-imports`)
- [x] New regression tests added for every corrected behavior
- [x] Rust tests and production-target Clippy pass
- [x] Documentation build passes
### Test Output
```text
Focused rollout coverage suite
57 passed; headroom.rollout + rollout CLI: 98% coverage
Affected proxy/rollout/transform/governance suites
222 passed; 0 failed
Final changed regression suites
100 passed; 0 failed
Cross-module hot-reload isolation regression
6 passed; 0 failed
cargo test -p headroom-core -p headroom-proxy --quiet
headroom-core: 924 passed; 1 ignored
headroom-proxy and integration suites: all passed
cargo clippy -p headroom-core -p headroom-proxy --lib --bins -- -D warnings
cargo fmt --all -- --check
ruff check .
ruff format --check .
mypy headroom --ignore-missing-imports
git diff --check
All passed
cd docs && npm run build
Compiled successfully; 164 static pages generated
```
The unsharded Windows-only CI selection exposed unrelated baseline
failures, principally the existing `sqlite:///C:\\...` URL parser
producing an invalid `\\C:\\...` path. At commit `8e793a80`, all 52
completed GitHub checks passed; the only other conclusions are expected
skips and superseded governance jobs.
## Real Behavior Proof
- **Environment:** Windows checkout on Python 3.13.3 and the current
Rust workspace, based on upstream `main` at `93f2d7a2`.
- **Exact command / steps:** Exercised canary and beta feature requests
through CLI status, Python `/stats.rollout`, Rust `/rollout/status`,
multi-worker payload round trips, loopback `/admin/runtime-env`, real
proxy request shaping before/after hot reload, installer manifest
generation, and shared Python/Rust policy vectors.
- **Observed result:** Stable blocks unstable requests; disable wins
over explicit/default/legacy/unsafe paths; unsafe state reports
`qualification_eligible=false`; worker handoff rejects tampering;
running output shaping changes only when the effective beta policy
permits it; explicit blocked flags fail with actionable diagnostics.
- **Not tested:** Live production traffic requiring provider
credentials, or future artifact qualification/promotion automation
(intentionally out of scope).
## Runtime Rollout Safety
- **Rollout-managed features:** Python `tool_result_interceptors`,
`proxy_output_shaper`, `read_maturation`; Rust `native_bedrock`,
`openai_responses_streaming`, `canary_probe`.
- **Minimum rollout channel:** Registry-defined per feature; process
default is `stable`.
- **Stable/default behavior changed:** No unstable feature becomes
enabled by default. Explicit blocked CLI flags now fail instead of
silently doing nothing.
- **Kill switch / disable path:**
`HEADROOM_DISABLE_FEATURES=<comma-separated feature names>`; explicit
disable has highest precedence, including over the unsafe override.
- **Unsafe override required:** No.
`HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1` is break-glass only and
makes qualification evidence ineligible.
- **Qualification impact:** Adds machine-readable policy/snapshot
identities and eligibility; does not implement qualification itself.
- **Rollback path:** Set the named disable list for operational
rollback, lower the channel, or revert this PR.
## Review Readiness
- [x] I have performed a full diff review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented hard-to-understand areas
- [x] I have made corresponding documentation changes
- [x] My changes generate no new warnings
- [x] I added tests that reproduce and prevent every regression fixed
during review
- [x] New and existing affected tests pass locally
- [x] I did **not** edit `CHANGELOG.md`; release-please generates it
from the Conventional Commit PR title
## Additional Notes
Out of scope: artifact candidates, benchmark orchestration,
qualification manifests/gates, promotion automation, release branches,
publication guards, and release-risk classification. Those workflows can
consume the rollout registry digest, runtime snapshot digest, decision
reasons, and qualification eligibility through supported black-box
interfaces.
---------
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
Co-authored-by: JD Davis <jd@JDH-AIR-00.local>
2026-08-12 23:16:54 -05:00
|
|
|
import json
|
2026-05-09 22:11:42 -07:00
|
|
|
import os
|
2026-04-23 07:39:52 -05:00
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
import click
|
|
|
|
|
import click.shell_completion as click_shell_completion
|
|
|
|
|
import pytest
|
|
|
|
|
from click.testing import CliRunner
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
from headroom.cli.learn import _AgentChoice
|
|
|
|
|
from headroom.cli.main import main
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
@pytest.fixture
|
|
|
|
|
def runner() -> CliRunner:
|
|
|
|
|
return CliRunner()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class FakeWriter:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls: list[tuple[list[object], object, bool]] = []
|
2026-05-09 15:45:26 -07:00
|
|
|
self.fail_for: object | None = None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((recommendations, project, dry_run))
|
2026-05-09 15:45:26 -07:00
|
|
|
if project is self.fail_for:
|
|
|
|
|
raise PermissionError(f"cannot write {project.project_path}")
|
2026-04-23 07:39:52 -05:00
|
|
|
return SimpleNamespace(
|
|
|
|
|
dry_run=dry_run,
|
|
|
|
|
content_by_file={
|
|
|
|
|
Path(project.project_path) / "AGENTS.md": "<!-- headroom -->\nRule 1\nRule 2"
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class FakePlugin:
|
|
|
|
|
def __init__(self, name: str, display_name: str, projects: list[object]) -> None:
|
|
|
|
|
self.name = name
|
|
|
|
|
self.display_name = display_name
|
|
|
|
|
self._projects = projects
|
|
|
|
|
self.writer = FakeWriter()
|
|
|
|
|
self.scan_calls: list[tuple[object, int]] = []
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
self.last_include_subagents: bool | None = None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def detect(self) -> bool:
|
|
|
|
|
return True
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def create_writer(self) -> FakeWriter:
|
|
|
|
|
return self.writer
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def discover_projects(self) -> list[object]:
|
|
|
|
|
return self._projects
|
2026-04-24 15:33:30 +02:00
|
|
|
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
def scan_project(self, project, max_workers: int = 1, include_subagents: bool = True): # noqa: ANN001, ANN201
|
2026-04-23 07:39:52 -05:00
|
|
|
self.scan_calls.append((project, max_workers))
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
self.last_include_subagents = include_subagents
|
2026-04-23 07:39:52 -05:00
|
|
|
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class FakeAnalyzer:
|
|
|
|
|
def __init__(self, model: str | None = None) -> None:
|
|
|
|
|
self.model = model
|
|
|
|
|
self.calls: list[tuple[object, list[object]]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def analyze(self, project, sessions): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((project, sessions))
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
total_sessions=len(sessions),
|
|
|
|
|
total_calls=3,
|
|
|
|
|
total_failures=1,
|
|
|
|
|
failure_rate=1 / 3,
|
|
|
|
|
recommendations=[SimpleNamespace(section="Rules")],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_agent_choice_convert_and_shell_complete(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
choice = _AgentChoice()
|
|
|
|
|
monkeypatch.setattr(click, "shell_completion", click_shell_completion)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.learn.registry.get_registry",
|
|
|
|
|
lambda: {"codex": object(), "claude": object()},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.learn.registry.available_agent_names",
|
|
|
|
|
lambda: ["claude", "codex"],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert choice.convert("auto", None, None) == "auto"
|
|
|
|
|
assert choice.convert("CODEX", None, None) == "codex"
|
|
|
|
|
with pytest.raises(Exception, match="Unknown agent: bad"):
|
|
|
|
|
choice.convert("bad", None, None)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
completions = choice.shell_complete(None, None, "c") # type: ignore[arg-type]
|
|
|
|
|
assert [item.value for item in completions] == ["claude", "codex"]
|
|
|
|
|
assert choice.get_metavar(None) == "[auto|<agent>]" # type: ignore[arg-type]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_exits_cleanly_when_model_detection_fails(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.learn.analyzer._detect_default_model",
|
|
|
|
|
lambda: (_ for _ in ()).throw(RuntimeError("no model")),
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 1
|
|
|
|
|
assert "Error: no model" in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_auto_agent_reports_no_detected_plugins(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.auto_detect_plugins", lambda: [])
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0
|
|
|
|
|
assert "No coding agent data found." in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_single_agent_shows_available_projects_when_cwd_missing(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project = SimpleNamespace(name="demo", project_path=tmp_path / "demo")
|
|
|
|
|
plugin = FakePlugin("codex", "Codex", [project])
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0
|
|
|
|
|
assert "No codex project data found for" in result.output
|
|
|
|
|
assert "Available codex projects:" in result.output
|
|
|
|
|
assert "demo" in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_project_lookup_and_apply_flow(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project_path = tmp_path / "project-a"
|
|
|
|
|
project_path.mkdir()
|
|
|
|
|
matched = SimpleNamespace(name="project-a", project_path=project_path)
|
|
|
|
|
unmatched = SimpleNamespace(name="project-b", project_path=tmp_path / "project-b")
|
|
|
|
|
plugin = FakePlugin("codex", "Codex", [matched, unmatched])
|
|
|
|
|
analyzer = FakeAnalyzer()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
|
2026-05-09 16:00:10 -07:00
|
|
|
monkeypatch.setattr("os.cpu_count", lambda: 12)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "codex", "--project", str(project_path), "--apply", "--workers", "4"],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "Path: " in result.output
|
|
|
|
|
assert "Analyzing with gpt-4o..." in result.output
|
|
|
|
|
assert "Recommendations: 1" in result.output
|
|
|
|
|
assert "[WROTE]" in result.output
|
|
|
|
|
assert "Rule 1" in result.output
|
|
|
|
|
assert plugin.scan_calls == [(matched, 4)]
|
|
|
|
|
assert analyzer.calls[0][0] is matched
|
|
|
|
|
assert plugin.writer.calls[0][2] is False
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
fix(learn): aggregate verbosity baselines across projects instead of overwriting (#1288)
## Description
`headroom learn --verbosity --apply --all` was building the
output-shaper's savings baseline from only **one** project.
`_run_verbosity` wrote the savings ledger *inside* the per-project loop
(`ledger.baseline = baseline; ledger.save(...)`), so each project
replaced the previous baseline and only the last project processed
survived — frequently a near-empty one. The synthetic-control estimate
that `/stats` exposes (`savings.by_layer.output_shaping`) was then
computed against a tiny, unrepresentative sample.
This PR makes `--all` aggregate across every targeted project and write
the ledger **once**, after the loop.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `BaselineModel.merge()` / `_Accum.merge()`
(`headroom/proxy/output_savings.py`): fold one baseline into another.
The accumulators hold additive online stats (`n` / `sum` / `sumsq`), so
merging is element-wise and order-independent — identical to having
observed both corpora against a single model.
- `_run_verbosity` (`headroom/cli/learn.py`): accumulate a single
`BaselineModel` across all targeted projects and persist it once after
the loop, instead of overwriting per project. The applied verbosity
level now comes from the project with the most samples (strongest
signal) rather than whichever sorted last. Single-project runs are
unchanged (an aggregate of one). When no transcripts are found, it
prints a clear message and writes nothing.
- Tests: unit test for `BaselineModel.merge`; CLI test that `--all
--apply` across two projects aggregates both strata (totals summed, not
last-wins) and applies the busier project's level.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_output_savings.py tests/test_cli_learn.py tests/test_verbosity_learn.py -q
tests/test_output_savings.py ............................... [ 54%]
tests/test_cli_learn.py ........... [ 73%]
tests/test_verbosity_learn.py ............... [100%]
============================== 57 passed in 0.51s ==============================
$ uv run ruff check headroom/cli/learn.py headroom/proxy/output_savings.py
All checks passed!
$ uv run mypy headroom/cli/learn.py headroom/proxy/output_savings.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: macOS, Python 3.12.13, this branch off `upstream/main`.
- Exact command / steps: `headroom learn --verbosity --apply --all` (run
across a multi-project transcript corpus), then inspect
`~/.headroom/output_savings.json` (`baseline.glob.n`); compared against
`headroom learn --verbosity --apply` for a single busy project.
- Symptom (pre-fix, installed build): `headroom learn --verbosity
--apply --all` across a multi-project transcript corpus wrote
`~/.headroom/output_savings.json` with `baseline.glob.n = 2` (the last
project processed was a near-empty `…/venv/bin` dir), while targeting a
single busy project gave `baseline.glob.n = 15658`.
- With this change: the new CLI test
(`test_verbosity_all_apply_aggregates_baselines_across_projects`) drives
`--all --apply` over two projects (3 samples + 1 sample) and asserts the
persisted ledger has `total_samples == 4` with both strata present, plus
the busier project's level applied.
- Observed result: aggregated baseline persisted once; both strata
retained; level taken from the higher-sample project.
- Not tested: re-running the patched `--all` end-to-end on a live
multi-project machine (covered instead by the unit merge-math test and
the faked-`analyze` CLI test).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A — CLI/behavioral change.
## Additional Notes
- No linked issue (`Closes #` left blank intentionally).
- Documentation checklist item is N/A — no user-facing docs describe the
per-project overwrite behavior.
- Level-selection note: for `--all`, the applied verbosity level is now
deterministic (most-samples project) instead of last-processed; this is
the intended improvement, not a behavior to preserve.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-30 15:37:37 +02:00
|
|
|
def test_verbosity_all_apply_aggregates_baselines_across_projects(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
import json as _json
|
|
|
|
|
|
|
|
|
|
from headroom.proxy.output_savings import BaselineModel, SavingsLedger
|
|
|
|
|
|
|
|
|
|
# Two projects, each with a transcript dir holding a dummy session file
|
|
|
|
|
# (analyze is faked, so contents are irrelevant — only presence matters).
|
|
|
|
|
proj_a_dir = tmp_path / "a"
|
|
|
|
|
proj_b_dir = tmp_path / "b"
|
|
|
|
|
for d in (proj_a_dir, proj_b_dir):
|
|
|
|
|
d.mkdir()
|
|
|
|
|
(d / "s.jsonl").write_text("{}")
|
|
|
|
|
proj_a = SimpleNamespace(name="a", project_path=tmp_path / "src-a", data_path=proj_a_dir)
|
|
|
|
|
proj_b = SimpleNamespace(name="b", project_path=tmp_path / "src-b", data_path=proj_b_dir)
|
|
|
|
|
plugin = FakePlugin("claude", "Claude Code", [proj_a, proj_b])
|
|
|
|
|
|
|
|
|
|
# Per-project synthetic baselines. Project A has more samples, so its level
|
|
|
|
|
# must be the one applied.
|
|
|
|
|
base_a = BaselineModel()
|
|
|
|
|
for v in (100, 200, 300):
|
|
|
|
|
base_a.observe("opus|new_user_ask|s|tools", v)
|
|
|
|
|
base_b = BaselineModel()
|
|
|
|
|
base_b.observe("sonnet|unknown|m|notools", 50)
|
|
|
|
|
|
|
|
|
|
class _Profile:
|
|
|
|
|
def __init__(self, level: int) -> None:
|
|
|
|
|
self.level = level
|
|
|
|
|
self.confidence = "high"
|
|
|
|
|
self.source = "heuristic"
|
|
|
|
|
self.rationale = "test"
|
|
|
|
|
self.signals: dict[str, object] = {}
|
|
|
|
|
self.learned_at: str | None = None
|
|
|
|
|
|
|
|
|
|
def save(self, path: object) -> None:
|
|
|
|
|
Path(str(path)).write_text(_json.dumps({"level": self.level}))
|
|
|
|
|
|
|
|
|
|
results = {
|
|
|
|
|
str(proj_a.project_path): (_Profile(1), base_a),
|
|
|
|
|
str(proj_b.project_path): (_Profile(3), base_b),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fake_analyze(session_paths, project_path, llm_judge=None): # noqa: ANN001, ANN201
|
|
|
|
|
return results[project_path]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.verbosity.analyze", fake_analyze)
|
|
|
|
|
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path / "ws"))
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "claude", "--verbosity", "--all", "--apply"],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
|
|
|
|
ledger = SavingsLedger.load(tmp_path / "ws" / "output_savings.json")
|
|
|
|
|
# Aggregated, not last-project-wins: both strata present and totals summed.
|
|
|
|
|
assert ledger.baseline.total_samples == 4
|
|
|
|
|
assert "opus|new_user_ask|s|tools" in ledger.baseline.strata
|
|
|
|
|
assert "sonnet|unknown|m|notools" in ledger.baseline.strata
|
|
|
|
|
assert "across 2 project(s)" in result.output
|
|
|
|
|
# The applied level comes from the project with the most samples (A → 1).
|
|
|
|
|
verbosity = _json.loads((tmp_path / "ws" / "verbosity.json").read_text())
|
|
|
|
|
assert verbosity["level"] == 1
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_reports_missing_requested_project_and_lists_discovered(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
requested = tmp_path / "missing"
|
|
|
|
|
requested.mkdir()
|
|
|
|
|
discovered = SimpleNamespace(name="project-a", project_path=tmp_path / "project-a")
|
|
|
|
|
plugin = FakePlugin("claude", "Claude Code", [discovered])
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "claude", "--project", str(requested)],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0
|
|
|
|
|
assert f"No project data found for {requested.resolve()}" in result.output
|
|
|
|
|
assert "Available discovered projects:" in result.output
|
|
|
|
|
assert "[claude]" in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_analyze_all_uses_default_workers_and_prints_summary(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
projects_a = [SimpleNamespace(name="a", project_path=tmp_path / "a")]
|
|
|
|
|
projects_b = [SimpleNamespace(name="b", project_path=tmp_path / "b")]
|
|
|
|
|
plugin_a = FakePlugin("codex", "Codex", projects_a)
|
|
|
|
|
plugin_b = FakePlugin("claude", "Claude Code", projects_b)
|
|
|
|
|
analyzer = FakeAnalyzer()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.learn.registry.auto_detect_plugins",
|
|
|
|
|
lambda: [plugin_a, plugin_b],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
|
|
|
|
|
monkeypatch.setattr("os.cpu_count", lambda: 12)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn", "--all"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "Detected agents: Codex, Claude Code" in result.output
|
|
|
|
|
assert "Total: 2 projects, 2 failures, 2 recommendations" in result.output
|
|
|
|
|
assert plugin_a.scan_calls == [(projects_a[0], 8)]
|
|
|
|
|
assert plugin_b.scan_calls == [(projects_b[0], 8)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-05-09 15:45:26 -07:00
|
|
|
def test_learn_analyze_all_continues_when_one_project_write_fails(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
blocked = SimpleNamespace(name="blocked", project_path=tmp_path / "blocked")
|
|
|
|
|
ok = SimpleNamespace(name="ok", project_path=tmp_path / "ok")
|
|
|
|
|
plugin = FakePlugin("claude", "Claude Code", [blocked, ok])
|
|
|
|
|
plugin.writer.fail_for = blocked
|
|
|
|
|
analyzer = FakeAnalyzer()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "claude", "--all", "--apply"],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "Warning: failed to write recommendations" in result.output
|
|
|
|
|
assert str(blocked.project_path) in result.output
|
|
|
|
|
assert "[WROTE]" in result.output
|
|
|
|
|
assert str(ok.project_path / "AGENTS.md") in result.output
|
2026-05-09 22:11:42 -07:00
|
|
|
expected_workers = min(os.cpu_count() or 4, 8)
|
|
|
|
|
assert plugin.scan_calls == [(blocked, expected_workers), (ok, expected_workers)]
|
2026-05-09 15:45:26 -07:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def test_learn_handles_empty_sessions_and_no_pattern_outputs(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
no_sessions = SimpleNamespace(name="empty", project_path=tmp_path / "empty")
|
|
|
|
|
no_failures = SimpleNamespace(name="clean", project_path=tmp_path / "clean")
|
|
|
|
|
no_actions = SimpleNamespace(name="no-actions", project_path=tmp_path / "no-actions")
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class BranchingPlugin(FakePlugin):
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
def scan_project(self, project, max_workers: int = 1, include_subagents: bool = True): # noqa: ANN001, ANN201
|
2026-04-23 07:39:52 -05:00
|
|
|
self.scan_calls.append((project, max_workers))
|
|
|
|
|
if project is no_sessions:
|
|
|
|
|
return []
|
|
|
|
|
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class BranchingAnalyzer(FakeAnalyzer):
|
|
|
|
|
def analyze(self, project, sessions): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((project, sessions))
|
|
|
|
|
if project is no_failures:
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
total_sessions=1,
|
|
|
|
|
total_calls=2,
|
|
|
|
|
total_failures=0,
|
|
|
|
|
failure_rate=0.0,
|
|
|
|
|
recommendations=[],
|
|
|
|
|
)
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
total_sessions=1,
|
|
|
|
|
total_calls=2,
|
|
|
|
|
total_failures=1,
|
|
|
|
|
failure_rate=0.5,
|
|
|
|
|
recommendations=[],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
plugin = BranchingPlugin("codex", "Codex", [no_sessions, no_failures, no_actions])
|
|
|
|
|
analyzer = BranchingAnalyzer()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "No conversation data found." in result.output
|
|
|
|
|
assert "No failures or patterns found." in result.output
|
|
|
|
|
assert "No actionable patterns found." in result.output
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
|
|
|
|
|
|
fix(learn): surface Codex analysis failures (#3016)
## Description
`headroom learn` could invoke Codex CLI from a non-Git working directory
without Codex’s required bypass flag. The resulting backend error was
then swallowed by the analyzer and rendered as “No actionable patterns
found” with exit code 0. This fixes both coupled defects so Codex can
run from discovered project locations and genuine analysis failures
remain visible and machine-detectable.
Closes #3008
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added `--skip-git-repo-check` to the Codex CLI analysis backend
command.
- Added an explicit `analysis_error` result field instead of conflating
backend failure with an empty recommendation set.
- Kept multi-project analysis best-effort, while returning exit code 1
after any project analysis fails.
- Prevented failed analysis from printing a misleading no-pattern
success message.
- Added analyzer and CLI regression coverage for the command and
failure-propagation contracts.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
uv run pytest -q tests/test_learn/test_analyzer.py tests/test_cli_learn.py
102 passed in 2.34s
uv run pytest -q tests/test_learn tests/test_cli_learn.py
257 passed, 7 skipped in 3.11s
uv run mypy headroom
Success: no issues found in 520 source files
uv run ruff check <changed files>
All checks passed!
uv run ruff format --check <changed files>
5 files already formatted
uv run pytest tests scripts/tests --splits 4 --group N --tb=short -q
shard 1: 2766 passed, 140 skipped in 174.08s
shard 2: 2699 passed, 207 skipped in 60.00s
shard 3: 2822 passed, 84 skipped in 76.10s
shard 4: 2734 passed, 172 skipped in 80.29s
```
## Real Behavior Proof
- Environment: macOS arm64, Python 3.13, Codex CLI 0.147.0-compatible
command surface, current `main` including #2996.
- Exact command / steps: verified `codex exec --help`; exercised
`_call_cli_llm` with a captured subprocess command; invoked the Click
command with a simulated Codex nonzero backend result.
- Observed result: the subprocess command is `codex exec
--skip-git-repo-check`; backend failure text is printed as `Analysis
failed`, the misleading no-pattern message is absent, and the CLI exits
1.
- Not tested: live paid Codex analysis against production account
credentials; subprocess and CLI behavior are covered deterministically.
## Runtime Rollout Safety
- Rollout-managed feature(s): none; this is CLI-only failure handling.
- Minimum rollout channel: normal patch release after exact-head CI is
entirely green.
- Stable/default behavior changed: failed LLM analysis now exits nonzero
instead of reporting success; successful and genuinely empty analyses
are unchanged.
- Kill switch / disable path: select another backend with
`HEADROOM_LEARN_CLI` or `--model` if Codex CLI is unavailable.
- Unsafe override required: none.
- Qualification impact: all four Python CI shards, static checks,
security checks, and command-level regression tests must pass.
- Rollback path: fix forward through a human-reviewed corrective PR; no
persisted data or migration is involved.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project’s style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation — inline
result-contract documentation; no separate user guide change is required
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
Not applicable; command-line backend and exit semantics only.
## Additional Notes
Human review only. No merge or auto-merge is configured. This corrects
the root failure and exit semantics without extending any timeout.
2026-08-25 21:40:12 -05:00
|
|
|
def test_learn_surfaces_analysis_failure_and_exits_nonzero(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project = SimpleNamespace(name="broken", project_path=tmp_path / "broken")
|
|
|
|
|
plugin = FakePlugin("codex", "Codex", [project])
|
|
|
|
|
|
|
|
|
|
class FailingAnalyzer(FakeAnalyzer):
|
|
|
|
|
def analyze(self, project, sessions): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((project, sessions))
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
total_sessions=1,
|
|
|
|
|
total_calls=3,
|
|
|
|
|
total_failures=1,
|
|
|
|
|
failure_rate=1 / 3,
|
|
|
|
|
recommendations=[],
|
|
|
|
|
analysis_error="codex CLI failed (exit 1): Not inside a trusted directory",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "codex-cli")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FailingAnalyzer)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 1
|
|
|
|
|
assert "Analysis failed: codex CLI failed (exit 1)" in result.output
|
|
|
|
|
assert "No actionable patterns found." not in result.output
|
|
|
|
|
|
|
|
|
|
|
fix(learn): scan subagent and workflow transcripts (#1045)
## Description
`headroom learn` only scanned top-level main Claude Code sessions
(`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts
under `<project>/<uuid>/subagents/**` were not opened, which hid a large
amount of tool-call failure and token-spend activity from failure mining
and downstream analysis.
This change makes the Claude scanner descend into nested transcripts by
default and tag each `SessionData` with its source. `--main-only`
restores the previous top-level-only scan scope.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Updated `ClaudeCodePlugin.scan_project` to discover nested subagent
and workflow transcripts by default.
- Added source tagging for `main`, `subagent`, and `workflow` sessions.
- Added `--main-only` and `include_subagents` plumbing so callers can
opt back into top-level-only scanning.
- Added the `include_subagents` scanner parameter to Codex/Gemini as a
documented no-op because those scanners use flat session layouts.
- Added regression tests for nested discovery, source tagging, parallel
scanning, and CLI flag threading.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
Full learn + CLI suite:
# 186 passed, 2 skipped
GitHub Actions CI for this PR:
# build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed
```
## Real Behavior Proof
- Environment: local Claude Code corpus with nested subagent/workflow
transcripts.
- Exact command / steps: Scanned the corpus with the previous
top-level-only behavior and then with nested transcript discovery
enabled.
- Observed result: The scanner saw 24 sessions before and 306 sessions
after descending into nested transcripts.
- Not tested: Codex/Gemini nested transcript discovery, because those
providers currently use flat session layouts and treat
`include_subagents` as a no-op.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
|
|
|
def test_learn_main_only_flag_threads_to_scanner(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project_path = tmp_path / "proj"
|
|
|
|
|
project_path.mkdir()
|
|
|
|
|
proj = SimpleNamespace(name="proj", project_path=project_path)
|
|
|
|
|
plugin = FakePlugin("codex", "Codex", [proj])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
|
|
|
|
|
|
|
|
|
# Default: descend into subagent/workflow transcripts.
|
|
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"], catch_exceptions=False)
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert plugin.last_include_subagents is True
|
|
|
|
|
|
|
|
|
|
# --main-only restricts to top-level main sessions.
|
|
|
|
|
plugin.last_include_subagents = None
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main, ["learn", "--agent", "codex", "--all", "--main-only"], catch_exceptions=False
|
|
|
|
|
)
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert plugin.last_include_subagents is False
|
feat(learn): write per-project learnings to CLAUDE.local.md by default (#1115)
## Description
`headroom learn` wrote per-project learnings into the project's
`CLAUDE.md`, which Claude Code treats as team-shared and git-tracked.
That meant machine-specific absolute paths and tool-discovery byproducts
polluted the shared file for every teammate. This switches the default
to the personal, gitignored `CLAUDE.local.md`, adds a `--target`
override, and migrates any stale block out of `CLAUDE.md`.
Closes #1072.
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `ClaudeCodeWriter` now writes CONTEXT_FILE recommendations to
`CLAUDE.local.md` by default instead of `CLAUDE.md` (the home-directory
case still uses `~/.claude/CLAUDE.md`, which is personal global memory).
- Added a `--target` flag (Claude Code only) and `set_context_target()`
to override the destination — e.g. `--target CLAUDE.md` to opt back into
the shared file, or any relative/absolute path.
- On first run after upgrade, a stale Headroom block left in `CLAUDE.md`
is moved into `CLAUDE.local.md` and stripped from `CLAUDE.md`, with a
warning surfaced by the CLI. If `CLAUDE.md` held nothing but the block,
the empty file is removed.
- `WriteResult` carries `warnings`; the `learn` CLI prints them.
- Updated docs (`failure-learning.mdx`) and `CHANGELOG.md`.
This implements the maintainer's stated preference order from the issue
(default → `CLAUDE.local.md`, plus a `--target` flag), scoped to the
Claude writer only — `AGENTS.md`/`GEMINI.md` have no `.local` convention
and are untouched.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_learn/ tests/test_cli_learn.py -q
196 passed, 2 skipped in 17.80s
$ ruff check headroom/learn/writer.py headroom/cli/learn.py
All checks passed!
$ mypy headroom/learn/writer.py headroom/cli/learn.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: macOS (Darwin), Python 3.11, headroom on rebased
upstream/main
- Exact command / steps: ran ClaudeCodeWriter against a temp project
whose `CLAUDE.md` held hand-written content plus a legacy Headroom
block, then `writer.write([...], dry_run=False)`
- Observed result: `CLAUDE.md` kept its hand-written content with the
block removed; `CLAUDE.local.md` gained both the migrated `### Old`
section and the new `### Env` section; `result.warnings` contained the
"Moved Headroom learnings out of …" notice. A block-only `CLAUDE.md` was
deleted and a "Removed …" warning emitted.
- Not tested: live end-to-end `headroom learn --apply` against real LLM
analysis (writer + CLI plumbing covered by unit/CLI tests with mocked
analysis)
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated CHANGELOG.md if applicable
## Additional Notes
Scoped to the Claude Code writer per the issue. After migration,
`discover_projects` may briefly re-surface a section the LLM re-derives,
but the write-side merge dedups by section name so the file stays
correct.
2026-06-22 22:05:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TargetAwareWriter(FakeWriter):
|
|
|
|
|
"""A writer that supports --target and surfaces a migration warning."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.context_target: str | None = None
|
|
|
|
|
|
|
|
|
|
def set_context_target(self, target: str | None) -> None:
|
|
|
|
|
self.context_target = target
|
|
|
|
|
|
|
|
|
|
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((recommendations, project, dry_run))
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
dry_run=dry_run,
|
|
|
|
|
content_by_file={
|
|
|
|
|
Path(project.project_path) / "CLAUDE.local.md": "<!-- headroom -->\nRule 1"
|
|
|
|
|
},
|
|
|
|
|
warnings=["Moved Headroom learnings out of CLAUDE.md into CLAUDE.local.md."],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_learn_target_threads_to_writer_and_prints_warnings(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project_path = tmp_path / "proj"
|
|
|
|
|
project_path.mkdir()
|
|
|
|
|
proj = SimpleNamespace(name="proj", project_path=project_path)
|
|
|
|
|
plugin = FakePlugin("claude", "Claude Code", [proj])
|
|
|
|
|
plugin.writer = TargetAwareWriter()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
[
|
|
|
|
|
"learn",
|
|
|
|
|
"--agent",
|
|
|
|
|
"claude",
|
|
|
|
|
"--project",
|
|
|
|
|
str(project_path),
|
|
|
|
|
"--apply",
|
|
|
|
|
"--target",
|
|
|
|
|
"CLAUDE.md",
|
|
|
|
|
],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
# --target is threaded into the writer...
|
|
|
|
|
assert plugin.writer.context_target == "CLAUDE.md"
|
|
|
|
|
# ...and the writer's warnings are surfaced to the user.
|
|
|
|
|
assert "Moved Headroom learnings" in result.output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_learn_target_ignored_for_unsupported_agent(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
project_path = tmp_path / "proj"
|
|
|
|
|
project_path.mkdir()
|
|
|
|
|
proj = SimpleNamespace(name="proj", project_path=project_path)
|
|
|
|
|
# FakePlugin's FakeWriter has no set_context_target, so --target is unsupported.
|
|
|
|
|
plugin = FakePlugin("codex", "Codex", [proj])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
|
|
|
|
|
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
|
|
|
|
|
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "codex", "--project", str(project_path), "--target", "CLAUDE.md"],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "Note: --target is not supported for codex" in result.output
|
feat: add deterministic runtime rollout controls (#1490)
## Description
Establish one centrally resolved, observable, deterministic, versioned
runtime rollout-control mechanism for Headroom. Runtime rollout controls
which behaviors an already-built artifact may expose; it does not select
or qualify a Headroom release/version.
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Bug fix (non-breaking change that fixes rollout enforcement
regressions)
- [x] Documentation update
- [x] Code refactoring (no functional changes)
## Changes Made
- Added `RolloutChannel`, `HEADROOM_ROLLOUT_CHANNEL`,
`--rollout-channel`, and a versioned immutable `RolloutSnapshot` shared
by Python configuration boundaries.
- Added schema/policy versions, canonical registry and snapshot SHA-256
identities, per-feature decision reasons, disable precedence, unsafe
qualification poisoning, strict CLI validation, and fail-closed
environment handling.
- Added `headroom rollout status --json`, Python `/stats.rollout`, and
Rust `/rollout/status` runtime provenance.
- Added equivalent Rust snapshot semantics and shared Python/Rust policy
vectors while retaining language-specific feature registries.
- Enforced rollout policy at alternate Python server composition roots
so `HEADROOM_READ_MATURATION=1` cannot bypass its beta gate.
- Preserved typed rollout snapshots across multi-worker serialization
with schema, policy, registry, snapshot-digest, type, and feature-name
validation.
- Made loopback runtime output-shaper updates replace the immutable
snapshot atomically for request readers, retain explicit request/disable
provenance, preserve channel and kill-switch precedence, invalidate
cached stats, and return the effective rollout decision.
- Made `headroom learn --verbosity --apply` report a channel-blocked
update instead of claiming the shaper is live.
- Made explicit CLI feature flags fail loudly when their current channel
blocks them.
- Made persistent interceptor installation select canary automatically,
or reject an explicitly insufficient channel unless the break-glass
override is set.
- Updated architecture, proxy, rollout, learn, and output-shaper
documentation with required channels and hot-reload semantics.
## Testing
- [x] Unit tests pass
- [x] Linting passes (`ruff check .` and `ruff format --check .`)
- [x] Type checking passes (`mypy headroom --ignore-missing-imports`)
- [x] New regression tests added for every corrected behavior
- [x] Rust tests and production-target Clippy pass
- [x] Documentation build passes
### Test Output
```text
Focused rollout coverage suite
57 passed; headroom.rollout + rollout CLI: 98% coverage
Affected proxy/rollout/transform/governance suites
222 passed; 0 failed
Final changed regression suites
100 passed; 0 failed
Cross-module hot-reload isolation regression
6 passed; 0 failed
cargo test -p headroom-core -p headroom-proxy --quiet
headroom-core: 924 passed; 1 ignored
headroom-proxy and integration suites: all passed
cargo clippy -p headroom-core -p headroom-proxy --lib --bins -- -D warnings
cargo fmt --all -- --check
ruff check .
ruff format --check .
mypy headroom --ignore-missing-imports
git diff --check
All passed
cd docs && npm run build
Compiled successfully; 164 static pages generated
```
The unsharded Windows-only CI selection exposed unrelated baseline
failures, principally the existing `sqlite:///C:\\...` URL parser
producing an invalid `\\C:\\...` path. At commit `8e793a80`, all 52
completed GitHub checks passed; the only other conclusions are expected
skips and superseded governance jobs.
## Real Behavior Proof
- **Environment:** Windows checkout on Python 3.13.3 and the current
Rust workspace, based on upstream `main` at `93f2d7a2`.
- **Exact command / steps:** Exercised canary and beta feature requests
through CLI status, Python `/stats.rollout`, Rust `/rollout/status`,
multi-worker payload round trips, loopback `/admin/runtime-env`, real
proxy request shaping before/after hot reload, installer manifest
generation, and shared Python/Rust policy vectors.
- **Observed result:** Stable blocks unstable requests; disable wins
over explicit/default/legacy/unsafe paths; unsafe state reports
`qualification_eligible=false`; worker handoff rejects tampering;
running output shaping changes only when the effective beta policy
permits it; explicit blocked flags fail with actionable diagnostics.
- **Not tested:** Live production traffic requiring provider
credentials, or future artifact qualification/promotion automation
(intentionally out of scope).
## Runtime Rollout Safety
- **Rollout-managed features:** Python `tool_result_interceptors`,
`proxy_output_shaper`, `read_maturation`; Rust `native_bedrock`,
`openai_responses_streaming`, `canary_probe`.
- **Minimum rollout channel:** Registry-defined per feature; process
default is `stable`.
- **Stable/default behavior changed:** No unstable feature becomes
enabled by default. Explicit blocked CLI flags now fail instead of
silently doing nothing.
- **Kill switch / disable path:**
`HEADROOM_DISABLE_FEATURES=<comma-separated feature names>`; explicit
disable has highest precedence, including over the unsafe override.
- **Unsafe override required:** No.
`HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1` is break-glass only and
makes qualification evidence ineligible.
- **Qualification impact:** Adds machine-readable policy/snapshot
identities and eligibility; does not implement qualification itself.
- **Rollback path:** Set the named disable list for operational
rollback, lower the channel, or revert this PR.
## Review Readiness
- [x] I have performed a full diff review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented hard-to-understand areas
- [x] I have made corresponding documentation changes
- [x] My changes generate no new warnings
- [x] I added tests that reproduce and prevent every regression fixed
during review
- [x] New and existing affected tests pass locally
- [x] I did **not** edit `CHANGELOG.md`; release-please generates it
from the Conventional Commit PR title
## Additional Notes
Out of scope: artifact candidates, benchmark orchestration,
qualification manifests/gates, promotion automation, release branches,
publication guards, and release-risk classification. Those workflows can
consume the rollout registry digest, runtime snapshot digest, decision
reasons, and qualification eligibility through supported black-box
interfaces.
---------
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
Co-authored-by: JD Davis <jd@JDH-AIR-00.local>
2026-08-12 23:16:54 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(("enabled", "expected"), [(True, "live"), (False, "blocked")])
|
|
|
|
|
def test_activate_output_shaper_reports_effective_rollout_decision(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, enabled: bool, expected: str
|
|
|
|
|
) -> None:
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
from headroom.cli.learn import _activate_output_shaper
|
|
|
|
|
|
|
|
|
|
class Response:
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *args):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def read(self) -> bytes:
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"rollout": {
|
|
|
|
|
"features": [
|
|
|
|
|
{"name": "proxy_output_shaper", "enabled": enabled},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
).encode()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", lambda *args, **kwargs: Response())
|
|
|
|
|
|
|
|
|
|
status, port = _activate_output_shaper(9876)
|
|
|
|
|
|
|
|
|
|
assert status == expected
|
|
|
|
|
assert port == 9876
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_activate_output_shaper_handles_malformed_response(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
from headroom.cli.learn import _activate_output_shaper
|
|
|
|
|
|
|
|
|
|
class Response:
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *args):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def read(self) -> bytes:
|
|
|
|
|
return b"not-json"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", lambda *args, **kwargs: Response())
|
|
|
|
|
|
|
|
|
|
assert _activate_output_shaper(9876) == ("error", 9876)
|