Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py
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

436 lines
15 KiB
Python

#!/usr/bin/env python3
"""cleanup.py — comanda `!cleanup`: procese lasate in urma de tururi anterioare (T13).
De ce exista (Codex #8): `KillMode=control-group` opreste arborele serviciului la
restart, dar NU prinde procesele care s-au desprins — daemoni pornite cu `&` sau
`nohup`, servere de dezvoltare lansate intr-un tur si uitate acolo, joburi lungi
reparentate la init. Fiecare proces `claude` are ~406 MB RSS masurat, iar
containerul are istoric de OOM: acumularea lor nu e cosmetica.
Contract (INTERFACES.md, granita A <-> C):
find_orphans(state) -> [{"pid", "cmdline", "age_s", "rss_mb"}]
kill_orphans(orphans, dry_run=True) -> [{...}]
`dry_run=True` e implicit si e intentionat: omul vede intai ce s-ar omori.
Ce NU e considerat orfan:
* procesele `claude` inregistrate in state.json si toti descendentii lor
(sunt turul care ruleaza chiar acum);
* procesul curent, parintii lui si botul insusi;
* orice proces al altui utilizator;
* infrastructura sesiunii (systemd --user, sshd, tmux, code-server, dbus...).
"""
from __future__ import annotations
import os
import pathlib
import signal
import sys
import time
try: # pragma: no cover - config.py apartine Lane A, poate lipsi la momentul asta
import config as _config # type: ignore
except Exception: # pragma: no cover
_config = None
PROC = pathlib.Path("/proc")
CLOCK_TICKS = float(os.sysconf("SC_CLK_TCK"))
PAGE_SIZE = float(os.sysconf("SC_PAGE_SIZE"))
# Cgroup-ul serviciului: procesele reparentate la init care raman inauntru sunt
# aproape sigur resturi ale unui tur. Numele e fix, vezi ops/claude-discord.service.
SERVICE_CGROUP = "claude-discord.service"
# Numele executabilului CLI-ului. Se compara pe BASENAME-ul fiecarui argument, nu ca
# subsir: utilizatorul containerului se numeste tot `claude`, deci orice cale din
# home-ul lui contine cuvantul si un match pe subsir ar da fals pozitiv pe tot.
CLAUDE_BASENAMES = ("claude", "claude.exe")
CLAUDE_PATH_MARKERS = ("claude-code/bin/claude", "node_modules/.bin/claude")
# Procese care nu se ating niciodata, chiar daca sunt in cgroup si reparentate.
NEVER_KILL = (
"systemd", "sshd", "dbus-daemon", "tmux", "code-server", "vscode-server",
"bot.py", "claude-discord", "login", "bash -l", "(sd-pam)", "pytest",
)
GRACE_S = 3.0 # cat asteptam intre SIGTERM si SIGKILL
# --- citire /proc ----------------------------------------------------------
def _read(path: pathlib.Path) -> str:
try:
return path.read_text(errors="replace")
except Exception:
return ""
def _boot_epoch() -> float:
"""Momentul (epoch) fata de care e masurat `starttime` din /proc/<pid>/stat.
ATENTIE, capcana de container: pe LXC 171 /proc/uptime e virtualizat de lxcfs
(arata uptime-ul CONTAINERULUI), in timp ce `starttime` ramane raportat la boot-ul
GAZDEI. Scaderea celor doua da varste negative (verificat: -561s pentru un proces
pornit acum 10 minute; si `ps -o etimes` arata acolo 4123168064). `btime` din
/proc/stat e coerent cu `starttime`, deci ea e referinta corecta.
"""
for line in _read(PROC / "stat").splitlines():
if line.startswith("btime"):
try:
return float(line.split()[1])
except Exception:
break
# ultima varianta: boot dedus din uptime (poate fi gresit in container)
try:
return time.time() - float(_read(PROC / "uptime").split()[0])
except Exception:
return 0.0
def _parse_stat(pid: int) -> tuple[int, float] | None:
"""(ppid, starttime_ticks) din /proc/<pid>/stat.
comm-ul e intre paranteze si poate contine spatii, deci se taie dupa ultimul ')'.
"""
raw = _read(PROC / str(pid) / "stat")
if not raw:
return None
try:
rest = raw[raw.rindex(")") + 2:].split()
# dupa comm, campul 1 e state; ppid e campul 4 global = rest[1]
ppid = int(rest[1])
starttime = float(rest[19]) # campul 22 global
return ppid, starttime
except Exception:
return None
def _is_zombie(pid: int) -> bool:
"""Un proces terminat dar nereaped are inca /proc/<pid>/stat; e mort, nu viu."""
raw = _read(PROC / str(pid) / "stat")
try:
return raw[raw.rindex(")") + 2:].split()[0] in ("Z", "X", "x")
except Exception:
return False
def _rss_mb(pid: int) -> float:
try:
pages = float(_read(PROC / str(pid) / "statm").split()[1])
return round(pages * PAGE_SIZE / (1024 * 1024), 1)
except Exception:
return 0.0
def _cmdline(pid: int) -> str:
raw = _read(PROC / str(pid) / "cmdline")
if raw:
parts = [p for p in raw.split("\0") if p]
if parts:
return " ".join(parts)
# proces de kernel sau cmdline gol: cadem pe comm
comm = _read(PROC / str(pid) / "comm").strip()
return f"[{comm}]" if comm else ""
def _uid(pid: int) -> int | None:
for line in _read(PROC / str(pid) / "status").splitlines():
if line.startswith("Uid:"):
try:
return int(line.split()[1])
except Exception:
return None
return None
def _cgroup(pid: int) -> str:
return _read(PROC / str(pid) / "cgroup").strip()
def scan_processes() -> dict[int, dict]:
"""Instantaneu al proceselor utilizatorului curent, indexat pe pid."""
me = os.getuid()
boot = _boot_epoch()
now = time.time()
out: dict[int, dict] = {}
try:
entries = [e for e in os.listdir(PROC) if e.isdigit()]
except Exception:
return out
for entry in entries:
pid = int(entry)
st = _parse_stat(pid)
if st is None:
continue # procesul a disparut intre listare si citire
uid = _uid(pid)
if uid is None or uid != me:
continue
ppid, starttime = st
age = now - (boot + starttime / CLOCK_TICKS) if boot else 0.0
out[pid] = {
"pid": pid,
"ppid": ppid,
"cmdline": _cmdline(pid),
"age_s": int(max(age, 0)),
"rss_mb": _rss_mb(pid),
"start_time": starttime,
"cgroup": _cgroup(pid),
}
return out
# --- clasificare -----------------------------------------------------------
def _known_pids(state: dict) -> set[int]:
"""Pid-urile pe care state.json le declara vii, cu verificare de PID reuse."""
known: set[int] = set()
threads = (state or {}).get("threads") or {}
if not isinstance(threads, dict):
return known
for entry in threads.values():
if not isinstance(entry, dict):
continue
pid = entry.get("pid")
if not isinstance(pid, int) or pid <= 0:
continue
expected = entry.get("pid_start_time")
if expected is not None:
st = _parse_stat(pid)
# Pid reciclat: alt proces poarta acum acelasi numar. Nu-l protejam,
# dar nici nu-l omoram automat — intra pe filtrele obisnuite.
if st is None or abs(st[1] - float(expected)) > 1.0:
continue
known.add(pid)
return known
def _descendants(roots: set[int], procs: dict[int, dict]) -> set[int]:
"""Toti descendentii pid-urilor date (turul care ruleaza acum e intocmai asta)."""
children: dict[int, list[int]] = {}
for pid, info in procs.items():
children.setdefault(info["ppid"], []).append(pid)
seen: set[int] = set()
stack = list(roots)
while stack:
pid = stack.pop()
for child in children.get(pid, ()):
if child not in seen:
seen.add(child)
stack.append(child)
return seen
def _ancestors_of_self(procs: dict[int, dict]) -> set[int]:
out: set[int] = set()
pid = os.getpid()
while pid and pid not in out:
out.add(pid)
info = procs.get(pid)
if not info:
break
pid = info["ppid"]
return out
def _is_claude(cmdline: str) -> bool:
for token in cmdline.split():
if token.rsplit("/", 1)[-1] in CLAUDE_BASENAMES:
return True
return any(m in cmdline for m in CLAUDE_PATH_MARKERS)
def _is_protected(cmdline: str) -> bool:
low = cmdline.lower()
return any(marker.lower() in low for marker in NEVER_KILL)
def find_orphans(state: dict, min_age_s: int = 0) -> list[dict]:
"""Procese ramase in urma, care nu apar in state.json.
Doua familii:
1. procese `claude` care nu sunt inregistrate in state.json;
2. copii reparentati la init (ppid == 1) ramasi in cgroup-ul serviciului —
serverele si joburile pornite intr-un tur anterior.
Intoarce [{"pid", "cmdline", "age_s", "rss_mb", ...}], sortat descrescator
dupa RSS (ce doare cel mai tare la OOM apare primul).
"""
procs = scan_processes()
known = _known_pids(state or {})
protected = set(known) | _descendants(known, procs) | _ancestors_of_self(procs)
orphans: list[dict] = []
for pid, info in procs.items():
if pid in protected:
continue
cmdline = info["cmdline"]
if not cmdline or _is_protected(cmdline):
continue
if info["age_s"] < min_age_s:
continue
if _is_zombie(pid):
continue # zombi: nu consuma memorie si nu se poate omori
if _is_claude(cmdline):
reason = "proces claude neinregistrat in state.json"
elif info["ppid"] == 1 and SERVICE_CGROUP in info["cgroup"]:
reason = "copil reparentat la init, ramas in cgroup-ul serviciului"
else:
continue
orphans.append({
"pid": pid,
"cmdline": cmdline,
"age_s": info["age_s"],
"rss_mb": info["rss_mb"],
"ppid": info["ppid"],
"start_time": info["start_time"],
"reason": reason,
})
orphans.sort(key=lambda o: (-o["rss_mb"], -o["age_s"]))
return orphans
# --- oprire ----------------------------------------------------------------
def _still_same_process(orphan: dict) -> bool:
"""Aparare impotriva PID reuse intre `find_orphans` si `kill_orphans`."""
pid = orphan.get("pid")
if not isinstance(pid, int) or pid <= 0:
return False
st = _parse_stat(pid)
if st is None or _is_zombie(pid):
return False
expected = orphan.get("start_time")
if expected is None:
return True
return abs(st[1] - float(expected)) <= 1.0
def kill_orphans(orphans: list[dict], dry_run: bool = True, grace_s: float = GRACE_S) -> list[dict]:
"""Opreste orfanii. Implicit NU omoara nimic (dry_run=True).
SIGTERM, apoi SIGKILL dupa `grace_s` daca procesul inca traieste.
Intoarce cate un rezultat per intrare: {"pid", "cmdline", "action", "detail"}.
action: "dry-run" | "terminated" | "killed" | "gone" | "skipped" | "error"
"""
results: list[dict] = []
for orphan in orphans or []:
pid = orphan.get("pid")
entry = {"pid": pid, "cmdline": orphan.get("cmdline", ""), "action": "", "detail": ""}
if not isinstance(pid, int) or not _still_same_process(orphan):
entry["action"] = "gone"
entry["detail"] = "procesul nu mai exista sau pid-ul a fost reciclat"
results.append(entry)
continue
if pid == os.getpid():
entry["action"] = "skipped"
entry["detail"] = "e chiar procesul curent"
results.append(entry)
continue
if dry_run:
entry["action"] = "dry-run"
entry["detail"] = (
f"s-ar trimite SIGTERM (varsta {orphan.get('age_s')}s, "
f"{orphan.get('rss_mb')}MB)"
)
results.append(entry)
continue
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
entry["action"] = "gone"
entry["detail"] = "disparut inainte de SIGTERM"
results.append(entry)
continue
except Exception as exc:
entry["action"] = "error"
entry["detail"] = f"SIGTERM a esuat: {exc}"
results.append(entry)
continue
deadline = time.time() + grace_s
while time.time() < deadline:
if not _still_same_process(orphan):
break
time.sleep(0.1)
if not _still_same_process(orphan):
entry["action"] = "terminated"
entry["detail"] = "oprit cu SIGTERM"
results.append(entry)
continue
try:
os.kill(pid, signal.SIGKILL)
entry["action"] = "killed"
entry["detail"] = f"nu a raspuns la SIGTERM in {grace_s:.0f}s, SIGKILL"
except ProcessLookupError:
entry["action"] = "terminated"
entry["detail"] = "oprit cu SIGTERM"
except Exception as exc:
entry["action"] = "error"
entry["detail"] = f"SIGKILL a esuat: {exc}"
results.append(entry)
return results
# --- randare pentru Discord ------------------------------------------------
def format_report(orphans: list[dict], results: list[dict] | None = None) -> str:
"""Text scurt pentru raspunsul comenzii `!cleanup` (sub 2000 caractere)."""
if not orphans:
return "Niciun proces orfan. Nimic de curatat."
total_mb = sum(o.get("rss_mb", 0) for o in orphans)
lines = [f"**{len(orphans)} procese orfane** (~{total_mb:.0f} MB RSS in total)", "```"]
by_pid = {r.get("pid"): r for r in (results or [])}
for orphan in orphans[:15]:
cmd = str(orphan.get("cmdline", ""))[:70]
line = (
f"pid={orphan.get('pid'):<7} {orphan.get('rss_mb'):>7} MB "
f"{orphan.get('age_s'):>7}s {cmd}"
)
res = by_pid.get(orphan.get("pid"))
if res:
line += f"\n -> {res.get('action')}: {res.get('detail')}"
lines.append(line)
if len(orphans) > 15:
lines.append(f"... si inca {len(orphans) - 15}")
lines.append("```")
if not results:
lines.append("Rulare seaca. `!cleanup --force` le opreste efectiv.")
return "\n".join(lines)
def _load_state() -> dict:
"""Citeste state.json pentru rularea din linia de comanda. Tolerant la lipsa."""
if _config is not None and getattr(_config, "STATE_FILE", None):
path = pathlib.Path(_config.STATE_FILE)
else:
path = pathlib.Path(os.path.expanduser("~/.claude-discord/state.json"))
try:
import json
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except Exception:
return {}
if __name__ == "__main__":
# Uz manual: python3 cleanup.py -> doar raporteaza
# python3 cleanup.py --force -> opreste efectiv
force = "--force" in sys.argv[1:]
found = find_orphans(_load_state())
res = kill_orphans(found, dry_run=not force)
print(format_report(found, res))