From f314723c9825de1db08271aa3aff5c86a9bc150d Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 20:18:26 +0200 Subject: [PATCH] fix(grok): close the panel relay when the cost-cap kills an interactive chat (M4) _enforce_grok_cost_budget killed + evicted a container directly. For the interactive roles (intake/secretary) that left the panel SSE relay open with no close sentinel, so the chat froze with no explanation. Add PrompterLiveRegistry.close_by_agent (push a final error event, then close every session bound to that agent) and call it from the cost-cap watchdog when the killed agent is the intake or secretary, so the panel reports the chat ended on the cost cap instead of hanging. --- roboco/runtime/orchestrator.py | 9 +++++++++ roboco/services/prompter_live.py | 19 +++++++++++++++++++ tests/unit/services/test_prompter_live.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 67f7c23b..0ba44300 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -3963,6 +3963,15 @@ class AgentOrchestrator: ) continue self._instances.pop(agent_id, None) + # Interactive roles (intake/secretary) have an open panel relay; a + # raw kill would leave the SSE hanging (frozen chat). Close it with a + # reason so the panel reports why the chat ended. + if agent_id in (INTAKE_AGENT_ID, SECRETARY_AGENT_ID): + from roboco.services.prompter_live import get_live_registry + + get_live_registry().close_by_agent( + agent_id, error="Chat ended: the Grok cost cap was exceeded." + ) logger.warning( "grok container killed: cost ceiling exceeded", agent_id=agent_id, diff --git a/roboco/services/prompter_live.py b/roboco/services/prompter_live.py index 8071a6e9..b4b00bc9 100644 --- a/roboco/services/prompter_live.py +++ b/roboco/services/prompter_live.py @@ -101,6 +101,25 @@ class PrompterLiveRegistry: session.queue.put_nowait(_CLOSE) self.log.info("Live intake session closed", session_id=session_id) + def close_by_agent(self, agent_id: str, *, error: str | None = None) -> list[str]: + """Close every live session bound to ``agent_id`` (e.g. on a forced kill). + + When the orchestrator kills an interactive container out-of-band (the + Grok cost-cap watchdog), the relay would otherwise stay open and the + panel SSE would hang forever. This pushes an optional final ``error`` + event so the panel shows WHY the chat ended, then closes the stream. + Returns the closed session ids. + """ + closed: list[str] = [] + for session_id, session in list(self._sessions.items()): + if session.agent_id != agent_id or session.closed: + continue + if error: + session.queue.put_nowait({"kind": "error", "text": error}) + self.close(session_id) + closed.append(session_id) + return closed + def park(self, session_id: str, task_id: str) -> bool: """Mark a session as parked awaiting board review of ``task_id``. diff --git a/tests/unit/services/test_prompter_live.py b/tests/unit/services/test_prompter_live.py index 97da09a8..0dcb789a 100644 --- a/tests/unit/services/test_prompter_live.py +++ b/tests/unit/services/test_prompter_live.py @@ -22,6 +22,20 @@ def test_open_get_close() -> None: assert reg.get("s1") is None +def test_close_by_agent_closes_matching_sessions_with_error() -> None: + reg = PrompterLiveRegistry() + reg.open("s1", "intake-1") + reg.open("s2", "secretary-1") # different agent — must survive + closed = reg.close_by_agent("intake-1", error="cost cap") + assert closed == ["s1"] + assert reg.get("s1") is None # closed + popped + assert reg.is_alive("s2") # untouched + # The error event is queued before the close sentinel so the panel sees it. + sess = reg.open("s3", "intake-1") + reg.close_by_agent("intake-1", error="boom") + assert sess.queue.get_nowait() == {"kind": "error", "text": "boom"} + + def test_park_and_find_by_task() -> None: """A parked session is discoverable by task id for board-feedback injection.""" reg = PrompterLiveRegistry()