Files
hermes-agent/tui_gateway/session_reaper.py
Austin Pickett 0c0796bf46 fix(tui-gateway): reap stale deferred active-session leases (#62823 zombie slot)
When a compute-host turn has not settled by session close, the session's
real active-session lease is parked in _deferred_active_session_leases and
the turn's completion callback is supposed to release it. If that callback
is lost (supervisor restart/reload, child killed without failing its
pending turns, dropped completion), the lease sat in the registry forever:
_own_live_lease_ids vouches for it so the orphan sweep never reclaims it,
and the concurrent-session cap treats the dead session as active - new
sessions could not send until a backend restart.

Track when each lease was deferred and have the reaper tick force-release
deferred leases past a generous 30-minute ceiling (the longest legitimate
isolated turn is the compression ceiling, minutes). Normal settlement
still releases immediately and clears the age entry.

Fixes #62823 (zombie-concurrency-slot half; cross-window queue-sync was
fixed by #122953 and visual-merge by #70986 - not touched here)
2026-09-25 16:48:03 -04:00

523 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Session flush / reaping / orphan sweep / cross-backend heartbeat: exit-flush signal handlers, idle + LRU
eviction, orphaned session-row sweep, backend heartbeat refresher. Bodies are rebound onto server.py's
globals at install time (method_ctx.bind_module), so they reference server.py globals bare — including the
knobs _SESSION_TTL_S, _REAPER_SCAN_S, _EXIT_FLUSH_BUDGET_S and _INCREMENTAL_FLUSH_INTERVAL_S.
"""
from __future__ import annotations
import contextlib
import secrets
import threading
from tui_gateway._env import env_float
from .method_ctx import bind_module
# ── Flush-on-kill + periodic incremental flush ───────────────────────────
# (a) SIGTERM/SIGINT run a bounded flush to state.db BEFORE normal shutdown, chained to the prior handler;
# (b) the idle-reaper scan piggybacks an incremental flush so a SIGKILL loses at most one interval.
def _session_has_pending_flush(session: dict | None) -> bool:
"""Would :func:`_flush_session_messages` write anything for this session? (The exit flush counts
these to tell a complete flush from a budget overrun.)"""
agent = session.get("agent") if isinstance(session, dict) else None
return bool(hasattr(agent, "_persist_session") and getattr(agent, "_session_messages", None))
def _flush_session_messages(session: dict | None) -> bool:
"""Best-effort durable flush of one session's transcript via ``agent._persist_session`` (same marker-deduped
contract as ``_finalize_session``: repeated calls never duplicate rows).
See #13121.
"""
agent = session.get("agent") if session else None
snapshot = getattr(agent, "_session_messages", None) if hasattr(agent, "_persist_session") else None
if not snapshot:
return False
# config.yaml, the token ledger and the memory/provider lookups ``_persist_session`` touches are
# resolved at CALL time, and every caller of this helper is an unscoped reaper / exit-flush thread
# with no turn on the stack — so a SERVED profile's flush resolved them against the LAUNCH home.
# (The transcript ROWS were never at risk: a profile session gets its own SessionDB whose db_path
# is frozen at construction, tui_gateway/server.py::_open_profile_session_db). Same binding
# _finalize_session makes at the identical chokepoint (session_lifecycle.py).
#
# hydrate_secrets=False: persisting a transcript needs no external credential, and hydration
# shells out to the operator's secret command (30s CLI budget, process-global lock) inside a
# worker whose whole budget is 5s — one slow source silently lost the transcript.
try:
with _session_profile_runtime_scope(session or {}, hydrate_secrets=False):
agent._persist_session(snapshot)
return True
except Exception:
logger.debug("incremental session flush failed", exc_info=True)
return False
def _reaper_session_snapshot() -> list:
with _sessions_lock:
return list(_sessions.values())
def _flush_dirty_sessions(now: float | None = None) -> int:
"""Periodic incremental flush, driven by the idle-reaper scan. Skips ``running`` sessions: the turn thread
owns mid-turn persistence and mutates the live message list, so racing it from the reaper thread is never
safe. Idle sessions flush at most once per ``_INCREMENTAL_FLUSH_INTERVAL_S``; ``now`` (monotonic) is
injectable for tests."""
if _INCREMENTAL_FLUSH_INTERVAL_S <= 0:
return 0
now = time.monotonic() if now is None else now
flushed = 0
for session in _reaper_session_snapshot():
if not isinstance(session, dict) or session.get("running"):
continue
last = float(session.get("_last_incremental_flush") or 0.0)
if last and (now - last) < _INCREMENTAL_FLUSH_INTERVAL_S:
continue
flushed += _flush_session_messages(session)
session["_last_incremental_flush"] = now
return flushed
def _flush_sessions_before_exit(budget_s: float | None = None) -> int:
"""Bounded flush of ALL in-memory sessions on the way out, on a daemon worker joined with the budget so a
hung SQLite write can't block exit past ``HERMES_TUI_EXIT_FLUSH_BUDGET_S`` (default 5s). Running sessions
are included — the process is dying, a partial transcript beats loss."""
budget = _EXIT_FLUSH_BUDGET_S if budget_s is None else max(0.0, budget_s)
if budget <= 0:
return 0
result = {"flushed": 0}
sessions = _reaper_session_snapshot()
flushable = sum(1 for s in sessions if _session_has_pending_flush(s))
def _run() -> None:
deadline = time.monotonic() + budget
for session in sessions:
if time.monotonic() >= deadline:
break
result["flushed"] += _flush_session_messages(session)
worker = threading.Thread(target=_run, daemon=True, name="hermes-exit-flush")
worker.start()
worker.join(budget)
# Silent loss is the failure mode this guard exists to prevent: both callers discard the return
# value, so a budget overrun (a slow state.db write, a scope binding that shells out) looks
# exactly like a clean exit.
if result["flushed"] < flushable:
logger.warning(
"Exit flush persisted %d of %d in-memory session transcript(s) within %.1fs; the rest "
"may have been lost (HERMES_TUI_EXIT_FLUSH_BUDGET_S)",
result["flushed"], flushable, budget)
return result["flushed"]
# Bounded wait for interrupted turns on the way out; the SIGTERM path hard-exits after a ~1s grace.
_EXIT_TURN_SETTLE_S = 0.5
def _stop_turns_before_exit(budget_s: float | None = None) -> None:
"""Interrupt every in-flight turn and give it ``budget_s`` to settle, so a running tool call ends
with a result the teardown's final persist records. A foreground command runs in its own process
group and would outlive the gateway, reparented to init. One still alive halfway through the budget
ignored the interrupt's SIGTERM: SIGKILL it then, early enough for its result to land as well (the
interrupt's own TERM, 1s, KILL outlasts the SIGTERM path's ~1s grace)."""
with _sessions_lock:
running = [(sid, s) for sid, s in _sessions.items() if s.get("running")]
threads = []
for sid, session in running:
with contextlib.suppress(Exception):
_interrupt_session_turn(sid, session)
if (t := session.get("_run_thread")) is not None and t is not threading.current_thread():
threads.append(t)
budget = _EXIT_TURN_SETTLE_S if budget_s is None else max(0.0, budget_s)
deadline = time.monotonic() + budget
def _join(until: float) -> None:
for t in threads:
t.join(max(0.0, until - time.monotonic()))
_join(deadline - budget / 2)
from tools.environments.base import kill_live_foreground_processes
kill_live_foreground_processes(now=True)
_join(deadline)
_exit_flush_prev_handlers: dict[int, Any] = {}
_exit_flush_handlers_installed = False
def _handle_exit_flush_signal(signum, frame) -> None:
"""Flush in-memory sessions, then hand off to the prior handler (uvicorn's graceful shutdown, a supervisor's
handler, or the default disposition) — this only *prepends* a bounded flush."""
import signal as _signal
prev = _exit_flush_prev_handlers.get(signum)
if prev is _signal.SIG_IGN:
# An inherited ignore (`cmd &` from a non-interactive shell) ends nothing: stopping turns here
# would raise the one-way exit fence in a process that keeps running and refuses every command.
return
with contextlib.suppress(Exception):
_flush_sessions_before_exit()
# The group signal that stopped us never reaches a command in its own session: reap it now,
# before a supervisor's SIGKILL can cut the graceful shutdown (and its atexit) short.
with contextlib.suppress(Exception):
_stop_turns_before_exit()
if callable(prev):
prev(signum, frame)
else:
# Default disposition: restore it and re-raise so the process dies with the correct signal (exit status
# visible to supervisors).
try:
_signal.signal(signum, _signal.SIG_DFL)
os.kill(os.getpid(), signum)
except Exception:
raise SystemExit(128 + int(signum)) from None
def install_exit_flush_signal_handlers() -> bool:
"""Install chaining SIGTERM/SIGINT flush handlers (main thread only). Called before uvicorn takes over
signals: its ``capture_signals()`` saves these as the "original" handlers and re-raises into them after
graceful shutdown, so the flush also covers terminations outside uvicorn's serve window. Idempotent; False
off-main-thread/on failure."""
global _exit_flush_handlers_installed
if _exit_flush_handlers_installed:
return True
if threading.current_thread() is not threading.main_thread():
return False
import signal as _signal
installed = False
for signum in (_signal.SIGTERM, _signal.SIGINT):
with contextlib.suppress(ValueError, OSError, RuntimeError):
prev = _signal.getsignal(signum)
_signal.signal(signum, _handle_exit_flush_signal)
_exit_flush_prev_handlers[signum] = prev
installed = True
_exit_flush_handlers_installed = installed
return installed
def _transport_is_dead(transport) -> bool:
# _detached_ws_transport is the post-disconnect drop sentinel. _stdio_transport is the REAL transport for
# standalone `hermes --tui` and must NOT count as dead.
if transport is _detached_ws_transport:
return True
if isinstance(transport, FanoutTransport):
# A fan-out is never the sentinel and has no ``_closed`` of its own, so without this arm every
# multi-client session reads as alive forever — the TTL reaper, the LRU cap and the #77129 disconnect
# revalidation all gate on this predicate. A fan-out can legitimately end up empty, or holding nothing
# but closed sockets, with no disconnect passing through _close_sessions_for_transport: a failed write
# prunes the peer that failed. It is dead exactly when no peer of its own is alive, and an empty one is
# dead. Peers are always leaf transports (attach flattens a fan-out argument instead of nesting it), so
# this recurses one level at most.
return all(_transport_is_dead(peer) for peer in transport.transports())
return getattr(transport, "_closed", None) is True
def _session_is_lru_evictable(sid: str, session: dict) -> bool:
"""Shared hard exemptions for both reapers (the LRU cap applies them WITHOUT the age gate: eligible the moment
it loses its client): never evict a session mid-turn, awaiting input, still building, owning live delegated
work, or on a live transport. Lazy watch sessions never start a build, so their unset agent_ready must not
make them immortal."""
if session.get("running") or _session_pending_kind(sid) or _session_has_active_delegations(sid, session):
return False
ready = session.get("agent_ready")
if ready is not None and not ready.is_set() and not session.get("lazy"):
return False
return _transport_is_dead(session.get("transport"))
def _sessions_quiescent(exclude: str | None = None) -> bool:
"""No session but ``exclude`` is mid-turn, building, awaiting input, holding live delegations, or on a live
transport. A non-forced memory trim holds the GIL (gc.collect) and every glibc arena lock (malloc_trim) for
its whole duration — 20-50 s on multi-GB heaps — which stalls the event loop, drops WS clients past the
write deadline and interrupts their turns (#58576); this is the moment it costs no other session. The
predicate is advisory (a turn can start right after), so the per-session checks — one may read state.db —
run outside ``_sessions_lock``."""
with _sessions_lock:
others = [(sid, s) for sid, s in _sessions.items() if sid != exclude]
return all(_session_is_lru_evictable(sid, s) for sid, s in others)
def _session_is_evictable(sid: str, session: dict, now: float) -> bool:
"""TTL eviction: the LRU exemptions plus idle-for-TTL AND older-than-TTL."""
if not _session_is_lru_evictable(sid, session):
return False
last_active = float(session.get("last_active") or 0.0)
created_at = float(session.get("created_at") or 0.0)
return (now - last_active) > _SESSION_TTL_S and (now - created_at) > _SESSION_TTL_S
def _reap_idle_sessions() -> None:
now = time.time()
try: # piggyback the incremental flush on the reaper tick — no new timer subsystem
_flush_dirty_sessions()
except Exception:
logger.debug("periodic incremental session flush failed", exc_info=True)
with _sessions_lock:
victims = [sid for sid, s in _sessions.items() if _session_is_evictable(sid, s, now)]
for sid in victims:
_close_session_by_id(
sid, end_reason="idle_timeout",
predicate=lambda session, vs=sid: _session_is_evictable(vs, session, time.time()))
_repair_missing_ws_orphan_reaps()
_enforce_session_cap()
_reclaim_orphaned_leases()
# Long-lived processes: gen2 GC rarely runs at steady state and glibc retains freed pages as RSS, so trim
# every quiescent scan to prevent unbounded RSS growth over days/weeks. Forced trims (agent close, cache
# pressure) are unaffected.
if not _sessions_quiescent():
logger.debug("idle reaper periodic trim deferred: a session is busy or attached")
return
try:
from hermes_cli.mem_trim import trim_memory
trim_memory(reason="idle reaper periodic trim")
except Exception as exc: # debug, not warning — a persistent failure would repeat every scan.
logger.debug("idle reaper memory trim failed: %s: %s", type(exc).__name__, exc)
def _repair_missing_ws_orphan_reaps() -> None:
"""Re-arm detached sessions whose disconnect path lost its teardown timer.
A resident record otherwise vouches for its lease during every orphan sweep,
while the live process prevents PID pruning. Reusing the normal WS grace
path preserves reconnect and in-flight-work protections instead of stealing
the lease directly.
"""
if _WS_ORPHAN_REAP_GRACE_S <= 0:
return
# A socket can be closed before its disconnect cleanup reaches the sentinel.
# Reuse that cleanup (including surviving viewers), never revoke a fence from
# a stale liveness snapshot.
with _sessions_lock:
closed_transports = [session.get("transport") for session in _sessions.values()
if session.get("transport") is not _detached_ws_transport
and _transport_is_dead(session.get("transport"))]
for transport in closed_transports:
_close_sessions_for_transport(transport)
with _sessions_lock:
missing = [
sid for sid, session in _sessions.items()
if _ws_session_is_detached(session) and sid not in _pending_ws_reaps
]
for sid in missing:
_schedule_ws_orphan_reap(sid)
def _reclaim_orphaned_leases() -> None:
"""Hand the registry the lease ids we still own so it can drop the rest."""
try:
# Stale deferred leases first: a settlement callback that never arrived must not
# keep vouching for a zombie slot (#62823). Released leases leave _own_live_lease_ids.
_reap_stale_deferred_leases()
except Exception:
logger.debug("stale deferred lease sweep failed", exc_info=True)
try:
from hermes_cli.active_sessions import release_orphaned_leases
if dropped := release_orphaned_leases(_own_live_lease_ids()):
logger.info("Reclaimed %d orphaned active-session lease(s)", dropped)
except Exception:
logger.debug("orphaned lease reclaim failed", exc_info=True)
# Soft LRU cap on in-memory sessions: the TTL reaper only frees sessions idle for hours, so a heavy reconnecting
# user accumulates resident detached agents. The cap evicts the least-recently-active DETACHED sessions sooner —
# never a running / pending / mid-build / live-transport one (reopening re-resumes from the DB). 0/null disables.
def _max_live_sessions() -> int:
try:
from hermes_cli.active_sessions import coerce_max_concurrent_sessions
cfg = _load_cfg() or {}
raw = cfg.get("max_live_sessions")
if raw is None and isinstance(gateway_cfg := cfg.get("gateway"), dict):
raw = gateway_cfg.get("max_live_sessions")
coerced = coerce_max_concurrent_sessions(raw, key="max_live_sessions")
return int(coerced) if coerced else 0
except Exception:
return 0
def _enforce_session_cap() -> None:
cap = _max_live_sessions()
if cap <= 0:
return
with _sessions_lock:
if len(_sessions) <= cap:
return
evictable = [(sid, s) for sid, s in _sessions.items() if _session_is_lru_evictable(sid, s)]
# Oldest-touched first; evict only down to the cap (may stop short: live sessions are never eligible).
evictable.sort(key=lambda kv: float(kv[1].get("last_active") or 0.0))
for sid, _s in evictable:
with _sessions_lock:
if len(_sessions) <= cap:
break
_close_session_by_id(
sid, end_reason="lru_evict", predicate=lambda session, vs=sid: _session_is_lru_evictable(vs, session))
def _reaper_daemon_timer(delay: float, fn, fail_log: str, level: str = "debug") -> None:
"""Run ``fn`` once on a daemon Timer after ``delay``; log (never raise) on failure."""
def _run() -> None:
try:
fn()
except Exception:
getattr(logger, level)(fail_log, exc_info=True)
timer = threading.Timer(delay, _run)
timer.daemon = True
timer.start()
def _schedule_session_cap_enforcement() -> None:
"""Run the LRU sweep off the response path (eviction can call agent.close)."""
_reaper_daemon_timer(0.1, _enforce_session_cap, "session cap enforcement failed")
# ── Startup sweep for orphaned session rows ──────────────────────────────
# The WS-orphan reaper is an in-process Timer: a gateway restart kills it before it fires, leaving the row
# `ended_at IS NULL` forever. Scheduled once per process from both gateway entry points (stdio `entry.main`, WS
# sidecar `handle_ws`). state.db is shared by sibling processes on the same profile, so eligibility is
# conservative. Disable via `dashboard.startup_orphan_sweep: false`.
# This is the startup complement every other resource type already has (docker_orphan_reaper, compression
# orphans). See #65194.
_ORPHAN_SWEEP_SOURCES = ("tui", "desktop", "subagent", "unknown")
_startup_orphan_sweep_ran = False
_startup_orphan_sweep_lock = threading.Lock()
def _session_orphan_reaper_enabled() -> bool:
"""``dashboard.startup_orphan_sweep`` (default on). Fail-open on errors and on a missing key (raw yaml, no
DEFAULT_CONFIG merge on this loader)."""
try:
dashboard_cfg = (_load_cfg() or {}).get("dashboard") or {}
if isinstance(dashboard_cfg, dict) and "startup_orphan_sweep" in dashboard_cfg:
return is_truthy_value(dashboard_cfg.get("startup_orphan_sweep"), default=True)
except Exception:
pass
return True
def _sweep_orphaned_session_rows() -> list[str]:
"""End orphaned tui/desktop/subagent rows left by a dead process. "Provably orphaned" is inferred
conservatively: the row must have been created AND last messaged at least the session TTL ago (a fresh row
that copied an old transcript is protected by its own ``started_at``). Rows held in memory (e.g. a
``session.resume`` in the startup grace window) are excluded. Cross-backend: the sweep refuses to close a
row any live backend (heartbeat within ``2 * TTL``) could own — see ``SessionDB.sweep_orphaned_sessions``."""
db = _get_db()
if db is None or _SESSION_TTL_S <= 0:
return []
live_ids: set[str] = set() # every id this process holds in memory: live sid, agent session_id, session_key
with _sessions_lock:
for sid, session in _sessions.items():
candidates = [sid]
if isinstance(session, dict):
candidates += [getattr(session.get("agent"), "session_id", None), session.get("session_key")]
live_ids.update(str(c) for c in candidates if c)
swept = db.sweep_orphaned_sessions(
max_idle_seconds=_SESSION_TTL_S, sources=_ORPHAN_SWEEP_SOURCES,
exclude_ids=tuple(sorted(live_ids)), exclude_pinned=True)
if swept:
logger.info(
"Closed %d orphaned session row(s) from a previous gateway process (startup_orphan_reap): %s",
len(swept), ", ".join(swept))
return swept
# ── Cross-backend heartbeat ──────────────────────────────────────────────
# Each serve / gateway process registers a heartbeat row in ``gateway_heartbeats`` so the startup sweep can tell
# "owned by a live but idle backend" from "truly orphaned" (else the first process to restart reaped every
# inactive row of the other N−1). Refresh 60s default — far shorter than the 6h TTL so a refresh always lands
# inside the staleness window. Removed at exit; a crashed row ages out.
_HEARTBEAT_REFRESH_S = max(0.0, env_float("HERMES_GATEWAY_HEARTBEAT_REFRESH_S", 60.0))
_heartbeat_refresher_started = False
_heartbeat_refresher_lock = threading.Lock()
_BACKEND_NONCE = secrets.token_hex(4)
def _reaper_hostname() -> str:
return os.uname().nodename if hasattr(os, "uname") else "host"
def _backend_id_for_this_process() -> str:
"""Stable identity for this process's heartbeat row: pid (readability) AND a startup nonce so a PID-reuse
respawn cannot inherit the dead predecessor's heartbeat."""
return f"{_current_profile_name()}@{_reaper_hostname()}:{os.getpid()}:{_BACKEND_NONCE}"
def _gateway_started_at() -> float:
"""Wall-clock time this process started (first-call time is a good-enough proxy: the heartbeat refresher
runs after the gateway is fully wired up)."""
if getattr(_gateway_started_at, "_t", None) is None:
_gateway_started_at._t = time.time()
return _gateway_started_at._t
def _refresh_backend_heartbeat() -> None:
"""Refresh this backend's heartbeat row. No-op when DB unavailable."""
db = _get_db()
if db is None:
return
try:
db.register_backend_heartbeat(
backend_id=_backend_id_for_this_process(), pid=os.getpid(), started_at=_gateway_started_at(),
profile=_current_profile_name(), host=_reaper_hostname())
except Exception:
logger.debug("backend heartbeat refresh failed", exc_info=True)
def _start_backend_heartbeat_refresher() -> None:
"""Register this backend and start the refresher thread (once per process). The first refresh writes the row
synchronously so this process's own sweep sees itself in the heartbeat table. ``_HEARTBEAT_REFRESH_S <= 0``
means "register once, never refresh"."""
global _heartbeat_refresher_started
with _heartbeat_refresher_lock:
if _heartbeat_refresher_started:
return
_heartbeat_refresher_started = True
try:
_refresh_backend_heartbeat()
except Exception:
logger.debug("initial backend heartbeat write failed", exc_info=True)
if _HEARTBEAT_REFRESH_S <= 0:
return
stop_event = threading.Event()
def _loop() -> None:
while not stop_event.is_set():
try:
_refresh_backend_heartbeat()
except Exception:
logger.debug("heartbeat refresh loop iteration failed", exc_info=True)
stop_event.wait(_HEARTBEAT_REFRESH_S)
def _atexit_clear():
stop_event.set()
with contextlib.suppress(Exception):
if (db := _get_db()) is not None:
db.clear_backend_heartbeat(_backend_id_for_this_process())
atexit.register(_atexit_clear)
threading.Thread(target=_loop, name="hermes-gateway-heartbeat", daemon=True).start()
def _schedule_startup_orphan_sweep() -> None:
"""Schedule the once-per-process startup orphan sweep, delayed by the WS-orphan grace window so a client
reconnecting right after a restart can ``session.resume`` its row first. Grace 0 (park forever), TTL 0 and
``dashboard.startup_orphan_sweep: false`` all suppress the sweep.
See #65194.
"""
global _startup_orphan_sweep_ran
if _WS_ORPHAN_REAP_GRACE_S <= 0 or _SESSION_TTL_S <= 0 or not _session_orphan_reaper_enabled():
return
with _startup_orphan_sweep_lock:
if _startup_orphan_sweep_ran:
return
_startup_orphan_sweep_ran = True
_reaper_daemon_timer(
_WS_ORPHAN_REAP_GRACE_S, _sweep_orphaned_session_rows, "startup orphan session sweep failed", level="warning")
def register(server) -> None:
"""Publish this module's helpers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))