diff --git a/app.py b/app.py index bee4dae8f..a5f3f6ec2 100644 --- a/app.py +++ b/app.py @@ -630,13 +630,24 @@ app.include_router(auth_router) @app.post("/api/activity/heartbeat") async def activity_heartbeat(): - from src.interactive_gate import mark_browser_activity + from src.interactive_gate import ( + mark_browser_activity, + maybe_stop_background_tasks_for_heartbeat, + ) + await mark_browser_activity() + async def _stop_background(): try: - await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") + await maybe_stop_background_tasks_for_heartbeat( + task_scheduler.stop_background_tasks_for_foreground + ) except Exception: - logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True) + logging.getLogger("app.foreground_gate").debug( + "heartbeat task stop failed", + exc_info=True, + ) + asyncio.create_task(_stop_background()) return {"ok": True} diff --git a/src/interactive_gate.py b/src/interactive_gate.py index 38c684fa2..efa46f453 100644 --- a/src/interactive_gate.py +++ b/src/interactive_gate.py @@ -63,6 +63,7 @@ _PASSIVE_EXACT_PATHS = { "/api/activity/heartbeat", "/api/client-perf", "/api/tasks/notifications", + "/api/tasks/runs/recent", "/api/research/active", "/api/email/urgency-state", # UI idle poll sibling of urgency-state; must not pre-empt background tasks. @@ -76,6 +77,19 @@ _PASSIVE_PREFIXES = ( ) +async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool: + """Stop background work for browser activity only when the gate is enabled. + + ``stop_background`` is injected by the application boundary so this policy + remains independently testable without importing the full FastAPI app. + """ + if not _enabled(): + return False + + await stop_background(reason="browser heartbeat") + return True + + def should_track_interactive_request(path: str, method: str = "GET") -> bool: if not _enabled(): return False diff --git a/tests/test_poll_endpoint_no_task_interrupt.py b/tests/test_poll_endpoint_no_task_interrupt.py new file mode 100644 index 000000000..da5c40412 --- /dev/null +++ b/tests/test_poll_endpoint_no_task_interrupt.py @@ -0,0 +1,66 @@ +"""Regression tests for polling endpoints and foreground task interruption.""" + +import asyncio +import importlib + + +def _reload_gate(): + import src.interactive_gate as ig + + importlib.reload(ig) + return ig + + +def test_tasks_runs_recent_is_passive(): + ig = _reload_gate() + + assert not ig.should_track_interactive_request( + "/api/tasks/runs/recent", "GET" + ) + + +def test_tasks_runs_recent_does_not_affect_other_task_paths(): + ig = _reload_gate() + + # A neighboring mutating path must remain interactive. + assert ig.should_track_interactive_request( + "/api/tasks/runs/recent/something", "POST" + ) + + +def test_heartbeat_does_not_stop_background_tasks_when_gate_disabled(monkeypatch): + ig = _reload_gate() + monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false") + + stop_calls = [] + + async def fake_stop_background_tasks_for_foreground(*, reason): + stop_calls.append(reason) + + result = asyncio.run( + ig.maybe_stop_background_tasks_for_heartbeat( + fake_stop_background_tasks_for_foreground + ) + ) + + assert result is False + assert stop_calls == [] + + +def test_heartbeat_stops_background_tasks_when_gate_enabled(monkeypatch): + ig = _reload_gate() + monkeypatch.delenv("BACKGROUND_TASK_FOREGROUND_GATE", raising=False) + + stop_calls = [] + + async def fake_stop_background_tasks_for_foreground(*, reason): + stop_calls.append(reason) + + result = asyncio.run( + ig.maybe_stop_background_tasks_for_heartbeat( + fake_stop_background_tasks_for_foreground + ) + ) + + assert result is True + assert stop_calls == ["browser heartbeat"]