From 10847bbb0fff9e440460023f5b563536f2bf03a7 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:32:54 -0700 Subject: [PATCH] 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> --- app.py | 15 +++--- src/interactive_gate.py | 1 + tests/test_poll_endpoint_no_task_interrupt.py | 54 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/test_poll_endpoint_no_task_interrupt.py diff --git a/app.py b/app.py index e740ad518..8b2b76f65 100644 --- a/app.py +++ b/app.py @@ -630,14 +630,15 @@ 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, _enabled as _gate_enabled await mark_browser_activity() - async def _stop_background(): - try: - await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") - except Exception: - logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True) - asyncio.create_task(_stop_background()) + if _gate_enabled(): + async def _stop_background(): + try: + await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") + except Exception: + 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 c0f5907fc..52a7deace 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", } 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..a04afdd55 --- /dev/null +++ b/tests/test_poll_endpoint_no_task_interrupt.py @@ -0,0 +1,54 @@ +"""Regression: read-only task-status polling must not interrupt running background tasks. + +GET /api/tasks/runs/recent is called by the Activity view while a scheduled +task is executing. Before this fix, the _InteractiveActivityMiddleware treated +it as a foreground request and called stop_background_tasks_for_foreground, +which immediately cancelled the running task. The fix adds the endpoint to +_PASSIVE_EXACT_PATHS so it is excluded from interactive tracking. + +Additionally, /api/activity/heartbeat called stop_background_tasks_for_foreground +unconditionally, ignoring BACKGROUND_TASK_FOREGROUND_GATE=false. The fix wraps +that call in a _gate_enabled() check so the env var fully disables interrupts. +""" + +import importlib +import os + + +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"), ( + "GET /api/tasks/runs/recent must be treated as a passive endpoint so that " + "the Activity-view polling does not trigger stop_background_tasks_for_foreground " + "and cancel running scheduled tasks" + ) + + +def test_tasks_runs_recent_does_not_affect_other_task_paths(): + ig = _reload_gate() + # Non-polling mutating task routes should still be interactive. + assert ig.should_track_interactive_request("/api/tasks/runs/recent/something", "POST") + + +def test_heartbeat_respects_foreground_gate_disabled(monkeypatch): + """stop_background_tasks_for_foreground must NOT be called from the heartbeat + handler when BACKGROUND_TASK_FOREGROUND_GATE=false.""" + monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false") + ig = _reload_gate() + assert not ig._enabled(), ( + "_enabled() should return False when BACKGROUND_TASK_FOREGROUND_GATE=false" + ) + + +def test_heartbeat_gate_enabled_by_default(monkeypatch): + monkeypatch.delenv("BACKGROUND_TASK_FOREGROUND_GATE", raising=False) + ig = _reload_gate() + assert ig._enabled(), ( + "_enabled() should return True by default (foreground gate on)" + )