"""Identify and clean up orphaned manual_slop processes left over from prior test runs. Categorizes running python/uv processes into: - sloppy.py: manual_slop GUI app spawned by live_gui tests; KILL (these are the orphans) - mcp_server.py: manual_slop's MCP server; preserve (legitimate, used by opencode) - minimax-coding-plan-mcp: opencode's MCP server; preserve (legitimate, used by opencode) - test subprocess: long-running pytest/test helpers; flag, kill if --kill-tests Default mode is DRY-RUN (lists orphans, prints what would be killed, kills nothing). Pass --kill to actually kill sloppy.py orphans. Pass --kill-tests to also kill the "test subprocess" category. Pass --kill-mcp to kill the MCP servers (off by default; usually you don't want to). Pass --json for machine-readable output. Usage: uv run python scripts/cleanup_orphaned_processes.py # dry-run uv run python scripts/cleanup_orphaned_processes.py --kill # kill sloppy.py orphans uv run python scripts/cleanup_orphaned_processes.py --kill --kill-tests uv run python scripts/cleanup_orphaned_processes.py --json """ from __future__ import annotations import argparse import json import subprocess import sys from dataclasses import asdict, dataclass from pathlib import Path @dataclass(frozen=True) class ProcInfo: pid: int name: str command_line: str age_seconds: float cpu_seconds: float category: str def render(self) -> str: cmd_short = self.command_line[:120].replace("\n", " ").replace("\r", " ") return (f" PID {self.pid:>6} {self.name:<10} age={self.age_seconds:>7.1f}s " f"cpu={self.cpu_seconds:>7.1f}s {self.category}\n" f" cmd: {cmd_short}") def _enum_processes() -> list[ProcInfo]: r = subprocess.run( ["powershell.exe", "-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='python.exe' OR Name='uv.exe'\" " "| Where-Object { $_.CommandLine } " "| Select-Object ProcessId, Name, CommandLine, CreationDate, KernelModeTime, UserModeTime " "| ConvertTo-Json -Depth 2 -Compress"], capture_output=True, text=True, timeout=15, ) if r.returncode != 0: print(f"ERROR: powershell enumeration failed: {r.stderr}", file=sys.stderr) return [] out = r.stdout.strip() if not out or out == "null": return [] raw = json.loads(out) if isinstance(raw, dict): raw = [raw] procs: list[ProcInfo] = [] for item in raw: pid = int(item.get("ProcessId", 0)) name = str(item.get("Name", "")) cl = str(item.get("CommandLine", "") or "") if not cl: continue creation = item.get("CreationDate", "") try: from datetime import datetime, timezone dt = datetime.fromisoformat(creation.replace("Z", "+00:00")) age = (datetime.now(timezone.utc) - dt).total_seconds() except Exception: age = 0.0 kernel = float(item.get("KernelModeTime", 0) or 0) / 1e7 user = float(item.get("UserModeTime", 0) or 0) / 1e7 cpu = kernel + user cat = _categorize(cl) if cat is None: continue procs.append(ProcInfo(pid=pid, name=name, command_line=cl, age_seconds=age, cpu_seconds=cpu, category=cat)) return procs def _categorize(cl: str) -> str | None: if "sloppy.py" in cl and "--enable-test-hooks" in cl: return "sloppy.py (test leftover)" if "scripts/mcp_server.py" in cl or "manual_slop" in cl and "mcp" in cl.lower(): return "manual_slop MCP server (preserve)" if "minimax-coding-plan-mcp" in cl: return "minimax MCP server (preserve)" if "pytest" in cl and "conftest" not in cl: return "pytest runner" if "src.gui_2" in cl and "App()" in cl: return "stuck test subprocess (src.gui_2 + App)" return None def _kill(pid: int) -> bool: r = subprocess.run(["powershell.exe", "-NoProfile", "-Command", f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue; if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'STILL_ALIVE' }} else {{ 'KILLED' }}"], capture_output=True, text=True, timeout=10) return "KILLED" in r.stdout def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--kill", action="store_true", help="Actually kill sloppy.py orphans (default: dry-run)") ap.add_argument("--kill-tests", action="store_true", help="Also kill stuck test subprocesses") ap.add_argument("--kill-mcp", action="store_true", help="Also kill MCP servers (usually DON'T want this)") ap.add_argument("--json", action="store_true", help="Machine-readable JSON output") args = ap.parse_args() procs = _enum_processes() by_cat: dict[str, list[ProcInfo]] = {} for p in procs: by_cat.setdefault(p.category, []).append(p) if args.json: out = { "dry_run": not args.kill, "categories": {cat: [asdict(p) for p in items] for cat, items in by_cat.items()}, } print(json.dumps(out, indent=2)) return 0 print("=" * 80) print(f"manual_slop process cleanup ({'DRY-RUN' if not args.kill else 'KILL MODE'})") print("=" * 80) kill_targets: list[ProcInfo] = [] preserve_targets: list[ProcInfo] = [] for cat, items in by_cat.items(): is_orphan = cat.startswith("sloppy.py") is_test = cat.startswith("pytest") or cat.startswith("stuck test") is_mcp = "MCP server" in cat if is_orphan: kill_targets.extend(items) header = f"[KILL: sloppy.py orphans] ({len(items)} found)" elif is_test and args.kill_tests: kill_targets.extend(items) header = f"[KILL: test subprocesses] ({len(items)} found)" elif is_mcp and args.kill_mcp: kill_targets.extend(items) header = f"[KILL: MCP servers] ({len(items)} found)" else: preserve_targets.extend(items) header = f"[PRESERVE] ({len(items)} found) {cat}" print() print(header) for p in items: print(p.render()) print() print("=" * 80) print(f"Totals: {len(kill_targets)} kill candidates, {len(preserve_targets)} preserved") print("=" * 80) if not kill_targets: print("Nothing to kill.") return 0 if not args.kill: print() print("DRY-RUN: pass --kill to actually terminate the above kill candidates.") return 0 print() print(f"Killing {len(kill_targets)} process(es)...") killed = 0 failed = [] for p in kill_targets: if _kill(p.pid): killed += 1 print(f" killed PID {p.pid:>6} ({p.category})") else: failed.append(p.pid) print(f" FAILED PID {p.pid:>6} ({p.category})") print() print(f"Result: {killed} killed, {len(failed)} failed") return 0 if not failed else 1 if __name__ == "__main__": raise SystemExit(main())