Private
Public Access
0
0

feat(scripts): add cleanup_orphaned_processes.py for sloppy.py leftover cleanup

After test runs that use live_gui, dozens of sloppy.py --enable-test-hooks processes can leak (the watchdog e1c8730f bounds the hang but doesn't kill the spawned GUI subprocesses). This script:

- Enumerates all python.exe / uv.exe processes via CIM
- Categorizes each by command-line content:
  - sloppy.py --enable-test-hooks       -> KILL (orphans)
  - scripts/mcp_server.py               -> PRESERVE (manual_slop's MCP server, used by opencode)
  - minimax-coding-plan-mcp             -> PRESERVE (opencode's MCP server, used by opencode)
  - pytest runner / stuck App() test    -> PRESERVE by default, kill with --kill-tests
- Defaults to DRY-RUN; pass --kill to terminate
- --kill-tests: also kill stuck test subprocesses
- --kill-mcp: also kill MCP servers (off by default; usually DON'T want this)
- --json: machine-readable output for CI/scripting

Verified after a 10-batch test run: 28 sloppy.py orphans identified, 21 MCP servers (9 manual_slop + 12 minimax) preserved correctly. The watchdog fix (e1c8730f) bounds the test hang; this script cleans up the leaked GUI subprocesses afterward.

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
This commit is contained in:
2026-06-07 12:11:01 -04:00
parent b94d949b4d
commit 5e1867bb50
+190
View File
@@ -0,0 +1,190 @@
"""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())