diff --git a/headroom/memory/traffic_learner.py b/headroom/memory/traffic_learner.py index 8bb930111..bcecefef0 100644 --- a/headroom/memory/traffic_learner.py +++ b/headroom/memory/traffic_learner.py @@ -629,10 +629,15 @@ class TrafficLearner: if not patterns: return - # Bucket patterns by project. + # Bucket patterns by project. discover_projects() walks the filesystem + # to decode escaped project directory names, which on a large home tree + # takes minutes; running it inline blocked the event loop, so uvicorn + # could not answer /readyz and supervisors killed a proxy that was + # merely busy. It is called once per learner (cached below), so the + # thread hop costs nothing on the steady-state path. if self._project_roots_cache is None: try: - self._project_roots_cache = plugin.discover_projects() + self._project_roots_cache = await asyncio.to_thread(plugin.discover_projects) except Exception as e: logger.warning("discover_projects failed: %s", e) self._project_roots_cache = [] diff --git a/tests/test_memory/test_traffic_learner.py b/tests/test_memory/test_traffic_learner.py index 1238399c0..eee21450b 100644 --- a/tests/test_memory/test_traffic_learner.py +++ b/tests/test_memory/test_traffic_learner.py @@ -6,6 +6,8 @@ a real memory backend. from __future__ import annotations +import asyncio +import threading from datetime import datetime, timedelta, timezone import pytest @@ -1214,6 +1216,46 @@ class TestFlushToFile: await learner.flush_to_file() assert writer.calls == [] # no roots → short-circuits before writer + @pytest.mark.asyncio + async def test_discover_projects_does_not_block_the_event_loop(self, tmp_path, monkeypatch): + """A slow discover_projects must not stall other loop work. + + discover_projects walks the filesystem; on a large home tree it takes + minutes. Called inline it froze the loop, so uvicorn stopped answering + /readyz and supervisors killed a proxy that was only busy. + """ + writer = _FakeWriter() + project_path = tmp_path.resolve() + plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer) + + release = threading.Event() + + def slow_discover(): + release.wait(timeout=5) + return [_make_project(str(project_path))] + + plugin.discover_projects = slow_discover # type: ignore[method-assign] + _install_plugin_registry(monkeypatch, plugin) + + learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1) + learner._pattern_counts["h"] = ( + ExtractedPattern( + category=PatternCategory.ENVIRONMENT, + content=f"Working test command: cd {project_path} && pytest", + importance=0.5, + evidence_count=2, + ), + 2, + ) + + flush = asyncio.create_task(learner.flush_to_file()) + # The loop stays responsive while discover_projects is stuck. + await asyncio.wait_for(asyncio.sleep(0), timeout=1) + assert not flush.done() + release.set() + await asyncio.wait_for(flush, timeout=5) + assert writer.calls, "flush should still complete once discovery returns" + @pytest.mark.asyncio async def test_unanchored_patterns_dropped(self, tmp_path, monkeypatch): """Patterns with no path anchoring are dropped before writer is called."""