headroom/tests/test_learn
gglucass 9bff5752bb
fix(learn): claude-cli streams output with idle timeout (#373)
## Description

`headroom learn` with the claude-cli backend used `subprocess.run` with
a hard 120s wall-clock cap and no liveness signal. A successful long
analysis and a hung connection looked identical — exit 0 with "0
recommendations" was the only user-visible signal when the LLM call
timed out, which silently hides genuine learnings.

This PR makes the CLI backend timeout-aware, with progress detection for
claude-cli and configurable wall-clock caps for every backend.

Fixes #(issue number)

## 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

- **Streaming claude-cli with idle timeout**: invoke `claude -p
--output-format stream-json --verbose` and run a watchdog loop that
drains stdout/stderr via reader threads. Each stream-json event resets
an idle deadline. Kill the process if no output for
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` (default 60s) or if total elapsed
exceeds `HEADROOM_LEARN_CLI_TIMEOUT_SECS` (default 300s, was 120s). The
final `type:"result"` event carries the assistant response, which is
then parsed as JSON. Reader threads (rather than `select`) are used so
the watchdog works on Windows where `select` does not support pipe
handles.
- **Bumped default `_CLI_TIMEOUT` from 120s to 300s** as the hard cap
for all CLI backends. The previous 120s was too tight for large digests
on slower networks.
- **Env-var overrides** via new helper `_resolve_timeout_secs(env_var,
default)`:
- `HEADROOM_LEARN_CLI_TIMEOUT_SECS` — hard wall-clock cap (all CLI
backends)
- `HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` — idle cap (streaming
claude-cli only)
- Non-positive or non-integer values log a warning and fall back to
defaults, so a typo can't disable the timeout.
- **gemini-cli and codex-cli** keep `subprocess.run(timeout=hard_cap)`
since they do not emit progress events. They benefit from the bumped
default and the env-var override.
- **CHANGELOG.md** updated under `[Unreleased]` → `### Fixed`.

## 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 (existing repro: 16k-call digest that
previously timed out at 120s)

New test coverage in `tests/test_learn/test_analyzer.py`:

- `test_claude_cli_streams_and_parses_result_event` — happy path, fake
Popen yields system/assistant/result events
- `test_claude_cli_parses_fenced_result` — markdown fences in the result
event still parse
- `test_claude_cli_idle_timeout_kills_hang` —
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS=1` + a hanging stdout iterator
triggers the idle watchdog
- `test_claude_cli_hard_cap_kills_continuous_chatter` — continuous
events with a low hard cap fire the wall-clock kill (proves idle reset
alone can't keep a runaway alive)
- `test_claude_cli_missing_result_event_raises` — graceful failure when
no `result` event is emitted
- `test_claude_cli_nonzero_exit_raises` /
`test_claude_cli_unparseable_result_raises_with_context` /
`test_claude_cli_not_installed_raises` — error paths
- Parallel codex-cli error coverage (timeout-honors-env-override
included) so the wall-clock path is exercised
- `TestResolveTimeoutSecs` — unset / empty / non-integer / non-positive
/ valid override

## Test Output

```
$ uv run pytest tests/test_learn/test_analyzer.py
============================== 67 passed in 2.14s ==============================

$ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!

$ uv run ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ uv run mypy headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

## 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

## Additional Notes

- The contract assumed for claude-cli stream-json output is: each line
is a JSON object with a `type` field; the final event has
`type:"result"` with a string `result` field carrying the assistant
text. This matches the documented Anthropic CLI behavior. If the
contract changes upstream, `_call_claude_cli_streaming` raises a clear
"did not emit a final \`result\` event" error rather than silently
succeeding.
- Backwards-compatible for users without env-var configuration: behavior
just becomes "longer hard cap, plus idle watchdog for claude-cli",
neither of which can falsely succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 11:55:19 -05:00
..
__init__.py Add headroom learn: offline failure learning for coding agents 2026-02-27 21:19:03 -08:00
test_analyzer.py fix(learn): claude-cli streams output with idle timeout (#373) 2026-06-11 11:55:19 -05:00
test_gemini_scanner.py Plugin architecture for headroom learn + live traffic flush 2026-04-09 20:30:20 -07:00
test_integration.py fix(testing): stabilize 3.12 suite and fingerprints 2026-04-28 21:35:32 +00:00
test_registry.py Plugin architecture for headroom learn + live traffic flush 2026-04-09 20:30:20 -07:00
test_scanner.py test(learn): ruff-format scanner test and broaden dotted-home coverage 2026-05-30 13:49:23 +02:00
test_writer.py fix: harden learn path handling across platforms 2026-05-09 15:45:26 -07:00