mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
102 lines
2.9 KiB
Python
102 lines
2.9 KiB
Python
"""Startup sweep closes orphan agent_spawn_sessions rows.
|
|||
|
|
|
||
|
|
A spawn session left open (ended_at IS NULL) by an orchestrator crash is
|
||
|
|
excluded from usage summaries (they filter ended_at IS NOT NULL). The sweep
|
||
|
|
closes any open session whose agent is no longer running, leaving running
|
||
|
|
agents' sessions open for their live finalize.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import roboco.db.base as db_base
|
||
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
||
|
|
|
||
|
|
# select(orphans) then update(orphans) — two executes when work is done.
|
||
|
|
_SELECT_THEN_UPDATE_CALLS = 2
|
||
|
|
|
||
|
|
|
||
|
|
class _Rows:
|
||
|
|
"""Fake execute result holding the rows the sweep reads."""
|
||
|
|
|
||
|
|
def __init__(self, rows: list[Any]) -> None:
|
||
|
|
self._rows = rows
|
||
|
|
|
||
|
|
def scalars(self) -> Any:
|
||
|
|
class _S:
|
||
|
|
def __init__(self, rows: list[Any]) -> None:
|
||
|
|
self._rows = rows
|
||
|
|
|
||
|
|
def all(self) -> list[Any]:
|
||
|
|
return self._rows
|
||
|
|
|
||
|
|
return _S(self._rows)
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeSession:
|
||
|
|
def __init__(self, rows: list[Any] | None = None) -> None:
|
||
|
|
self._rows = rows or []
|
||
|
|
self.executed: list[Any] = []
|
||
|
|
self.committed = False
|
||
|
|
|
||
|
|
def __call__(self) -> _FakeSession:
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __aenter__(self) -> _FakeSession:
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __aexit__(self, *exc: object) -> None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
async def execute(self, stmt: Any) -> _Rows:
|
||
|
|
self.executed.append(stmt)
|
||
|
|
return _Rows(self._rows)
|
||
|
|
|
||
|
|
async def commit(self) -> None:
|
||
|
|
self.committed = True
|
||
|
|
|
||
|
|
|
||
|
|
def _open_session(slug: str) -> MagicMock:
|
||
|
|
row = MagicMock()
|
||
|
|
row.id = f"sess-{slug}"
|
||
|
|
row.agent_slug = slug
|
||
|
|
row.ended_at = None
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_sweep_closes_orphans_not_running(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||
|
|
# be-dev-1 still running (re-adopted); be-dev-2 dead -> orphan.
|
||
|
|
orch._instances = {"be-dev-1": MagicMock()}
|
||
|
|
orphan = _open_session("be-dev-2")
|
||
|
|
live = _open_session("be-dev-1")
|
||
|
|
fake_session = _FakeSession(rows=[orphan, live])
|
||
|
|
|
||
|
|
factory = MagicMock(return_value=fake_session)
|
||
|
|
monkeypatch.setattr(db_base, "get_session_factory", lambda: factory)
|
||
|
|
|
||
|
|
closed = await orch._reconcile_orphan_spawn_sessions()
|
||
|
|
|
||
|
|
assert closed == 1
|
||
|
|
assert len(fake_session.executed) == _SELECT_THEN_UPDATE_CALLS
|
||
|
|
assert fake_session.committed
|
||
|
|
assert live.ended_at is None # untouched (not in the update's where)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_sweep_no_op_when_no_open_sessions(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||
|
|
orch._instances = {}
|
||
|
|
fake_session = _FakeSession(rows=[])
|
||
|
|
monkeypatch.setattr(db_base, "get_session_factory", lambda: fake_session)
|
||
|
|
assert await orch._reconcile_orphan_spawn_sessions() == 0
|
||
|
|
assert not fake_session.committed
|