Files

730 lines
27 KiB
Python

#!/usr/bin/env python3
"""Persistent, streaming subprocess runtime for reproducible agent runs."""
from __future__ import annotations
import argparse
import ctypes
import json
import os
import signal
import subprocess
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from command_utils import ShellSyntaxRequired, build_command
from resource_monitor import append_resource_sample, sample_resources
SCHEMA_VERSION = "1.0"
TERMINAL_STATES = {"success", "failed", "timed_out", "cancelled", "blocked", "interrupted"}
ACTIVE_STATES = {"created", "running", "orphaned"}
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def new_run_id() -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"{stamp}-{uuid.uuid4().hex[:8]}"
def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# Keep the sibling name short: exploratory Git worktrees can already sit
# close to the legacy Windows MAX_PATH boundary.
temporary = path.with_name(f".{uuid.uuid4().hex[:4]}.tmp")
temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
os.replace(temporary, path)
class TailBuffer:
"""Retain the newest text up to a fixed character budget."""
def __init__(self, limit: int) -> None:
self.limit = max(0, limit)
self.value = ""
self.truncated = False
self.lock = threading.Lock()
def append(self, chunk: str) -> None:
with self.lock:
combined = self.value + chunk
if len(combined) > self.limit:
self.truncated = True
combined = combined[-self.limit :] if self.limit else ""
self.value = combined
def get(self) -> str:
with self.lock:
return self.value
class RuntimeJournal:
def __init__(self, run_dir: Path, state: Dict[str, Any]) -> None:
self.run_dir = run_dir
self.state_path = run_dir / "state.json"
self.events_path = run_dir / "events.jsonl"
self.state = state
self.sequence = 0
self.lock = threading.Lock()
atomic_write_json(self.state_path, self.state)
def event(self, event_type: str, **data: Any) -> None:
with self.lock:
self.sequence += 1
payload = {
"schema_version": SCHEMA_VERSION,
"sequence": self.sequence,
"timestamp": utc_now(),
"type": event_type,
"data": data,
}
with self.events_path.open("a", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
def update(self, **changes: Any) -> None:
with self.lock:
self.state.update(changes)
atomic_write_json(self.state_path, self.state)
def read_json(path: Path) -> Dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8-sig"))
def append_existing_event(run_dir: Path, event_type: str, **data: Any) -> None:
events_path = run_dir / "events.jsonl"
sequence = 0
if events_path.is_file():
for line in events_path.read_text(encoding="utf-8-sig").splitlines():
try:
sequence = max(sequence, int(json.loads(line).get("sequence") or 0))
except (json.JSONDecodeError, TypeError, ValueError):
continue
payload = {
"schema_version": SCHEMA_VERSION,
"sequence": sequence + 1,
"timestamp": utc_now(),
"type": event_type,
"data": data,
}
with events_path.open("a", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
def pid_is_alive(pid: Optional[int]) -> bool:
if not pid or pid <= 0:
return False
if os.name == "nt":
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
kernel32.WaitForSingleObject.restype = ctypes.c_ulong
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
kernel32.CloseHandle.restype = ctypes.c_int
handle = kernel32.OpenProcess(0x00100000, 0, pid)
if not handle:
return False
try:
return kernel32.WaitForSingleObject(handle, 0) == 258
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def timestamp_age_seconds(value: Any) -> Optional[float]:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return max(0.0, (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds())
except (TypeError, ValueError):
return None
def reconcile_run(run_dir: Path, stale_after_seconds: float = 30.0) -> Dict[str, Any]:
state_path = run_dir / "state.json"
if not state_path.is_file():
return {"run_id": run_dir.name, "status": "invalid", "reason": "missing-state"}
try:
state = read_json(state_path)
except (OSError, json.JSONDecodeError) as exc:
return {"run_id": run_dir.name, "status": "invalid", "reason": f"unreadable-state:{type(exc).__name__}"}
status = str(state.get("status") or "invalid")
if status not in ACTIVE_STATES:
return {"run_id": state.get("run_id", run_dir.name), "status": status, "action": "unchanged"}
pid = state.get("pid")
alive = pid_is_alive(pid if isinstance(pid, int) else None)
heartbeat_age = timestamp_age_seconds(state.get("last_heartbeat"))
if alive and heartbeat_age is not None and heartbeat_age <= stale_after_seconds and status != "orphaned":
return {"run_id": state.get("run_id", run_dir.name), "status": status, "action": "active"}
if alive:
if status == "orphaned":
return {"run_id": state.get("run_id", run_dir.name), "status": "orphaned", "action": "unchanged-orphaned"}
state.update(status="orphaned", recovery_checked_at=utc_now(), recovery_reason="stale-heartbeat-process-alive")
atomic_write_json(state_path, state)
append_existing_event(run_dir, "recovered_orphaned", pid=pid, heartbeat_age_seconds=heartbeat_age)
return {"run_id": state.get("run_id", run_dir.name), "status": "orphaned", "action": "marked-orphaned"}
finished_at = utc_now()
state.update(
status="interrupted",
finished_at=finished_at,
last_heartbeat=finished_at,
recovery_checked_at=finished_at,
recovery_reason="process-not-running",
)
atomic_write_json(state_path, state)
append_existing_event(run_dir, "recovered_interrupted", prior_status=status, pid=pid)
return {"run_id": state.get("run_id", run_dir.name), "status": "interrupted", "action": "marked-interrupted"}
def recover_runtime_root(runtime_root: Path, stale_after_seconds: float = 30.0) -> list[Dict[str, Any]]:
root = Path(runtime_root).resolve()
if not root.is_dir():
return []
return [reconcile_run(path, stale_after_seconds) for path in sorted(root.iterdir()) if path.is_dir()]
def request_cancel(runtime_root: Path, run_id: str) -> Dict[str, Any]:
run_dir = Path(runtime_root).resolve() / run_id
state_path = run_dir / "state.json"
if not state_path.is_file():
raise FileNotFoundError(f"Unknown runtime run: {run_id}")
state = read_json(state_path)
if state.get("status") in TERMINAL_STATES:
return {"run_id": run_id, "status": state.get("status"), "cancel_requested": False}
if state.get("status") == "orphaned":
return {
"run_id": run_id,
"status": "orphaned",
"cancel_requested": False,
"reason": "orphaned-run-requires-explicit-process-inspection",
}
(run_dir / "CANCEL").touch()
return {"run_id": run_id, "status": state.get("status"), "cancel_requested": True}
def list_runs(runtime_root: Path) -> list[Dict[str, Any]]:
root = Path(runtime_root).resolve()
if not root.is_dir():
return []
rows = []
for run_dir in sorted(root.iterdir()):
state_path = run_dir / "state.json"
if not run_dir.is_dir() or not state_path.is_file():
continue
try:
state = read_json(state_path)
rows.append(
{
"run_id": state.get("run_id", run_dir.name),
"status": state.get("status"),
"attempt": state.get("attempt", 1),
"retry_of": state.get("retry_of"),
"pid": state.get("pid"),
"last_heartbeat": state.get("last_heartbeat"),
}
)
except (OSError, json.JSONDecodeError):
rows.append({"run_id": run_dir.name, "status": "invalid"})
return rows
def _stream_reader(
stream: Any,
log_path: Path,
stream_name: str,
capture: TailBuffer,
journal: RuntimeJournal,
) -> None:
total_chars = 0
with log_path.open("w", encoding="utf-8", newline="") as log:
while True:
chunk = stream.read(4096)
if not chunk:
break
log.write(chunk)
log.flush()
capture.append(chunk)
total_chars += len(chunk)
journal.event("stream_chunk", stream=stream_name, characters=len(chunk))
journal.event("stream_closed", stream=stream_name, characters=total_chars)
def _terminate_process_tree(process: subprocess.Popen[str], journal: RuntimeJournal) -> None:
if process.poll() is not None:
return
journal.event("termination_requested", pid=process.pid)
if os.name == "nt":
try:
result = subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
journal.event("taskkill_finished", returncode=result.returncode)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
journal.event("taskkill_failed", error=str(exc))
try:
process.terminate()
except OSError:
pass
else:
try:
os.killpg(process.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
try:
process.terminate()
except OSError:
pass
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
journal.event("termination_escalated", pid=process.pid)
if os.name == "nt":
try:
process.kill()
except OSError:
pass
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
process.kill()
except OSError:
pass
def record_resource_snapshot(
*,
pid: int,
resources_path: Path,
summary: Dict[str, Any],
journal: RuntimeJournal,
include_gpu: bool,
) -> None:
try:
sample = sample_resources(pid, include_gpu=include_gpu)
append_resource_sample(resources_path, sample)
except Exception as exc:
journal.event("resource_sample_failed", error=f"{type(exc).__name__}: {exc}")
return
process = sample.get("process") or {}
accelerators = sample.get("accelerators") or {}
summary["samples"] = int(summary.get("samples") or 0) + 1
rss = process.get("rss_bytes")
cpu = process.get("cpu_seconds")
if isinstance(rss, int):
summary["max_root_process_rss_bytes"] = max(int(summary.get("max_root_process_rss_bytes") or 0), rss)
if isinstance(cpu, (int, float)):
summary["max_root_process_cpu_seconds"] = max(float(summary.get("max_root_process_cpu_seconds") or 0.0), float(cpu))
if accelerators.get("available"):
summary["gpu_sampling_available"] = True
peaks = summary.setdefault("max_device_gpu_memory_used_mib", {})
for device in accelerators.get("devices") or []:
key = str(device.get("uuid") or device.get("index"))
used = device.get("memory_used_mib")
if isinstance(used, (int, float)):
peaks[key] = max(float(peaks.get(key) or 0.0), float(used))
journal.event(
"resource_sample",
root_process_available=bool(process.get("available")),
rss_bytes=rss,
cpu_seconds=cpu,
gpu_sampled=include_gpu,
gpu_available=bool(accelerators.get("available")),
)
def resolve_direct_argv(argv: List[str], cwd: Path, environment: Dict[str, str]) -> List[str]:
"""Resolve bare executables only against the environment used by the child.
Windows CreateProcess may prefer the controller's interpreter directory
over an activated venv. Do not delegate that search or resolve a venv
interpreter symlink to its host target. Relative explicit paths use cwd.
"""
if not argv or not argv[0]:
raise ValueError("direct command must name an executable")
name = argv[0]
if os.path.dirname(name):
if os.path.isabs(name):
return list(argv)
return [os.path.abspath(cwd / name), *argv[1:]]
def value(key: str) -> Optional[str]:
if os.name != "nt":
return environment.get(key)
return next((item for candidate, item in environment.items() if candidate.upper() == key), None)
path_value = value("PATH")
if not path_value:
raise FileNotFoundError(f"Executable not found on child PATH: {name} (PATH is empty or absent)")
suffixes = [""]
if os.name == "nt":
extensions = value("PATHEXT")
if extensions is None:
extensions = ".COM;.EXE;.BAT;.CMD"
suffixes.extend(extension for extension in extensions.split(";") if extension)
for entry in path_value.split(os.pathsep):
if os.name == "nt" and entry.startswith('"') and entry.endswith('"'):
entry = entry[1:-1]
directory = Path(entry) if entry else cwd
if not directory.is_absolute():
directory = cwd / directory
for suffix in suffixes:
candidate = Path(os.path.abspath(directory / (name + suffix)))
if candidate.is_file() and os.access(candidate, os.X_OK):
return [str(candidate), *argv[1:]]
raise FileNotFoundError(f"Executable not found on child PATH: {name}")
def run_persistent_command(
*,
repo: Path,
command: str,
timeout: int,
runtime_root: Path,
shell_mode: str = "direct",
run_id: Optional[str] = None,
capture_limit: int = 1_048_576,
heartbeat_interval: float = 1.0,
model_adapter: Optional[Dict[str, Any]] = None,
retry_of: Optional[str] = None,
attempt: int = 1,
monitor_gpu: bool = False,
child_env: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Run a command with durable state, append-only events, and streamed logs."""
if timeout <= 0:
raise ValueError("timeout must be greater than zero")
if capture_limit < 0:
raise ValueError("capture_limit must be non-negative")
if attempt < 1:
raise ValueError("attempt must be at least one")
repo = Path(repo).resolve()
runtime_root = Path(runtime_root).resolve()
run_id = run_id or new_run_id()
run_dir = runtime_root / run_id
run_dir.mkdir(parents=True, exist_ok=False)
started_at = utc_now()
started_monotonic = time.monotonic()
spec = {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
"command": command,
"cwd": str(repo),
"timeout_seconds": timeout,
"shell_mode": shell_mode,
"capture_limit_characters": capture_limit,
"model_adapter": model_adapter,
"retry_of": retry_of,
"attempt": attempt,
"resource_monitoring": {
"root_process": True,
"nvidia_device_global": monitor_gpu,
},
"created_at": started_at,
}
atomic_write_json(run_dir / "spec.json", spec)
state: Dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
"attempt": attempt,
"retry_of": retry_of,
"status": "created",
"created_at": started_at,
"started_at": None,
"finished_at": None,
"last_heartbeat": started_at,
"pid": None,
"returncode": None,
"timed_out": False,
"cancelled": False,
"launch_error": None,
"stdout_truncated": False,
"stderr_truncated": False,
"model_adapter": model_adapter,
"resource_summary": {
"scope": "root_process_and_optional_device_global",
"samples": 0,
"max_root_process_rss_bytes": 0,
"max_root_process_cpu_seconds": 0.0,
"gpu_sampling_available": False,
"max_device_gpu_memory_used_mib": {},
},
}
journal = RuntimeJournal(run_dir, state)
journal.event("created")
stdout_capture = TailBuffer(capture_limit)
stderr_capture = TailBuffer(capture_limit)
stdout_path = run_dir / "stdout.log"
stderr_path = run_dir / "stderr.log"
resources_path = run_dir / "resources.jsonl"
stdout_path.touch()
stderr_path.touch()
resources_path.touch()
try:
argv = build_command(command, shell_mode)
environment = dict(os.environ if child_env is None else child_env)
spec["requested_argv"] = list(argv)
atomic_write_json(run_dir / "spec.json", spec)
if shell_mode == "direct":
argv = resolve_direct_argv(argv, repo, environment)
spec["argv"] = list(argv)
atomic_write_json(run_dir / "spec.json", spec)
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
process = subprocess.Popen(
argv,
env=environment,
cwd=repo,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
creationflags=creationflags,
start_new_session=os.name != "nt",
)
except (FileNotFoundError, ShellSyntaxRequired, OSError, ValueError) as exc:
finished_at = utc_now()
journal.update(
status="blocked",
finished_at=finished_at,
last_heartbeat=finished_at,
launch_error=str(exc),
)
journal.event("launch_failed", error=str(exc))
return _result_payload(journal.state, run_dir, stdout_capture, stderr_capture, shell_mode, started_monotonic)
journal.event("started", pid=process.pid, argv=argv)
journal.update(status="running", started_at=utc_now(), pid=process.pid, last_heartbeat=utc_now())
resource_summary = journal.state["resource_summary"]
record_resource_snapshot(
pid=process.pid,
resources_path=resources_path,
summary=resource_summary,
journal=journal,
include_gpu=monitor_gpu,
)
journal.update(resource_summary=resource_summary)
threads = [
threading.Thread(
target=_stream_reader,
args=(process.stdout, stdout_path, "stdout", stdout_capture, journal),
daemon=True,
),
threading.Thread(
target=_stream_reader,
args=(process.stderr, stderr_path, "stderr", stderr_capture, journal),
daemon=True,
),
]
for thread in threads:
thread.start()
timed_out = False
cancelled = False
cancel_path = run_dir / "CANCEL"
next_heartbeat = time.monotonic() + max(0.1, heartbeat_interval)
next_gpu_sample = time.monotonic() + 5.0
try:
while process.poll() is None:
now = time.monotonic()
if cancel_path.exists():
cancelled = True
journal.event("cancel_detected", source="CANCEL")
_terminate_process_tree(process, journal)
break
if timeout >= 0 and now - started_monotonic >= timeout:
timed_out = True
journal.event("timeout_detected", timeout_seconds=timeout)
_terminate_process_tree(process, journal)
break
if now >= next_heartbeat:
include_gpu = monitor_gpu and now >= next_gpu_sample
record_resource_snapshot(
pid=process.pid,
resources_path=resources_path,
summary=resource_summary,
journal=journal,
include_gpu=include_gpu,
)
if include_gpu:
next_gpu_sample = now + 5.0
journal.update(last_heartbeat=utc_now(), resource_summary=resource_summary)
journal.event("heartbeat", pid=process.pid)
next_heartbeat = now + max(0.1, heartbeat_interval)
time.sleep(0.05)
except KeyboardInterrupt:
cancelled = True
journal.event("cancel_detected", source="keyboard_interrupt")
_terminate_process_tree(process, journal)
try:
returncode = process.wait(timeout=5)
except subprocess.TimeoutExpired:
_terminate_process_tree(process, journal)
returncode = process.poll()
for thread in threads:
thread.join(timeout=5)
if cancelled:
status = "cancelled"
elif timed_out:
status = "timed_out"
elif returncode == 0:
status = "success"
else:
status = "failed"
finished_at = utc_now()
journal.update(
status=status,
finished_at=finished_at,
last_heartbeat=finished_at,
returncode=returncode,
timed_out=timed_out,
cancelled=cancelled,
stdout_truncated=stdout_capture.truncated,
stderr_truncated=stderr_capture.truncated,
resource_summary=resource_summary,
)
journal.event("completed", status=status, returncode=returncode)
return _result_payload(journal.state, run_dir, stdout_capture, stderr_capture, shell_mode, started_monotonic)
def _result_payload(
state: Dict[str, Any],
run_dir: Path,
stdout_capture: TailBuffer,
stderr_capture: TailBuffer,
shell_mode: str,
started_monotonic: float,
) -> Dict[str, Any]:
return {
"returncode": state.get("returncode"),
"timed_out": bool(state.get("timed_out")),
"cancelled": bool(state.get("cancelled")),
"launch_error": state.get("launch_error"),
"execution_mode": shell_mode,
"stdout": stdout_capture.get(),
"stderr": stderr_capture.get(),
"stdout_truncated": stdout_capture.truncated,
"stderr_truncated": stderr_capture.truncated,
"runtime_run_id": state["run_id"],
"runtime_dir": str(run_dir),
"runtime_status": state["status"],
"runtime_attempt": state.get("attempt", 1),
"runtime_retry_of": state.get("retry_of"),
"runtime_state_path": str(run_dir / "state.json"),
"runtime_events_path": str(run_dir / "events.jsonl"),
"stdout_log_path": str(run_dir / "stdout.log"),
"stderr_log_path": str(run_dir / "stderr.log"),
"resources_log_path": str(run_dir / "resources.jsonl"),
"resource_summary": state.get("resource_summary", {}),
"model_adapter": state.get("model_adapter"),
"pid": state.get("pid"),
"duration_seconds": round(time.monotonic() - started_monotonic, 6),
}
def retry_run(
*,
runtime_root: Path,
run_id: str,
timeout: Optional[int] = None,
allow_success_retry: bool = False,
) -> Dict[str, Any]:
root = Path(runtime_root).resolve()
parent_dir = root / run_id
state_path = parent_dir / "state.json"
spec_path = parent_dir / "spec.json"
if not state_path.is_file() or not spec_path.is_file():
raise FileNotFoundError(f"Unknown or incomplete runtime run: {run_id}")
recovery = reconcile_run(parent_dir)
state = read_json(state_path)
status = str(state.get("status") or recovery.get("status"))
if status in ACTIVE_STATES:
raise RuntimeError(f"Run {run_id} is still active or orphaned ({status}); refusing duplicate execution")
if status == "success" and not allow_success_retry:
raise RuntimeError(f"Run {run_id} already succeeded; use allow_success_retry only when repetition is intentional")
spec = read_json(spec_path)
selected_timeout = int(timeout if timeout is not None else spec.get("timeout_seconds", 60))
if selected_timeout <= 0:
raise ValueError("retry timeout must be greater than zero")
return run_persistent_command(
repo=Path(spec["cwd"]),
command=str(spec["command"]),
timeout=selected_timeout,
runtime_root=root,
shell_mode=str(spec.get("shell_mode") or "direct"),
capture_limit=int(spec.get("capture_limit_characters") or 1_048_576),
model_adapter=spec.get("model_adapter"),
retry_of=run_id,
attempt=int(state.get("attempt") or 1) + 1,
monitor_gpu=bool((spec.get("resource_monitoring") or {}).get("nvidia_device_global")),
)
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect and recover persistent RigorPilot runtime runs.")
parser.add_argument("--runtime-root", required=True, help="Runtime root containing per-run directories.")
subparsers = parser.add_subparsers(dest="action", required=True)
subparsers.add_parser("list", help="List durable run states.")
recover_parser = subparsers.add_parser("recover", help="Reconcile stale active states after a controller restart.")
recover_parser.add_argument("--stale-after", type=float, default=30.0)
cancel_parser = subparsers.add_parser("cancel", help="Write a cancellation request for an actively monitored run.")
cancel_parser.add_argument("--run-id", required=True)
retry_parser = subparsers.add_parser("retry", help="Explicitly retry a terminal run as a new attempt.")
retry_parser.add_argument("--run-id", required=True)
retry_parser.add_argument("--timeout", type=int)
retry_parser.add_argument("--allow-success-retry", action="store_true")
args = parser.parse_args()
root = Path(args.runtime_root)
if args.action == "recover" and args.stale_after < 0:
parser.error("--stale-after must be non-negative")
try:
if args.action == "list":
payload: Any = list_runs(root)
elif args.action == "recover":
payload = recover_runtime_root(root, args.stale_after)
elif args.action == "cancel":
payload = request_cancel(root, args.run_id)
else:
payload = retry_run(
runtime_root=root,
run_id=args.run_id,
timeout=args.timeout,
allow_success_retry=args.allow_success_retry,
)
except (FileNotFoundError, RuntimeError, ValueError) as exc:
print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
return 2
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())