| """Tests for the idle-session reaper and the global create-slot reservation. |
| |
| Covers Parts B (hard global cap), C (idle reaper + activity stamps + safe |
| teardown + submit/reap race), and E (per-user concurrent cap interacts with |
| reaping) of the session-limit fix. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import sys |
| from datetime import datetime, timedelta |
| from pathlib import Path |
| from types import SimpleNamespace |
| from typing import Any |
|
|
| import pytest |
|
|
| _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend" |
| if str(_BACKEND_DIR) not in sys.path: |
| sys.path.insert(0, str(_BACKEND_DIR)) |
|
|
| import session_manager as sm |
| from agent.core.session_persistence import NoopSessionStore |
| from session_manager import ( |
| AgentSession, |
| Operation, |
| SessionCapacityError, |
| SessionManager, |
| ) |
| from agent.core.session import OpType |
|
|
|
|
| def test_reaper_idle_default_is_fifteen_minutes(): |
| assert sm.REAPER_IDLE_MINUTES == 15 |
| assert sm.REAPER_IDLE == timedelta(minutes=15) |
|
|
|
|
| class RecordingStore(NoopSessionStore): |
| """Captures every save_snapshot call so tests can assert persistence.""" |
|
|
| enabled = True |
|
|
| def __init__(self) -> None: |
| self.snapshots: list[dict[str, Any]] = [] |
|
|
| async def save_snapshot(self, **kwargs: Any) -> None: |
| self.snapshots.append(kwargs) |
|
|
| def snapshots_for(self, session_id: str) -> list[dict[str, Any]]: |
| return [s for s in self.snapshots if s.get("session_id") == session_id] |
|
|
|
|
| class FakeSession: |
| """Minimal Session stand-in supporting both persistence and _run_session.""" |
|
|
| def __init__( |
| self, |
| *, |
| hf_token: str | None = "token", |
| user_plan: str | None = None, |
| ) -> None: |
| self.hf_token = hf_token |
| self.user_plan = user_plan |
| self.context_manager = SimpleNamespace(items=[]) |
| self.pending_approval: Any = None |
| self.turn_count = 0 |
| self.config = SimpleNamespace(model_name="test-model", save_sessions=False) |
| self.notification_destinations: list[str] = [] |
| self.auto_approval_enabled = False |
| self.auto_approval_cost_cap_usd = None |
| self.auto_approval_estimated_spend_usd = 0.0 |
| self.is_running = True |
|
|
| async def send_event(self, event: Any) -> None: |
| return None |
|
|
|
|
| class FakeToolRouter: |
| async def __aenter__(self) -> "FakeToolRouter": |
| return self |
|
|
| async def __aexit__(self, *exc: Any) -> bool: |
| return False |
|
|
|
|
| def _manager() -> SessionManager: |
| manager = object.__new__(SessionManager) |
| manager.config = SimpleNamespace(model_name="test-model") |
| manager.sessions = {} |
| manager._lock = asyncio.Lock() |
| manager.persistence_store = RecordingStore() |
| manager.messaging_gateway = SimpleNamespace() |
| manager._pending_creates = 0 |
| manager._reaper_task = None |
| return manager |
|
|
|
|
| def _make_agent_session( |
| session_id: str, |
| *, |
| user_id: str = "owner", |
| last_active_at: datetime | None = None, |
| is_processing: bool = False, |
| pending_approval: Any = None, |
| ) -> AgentSession: |
| session = FakeSession() |
| session.pending_approval = pending_approval |
| agent_session = AgentSession( |
| session_id=session_id, |
| session=session, |
| tool_router=FakeToolRouter(), |
| submission_queue=asyncio.Queue(), |
| user_id=user_id, |
| hf_token="token", |
| ) |
| agent_session.is_processing = is_processing |
| if last_active_at is not None: |
| agent_session.last_active_at = last_active_at |
| return agent_session |
|
|
|
|
| async def _start_real_run_session( |
| manager: SessionManager, |
| session_id: str, |
| *, |
| user_id: str = "owner", |
| last_active_at: datetime | None = None, |
| ) -> AgentSession: |
| """Insert a session and start the REAL _run_session task for it.""" |
| agent_session = _make_agent_session( |
| session_id, user_id=user_id, last_active_at=last_active_at |
| ) |
| event_queue: asyncio.Queue = asyncio.Queue() |
| await manager._start_agent_session( |
| agent_session=agent_session, |
| event_queue=event_queue, |
| tool_router=agent_session.tool_router, |
| ) |
| await asyncio.sleep(0) |
| return agent_session |
|
|
|
|
| def _install_fake_create(manager: SessionManager) -> asyncio.Event: |
| """Replace blocking constructors + run loop with fakes for create tests.""" |
| stop = asyncio.Event() |
|
|
| def fake_create_session_sync(**kwargs: Any): |
| return object(), FakeSession( |
| hf_token=kwargs.get("hf_token"), |
| user_plan=kwargs.get("user_plan"), |
| ) |
|
|
| async def fake_run_session(*_: Any) -> None: |
| await stop.wait() |
|
|
| manager._create_session_sync = fake_create_session_sync |
| manager._run_session = fake_run_session |
| manager._start_cpu_sandbox_preload = lambda _agent_session: None |
| return stop |
|
|
|
|
| async def _cancel_tasks(manager: SessionManager) -> None: |
| tasks = [ |
| agent_session.task |
| for agent_session in manager.sessions.values() |
| if agent_session.task and not agent_session.task.done() |
| ] |
| for task in tasks: |
| task.cancel() |
| if tasks: |
| await asyncio.gather(*tasks, return_exceptions=True) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reaper_evicts_idle_session_as_resumable(): |
| manager = _manager() |
| cleaned: list[Any] = [] |
|
|
| async def fake_cleanup(session: Any) -> None: |
| cleaned.append(session) |
|
|
| manager._cleanup_sandbox = fake_cleanup |
|
|
| agent_session = await _start_real_run_session( |
| manager, |
| "stale", |
| last_active_at=datetime.utcnow() - timedelta(hours=3), |
| ) |
| session = agent_session.session |
|
|
| await manager._reap_idle_sessions() |
|
|
| |
| assert "stale" not in manager.sessions |
| assert cleaned == [session] |
|
|
| |
| store = manager.persistence_store |
| snapshots = store.snapshots_for("stale") |
| assert snapshots, "reaper should persist a snapshot" |
| assert all(s["status"] == "active" for s in snapshots) |
| assert snapshots[-1]["runtime_state"] == "idle" |
| assert not any(s["status"] == "ended" for s in snapshots) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| "kwargs", |
| [ |
| |
| {"last_active_at": None}, |
| |
| { |
| "last_active_at": datetime.utcnow() - timedelta(hours=5), |
| "is_processing": True, |
| }, |
| |
| { |
| "last_active_at": datetime.utcnow() - timedelta(hours=5), |
| "pending_approval": {"tool_calls": [object()]}, |
| }, |
| |
| {"last_active_at": datetime.utcnow() - timedelta(hours=5), "user_id": "dev"}, |
| ], |
| ids=["fresh", "processing", "pending_approval", "dev"], |
| ) |
| async def test_reaper_spares(kwargs): |
| manager = _manager() |
| agent_session = _make_agent_session("spared", **kwargs) |
| manager.sessions["spared"] = agent_session |
|
|
| await manager._reap_idle_sessions() |
|
|
| assert "spared" in manager.sessions |
| assert agent_session.is_reaping is False |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reap_aborts_when_message_enqueued_first(): |
| """A message enqueued before teardown makes the queue non-empty, so the |
| reaper's re-check aborts β the message is never lost.""" |
| manager = _manager() |
| agent_session = _make_agent_session( |
| "racing", last_active_at=datetime.utcnow() - timedelta(hours=3) |
| ) |
| manager.sessions["racing"] = agent_session |
| agent_session.submission_queue.put_nowait(object()) |
|
|
| cutoff = datetime.utcnow() - sm.REAPER_IDLE |
| reaped = await manager._reap_one("racing", cutoff) |
|
|
| assert reaped is False |
| assert "racing" in manager.sessions |
| assert agent_session.is_reaping is False |
| assert agent_session.submission_queue.qsize() == 1 |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_submit_rejected_while_reaping(): |
| """submit() refuses a session being reaped instead of silently enqueuing |
| onto a dying runtime (the caller then reloads a fresh one).""" |
| manager = _manager() |
| agent_session = _make_agent_session("reaping") |
| agent_session.is_reaping = True |
| manager.sessions["reaping"] = agent_session |
|
|
| ok = await manager.submit("reaping", Operation(op_type=OpType.USER_INPUT, data={})) |
|
|
| assert ok is False |
| assert agent_session.submission_queue.empty() |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_turn_finish_restamps_activity(monkeypatch): |
| """A turn that runs longer than the idle window must not be reaped the |
| instant it finishes β the turn-finish stamp resets the idle clock.""" |
| manager = _manager() |
|
|
| async def fake_cleanup(session: Any) -> None: |
| return None |
|
|
| manager._cleanup_sandbox = fake_cleanup |
|
|
| agent_session = await _start_real_run_session( |
| manager, "longturn", last_active_at=datetime.utcnow() |
| ) |
|
|
| async def fake_process(session: Any, submission: Any) -> bool: |
| |
| |
| agent_session.last_active_at = datetime.utcnow() - timedelta(hours=3) |
| return True |
|
|
| monkeypatch.setattr(sm, "process_submission", fake_process) |
|
|
| agent_session.submission_queue.put_nowait( |
| sm.Submission(id="s1", operation=Operation(op_type=OpType.USER_INPUT, data={})) |
| ) |
|
|
| |
| for _ in range(200): |
| await asyncio.sleep(0.01) |
| if datetime.utcnow() - agent_session.last_active_at < timedelta(minutes=1): |
| break |
|
|
| assert datetime.utcnow() - agent_session.last_active_at < timedelta(minutes=1) |
|
|
| await _cancel_tasks(manager) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_concurrent_creates_cannot_exceed_global_cap(monkeypatch): |
| manager = _manager() |
| stop = _install_fake_create(manager) |
| monkeypatch.setattr(sm, "MAX_SESSIONS", 3) |
|
|
| try: |
| results = await asyncio.gather( |
| *[manager.create_session(user_id="owner") for _ in range(10)], |
| return_exceptions=True, |
| ) |
| created = [r for r in results if isinstance(r, str)] |
| errors = [r for r in results if isinstance(r, SessionCapacityError)] |
|
|
| assert len(created) == 3 |
| assert len(errors) == 7 |
| assert all(e.error_type == "global" for e in errors) |
| assert len(manager.sessions) == 3 |
| assert manager._pending_creates == 0 |
| finally: |
| stop.set() |
| await _cancel_tasks(manager) |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_failed_build_releases_reservation(monkeypatch): |
| manager = _manager() |
| _install_fake_create(manager) |
| monkeypatch.setattr(sm, "MAX_SESSIONS", 5) |
|
|
| def boom(**_: Any): |
| raise RuntimeError("build failed") |
|
|
| manager._create_session_sync = boom |
|
|
| with pytest.raises(RuntimeError): |
| await manager.create_session(user_id="owner") |
|
|
| |
| assert manager._pending_creates == 0 |
| assert manager.sessions == {} |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_per_user_cap_frees_up_after_slot_reclaimed(): |
| manager = _manager() |
| stop = _install_fake_create(manager) |
|
|
| try: |
| for i in range(sm.MAX_SESSIONS_PER_USER): |
| manager.sessions[f"owner-{i}"] = _make_agent_session( |
| f"owner-{i}", user_id="owner" |
| ) |
|
|
| |
| with pytest.raises(SessionCapacityError) as exc: |
| await manager.create_session(user_id="owner") |
| assert exc.value.error_type == "per_user" |
| message = str(exc.value) |
| assert f"maximum of {sm.MAX_SESSIONS_PER_USER} live sessions" in message |
| assert "Close an existing session" in message |
| assert f"wait {sm.REAPER_IDLE_MINUTES:g} minutes" in message |
| assert "after your last activity" in message |
| assert "idle session to be released" in message |
|
|
| |
| manager.sessions.pop("owner-0") |
| new_id = await manager.create_session(user_id="owner") |
|
|
| assert isinstance(new_id, str) |
| assert manager._count_user_sessions("owner") == sm.MAX_SESSIONS_PER_USER |
| finally: |
| stop.set() |
| await _cancel_tasks(manager) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reaper_skips_when_persistence_disabled(): |
| """With no usable store a reaped session couldn't be restored, so eviction |
| would destroy non-dev chats outright. The sweep must be a no-op.""" |
| manager = _manager() |
| manager.persistence_store = NoopSessionStore() |
| agent_session = _make_agent_session( |
| "idle", last_active_at=datetime.utcnow() - timedelta(hours=5) |
| ) |
| manager.sessions["idle"] = agent_session |
|
|
| await manager._reap_idle_sessions() |
|
|
| assert "idle" in manager.sessions |
| assert agent_session.is_reaping is False |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reap_aborts_when_snapshot_write_fails(): |
| """If the resumable snapshot can't be written (e.g. a transient Mongo |
| error), abort rather than evict unrecoverable state β leave it live.""" |
| manager = _manager() |
|
|
| class FailingStore(RecordingStore): |
| async def save_snapshot(self, **kwargs: Any) -> None: |
| raise RuntimeError("mongo write failed") |
|
|
| manager.persistence_store = FailingStore() |
| agent_session = _make_agent_session( |
| "idle", last_active_at=datetime.utcnow() - timedelta(hours=5) |
| ) |
| manager.sessions["idle"] = agent_session |
|
|
| cutoff = datetime.utcnow() - sm.REAPER_IDLE |
| reaped = await manager._reap_one("idle", cutoff) |
|
|
| assert reaped is False |
| assert "idle" in manager.sessions |
| assert agent_session.is_reaping is False |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reap_aborts_when_message_write_fails_silently(): |
| """The real store swallows message bulk_write errors for best-effort callers |
| and only surfaces them when asked to be strict. The reaper must request |
| strict mode, so a silent message-write failure still aborts the reap (not |
| only metadata/connection failures that already make save_snapshot raise).""" |
| manager = _manager() |
|
|
| class StrictModeStore(RecordingStore): |
| |
| |
| async def save_snapshot( |
| self, *, raise_on_error: bool = False, **kwargs: Any |
| ) -> None: |
| if raise_on_error: |
| raise RuntimeError("message bulk_write failed") |
|
|
| manager.persistence_store = StrictModeStore() |
| agent_session = _make_agent_session( |
| "idle", last_active_at=datetime.utcnow() - timedelta(hours=5) |
| ) |
| manager.sessions["idle"] = agent_session |
|
|
| cutoff = datetime.utcnow() - sm.REAPER_IDLE |
| reaped = await manager._reap_one("idle", cutoff) |
|
|
| assert reaped is False |
| assert "idle" in manager.sessions |
| assert agent_session.is_reaping is False |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_old_reaped_task_does_not_end_freshly_restored_session(): |
| """If a user reopens after the reaper pops the old wrapper but before the |
| old task finishes sandbox cleanup, the old finally must not mark the fresh |
| wrapper ended.""" |
| manager = _manager() |
| cleanup_started = asyncio.Event() |
| release_cleanup = asyncio.Event() |
|
|
| async def slow_cleanup(session: Any) -> None: |
| cleanup_started.set() |
| await release_cleanup.wait() |
|
|
| manager._cleanup_sandbox = slow_cleanup |
|
|
| old = await _start_real_run_session( |
| manager, |
| "restore-race", |
| last_active_at=datetime.utcnow() - timedelta(hours=3), |
| ) |
| cutoff = datetime.utcnow() - sm.REAPER_IDLE |
| reap_task = asyncio.create_task(manager._reap_one("restore-race", cutoff)) |
|
|
| for _ in range(100): |
| await asyncio.sleep(0.01) |
| if "restore-race" not in manager.sessions and cleanup_started.is_set(): |
| break |
|
|
| assert "restore-race" not in manager.sessions |
| assert cleanup_started.is_set() |
|
|
| fresh = _make_agent_session("restore-race") |
| async with manager._lock: |
| manager.sessions["restore-race"] = fresh |
|
|
| release_cleanup.set() |
| assert await reap_task is True |
| if old.task is not None: |
| assert old.task.done() |
|
|
| assert manager.sessions["restore-race"] is fresh |
| assert fresh.is_active is True |
| assert all( |
| snapshot.get("status") != "ended" |
| for snapshot in manager.persistence_store.snapshots_for("restore-race") |
| ) |
|
|
|
|
| |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_reaper_teardown_propagates_outer_cancellation(): |
| """Cancelling the reaper while it awaits a slow teardown must propagate, so |
| close() can't hang. Regression for the CancelledError-conflation bug: the |
| old wait_for + bare-except swallowed the reaper's own cancellation.""" |
| manager = _manager() |
|
|
| release = asyncio.Event() |
|
|
| async def slow_cleanup(session: Any) -> None: |
| await release.wait() |
|
|
| manager._cleanup_sandbox = slow_cleanup |
|
|
| agent_session = await _start_real_run_session( |
| manager, "slow", last_active_at=datetime.utcnow() - timedelta(hours=3) |
| ) |
| cutoff = datetime.utcnow() - sm.REAPER_IDLE |
|
|
| reap_task = asyncio.create_task(manager._reap_one("slow", cutoff)) |
| |
| |
| for _ in range(100): |
| await asyncio.sleep(0.01) |
| if "slow" not in manager.sessions: |
| break |
| await asyncio.sleep(0.02) |
|
|
| |
| reap_task.cancel() |
| with pytest.raises(asyncio.CancelledError): |
| await reap_task |
|
|
| |
| release.set() |
| if agent_session.task is not None: |
| await asyncio.gather(agent_session.task, return_exceptions=True) |
|
|