[eric] dashboards: drop orphan session cards from GET so a dead-session card stops 404ing on load

This commit is contained in:
ciregenz
2026-06-15 14:34:16 -07:00
parent bd73d49828
commit 46e92c9fe0
2 changed files with 63 additions and 1 deletions
+36 -1
View File
@@ -363,10 +363,45 @@ async def generate_name(dashboard_id: str):
return {"name": dashboard.name, "auto_named": True}
def _strip_orphan_session_cards(data: dict) -> None:
"""Drop layout cards (and expanded ids) whose agent session no longer exists
anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and
GETs its session; a card pointing at a vanished session (e.g. an empty
never-saved session) 404s on every load and flashes a dead "connect a model"
card before the client reconciles it away. The `gone()` test is the exact
condition that makes GET /sessions/{id} 404, so it removes precisely those
cards and nothing else. Filtering the RESPONSE (never the stored file) is
non-destructive: a wrong check can only hide a card for one response, not
delete it. Drafts have no backend session yet, so they're always kept."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.manager.session.session_store import _load_session_data
layout = data.get("layout")
if not isinstance(layout, dict):
return
cards = layout.get("cards")
if not isinstance(cards, dict):
return
def gone(sid: str) -> bool:
if sid.startswith("draft-") or sid in agent_manager.sessions:
return False
return _load_session_data(sid) is None
orphans = [sid for sid in cards if gone(sid)]
for sid in orphans:
cards.pop(sid, None)
if orphans:
exp = layout.get("expanded_session_ids")
if isinstance(exp, list):
layout["expanded_session_ids"] = [s for s in exp if s not in orphans]
@dashboards.router.get("/{dashboard_id}")
async def get_dashboard(dashboard_id: str):
dashboard = _load(dashboard_id)
return dashboard.model_dump(mode="json")
data = dashboard.model_dump(mode="json")
_strip_orphan_session_cards(data)
return data
@dashboards.router.put("/{dashboard_id}")
+27
View File
@@ -594,6 +594,33 @@ async def test_mcp_gate_only_forwards_activated_servers():
assert forwarded == (set(active) & set(names)), f"mismatch for active={active}"
def test_dashboard_get_strips_only_orphan_session_cards():
"""A layout card whose session vanished (gone from memory AND disk) makes the
frontend GET /sessions/{id} 404 on every load and flash a dead card. The
dashboard GET filters those orphan cards out of the response, but must keep
live (in-memory) cards, on-disk cards, and drafts. Non-destructive: only the
response is filtered, never the stored layout."""
from types import SimpleNamespace
from backend.apps.dashboards import dashboards as D
data = {"layout": {
"cards": {
"live": {"session_id": "live"}, # in memory
"ondisk": {"session_id": "ondisk"}, # closed but on disk
"draft-1": {"session_id": "draft-1"}, # unsent draft, no backend session yet
"ghost": {"session_id": "ghost"}, # gone from memory AND disk -> would 404
},
"expanded_session_ids": ["live", "ghost"],
}}
fake_mgr = SimpleNamespace(sessions={"live": object()})
on_disk = {"ondisk": {"id": "ondisk"}}
with patch("backend.apps.agents.agent_manager.agent_manager", fake_mgr), \
patch("backend.apps.agents.manager.session.session_store._load_session_data",
side_effect=lambda sid: on_disk.get(sid)):
D._strip_orphan_session_cards(data)
assert set(data["layout"]["cards"].keys()) == {"live", "ondisk", "draft-1"}, "only the ghost should be dropped"
assert data["layout"]["expanded_session_ids"] == ["live"], "ghost dropped from expanded too"
def test_banned_models_not_offered():
"""Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve
it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a