"""Proces `claude` PERSISTENT per fir, alimentat pe stdin cu --input-format stream-json. De ce persistent (T4): asa se poate face steering mid-tur -- un mesaj trimis in timpul unui tool call lung ajunge la model si schimba raspunsul final (verificat in plan). Totul e asyncio: NICIODATA readline blocant. stderr are task propriu si buffer circular. """ from __future__ import annotations import asyncio import collections import contextlib import json import logging import os import time import uuid from dataclasses import dataclass, field from typing import Any, AsyncIterator, Awaitable, Callable import config import stream as stream_mod log = logging.getLogger("discord-bridge.runner") # linii mari (tool results) -> limita generoasa pe StreamReader _READ_LIMIT = 16 * 1024 * 1024 STDERR_TAIL = 50 class TurnFailed(RuntimeError): """Turul nu s-a putut duce la capat (proces mort, EOF inainte de result).""" class TurnTimeout(TurnFailed): """Turul a depasit timpul maxim.""" @dataclass class SessionRestarted: """Eveniment sintetic: procesul murise, l-am repornit cu --resume.""" thread_id: str sid: str | None reason: str = "procesul claude murise" @property def text(self) -> str: return f"sesiune repornita ({self.reason})" @dataclass class TurnOutcome: result: stream_mod.Result | None restarted: bool = False events: list[Any] = field(default_factory=list) def build_cmd( claude_bin: str | list[str], model: str, sid: str | None = None, settings: str | os.PathLike | None = None, extra: tuple[str, ...] = (), ) -> list[str]: """Comanda exacta din plan; `--resume ` doar la respawn.""" base = [claude_bin] if isinstance(claude_bin, str) else list(claude_bin) cmd = base + [ "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions", ] if settings: cmd += ["--settings", os.fspath(settings)] cmd += ["--model", model, "--autocompact", "auto"] if sid: cmd += ["--resume", sid] cmd += list(extra) return cmd def user_message(content: str | list[dict]) -> str: blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content return json.dumps( {"type": "user", "message": {"role": "user", "content": blocks}}, ensure_ascii=False, ) class ClaudeProcess: """Un proces claude viu, legat de un fir Discord.""" def __init__( self, thread_id: str, cwd: str, model: str, sid: str | None = None, *, claude_bin: str | list[str] | None = None, settings: str | os.PathLike | None = None, env: dict[str, str] | None = None, on_pid: Callable[[str, int | None], Any] | None = None, ): self.thread_id = str(thread_id) self.cwd = cwd self.model = model self.sid = sid self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude") self.settings = settings self.env = env self.on_pid = on_pid self.proc: asyncio.subprocess.Process | None = None self.stderr_buf: collections.deque[str] = collections.deque(maxlen=STDERR_TAIL) self.last_active = time.monotonic() self.inflight = False self.started_turns = 0 self.restarts = 0 self._stderr_task: asyncio.Task | None = None self._parser = stream_mod.StreamParser() self._start_lock = asyncio.Lock() # --------------------------------------------------------------- proces @property def alive(self) -> bool: return self.proc is not None and self.proc.returncode is None @property def pid(self) -> int | None: return self.proc.pid if self.proc is not None else None async def start(self) -> None: async with self._start_lock: if self.alive: return cmd = build_cmd(self.claude_bin, self.model, self.sid, self.settings) log.info("pornesc claude pentru firul %s: %s", self.thread_id, " ".join(cmd)) env = dict(os.environ) # Lane B: hook-ul PreToolUse afla firul Discord din mediul procesului claude env["CLAUDE_DISCORD_THREAD_ID"] = self.thread_id if self.sid: env["CLAUDE_DISCORD_SESSION_ID"] = self.sid if self.env: env.update(self.env) self.proc = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=self.cwd, env=env, limit=_READ_LIMIT, ) self.last_active = time.monotonic() self._stderr_task = asyncio.create_task(self._pump_stderr()) if self.on_pid: res = self.on_pid(self.thread_id, self.proc.pid) if asyncio.iscoroutine(res): await res async def _pump_stderr(self) -> None: assert self.proc is not None and self.proc.stderr is not None try: async for raw in self.proc.stderr: line = raw.decode("utf-8", "replace").rstrip("\n") if line: self.stderr_buf.append(line) except (asyncio.CancelledError, ValueError): raise except Exception as exc: # pragma: no cover log.debug("stderr pump s-a oprit: %s", exc) def stderr_tail(self, n: int = STDERR_TAIL) -> list[str]: """Ultimele linii de stderr, pentru `!status`.""" return list(self.stderr_buf)[-n:] async def stop(self, grace: float = 5.0) -> None: task, self._stderr_task = self._stderr_task, None proc, self.proc = self.proc, None if proc is not None and proc.returncode is None: with contextlib.suppress(ProcessLookupError): if proc.stdin and not proc.stdin.is_closing(): proc.stdin.close() proc.terminate() try: await asyncio.wait_for(proc.wait(), grace) except (asyncio.TimeoutError, ProcessLookupError): with contextlib.suppress(ProcessLookupError): proc.kill() with contextlib.suppress(Exception): await proc.wait() if task is not None: task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await task if self.on_pid: res = self.on_pid(self.thread_id, None) if asyncio.iscoroutine(res): await res # ------------------------------------------------------------- mesaje async def send(self, content: str | list[dict]) -> None: """Trimite un mesaj de utilizator pe stdin (si mid-tur: asta e steering-ul).""" if not self.alive or self.proc is None or self.proc.stdin is None: raise TurnFailed("procesul claude nu e viu") self.proc.stdin.write((user_message(content) + "\n").encode("utf-8")) await self.proc.stdin.drain() self.last_active = time.monotonic() async def _stdout_lines(self) -> AsyncIterator[str]: assert self.proc is not None and self.proc.stdout is not None reader = self.proc.stdout while True: try: raw = await reader.readline() except ValueError: # linie peste limita: nu doboram turul log.warning("linie de stdout peste limita, ignorata") continue if not raw: return yield raw.decode("utf-8", "replace") async def run_turn( self, prompt: str | list[dict], on_event: Callable[[Any], Awaitable[None]] | None = None, timeout: float | None = None, ) -> TurnOutcome: """Un tur complet: (re)porneste procesul daca trebuie, trimite promptul, consuma stream-ul pana la `result`.""" restarted = False if not self.alive: if self.started_turns: restarted = True self.restarts += 1 await self.start() if timeout is None: timeout = config.get_float("TURN_TIMEOUT_S", 900.0) self.started_turns += 1 self.inflight = True events: list[Any] = [] try: if restarted: ev = SessionRestarted(self.thread_id, self.sid) events.append(ev) if on_event: await on_event(ev) await self.send(prompt) outcome = await asyncio.wait_for(self._consume(on_event, events), timeout) return TurnOutcome(result=outcome, restarted=restarted, events=events) except asyncio.TimeoutError: await self.stop() raise TurnTimeout(f"turul a depasit {timeout:.0f}s si a fost oprit") from None except stream_mod.StreamEOFError as exc: tail = "\n".join(self.stderr_tail(10)) await self.stop() raise TurnFailed(f"{exc}; stderr:\n{tail}") from None finally: self.inflight = False self.last_active = time.monotonic() async def _consume(self, on_event, events: list) -> stream_mod.Result: parser = stream_mod.StreamParser() self._parser = parser async for ev in parser.aiter_events(self._stdout_lines()): if isinstance(ev, stream_mod.SystemInit) and ev.session_id: self.sid = ev.session_id events.append(ev) if on_event: await on_event(ev) if isinstance(ev, stream_mod.Result): return ev raise stream_mod.StreamEOFError("stream inchis fara result") # pragma: no cover class RunnerManager: """Registrul de procese vii + reaper-ul de inactivitate (20 min implicit).""" def __init__( self, store=None, *, claude_bin: str | list[str] | None = None, settings: str | os.PathLike | None = None, idle_s: float | None = None, poll_s: float = 30.0, is_inflight: Callable[[str], bool] | None = None, ): self.store = store self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude") self.settings = settings self.idle_s = idle_s if idle_s is not None else config.get_float("IDLE_REAP_S", 1200.0) self.poll_s = poll_s self._is_inflight = is_inflight self.procs: dict[str, ClaudeProcess] = {} self._reaper: asyncio.Task | None = None # ------------------------------------------------------------ procese def _on_pid(self, thread_id: str, pid: int | None): if self.store is not None: with contextlib.suppress(Exception): self.store.set_pid(thread_id, pid) def get(self, thread_id: str, cwd: str | None = None, model: str | None = None, sid: str | None = None) -> ClaudeProcess: key = str(thread_id) proc = self.procs.get(key) if proc is None: proc = self.procs[key] = ClaudeProcess( key, cwd or config.get("DEFAULT_CWD", "/workspace"), model or config.get("MODEL_DEFAULT", "sonnet"), sid=sid, claude_bin=self.claude_bin, settings=self.settings, on_pid=self._on_pid, ) return proc async def set_options(self, thread_id: str, cwd: str | None = None, model: str | None = None) -> bool: """`!cd` / `!model`: procesul curent se opreste, urmatorul tur il reia cu --resume.""" proc = self.get(thread_id) changed = False if cwd and cwd != proc.cwd: proc.cwd, changed = cwd, True if model and model != proc.model: proc.model, changed = model, True if changed and proc.alive: await proc.stop() return changed async def reset(self, thread_id: str, fork: bool = False) -> None: """`!new`: sesiune noua (fork=True pastreaza sid-ul pentru un fir nou).""" proc = self.procs.get(str(thread_id)) if proc is not None: await proc.stop() if not fork: proc.sid = None proc.started_turns = 0 def inflight(self, thread_id: str) -> bool: proc = self.procs.get(str(thread_id)) if proc is not None and proc.inflight: return True if self._is_inflight is not None: with contextlib.suppress(Exception): return bool(self._is_inflight(str(thread_id))) if self.store is not None: with contextlib.suppress(Exception): return bool(self.store.is_inflight(str(thread_id))) return False def live_count(self) -> int: return sum(1 for p in self.procs.values() if p.alive) # ------------------------------------------------------------- reaper async def reap_once(self, now: float | None = None) -> list[str]: """Omoara procesele inactive. NICIODATA un fir cu tur in zbor.""" now = time.monotonic() if now is None else now killed: list[str] = [] for key, proc in list(self.procs.items()): if not proc.alive: continue if self.inflight(key): continue if now - proc.last_active >= self.idle_s: log.info("reaper: opresc claude pentru firul %s (inactiv)", key) await proc.stop() killed.append(key) return killed async def _reaper_loop(self) -> None: while True: try: await asyncio.sleep(self.poll_s) await self.reap_once() except asyncio.CancelledError: raise except Exception: # pragma: no cover log.exception("reaper a esuat, continui") def start_reaper(self) -> asyncio.Task: if self._reaper is None or self._reaper.done(): self._reaper = asyncio.create_task(self._reaper_loop()) return self._reaper async def stop_all(self) -> None: if self._reaper is not None: self._reaper.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await self._reaper self._reaper = None for proc in list(self.procs.values()): await proc.stop() def new_turn_id() -> str: return uuid.uuid4().hex[:12]