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.
This commit is contained in:
Renn F
2026-06-18 20:18:26 +02:00
parent a0b73cf69b
commit f314723c98
3 changed files with 42 additions and 0 deletions
+9
View File
@@ -3963,6 +3963,15 @@ class AgentOrchestrator:
) )
continue continue
self._instances.pop(agent_id, None) 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( logger.warning(
"grok container killed: cost ceiling exceeded", "grok container killed: cost ceiling exceeded",
agent_id=agent_id, agent_id=agent_id,
+19
View File
@@ -101,6 +101,25 @@ class PrompterLiveRegistry:
session.queue.put_nowait(_CLOSE) session.queue.put_nowait(_CLOSE)
self.log.info("Live intake session closed", session_id=session_id) 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: def park(self, session_id: str, task_id: str) -> bool:
"""Mark a session as parked awaiting board review of ``task_id``. """Mark a session as parked awaiting board review of ``task_id``.
+14
View File
@@ -22,6 +22,20 @@ def test_open_get_close() -> None:
assert reg.get("s1") is 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: def test_park_and_find_by_task() -> None:
"""A parked session is discoverable by task id for board-feedback injection.""" """A parked session is discoverable by task id for board-feedback injection."""
reg = PrompterLiveRegistry() reg = PrompterLiveRegistry()