From b2789d04fba1a937d3a47f865a47a5e7750e943a Mon Sep 17 00:00:00 2001 From: Christian Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:47:47 -0700 Subject: [PATCH] fix: stop status polling from cancelling running scheduled tasks (#5789) * fix: stop polling GET /api/tasks/runs/recent from cancelling running tasks Two paths caused the scheduler to interrupt a running background task when the frontend Activity view polled for status: 1. GET /api/tasks/runs/recent was not in _PASSIVE_EXACT_PATHS, so _InteractiveActivityMiddleware treated it as a foreground request and called stop_background_tasks_for_foreground, cancelling any in-flight scheduled task. Add it to _PASSIVE_EXACT_PATHS alongside the other read-only polling endpoints. 2. The /api/activity/heartbeat handler called stop_background_tasks_for_foreground unconditionally, ignoring BACKGROUND_TASK_FOREGROUND_GATE=false. Wrap the call in a _gate_enabled() guard so the env var fully disables heartbeat- triggered cancellations. Fixes #5782 Signed-off-by: Christian Sidak Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> * fix(scheduler): respect foreground gate for heartbeat --------- Signed-off-by: Christian Sidak Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Co-authored-by: Alexandre Teixeira --- app.py | 17 ++++- src/interactive_gate.py | 14 ++++ tests/test_poll_endpoint_no_task_interrupt.py | 66 +++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/test_poll_endpoint_no_task_interrupt.py 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"]