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

@@ -87,6 +87,54 @@ class TestStatus:
assert "WA Bridge: ONLINE" in out
# ---------------------------------------------------------------------------
# eco status — steering line (T4/A3)
# ---------------------------------------------------------------------------
class TestSteeringStatusLine:
def _mock_service(self, active="inactive"):
return {"ActiveState": active, "MainPID": "0", "ActiveEnterTimestamp": ""}
def test_steering_off_no_procs(self, iso, capsys):
"""pgrep's own no-match case: exit code 1, stdout '0' — a
legitimate zero, not a counting failure."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
mock_pgrep = MagicMock(returncode=1, stdout="0\n")
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", return_value=mock_pgrep):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: off · 0 procese vii" in out
def test_steering_counts_live_procs(self, iso, capsys):
"""A real match: pgrep -fc finds 2 live steering processes."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = True
mock_pgrep = MagicMock(returncode=0, stdout="2\n")
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", return_value=mock_pgrep):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: on · 2 procese vii" in out
def test_survives_pgrep_failure(self, iso, capsys):
"""D1/D3 guard: a failed process-table scan must not break the
rest of `eco status`, and must not silently read as a wrong zero."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", side_effect=OSError("pgrep not found")):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: off · necunoscut procese vii" in out
assert "Sessions:" in out
# ---------------------------------------------------------------------------
# cmd_doctor
# ---------------------------------------------------------------------------
@@ -194,6 +242,22 @@ class TestDoctor:
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "Claude CLI functional" in out
def test_steering_off_by_default(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
with patch("src.config.Config", return_value=mock_cfg):
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "[PASS] Steering (optional, currently off)" in out
def test_steering_checks_stream_json_when_enabled(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
mock_cfg = MagicMock()
mock_cfg.get.return_value = True
with patch("src.config.Config", return_value=mock_cfg):
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "Claude CLI supports --input-format stream-json" in out
# ---------------------------------------------------------------------------
# cmd_restart