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
436 lines
15 KiB
Python
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))
|