Files
Claude Agent d466f358ce feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
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
2026-08-30 10:44:39 +00:00

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"])