Files
hermes-agent/gateway/memory_status.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

134 lines
5.9 KiB
Python

"""Memory status rollup for ``/api/status``.
Read side for signals the gateway already persists: the 30s ``state/gateway.heartbeat``
(RSS + MemAvailable/MemTotal + swap) and the lifecycle sentinel's ``suspected_oom``
flag — two small file reads, no IPC. ``/api/status`` is unauthenticated, so only
coarse numbers (MB), enums and booleans. A missing/corrupt file degrades to
``pressure="unknown"`` rather than raising into the status endpoint.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
# Thresholds on system MemAvailable. ``critical`` doubles as the lifecycle
# ledger's OOM-suspicion heuristic: a level that makes a later unclean death
# "suspected OOM" already warns while the process is alive.
_CRITICAL_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_CRITICAL_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal
_ELEVATED_AVAILABLE_KIB = 128 * 1024 # < 128 MiB available
_ELEVATED_AVAILABLE_FRACTION = 0.15 # < 15% of MemTotal
_PRESSURE_TIERS = ( # order-sensitive: worst first
("critical", _CRITICAL_AVAILABLE_KIB, _CRITICAL_AVAILABLE_FRACTION),
("elevated", _ELEVATED_AVAILABLE_KIB, _ELEVATED_AVAILABLE_FRACTION),
)
# Writer cadence is 30s; 150s tolerates a briefly stalled loop without letting
# a long-dead gateway's last sample pose as current.
_HEARTBEAT_FRESH_TTL_S = 150.0
def _nonneg_int(value: Any) -> Optional[int]:
"""Return *value* if it is a non-negative int (bools rejected), else None."""
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def _mb(kib: Any) -> Optional[int]:
return None if _nonneg_int(kib) is None else kib // 1024
def _parse_iso(value: Any) -> Optional[datetime]:
try:
parsed = datetime.fromisoformat(value) if isinstance(value, str) and value else None
except ValueError:
return None
return parsed.replace(tzinfo=timezone.utc) if parsed is not None and parsed.tzinfo is None else parsed
def classify_pressure(available_kib: Any, total_kib: Any) -> str:
"""``ok``/``elevated``/``critical`` from MemAvailable/MemTotal; ``unknown`` when the
sample is missing/malformed — "could not read it" must never read as "fine"."""
available, total = _nonneg_int(available_kib), _nonneg_int(total_kib)
if available is None:
return "unknown"
fraction = available / total if total else None
for level, kib_floor, frac_floor in _PRESSURE_TIERS:
if available < kib_floor or (fraction is not None and fraction < frac_floor):
return level
return "ok"
def _read_state_files(home: Optional[Path]) -> tuple:
"""``(heartbeat, sentinel)`` dicts, each ``None`` when unreadable."""
try:
from gateway.lifecycle_ledger import _read_json, get_lifecycle_sentinel_path
from gateway.shutdown_watchdog import get_loop_heartbeat_path
return _read_json(get_loop_heartbeat_path(home)), _read_json(get_lifecycle_sentinel_path(home))
except Exception:
return None, None
def collect_memory_status(
home: Optional[Path] = None,
*,
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""``memory`` block for ``/api/status``; ``home`` scopes to a profile (``None`` =
active), ``now`` is injectable. Never raises — a down gateway or corrupt files
yield ``pressure="unknown"`` plus whatever fields could be recovered."""
moment = now or datetime.now(timezone.utc)
status: Dict[str, Any] = {
"pressure": "unknown", "gateway_rss_mb": None, "system_total_mb": None, "system_available_mb": None,
"swap_used_mb": None, "sampled_at": None, "last_boot_unclean": False, "last_boot_suspected_oom": False,
# Identity of the CURRENT life (sentinel started_at): the dashboard keys
# banner dismissal on it so acknowledging one OOM restart does not mute the NEXT.
"boot_id": None,
}
heartbeat, sentinel = _read_state_files(home)
if heartbeat:
sampled_at, mem = _parse_iso(heartbeat.get("updated_at")), heartbeat.get("mem")
if isinstance(mem, dict):
for dst, src in (("gateway_rss_mb", "rss_kib"), ("system_total_mb", "mem_total_kib"),
("system_available_mb", "mem_available_kib"), ("swap_used_mb", "swap_used_kib")):
status[dst] = _mb(mem.get(src))
if sampled_at is not None:
status["sampled_at"] = sampled_at.isoformat()
# Stale sample: numbers still reported (sampled_at says when) but
# pressure stays "unknown" so a dead gateway's final gasp cannot
# render a live "critical" banner forever.
if 0 <= (moment - sampled_at).total_seconds() <= _HEARTBEAT_FRESH_TTL_S:
status["pressure"] = classify_pressure(mem.get("mem_available_kib"), mem.get("mem_total_kib"))
if sentinel:
status["last_boot_unclean"] = bool(sentinel.get("prior_unclean_exit"))
status["last_boot_suspected_oom"] = bool(sentinel.get("prior_suspected_oom"))
started_at = sentinel.get("started_at")
status["boot_id"] = started_at if isinstance(started_at, str) and started_at else None
return status
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import logging # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'logger': ('gateway.run', 'logger'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----