mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] browser: reap idle agent cards so finished runs stop stacking live webviews
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user