"""Gateway lifecycle ledger — durable termination-reason evidence. Graceful shutdowns already leave forensics (:mod:`gateway.shutdown_forensics`, ``gateway-exit-diag.log``); an **unclean death** (SIGKILL, kernel OOM kill, VM death) runs no handler, so the next boot would otherwise have no record of it. A tiny state machine persisted to ``/state/gateway.lifecycle.json`` closes that gap: * :func:`record_startup` reads the previous life's sentinel. ``phase == "running"`` means no exit path ran → unclean death. The finding (with the last heartbeat's memory sample) is appended to ``gateway-exit-diag.log`` as a ``gateway.previous_unclean_exit`` record and logged at WARNING, then the sentinel is rewritten as ``phase=running`` for the new life. * :func:`mark_exited` rewrites the sentinel as ``phase=exited`` on every clean exit path (``_exit_after_graceful_shutdown`` and the watchdog ``os._exit`` sites). * :func:`sample_memory` is the cheap /proc snapshot the 30s loop heartbeat embeds, so OOM crash cycles are classifiable from the volume alone. 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 sqlite3 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") _STATE_DB_RELATIVE = ("state.db",) # Heuristic OOM-suspicion thresholds on the last heartbeat's memory sample. # Deliberately conservative: only a hint; classification stays with the reader. _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 _home_path(home: Optional[Path], relative: tuple) -> Path: base = home if home is not None else _process_hermes_home() return base.joinpath(*relative) def get_lifecycle_sentinel_path(home: Optional[Path] = None) -> Path: """Return ``/state/gateway.lifecycle.json``.""" return _home_path(home, _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 for src, dst in (("MemTotal", "mem_total_kib"), ("MemAvailable", "mem_available_kib")): if src in meminfo: sample[dst] = meminfo[src] 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).""" path = _home_path(home, _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. _pid_exists is the canonical no-kill probe. 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) return actual is None or 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: last proven liveness and memory 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 check_state_db_integrity(home: Optional[Path] = None) -> str: """Return ``"ok"``, ``"absent"``, or the first ``quick_check`` complaint. Called only after an unclean death — a SIGKILL mid-WAL-checkpoint can leave half-written b-tree pages (see ``_enforce_macos_synchronous_full`` in :mod:`hermes_state`). ``quick_check(1)`` stops at the first problem (~2s on a healthy 500MB store): cheap once per unclean boot, too costly every boot. Opened normally, not read-only: a WAL store needs its -shm sidecar for a read-only open, and the PRAGMA itself writes nothing. Never raises. """ path = _home_path(home, _STATE_DB_RELATIVE) if not path.exists(): return "absent" try: conn = sqlite3.connect(str(path)) try: row = conn.execute("PRAGMA quick_check(1)").fetchone() finally: conn.close() except Exception as exc: # sqlite3.Error, OSError, anything return f"check-failed: {exc}" if not row or row[0] is None: return "check-failed: no result" return str(row[0]) 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: # The death may have torn the store; this is the only moment we know to look. verdict = check_state_db_integrity(home=home) evidence["state_db_integrity"] = verdict if verdict not in ("ok", "absent"): logger.error( "state.db FAILED integrity check after an unclean gateway " "exit: %s — sessions may read as missing until it is " "repaired. Run `hermes doctor`.", verdict, ) _append_exit_diag({ "ts": datetime.now(timezone.utc).isoformat(), "tag": "gateway.previous_unclean_exit", "pid": os.getpid(), **evidence, }, 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: claim: Dict[str, Any] = { "phase": "running", "pid": os.getpid(), "start_time": time.time(), "started_at": datetime.now(timezone.utc).isoformat(), } # Carry the verdict on the PREVIOUS life forward on the new sentinel: it # is the only machine-readable copy (the exit-diag log is append-only # prose) and /api/status reads it to report an OOM restart. Scoped to # this life only — the next clean exit or boot rewrites the sentinel. if evidence is not None: claim["prior_unclean_exit"] = True if evidence.get("suspected_oom"): claim["prior_suspected_oom"] = True _write_sentinel(claim, 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. A sentinel with ``pid=None`` (or malformed) has unknown ownership and is likewise left alone. """ 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: ``clean`` / ``unclean`` / ``unknown`` summary of how the profile's last gateway life ended. Read-only, 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": # Old PID namespace is gone at container boot — never exited cleanly. return "unclean" except Exception: pass return "unknown"