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:
70
cli.py
70
cli.py
@@ -94,6 +94,7 @@ def cmd_status(args):
|
||||
print(f"WA Bridge: OFFLINE ({bridge_active})")
|
||||
|
||||
_print_session_count()
|
||||
print(_steering_status_line())
|
||||
|
||||
|
||||
def _print_session_count():
|
||||
@@ -103,6 +104,56 @@ def _print_session_count():
|
||||
print(f"Sessions: {count} active")
|
||||
|
||||
|
||||
def _count_steering_processes() -> int | None:
|
||||
"""Count live steering `claude` processes via the process table, not
|
||||
the in-process registry — `eco` is its own short-lived process, so
|
||||
`get_registry().live_count()` here would always be 0 regardless of what
|
||||
the running echo-core.service actually has live (a diagnostic that
|
||||
structurally cannot be non-zero is worse than none).
|
||||
|
||||
Works because `ClaudeProcess._build_cmd()` (claude_runner.py) always
|
||||
spawns with `--input-format stream-json`, and the one-shot path
|
||||
(`_run_claude` in claude_session.py) never passes that flag — so
|
||||
counting processes with it in their command line counts exactly the
|
||||
live steering procs, from any process, no shared state needed.
|
||||
|
||||
Returns None (unknown) only on a real failure to count. `pgrep`'s exit
|
||||
code 1 means zero matches, not an error — must not be treated as one.
|
||||
"""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-fc", "--", "--input-format stream-json"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode not in (0, 1):
|
||||
return None
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _steering_status_line() -> str:
|
||||
"""A3: 'steering: on/off · N procese vii'.
|
||||
|
||||
Flag read through `Config()` at call time (never cached at module load —
|
||||
the flag is meant to be flippable without a restart). Guarded (D1/D3):
|
||||
a counting failure must not break `eco status` — a diagnostics command
|
||||
that dies because the thing it diagnoses is broken is worthless.
|
||||
"""
|
||||
try:
|
||||
from src.config import Config
|
||||
enabled = bool(Config().get("steering.enabled", False))
|
||||
except Exception:
|
||||
enabled = False
|
||||
count = _count_steering_processes()
|
||||
count_str = "necunoscut" if count is None else str(count)
|
||||
return f"steering: {'on' if enabled else 'off'} · {count_str} procese vii"
|
||||
|
||||
|
||||
def _load_sessions_file() -> dict:
|
||||
"""Load sessions/active.json, return {} on any error."""
|
||||
try:
|
||||
@@ -325,6 +376,25 @@ def cmd_doctor(args):
|
||||
else:
|
||||
checks.append(("WhatsApp bridge (optional)", True))
|
||||
|
||||
# 13. Steering: verify the CLI binary supports stream-json input (D1) —
|
||||
# only meaningful if the feature is actually turned on.
|
||||
try:
|
||||
from src.config import Config
|
||||
steering_enabled = bool(Config().get("steering.enabled", False))
|
||||
except Exception:
|
||||
steering_enabled = False
|
||||
if steering_enabled and claude_found:
|
||||
try:
|
||||
help_out = subprocess.run(
|
||||
["claude", "--help"], capture_output=True, text=True, timeout=10,
|
||||
).stdout
|
||||
checks.append(("Claude CLI supports --input-format stream-json",
|
||||
"--input-format" in help_out))
|
||||
except Exception:
|
||||
checks.append(("Claude CLI supports --input-format stream-json", False))
|
||||
else:
|
||||
checks.append(("Steering (optional, currently off)", True))
|
||||
|
||||
# ---- Voice stack checks (Pas 10) ----
|
||||
checks.extend(_voice_doctor_checks())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user