diff --git a/headroom/cli/learn.py b/headroom/cli/learn.py index b17a339ec..6ea29f2ad 100644 --- a/headroom/cli/learn.py +++ b/headroom/cli/learn.py @@ -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. diff --git a/headroom/learn/analyzer.py b/headroom/learn/analyzer.py index 670b5c2b6..5fdb44a0e 100644 --- a/headroom/learn/analyzer.py +++ b/headroom/learn/analyzer.py @@ -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 diff --git a/headroom/learn/models.py b/headroom/learn/models.py index 5af68211e..6852f38f8 100644 --- a/headroom/learn/models.py +++ b/headroom/learn/models.py @@ -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: diff --git a/tests/test_cli_learn.py b/tests/test_cli_learn.py index 0142e8fc9..cdb92e123 100644 --- a/tests/test_cli_learn.py +++ b/tests/test_cli_learn.py @@ -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: diff --git a/tests/test_learn/test_analyzer.py b/tests/test_learn/test_analyzer.py index e271d2226..69308f4e3 100644 --- a/tests/test_learn/test_analyzer.py +++ b/tests/test_learn/test_analyzer.py @@ -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):