fix(prompter): keep a watched intake chat alive (idle-reap counted reading as idle) (#379)

* fix(prompter): keep a watched intake chat alive (idle-reap counted reading as idle)

Intake chats "dropped after a while" — the panel showed "Live connection lost".
The idle reaper retires an interactive session whose last_activity is older than
interactive_idle_reap_seconds (30m default), but last_activity was bumped only by
an agent event or a human turn. An open SSE stream — the human reading a proposed
draft / MegaTask spec without typing — bumped nothing, so a chat under active
review was reaped mid-read, closing the stream (the SSE transport error the panel
reports as "Live connection lost").

stream() now runs a keepalive task that refreshes last_activity every 60s while
the stream is connected, so an open, actively-watched chat counts as alive; when
the tab closes the generator ends, the keepalive is cancelled, and a genuinely
abandoned chat still reaps after the threshold. The keepalive runs beside an
un-cancelled queue.get() so no live token or the close sentinel can be dropped.

* fix(tests): conventions PR integration test honors the #375 workspace-scope guard

#375 added a containment guard to open_conventions_pr (workspace_path must sit
under {workspaces_root}/{slug}); the unit test was updated but this integration
test still seeded a bare tmp_path/repo, so open_conventions_pr returned None and
test_open_conventions_pr_commits_locally_without_remote failed on master. Anchor
workspaces_root at the test dir and place the repo under the project's slug.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-10 03:58:37 +02:00
committed by GitHub
co-authored by Renn F
parent 3787d15524
commit 2297d448f3
3 changed files with 78 additions and 12 deletions
+34 -6
View File
@@ -34,6 +34,13 @@ logger = structlog.get_logger()
SDK_PORT = 9000 SDK_PORT = 9000
# Sentinel pushed onto a session's queue to end its SSE stream. # Sentinel pushed onto a session's queue to end its SSE stream.
_CLOSE = object() _CLOSE = object()
# While the panel holds the SSE stream open, refresh ``last_activity`` on this
# cadence even with no events — an open stream means the human is watching (e.g.
# reading a proposed draft / MegaTask without typing), so the idle reaper must
# not retire the chat out from under them. Well under
# ``interactive_idle_reap_seconds`` (default 1800). When the tab closes the
# generator is cancelled, the refresh stops, and the chat reaps normally.
_STREAM_KEEPALIVE_SECONDS = 60.0
@dataclass @dataclass
@@ -182,16 +189,37 @@ class PrompterLiveRegistry:
and now - s.last_activity > threshold_seconds and now - s.last_activity > threshold_seconds
] ]
@staticmethod
async def _keepalive(session: LiveIntakeSession) -> None:
"""Refresh ``last_activity`` while a stream is connected (see ``stream``)."""
while True:
await asyncio.sleep(_STREAM_KEEPALIVE_SECONDS)
session.last_activity = time.monotonic()
async def stream(self, session_id: str) -> AsyncIterator[dict[str, Any]]: async def stream(self, session_id: str) -> AsyncIterator[dict[str, Any]]:
"""Yield queued agent events until the session is closed.""" """Yield queued agent events until the session is closed.
A connected stream keeps the session's ``last_activity`` fresh (a
``_keepalive`` task ticks every ``_STREAM_KEEPALIVE_SECONDS`` even with
no events), so the idle reaper treats an open, actively-watched chat as
alive — a human reading a proposed draft without typing is not "idle".
The keepalive runs beside an un-cancelled ``queue.get()`` (so no live
token/``_CLOSE`` is lost) and is cancelled when the stream ends; on
client disconnect the refresh stops and an abandoned chat reaps normally.
"""
session = self._sessions.get(session_id) session = self._sessions.get(session_id)
if session is None: if session is None:
return return
while True: keepalive = asyncio.create_task(self._keepalive(session))
item = await session.queue.get() try:
if item is _CLOSE: while True:
return item = await session.queue.get()
yield item if item is _CLOSE:
return
session.last_activity = time.monotonic()
yield item
finally:
keepalive.cancel()
# -- panel -> agent ---------------------------------------------------- # -- panel -> agent ----------------------------------------------------
+14 -6
View File
@@ -6,6 +6,7 @@ import subprocess
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import uuid4 from uuid import uuid4
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team from roboco.models import AgentRole, AgentStatus, Team
from roboco.services.git import _ConventionsPr, get_git_service from roboco.services.git import _ConventionsPr, get_git_service
@@ -23,7 +24,9 @@ def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True)
async def _seed_project(db: AsyncSession, workspace_path: str) -> ProjectTable: async def _seed_project(
db: AsyncSession, workspace_path: str, *, slug: str | None = None
) -> ProjectTable:
agent = AgentTable( agent = AgentTable(
id=uuid4(), id=uuid4(),
name="Dev", name="Dev",
@@ -42,7 +45,7 @@ async def _seed_project(db: AsyncSession, workspace_path: str) -> ProjectTable:
project = ProjectTable( project = ProjectTable(
id=uuid4(), id=uuid4(),
name="G-Proj", name="G-Proj",
slug=f"g-proj-{uuid4().hex[:8]}", slug=slug or f"g-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git", git_url="https://example.com/r.git",
default_branch="master", default_branch="master",
assigned_cell=Team.BACKEND, assigned_cell=Team.BACKEND,
@@ -55,10 +58,15 @@ async def _seed_project(db: AsyncSession, workspace_path: str) -> ProjectTable:
async def test_open_conventions_pr_commits_locally_without_remote( async def test_open_conventions_pr_commits_locally_without_remote(
db_session: AsyncSession, tmp_path: Path db_session: AsyncSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
repo = tmp_path / "repo" # open_conventions_pr only accepts a workspace_path under
repo.mkdir() # {workspaces_root}/{slug} (the containment guard), so anchor the root at
# the test dir and place the repo under the project's own slug.
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path))
slug = f"g-proj-{uuid4().hex[:8]}"
repo = tmp_path / slug / "repo"
repo.mkdir(parents=True)
_git(repo, "init", "-b", "master") _git(repo, "init", "-b", "master")
_git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T") _git(repo, "config", "user.name", "T")
@@ -67,7 +75,7 @@ async def test_open_conventions_pr_commits_locally_without_remote(
_git(repo, "add", "README.md") _git(repo, "add", "README.md")
_git(repo, "commit", "-m", "init") _git(repo, "commit", "-m", "init")
project = await _seed_project(db_session, str(repo)) project = await _seed_project(db_session, str(repo), slug=slug)
git = get_git_service(db_session) git = get_git_service(db_session)
result = await git.open_conventions_pr( result = await git.open_conventions_pr(
project.slug, project.slug,
+30
View File
@@ -8,6 +8,7 @@ import time
import httpx import httpx
import pytest import pytest
import roboco.services.prompter_live as pl
from roboco.services.prompter_live import ( from roboco.services.prompter_live import (
PrompterLiveRegistry, PrompterLiveRegistry,
get_live_registry, get_live_registry,
@@ -151,6 +152,35 @@ async def test_stream_yields_queued_events_then_ends_on_close() -> None:
] ]
@pytest.mark.asyncio
async def test_stream_keepalive_keeps_watched_chat_alive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An open SSE stream refreshes last_activity even with no events, so a
human reading a proposal (tab open, not typing) is not idle-reaped. Without
this the chat "drops after a while" mid-review."""
monkeypatch.setattr(pl, "_STREAM_KEEPALIVE_SECONDS", 0.01)
reg = PrompterLiveRegistry()
session = reg.open("s1", "intake-1")
session.last_activity = time.monotonic() - 4000 # silent for >1h
# Before anyone connects, the abandoned-looking chat IS idle-reapable.
assert ("s1", "intake-1") in reg.idle_session_ids(1800)
async def watch() -> None:
async for _ in reg.stream("s1"):
pass
task = asyncio.create_task(watch())
try:
await asyncio.sleep(0.05) # let a keepalive tick fire on the open stream
# The connected stream refreshed activity → no longer idle.
assert reg.idle_session_ids(1800) == []
finally:
reg.close("s1")
await asyncio.wait_for(task, timeout=1.0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_unknown_session_is_empty() -> None: async def test_stream_unknown_session_is_empty() -> None:
reg = PrompterLiveRegistry() reg = PrompterLiveRegistry()