"""Deploy Supervisor process skeleton (U-036). Independent OS process (started via `python3 -m supervisor.deploy_supervisor`), pure stdlib, no LLM CLI dependency -- per docs/supervisor-auto-rollback-design.md ยง2's "three independent processes, no shared failure domain" principle: Deploy Supervisor crashing must not affect Live/Candidate, and Live/Candidate crashing must not affect Deploy Supervisor's own ability to keep observing and act. PID/status file conventions adapted (NOT imported -- see CLAUDE.md's "do not import graphloop code directly" rule) from graphloop's hermes_watchdog.py: atomic writes (tempfile-in-same-dir + os.replace), liveness via `os.kill(pid, 0)`, fail-closed (this skeleton never auto-restarts anything -- a crashing `poll_fn` stops the loop and marks status='stopped' rather than silently dying while still holding the PID file, which would make `is_running()` lie). Scope: this Unit owns process lifecycle only (start/stop/status/PID file). The actual "check for staged deployments and act" logic is U-037+'s job, injected here as the `poll_fn` callback -- U-036 has no opinion about what that logic does, only about running it on a schedule and surviving/reporting its own crashes correctly. """ import argparse import json import os import signal import subprocess import sys import time from src import paths def runtime_dir(): d = os.path.join(paths.platform_home(), "deploy_supervisor") os.makedirs(d, exist_ok=True) return d def pid_file_path(): return os.path.join(runtime_dir(), "supervisor.pid") def status_file_path(): return os.path.join(runtime_dir(), "supervisor.status.json") def _atomic_write_json(path, data): tmp = path + ".{}.tmp".format(os.getpid()) with open(tmp, "w", encoding="utf-8") as handle: json.dump(data, handle) os.replace(tmp, path) def is_alive(pid): """`os.kill(pid, 0)`-based liveness -- NOTE this can't distinguish "truly running" from "exited but not yet reaped by its parent" (a zombie's PID slot still answers kill(pid, 0) successfully until something calls wait() on it). If the calling process spawned `pid` itself via `subprocess.Popen` and holds onto it across multiple `is_alive()` calls without ever waiting on it, this can report True forever for an already-dead process -- found for real (2026-08-02, U-039) via a test that actually waited for a stopped process to go away and it never did. Callers that spawned `pid` themselves should use `reap_if_exited()` instead, which handles this correctly; this function is still right for checking a pid this process did NOT spawn (nothing to reap, this is the best available check).""" if not isinstance(pid, int) or pid <= 0: return False try: os.kill(pid, 0) except OSError: return False return True def reap_if_exited(pid): """True once `pid` is confirmed gone. If `pid` is a real child of the calling process (e.g. spawned via `subprocess.Popen` earlier in this same process's lifetime), this actually reaps it via a non-blocking `os.waitpid` -- required for `is_alive()` to ever correctly report False for it afterward (see that function's own docstring for why). If `pid` isn't our child (belongs to a different process/session -- e.g. a `live_pid` inherited from a prior Deploy Supervisor run), `os.waitpid` raises `ChildProcessError` and this falls back to `is_alive()`'s plain liveness check instead, which is the best available answer without being the real parent.""" try: reaped_pid, _ = os.waitpid(pid, os.WNOHANG) if reaped_pid == pid: return True except ChildProcessError: pass return not is_alive(pid) def write_status(status, detail=None): _atomic_write_json(status_file_path(), { "status": status, "pid": os.getpid(), "updated_at": time.time(), "detail": detail, }) def read_status(): try: with open(status_file_path(), encoding="utf-8") as handle: return json.load(handle) except (OSError, json.JSONDecodeError): return None def is_running(): """True if a Deploy Supervisor process is currently alive per the PID file. Doesn't distinguish "never started" from "PID file stale" -- callers needing that distinction should also check `read_status()`.""" try: with open(pid_file_path(), encoding="utf-8") as handle: pid = int(handle.read().strip()) except (OSError, ValueError): return False return is_alive(pid) class _StopRequested(Exception): """Internal: raised by the SIGTERM handler to break run_foreground's loop.""" def run_foreground(poll_fn=None, poll_interval=5.0, iterations=None): """Blocking main loop -- calls `poll_fn()` (if given) once per `poll_interval` seconds, up to `iterations` cycles (None = forever, only exits on SIGTERM). Writes a PID file + 'running' status on entry, and unconditionally writes a 'stopped' status + removes the PID file on any exit path (normal completion, SIGTERM, or an unhandled exception from `poll_fn`) -- an exception from `poll_fn` propagates to the caller after cleanup runs, it is not swallowed (fail-closed: a broken poll cycle should surface, not vanish into an infinite silent retry). """ def _handle_sigterm(signum, frame): raise _StopRequested() previous_handler = signal.signal(signal.SIGTERM, _handle_sigterm) with open(pid_file_path(), "w", encoding="utf-8") as handle: handle.write(str(os.getpid())) write_status("running") try: count = 0 while iterations is None or count < iterations: if poll_fn is not None: poll_fn() count += 1 if iterations is not None and count >= iterations: break time.sleep(poll_interval) except _StopRequested: pass finally: signal.signal(signal.SIGTERM, previous_handler) write_status("stopped") if os.path.exists(pid_file_path()): os.remove(pid_file_path()) def start_detached(poll_interval=5.0): """Launches `python3 -m supervisor.deploy_supervisor` as a real, independent OS process (Popen, not a thread) -- crashing does not take down whatever called this. Returns the child's pid. Raises RuntimeError if a Deploy Supervisor is already running (per its own PID file).""" if is_running(): raise RuntimeError("Deploy Supervisor is already running") process = subprocess.Popen( [sys.executable, "-m", "supervisor.deploy_supervisor", "--poll-interval", str(poll_interval)], cwd=paths.repo_root(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return process.pid def stop(timeout=10.0): """Sends SIGTERM to the running Deploy Supervisor (if any) and waits up to `timeout` seconds for it to exit cleanly (PID file removed). Returns True if it stopped, False if it was already not running or didn't stop in time -- fail-closed: this does not SIGKILL on timeout, that's a judgment call left to whoever's driving it, not silently forced here.""" try: with open(pid_file_path(), encoding="utf-8") as handle: pid = int(handle.read().strip()) except (OSError, ValueError): return False if not is_alive(pid): return False os.kill(pid, signal.SIGTERM) deadline = time.time() + timeout while time.time() < deadline: if not is_running(): reap_if_exited(pid) # best-effort zombie cleanup, doesn't affect the True/False result return True time.sleep(0.1) return False def main(): parser = argparse.ArgumentParser() parser.add_argument("--poll-interval", type=float, default=5.0) args = parser.parse_args() run_foreground(poll_interval=args.poll_interval) if __name__ == "__main__": main()