Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata. Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în lock, apoi pornea ca tur separat. Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin deschis, și al doilea mesaj intră în ACELAȘI tur. - `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume, drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry (max_live, reaper pe inactivitate, stop_all la shutdown) - `src/stream_json.py` — parser stream-json partajat cu `_run_claude`; pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe error_max_turns și depinde de asta) - `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc de 4 verificări copiate; repară și bug-ul preexistent prin care WhatsApp posta literal `__AUDIO__:/cale` - dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din decizie și cursa TOCTOU odată cu el - `/stop` oprește turul, nu sesiunea — active.json rămâne valid - rate limit prin proces persistent vine ca result.is_error, nu ca exit code; convertit înapoi în același RuntimeError, altfel fallback-ul local nu s-ar mai declanșa niciodată, în tăcere Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră într-un tur voice: împart același channel_id). Mesajele steered dintr-un tur care pică sunt re-livrate, nu pierdute. Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s, un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în [EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate fi refuzată ca prompt injection — pentru oprire folosește /stop. Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""Tests for src/claude_runner.py (steering — persistent Claude processes).
|
|
|
|
Fully offline: `tests/fake_claude.py` is a real subprocess (no mocked
|
|
`subprocess.Popen`) speaking a minimal stream-json dialect, so steering
|
|
timing over real OS pipes is genuinely exercised rather than assumed.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src import claude_runner, claude_session
|
|
from src.claude_runner import SteerStatus
|
|
|
|
FAKE_CLAUDE = Path(__file__).parent / "fake_claude.py"
|
|
|
|
|
|
def _wait_until(predicate, timeout=5.0, interval=0.02):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(interval)
|
|
raise AssertionError("condition not met within timeout")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_registry():
|
|
"""H2: the registry is module-level global state (like
|
|
claude_session._session_locks) — clear it around every test so fake
|
|
processes from one test never leak into the next."""
|
|
claude_runner.reset_registry_for_tests()
|
|
yield
|
|
claude_runner.reset_registry_for_tests()
|
|
|
|
|
|
@pytest.fixture
|
|
def make_proc(monkeypatch):
|
|
"""Factory for a ClaudeProcess wired to tests/fake_claude.py, with
|
|
automatic cleanup of every process it creates."""
|
|
created: list[claude_runner.ClaudeProcess] = []
|
|
monkeypatch.setattr(claude_runner, "CLAUDE_BIN", str(FAKE_CLAUDE))
|
|
|
|
def _factory(scenario: str, channel_id: str = "test-channel",
|
|
session_id_env: str = "fake-session-1", model: str = "sonnet"):
|
|
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", scenario)
|
|
monkeypatch.setenv("FAKE_CLAUDE_SESSION_ID", session_id_env)
|
|
proc = claude_runner.ClaudeProcess(channel_id, model=model)
|
|
created.append(proc)
|
|
return proc
|
|
|
|
yield _factory
|
|
|
|
for p in created:
|
|
try:
|
|
p.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T1 — normal turn shape + rate limit + timeout (Etapa 1 contract)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_turn_matches_run_claude_dict_shape(make_proc):
|
|
proc = make_proc("normal")
|
|
result = proc.run_turn("hello")
|
|
expected_keys = {
|
|
"result", "session_id", "usage", "total_cost_usd", "cost_usd",
|
|
"duration_ms", "num_turns", "intermediate_count", "subtype", "is_error",
|
|
}
|
|
assert set(result.keys()) == expected_keys
|
|
assert result["is_error"] is False
|
|
assert result["session_id"] == "fake-session-1"
|
|
|
|
|
|
def test_run_turn_wraps_external_content(make_proc):
|
|
"""T1 (-k external_content selects this test and the steer one below)."""
|
|
proc = make_proc("normal")
|
|
result = proc.run_turn("plain text from Marius")
|
|
assert "[EXTERNAL CONTENT]" in result["result"]
|
|
assert "[END EXTERNAL CONTENT]" in result["result"]
|
|
assert "plain text from Marius" in result["result"]
|
|
|
|
|
|
def test_timeout_raises_exact_message(make_proc):
|
|
proc = make_proc("timeout")
|
|
with pytest.raises(TimeoutError, match=r"Claude CLI timed out after 1s"):
|
|
proc.run_turn("hi", timeout=1)
|
|
|
|
|
|
def test_rate_limit_raises_runtime_error_router_can_detect(make_proc):
|
|
"""T2/C4/T6: the exact failure mode that must keep `_local_fallback_reply`
|
|
alive — router.py/scheduler.py both gate on `is_rate_limit_error`."""
|
|
proc = make_proc("rate_limit")
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
proc.run_turn("hi")
|
|
assert claude_session.is_rate_limit_error(str(excinfo.value))
|
|
|
|
|
|
def test_non_rate_limit_error_is_returned_not_raised(make_proc):
|
|
"""C4: the parser/wrapper must stay non-throwing for any `is_error` that
|
|
isn't a rate limit (PlanningSession-style callers retry on subtype)."""
|
|
proc = make_proc("generic_error")
|
|
result = proc.run_turn("hi") # must NOT raise
|
|
assert result["is_error"] is True
|
|
assert result["subtype"] == "error_max_turns"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Steering — mid-turn message lands on the same stdin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_steering_mid_turn_reflects_second_message(make_proc):
|
|
proc = make_proc("steer")
|
|
texts: list[str] = []
|
|
holder: dict = {}
|
|
|
|
def _go():
|
|
holder["result"] = proc.run_turn("first message", on_text=texts.append)
|
|
|
|
t = threading.Thread(target=_go)
|
|
t.start()
|
|
# Wait for the fake's first assistant block — proves it's now blocked
|
|
# on its own stdin.readline(), genuinely waiting for the steer.
|
|
_wait_until(lambda: len(texts) >= 1)
|
|
assert proc.inflight is True
|
|
|
|
outcome = proc.steer("second message")
|
|
t.join(timeout=5)
|
|
|
|
assert outcome.status == SteerStatus.STEERED
|
|
assert outcome.turn is None
|
|
result = holder["result"]
|
|
assert "steered:" in result["result"]
|
|
assert result["num_turns"] == 2
|
|
assert proc.inflight is False
|
|
|
|
|
|
def test_steer_wraps_external_content(make_proc):
|
|
"""T1 (-k external_content)."""
|
|
proc = make_proc("steer")
|
|
texts: list[str] = []
|
|
holder: dict = {}
|
|
|
|
def _go():
|
|
holder["result"] = proc.run_turn("first", on_text=texts.append)
|
|
|
|
t = threading.Thread(target=_go)
|
|
t.start()
|
|
_wait_until(lambda: len(texts) >= 1)
|
|
proc.steer("stai, schimbare de plan")
|
|
t.join(timeout=5)
|
|
|
|
assert "[EXTERNAL CONTENT]" in holder["result"]["result"]
|
|
assert "stai, schimbare de plan" in holder["result"]["result"]
|
|
|
|
|
|
def test_steer_pending_texts_available_on_turn_failure(make_proc):
|
|
"""C3: if the turn a steer landed in fails, the steered text must be
|
|
exposed for re-dispatch, never silently dropped."""
|
|
proc = make_proc("timeout") # sleeps forever after init — never sends a result
|
|
|
|
def _go():
|
|
try:
|
|
proc.run_turn("first", timeout=1)
|
|
except TimeoutError:
|
|
pass
|
|
|
|
t = threading.Thread(target=_go)
|
|
t.start()
|
|
# session_id arrives (via the init event) right after spawn, well
|
|
# before the fake's 600s sleep would ever resolve — a reliable
|
|
# "the turn is genuinely in flight" signal for this scenario.
|
|
_wait_until(lambda: proc.session_id == "fake-session-1")
|
|
assert proc.inflight is True
|
|
|
|
outcome = proc.steer("please don't get lost")
|
|
assert outcome.status == SteerStatus.STEERED
|
|
t.join(timeout=5) # watchdog kills the process after 1s -> TimeoutError
|
|
|
|
pending = proc.pop_pending_steers()
|
|
assert pending == ["please don't get lost"]
|
|
# popped once -> empty on a second call
|
|
assert proc.pop_pending_steers() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1 — RAN_AS_TURN: steer() finds no in-flight turn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_steer_without_inflight_turn_becomes_ran_as_turn(make_proc):
|
|
proc = make_proc("normal")
|
|
first = proc.run_turn("warm up")
|
|
assert first["is_error"] is False
|
|
assert proc.inflight is False
|
|
|
|
outcome = proc.steer("out of band message")
|
|
|
|
assert outcome.status == SteerStatus.RAN_AS_TURN
|
|
assert outcome.turn is not None
|
|
assert "out of band message" in outcome.turn["result"]
|
|
# D4: the caller must treat `turn` as the response and never re-dispatch
|
|
# by calling run_turn() again for the same text — nothing to assert
|
|
# mechanically here beyond the API shape making that the only sane path.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T7 — BrokenPipeError on steer falls back to a normal turn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_steer_broken_pipe_falls_back_to_turn(make_proc, monkeypatch):
|
|
proc = make_proc("normal")
|
|
proc.run_turn("warm up") # spawns the process
|
|
proc.proc.kill()
|
|
proc.proc.wait()
|
|
|
|
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal")
|
|
outcome = proc.steer("please deliver me")
|
|
|
|
assert outcome.status == SteerStatus.PROCESS_DEAD
|
|
assert outcome.turn is not None
|
|
assert "please deliver me" in outcome.turn["result"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# M2 — timeout kills the process; next turn respawns with --resume
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_respawn_with_resume_after_timeout_kill(make_proc, monkeypatch):
|
|
proc = make_proc("timeout")
|
|
with pytest.raises(TimeoutError):
|
|
proc.run_turn("hi", timeout=1)
|
|
|
|
# session_id came from the `system`/`init` event, captured before the
|
|
# turn ever got its (never-arriving) result line.
|
|
assert proc.session_id == "fake-session-1"
|
|
assert not proc.alive()
|
|
dead_pid = proc.proc.pid
|
|
|
|
cmd = proc._build_cmd()
|
|
assert "--resume" in cmd
|
|
assert cmd[cmd.index("--resume") + 1] == "fake-session-1"
|
|
|
|
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal")
|
|
result = proc.run_turn("again", timeout=5)
|
|
|
|
assert result["is_error"] is False
|
|
assert proc.proc.pid != dead_pid # actually respawned, not reused
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T3 — stderr pipe must never deadlock a turn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_big_stderr_does_not_deadlock(make_proc):
|
|
proc = make_proc("big_stderr")
|
|
result = proc.run_turn("hello", timeout=15)
|
|
assert result["is_error"] is False
|
|
assert len(proc._stderr_buf) > 0
|
|
assert len(proc._stderr_buf) <= 50 # deque(maxlen=50)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RunnerRegistry — max_live degradation, reaper resilience
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_max_live_degrades_second_channel_to_one_shot():
|
|
registry = claude_runner.RunnerRegistry(max_live=1, idle_minutes=20)
|
|
proc_a = registry.get("channel-a")
|
|
assert proc_a is not None
|
|
proc_b = registry.get("channel-b")
|
|
assert proc_b is None # T10: degrade, don't evict (X6) and don't block
|
|
# the same channel is never punished by its own occupied slot:
|
|
assert registry.get("channel-a") is proc_a
|
|
registry.stop_all()
|
|
|
|
|
|
def test_reaper_never_stops_inflight_process():
|
|
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
|
|
proc = registry.get("channel-a")
|
|
proc.inflight = True
|
|
proc.last_active = time.monotonic() - 3600
|
|
registry._reap_once()
|
|
assert registry.live_count() == 1
|
|
registry.stop_all()
|
|
|
|
|
|
def test_reaper_stops_idle_process():
|
|
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
|
|
proc = registry.get("channel-a")
|
|
proc.inflight = False
|
|
proc.last_active = time.monotonic() - 3600
|
|
registry._reap_once()
|
|
assert registry.live_count() == 0
|
|
|
|
|
|
def test_reaper_survives_stop_exception(monkeypatch):
|
|
"""T11: a dead reaper is a silent RAM leak — one process's stop()
|
|
raising must not kill the reap loop or the process."""
|
|
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
|
|
proc = registry.get("channel-a")
|
|
proc.inflight = False
|
|
proc.last_active = time.monotonic() - 3600
|
|
|
|
def _boom():
|
|
raise RuntimeError("stop() exploded")
|
|
|
|
monkeypatch.setattr(proc, "stop", _boom)
|
|
registry._reap_once() # must not raise
|
|
assert registry.live_count() == 0 # bookkeeping removed regardless
|
|
|
|
|
|
def test_stop_all_clears_registry(make_proc):
|
|
proc = make_proc("normal")
|
|
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=20)
|
|
registry._procs["test-channel"] = proc
|
|
proc.run_turn("hello") # actually spawns
|
|
assert proc.alive()
|
|
registry.stop_all()
|
|
assert registry.live_count() == 0
|
|
assert not proc.alive()
|
|
|
|
|
|
def test_get_registry_singleton_and_reset():
|
|
r1 = claude_runner.get_registry(max_live=3, idle_minutes=5)
|
|
r2 = claude_runner.get_registry(max_live=99) # ignored once created
|
|
assert r1 is r2
|
|
assert r1.max_live == 3
|
|
|
|
claude_runner.reset_registry_for_tests()
|
|
|
|
r3 = claude_runner.get_registry(max_live=7)
|
|
assert r3 is not r1
|
|
assert r3.max_live == 7
|
|
|
|
|
|
def test_max_live_non_positive_logs_and_always_degrades(caplog):
|
|
with caplog.at_level("WARNING"):
|
|
registry = claude_runner.RunnerRegistry(max_live=0, idle_minutes=20)
|
|
assert any("max_live" in rec.message for rec in caplog.records)
|
|
assert registry.get("any-channel") is None
|