fix(gateway): detect and report unclean shutdowns via lifecycle ledger (NS-608)

Hosted agents that die uncleanly (kernel OOM kill, SIGKILL, whole-VM
death) leave no trace: shutdown_forensics only covers graceful signals,
gateway-exit-diag.log only covers exit paths that actually run, and the
VM reboot wipes dmesg before anyone can capture it. NS-608 (BlueAtlas
hourly crash cycle, July 12-15) took days of manual log correlation to
classify because nothing recorded 'the previous life ended violently'.

Add gateway/lifecycle_ledger.py — a sentinel state machine persisted to
<HERMES_HOME>/state/gateway.lifecycle.json:

- start_gateway() claims the sentinel (phase=running) right after the
  PID-file/runtime-lock claim, and reports any prior life that never
  reached an exit path as gateway.previous_unclean_exit in
  gateway-exit-diag.log + a WARNING log line.
- Every exit funnel marks the sentinel exited with a reason:
  _exit_after_graceful_shutdown (graceful_shutdown), the shutdown
  watchdog (shutdown_watchdog), and the loop-liveness watchdog
  (loop_liveness_watchdog).
- Ownership-guarded for --replace takeovers: a live matching owner is
  never reported dead, and the old life cannot clobber the
  replacement's freshly claimed sentinel on its way out.

The 30s loop heartbeat now embeds a cheap /proc memory sample (own RSS,
MemAvailable, swap used) so every unclean-death report carries a
'memory N seconds before death' snapshot; the detector flags
suspected_oom when the last sample shows <64MiB or <5% available.

container-boot.log lines gain prior_exit=clean|unclean|unknown per
profile, stamping unclean container deaths into the volume-persisted
boot log where support can grep for them.

Tests: tests/gateway/test_lifecycle_ledger.py (16 cases) + 4 new
container-boot annotation cases. Existing watchdog/forensics/boot
suites all green; ruff clean.
This commit is contained in:
Shannon Sands 2026-07-27 13:40:26 +10:00 committed by Teknium
parent 805c1c340c
commit 9c76c133b7
6 changed files with 733 additions and 1 deletions

314
gateway/lifecycle_ledger.py Normal file
View file

@ -0,0 +1,314 @@
"""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:
os.kill(pid_int, 0)
except (ProcessLookupError, PermissionError, OSError):
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 still 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.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if sentinel and sentinel.get("pid") not in (None, 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"

View file

@ -24948,6 +24948,17 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
atexit.register(remove_pid_file)
atexit.register(release_gateway_runtime_lock)
# Lifecycle ledger (NS-608): report if the previous gateway life died
# uncleanly (SIGKILL / OOM / VM death — no exit path ran), then claim
# the sentinel for this life. Placed after the PID-file/lock claim so
# only the authoritative gateway for this HERMES_HOME touches the
# sentinel — a --replace loser exiting above must not clobber it.
try:
from gateway.lifecycle_ledger import record_startup as _lifecycle_record_startup
_lifecycle_record_startup()
except Exception as _lc_exc:
logger.debug("Lifecycle ledger startup record failed: %s", _lc_exc)
try:
from hermes_cli.nous_auth_keepalive import start_nous_auth_keepalive
@ -25265,6 +25276,16 @@ def _exit_after_graceful_shutdown(exit_code: int) -> None:
release_gateway_runtime_lock()
except Exception:
pass
# Mark this life cleanly exited in the lifecycle sentinel (NS-608). This
# is the single funnel every graceful exit passes through, so the next
# boot's unclean-death detector only fires for genuine SIGKILL/OOM/VM
# deaths. Ownership-guarded internally: a --replace old life won't
# clobber the replacement's freshly claimed "running" sentinel.
try:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="graceful_shutdown")
except Exception:
pass
# Drain the async log queue: os._exit bypasses atexit, so the listener's
# atexit drain won't fire. Use drain_log_queue() (bounded, no restart), NOT
# flush_log_queue(): if the listener is wedged on the rotation lock — the

View file

@ -185,6 +185,14 @@ def start_loop_liveness_watchdog(
logger.debug("Loop liveness faulthandler dump failed", exc_info=True)
if stop_event.is_set():
return
# Record the watchdog exit in the lifecycle sentinel so the next
# boot reports "watchdog hard-exit" instead of misclassifying
# this as an unclean SIGKILL/OOM death (NS-608).
try:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="loop_liveness_watchdog")
except Exception:
pass
os._exit(exit_code)
return
@ -241,6 +249,19 @@ def write_loop_heartbeat(
}
if start_time is not None:
payload["start_time"] = float(start_time)
# Embed a cheap memory sample (own RSS + MemAvailable + swap) so the
# heartbeat doubles as a rolling pre-death telemetry snapshot: after an
# unclean death (SIGKILL/OOM/VM loss) the last heartbeat is the closest
# surviving record of memory pressure — see gateway.lifecycle_ledger
# (NS-608). Best-effort; <1ms of /proc reads on Linux, {} elsewhere.
try:
from gateway.lifecycle_ledger import sample_memory
mem = sample_memory()
if mem:
payload["mem"] = mem
except Exception:
pass
if extra:
payload.update(extra)
try:
@ -391,6 +412,13 @@ def arm_shutdown_watchdog(
drain_log_queue(timeout=1.0)
except Exception:
pass
# Record the watchdog exit so the next boot's unclean-death detector
# reports "shutdown watchdog fired" instead of SIGKILL/OOM (NS-608).
try:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="shutdown_watchdog")
except Exception:
pass
os._exit(exit_code)
try:

View file

@ -83,6 +83,13 @@ class ReconcileAction:
profile: str
prior_state: str | None
action: ReconcileActionLabel
# How the profile's previous gateway life ended: "clean" (exit path ran),
# "unclean" (sentinel still says running — SIGKILL/OOM/VM death), or
# "unknown" (no sentinel / never ran). See gateway.lifecycle_ledger
# (NS-608): at container boot this is the one place that can stamp
# "the previous container life ended violently" into a durable,
# volume-persisted log line.
prior_exit: str = "unknown"
def reconcile_profile_gateways(
@ -156,6 +163,7 @@ def reconcile_profile_gateways(
profile="default",
prior_state=default_prior_state,
action="started" if default_should_start else "registered",
prior_exit=_read_prior_exit_label(hermes_home),
))
profiles_root = hermes_home / "profiles"
@ -195,6 +203,7 @@ def reconcile_profile_gateways(
profile=entry.name,
prior_state=prior_state,
action="started" if should_start else "registered",
prior_exit=_read_prior_exit_label(entry),
))
if not dry_run:
@ -405,6 +414,20 @@ def _cleanup_stale_runtime_files(profile_dir: Path) -> None:
(profile_dir / name).unlink(missing_ok=True)
def _read_prior_exit_label(profile_dir: Path) -> str:
"""How the profile's previous gateway life ended (clean/unclean/unknown).
Thin, exception-free wrapper over
:func:`gateway.lifecycle_ledger.read_prior_exit_label` cont-init runs
in a minimal environment and forensics must never block reconciliation
(NS-608)."""
try:
from gateway.lifecycle_ledger import read_prior_exit_label
return read_prior_exit_label(profile_dir)
except Exception:
return "unknown"
def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
"""Recreate the s6 service slot for one profile.
@ -541,7 +564,7 @@ def _write_reconcile_log(
for a in actions:
f.write(
f"{ts} profile={a.profile} prior_state={a.prior_state} "
f"action={a.action}\n"
f"action={a.action} prior_exit={a.prior_exit}\n"
)

View file

@ -0,0 +1,266 @@
"""Tests for gateway.lifecycle_ledger — unclean-shutdown detection (NS-608).
The ledger is a tiny sentinel state machine:
``record_startup`` claims ``state/gateway.lifecycle.json`` as
``phase=running``; every exit path calls ``mark_exited``; the next boot's
``record_startup``/``detect_unclean_exit`` reports a still-``running``
sentinel from a dead process as an unclean death (SIGKILL / OOM / VM loss)
and enriches the report with the last heartbeat's memory sample.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import pytest
from gateway.lifecycle_ledger import (
detect_unclean_exit,
get_lifecycle_sentinel_path,
mark_exited,
read_prior_exit_label,
record_startup,
sample_memory,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_DEAD_PID = 2 ** 22 + 12345 # beyond default pid_max on Linux; never alive
def _write_sentinel(home: Path, payload: dict) -> Path:
path = get_lifecycle_sentinel_path(home)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def _read_sentinel(home: Path) -> dict:
return json.loads(get_lifecycle_sentinel_path(home).read_text(encoding="utf-8"))
def _write_heartbeat(home: Path, payload: dict) -> Path:
path = home / "state" / "gateway.heartbeat"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def _exit_diag_records(home: Path) -> list[dict]:
path = home / "logs" / "gateway-exit-diag.log"
if not path.exists():
return []
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
# ---------------------------------------------------------------------------
# sample_memory
# ---------------------------------------------------------------------------
def test_sample_memory_never_raises() -> None:
sample = sample_memory()
assert isinstance(sample, dict)
@pytest.mark.skipif(sys.platform != "linux", reason="/proc is Linux-only")
def test_sample_memory_has_expected_keys_on_linux() -> None:
sample = sample_memory()
assert sample.get("rss_kib", 0) > 0
assert sample.get("mem_total_kib", 0) > 0
assert "mem_available_kib" in sample
# ---------------------------------------------------------------------------
# First boot / clean lifecycle
# ---------------------------------------------------------------------------
def test_first_boot_reports_nothing_and_claims_sentinel(tmp_path: Path) -> None:
assert record_startup(home=tmp_path) is None
sentinel = _read_sentinel(tmp_path)
assert sentinel["phase"] == "running"
assert sentinel["pid"] == os.getpid()
assert "start_time" in sentinel
def test_clean_exit_then_boot_reports_nothing(tmp_path: Path) -> None:
record_startup(home=tmp_path)
mark_exited(0, reason="graceful_shutdown", home=tmp_path)
sentinel = _read_sentinel(tmp_path)
assert sentinel["phase"] == "exited"
assert sentinel["exit_code"] == 0
assert sentinel["exit_reason"] == "graceful_shutdown"
assert record_startup(home=tmp_path) is None
assert _exit_diag_records(tmp_path) == []
def test_mark_exited_records_watchdog_reason(tmp_path: Path) -> None:
record_startup(home=tmp_path)
mark_exited(70, reason="loop_liveness_watchdog", home=tmp_path)
sentinel = _read_sentinel(tmp_path)
assert sentinel["exit_reason"] == "loop_liveness_watchdog"
assert sentinel["exit_code"] == 70
# ---------------------------------------------------------------------------
# Unclean-death detection
# ---------------------------------------------------------------------------
def test_running_sentinel_from_dead_pid_is_unclean(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {
"phase": "running",
"pid": _DEAD_PID,
"start_time": 1000.0,
"started_at": "2026-07-11T04:30:00+00:00",
})
evidence = detect_unclean_exit(home=tmp_path)
assert evidence is not None
assert evidence["prior_pid"] == _DEAD_PID
assert evidence["prior_started_at"] == "2026-07-11T04:30:00+00:00"
def test_record_startup_persists_unclean_report_and_reclaims(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {
"phase": "running",
"pid": _DEAD_PID,
"start_time": 1000.0,
"started_at": "2026-07-11T04:30:00+00:00",
})
evidence = record_startup(home=tmp_path)
assert evidence is not None
records = _exit_diag_records(tmp_path)
assert len(records) == 1
assert records[0]["tag"] == "gateway.previous_unclean_exit"
assert records[0]["prior_pid"] == _DEAD_PID
assert records[0]["pid"] == os.getpid()
# Sentinel reclaimed for the new life.
sentinel = _read_sentinel(tmp_path)
assert sentinel["phase"] == "running"
assert sentinel["pid"] == os.getpid()
def test_unclean_report_includes_last_heartbeat_memory(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {
"phase": "running", "pid": _DEAD_PID, "start_time": 1000.0,
})
_write_heartbeat(tmp_path, {
"pid": _DEAD_PID,
"updated_at": "2026-07-12T19:33:00+00:00",
"mem": {
"rss_kib": 900_000,
"mem_total_kib": 2_015_136,
"mem_available_kib": 40_000, # ~2% available → OOM suspicion
"swap_used_kib": 900_000,
},
})
evidence = detect_unclean_exit(home=tmp_path)
assert evidence is not None
assert evidence["last_heartbeat_at"] == "2026-07-12T19:33:00+00:00"
assert evidence["last_heartbeat_mem"]["mem_available_kib"] == 40_000
assert evidence["suspected_oom"] is True
def test_healthy_memory_heartbeat_does_not_suspect_oom(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {
"phase": "running", "pid": _DEAD_PID, "start_time": 1000.0,
})
_write_heartbeat(tmp_path, {
"pid": _DEAD_PID,
"updated_at": "2026-07-12T19:33:00+00:00",
"mem": {
"mem_total_kib": 2_015_136,
"mem_available_kib": 1_000_000,
},
})
evidence = detect_unclean_exit(home=tmp_path)
assert evidence is not None
assert "suspected_oom" not in evidence
def test_live_owner_is_not_reported_as_unclean(tmp_path: Path) -> None:
"""A live matching PID means a --replace takeover is in flight, not a
death the detector must stay quiet (start_time omitted assume alive)."""
_write_sentinel(tmp_path, {
"phase": "running",
"pid": os.getpid(),
})
assert detect_unclean_exit(home=tmp_path) is None
def test_corrupt_sentinel_is_ignored(tmp_path: Path) -> None:
path = get_lifecycle_sentinel_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{not json", encoding="utf-8")
assert detect_unclean_exit(home=tmp_path) is None
assert record_startup(home=tmp_path) is None
# And the boot still claims a fresh sentinel.
assert _read_sentinel(tmp_path)["phase"] == "running"
# ---------------------------------------------------------------------------
# Takeover ownership guard on mark_exited
# ---------------------------------------------------------------------------
def test_old_life_cannot_clobber_new_owner_sentinel(tmp_path: Path) -> None:
"""--replace: the replacement claims the sentinel while the old process
is mid-teardown; the old life's mark_exited must be a no-op."""
_write_sentinel(tmp_path, {
"phase": "running",
"pid": os.getpid() + 1, # someone else owns it
"start_time": 2000.0,
})
mark_exited(0, reason="graceful_shutdown", home=tmp_path)
sentinel = _read_sentinel(tmp_path)
assert sentinel["phase"] == "running"
assert sentinel["pid"] == os.getpid() + 1
def test_mark_exited_without_prior_sentinel_writes_exited(tmp_path: Path) -> None:
mark_exited(1, reason="graceful_shutdown", home=tmp_path)
assert _read_sentinel(tmp_path)["phase"] == "exited"
# ---------------------------------------------------------------------------
# read_prior_exit_label (container-boot annotation)
# ---------------------------------------------------------------------------
def test_prior_exit_label_unknown_when_no_sentinel(tmp_path: Path) -> None:
assert read_prior_exit_label(tmp_path) == "unknown"
def test_prior_exit_label_clean_after_exit(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {"phase": "exited", "pid": 123, "exit_code": 0})
assert read_prior_exit_label(tmp_path) == "clean"
def test_prior_exit_label_unclean_when_still_running(tmp_path: Path) -> None:
_write_sentinel(tmp_path, {"phase": "running", "pid": _DEAD_PID})
assert read_prior_exit_label(tmp_path) == "unclean"
def test_prior_exit_label_survives_corrupt_sentinel(tmp_path: Path) -> None:
path = get_lifecycle_sentinel_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("garbage", encoding="utf-8")
assert read_prior_exit_label(tmp_path) == "unknown"

View file

@ -1094,3 +1094,83 @@ def test_main_ignores_removed_skip_reconcile_env_var(
assert rc == 0
# Reconcile still ran despite the stale env var.
assert (scandir / "gateway-worker").exists()
# ---------------------------------------------------------------------------
# prior_exit annotation (NS-608 — unclean-shutdown forensics)
# ---------------------------------------------------------------------------
def _write_lifecycle_sentinel(profile_dir: Path, payload: dict) -> None:
state_dir = profile_dir / "state"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "gateway.lifecycle.json").write_text(json.dumps(payload))
def test_reconcile_log_annotates_unclean_prior_exit(tmp_path: Path) -> None:
"""A profile whose lifecycle sentinel still says phase=running at
container boot died uncleanly (OOM / SIGKILL / VM death) the
container-boot.log line must say so (NS-608)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
crashed = _make_profile(tmp_path, "crashy", state="running")
_write_lifecycle_sentinel(crashed, {
"phase": "running", "pid": 2**22 + 999, "start_time": 1000.0,
})
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
(crashy,) = [a for a in actions if a.profile == "crashy"]
assert crashy.prior_exit == "unclean"
log = (tmp_path / "logs" / "container-boot.log").read_text()
assert "profile=crashy" in log
assert "prior_exit=unclean" in log
def test_reconcile_log_annotates_clean_prior_exit(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
stopped = _make_profile(tmp_path, "tidy", state="stopped")
_write_lifecycle_sentinel(stopped, {
"phase": "exited", "pid": 12345, "exit_code": 0,
"exit_reason": "graceful_shutdown",
})
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
(tidy,) = [a for a in actions if a.profile == "tidy"]
assert tidy.prior_exit == "clean"
log = (tmp_path / "logs" / "container-boot.log").read_text()
assert "prior_exit=clean" in log
def test_reconcile_log_prior_exit_unknown_without_sentinel(tmp_path: Path) -> None:
"""No lifecycle sentinel (fresh profile, pre-upgrade volume) →
prior_exit=unknown, and reconciliation is unaffected."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "fresh", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
(fresh,) = [a for a in actions if a.profile == "fresh"]
assert fresh.prior_exit == "unknown"
assert fresh.action == "started"
def test_default_root_prior_exit_annotated(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running")
_write_lifecycle_sentinel(tmp_path, {
"phase": "running", "pid": 2**22 + 999, "start_time": 1000.0,
})
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
(default,) = [a for a in actions if a.profile == "default"]
assert default.prior_exit == "unclean"