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:
2026-09-02 11:05:58 +00:00
parent 2d8ee9b581
commit 747afbaf9d
23 changed files with 4183 additions and 95 deletions

View File

@@ -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"