193 lines
7.2 KiB
Python
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
|