hermes-agent/gateway/lifecycle_ledger.py
Teknium a65494ed00 fix(gateway): treat pid=None lifecycle sentinel as unknown ownership in mark_exited
The ownership guard let a sentinel with pid=None pass as self-owned, so
an exiting life could clobber evidence of unknown provenance with a
clean-exit claim. Tighten: only rewrite when the sentinel pid matches
os.getpid() exactly; pid=None (or malformed) is left untouched. Adds
tests for the pid=None no-op and the own-pid rewrite paths.
2026-07-28 22:41:09 -07:00

323 lines
12 KiB
Python

"""Gateway lifecycle ledger — durable termination-reason evidence (NS-608).
The gateway already has *graceful* shutdown forensics
(:mod:`gateway.shutdown_forensics` — who sent the SIGTERM) and an exit-path
diagnostic log (``gateway-exit-diag.log`` — every way ``asyncio.run`` can
return). What it does NOT have is any record of an **unclean death**: a
SIGKILL, a kernel OOM kill, or the whole VM dying takes the process out
before any handler runs, so the next boot has no idea the previous life
ended violently — support tickets like NS-608 then require manually
cross-correlating four log files and two external APIs to answer "what
killed the gateway?".
This module closes that gap with a tiny state machine persisted to
``<HERMES_HOME>/state/gateway.lifecycle.json``:
* On startup, :func:`record_startup` reads the sentinel left by the
previous life. ``phase == "running"`` means that life never reached any
exit path → it died uncleanly. The finding — including the last
heartbeat's memory sample, which is the closest thing to a pre-death
telemetry snapshot — is appended to ``gateway-exit-diag.log`` as a
``gateway.previous_unclean_exit`` record and logged at WARNING. The
sentinel is then rewritten as ``phase=running`` for the new life.
* On every clean exit path, :func:`mark_exited` rewrites the sentinel as
``phase=exited`` with the exit code and a reason string. Wired into
``_exit_after_graceful_shutdown`` (the single funnel for all graceful
exits, #53107) and the two watchdog ``os._exit`` sites in
:mod:`gateway.shutdown_watchdog`.
:func:`sample_memory` provides the cheap (<1ms, pure /proc reads) memory
snapshot that :func:`gateway.shutdown_watchdog.write_loop_heartbeat`
embeds in the 30s heartbeat — giving every unclean-death report a
"memory available N seconds before death" data point so OOM crash cycles
are classifiable from the volume alone (no Prometheus retention races).
Everything here is best-effort: a forensics failure must never affect the
gateway lifecycle it is observing.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_LIFECYCLE_RELATIVE = ("state", "gateway.lifecycle.json")
_EXIT_DIAG_RELATIVE = ("logs", "gateway-exit-diag.log")
# Heuristic OOM-suspicion thresholds applied to the last heartbeat's memory
# sample. Deliberately conservative: this only annotates the report with a
# hint; classification stays with the human reading the evidence.
_LOW_MEM_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_LOW_MEM_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal available
def _process_hermes_home() -> Path:
"""HERMES_HOME for process-level identity files (ignore task overrides)."""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
from hermes_constants import get_hermes_home
return get_hermes_home()
def get_lifecycle_sentinel_path(home: Optional[Path] = None) -> Path:
"""Return ``<HERMES_HOME>/state/gateway.lifecycle.json``."""
base = home if home is not None else _process_hermes_home()
return base.joinpath(*_LIFECYCLE_RELATIVE)
def sample_memory() -> Dict[str, Any]:
"""Cheap memory snapshot: own RSS + system availability + swap.
Pure ``/proc`` reads, Linux-only (returns ``{}`` elsewhere), never
raises. Values in KiB to match the kernel's units.
"""
sample: Dict[str, Any] = {}
try:
with open("/proc/self/status", encoding="utf-8") as fh:
for line in fh:
if line.startswith("VmRSS:"):
sample["rss_kib"] = int(line.split()[1])
break
except (OSError, ValueError, IndexError):
pass
try:
meminfo: Dict[str, int] = {}
wanted = {"MemTotal", "MemAvailable", "SwapTotal", "SwapFree"}
with open("/proc/meminfo", encoding="utf-8") as fh:
for line in fh:
key = line.split(":", 1)[0]
if key in wanted:
meminfo[key] = int(line.split()[1])
if len(meminfo) == len(wanted):
break
if "MemTotal" in meminfo:
sample["mem_total_kib"] = meminfo["MemTotal"]
if "MemAvailable" in meminfo:
sample["mem_available_kib"] = meminfo["MemAvailable"]
if "SwapTotal" in meminfo and "SwapFree" in meminfo:
sample["swap_used_kib"] = meminfo["SwapTotal"] - meminfo["SwapFree"]
except (OSError, ValueError, IndexError):
pass
return sample
def _read_json(path: Path) -> Optional[Dict[str, Any]]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write_sentinel(payload: Dict[str, Any], home: Optional[Path]) -> None:
path = get_lifecycle_sentinel_path(home)
try:
from utils import atomic_json_write
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, payload, indent=None)
except Exception:
logger.debug("Failed to write lifecycle sentinel", exc_info=True)
def _append_exit_diag(record: Dict[str, Any], home: Optional[Path]) -> None:
"""Append a JSON line to gateway-exit-diag.log (same format as the CLI's
``_exit_diag`` records so existing tooling greps both)."""
base = home if home is not None else _process_hermes_home()
path = base.joinpath(*_EXIT_DIAG_RELATIVE)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, default=str) + "\n")
except OSError:
logger.debug("Failed to append unclean-exit record", exc_info=True)
def _pid_alive_with_start_time(pid: Any, start_time: Any) -> bool:
"""True when ``pid`` is a live process matching ``start_time`` (±2s).
Guards the takeover race: during ``--replace`` the old gateway can still
be mid-teardown when the new one boots — a live matching owner is a
planned handover, not an unclean death.
"""
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False
try:
# NOT os.kill(pid, 0): on Windows that sends CTRL_C_EVENT to the
# target's console group (bpo-14484). _pid_exists is the repo's
# canonical no-kill cross-platform probe (psutil-backed).
from gateway.status import _pid_exists
if not _pid_exists(pid_int):
return False
except Exception:
return False
if start_time is None:
return True # alive; can't disambiguate PID reuse — err on "alive"
try:
from gateway.status import get_process_start_time
actual = get_process_start_time(pid_int)
if actual is None:
return True
return abs(float(actual) - float(start_time)) <= 2.0
except Exception:
return True
def detect_unclean_exit(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Inspect the previous life's sentinel; return an evidence dict when it
died uncleanly, else ``None``. Read-only — does not rewrite the sentinel.
"""
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if not sentinel or sentinel.get("phase") != "running":
return None
if _pid_alive_with_start_time(sentinel.get("pid"), sentinel.get("start_time")):
return None # live owner — planned takeover in flight, not a death
evidence: Dict[str, Any] = {
"prior_pid": sentinel.get("pid"),
"prior_started_at": sentinel.get("started_at"),
"prior_start_time": sentinel.get("start_time"),
}
# Enrich with the last heartbeat: when did the loop last prove liveness,
# and what did memory look like at that moment?
try:
from gateway.shutdown_watchdog import get_loop_heartbeat_path
hb = _read_json(get_loop_heartbeat_path(home))
except Exception:
hb = None
if hb:
evidence["last_heartbeat_at"] = hb.get("updated_at")
mem = hb.get("mem")
if isinstance(mem, dict):
evidence["last_heartbeat_mem"] = mem
total = mem.get("mem_total_kib")
avail = mem.get("mem_available_kib")
if isinstance(avail, int) and (
avail < _LOW_MEM_AVAILABLE_KIB
or (
isinstance(total, int)
and total > 0
and avail / total < _LOW_MEM_AVAILABLE_FRACTION
)
):
evidence["suspected_oom"] = True
return evidence
def record_startup(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Boot-time entry point: report any unclean previous exit, then claim
the sentinel for the current life.
Returns the unclean-exit evidence dict (also persisted to
``gateway-exit-diag.log`` and logged at WARNING) or ``None``. Never
raises.
"""
evidence: Optional[Dict[str, Any]] = None
try:
evidence = detect_unclean_exit(home)
if evidence is not None:
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"tag": "gateway.previous_unclean_exit",
"pid": os.getpid(),
**evidence,
}
_append_exit_diag(record, home)
logger.warning(
"Previous gateway life (pid=%s, started_at=%s) exited UNCLEANLY "
"(no exit path ran — SIGKILL / OOM / VM death). "
"last_heartbeat_at=%s last_mem=%s suspected_oom=%s",
evidence.get("prior_pid"),
evidence.get("prior_started_at"),
evidence.get("last_heartbeat_at"),
evidence.get("last_heartbeat_mem"),
evidence.get("suspected_oom", False),
)
except Exception:
logger.debug("Unclean-exit detection failed", exc_info=True)
try:
_write_sentinel(
{
"phase": "running",
"pid": os.getpid(),
"start_time": time.time(),
"started_at": datetime.now(timezone.utc).isoformat(),
},
home,
)
except Exception:
logger.debug("Failed to claim lifecycle sentinel", exc_info=True)
return evidence
def mark_exited(
exit_code: Optional[int] = None,
reason: str = "graceful_shutdown",
home: Optional[Path] = None,
) -> None:
"""Mark the current life as cleanly exited. Idempotent, never raises.
Only rewrites the sentinel when it is provably owned by this process —
during a ``--replace`` takeover the replacement claims the sentinel
before the old process finishes teardown, and the old life must not
clobber the new owner's ``running`` phase on its way out. A sentinel
with ``pid=None`` (or a malformed pid) has *unknown* ownership and is
likewise left alone: we must not overwrite evidence we cannot prove is
ours with a ``clean exit`` claim.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if sentinel is not None and sentinel.get("pid") != os.getpid():
return
_write_sentinel(
{
"phase": "exited",
"pid": os.getpid(),
"exit_code": exit_code,
"exit_reason": reason,
"exited_at": datetime.now(timezone.utc).isoformat(),
},
home,
)
except Exception:
logger.debug("Failed to mark lifecycle sentinel exited", exc_info=True)
def read_prior_exit_label(profile_home: Path) -> str:
"""Container-boot helper: one-word summary of how the profile's last
gateway life ended. ``clean`` / ``unclean`` / ``unknown`` (no sentinel
or never ran). Read-only and exception-free — used by
``hermes_cli.container_boot`` to annotate ``container-boot.log``.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(profile_home))
if not sentinel:
return "unknown"
phase = sentinel.get("phase")
if phase == "exited":
return "clean"
if phase == "running":
# At container boot the old PID namespace is gone — any
# "running" sentinel is from a life that never exited cleanly.
return "unclean"
except Exception:
pass
return "unknown"