headroom/tests/test_memory
gglucass a70e5ff78d
fix(learn): run project discovery off the event loop (#2731)
## Description

`TrafficLearner.flush_to_file` is a coroutine, but it called
`plugin.discover_projects()` inline. That function walks the filesystem
to decode escaped project directory names — in
`learn/plugins/claude.py`, `_greedy_path_decode` recurses through
`iterdir()` at every level and tries each tokenization of each child,
backtracking on a miss — so on a large home tree it runs for minutes.

Doing that on the event loop freezes uvicorn for the whole window. The
port keeps accepting TCP, but `/readyz` never answers, so a supervisor
health-checking the proxy kills a process that is merely busy.

Field thread dumps show exactly that:

```
Current thread (most recent call first):
  File "python3.12/pathlib.py", line 1056 in iterdir
  File "headroom/learn/plugins/claude.py", line 454 in _greedy_path_decode
  File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode
  File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode
  File "headroom/learn/plugins/claude.py", line 426 in _decode_project_path
  File "headroom/learn/plugins/claude.py", line 71 in discover_projects
  File "headroom/memory/traffic_learner.py", line 591 in flush_to_file
  File "headroom/memory/traffic_learner.py", line 535 in _flush_worker
  File "python3.12/asyncio/events.py", line 88 in _run
  File "python3.12/asyncio/base_events.py", line 1999 in _run_once
  File "python3.12/asyncio/base_events.py", line 645 in run_forever
  File "uvicorn/server.py", line 75 in run
  File "headroom/proxy/server.py", line 4992 in run_server
```

Accompanying signals from the same incidents: port accepts TCP,
`/readyz` times out, process CPU 2-13s across the window (I/O bound, not
spinning), proxy log silent 66-336s.

## Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which 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

- `headroom/memory/traffic_learner.py`: `flush_to_file` now awaits
`asyncio.to_thread(plugin.discover_projects)` instead of calling it
inline. `asyncio` was already imported. The result is cached per learner
(`_project_roots_cache`), so the steady-state flush path pays nothing
for the thread hop.
- `tests/test_memory/test_traffic_learner.py`: added
`test_discover_projects_does_not_block_the_event_loop`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -q
..................................................                       [100%]
============================= 152 passed in 2.93s ==============================

$ uvx ruff check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py
All checks passed!

$ uvx ruff format --check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py
2 files already formatted

$ uv run --frozen --extra dev mypy headroom/memory/traffic_learner.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 15.6 arm64, Python 3.10.18, pytest 9.0.3, branch
off `main` @ `01df2452`.
- Exact command / steps: reverted only the one-line source change in the
working tree (`await asyncio.to_thread(plugin.discover_projects)` back
to `plugin.discover_projects()`), left the new test in place, ran `uv
run --frozen --extra dev pytest
tests/test_memory/test_traffic_learner.py -k does_not_block -q`, then
restored the line and re-ran the full file.
- Observed result: without the change the test fails — `flush_to_file`
runs to completion synchronously the moment the task is created, so the
loop never regains control while `discover_projects` is parked on a
`threading.Event`. With the change the loop stays responsive and the
flush completes once discovery returns. Full file: 152 passed.
- Not tested: no live proxy run against a multi-minute real home tree;
the blocking behaviour is reproduced deterministically in the test
instead. The thread dump above is captured field evidence, not a run in
this environment.

Failing output with the fix reverted:

```text
$ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -k does_not_block -q
tests/test_memory/test_traffic_learner.py:1254: in test_discover_projects_does_not_block_the_event_loop
    assert not flush.done()
E   AssertionError: assert not True
E    +  where True = <built-in method done of _asyncio.Task object at 0x10882dff0>()
E    +    where <built-in method done of _asyncio.Task object at 0x10882dff0> = <Task finished name='Task-1' coro=<TrafficLearner.flush_to_file() done ...>>.done
========================= 1 failed, 151 deselected in 5.51s =========================
```

## 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
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

- Documentation: N/A — no user-facing behaviour or interface change.
- Bounding `_greedy_path_decode`'s backtracking is the real cost fix and
belongs in its own change. This one only stops a slow walk from taking
the server's liveness with it.
2026-08-03 06:00:52 -07:00
..
__init__.py Add persistent memory system with zero-latency inline extraction 2026-01-14 21:32:09 -08:00
conftest.py fix(ccr): skip Anthropic marker emission when tool injection is deferred (#1273) 2026-06-23 12:48:05 -05:00
test_budget.py Fix ruff lint errors in test files 2026-03-24 15:54:12 -07:00
test_core_operations.py fix(memory): remove a superseded memory from the search indexes (#2143) 2026-07-14 04:24:00 -04:00
test_easy.py test(memory): skip decorators on offline model misses (#2020) 2026-07-11 10:14:05 -05:00
test_embedder_mps_serialization.py fix(memory): cap local embedder CPU thread oversubscription (#198) (#1559) 2026-07-01 17:12:02 -05:00
test_embedder_thread_cap.py fix(memory): cap local embedder CPU thread oversubscription (#198) (#1559) 2026-07-01 17:12:02 -05:00
test_extraction.py Add hierarchical memory system with graph + vector storage 2026-01-26 21:58:47 -08:00
test_factory.py Add centralized ML model configuration 2026-02-01 23:47:42 -08:00
test_factory_embedder_cache.py fix(memory): key the embedder cache on ollama_base_url (#2109) 2026-07-13 10:54:16 -04:00
test_factory_external.py chore(memory): add EXTERNAL backend extension points 2026-04-20 16:42:10 -07:00
test_hierarchical.py fix(memory/sqlite): don't emit OFFSET without LIMIT in query (#2063) 2026-07-13 09:46:45 -04:00
test_hnsw_batch_capacity.py fix(memory): size HNSW index_batch resize off the id high-water mark (#2139) 2026-07-13 23:43:12 -04:00
test_learn_flag.py fix(traffic-learner): raise min-evidence default and make it configurable 2026-04-30 17:44:22 +09:00
test_local_backend_search.py fix(memory): filter inactive graph-expanded results (#2210) 2026-07-15 19:58:13 +00:00
test_mcp_server.py fix(memory): serialize MCP backend initialization (#2309) 2026-07-16 14:38:53 -07:00
test_qdrant_env.py feat(memory): resolve Qdrant connection from HEADROOM_QDRANT_* env vars (#31) 2026-04-24 22:16:16 -07:00
test_query_conditions.py fix(memory): apply turn_id scope filter even without agent_id (#2130) 2026-07-13 23:41:40 -04:00
test_skip_helpers.py fix(ccr): skip Anthropic marker emission when tool injection is deferred (#1273) 2026-06-23 12:48:05 -05:00
test_supersession_repair.py feat(memory): add explicit supersession repair (#2217) 2026-07-15 18:17:17 +00:00
test_traffic_learner.py fix(learn): run project discovery off the event loop (#2731) 2026-08-03 06:00:52 -07:00
test_writers.py fix: harden learn path handling across platforms 2026-05-09 15:45:26 -07:00