Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md
Claude Agent c8a5d418f3 fix(discord-bridge): /cleanup urmareste descendentii, dar STRICT in cgroup-ul serviciului
Doua defecte, al doilea gasit la verificarea primului.

1. Procesul `claude` al unui fir isi porneste serverele MCP (npm/sh/node pentru
   playwright), care nu se numesc `claude` si scapau cautarii. Masurat pe firul viu:
   claude 300 MB + MCP 137 MB = ~440 MB per fir, nu 300. find_orphans merge acum pe
   arbore, iar kill_orphans opreste copiii inaintea parintilor (altfel se reparenteaza
   si scapa).

2. PERICULOS: definitia initiala a orfanului ("orice `claude` absent din state.json")
   prindea sesiunile Claude interactive de pe container. Rularea seaca propunea 25 de
   procese / ~3864 MB — sesiunile de lucru din tmux, inclusiv cea din care ar fi fost
   data comanda. `/cleanup force:True` din Discord si-ar fi omorat propria sesiune.
   Apartenenta la cgroup-ul `claude-discord.service` devine conditie necesara pentru
   toate familiile, fail-closed la cgroup necitibil. Dupa fix: zero procese raportate.

   Test de regresie pe instantaneul real (doua sesiuni in tmux-spawn-*.scope, una in
   claude-discord.service): se raporteaza doar a treia. Verificat prin mutant ca testul
   musca — cu filtrul scos pica 3 teste.

3. INTERFACES.md cerea pid_start_time in ticks, dar session_store scrie secunde (si
   state.json viu contine secunde). Contractul era imprecis, nu codul: un consumator
   care compara ticks nu s-ar potrivi niciodata, iar cum acea comparatie protejeaza
   turul in desfasurare, esecul ar fi fost tacut si ar fi facut eligibil exact ce
   trebuia protejat. Documentat, cu avertismentul explicit.

Bug colateral prins de agent: raportul cu rezultate de omorare ajungea la 2289 caractere,
peste limita Discord — mesajul ar fi fost respins exact la `/cleanup force:True`. Buget
de caractere adaugat.

Suita: 322 passed cu discord.py, 319 passed + 3 skipped fara. Rulare seaca pe container: curat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 12:47:16 +00:00

5.9 KiB

INTERFACES — contract intre lane-uri (proprietate: orchestrator, NU modifica)

Cele trei lane-uri lucreaza in acelasi director. Acest fisier fixeaza cine ce fisier scrie si ce semnaturi trec granita, ca merge-ul sa fie mecanic.

Proprietate pe fisiere (STRICTA — nu scrie in fisierele altui lane)

Lane Fisiere pe care le creeaza/editeaza
A (nucleu + adaptor) session_store.py, stream.py, runner.py, render.py, limits.py, bot.py, config.py, tests/**, requirements.txt, requirements-dev.txt
B (securitate) security/confirm_hook.py, security/infra, security/approvals.py, security/bot-settings.json.example, security/README.md, tests/test_confirm_hook.py, tests/test_infra.py
C (ops) alerts.py, cleanup.py, ops/claude-discord.service, ops/install.sh, ops/logrotate.conf, tests/test_alerts.py, tests/test_cleanup.py, README.md, si liniile de index din ../README.md + /workspace/romfastsql/CLAUDE.md

Fisiere partajate ca citire: acest INTERFACES.md. Nimeni nu-l editeaza.

Layout runtime (in afara repo)

~/.claude-discord/
  env                     # 0600: DISCORD_TOKEN, allowlist, ALERT_RECIPIENT, COST_CAP_USD_DAY
  bot-settings.json       # settings pasat cu --settings (hook PreToolUse) — Lane B
  state.json              # stare sesiuni — Lane A
  venv/
  logs/bot.log

Modelul de date state.json (Lane A e autoritatea)

{
  "version": 1,
  "threads": {
    "<discord_thread_or_channel_id>": {
      "sid": "uuid sesiune claude sau null",
      "cwd": "/workspace/<proiect>",
      "model": "sonnet",
      "pid": 12345,
      "pid_start_time": 987654.21,
      "inflight": {"turn_id": "...", "started_at": 1756512000.0, "user_id": "...", "message_id": "..."},
      "cost_usd_total": 0.0,
      "last_active": 1756512000.0
    }
  },
  "cost": {"day": "2026-08-30", "usd": 0.0}
}

pid_start_time = /proc/<pid>/stat campul 22 (starttime), convertit in SECUNDE de la boot (ticks / os.sysconf('SC_CLK_TCK')), pentru detectarea PID reuse. Unitatea conteaza: un consumator care compara direct ticks-urile din /proc cu valoarea din state.json nu se va potrivi NICIODATA, iar daca acea comparatie protejeaza ceva (cleanup.py), esecul e tacut si periculos — tot ce trebuia protejat devine eligibil pentru omorare.

Granita A <-> B (aprobari)

Lane B expune security/approvals.py. Lane A il importa si nu-i cunoaste interiorul.

# security/approvals.py — implementat de Lane B, consumat de Lane A
async def wait_for_decision(request_id: str, timeout: float) -> str: ...
    # returneaza "allow" | "deny"; la timeout returneaza "deny" (fail-closed)

def submit_decision(request_id: str, decision: str) -> bool: ...
    # apelat de bot.py cand utilizatorul apasa butonul; True daca cererea exista

async def pending_requests() -> list[dict]: ...
    # [{"request_id", "thread_id", "tool_name", "command", "created_at"}]

def set_on_request(callback) -> None: ...
    # Lane A inregistreaza aici un async callback(request: dict) apelat cand
    # hook-ul cere o confirmare; bot.py posteaza atunci butoanele in fir.

Canalul hook -> bot e un director de cereri pe disc (~/.claude-discord/approvals/), fiindca hook-ul PreToolUse ruleaza intr-un proces separat, nu in botul Python. Lane B alege formatul; Lane A vede doar functiile de mai sus.

Fail-closed e obligatoriu: orice eroare, timeout sau fisier corupt => deny.

Granita A <-> C (alerte)

Lane C expune alerts.py. Lane A il apeleaza in caile de esec.

# alerts.py — implementat de Lane C, consumat de Lane A
def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: ...
    # level: "INFO" | "WARN" | "CRITICAL"
    # trimite email prin `mail -s "[LEVEL] subject" "$ALERT_RECIPIENT"` (conventia repo,
    # vezi proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh)
    # NU arunca niciodata exceptii — o alerta esuata nu are voie sa doboare botul
    # dedup_key: aceeasi cheie nu retrimite in fereastra de 1h

Conditiile pe care Lane A le semnaleaza (T12): proces mort neasteptat, crash loop, plafon de cost atins, state.json corupt, orfani detectati la sweep.

Granita A <-> C (cleanup)

# cleanup.py — implementat de Lane C, consumat de bot.py pentru comanda !cleanup
def find_orphans(state: dict) -> list[dict]: ...
    # procese `claude` din cgroup-ul serviciului care nu apar in state.json,
    # plus copii lasati in urma (servere pornite in tururi anterioare)
    # -> [{"pid", "cmdline", "age_s", "rss_mb"}]
def kill_orphans(orphans: list[dict], dry_run: bool = True) -> list[dict]: ...

Granita comuna: config

Lane A creeaza config.py, care citeste ~/.claude-discord/env. B si C il importa pentru cai si setari; nu-si citesc singure env-ul.

# config.py — implementat de Lane A
STATE_DIR: pathlib.Path      # ~/.claude-discord
APPROVALS_DIR: pathlib.Path  # ~/.claude-discord/approvals
STATE_FILE: pathlib.Path
LOG_DIR: pathlib.Path
def get(key: str, default=None) -> str | None: ...   # citeste din env-ul incarcat

Daca config.py nu exista inca la momentul in care B sau C au nevoie de el (lane-uri paralele), scrie codul care il importa oricum — se rezolva la merge — si NU crea o varianta proprie.

Reguli de test

  • pytest fara marker: zero retea, zero Discord, zero API. Suita sub 3s.
  • Testele care ating CLI-ul real: @pytest.mark.e2e, excluse implicit prin pytest.ini (Lane A scrie pytest.ini cu addopts = -m "not e2e").
  • Fiecare lane isi scrie doar propriile fisiere de test, dupa tabelul de proprietate.

Decizii deja luate (nu le redeschide)

  • --permission-mode bypassPermissions e intentionat; deny rules sunt strat cosmetic, nu bariera.
  • Accesul larg la /workspace si la infrastructura e FUNCTIONALITATE ceruta, nu bug.
  • Fara user separat cdbot, fara audit append-only, fara dashboard web, fara Agent SDK.
  • Fara reluare automata a turului pierdut (risc de dubla executie).
  • Model default sonnet; !model opus per fir.