Files
ai-agent/sidecar/pids.py
Gabriel Vidal cf01bc00f4 refactor(sidecar): split sidecar.py into focused modules
config / pids / validate / claude_accounts / launch / fork / routes_runs /
routes_accounts, mounted by a thin sidecar.py (same `sidecar:app`, same 24
routes, same startup hook). Mutable state keeps one owner module
(pids._procs, claude_accounts._account_status_cache). Includes the
_claude_args builder from 1de9426 and its test; the Dockerfile's explicit
COPY list gains the new modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

176 lines
6.2 KiB
Python

"""
Pid-record tracking: which sessions are still running, across sidecar restarts.
Owns the mutable ``_procs`` table (the children we launched ourselves) — other
modules reach it as ``pids._procs``, never by copying the reference.
"""
import json
import os
import pathlib
import subprocess
from config import LOG_DIR
# ---- pid tracking ---------------------------------------------------------
# Each running session is persisted to `logs/<sid>.pid` as a small JSON record
# so the sidecar can re-discover, list and interrupt runs that outlived it. The
# `/proc` start-time pins the record to *this* process, so a recycled PID (a new
# unrelated process that happens to reuse the number) is never mistaken for a
# live session — critical before we send it a signal.
# The runs we launched ourselves, kept only so we can *reap* them. A `claude`
# started with Popen stays our child even though it leads its own session, so
# when it exits it lingers as a zombie until the parent waits on it — and a
# zombie still answers `os.kill(pid, 0)` and still has a `/proc/<pid>/stat`. So
# a finished run kept looking "alive", `/resume` took the idempotency shortcut
# below, launched nothing, and the prompt was silently dropped. We poll (reap)
# them before every liveness check, and treat state `Z` as dead as a belt-and-
# braces guard (a survivor of a previous sidecar instance is reparented to init,
# which reaps it for us).
_procs: dict[str, subprocess.Popen] = {}
def _reap() -> None:
"""Wait on any of our children that have exited, so no zombie lingers."""
for sid, proc in list(_procs.items()):
if proc.poll() is not None: # returncode set ⇒ child reaped
_procs.pop(sid, None)
# Linux reads /proc directly (no dependency — the host sidecar's venv has only
# fastapi + uvicorn); anywhere else (the macOS worker) falls back to psutil,
# which the worker installer puts in its venv. Both answer the same two
# questions: is the process a zombie, and what is its start-time fingerprint.
_HAVE_PROC = pathlib.Path("/proc/self/stat").exists()
def _psutil_proc(pid: int):
try:
import psutil # noqa: PLC0415 — optional, macOS worker only
return psutil.Process(pid)
except Exception: # ImportError, NoSuchProcess, AccessDenied
return None
def _proc_state(pid: int) -> str | None:
"""Process state letter ('Z' = zombie, 'S'/'R' = live), from /proc/pid/stat
on Linux, psutil elsewhere."""
if not _HAVE_PROC:
p = _psutil_proc(pid)
try:
return None if p is None else (
"Z" if p.status() == "zombie" else "R")
except Exception:
return None
try:
stat = pathlib.Path(f"/proc/{pid}/stat").read_text()
return stat[stat.rindex(")") + 1:].split()[0]
except (OSError, ValueError, IndexError):
return None
def _proc_starttime(pid: int) -> int | float | None:
"""Process start-time fingerprint: clock ticks since boot (field 22 of
/proc/pid/stat) on Linux, psutil's ``create_time()`` (epoch seconds,
rounded) elsewhere.
Unique per (pid, boot), so it fingerprints the exact process and lets us
detect PID reuse. The `comm` field (field 2) can contain spaces and parens,
so we parse everything after the final ')'."""
if not _HAVE_PROC:
p = _psutil_proc(pid)
try:
return None if p is None else round(p.create_time(), 2)
except Exception:
return None
try:
stat = pathlib.Path(f"/proc/{pid}/stat").read_text()
after = stat[stat.rindex(")") + 1:].split()
return int(after[19]) # field 22 overall (fields 3.. after comm)
except (OSError, ValueError, IndexError):
return None
def _pidfile(sid: str) -> pathlib.Path:
return LOG_DIR / f"{sid}.pid"
def _read_record(sid: str) -> dict | None:
"""Load a session's pid record, tolerating the legacy plain-integer format."""
try:
raw = _pidfile(sid).read_text().strip()
except OSError:
return None
if not raw:
return None
try:
rec = json.loads(raw)
if isinstance(rec, dict) and rec.get("pid"):
return rec
except ValueError:
pass
try: # legacy pidfile: bare pid, no start-time guard
return {"pid": int(raw)}
except ValueError:
return None
def _alive(rec: dict) -> bool:
"""True if the recorded process is still *running* — same process, not a zombie.
Reaps our finished children first: an exited-but-unwaited child would
otherwise still pass every check below and be mistaken for a live run."""
pid = rec.get("pid")
if not pid:
return False
_reap()
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True # exists but owned by someone else — still 'alive'
except OSError:
return False
if _proc_state(pid) == "Z":
return False # exited, just not reaped yet
want = rec.get("starttime")
if want is not None:
now = _proc_starttime(pid)
if now is not None and now != want:
return False # PID was recycled into a different process
return True
def _live_record(sid: str) -> dict | None:
"""The session's pid record if its run is still going; else None (and the
stale pidfile is dropped, so finished runs don't accumulate on disk)."""
rec = _read_record(sid)
if rec is None:
return None
if _alive(rec):
return rec
_pidfile(sid).unlink(missing_ok=True)
return None
def _reconcile_pidfiles() -> None:
"""On (re)start, GC pidfiles whose process is gone and count the survivors.
The spawned runs are daemons, so after a sidecar restart the disk pidfiles
are our only record of what's still running — reconcile them into a clean
view and drop the dead ones so /interrupt never signals a stale PID."""
if not LOG_DIR.is_dir():
return
survived = 0
for p in sorted(LOG_DIR.glob("*.pid")):
rec = _read_record(p.stem)
if rec is None or not _alive(rec):
p.unlink(missing_ok=True)
else:
survived += 1
print(f"[sidecar] startup reconcile: {survived} session(s) survived restart",
flush=True)