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:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
548
src/claude_runner.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
16
src/main.py
16
src/main.py
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
40
src/sentinels.py
Normal 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
153
src/stream_json.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user