diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index bbed1706..021ac694 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -3261,6 +3261,60 @@ async def evict_dead_card(dashboard_id: str | None, browser_id: str) -> None: await asyncio.sleep(P_EVICT_SETTLE_S) +# The user can still scroll back through the last few results; everything older is a site-isolated renderer process held for nobody. Small on purpose. +P_KEEP_IDLE_AGENT_CARDS = 3 + + +async def reap_idle_agent_cards(dashboard_id: str | None, keep: int = P_KEEP_IDLE_AGENT_CARDS) -> int: + """Drop all but the newest `keep` finished agent-spawned cards; returns how many went. + + The frontend does fade a spawned card away when its parent finishes, but that removal is owned + by the card's OWN component: switch dashboards, quit, or unmount it inside the 3s timer and the + card survives, persisted, forever (measured: 8 stranded cards on one dashboard, 6 of them with + the keep-open flag clear, so every one was supposed to be gone). Each survivor remounts a live + webview on the next launch, and they accumulate for as long as the user keeps using the agent. + So the bound lives here, at the point of allocation, where no unmounted component can skip it. + Never touches a user's own card, and never one an agent is driving right now.""" + if not dashboard_id: + return 0 + try: + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.dashboards.dashboards import load, save + + dash = load(dashboard_id) + idle: list[tuple] = [] + for bid, card in dash.layout.browser_cards.items(): + spawned = getattr(card, "spawned_by", None) + if not spawned or bid in ACTIVE_AGENT_CARDS: + continue + parent = agent_manager.get_session(spawned) + if parent is not None and getattr(parent, "status", "") == "running": + continue + born = getattr(card, "created_at", None) + # A float key never raises the way comparing a naive datetime to an aware one would; cards from before the field existed sort to 0 and reap first, which is correct, they are the oldest thing here. + idle.append((born.timestamp() if born else 0.0, bid)) + if len(idle) <= keep: + return 0 + idle.sort() + doomed = [bid for _, bid in idle[:len(idle) - keep]] + for bid in doomed: + del dash.layout.browser_cards[bid] + DEAD_CARDS.discard(bid) + dash.updated_at = datetime.now() + save(dash) + for bid in doomed: + try: + await ws_manager.broadcast_global("dashboard:browser_card_evict", { + "dashboard_id": dashboard_id, "browser_id": bid}) + except Exception: + pass + logger.info(f"[browser-agent] reaped {len(doomed)} idle agent cards, kept newest {keep}: {doomed}") + return len(doomed) + except Exception as e: + logger.info(f"[browser-agent] idle-card reap skipped ({e})") + return 0 + + async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str: """Create a new browser card on the dashboard and return its browser_id.""" from backend.apps.dashboards.dashboards import load, save @@ -3281,6 +3335,7 @@ async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: height=800, spawned_by=parent_session_id, dashboard_id=dashboard_id, + created_at=datetime.now(), ) dashboard.layout.browser_cards[browser_id] = card dashboard.updated_at = datetime.now() @@ -3366,6 +3421,8 @@ async def run_browser_agents( # the keychain, and holding a lock across that would serialize every card creation. await borrow_signin_before_nav(host_src, "") async with p_card_pick_lock: + # Before allocating, collect what earlier runs left behind, else a session's cards only ever grow. + await reap_idle_agent_cards(dashboard_id) browser_id = find_reusable_card(dashboard_id, host_src, parent_session_id) if browser_id: reused = True diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index ea766691..9245fdbe 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -48,6 +48,8 @@ class BrowserCardPosition(BaseModel): dashboard_id: Optional[str] = None # Chat session this browser lives inside (renders over the chat's dock slot); None = free card. docked_to: Optional[str] = None + # When this card was spawned, so the idle-agent-card reaper can keep the newest and drop the rest. Cards saved before this field existed read as None and reap first, which is right: they are the oldest thing on the canvas. + created_at: Optional[datetime] = None class DashboardLayout(BaseModel): diff --git a/backend/tests/test_deadcard_evict.py b/backend/tests/test_deadcard_evict.py index 898d11e7..c6aa6c58 100644 --- a/backend/tests/test_deadcard_evict.py +++ b/backend/tests/test_deadcard_evict.py @@ -2,6 +2,7 @@ torn down (renderer unmount + layout removal) BEFORE recovery spawns a fresh card, so two heavy pages never co-exist and starve the renderer. Pins evict_dead_card.""" import asyncio +from datetime import datetime, timedelta import backend.apps.agents.browser.browser_agent as ba @@ -55,8 +56,9 @@ def test_evict_without_a_dashboard_deletes_nothing(monkeypatch): class FakeCard: - def __init__(self, spawned_by=None): + def __init__(self, spawned_by=None, created_at=None): self.spawned_by = spawned_by + self.created_at = created_at def test_user_card_is_never_evicted(monkeypatch): @@ -66,3 +68,75 @@ def test_user_card_is_never_evicted(monkeypatch): asyncio.run(ba.evict_dead_card("dash-1", "browser-user")) assert not broadcasts and not saved assert "browser-user" in dash.layout.browser_cards + + +# --- the idle-card reaper: cards a FINISHED run left behind ------------------------------------ +# The frontend's fade-and-remove is owned by the card's own component, so a dashboard switch or a +# quit strands it. These pin the backend bound that holds no matter what the UI did. + +def p_born(n): + return datetime(2026, 1, 1) + timedelta(minutes=n) + + +def p_reap_patch(monkeypatch, cards, running=()): + broadcasts, saved, dash = p_patch(monkeypatch, cards) + + class FakeSession: + def __init__(self, status): + self.status = status + + class FakeManager: + def get_session(self, sid): + return FakeSession("running") if sid in running else FakeSession("completed") + + import backend.apps.agents.agent_manager as am + monkeypatch.setattr(am, "agent_manager", FakeManager(), raising=True) + return broadcasts, saved, dash + + +def test_idle_agent_cards_are_reaped_down_to_the_newest_few(monkeypatch): + cards = {f"browser-{i}": FakeCard("sess-old", p_born(i)) for i in range(8)} + broadcasts, saved, dash = p_reap_patch(monkeypatch, cards) + assert asyncio.run(ba.reap_idle_agent_cards("dash-1", keep=3)) == 5 + # the newest three survive, the five older ones are gone and the renderer was told about each + assert sorted(dash.layout.browser_cards) == ["browser-5", "browser-6", "browser-7"] + assert len([b for b in broadcasts if b[0] == "dashboard:browser_card_evict"]) == 5 + assert saved + + +def test_reaper_never_touches_user_cards_or_live_runs(monkeypatch): + cards = { + "browser-user": FakeCard(None, p_born(0)), # the user's own, oldest of all + "browser-live": FakeCard("sess-live", p_born(1)), # its agent is still running + "browser-driving": FakeCard("sess-old", p_born(2)), # being driven RIGHT NOW + "browser-idle-a": FakeCard("sess-old", p_born(3)), + "browser-idle-b": FakeCard("sess-old", p_born(4)), + } + _, _, dash = p_reap_patch(monkeypatch, cards, running=("sess-live",)) + ba.ACTIVE_AGENT_CARDS.add("browser-driving") + try: + # keep=0 is the harshest possible sweep: anything that survives is protected by rule, not luck + assert asyncio.run(ba.reap_idle_agent_cards("dash-1", keep=0)) == 2 + finally: + ba.ACTIVE_AGENT_CARDS.discard("browser-driving") + assert sorted(dash.layout.browser_cards) == ["browser-driving", "browser-live", "browser-user"] + + +def test_cards_from_before_created_at_existed_reap_first(monkeypatch): + # An older build's cards have no timestamp. They are the oldest thing on the canvas, so they + # must go before anything stamped, never outlive it on a technicality. + cards = { + "browser-legacy": FakeCard("sess-old", None), + "browser-new": FakeCard("sess-old", p_born(1)), + } + _, _, dash = p_reap_patch(monkeypatch, cards) + assert asyncio.run(ba.reap_idle_agent_cards("dash-1", keep=1)) == 1 + assert list(dash.layout.browser_cards) == ["browser-new"] + + +def test_reaper_is_quiet_when_there_is_nothing_to_collect(monkeypatch): + cards = {"browser-a": FakeCard("sess-old", p_born(1))} + broadcasts, saved, dash = p_reap_patch(monkeypatch, cards) + assert asyncio.run(ba.reap_idle_agent_cards("dash-1", keep=3)) == 0 + assert not broadcasts and not saved + assert "browser-a" in dash.layout.browser_cards