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

@@ -29,6 +29,7 @@ from src.router import (
start_planning_session,
)
from src.adapters._text_chunks import split_message
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
from src.adapters.discord_views import (
RalphRootView,
PlanningActiveView,
@@ -1036,8 +1037,8 @@ def create_bot(config: Config) -> discord.Client:
total = len(chunks)
for i, chunk in enumerate(chunks, 1):
result = await asyncio.to_thread(fast_dispatch, "audio", [voice, chunk])
if result and result.startswith("__AUDIO__:"):
wav_path = result[len("__AUDIO__:"):]
wav_path = _sentinel_audio_path(result)
if wav_path:
ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
try:
ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
@@ -1066,8 +1067,8 @@ def create_bot(config: Config) -> discord.Client:
if rezumat:
args.append("rezumat")
result = await asyncio.to_thread(fast_dispatch, "audio", args)
if result and result.startswith("__AUDIO__:"):
wav_path = result[len("__AUDIO__:"):]
wav_path = _sentinel_audio_path(result)
if wav_path:
ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
try:
ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
@@ -1298,11 +1299,18 @@ def create_bot(config: Config) -> discord.Client:
adapter_name="discord",
)
# Steered: message was injected into an in-flight turn — react,
# post nothing.
if _sentinel_is_steered(response):
try:
await message.add_reaction("➡️")
except Exception:
logger.warning("steered reaction failed", exc_info=True)
# Only send the final combined response if no intermediates
# were delivered (avoids duplicating content).
if sent_count == 0:
if response.startswith("__AUDIO__:"):
wav_path = response[len("__AUDIO__:"):]
elif sent_count == 0:
wav_path = _sentinel_audio_path(response)
if wav_path:
await message.channel.send(
file=discord.File(wav_path, filename="echo-audio.wav")
)

View File

@@ -47,6 +47,7 @@ from src.router import (
start_planning_session,
)
from src.planning_session import is_in_planning
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
WORKSPACE_DIR = Path("/home/moltbot/workspace")
ADAPTER_NAME = "telegram"
@@ -747,8 +748,6 @@ async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# --- Audio helpers ---
_AUDIO_PREFIX = "__AUDIO__:"
async def _send_voice_telegram(update: Update, wav_path: str) -> None:
"""Convertește WAV→OGG (ffmpeg) și trimite ca voice note Telegram."""
@@ -787,8 +786,8 @@ async def _fast_cmd(update: Update, name: str, args: list[str]) -> None:
await update.message.chat.send_action(ChatAction.TYPING)
result = await asyncio.to_thread(fast_dispatch, name, args)
if result:
if result.startswith(_AUDIO_PREFIX):
wav_path = result[len(_AUDIO_PREFIX):]
wav_path = _sentinel_audio_path(result)
if wav_path:
await _send_voice_telegram(update, wav_path)
else:
for chunk in split_message(result):
@@ -1034,18 +1033,22 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
adapter_name=ADAPTER_NAME,
)
# Only send combined response if no intermediates were delivered
if sent_count == 0:
chunks = split_message(response)
for chunk in chunks:
await message.reply_text(chunk)
if _sentinel_is_steered(response):
# Injected into an in-flight turn — react, post nothing.
reaction = "➡️"
else:
# Only send combined response if no intermediates were delivered
if sent_count == 0:
chunks = split_message(response)
for chunk in chunks:
await message.reply_text(chunk)
reaction = "✅"
# Emoji reaction: ✅ = răspuns trimis
try:
await context.bot.set_message_reaction(
chat_id=chat_id,
message_id=message.message_id,
reaction=[ReactionTypeEmoji(emoji="✅")],
reaction=[ReactionTypeEmoji(emoji=reaction)],
)
except Exception:
pass

View File

@@ -1,13 +1,16 @@
"""WhatsApp adapter for Echo Core — connects to Node.js bridge."""
import asyncio
import base64
import logging
import os
import httpx
from src.config import Config
from src.router import route_message
from src.claude_session import clear_session, get_active_session
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
log = logging.getLogger("echo-core.whatsapp")
_security_log = logging.getLogger("echo-core.security")
@@ -104,6 +107,32 @@ async def send_whatsapp(client: httpx.AsyncClient, to: str, text: str) -> bool:
return False
async def send_whatsapp_audio(client: httpx.AsyncClient, to: str, wav_path: str) -> bool:
"""Send a WAV file via the bridge's /send-document endpoint.
The Baileys bridge has no dedicated voice-note (PTT) route, only
generic document upload — so this arrives as a playable attachment,
not a mic-bubble voice note. Good enough to not lose the audio.
"""
try:
with open(wav_path, "rb") as f:
data_b64 = base64.b64encode(f.read()).decode()
resp = await client.post(
f"{_bridge_url}/send-document",
json={
"to": to,
"filename": "echo-audio.wav",
"mimetype": "audio/wav",
"data_base64": data_b64,
},
timeout=30,
)
return resp.status_code == 200 and resp.json().get("ok", False)
except Exception as e:
log.error("Send audio error: %s", e)
return False
async def react_whatsapp(
client: httpx.AsyncClient, to: str, message_id: str, emoji: str,
*, from_me: bool = False, participant: str | None = None,
@@ -222,23 +251,36 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
)
sent_count += 1
steered = False
try:
response, _is_cmd = await asyncio.to_thread(
route_message, channel_id, user_id, text,
on_text=on_text,
adapter_name="whatsapp",
)
# Only send combined response if no intermediates were delivered
if sent_count == 0:
await send_whatsapp(client, sender, response)
if _sentinel_is_steered(response):
# Injected into an in-flight turn — react, post nothing.
steered = True
elif sent_count == 0:
# Only send combined response if no intermediates were delivered
wav_path = _sentinel_audio_path(response)
if wav_path:
if not await send_whatsapp_audio(client, sender, wav_path):
await send_whatsapp(client, sender, "Nu am putut trimite audio.")
try:
os.unlink(wav_path)
except OSError:
pass
else:
await send_whatsapp(client, sender, response)
except Exception as e:
log.error("Error handling message from %s: %s", user_id, e)
await send_whatsapp(client, sender, "Sorry, an error occurred.")
finally:
# Remove eyes reaction after responding
# Swap eyes for an arrow when steered, otherwise just clear it.
if message_id:
await react_whatsapp(
client, sender, message_id, "",
client, sender, message_id, "➡️" if steered else "",
from_me=from_me,
participant=msg.get("participant"),
)

548
src/claude_runner.py Normal file
View File

@@ -0,0 +1,548 @@
"""Persistent Claude CLI processes ("steering") for Echo-Core.
Why two paths exist
--------------------
Echo-Core has ALWAYS run the Claude CLI one-shot: `_run_claude` in
`claude_session.py` spawns `claude -p "<prompt>"`, reads the response, and
the process exits. That is simple and fine for a request/response turn, but
it means a second message sent while turn 1 is still running can't reach
Claude until turn 1 finishes — by then it's too late to say "wait, don't do
that".
`ClaudeProcess` below keeps one `claude` subprocess ALIVE per channel, with
stdin held open, so a second message can be written into the SAME turn
(`steer()`) instead of queueing behind it. `RunnerRegistry` owns the set of
live processes: a cap (`max_live`), an idle reaper, and shutdown.
`heartbeat.py` and `planning_session.py` deliberately stay on the one-shot
path (`_run_claude` / `start_session` / `resume_session`, unchanged) — there
is no human waiting to correct a 3am cron job or a planning conversation
mid-turn, so a live process there would only add RAM (measured 292-541 MB
per process) for a capability nobody uses. Only interactive chat channels
(Discord/Telegram/WhatsApp) route through this module, and only when
`steering.enabled` is on (that flag and the dispatch decision live in
`claude_session.py`/`router.py` — this module doesn't read config itself).
Manual repro recipe (T15): to exercise steering by hand, ask Echo for
something with a 30s+ `sleep` in Bash ("run `sleep 40 && echo done` then
tell me the weather"), then send a second message on the same channel
while it's still running. Watch for the "steered N chars" log line below.
Threading model
----------------
Thread-based, NOT asyncio: `send_message` (in `claude_session.py`) is sync
code called via `asyncio.to_thread` from the async adapters, so a second
event loop here would be unused complexity. Three kinds of threads touch a
single `ClaudeProcess`: the caller's own thread running `run_turn()`
(blocking read of stdout), a caller's thread calling `steer()` concurrently,
and the shared reaper thread. `_stdin_lock` is the single lock serializing
all of them around `self.inflight` and stdin writes — see `steer()`'s
docstring for the race it closes and the one it deliberately does not (and
cannot: it is an inter-process race, not a Python one).
"""
import atexit
import dataclasses
import enum
import json
import logging
import shutil
import subprocess
import threading
import time
from collections import deque
from pathlib import Path
from typing import Callable
from src.claude_session import (
CLAUDE_BIN,
DEFAULT_MODEL,
DEFAULT_TIMEOUT,
PROJECT_ROOT,
_safe_env,
build_system_prompt,
is_rate_limit_error,
)
from src.stream_json import consume_stream
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_LIVE_DEFAULT = 2 # measured 292-541 MB RSS per live `claude` process (8 GB host)
IDLE_MINUTES_DEFAULT = 20
# A2: `_safe_env()` checks this at spawn to decide whether to route through
# OpenRouter. On a one-shot process that's re-evaluated every turn; on a
# persistent one it would freeze at spawn time. Compared against its state
# each turn (see `_respawn_if_needed`) — the ONLY justified respawn-on-change
# (respawning on personality/ edits was explicitly rejected, see plan
# "Riscul #2").
OPENROUTER_SEMAPHORE = PROJECT_ROOT / ".use_openrouter"
def _wrap_external_content(text: str) -> str:
"""Same injection-protection wrapping `start_session`/`resume_session`
use — T1: a steered message MUST NOT bypass it just because it goes in
over stdin instead of argv (Section 3 finding S1, a security regression
otherwise)."""
return f"[EXTERNAL CONTENT]\n{text}\n[END EXTERNAL CONTENT]"
def _user_turn_line(text: str) -> str:
wrapped = _wrap_external_content(text)
return json.dumps(
{"type": "user", "message": {"role": "user", "content": wrapped}},
ensure_ascii=False,
)
# ---------------------------------------------------------------------------
# steer() outcome — X2: enum, not bool, so the fallback logic can't be
# skipped by accident.
# ---------------------------------------------------------------------------
class SteerStatus(enum.Enum):
STEERED = "steered"
# DANGER, not the safe case (C1): no turn was in flight when the write
# landed, so the CLI treated it as a brand-new turn N+1 whose stdout
# nobody else is reading. `SteerResult.turn` already holds that turn's
# full result — decision D4 ("consume the stream you just started").
RAN_AS_TURN = "ran_as_turn"
# T7: the stdin write itself raised (process was dead/dying). The text
# was re-dispatched as a fresh `run_turn()` (respawning if needed) —
# `SteerResult.turn` holds ITS result. Never lost, per CLAUDE.md's
# "turnul nu se pierde niciodată".
PROCESS_DEAD = "process_dead"
@dataclasses.dataclass
class SteerResult:
"""Return value of `ClaudeProcess.steer()`.
`turn` is populated for every status except STEERED, and IS the
response for that text — bundling it here (instead of a bare enum)
makes it structurally impossible for a caller to see a non-STEERED
status and call `run_turn(text)` again "just in case": that would
double-send the message and bill it twice.
"""
status: SteerStatus
turn: dict | None = None
# ---------------------------------------------------------------------------
# ClaudeProcess
# ---------------------------------------------------------------------------
class ClaudeProcess:
"""One live `claude` subprocess for a single channel."""
def __init__(self, channel_id: str, model: str = DEFAULT_MODEL,
session_id: str | None = None, cwd: Path | str | None = None):
self.channel_id = channel_id
self.model = model
self.session_id = session_id
self.cwd = cwd or PROJECT_ROOT
self.proc: subprocess.Popen | None = None
self.inflight = False
self.last_active = time.monotonic()
self._stdin_lock = threading.Lock()
self._lifecycle_lock = threading.Lock()
self._pending_steers: list[str] = []
self._stderr_buf: deque[str] = deque(maxlen=50)
self._openrouter_at_spawn = False
# -- lifecycle ---------------------------------------------------------
def alive(self) -> bool:
return self.proc is not None and self.proc.poll() is None
def _build_cmd(self) -> list[str]:
cmd = [
CLAUDE_BIN, "-p",
"--input-format", "stream-json",
"--output-format", "stream-json", "--verbose",
"--model", self.model,
"--system-prompt", build_system_prompt(),
"--dangerously-skip-permissions",
"--autocompact", "auto",
]
if self.session_id:
cmd += ["--resume", self.session_id]
return cmd
def _spawn(self) -> None:
if not shutil.which(CLAUDE_BIN):
raise FileNotFoundError(
"Claude CLI not found. "
"Install: https://docs.anthropic.com/en/docs/claude-code"
)
self._openrouter_at_spawn = OPENROUTER_SEMAPHORE.exists()
self.proc = subprocess.Popen(
self._build_cmd(),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # line-buffered: a steer() write must reach the CLI promptly
env=_safe_env(),
cwd=str(self.cwd),
)
self.inflight = False
self.last_active = time.monotonic()
self._stderr_buf.clear()
threading.Thread(target=self._drain_stderr, args=(self.proc,), daemon=True).start()
logger.info("channel=%s: spawned claude pid=%s resume=%s",
self.channel_id, self.proc.pid, bool(self.session_id))
def _drain_stderr(self, proc: subprocess.Popen) -> None:
"""T3: MANDATORY. An undrained stderr pipe fills (~64KB) and
deadlocks the child mid-turn with no exception on our side —
this thread is the only thing preventing that."""
try:
for line in proc.stderr:
self._stderr_buf.append(line.rstrip("\n"))
except (ValueError, OSError):
pass # pipe torn down under us — process is exiting, nothing left to drain
def _respawn_if_needed(self) -> None:
"""Call with `_lifecycle_lock` held."""
if self.alive() and OPENROUTER_SEMAPHORE.exists() != self._openrouter_at_spawn:
logger.info("channel=%s: .use_openrouter changed since spawn — respawning (A2)",
self.channel_id)
self.stop()
if not self.alive():
self._spawn()
def stop(self) -> None:
"""Terminate the process. Safe to call repeatedly / when already dead.
Takes `_stdin_lock` (E-C2): a concurrent `steer()` must never write
into a pipe we're closing underneath it.
"""
with self._stdin_lock:
proc = self.proc
if proc is None:
return
try:
proc.stdin.close()
except (BrokenPipeError, OSError, ValueError):
pass
try:
proc.terminate()
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
proc.kill()
proc.wait(timeout=3)
except OSError:
pass
except OSError:
pass
logger.info("channel=%s: process stopped", self.channel_id)
# -- turns ---------------------------------------------------------
def run_turn(self, text: str, on_text: Callable[[str], None] | None = None,
timeout: int = DEFAULT_TIMEOUT) -> dict:
"""Run one turn. Returns exactly the dict `_run_claude` returns, and
raises the exact same exceptions for the same reasons
(FileNotFoundError, TimeoutError, `RuntimeError("Claude CLI error
(exit 1): ...")` on a rate limit) so `router.py`'s existing handling
— including the local-fallback trigger — keeps working unchanged.
"""
with self._lifecycle_lock:
self._respawn_if_needed()
line = _user_turn_line(text)
with self._stdin_lock:
self.inflight = True
self._pending_steers = []
try:
self.proc.stdin.write(line + "\n")
self.proc.stdin.flush()
except (BrokenPipeError, ValueError, OSError) as exc:
self.inflight = False
raise RuntimeError(f"Claude CLI error: failed to start turn: {exc}") from exc
return self._read_until_result(on_text=on_text, timeout=timeout)
def steer(self, text: str) -> SteerResult:
"""Write *text* into the current in-flight turn's stdin, without
opening a new turn — the common case.
C1/E-C1: the only race a lock can close is between OUR threads —
`steer()` checks `self.inflight` under the same `_stdin_lock` that
`run_turn()`/`_read_until_result()` flips it under, so nothing here
can observe a stale `True`. What no lock can close is the CLI's own
internal turn boundary: it may have already finished turn N and
gone idle before our write physically reaches its stdin pipe, in
which case the CLI treats the write as opening turn N+1 regardless
of what we believed. That is `RAN_AS_TURN` — see decision D4 on
`SteerStatus` above. (A permanent stdout-owning reader thread would
close this residual race too; deferred as a documented upgrade, not
required for the accepted fix.)
"""
line = _user_turn_line(text)
wrote = False
became_new_turn = False
with self._stdin_lock:
was_inflight = self.inflight
if self.proc is not None:
try:
self.proc.stdin.write(line + "\n")
self.proc.stdin.flush()
wrote = True
except (BrokenPipeError, ValueError, OSError):
wrote = False
if wrote and was_inflight:
self._pending_steers.append(text)
elif wrote:
became_new_turn = True
self.inflight = True # we now own reading this orphaned turn
self._pending_steers = []
if wrote and was_inflight:
logger.info("channel=%s: steered %d chars into in-flight turn",
self.channel_id, len(text))
return SteerResult(SteerStatus.STEERED)
if became_new_turn:
logger.info(
"channel=%s: steer() found no in-flight turn — the write became "
"turn N+1, consuming its stream now (D4)", self.channel_id,
)
turn = self._read_until_result(on_text=None, timeout=DEFAULT_TIMEOUT)
return SteerResult(SteerStatus.RAN_AS_TURN, turn=turn)
# T7: the write itself failed — process dead/dying. Never lose the
# message: re-dispatch as a fresh (respawning) turn.
logger.info("channel=%s: steer() hit a dead process — falling back to run_turn (T7)",
self.channel_id)
turn = self.run_turn(text)
return SteerResult(SteerStatus.PROCESS_DEAD, turn=turn)
def pop_pending_steers(self) -> list[str]:
"""C3: texts steered into the turn that just failed (rate limit,
timeout, crash) — the caller MUST re-dispatch them ("turnul nu se
pierde niciodată"). Empty once the turn they belonged to succeeds."""
pending, self._pending_steers = self._pending_steers, []
return pending
def _read_until_result(self, on_text: Callable[[str], None] | None,
timeout: int) -> dict:
proc = self.proc
timed_out = threading.Event()
def _watchdog():
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out.set()
try:
proc.kill() # M2: next run_turn() respawns with --resume
except OSError:
pass
watchdog = threading.Thread(target=_watchdog, daemon=True)
watchdog.start()
def _on_init(sid: str) -> None:
self.session_id = sid
try:
result = consume_stream(proc.stdout, on_text=on_text, on_init=_on_init)
finally:
with self._stdin_lock:
self.inflight = False
self.last_active = time.monotonic()
if timed_out.is_set():
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
if result is None:
stderr_tail = "\n".join(self._stderr_buf)[-500:]
raise RuntimeError(
f"Claude CLI error: no result line in stream. stderr: {stderr_tail}"
)
if result.get("session_id"):
# E-T4: refresh every turn, not just the first — a respawn after
# turn 5 must --resume turn 5's session, not turn 1's.
self.session_id = result["session_id"]
if result.get("is_error"):
detail = result.get("result", "")
if is_rate_limit_error(detail):
# T2/C4: persistent process surfaces a rate limit as
# `result.is_error`, never a nonzero exit code. Must raise
# this EXACT phrasing or `is_rate_limit_error()` in
# router.py/scheduler.py won't recognize it and the local
# fallback never fires.
raise RuntimeError(f"Claude CLI error (exit 1): {detail}")
# any other is_error: return it, don't raise (C4 — parser and
# this wrapper both stay non-throwing for non-rate-limit errors)
self._pending_steers = []
return result
# ---------------------------------------------------------------------------
# RunnerRegistry
# ---------------------------------------------------------------------------
class RunnerRegistry:
"""Owns the set of live `ClaudeProcess` instances, one per channel.
Module-level global state (like `claude_session._session_locks`) — see
`get_registry()` below. X6: no LRU eviction; at `max_live` capacity a
new channel simply degrades to the one-shot path (T10/M1) rather than
evicting someone else's live process.
"""
def __init__(self, max_live: int = MAX_LIVE_DEFAULT,
idle_minutes: int = IDLE_MINUTES_DEFAULT,
reap_interval_s: float = 60.0):
if max_live <= 0:
# X7: don't fail silently — a mis-set config value should be
# loud in the logs, even though the *behavior* (always degrade)
# is already correct without special-casing it below.
logger.warning(
"steering.max_live=%d <= 0 — steering effectively disabled, "
"every channel degrades to one-shot", max_live,
)
self.max_live = max_live
self.idle_minutes = max(1, idle_minutes) # X7: clamp, never "reap instantly"
self._procs: dict[str, ClaudeProcess] = {}
self._lock = threading.Lock()
self._reap_interval_s = reap_interval_s
self._stopping = threading.Event()
self._reaper = threading.Thread(target=self._reap_loop, daemon=True)
self._reaper.start()
atexit.register(self.stop_all)
def get(self, channel_id: str, model: str = DEFAULT_MODEL,
session_id: str | None = None, cwd: Path | str | None = None) -> ClaudeProcess | None:
"""Return the channel's live process, creating one if under
`max_live`. Returns None (T10) when at capacity and this channel
doesn't already have one — the caller must degrade to one-shot.
An existing entry for *channel_id* is always returned as-is,
regardless of capacity — which is also what rules out the M1
"two writers on one session_id" scenario: a channel already
holding a live process can never be told to fall back to one-shot
by this method.
"""
with self._lock:
proc = self._procs.get(channel_id)
if proc is not None:
return proc
if len(self._procs) >= self.max_live:
logger.info(
"channel=%s: max_live=%d reached, degrading to one-shot",
channel_id, self.max_live,
)
return None
proc = ClaudeProcess(channel_id, model=model, session_id=session_id, cwd=cwd)
self._procs[channel_id] = proc
return proc
def stop(self, channel_id: str) -> bool:
with self._lock:
proc = self._procs.pop(channel_id, None)
if proc is None:
return False
proc.stop()
return True
def stop_all(self) -> None:
"""Shutdown path (T4/A1) — zero orphaned `claude` processes after a
`systemctl restart`. Registered with `atexit` in `__init__`; a
caller in `main.py` should also invoke this explicitly on SIGTERM
for a synchronous shutdown (atexit is the fallback, not the primary
path)."""
self._stopping.set()
with self._lock:
procs = list(self._procs.values())
self._procs.clear()
for proc in procs:
try:
proc.stop()
except Exception:
logger.exception("channel=%s: error stopping during stop_all", proc.channel_id)
def live_count(self) -> int:
with self._lock:
return len(self._procs)
# -- reaper ---------------------------------------------------------
def _reap_loop(self) -> None:
while not self._stopping.wait(self._reap_interval_s):
self._reap_once()
def _reap_once(self) -> None:
"""T11: resilient to any single process's `stop()` raising — the
loop (and the rest of the batch) must keep going, and every reap
must be logged (a silently-dead reaper is an invisible RAM leak)."""
cutoff = time.monotonic() - self.idle_minutes * 60
with self._lock:
candidates = [
cid for cid, p in self._procs.items()
if not p.inflight and p.last_active < cutoff
]
for cid in candidates:
try:
with self._lock:
proc = self._procs.get(cid)
# Re-check under lock: it may have gone inflight, been
# stopped, or been replaced since the snapshot above.
if proc is None or proc.inflight or proc.last_active >= cutoff:
continue
del self._procs[cid]
proc.stop()
logger.info("reaper: stopped idle channel=%s (idle >= %d min)",
cid, self.idle_minutes)
except Exception:
logger.exception("reaper: error stopping channel=%s — continuing", cid)
# ---------------------------------------------------------------------------
# Module-level singleton — mirrors `claude_session._session_locks`.
# ---------------------------------------------------------------------------
_registry: RunnerRegistry | None = None
_registry_lock = threading.Lock()
def get_registry(max_live: int = MAX_LIVE_DEFAULT,
idle_minutes: int = IDLE_MINUTES_DEFAULT) -> RunnerRegistry:
"""Lazy singleton. *max_live*/*idle_minutes* only apply on first call —
later calls just return the existing registry (config is read by
`router.py` per call and passed in here; this function doesn't re-read
config.json itself)."""
global _registry
if _registry is not None:
return _registry
with _registry_lock:
if _registry is None:
_registry = RunnerRegistry(max_live=max_live, idle_minutes=idle_minutes)
return _registry
def reset_registry_for_tests() -> None:
"""H2: tests must not leak live (fake) processes into each other. Stops
and drops the current singleton so the next `get_registry()` call
builds a fresh one."""
global _registry
with _registry_lock:
if _registry is not None:
_registry.stop_all()
_registry = None

View File

@@ -19,6 +19,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from src.stream_json import consume_stream
logger = logging.getLogger(__name__)
_invoke_log = logging.getLogger("echo-core.invoke")
_security_log = logging.getLogger("echo-core.security")
@@ -95,6 +97,53 @@ def _get_session_lock(channel_id: str) -> threading.Lock:
return _session_locks.setdefault(channel_id, threading.Lock())
# ---------------------------------------------------------------------------
# In-flight turn registry — for /stop
# ---------------------------------------------------------------------------
#
# Etapa 0 of the steering plan: `/stop` kills only the current turn's
# subprocess, not the session (sessions/active.json is untouched). Maps
# channel_id -> the live Popen for that channel's in-flight `_run_claude`
# call. Registered at process start, removed in `_run_claude`'s `finally`
# so it's cleaned up on every exit path (success, error, timeout).
_live_procs: dict[str, subprocess.Popen] = {}
_live_procs_lock = threading.Lock()
def stop_turn(channel_id: str) -> bool:
"""Kill the in-flight Claude CLI turn for *channel_id*, if any.
Returns True if a process was signalled, False if nothing was running.
Does NOT touch sessions/active.json — the session survives, only the
turn dies (killed proc makes _run_claude raise; route_message's
existing error handling reports that back to the user).
Must NOT acquire `_get_session_lock(channel_id)` — the turn thread
holds that lock for the whole call, so taking it here would deadlock.
"""
with _live_procs_lock:
proc = _live_procs.get(channel_id)
if proc is None or proc.poll() is not None:
return False
try:
proc.terminate()
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
proc.kill()
except OSError:
pass
except OSError:
pass
# Also remove here (not just in _run_claude's finally): the turn thread
# may take a moment to notice the process died, and a repeated /stop
# in that window should see "nothing running", not re-signal a dead proc.
with _live_procs_lock:
if _live_procs.get(channel_id) is proc:
del _live_procs[channel_id]
return True
PERSONALITY_FILES = [
"IDENTITY.md",
"SOUL.md",
@@ -286,6 +335,7 @@ def _run_claude(
timeout: int,
on_text: Callable[[str], None] | None = None,
cwd: Path | str | None = None,
channel_id: str | None = None,
) -> dict:
"""Run a Claude CLI command and return parsed output.
@@ -300,6 +350,10 @@ def _run_claude(
*cwd* — optional working directory override (default: PROJECT_ROOT).
Used by PlanningSession to scope the subprocess to ``~/workspace/<slug>/``
so artifacts land in the target repo.
*channel_id* — if given, registers the subprocess in `_live_procs` so
`/stop` can kill this turn. Callers that omit it (heartbeat, planning
sessions) simply aren't stoppable — unchanged behavior.
"""
if not shutil.which(CLAUDE_BIN):
raise FileNotFoundError(
@@ -316,6 +370,10 @@ def _run_claude(
cwd=str(cwd) if cwd else PROJECT_ROOT,
)
if channel_id is not None:
with _live_procs_lock:
_live_procs[channel_id] = proc
# Watchdog thread: kill the process if it exceeds the timeout
timed_out = threading.Event()
@@ -332,40 +390,18 @@ def _run_claude(
watchdog = threading.Thread(target=_watchdog, daemon=True)
watchdog.start()
# --- Parse stream-json output line by line ---
text_blocks: list[str] = []
result_obj: dict | None = None
intermediate_count = 0
# --- Parse stream-json output (T5: shared with ClaudeProcess via
# src.stream_json.consume_stream; see that module's docstring for its
# non-throwing contract — blocker C4. The raising below stays here. ---
try:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = obj.get("type")
if msg_type == "assistant":
message = obj.get("message", {})
for block in message.get("content", []):
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
text_blocks.append(text)
if on_text:
try:
on_text(text)
intermediate_count += 1
except Exception:
logger.exception("on_text callback error")
elif msg_type == "result":
result_obj = obj
result = consume_stream(proc.stdout, on_text=on_text)
finally:
# Deregister on every exit path (success, error, timeout) so a
# stale entry never outlives the process it points to.
if channel_id is not None:
with _live_procs_lock:
if _live_procs.get(channel_id) is proc:
del _live_procs[channel_id]
# Ensure process resources are cleaned up
proc.stdout.close()
try:
@@ -381,39 +417,24 @@ def _run_claude(
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
if proc.returncode != 0:
stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else ""
# Check if result_obj has an error
result_error = ""
if result_obj and result_obj.get("is_error"):
result_error = result_obj.get("result", "") or result_obj.get("error", "")
stdout_tail = (result or {}).get("result", "") or ""
result_error = stdout_tail if (result and result.get("is_error")) else ""
detail = stderr_output[:500] or result_error[:500] or stdout_tail[:500]
logger.error("Claude CLI stderr: %s", stderr_output[:1000])
logger.error("Claude CLI result_obj: %s", result_obj)
logger.error("Claude CLI result_obj: %s", result)
raise RuntimeError(
f"Claude CLI error (exit {proc.returncode}): {detail}"
)
if result_obj is None:
if result is None:
raise RuntimeError(
"Failed to parse Claude CLI output: no result line in stream"
)
combined_text = "\n\n".join(text_blocks) if text_blocks else result_obj.get("result", "")
return {
"result": combined_text,
"session_id": result_obj.get("session_id", ""),
"usage": result_obj.get("usage", {}),
"total_cost_usd": result_obj.get("total_cost_usd", 0),
"cost_usd": result_obj.get("cost_usd", 0),
"duration_ms": result_obj.get("duration_ms", 0),
"num_turns": result_obj.get("num_turns", 0),
"intermediate_count": intermediate_count,
# Surface subtype/is_error for callers that retry on `error_max_turns`
# (PlanningSession does this — spike findings recommended retry strategy).
"subtype": result_obj.get("subtype", ""),
"is_error": bool(result_obj.get("is_error", False)),
}
# `consume_stream` already returns exactly the dict shape below
# (result/session_id/usage/total_cost_usd/cost_usd/duration_ms/num_turns/
# intermediate_count/subtype/is_error) — nothing left to rebuild.
return result
# ---------------------------------------------------------------------------
@@ -497,7 +518,7 @@ def start_session(
]
_t0 = time.monotonic()
data = _run_claude(cmd, timeout, on_text=on_text)
data = _run_claude(cmd, timeout, on_text=on_text, channel_id=channel_id)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
for field in ("result", "session_id"):
@@ -543,11 +564,15 @@ def resume_session(
message: str,
timeout: int = DEFAULT_TIMEOUT,
on_text: Callable[[str], None] | None = None,
channel_id: str | None = None,
) -> str:
"""Resume an existing Claude session by ID. Returns response text.
If *on_text* is provided, each intermediate Claude text block is passed
to the callback as soon as it arrives.
*channel_id* — passed through to `_run_claude` so `/stop` can find this
turn's subprocess; optional (defaults to not stoppable).
"""
# Find channel/model for logging and model selection
sessions = _load_sessions()
@@ -571,7 +596,7 @@ def resume_session(
]
_t0 = time.monotonic()
data = _run_claude(cmd, timeout, on_text=on_text)
data = _run_claude(cmd, timeout, on_text=on_text, channel_id=channel_id)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
if not data.get("result"):
@@ -608,6 +633,268 @@ def resume_session(
return response_text
# ---------------------------------------------------------------------------
# Steering dispatcher (H1/C2/C3/M1) — see tasks/steering-plan.md.
#
# `claude_runner` imports FROM this module (parser, build_system_prompt,
# _safe_env) — importing it back at module level here would be a cycle, so
# every reference below is a lazy `from src import claude_runner` inside a
# function body, as the plan requires.
# ---------------------------------------------------------------------------
# C2: which adapter owns the turn currently dispatched on a channel. Voice
# and text share `channel_id` as session_key (router.py's `session_key =
# channel_id`) — without this a text message could steer into a live VOICE
# turn and its reply would be spoken aloud, never rendered as text. Plain
# dict; a stale/racy read only affects the rare tie-break in the tiny
# window between acquiring the lock and recording ownership below, which is
# dwarfed by real subprocess/IO latency.
_channel_adapter: dict[str, str] = {}
# Same three namespaces router.py's channel config lives in (Discord,
# Telegram, WhatsApp) — checked in order so a per-channel `steering` key
# works regardless of which adapter the channel_id came from.
_CHANNEL_NAMESPACES = ("channels", "telegram_channels", "whatsapp_channels")
def _channel_steering_override(config, channel_id: str) -> bool | None:
"""Etapa 6 (X16): `channels.<alias>.steering` (or its telegram/whatsapp
twins), matched by the channel's `id` field — same lookup router.py's
`_get_channel_config` uses. None if unset, so the caller falls through
to the global `steering.enabled` default."""
for namespace in _CHANNEL_NAMESPACES:
for ch in (config.get(namespace, {}) or {}).values():
if ch.get("id") == channel_id and "steering" in ch:
return bool(ch["steering"])
return None
def _steering_config(channel_id: str | None = None) -> tuple[bool, int, int]:
"""Read `steering.*` through `Config()` on EVERY call — never at import
time. A module-level read (like `ALLOWED_TOOLS` above) would mean the
flag can't be flipped without a restart, which defeats the point of a
rollback flag. `ECHO_STEERING=off` (kill switch, precedent: `CLAUDE_BIN`)
always wins over config.json. Returns (enabled, max_live, idle_minutes)
with the clamps already applied: `max_live <= 0` forces disabled;
`idle_minutes < 1` clamps to 1.
*channel_id* — if given, a per-channel `steering` override (see
`_channel_steering_override`) wins over the global `enabled` flag for
that one channel — the staged rollout the plan's Etapa 6 needs ("flip
on one channel, then global"). Unset on the channel -> global default.
"""
if os.environ.get("ECHO_STEERING", "").strip().lower() == "off":
logger.debug("steering: disabled by ECHO_STEERING=off kill switch")
return False, 0, 20
try:
from src.config import Config
full_cfg = Config()
cfg = full_cfg.get("steering", {}) or {}
except (OSError, ValueError):
logger.exception("steering: failed to read config.json — treating as disabled")
return False, 0, 20
enabled = bool(cfg.get("enabled", False))
if channel_id is not None:
override = _channel_steering_override(full_cfg, channel_id)
if override is not None:
logger.debug("channel=%s: per-channel steering override=%s", channel_id, override)
enabled = override
max_live = cfg.get("max_live", 2)
if not isinstance(max_live, int):
max_live = 0
idle_minutes = cfg.get("idle_minutes", 20)
if not isinstance(idle_minutes, int) or idle_minutes < 1:
idle_minutes = 1
if max_live <= 0 and enabled:
logger.warning("steering: max_live=%r <= 0 — disabling steering", max_live)
enabled = False
if not enabled:
logger.debug("steering: disabled by config")
return enabled, max_live, idle_minutes
def _persist_steering_session(channel_id: str, model: str, data: dict) -> None:
"""Persist sessions/active.json after a steering-dispatched turn — same
fields/update semantics `start_session`/`resume_session` already use, so
`/status`, `/model`, and a later one-shot fallback all keep working."""
usage = data.get("usage", {})
now = datetime.now(timezone.utc).isoformat()
sessions = _load_sessions()
existing = sessions.get(channel_id)
if existing is None:
sessions[channel_id] = {
"session_id": data.get("session_id", ""),
"model": model,
"created_at": now,
"last_message_at": now,
"message_count": 1,
"total_input_tokens": usage.get("input_tokens", 0),
"total_output_tokens": usage.get("output_tokens", 0),
"total_cost_usd": data.get("total_cost_usd", 0),
"duration_ms": data.get("duration_ms", 0),
"context_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
}
else:
existing["session_id"] = data.get("session_id") or existing.get("session_id", "")
existing["model"] = model
existing["last_message_at"] = now
existing["message_count"] = existing.get("message_count", 0) + 1
existing["total_input_tokens"] = existing.get("total_input_tokens", 0) + usage.get("input_tokens", 0)
existing["total_output_tokens"] = existing.get("total_output_tokens", 0) + usage.get("output_tokens", 0)
existing["total_cost_usd"] = existing.get("total_cost_usd", 0) + data.get("total_cost_usd", 0)
existing["duration_ms"] = existing.get("duration_ms", 0) + data.get("duration_ms", 0)
existing["context_tokens"] = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
_save_sessions(sessions)
_invoke_log.info(
"channel=%s model=%s steered=1 session=%s",
channel_id, model, sessions[channel_id]["session_id"][:8],
)
def _run_steering_turn(
proc, lock: threading.Lock, channel_id: str, message: str, model: str,
timeout: int, on_text: Callable[[str], None] | None, adapter_name: str,
) -> str:
"""Run *message* as a fresh turn on the already-acquired *lock*/*proc*.
Releases the lock on every exit path. On failure, any messages that got
steered into this same turn before it failed are left in
`proc._pending_steers` for `pop_pending_steers()` (C3) — router.py picks
them up from its exception handler and re-dispatches them, since their
own request threads already returned `__STEERED__` and are gone.
"""
_channel_adapter[channel_id] = adapter_name
try:
data = proc.run_turn(message, on_text=on_text, timeout=timeout)
except Exception:
lock.release()
raise
lock.release()
_persist_steering_session(channel_id, model, data)
return data["result"]
def _dispatch_steering(
channel_id: str,
message: str,
model: str,
timeout: int,
on_text: Callable[[str], None] | None,
adapter_name: str | None,
max_live: int,
idle_minutes: int,
) -> str | None:
"""Try to steer *message* into a live process for *channel_id*, or run
it as a fresh turn. Returns the response text, the `__STEERED__`
sentinel, or None to tell `send_message` to fall back to the unchanged
one-shot path (T10: registry is at capacity and this channel has no
live process of its own — the only case where that path is safe, see
M1 below).
T12: every branch logs why a message did or did not become a steer.
"""
from src import claude_runner
registry = claude_runner.get_registry(max_live=max_live, idle_minutes=idle_minutes)
session = get_active_session(channel_id)
session_id = session.get("session_id") if session else None
effective_model = model
if session is not None and session.get("model"):
effective_model = session["model"]
proc = registry.get(channel_id, model=effective_model, session_id=session_id)
if proc is None:
# M1: safe only because a channel that ALREADY has a live process
# is always returned as-is by `registry.get()`, regardless of
# capacity — so we only ever land here for a channel with no live
# process, meaning the one-shot fallback below is the sole writer.
logger.info(
"channel=%s: steering registry at capacity — degrading to one-shot (T10)",
channel_id,
)
return None
lock = _get_session_lock(channel_id)
caller_adapter = adapter_name or "echo"
if lock.acquire(blocking=False):
# H1: acquiring the lock IS "no turn is running" — no separate
# `inflight` flag/check, no TOCTOU between them.
logger.debug("channel=%s: lock free — running as a full turn (not a steer)", channel_id)
return _run_steering_turn(
proc, lock, channel_id, message, effective_model, timeout, on_text, caller_adapter,
)
owner = _channel_adapter.get(channel_id)
if owner is not None and owner != caller_adapter:
# C2: never steer a message from a different adapter into someone
# else's in-flight turn (a text message hijacking a live voice
# turn, or vice versa — they share `channel_id`). M1 rules out
# falling back to the one-shot `resume_session` path here too: this
# channel already has a live process holding the session, and a
# concurrent one-shot `--resume` would be a second writer on the
# same session_id. So: wait for the current turn's lock like today,
# then take our turn through the SAME live process.
logger.info(
"channel=%s: turn owned by adapter=%s, message from adapter=%s — "
"waiting instead of steering (C2), staying on steering process (M1)",
channel_id, owner, caller_adapter,
)
lock.acquire(blocking=True)
return _run_steering_turn(
proc, lock, channel_id, message, effective_model, timeout, on_text, caller_adapter,
)
result = proc.steer(message)
if result.status is claude_runner.SteerStatus.STEERED:
logger.info("channel=%s: steered %d chars (adapter=%s)",
channel_id, len(message), caller_adapter)
from src.sentinels import STEERED
return STEERED
# RAN_AS_TURN / PROCESS_DEAD: `result.turn` IS the response for this
# text (D4/T7) — never re-run it, that would double-send and double-bill.
logger.info("channel=%s: steer() resolved as %s — using its turn result directly",
channel_id, result.status.value)
_persist_steering_session(channel_id, effective_model, result.turn)
return result.turn["result"]
def pop_pending_steers(channel_id: str) -> list[str]:
"""C3 hook for router.py: texts steered into a turn on *channel_id*
that then failed (rate limit, timeout, crash) — the caller MUST
re-dispatch them, never drop them (CLAUDE.md: "turnul nu se pierde
niciodată"). Empty list if steering was never used on this process
(never spins up the registry as a side effect — that would be wrong
for a channel that never used steering) or the channel has no live
process, or nothing was pending.
"""
from src import claude_runner
registry = claude_runner._registry
if registry is None:
return []
proc = registry._procs.get(channel_id) # peek only — .get() would spawn a new one
if proc is None:
return []
return proc.pop_pending_steers()
def _stop_steering_process(channel_id: str) -> None:
"""T9: `/clear` and `/model` must not leave a live steering process
running with stale session/model config — it respawns on the next
turn (via `_respawn_if_needed`/`--resume`), picking up the new state.
No-op if steering was never used (doesn't spin up the registry)."""
from src import claude_runner
registry = claude_runner._registry
if registry is None:
return
if registry.stop(channel_id):
logger.info("channel=%s: stopped live steering process (T9)", channel_id)
def send_message(
channel_id: str,
message: str,
@@ -615,6 +902,7 @@ def send_message(
timeout: int = DEFAULT_TIMEOUT,
on_text: Callable[[str], None] | None = None,
voice_mode: bool = False,
adapter_name: str | None = None,
) -> str:
"""High-level convenience: auto start or resume based on channel state.
@@ -624,12 +912,35 @@ def send_message(
parallel — each holds its own lock. Lock is acquired blocking; we rely
on `timeout` (default 5 minutes) to bound the worst case rather than
a non-blocking acquire (loss of fairness vs adapter-side queueing).
*adapter_name* — steering dispatcher only (C2). Identifies which
adapter's turn is in flight so a message from a DIFFERENT adapter on
the same channel_id never gets steered into it. Ignored entirely when
steering is off — this whole path is then bit-for-bit what it always
was.
When `steering.enabled` is on (config.json, per-call — see
`_steering_config`), this dispatches through `_dispatch_steering`
first: it returns a response, the `__STEERED__` sentinel, or None to
fall through to the unchanged path below.
"""
enabled, max_live, idle_minutes = _steering_config(channel_id)
if enabled:
dispatched = _dispatch_steering(
channel_id, message, model, timeout, on_text, adapter_name,
max_live, idle_minutes,
)
if dispatched is not None:
return dispatched
with _get_session_lock(channel_id):
session = get_active_session(channel_id)
# Only resume if session has a valid session_id (not a pre-set model placeholder)
if session is not None and session.get("session_id"):
return resume_session(session["session_id"], message, timeout, on_text=on_text)
return resume_session(
session["session_id"], message, timeout,
on_text=on_text, channel_id=channel_id,
)
# Use model from pre-set session if available, otherwise use provided model
effective_model = model
if session is not None and session.get("model"):
@@ -643,6 +954,7 @@ def send_message(
def clear_session(channel_id: str) -> bool:
"""Remove a channel's session entry. Returns True if removed."""
_stop_steering_process(channel_id) # T9
sessions = _load_sessions()
if channel_id not in sessions:
return False
@@ -663,6 +975,7 @@ def set_session_model(channel_id: str, model: str) -> bool:
raise ValueError(
f"Invalid model '{model}'. Must be one of: {', '.join(sorted(VALID_MODELS))}"
)
_stop_steering_process(channel_id) # T9
sessions = _load_sessions()
if channel_id not in sessions:
return False

View File

@@ -21,6 +21,21 @@ PID_FILE = PROJECT_ROOT / "echo-core.pid"
LOG_DIR = PROJECT_ROOT / "logs"
def _stop_steering_registry(logger: logging.Logger) -> None:
"""T4: kill any live persistent `claude` processes before the process
exits. `RunnerRegistry.stop_all()` is already `atexit`-registered, but
`atexit` does NOT fire on SIGTERM — exactly how `systemctl --user
restart echo-core` stops this process — so it must also be called
explicitly from the shutdown path. A leaked live process is 292-541 MB
RSS on an 8 GB host. Imported lazily so a system without steering wired
up yet (or any import error in claude_runner) can't break shutdown."""
try:
from src.claude_runner import get_registry
get_registry().stop_all()
except Exception:
logger.exception("Error stopping claude_runner registry during shutdown")
def setup_logging():
LOG_DIR.mkdir(parents=True, exist_ok=True)
fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
@@ -148,6 +163,7 @@ def main():
loop.run_until_complete(scheduler.stop())
loop.run_until_complete(client.close())
finally:
_stop_steering_registry(logger)
PID_FILE.unlink(missing_ok=True)
logger.info("Echo Core shut down.")

View File

@@ -24,7 +24,10 @@ from src.claude_session import (
rate_limit_detail as _rate_limit_detail,
RATE_LIMIT_RE as _RATE_LIMIT_RE,
VALID_MODELS,
stop_turn,
pop_pending_steers as _pop_pending_steers,
)
from src.sentinels import is_steered as _is_steered
from src.jsonlock import read_locked, write_locked
from src.planning_orchestrator import PlanningOrchestrator
from src.planning_session import (
@@ -566,6 +569,11 @@ def route_message(
if text.lower() == "/status":
return _status(channel_id), True
if text.lower() == "/stop":
if stop_turn(channel_id):
return "⏹ Oprit.", True
return "Nu rulează nimic pe canalul ăsta.", True
if text.lower().startswith("/model"):
return _model_command(channel_id, text), True
@@ -602,15 +610,29 @@ def route_message(
try:
response = send_message(
session_key, claude_text, model=model, on_text=on_text,
voice_mode=voice_mode,
voice_mode=voice_mode, adapter_name=adapter_name,
)
if _is_steered(response):
# Same pattern as the existing __AUDIO__: sentinel — no
# _set_last_response, the adapter reacts instead of replying.
return response, False
_set_last_response(channel_id, response)
return response, False
except Exception as e:
log.error("Claude error for channel %s: %s", channel_id, e)
# C3: texts steered into this same turn before it failed — their own
# request threads already returned __STEERED__ and are gone, so the
# only way left to answer them is `on_text`, the same real-time
# channel already used for intermediate assistant text.
pending_steers = _pop_pending_steers(channel_id)
if _is_rate_limit_error(e):
log.warning("Rate limit detected for channel %s — trying local fallback", channel_id)
fallback = _local_fallback_reply(text, channel_id=channel_id)
for steered_text in pending_steers:
_redeliver_steered_reply(
steered_text, channel_id, on_text,
_local_fallback_reply(steered_text, channel_id=channel_id),
)
if fallback is not None:
_set_last_response(channel_id, fallback)
return fallback, False
@@ -622,9 +644,37 @@ def route_message(
"⚠️ Claude e la limită, iar modelul local nu a răspuns.\n"
f"{_rate_limit_detail(e)}"
), False
for steered_text in pending_steers:
_redeliver_steered_reply(steered_text, channel_id, on_text, f"Error: {e}")
return f"Error: {e}", False
def _redeliver_steered_reply(
steered_text: str,
channel_id: str,
on_text: Callable[[str], None] | None,
reply: str | None,
) -> None:
"""C3 — a message steered into a turn that then failed must still get
an answer. Its own request thread already returned `__STEERED__` and is
gone, so the only way left to reach the user is `on_text` (the same
real-time channel adapters already use for intermediate assistant
text). Logs instead of dropping silently when there's no `on_text` to
push through (T12 — "it ignored my message" must stay diagnosable)."""
if reply is None:
reply = "⚠️ Claude e la limită — mesajul tău steered nu a primit răspuns."
if on_text is None:
log.warning(
"channel=%s: steered message lost — no on_text to redeliver it: %r",
channel_id, steered_text[:80],
)
return
try:
on_text(reply)
except Exception:
log.exception("channel=%s: failed to redeliver steered reply via on_text", channel_id)
def _status(channel_id: str) -> str:
"""Build status message for a channel."""
session = get_active_session(channel_id)

40
src/sentinels.py Normal file
View File

@@ -0,0 +1,40 @@
"""Single place to recognise router sentinels.
``route_message()`` normally returns text to post back to the user. Two
special values are protocol instead of a reply, and every adapter must
catch them before sending anything:
- ``__AUDIO__:<path>`` — a TTS result; the payload is a WAV path to attach.
- ``__STEERED__`` — the message was injected into an in-flight turn; the
adapter should react (not reply) and post nothing.
Zero dependencies on adapters or router — importable standalone.
"""
from __future__ import annotations
AUDIO_PREFIX = "__AUDIO__:"
STEERED = "__STEERED__"
def audio_path(response: str | None) -> str | None:
"""Return the WAV path if `response` is an audio sentinel, else None."""
if response and response.startswith(AUDIO_PREFIX):
return response[len(AUDIO_PREFIX):]
return None
def is_steered(response: str | None) -> bool:
"""True if `response` is the steering-acknowledgement sentinel."""
return response == STEERED
if __name__ == "__main__":
assert audio_path("__AUDIO__:/tmp/x.wav") == "/tmp/x.wav"
assert audio_path("hello") is None
assert audio_path("") is None
assert audio_path(None) is None
assert is_steered("__STEERED__") is True
assert is_steered("__STEERED__ ") is False
assert is_steered("") is False
assert is_steered(None) is False
print("ok")

153
src/stream_json.py Normal file
View File

@@ -0,0 +1,153 @@
"""Pure stream-json parsing, shared by the one-shot (`_run_claude`) and
persistent (`ClaudeProcess`) read loops.
Leaf module: imports nothing from `claude_session.py` or `claude_runner.py`,
so neither can create an import cycle through it.
Contract (blocker C4 in tasks/steering-plan.md): this module NEVER raises on
`is_error` / `subtype`. It only parses stream-json events into the dict shape
`_run_claude` has always returned. `PlanningSession` depends on `_run_claude`
returning an error *result* rather than throwing (it retries on
`error_max_turns` — see `planning_session.py`'s `RETRY_MAX_TURNS`); any
future caller that wants to raise on a particular error (e.g. rate limits on
a persistent process, done in `claude_runner.ClaudeProcess.run_turn`) must
do so itself, on top of the dict this module returns.
"""
import json
import logging
from typing import Callable, Iterable
logger = logging.getLogger(__name__)
def parse_event(line: str) -> dict | None:
"""Parse one stream-json line into an event dict, or None if blank/invalid."""
line = line.strip()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return None
def is_init_event(event: dict) -> bool:
"""True for the `system`/`init` event a fresh Claude CLI process emits
once, at the start of its very first turn, carrying `session_id` before
any assistant text or the turn's `result` line exists.
Needed for `ClaudeProcess`: if a turn times out before its `result`
ever arrives, the session_id captured here is the only way the *next*
turn can `--resume` the same conversation (see plan finding M2) — the
one-shot path doesn't need this, since it always gets `session_id` off
the `result` line of a turn that, by definition, already completed.
"""
return event.get("type") == "system" and event.get("subtype") == "init"
def consume_stream(
lines: Iterable[str],
on_text: Callable[[str], None] | None = None,
on_init: Callable[[str], None] | None = None,
) -> dict | None:
"""Read stream-json events from *lines* until (and including) a `result`
event, then stop — never read past it.
Stopping exactly at the turn boundary is what lets a persistent process
reuse this for one turn at a time: reading further would eat the *next*
turn's events off the same stdout.
*on_text* is called with each intermediate assistant text block as soon
as it arrives (same as `_run_claude` today). *on_init* is called once,
with the session_id, when a `system`/`init` event is seen (may never
fire — the one-shot caller doesn't need it and passes None).
Returns the same dict `_run_claude` has always returned, or None if the
stream ended (EOF) without ever producing a `result` line — mirroring
`_run_claude`'s own `if result_obj is None: raise RuntimeError(...)`
check. Raising on that is the CALLER's job, not this function's (C4).
"""
text_blocks: list[str] = []
result_obj: dict | None = None
intermediate_count = 0
for raw_line in lines:
event = parse_event(raw_line)
if event is None:
continue
msg_type = event.get("type")
if msg_type == "assistant":
message = event.get("message", {})
for block in message.get("content", []):
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
text_blocks.append(text)
if on_text:
try:
on_text(text)
intermediate_count += 1
except Exception:
logger.exception("on_text callback error")
elif on_init is not None and is_init_event(event):
session_id = event.get("session_id")
if session_id:
try:
on_init(session_id)
except Exception:
logger.exception("on_init callback error")
elif msg_type == "result":
result_obj = event
break # never read past the turn boundary
if result_obj is None:
return None
combined_text = "\n\n".join(text_blocks) if text_blocks else result_obj.get("result", "")
return {
"result": combined_text,
"session_id": result_obj.get("session_id", ""),
"usage": result_obj.get("usage", {}),
"total_cost_usd": result_obj.get("total_cost_usd", 0),
"cost_usd": result_obj.get("cost_usd", 0),
"duration_ms": result_obj.get("duration_ms", 0),
"num_turns": result_obj.get("num_turns", 0),
"intermediate_count": intermediate_count,
"subtype": result_obj.get("subtype", ""),
"is_error": bool(result_obj.get("is_error", False)),
}
def demo() -> None:
"""Smallest runnable self-check (ponytail: no test framework needed)."""
lines = [
json.dumps({"type": "system", "subtype": "init", "session_id": "s1"}),
json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}),
json.dumps({"type": "result", "session_id": "s1", "result": "hi", "is_error": False}),
json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "SHOULD NOT APPEAR"}]}}),
]
seen_init = []
out = consume_stream(lines, on_init=seen_init.append)
assert out is not None
assert out["result"] == "hi"
assert out["session_id"] == "s1"
assert out["is_error"] is False
assert seen_init == ["s1"]
# No result line at all -> None, does not raise.
assert consume_stream([json.dumps({"type": "assistant", "message": {"content": []}})]) is None
# Blank/garbage lines are skipped, not fatal.
assert consume_stream(["", "not json", json.dumps({"type": "result", "result": "ok"})])["result"] == "ok"
print("stream_json self-check OK")
if __name__ == "__main__":
demo()

View File

@@ -34,6 +34,7 @@ from typing import Any, Callable, Optional
import numpy as np
from src.router import _strip_leading_voice_tokens
from src.sentinels import is_steered
from src.voice._discord_voice_adapter import AudioSink, VoiceData
from src.voice.voice_commands import detect_voice_change
@@ -434,7 +435,7 @@ class VoiceSession:
voice_stream_callback, # on_text
"discord-voice", # adapter_name
)
if isinstance(result, tuple) and result:
if isinstance(result, tuple) and result and not is_steered(result[0]):
response_text = _strip_leading_voice_tokens(result[0] or "")
except Exception as e: # noqa: BLE001
log.error("route_message voice path failed: %s", e)