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.
This commit is contained in:
JD Davis 2026-08-25 21:40:12 -05:00 committed by GitHub
parent 36cc800162
commit 632cb81dbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 46 additions and 3 deletions

View file

@ -225,6 +225,7 @@ def learn(
total_projects = 0
total_failures = 0
total_recommendations = 0
total_analysis_failures = 0
matched_projects = 0
available_projects: list[tuple[str, Path]] = []
@ -299,6 +300,12 @@ def learn(
f"Failures: {result_data.total_failures} ({result_data.failure_rate:.1%})"
)
analysis_error = getattr(result_data, "analysis_error", None)
if analysis_error:
total_analysis_failures += 1
click.echo(f" Analysis failed: {analysis_error}", err=True)
continue
if result_data.failure_rate == 0 and not result_data.recommendations:
click.echo(" No failures or patterns found.")
continue
@ -350,6 +357,9 @@ def learn(
f"{total_recommendations} recommendations"
)
if total_analysis_failures:
raise SystemExit(1)
def _make_llm_judge(model: str) -> Any:
"""Build an LLM judge callable for verbosity, or None if unavailable.

View file

@ -56,7 +56,7 @@ _MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + ou
_CLI_BACKENDS: list[tuple[str, str, list[str]]] = [
("claude", "claude-cli", ["claude", "-p", "--output-format", "stream-json", "--verbose"]),
("gemini", "gemini-cli", ["gemini", "-p"]),
("codex", "codex-cli", ["codex", "exec"]),
("codex", "codex-cli", ["codex", "exec", "--skip-git-repo-check"]),
]
# Set of valid CLI model identifiers, derived from _CLI_BACKENDS.
@ -202,7 +202,9 @@ class SessionAnalyzer:
result.recommendations.sort(key=lambda r: r.estimated_tokens_saved, reverse=True)
except Exception as e:
logger.warning("LLM analysis failed: %s", e)
# Return result with stats but no recommendations
# Preserve the stats so multi-project runs can continue, but retain
# the failure so the CLI cannot report an empty result as success.
result.analysis_error = str(e) or type(e).__name__
return result

View file

@ -174,6 +174,7 @@ class AnalysisResult:
total_calls: int = 0
total_failures: int = 0
recommendations: list[Recommendation] = field(default_factory=list)
analysis_error: str | None = None
@property
def failure_rate(self) -> float:

View file

@ -370,6 +370,35 @@ def test_learn_handles_empty_sessions_and_no_pattern_outputs(
assert "No actionable patterns found." in result.output
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
def test_learn_main_only_flag_threads_to_scanner(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:

View file

@ -473,6 +473,7 @@ class TestSessionAnalyzer:
assert result.total_calls == 1
assert result.total_failures == 1
assert result.recommendations == []
assert result.analysis_error == "API key not set"
@patch("headroom.learn.analyzer._call_llm")
def test_passes_events_to_digest(self, mock_call_llm: MagicMock):
@ -865,7 +866,7 @@ class TestCallCliLlm:
result = _call_cli_llm("test digest", "codex-cli")
assert result == {"context_file_rules": [], "memory_file_rules": []}
cmd = mock_run.call_args[0][0]
assert cmd == ["codex", "exec"]
assert cmd == ["codex", "exec", "--skip-git-repo-check"]
@patch("headroom.learn.analyzer.subprocess.run")
def test_gemini_cli_uses_p_flag(self, mock_run: MagicMock):