Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Starea sesiunilor: state.json, scriere atomica, lock per fir, PID reuse, recovery.
|
|
|
|
Autoritatea pe schema din INTERFACES.md o are acest modul (T7 + partea de nucleu a T5).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import datetime as _dt
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any, Callable
|
|
|
|
import config
|
|
|
|
log = logging.getLogger("discord-bridge.state")
|
|
|
|
# Lane C poate lipsi cand rulam nucleul singur; import tolerant, fara varianta proprie.
|
|
try: # pragma: no cover - depinde de ordinea de merge a lane-urilor
|
|
import alerts # type: ignore
|
|
except ImportError: # pragma: no cover
|
|
class _NoAlerts:
|
|
@staticmethod
|
|
def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None:
|
|
log.warning("alerta (%s) %s: %s", level, subject, body)
|
|
|
|
alerts = _NoAlerts() # type: ignore
|
|
|
|
VERSION = 1
|
|
_CLOCK_TICKS = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100.0
|
|
|
|
|
|
# ------------------------------------------------------------------ procese
|
|
def pid_start_time(pid: int) -> float | None:
|
|
"""Campul 22 din /proc/<pid>/stat (starttime, in secunde de la boot).
|
|
|
|
Il folosim ca semnatura a procesului: un PID reciclat are alt starttime.
|
|
"""
|
|
try:
|
|
with open(f"/proc/{int(pid)}/stat", "rb") as fh:
|
|
data = fh.read().decode("utf-8", "replace")
|
|
except (OSError, ValueError):
|
|
return None
|
|
# comm poate contine spatii si paranteze -> taiem dupa ultima ')'
|
|
close = data.rfind(")")
|
|
if close < 0:
|
|
return None
|
|
fields = data[close + 2:].split()
|
|
# dupa comm, fields[0] este campul 3 (state); campul 22 e fields[19]
|
|
if len(fields) < 20:
|
|
return None
|
|
try:
|
|
return int(fields[19]) / float(_CLOCK_TICKS)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def pid_alive(pid: int | None, start_time: float | None = None) -> bool:
|
|
"""True doar daca PID-ul traieste SI (optional) are acelasi starttime."""
|
|
if not pid:
|
|
return False
|
|
cur = pid_start_time(int(pid))
|
|
if cur is None:
|
|
return False
|
|
if start_time is None:
|
|
return True
|
|
return abs(cur - float(start_time)) < 0.05
|
|
|
|
|
|
def today() -> str:
|
|
return _dt.date.today().isoformat()
|
|
|
|
|
|
def new_thread_record(cwd: str | None = None, model: str | None = None) -> dict[str, Any]:
|
|
return {
|
|
"sid": None,
|
|
"cwd": cwd or config.get("DEFAULT_CWD", "/workspace"),
|
|
"model": model or config.get("MODEL_DEFAULT", "sonnet"),
|
|
"pid": None,
|
|
"pid_start_time": None,
|
|
"inflight": None,
|
|
"cost_usd_total": 0.0,
|
|
"last_active": 0.0,
|
|
}
|
|
|
|
|
|
def empty_state() -> dict[str, Any]:
|
|
return {"version": VERSION, "threads": {}, "cost": {"day": today(), "usd": 0.0}}
|
|
|
|
|
|
# ------------------------------------------------------------------- store
|
|
class SessionStore:
|
|
"""state.json cu scriere atomica, lock de fisier intre procese si lock per fir."""
|
|
|
|
def __init__(self, path: os.PathLike | str | None = None, alerter: Callable | None = None):
|
|
self.path = os.fspath(path) if path else os.fspath(config.STATE_FILE)
|
|
self.lock_path = self.path + ".lock"
|
|
self._state: dict[str, Any] = empty_state()
|
|
self._thread_locks: dict[str, asyncio.Lock] = {}
|
|
self._write_lock = asyncio.Lock()
|
|
self._alert = alerter or alerts.alert
|
|
self.loaded = False
|
|
|
|
# ------------------------------------------------------------ interne
|
|
def _dir(self) -> str:
|
|
return os.path.dirname(os.path.abspath(self.path)) or "."
|
|
|
|
@contextlib.contextmanager
|
|
def _file_lock(self):
|
|
os.makedirs(self._dir(), exist_ok=True)
|
|
fh = open(self.lock_path, "a+")
|
|
try:
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
fh.close()
|
|
|
|
def _quarantine(self, reason: str) -> None:
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
dest = f"{self.path}.corrupt-{stamp}"
|
|
try:
|
|
os.replace(self.path, dest)
|
|
except OSError as exc:
|
|
dest = f"(nu am putut muta: {exc})"
|
|
log.error("state.json corupt (%s), salvat ca %s", reason, dest)
|
|
try:
|
|
self._alert(
|
|
"CRITICAL",
|
|
"state.json corupt",
|
|
f"Motiv: {reason}\nFisierul stricat: {dest}\nPuntea reporneste de la stare goala.",
|
|
"state-corrupt",
|
|
)
|
|
except Exception: # o alerta esuata nu are voie sa doboare botul
|
|
log.exception("alerta pentru state corupt a esuat")
|
|
|
|
def _normalize(self, data: Any) -> dict[str, Any]:
|
|
if not isinstance(data, dict) or not isinstance(data.get("threads"), dict):
|
|
raise ValueError("structura neasteptata")
|
|
state = empty_state()
|
|
state["version"] = data.get("version", VERSION)
|
|
cost = data.get("cost")
|
|
if isinstance(cost, dict):
|
|
state["cost"] = {
|
|
"day": str(cost.get("day") or today()),
|
|
"usd": float(cost.get("usd") or 0.0),
|
|
}
|
|
for tid, rec in data["threads"].items():
|
|
base = new_thread_record()
|
|
if isinstance(rec, dict):
|
|
base.update({k: v for k, v in rec.items() if k in base})
|
|
state["threads"][str(tid)] = base
|
|
return state
|
|
|
|
# ------------------------------------------------------------- publice
|
|
def load(self) -> dict[str, Any]:
|
|
"""Incarca starea. La fisier corupt: quarantine + alerta + stare goala."""
|
|
with self._file_lock():
|
|
try:
|
|
raw = open(self.path, "rb").read()
|
|
except FileNotFoundError:
|
|
self._state = empty_state()
|
|
self.loaded = True
|
|
return self._state
|
|
except OSError as exc:
|
|
log.error("nu pot citi %s: %s", self.path, exc)
|
|
self._state = empty_state()
|
|
self.loaded = True
|
|
return self._state
|
|
try:
|
|
self._state = self._normalize(json.loads(raw.decode("utf-8")))
|
|
except Exception as exc:
|
|
self._quarantine(str(exc))
|
|
self._state = empty_state()
|
|
self._write_locked()
|
|
self.loaded = True
|
|
return self._state
|
|
|
|
def _write_locked(self) -> None:
|
|
"""Scriere atomica: tmp in acelasi director + fsync + os.replace + fsync dir."""
|
|
d = self._dir()
|
|
os.makedirs(d, exist_ok=True)
|
|
tmp = os.path.join(d, f".state.{os.getpid()}.tmp")
|
|
payload = json.dumps(self._state, indent=2, sort_keys=True).encode("utf-8")
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
try:
|
|
os.write(fd, payload)
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
os.replace(tmp, self.path)
|
|
dfd = os.open(d, os.O_RDONLY)
|
|
try:
|
|
os.fsync(dfd)
|
|
finally:
|
|
os.close(dfd)
|
|
|
|
def save(self) -> None:
|
|
with self._file_lock():
|
|
self._write_locked()
|
|
|
|
@property
|
|
def state(self) -> dict[str, Any]:
|
|
return self._state
|
|
|
|
def lock_for(self, thread_id: str) -> asyncio.Lock:
|
|
"""Lock per fir (in proces). Intre procese lucreaza flock-ul de fisier."""
|
|
key = str(thread_id)
|
|
lock = self._thread_locks.get(key)
|
|
if lock is None:
|
|
lock = self._thread_locks[key] = asyncio.Lock()
|
|
return lock
|
|
|
|
def thread(self, thread_id: str, create: bool = True) -> dict[str, Any] | None:
|
|
key = str(thread_id)
|
|
rec = self._state["threads"].get(key)
|
|
if rec is None and create:
|
|
rec = self._state["threads"][key] = new_thread_record()
|
|
return rec
|
|
|
|
def update_thread(self, thread_id: str, **fields) -> dict[str, Any]:
|
|
"""Modifica un fir si scrie atomic. Apeleaza-l sub `lock_for(thread_id)`."""
|
|
rec = self.thread(thread_id)
|
|
assert rec is not None
|
|
rec.update(fields)
|
|
rec["last_active"] = fields.get("last_active", time.time())
|
|
self.save()
|
|
return rec
|
|
|
|
def set_pid(self, thread_id: str, pid: int | None) -> dict[str, Any]:
|
|
st = pid_start_time(pid) if pid else None
|
|
return self.update_thread(thread_id, pid=pid, pid_start_time=st)
|
|
|
|
def thread_process_alive(self, thread_id: str) -> bool:
|
|
rec = self.thread(thread_id, create=False)
|
|
if not rec:
|
|
return False
|
|
return pid_alive(rec.get("pid"), rec.get("pid_start_time"))
|
|
|
|
# ------------------------------------------------------------ inflight
|
|
def set_inflight(self, thread_id: str, turn_id: str, user_id: str, message_id: str) -> dict:
|
|
return self.update_thread(
|
|
thread_id,
|
|
inflight={
|
|
"turn_id": turn_id,
|
|
"started_at": time.time(),
|
|
"user_id": str(user_id),
|
|
"message_id": str(message_id),
|
|
},
|
|
)
|
|
|
|
def clear_inflight(self, thread_id: str) -> dict:
|
|
return self.update_thread(thread_id, inflight=None)
|
|
|
|
def is_inflight(self, thread_id: str) -> bool:
|
|
rec = self.thread(thread_id, create=False)
|
|
return bool(rec and rec.get("inflight"))
|
|
|
|
def sweep_lost_turns(self) -> list[dict[str, Any]]:
|
|
"""T5: la pornire, orice fir cu `inflight` al carui proces nu mai e al nostru
|
|
primeste turul marcat drept PIERDUT. Fara reluare automata (dubla executie).
|
|
"""
|
|
lost: list[dict[str, Any]] = []
|
|
changed = False
|
|
for tid, rec in self._state["threads"].items():
|
|
inflight = rec.get("inflight")
|
|
if not inflight:
|
|
continue
|
|
if pid_alive(rec.get("pid"), rec.get("pid_start_time")):
|
|
continue # procesul nostru inca traieste: turul e viu
|
|
lost.append(
|
|
{
|
|
"thread_id": tid,
|
|
"turn_id": inflight.get("turn_id"),
|
|
"user_id": inflight.get("user_id"),
|
|
"message_id": inflight.get("message_id"),
|
|
"started_at": inflight.get("started_at"),
|
|
"warning": (
|
|
"Turul anterior s-a pierdut (botul a fost repornit sau procesul a murit). "
|
|
"NU il reiau automat, ca sa nu se execute de doua ori. Trimite-l din nou daca mai e nevoie."
|
|
),
|
|
}
|
|
)
|
|
rec["inflight"] = None
|
|
rec["pid"] = None
|
|
rec["pid_start_time"] = None
|
|
changed = True
|
|
if changed:
|
|
self.save()
|
|
try:
|
|
self._alert(
|
|
"WARN",
|
|
"tururi pierdute la pornire",
|
|
"Fire afectate: " + ", ".join(x["thread_id"] for x in lost),
|
|
"sweep-lost",
|
|
)
|
|
except Exception:
|
|
log.exception("alerta pentru sweep a esuat")
|
|
return lost
|
|
|
|
# ---------------------------------------------------------------- cost
|
|
def roll_day(self) -> None:
|
|
cost = self._state.setdefault("cost", {"day": today(), "usd": 0.0})
|
|
if cost.get("day") != today():
|
|
cost["day"] = today()
|
|
cost["usd"] = 0.0
|
|
|
|
def add_cost(self, thread_id: str | None, usd: float) -> float:
|
|
"""Adauga costul unui tur; returneaza totalul pe ziua curenta."""
|
|
try:
|
|
usd = float(usd)
|
|
except (TypeError, ValueError):
|
|
usd = 0.0
|
|
self.roll_day()
|
|
self._state["cost"]["usd"] = round(self._state["cost"]["usd"] + usd, 6)
|
|
if thread_id is not None:
|
|
rec = self.thread(thread_id)
|
|
rec["cost_usd_total"] = round(float(rec.get("cost_usd_total") or 0.0) + usd, 6)
|
|
self.save()
|
|
return self._state["cost"]["usd"]
|
|
|
|
def cost_today(self) -> float:
|
|
self.roll_day()
|
|
return float(self._state["cost"]["usd"])
|