[eric] browser: reap idle agent cards so finished runs stop stacking live webviews

This commit is contained in:
ciregenz
2026-07-30 19:47:20 -07:00
parent ece00223ae
commit f1f8464f1a
3 changed files with 134 additions and 1 deletions
@@ -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
+2
View File
@@ -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):
+75 -1
View File
@@ -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