"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker. Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach ``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher (``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen. Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B. über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task`` ``worker_pid = NULL`` — der Worker-PROZESS läuft aber weiter. Die DB zählt ihn nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren Task nicht mehr ``running`` ist. Linux-only (/proc-Scan). Best-effort: jeder Fehler → leere Liste, nie werfen. """ from __future__ import annotations import os import re import signal import sys _TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)") def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]": """Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist. ``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus ``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste der beendeten ``(pid, task_id)`` zurück. """ if sys.platform != "linux": return [] candidates: "dict[int, str]" = {} try: entries = os.listdir("/proc") except OSError: return [] for entry in entries: if not entry.isdigit(): continue try: with open(f"/proc/{entry}/cmdline", "rb") as fh: cmd = fh.read().replace(b"\x00", b" ") except OSError: continue m = _TASK_RE.search(cmd) if m: candidates[int(entry)] = m.group(1).decode() if not candidates: return [] task_ids = list(set(candidates.values())) placeholders = ",".join("?" * len(task_ids)) running: "set[str]" = set() try: with connect_closing() as conn: running = { row[0] for row in conn.execute( f"SELECT id FROM tasks WHERE id IN ({placeholders}) " "AND status = 'running'", task_ids, ) } except Exception: return [] killed: "list[tuple[int, str]]" = [] for pid, task_id in candidates.items(): if task_id in running: continue # Prozessgruppe zuerst (Worker starten start_new_session=True → eigene # Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit). try: os.killpg(os.getpgid(pid), signal.SIGTERM) except OSError: try: os.kill(pid, signal.SIGTERM) except OSError: continue killed.append((pid, task_id)) return killed