Skip to content

Commit 810ce4b

Browse files
maxmilianclaude
andcommitted
fix(tasks): respect BACKGROUND_TASK_FOREGROUND_GATE in heartbeat-triggered background task stops
stop_background_tasks_for_foreground() cancelled every executing scheduler task unconditionally, so browser heartbeats aborted scheduled tasks even with BACKGROUND_TASK_FOREGROUND_GATE=false (the /api/activity/heartbeat call site is not gated, unlike the interactive middleware). Gate the method itself so all call sites honor the opt-out. Runs cancelled by this path also recorded the misleading 'Stopped by user' and missed the 15-minute foreground defer, because only the in-task monitor set foreground_cancel['hit']. Track externally stopped task ids in _foreground_stops so the CancelledError path applies the foreground pause message and defer semantics. Also pass BACKGROUND_TASK_* through docker-compose so .env settings reach the container. Fixes #5536 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c80462e commit 810ce4b

7 files changed

Lines changed: 255 additions & 3 deletions

docker-compose.gpu-amd.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ services:
4545
- CHROMADB_PORT=8000
4646
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
4747
- AUTH_ENABLED=${AUTH_ENABLED:-true}
48+
- BACKGROUND_TASK_FOREGROUND_GATE=${BACKGROUND_TASK_FOREGROUND_GATE:-true}
49+
- BACKGROUND_TASK_QUIET_MS=${BACKGROUND_TASK_QUIET_MS:-1500}
50+
- BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS=${BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS:-45}
51+
- BACKGROUND_TASK_MAX_WAIT_SECONDS=${BACKGROUND_TASK_MAX_WAIT_SECONDS:-0}
4852
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
4953
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
5054
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}

docker-compose.gpu-nvidia.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ services:
4444
- CHROMADB_PORT=8000
4545
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
4646
- AUTH_ENABLED=${AUTH_ENABLED:-true}
47+
- BACKGROUND_TASK_FOREGROUND_GATE=${BACKGROUND_TASK_FOREGROUND_GATE:-true}
48+
- BACKGROUND_TASK_QUIET_MS=${BACKGROUND_TASK_QUIET_MS:-1500}
49+
- BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS=${BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS:-45}
50+
- BACKGROUND_TASK_MAX_WAIT_SECONDS=${BACKGROUND_TASK_MAX_WAIT_SECONDS:-0}
4751
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
4852
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
4953
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ services:
3333
- CHROMADB_PORT=8000
3434
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
3535
- AUTH_ENABLED=${AUTH_ENABLED:-true}
36+
- BACKGROUND_TASK_FOREGROUND_GATE=${BACKGROUND_TASK_FOREGROUND_GATE:-true}
37+
- BACKGROUND_TASK_QUIET_MS=${BACKGROUND_TASK_QUIET_MS:-1500}
38+
- BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS=${BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS:-45}
39+
- BACKGROUND_TASK_MAX_WAIT_SECONDS=${BACKGROUND_TASK_MAX_WAIT_SECONDS:-0}
3640
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
3741
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
3842
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}

src/interactive_gate.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ def _enabled() -> bool:
2424
return os.getenv("BACKGROUND_TASK_FOREGROUND_GATE", "true").lower() not in {"0", "false", "no", "off"}
2525

2626

27+
def foreground_gate_enabled() -> bool:
28+
"""Whether foreground activity may pause/stop background tasks at all."""
29+
return _enabled()
30+
31+
2732
def _quiet_seconds() -> float:
2833
try:
2934
return max(0.0, float(os.getenv("BACKGROUND_TASK_QUIET_MS", "1500")) / 1000.0)

src/task_scheduler.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ def __init__(self, session_manager):
350350
self._run_semaphore = asyncio.Semaphore(1)
351351
self._concurrency_cap = 1
352352
self._task_handles = {}
353+
# Task IDs cancelled by stop_background_tasks_for_foreground; lets the
354+
# CancelledError path distinguish a foreground pause from a user stop.
355+
self._foreground_stops = set()
353356

354357
def _set_run_progress(self, run_id: str, message: str):
355358
"""Persist short live progress text for Activity while a run is active."""
@@ -772,6 +775,10 @@ async def _execute_task(self, task_id: str, *, bypass_model_slot: bool = False,
772775
self._defer_immediately_due_task(task_id, delay=timedelta(minutes=15))
773776
raise
774777
finally:
778+
# Task-level cleanup so the flag can't leak when the cancel lands
779+
# outside _execute_task_locked's own handler (e.g. while queued
780+
# behind the run semaphore) and misclassify a later user stop.
781+
self._foreground_stops.discard(task_id)
775782
handle = self._task_handles.get(task_id)
776783
if handle is current:
777784
self._task_handles.pop(task_id, None)
@@ -945,9 +952,15 @@ async def _cancel_if_foreground_active():
945952
db.commit()
946953
return
947954
except asyncio.CancelledError:
955+
# A cancel that came from stop_background_tasks_for_foreground
956+
# is a foreground pause too, even though the in-task monitor
957+
# never saw the activity itself.
958+
foreground_hit = (
959+
foreground_cancel.get("hit") or task_id in self._foreground_stops
960+
)
948961
msg = (
949962
"Paused because Odysseus became active"
950-
if foreground_cancel.get("hit")
963+
if foreground_hit
951964
else "Stopped by user"
952965
)
953966
logger.info("Task '%s' %s", task.name, msg)
@@ -958,7 +971,7 @@ async def _cancel_if_foreground_active():
958971
run_obj.result = run_obj.result or msg
959972
run_obj.finished_at = _utcnow()
960973
task.last_run = _utcnow()
961-
if foreground_cancel.get("hit"):
974+
if foreground_hit:
962975
task.next_run = _utcnow() + timedelta(minutes=15)
963976
elif (task.trigger_type or "schedule") == "schedule":
964977
task.next_run = compute_next_run(
@@ -2247,16 +2260,25 @@ async def stop_background_tasks_for_foreground(self, *, reason: str = "Odysseus
22472260
user opens or uses Odysseus, foreground interaction wins immediately.
22482261
Manual force-runs can be restarted by the user; automatic jobs will be
22492262
deferred by their cancellation path instead of stealing the app.
2263+
2264+
Respects BACKGROUND_TASK_FOREGROUND_GATE: with the gate disabled the
2265+
user has opted out of foreground preemption entirely, so this is a
2266+
no-op (#5536 — browser heartbeats were cancelling scheduled tasks
2267+
regardless of the gate).
22502268
"""
2269+
from src.interactive_gate import foreground_gate_enabled
2270+
if not foreground_gate_enabled():
2271+
return 0
22512272
async with self._executing_lock:
22522273
task_ids = list(self._executing)
22532274
stopped = 0
22542275
for task_id in task_ids:
22552276
handle = self._task_handles.get(task_id)
22562277
if handle and not handle.done():
2278+
self._foreground_stops.add(task_id)
22572279
handle.cancel()
22582280
stopped += 1
2259-
if self._mark_run_aborted(task_id):
2281+
if self._mark_run_aborted(task_id, message="Paused because Odysseus became active"):
22602282
stopped += 1
22612283
if stopped:
22622284
logger.info("Stopped %d background scheduler task(s): %s", stopped, reason)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Regression for #5536: browser heartbeats abort scheduled tasks even when
2+
BACKGROUND_TASK_FOREGROUND_GATE is disabled.
3+
4+
`/api/activity/heartbeat` unconditionally calls
5+
`stop_background_tasks_for_foreground()`, and that method never consults the
6+
gate — so setting BACKGROUND_TASK_FOREGROUND_GATE=false silences
7+
`mark_browser_activity()` / the interactive middleware but heartbeats still
8+
force-cancel every executing task. Additionally, runs cancelled this way never
9+
set the in-task `foreground_cancel["hit"]` flag, so Activity records the
10+
misleading "Stopped by user" instead of "Paused because Odysseus became
11+
active" and the task misses the 15-minute foreground defer.
12+
13+
These tests drive the real `TaskScheduler` (no HTTP layer): the executing
14+
task is a genuine asyncio task registered in `_task_handles`, exactly how the
15+
heartbeat-triggered stop sees it in production.
16+
"""
17+
import asyncio
18+
19+
import pytest
20+
21+
22+
def _make_scheduler():
23+
from src.task_scheduler import TaskScheduler
24+
return TaskScheduler(session_manager=None)
25+
26+
27+
async def _register_hanging_task(scheduler, task_id):
28+
"""Register a real, cancellable asyncio task as an executing scheduler job."""
29+
started = asyncio.Event()
30+
31+
async def _hang():
32+
started.set()
33+
await asyncio.sleep(3600)
34+
35+
handle = asyncio.create_task(_hang())
36+
async with scheduler._executing_lock:
37+
scheduler._executing.add(task_id)
38+
scheduler._task_handles[task_id] = handle
39+
await started.wait()
40+
return handle
41+
42+
43+
def test_stop_is_noop_when_gate_disabled(monkeypatch):
44+
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
45+
scheduler = _make_scheduler()
46+
aborted = []
47+
monkeypatch.setattr(
48+
scheduler, "_mark_run_aborted",
49+
lambda *a, **k: aborted.append((a, k)) or False,
50+
)
51+
52+
async def _scenario():
53+
handle = await _register_hanging_task(scheduler, "task-gate-off")
54+
stopped = await scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
55+
# Give a cancelled handle the chance to actually die before asserting.
56+
await asyncio.sleep(0)
57+
alive = not handle.done()
58+
handle.cancel()
59+
return stopped, alive
60+
61+
stopped, alive = asyncio.run(_scenario())
62+
assert stopped == 0, (
63+
"stop_background_tasks_for_foreground must be a no-op when "
64+
"BACKGROUND_TASK_FOREGROUND_GATE is disabled"
65+
)
66+
assert alive, "executing task must not be cancelled while the gate is disabled"
67+
assert not aborted, "no run may be marked aborted while the gate is disabled"
68+
69+
70+
def test_stop_cancels_when_gate_enabled(monkeypatch):
71+
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "true")
72+
scheduler = _make_scheduler()
73+
monkeypatch.setattr(scheduler, "_mark_run_aborted", lambda *a, **k: True)
74+
75+
async def _scenario():
76+
handle = await _register_hanging_task(scheduler, "task-gate-on")
77+
stopped = await scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
78+
await asyncio.sleep(0)
79+
return stopped, handle.cancelled() or handle.done()
80+
81+
stopped, cancelled = asyncio.run(_scenario())
82+
assert stopped > 0
83+
assert cancelled, "with the gate enabled the executing task must be cancelled"
84+
85+
86+
def test_heartbeat_stop_records_foreground_pause_not_user_stop(monkeypatch):
87+
"""Full path through _execute_task_locked: a heartbeat-triggered stop must
88+
record the foreground-pause message and the 15-minute defer, not the
89+
misleading 'Stopped by user' + regular reschedule."""
90+
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "true")
91+
from core.database import SessionLocal, ScheduledTask, TaskRun
92+
from src.task_scheduler import TaskScheduler, _utcnow
93+
94+
task_id = "task-5536-fg-pause"
95+
run_id = "run-5536-fg-pause"
96+
db = SessionLocal()
97+
db.add(ScheduledTask(
98+
id=task_id,
99+
owner="alice",
100+
name="hang forever",
101+
task_type="action",
102+
action="test_hang_5536",
103+
status="active",
104+
trigger_type="manual",
105+
))
106+
db.add(TaskRun(id=run_id, task_id=task_id, status="queued", started_at=_utcnow()))
107+
db.commit()
108+
db.close()
109+
110+
scheduler = TaskScheduler(session_manager=None)
111+
entered = asyncio.Event()
112+
113+
async def _hanging_action(task, run_id=None):
114+
entered.set()
115+
await asyncio.sleep(3600)
116+
return "unreachable", True
117+
118+
monkeypatch.setattr(scheduler, "_execute_action", _hanging_action)
119+
120+
async def _scenario():
121+
async with scheduler._executing_lock:
122+
scheduler._executing.add(task_id)
123+
handle = asyncio.create_task(
124+
scheduler._execute_task_locked(
125+
task_id, run_id, release_executing=False, gate_foreground=True,
126+
)
127+
)
128+
scheduler._task_handles[task_id] = handle
129+
await asyncio.wait_for(entered.wait(), timeout=10)
130+
await scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
131+
try:
132+
await asyncio.wait_for(handle, timeout=10)
133+
except asyncio.CancelledError:
134+
pass
135+
136+
asyncio.run(_scenario())
137+
138+
db = SessionLocal()
139+
try:
140+
run = db.query(TaskRun).filter(TaskRun.id == run_id).first()
141+
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
142+
assert run.status == "aborted"
143+
assert run.error == "Paused because Odysseus became active", (
144+
f"heartbeat-triggered cancel recorded {run.error!r} — this is a "
145+
"foreground pause, not a user stop"
146+
)
147+
assert task.next_run is not None, (
148+
"a foreground pause must defer the task (15 min), not drop next_run"
149+
)
150+
delta = (task.next_run - _utcnow()).total_seconds()
151+
assert 13 * 60 < delta <= 16 * 60, f"expected ~15min defer, got {delta}s"
152+
finally:
153+
db.close()
154+
155+
156+
def test_queued_cancel_does_not_leak_foreground_stop_flag(monkeypatch):
157+
"""A heartbeat stop that lands while the task is still queued behind the
158+
run semaphore never reaches _execute_task_locked's CancelledError handler.
159+
The _foreground_stops entry must still be cleaned up, or a later genuine
160+
user stop of the same task gets misrecorded as a foreground pause and
161+
silently rescheduled +15 min."""
162+
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "true")
163+
from core.database import SessionLocal, ScheduledTask
164+
from src.task_scheduler import TaskScheduler
165+
166+
task_id = "task-5536-queued-leak"
167+
db = SessionLocal()
168+
db.add(ScheduledTask(
169+
id=task_id,
170+
owner="alice",
171+
name="queued victim",
172+
task_type="llm",
173+
prompt="hang",
174+
status="active",
175+
trigger_type="manual",
176+
))
177+
db.commit()
178+
db.close()
179+
180+
scheduler = TaskScheduler(session_manager=None)
181+
182+
async def _scenario():
183+
# Hold the single run slot so the task parks on the semaphore.
184+
await scheduler._run_semaphore.acquire()
185+
try:
186+
async with scheduler._executing_lock:
187+
scheduler._executing.add(task_id)
188+
qtask = asyncio.create_task(
189+
scheduler._execute_task(task_id, release_executing=False)
190+
)
191+
for _ in range(200):
192+
if scheduler._task_handles.get(task_id) is qtask:
193+
break
194+
await asyncio.sleep(0.01)
195+
else:
196+
pytest.fail("queued task never registered its handle")
197+
await asyncio.sleep(0.05) # let it park on the semaphore
198+
199+
stopped = await scheduler.stop_background_tasks_for_foreground(
200+
reason="browser heartbeat"
201+
)
202+
assert stopped > 0
203+
with pytest.raises(asyncio.CancelledError):
204+
await qtask
205+
finally:
206+
scheduler._run_semaphore.release()
207+
208+
asyncio.run(_scenario())
209+
assert task_id not in scheduler._foreground_stops, (
210+
"queued-cancel path leaked the foreground-stop flag; the next user "
211+
"stop of this task would be misclassified as a foreground pause"
212+
)

tests/test_task_scheduler_cancel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def drive():
6464
scheduler._task_handles = {}
6565
scheduler._concurrency_cap = 1
6666
scheduler._task_defer_counts = {}
67+
scheduler._foreground_stops = set()
6768
await scheduler._run_semaphore.acquire()
6869

6970
task = asyncio.create_task(scheduler._execute_task("queued-task"))

0 commit comments

Comments
 (0)