mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-10-11 03:37:55 +00:00
324 lines
9.3 KiB
Python
Executable file
324 lines
9.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
|
|
PROCESS_MARKERS = (
|
|
"openspace.mcp_server",
|
|
"openspace.evolution_mcp_server",
|
|
)
|
|
STATE_FILE_PREFIXES = ("main-", "evolution-")
|
|
STATE_FILE_SUFFIXES = (".json", ".lock", ".log")
|
|
|
|
|
|
@dataclass
|
|
class ManagedProcess:
|
|
pid: int
|
|
command: str
|
|
source: str
|
|
state_dir: str | None = None
|
|
record_file: str | None = None
|
|
|
|
|
|
def default_state_dirs() -> list[Path]:
|
|
env_override = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
|
|
candidates = [
|
|
Path(env_override).expanduser().resolve() if env_override else None,
|
|
Path.home() / ".codex" / "state" / "openspace",
|
|
Path.home() / ".codex-openspace" / "state" / "openspace",
|
|
Path.home() / "Library" / "Application Support" / "openspace" / "mcp-daemons",
|
|
]
|
|
|
|
result: list[Path] = []
|
|
seen: set[str] = set()
|
|
for path in candidates:
|
|
if path is None:
|
|
continue
|
|
key = str(path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
result.append(path)
|
|
return result
|
|
|
|
|
|
def is_target_command(command: str) -> bool:
|
|
return any(marker in command for marker in PROCESS_MARKERS)
|
|
|
|
|
|
def pid_exists(pid: int) -> bool:
|
|
if pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def command_for_pid(pid: int) -> str:
|
|
if pid <= 0:
|
|
return ""
|
|
proc = subprocess.run(
|
|
["ps", "-o", "command=", "-p", str(pid)],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return proc.stdout.strip()
|
|
|
|
|
|
def collect_metadata_processes(state_dir: Path) -> tuple[list[ManagedProcess], list[dict[str, object]], list[str]]:
|
|
managed: list[ManagedProcess] = []
|
|
metadata_rows: list[dict[str, object]] = []
|
|
warnings: list[str] = []
|
|
|
|
if not state_dir.is_dir():
|
|
return managed, metadata_rows, warnings
|
|
|
|
for metadata_path in sorted(state_dir.glob("*.json")):
|
|
try:
|
|
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
except Exception as exc: # noqa: BLE001
|
|
warnings.append(f"failed to read {metadata_path}: {exc}")
|
|
continue
|
|
|
|
pid = int(payload.get("pid") or 0)
|
|
command = command_for_pid(pid) if pid_exists(pid) else ""
|
|
live_target = bool(command and is_target_command(command))
|
|
metadata_rows.append(
|
|
{
|
|
"state_dir": str(state_dir),
|
|
"record_file": metadata_path.name,
|
|
"pid": pid,
|
|
"port": payload.get("port"),
|
|
"workspace": payload.get("workspace"),
|
|
"server_kind": payload.get("server_kind"),
|
|
"live_target": live_target,
|
|
"command": command,
|
|
}
|
|
)
|
|
if live_target:
|
|
managed.append(
|
|
ManagedProcess(
|
|
pid=pid,
|
|
command=command,
|
|
source="metadata",
|
|
state_dir=str(state_dir),
|
|
record_file=metadata_path.name,
|
|
)
|
|
)
|
|
|
|
return managed, metadata_rows, warnings
|
|
|
|
|
|
def collect_orphan_processes(excluded_pids: Iterable[int]) -> list[ManagedProcess]:
|
|
excluded = set(excluded_pids)
|
|
proc = subprocess.run(
|
|
["ps", "-axo", "pid=,command="],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
results: list[ManagedProcess] = []
|
|
for line in proc.stdout.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
pid_text, command = stripped.split(None, 1)
|
|
except ValueError:
|
|
continue
|
|
pid = int(pid_text)
|
|
if pid in excluded:
|
|
continue
|
|
if not is_target_command(command):
|
|
continue
|
|
results.append(ManagedProcess(pid=pid, command=command, source="orphan-scan"))
|
|
return results
|
|
|
|
|
|
def terminate_process(pid: int, timeout_seconds: float) -> str:
|
|
if not pid_exists(pid):
|
|
return "already-exited"
|
|
|
|
os.kill(pid, signal.SIGTERM)
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if not pid_exists(pid):
|
|
return "terminated"
|
|
time.sleep(0.1)
|
|
|
|
if pid_exists(pid):
|
|
os.kill(pid, signal.SIGKILL)
|
|
deadline = time.monotonic() + 1.0
|
|
while time.monotonic() < deadline:
|
|
if not pid_exists(pid):
|
|
return "killed"
|
|
time.sleep(0.05)
|
|
return "kill-sent"
|
|
|
|
|
|
def removable_state_files(state_dir: Path, keep_logs: bool) -> list[Path]:
|
|
if not state_dir.is_dir():
|
|
return []
|
|
|
|
removable: list[Path] = []
|
|
for path in sorted(state_dir.iterdir()):
|
|
if not path.is_file():
|
|
continue
|
|
if not path.name.startswith(STATE_FILE_PREFIXES):
|
|
continue
|
|
if not path.name.endswith(STATE_FILE_SUFFIXES):
|
|
continue
|
|
if keep_logs and path.suffix == ".log":
|
|
continue
|
|
removable.append(path)
|
|
return removable
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Clean OpenSpace MCP daemon processes and state files")
|
|
parser.add_argument(
|
|
"--state-dir",
|
|
action="append",
|
|
default=[],
|
|
help="Additional state directory to clean. Can be passed multiple times.",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would be terminated/removed without changing anything.",
|
|
)
|
|
parser.add_argument(
|
|
"--keep-logs",
|
|
action="store_true",
|
|
help="Keep *.log files in state dirs while removing json/lock artifacts.",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout-seconds",
|
|
type=float,
|
|
default=3.0,
|
|
help="How long to wait after SIGTERM before SIGKILL.",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Print the final report as JSON.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
|
|
state_dirs = default_state_dirs()
|
|
for raw in args.state_dir:
|
|
state_dirs.append(Path(raw).expanduser().resolve())
|
|
|
|
deduped_state_dirs: list[Path] = []
|
|
seen_state_dirs: set[str] = set()
|
|
for path in state_dirs:
|
|
key = str(path)
|
|
if key in seen_state_dirs:
|
|
continue
|
|
seen_state_dirs.add(key)
|
|
deduped_state_dirs.append(path)
|
|
|
|
metadata_processes: list[ManagedProcess] = []
|
|
metadata_rows: list[dict[str, object]] = []
|
|
warnings: list[str] = []
|
|
for state_dir in deduped_state_dirs:
|
|
managed, rows, row_warnings = collect_metadata_processes(state_dir)
|
|
metadata_processes.extend(managed)
|
|
metadata_rows.extend(rows)
|
|
warnings.extend(row_warnings)
|
|
|
|
orphan_processes = collect_orphan_processes(proc.pid for proc in metadata_processes)
|
|
|
|
unique_processes: dict[int, ManagedProcess] = {}
|
|
for proc in [*metadata_processes, *orphan_processes]:
|
|
unique_processes.setdefault(proc.pid, proc)
|
|
|
|
process_actions: list[dict[str, object]] = []
|
|
for proc in sorted(unique_processes.values(), key=lambda item: item.pid):
|
|
action = "would-terminate" if args.dry_run else terminate_process(proc.pid, args.timeout_seconds)
|
|
process_actions.append(
|
|
{
|
|
**asdict(proc),
|
|
"action": action,
|
|
}
|
|
)
|
|
|
|
file_actions: list[dict[str, object]] = []
|
|
for state_dir in deduped_state_dirs:
|
|
for path in removable_state_files(state_dir, keep_logs=args.keep_logs):
|
|
action = "would-remove"
|
|
if not args.dry_run:
|
|
path.unlink(missing_ok=True)
|
|
action = "removed"
|
|
file_actions.append(
|
|
{
|
|
"state_dir": str(state_dir),
|
|
"path": str(path),
|
|
"action": action,
|
|
}
|
|
)
|
|
|
|
report = {
|
|
"state_dirs": [str(path) for path in deduped_state_dirs],
|
|
"metadata_records": metadata_rows,
|
|
"process_actions": process_actions,
|
|
"file_actions": file_actions,
|
|
"warnings": warnings,
|
|
"dry_run": args.dry_run,
|
|
}
|
|
|
|
if args.json:
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
print("OpenSpace daemon cleanup")
|
|
print(f"dry run: {'yes' if args.dry_run else 'no'}")
|
|
print("state dirs:")
|
|
for path in report["state_dirs"]:
|
|
print(f"- {path}")
|
|
|
|
print("\nprocesses:")
|
|
if process_actions:
|
|
for item in process_actions:
|
|
print(
|
|
f"- pid={item['pid']} source={item['source']} action={item['action']} "
|
|
f"record={item.get('record_file') or '-'}"
|
|
)
|
|
else:
|
|
print("- none")
|
|
|
|
print("\nfiles:")
|
|
if file_actions:
|
|
for item in file_actions:
|
|
print(f"- {item['action']}: {item['path']}")
|
|
else:
|
|
print("- none")
|
|
|
|
if warnings:
|
|
print("\nwarnings:")
|
|
for warning in warnings:
|
|
print(f"- {warning}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|