feat(grok): reap abandoned interactive chats (M3)

An interactive intake/secretary chat the human abandoned (closed the tab without
confirming or stopping) leaked its container until the orchestrator restarted —
the wedged-grok reaper is task-driven and these run task_id=None, and an SSE
disconnect intentionally does NOT reap (so a page reload can reconnect).

Reap by IDLE TIME, not connection state: PrompterLiveRegistry tracks
last_activity (bumped on every push/deliver = a turn), and the 60s sweeper
retires sessions idle past ROBOCO_INTERACTIVE_IDLE_REAP_SECONDS (default 1800;
0 disables) via reap_intake_session / reap_secretary_session. An active or
page-reloaded chat that keeps exchanging turns stays fresh and is never reaped;
board-review-parked sessions (task_id set) are exempt. Provider-agnostic — fixes
the leak for both Claude and Grok interactive.

Tests: idle-only reap (active/parked/closed excluded), activity bump keeps a
session alive, threshold 0 disables. Gate green (ruff/mypy/xenon + prompter_live).
This commit is contained in:
Renn F
2026-06-18 22:17:52 +02:00
parent eac065e504
commit 7d9215412b
4 changed files with 110 additions and 0 deletions
+30
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import time
import httpx
import pytest
@@ -22,6 +23,35 @@ def test_open_get_close() -> None:
assert reg.get("s1") is None
def test_idle_session_ids_reaps_only_abandoned_chats() -> None:
reg = PrompterLiveRegistry()
old = reg.open("idle", "intake-1")
old.last_activity = time.monotonic() - 4000 # silent for >1h
reg.open("fresh", "intake-1") # just opened — active
parked = reg.open("parked", "intake-1") # board-review parked
parked.last_activity = time.monotonic() - 4000
parked.task_id = "task-9"
reg.open("done", "secretary-1")
reg.close("done") # closed sessions excluded
idle = dict(reg.idle_session_ids(1800))
assert "idle" in idle and idle["idle"] == "intake-1" # abandoned -> reaped
assert "fresh" not in idle # active -> kept
assert "parked" not in idle # board-review parked -> exempt
assert "done" not in idle # closed -> excluded
# Disabled when threshold <= 0.
assert reg.idle_session_ids(0) == []
def test_activity_bump_keeps_session_alive() -> None:
reg = PrompterLiveRegistry()
s = reg.open("s1", "intake-1")
s.last_activity = time.monotonic() - 4000
assert ("s1", "intake-1") in reg.idle_session_ids(1800)
reg.push("s1", {"kind": "text", "text": "hi"}) # an agent turn = activity
assert reg.idle_session_ids(1800) == [] # no longer idle
def test_close_by_agent_closes_matching_sessions_with_error() -> None:
reg = PrompterLiveRegistry()
reg.open("s1", "intake-1")