claire/tests/test_orchestrator_supervisor.py
autocommit 6d212b7dbe refactor(testing-test): ♻️ Update test imports to use claire instead of clare in package references
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-20 19:54:05 -07:00

193 lines
7.2 KiB
Python

"""Supervisor tests: `claire web` brings up the orchestrator on startup."""
from __future__ import annotations
import uuid as _uuid
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
import pytest
from claire.orchestrator import bootstrap
@dataclass
class _FakeSessionRow:
host: str
uuid: UUID
cwd: str
mtime_epoch: int
snippet: str = ""
@dataclass
class _FakeTmuxRow:
host: str
session_name: str
detail: str = ""
class _FakeRclaude:
"""Captures spawn calls and feeds list_sessions a scripted response."""
def __init__(self, *, rows_after_spawn: list[_FakeSessionRow] | None = None) -> None:
self._initial_rows: list[_FakeSessionRow] = []
self._post_spawn_rows: list[_FakeSessionRow] = rows_after_spawn or []
self._tmux_rows: list[_FakeTmuxRow] = []
self.spawn_calls: list[dict[str, str]] = []
self._spawned = False
def list_sessions(self) -> list[_FakeSessionRow]:
return self._post_spawn_rows if self._spawned else self._initial_rows
def list_tmux(self) -> list[_FakeTmuxRow]:
"""Mirror the production Rclaude.list_tmux — defaults to empty."""
return self._tmux_rows
def with_tmux_rows(self, rows: list[_FakeTmuxRow]) -> "_FakeRclaude":
self._tmux_rows = rows
return self
def spawn(self, *, host: str, cwd: str, mcp_config: str | None = None) -> str:
self.spawn_calls.append({"host": host, "cwd": cwd, "mcp_config": mcp_config or ""})
self._spawned = True
return f"claude-tester-{len(self.spawn_calls)}"
# Bootstrap also calls `.send()` to kick the new session so Claude
# flushes its JSONL — fake it as a recorded no-op.
def send(self, *, text: str, match: str, yes: bool = False, dry_run: bool = False): # noqa: ARG002
return None
# `.kill()` is used to recycle a stale orchestrator session.
def kill(self, *, match: str, yes: bool = True) -> str: # noqa: ARG002
self.kill_calls = getattr(self, "kill_calls", [])
self.kill_calls.append(match)
self._tmux_rows = [] # killed sessions vanish from the tmux roster
return "killed"
def with_initial_rows(self, rows: list[_FakeSessionRow]) -> "_FakeRclaude":
self._initial_rows = rows
return self
@pytest.fixture
def isolated_cfg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
return tmp_path / "config" / "claire" / "claire.toml"
def test_ensure_running_spawns_when_unconfigured(isolated_cfg: Path) -> None:
new_uuid = _uuid.uuid4()
rcl = _FakeRclaude(rows_after_spawn=[
_FakeSessionRow(
host="local",
uuid=new_uuid,
cwd=str((Path.home() / ".local/share/claire/orchestrator").resolve()),
mtime_epoch=200,
),
])
result = bootstrap.ensure_running(rclaude=rcl, discover_timeout_s=2)
assert result == str(new_uuid)
assert len(rcl.spawn_calls) == 1
# Persisted to config.
from claire.config import load_or_init
assert load_or_init().orchestrator.session_uuid == str(new_uuid)
def test_ensure_running_noop_when_session_already_alive(isolated_cfg: Path) -> None:
"""A live tmux pane at the configured cwd is the source of truth.
The on-disk JSONL alone is no longer enough — apricot tmux can crash
leaving the JSONL behind, and the supervisor must detect that and
respawn. Liveness is checked against `list_tmux()`.
"""
import time as _time
existing = _uuid.uuid4()
bootstrap.write_session_uuid(str(existing))
# The cwd-slug `Users-natalie--local-share-claire-orchestrator` is the
# substring `_cwd_slug(_DEFAULT_CWD)` looks for in the tmux name. The
# trailing epoch must be RECENT so the age-recycle check treats it as
# fresh (an epoch of `1` would look ancient and trigger a recycle).
from claire.orchestrator.bootstrap import _DEFAULT_CWD, _cwd_slug
slug = _cwd_slug(str(_DEFAULT_CWD.expanduser().resolve()))
now_epoch = int(_time.time())
rcl = _FakeRclaude().with_tmux_rows([
_FakeTmuxRow(host="local", session_name=f"claude-tester-{slug}-{now_epoch}"),
])
result = bootstrap.ensure_running(rclaude=rcl, discover_timeout_s=1)
assert result == str(existing)
assert rcl.spawn_calls == [] # nothing spawned — already alive + fresh
def test_ensure_running_respawns_when_session_died(isolated_cfg: Path) -> None:
dead = _uuid.uuid4()
bootstrap.write_session_uuid(str(dead))
new_uuid = _uuid.uuid4()
rcl = _FakeRclaude(rows_after_spawn=[
_FakeSessionRow(
host="local",
uuid=new_uuid,
cwd=str((Path.home() / ".local/share/claire/orchestrator").resolve()),
mtime_epoch=300,
),
])
# list_sessions initially returns empty (dead session is gone).
result = bootstrap.ensure_running(rclaude=rcl, discover_timeout_s=2)
assert result == str(new_uuid)
assert len(rcl.spawn_calls) == 1
from claire.config import load_or_init
assert load_or_init().orchestrator.session_uuid == str(new_uuid)
def test_ensure_running_recycles_stale_session(isolated_cfg: Path) -> None:
"""An alive-but-too-old orchestrator session is killed + respawned.
Guards the 2026-05-20 regression: the orchestrator ran one session for
~12h, the rounds loop appended 1100+ turns, context bloated, and it
stopped replying. The supervisor must recycle past max_session_age_s.
"""
from claire.orchestrator.bootstrap import _DEFAULT_CWD, _cwd_slug
existing = _uuid.uuid4()
bootstrap.write_session_uuid(str(existing))
slug = _cwd_slug(str(_DEFAULT_CWD.expanduser().resolve()))
# tmux name with an ANCIENT epoch (year-2001-ish) → far past 6h cap.
new_uuid = _uuid.uuid4()
rcl = _FakeRclaude(rows_after_spawn=[
_FakeSessionRow(
host="local", uuid=new_uuid,
cwd=str(_DEFAULT_CWD.expanduser().resolve()), mtime_epoch=999,
),
]).with_tmux_rows([
_FakeTmuxRow(host="local", session_name=f"claude-natalie-{slug}-1000000000"),
])
result = bootstrap.ensure_running(rclaude=rcl, discover_timeout_s=2)
# The stale session was killed and a fresh one spawned + persisted.
assert getattr(rcl, "kill_calls", []) == [slug]
assert len(rcl.spawn_calls) == 1
assert result == str(new_uuid)
def test_ensure_running_returns_none_when_discovery_times_out(
isolated_cfg: Path,
) -> None:
# Spawn succeeds but rclaude never reports the new session.
rcl = _FakeRclaude(rows_after_spawn=[])
result = bootstrap.ensure_running(
rclaude=rcl, discover_timeout_s=0.1, # type: ignore[arg-type]
)
assert result is None
assert len(rcl.spawn_calls) == 1
def test_ensure_running_handles_rclaude_failure(isolated_cfg: Path) -> None:
from claire.rclaude import RclaudeError
class _FailingRcl(_FakeRclaude):
def spawn(self, *, host: str, cwd: str, mcp_config: str | None = None) -> str:
raise RclaudeError("rclaude not on PATH")
rcl = _FailingRcl()
result = bootstrap.ensure_running(rclaude=rcl, discover_timeout_s=1)
assert result is None