feat(steering): mesaje mid-tur + /stop pe turul în zbor
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
This commit is contained in:
@@ -25,12 +25,14 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import claude_session
|
||||
from src import claude_runner, claude_session
|
||||
from src.claude_runner import SteerResult, SteerStatus
|
||||
from src.claude_session import (
|
||||
_get_session_lock,
|
||||
_session_locks,
|
||||
send_message,
|
||||
)
|
||||
from src.sentinels import is_steered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,10 +42,19 @@ from src.claude_session import (
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_session_locks():
|
||||
"""Each test starts with a fresh lock map so we don't share state."""
|
||||
"""Each test starts with a fresh lock map so we don't share state.
|
||||
|
||||
H2: also resets the steering registry singleton and adapter-ownership
|
||||
map — both module-level globals — so a fake `ClaudeProcess` installed
|
||||
by one test (steering-ON variants below) never leaks into the next.
|
||||
"""
|
||||
_session_locks.clear()
|
||||
claude_session._channel_adapter.clear()
|
||||
claude_runner.reset_registry_for_tests()
|
||||
yield
|
||||
_session_locks.clear()
|
||||
claude_session._channel_adapter.clear()
|
||||
claude_runner.reset_registry_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -68,7 +79,7 @@ def _slow_run_claude(sleep_seconds: float, in_critical: threading.Event,
|
||||
"""
|
||||
state = {"active": 0, "lock": threading.Lock()}
|
||||
|
||||
def fake(cmd, timeout, on_text=None, cwd=None):
|
||||
def fake(cmd, timeout, on_text=None, cwd=None, channel_id=None):
|
||||
with state["lock"]:
|
||||
state["active"] += 1
|
||||
if state["active"] > 1:
|
||||
@@ -268,7 +279,7 @@ class TestAcquisitionBehavior:
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def flaky(cmd, timeout, on_text=None, cwd=None):
|
||||
def flaky(cmd, timeout, on_text=None, cwd=None, channel_id=None):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise RuntimeError("simulated subprocess crash")
|
||||
@@ -305,3 +316,98 @@ class TestAcquisitionBehavior:
|
||||
)
|
||||
t.join(timeout=1.0)
|
||||
assert result_box == ["Hello from Claude!"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# H2 — twin of TestAcquisitionBehavior with steering ON: a contending
|
||||
# caller must STEER, not block-then-run. Pins that the flag actually
|
||||
# changes this contract (the flag-off tests above must stay unchanged).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSteeringProc:
|
||||
"""Minimal `ClaudeProcess` stand-in — just enough of the contract
|
||||
`_dispatch_steering` relies on (`inflight`, `run_turn`, `steer`,
|
||||
`pop_pending_steers`) to prove the dispatch decision, without a real
|
||||
subprocess (that's covered by tests/test_claude_runner.py)."""
|
||||
|
||||
def __init__(self):
|
||||
self.inflight = False
|
||||
self.session_id = "fake-sid"
|
||||
self.steer_calls: list[str] = []
|
||||
self._release = threading.Event()
|
||||
|
||||
def run_turn(self, text, on_text=None, timeout=300):
|
||||
self._release.clear()
|
||||
self.inflight = True
|
||||
released = self._release.wait(timeout=5.0)
|
||||
self.inflight = False
|
||||
assert released, "test never released the fake turn"
|
||||
return {
|
||||
"result": f"done:{text}", "session_id": self.session_id, "usage": {},
|
||||
"total_cost_usd": 0, "cost_usd": 0, "duration_ms": 1, "num_turns": 1,
|
||||
"intermediate_count": 0, "subtype": "success", "is_error": False,
|
||||
}
|
||||
|
||||
def steer(self, text):
|
||||
self.steer_calls.append(text)
|
||||
return SteerResult(SteerStatus.STEERED)
|
||||
|
||||
def pop_pending_steers(self):
|
||||
return []
|
||||
|
||||
def release(self):
|
||||
self._release.set()
|
||||
|
||||
|
||||
class _FakeSteeringRegistry:
|
||||
def __init__(self, proc, channel_id):
|
||||
self._procs = {channel_id: proc}
|
||||
|
||||
def get(self, channel_id, model=None, session_id=None, cwd=None):
|
||||
return self._procs.get(channel_id)
|
||||
|
||||
def stop(self, channel_id):
|
||||
return False
|
||||
|
||||
|
||||
class TestAcquisitionBehaviorSteering:
|
||||
"""Twin of `TestAcquisitionBehavior.test_contested_acquire_blocks_then_proceeds`
|
||||
with `steering.enabled` on: contention must STEER instead of blocking."""
|
||||
|
||||
def test_contested_acquire_steers_when_steering_enabled(
|
||||
self, temp_sessions, monkeypatch
|
||||
):
|
||||
proc = _FakeSteeringProc()
|
||||
registry = _FakeSteeringRegistry(proc, "ch-contend-steer")
|
||||
monkeypatch.setattr(claude_runner, "_registry", registry)
|
||||
monkeypatch.setattr(claude_runner, "get_registry", lambda **kw: registry)
|
||||
monkeypatch.setattr(claude_session, "_steering_config", lambda channel_id=None: (True, 2, 20))
|
||||
|
||||
outcome: dict[str, str] = {}
|
||||
|
||||
def run(label: str):
|
||||
outcome[label] = send_message(
|
||||
"ch-contend-steer", label, adapter_name="discord",
|
||||
)
|
||||
|
||||
t1 = threading.Thread(target=run, args=("first",))
|
||||
t1.start()
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline and not proc.inflight:
|
||||
time.sleep(0.01)
|
||||
assert proc.inflight, "first call never entered the turn"
|
||||
|
||||
t2 = threading.Thread(target=run, args=("second",))
|
||||
t2.start()
|
||||
t2.join(timeout=5.0)
|
||||
|
||||
assert is_steered(outcome["second"]), (
|
||||
"With steering on, a contended caller must STEER, not block."
|
||||
)
|
||||
assert proc.steer_calls == ["second"]
|
||||
|
||||
proc.release()
|
||||
t1.join(timeout=5.0)
|
||||
assert outcome["first"] == "done:first"
|
||||
|
||||
Reference in New Issue
Block a user