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
This commit is contained in:
435
proxmox/lxc171-claude-agent/discord-bridge/cleanup.py
Normal file
435
proxmox/lxc171-claude-agent/discord-bridge/cleanup.py
Normal file
@@ -0,0 +1,435 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user