Files
hermes-agent/gateway/session_viewer_close.py
Teknium 2dd7d59a7b perf(gateway): retire closed local and idle API sessions from the authority's memory
A LiveSession (event stream, replay ring key, locks, pending controls) stayed in
SessionAuthority.sessions for the life of the process: one per local conversation ever
closed and one per API conversation ever served (+2 per soak cycle, unbounded).

Retirement (gateway/session_retirement.py) drops it when nothing pins it, leaving the
state a restart leaves: a closed local conversation once its close lifecycle ran, an API
conversation at its drain's idle exit. Every entry point already restores an absent
conversation (attach, admission, API bind); _schedule, _publish_pending and
observe_api_controls now restore one input committed for after it retired.

Fixes the Greptile findings on the split-out code (#147831):
- P1 accepted prompts: the pins and a FIFO/worker read are re-checked after the last
  await, in one loop step with the removal; a claim since the close (execution stamp)
  keeps the reopened conversation; a drain alive at close retires it at its idle exit.
- P1 shared launch keys: release_launch_secrets counts retired conversations' policies
  (authority.retired_local), so a closed branch keeps its parent's key.
- P2 prune: retire_live_sessions forgets a retired conversation's adapter source and
  policy by id.
2026-11-02 06:58:58 -08:00

397 lines
21 KiB
Python

"""The last viewer closing a session ends it on the owner, lifecycle included (#134889 LC-1/LC-2/LC-6).
Main's classic CLI and TUI ran the end of a conversation in their own process: ``end_session``,
the memory providers' ``on_session_end`` extraction and plugin ``on_session_finalize`` (which also
closes the Relay conversation scope). Under the gateway those clients are viewers: the agent, its
memory manager and the Relay scope live in the owner, so ``session.detach {end_reason}`` from the
last viewer is where the owner runs them. A close that lands while a turn is still queued or running
is remembered and completed when the FIFO goes idle, so exiting mid-turn still ends the row.
The pending close is also kept in ``state_meta`` (``CLOSE_PREFIX + session_id``): a gateway stop or
restart before the FIFO goes idle would otherwise drop it, and nothing else ends a local row. Boot
recovery re-arms it (``rearm_persisted_closes``), so the restarted owner's first idle point ends it.
Each stored close carries its own operation id, and every delete is compare-and-delete on that exact
record under the owner-epoch fence: a cancelled older close, a reattach or a stale owner can only
remove the record it saw, never a newer close stored since.
"""
import asyncio
import contextlib
import json
import logging
import time
import uuid
from functools import partial
from pathlib import Path
from gateway.session_history_mount import is_history_mount
from hermes_state_local import local_receipt
from hermes_state_mutation_retirement import VIEWER_CLOSE_PREFIX
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
CLOSE_PREFIX = VIEWER_CLOSE_PREFIX # route retirement drops it too
_BUSY_SQL = ("SELECT 1 FROM session_admissions WHERE target_session_id=? AND status IN ('queued','started','unknown') "
"UNION ALL SELECT 1 FROM worker_executions WHERE session_id IN (?,?) "
"AND status IN ('registered','running','unknown') LIMIT 1")
def _physical_target(authority, session_id):
try:
return local_receipt(authority.db, session_id)['entry']['session_id']
except (RuntimeStoreError, KeyError, TypeError):
return authority.db.get_compression_tip(session_id) or session_id
def _busy(authority, session_id, target):
with authority.db._read_ctx() as conn:
return conn.execute(_BUSY_SQL, (session_id, session_id, target)).fetchone() is not None
# The viewer's /new: the classic CLI's ``new_session``; Ink's ``tui_close`` (/new or a resume switch,
# both of which main's TUI followed with on_session_reset for the session it opened next).
_RESET_REASONS = frozenset({'new_session', 'tui_close'})
# Ends nobody asked for (#143547 LC-35/LC-37): the last viewer vanished without closing (a killed
# terminal, a dropped socket), or the previous gateway stopped under it. Both stay resumable.
ORPHAN_REASONS = frozenset({'ws_orphan_reap', 'startup_orphan_reap'})
# The legacy startup sweep's session TTL (tui_gateway ``_SESSION_TTL_S``): see arm_startup_orphan_closes.
STARTUP_IDLE_S = 6 * 3600.0
class _PendingClose:
"""One viewer close; identity, not the reason, tells a settler its close is still the pending one.
``record`` is its ``state_meta`` value: a unique operation id plus the reason."""
__slots__ = ('reason', 'record')
def __init__(self, reason, record=None):
self.reason = reason
self.record = record or json.dumps({'op': uuid.uuid4().hex, 'reason': reason}, sort_keys=True)
@classmethod
def from_record(cls, record):
try:
reason = json.loads(record)['reason']
except (ValueError, TypeError, KeyError):
reason = record # a bare-reason record from before operation ids
return cls(reason, record)
async def close_viewer_session(authority, session_id, reason):
"""``session.detach {end_reason}`` left *session_id* without viewers: close it now, or once idle.
Its own async delegations end now, mid-turn too, as main's CLI exit and TUI close ended them
(#134889 LC-7); each returns as an interrupted completion, and ones its still-running turn starts
later are refused until a viewer attaches again. Other sessions' work is untouched."""
live = authority.sessions[session_id]
if live.subscribers:
return # a viewer attached while the detach was off the loop: nothing closes or ends
if is_history_mount(live):
return # stored history: no route, agent or delegation of its own
from tools.async_delegation import interrupt_for_session, refuse_for_session
route = live.route
parents = tuple(dict.fromkeys((session_id, _physical_target(authority, session_id))))
refuse_for_session((authority.profile_id, session_id), session_key=route, parent_session_ids=parents)
for parent in parents:
interrupt_for_session(session_key=route, parent_session_id=parent, reason=f'viewer_close:{reason}')
authority.pending_closes[session_id] = close = _PendingClose(reason)
await _store_and_settle(authority, session_id, live, close)
async def _store_and_settle(authority, session_id, live, close):
"""Persist *close* (already pending in memory), then complete it at once if the FIFO is idle."""
await asyncio.to_thread(_persist_close, authority, session_id, close.record)
if not _still_pending(authority, session_id, live, close):
# A viewer attached during the write: its own clear may have run before this commit. Only
# this close's record goes; a newer close stored since keeps its own.
await asyncio.to_thread(forget_persisted_close, authority, session_id, close.record)
return
await settle_viewer_close(authority, session_id)
def _persist_close(authority, session_id, record):
from hermes_state_runtime import _epoch
def write(conn):
_epoch(conn, authority.epoch)
conn.execute('INSERT INTO state_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
(CLOSE_PREFIX + session_id, record))
authority.db._execute_write(write)
def forget_persisted_close(authority, session_id, record):
"""Blocking: drop *session_id*'s stored close only if it is still exactly *record* (that close
completed or was cancelled), and only while this authority owns the store. Returns whether a
row went."""
from hermes_state_runtime import _epoch
def write(conn):
_epoch(conn, authority.epoch)
return conn.execute('DELETE FROM state_meta WHERE key=? AND value=?',
(CLOSE_PREFIX + session_id, record)).rowcount
try:
return bool(authority.db._execute_write(write))
except RuntimeStoreError as exc:
logger.info('Stored close for %s left in place: %s', session_id, exc)
return False
def cancel_persisted_close(authority, session_id):
"""Blocking, for a viewer attach: drop the stored close unless it is the one now pending in
memory (a detach that ran after the attach stored it; it is that close's own to settle)."""
record = authority.db.get_meta(CLOSE_PREFIX + session_id)
if record is None:
return
pending = authority.pending_closes.get(session_id)
if pending is not None and pending.record == record:
return
forget_persisted_close(authority, session_id, record)
def rearm_persisted_closes(authority):
"""Boot/serve recovery, after the local sessions are restored: every stored close whose session
is mounted with no viewer goes back to ``pending_closes``. The caller schedules the drain, whose
idle point (after any queued turn; never past an ``unknown`` one) completes it. Returns the ids."""
with authority.db._read_ctx() as conn:
stored = conn.execute('SELECT key, value FROM state_meta WHERE key LIKE ?', (CLOSE_PREFIX + '%',)).fetchall()
armed = []
for key, record in stored:
session_id = key[len(CLOSE_PREFIX):]
live = authority.sessions.get(session_id)
if live is None or is_history_mount(live):
continue # not restored (paused, retired): left stored for a later recovery
if live.subscribers:
forget_persisted_close(authority, session_id, record)
continue
authority.pending_closes.setdefault(session_id, _PendingClose.from_record(record))
armed.append(session_id)
return armed
def _seconds(raw, default):
with contextlib.suppress(ValueError, TypeError):
return max(0.0, float(raw))
return default
def _section(config, key):
value = config.get(key)
return value if isinstance(value, dict) else {}
def orphan_close_grace(authority, *, startup=False):
"""Blocking: ``sessions.orphan_end_grace_s`` of the profile that owns *authority*'s store (0 = never).
Unset, the legacy backend's switches for the same reaping still opt out: ``dashboard.
ws_orphan_reap_grace_s: 0`` (park forever) and, for *startup* arms, ``startup_orphan_sweep: false``."""
from hermes_cli.config_defaults import DEFAULT_CONFIG
from hermes_cli.config_effective import load_user_config_effective
from gateway.session_local import profile_env_scope
from utils import is_truthy_value
default = DEFAULT_CONFIG['sessions']['orphan_end_grace_s']
home = Path(authority.db.db_path).resolve().parent
# A dropped socket detaches outside the RPC's owner scope: expand ``${VAR}`` against this profile.
with profile_env_scope(home):
config = load_user_config_effective(home / 'config.yaml')
sessions, dashboard = _section(config, 'sessions'), _section(config, 'dashboard')
if 'orphan_end_grace_s' in sessions:
return _seconds(sessions['orphan_end_grace_s'], default)
legacy_off = _seconds(dashboard.get('ws_orphan_reap_grace_s'), default) == 0 or (
startup and not is_truthy_value(dashboard.get('startup_orphan_sweep'), default=True))
return 0.0 if legacy_off else default
def orphan_candidate(authority, session_id):
"""Blocking: *session_id* is a local session with an open row. ACP ends on its own at detach."""
try:
receipt = local_receipt(authority.db, session_id)
row = authority.db.get_session(receipt['entry']['session_id'])
except (RuntimeStoreError, KeyError, TypeError):
return False # not a local session
return receipt.get('policy', {}).get('source') != 'acp' and row is not None and row['ended_at'] is None
def _call_later(loop, delay, callback):
# The loop's monotonic clock: time the host spends asleep does not count toward the grace.
return loop.call_later(delay, callback)
def _stopping(authority):
return authority.retiring or authority.retired or authority.runner._draining
def arm_orphan_close(authority, session_id, reason, grace):
"""*session_id* has no viewer and nobody closed it: unless one attaches within *grace* seconds,
close it as a viewer would, without stopping its turn or its async delegations (#106742). A
stopping owner arms nothing; the next boot does (``recover_local_sessions``)."""
live = authority.sessions.get(session_id)
if not grace or live is None or live.subscribers or is_history_mount(live) or _stopping(authority):
return
cancel_orphan_close(authority, session_id)
authority.orphan_closes[session_id] = _call_later(
asyncio.get_running_loop(), grace, partial(_fire_orphan_close, authority, session_id, reason))
async def arm_detached_orphan_close(authority, session_id):
"""``session.detach`` with no ``end_reason`` (a killed terminal, a dropped socket) left no viewer."""
def grace():
return orphan_close_grace(authority) if orphan_candidate(authority, session_id) else 0
arm_orphan_close(authority, session_id, 'ws_orphan_reap', await asyncio.to_thread(grace))
def _idle_s(authority, session_id):
from hermes_state_common import _sql_session_last_active
# Resolve the target BEFORE opening a read context: ``_physical_target`` reads the receipt, and
# a read that lands on the degraded path (non-WAL, or every read permit spent) waits on the
# writer's non-reentrant lock. Nested inside this block it would wait on the lock this thread
# already holds — a boot-time self-deadlock the watchdog can only answer by killing the gateway.
target = _physical_target(authority, session_id)
with authority.db._read_ctx() as conn:
row = conn.execute(f'SELECT {_sql_session_last_active("s")} FROM sessions s WHERE s.id = ?',
(target,)).fetchone()
return time.time() - row[0] if row and row[0] else 0.0
def arm_startup_orphan_closes(authority, session_ids):
"""Boot, after the stored closes are re-armed: the previous owner stopped under these sessions
(idle, or with input it left queued), so no viewer is left to close them (#143547 LC-37). One
that reattaches keeps it open; a fired close still waits for the queued input to settle.
An idle classic CLI only redials on its next call, so a session idle less than the legacy
sweep's TTL waits out the rest of it, as that sweep did, before its grace starts to count."""
grace = orphan_close_grace(authority, startup=True)
for session_id in session_ids:
if grace and session_id not in authority.pending_closes and orphan_candidate(authority, session_id):
delay = max(grace, STARTUP_IDLE_S - _idle_s(authority, session_id))
arm_orphan_close(authority, session_id, 'startup_orphan_reap', delay)
def cancel_orphan_close(authority, session_id):
handle = authority.orphan_closes.pop(session_id, None)
if handle is not None:
handle.cancel()
def _fire_orphan_close(authority, session_id, reason):
authority.orphan_closes.pop(session_id, None)
live = authority.sessions.get(session_id)
if live is None or live.subscribers or session_id in authority.pending_closes or _stopping(authority):
return
# The persisted pending close a viewer's own close uses: it completes once the turn settles, and
# survives a restart. Never ``close_viewer_session``: that would interrupt the delegations.
authority.pending_closes[session_id] = close = _PendingClose(reason)
from gateway.session_runtime_workers import track_mutation
track_mutation(authority, _store_and_settle(authority, session_id, live, close))
async def settle_viewer_close(authority, session_id):
"""Complete a pending viewer close once *session_id* has no viewer and an idle FIFO: stamp the
row with the viewer's reason, commit the cached agent's memory and fire ``on_session_finalize``.
The drain calls this at every idle point; a viewer attaching meanwhile cancels the close."""
close = authority.pending_closes.get(session_id)
live = authority.sessions.get(session_id)
if close is None or live is None or live.subscribers:
authority.pending_closes.pop(session_id, None)
return
if not await _resolve_abandoned_unknown(authority, session_id, live, close):
return # left paused (a viewer came back, or the resolution was refused)
target = _physical_target(authority, session_id)
# Every await below can let a viewer reattach (attach drops the pending close) or another
# settler run, so the close is re-checked after each one.
if await asyncio.to_thread(_busy, authority, session_id, target):
return # the drain's next idle point completes it
idle_stamp = live.event_stream.execution # each claim stamps a new one (``_stamp_claim``)
row = await asyncio.to_thread(authority.db.get_session, target)
if not _still_pending(authority, session_id, live, close):
return
# Claimed with no await since the check: exactly one settler stamps the row and runs the
# lifecycle, and a close cancelled before its claim never wrote ``ended_at``.
del authority.pending_closes[session_id]
from gateway.session_acp_lifecycle import end_idle_viewer_session, reopen_if_reattached
from gateway.session_runtime_workers import tracked_write
ended = await tracked_write(authority, partial(end_idle_viewer_session, authority, session_id, close.reason),
then=partial(reopen_if_reattached, authority, session_id))
if not ended:
await _requeue_if_busy(authority, session_id, live, target, close)
return
await asyncio.to_thread(forget_persisted_close, authority, session_id, close.record)
if live.subscribers:
return # a viewer reattached during the stamp; reopen_if_reattached reopened the row
platform = (row or {}).get('source') or (live.source.platform.value if live.source else '')
from gateway.session_vault_prompt import release_unlocks
release_unlocks(session_id)
await _run_close_lifecycle(authority, live, target, platform, close.reason)
if not live.subscribers: # ended: no viewer is left to replay to; a resume refetches history
live.event_stream.release()
from gateway.session_retirement import retire_closed
retire_closed(authority, session_id, live, target, idle_stamp)
async def _resolve_abandoned_unknown(authority, session_id, live, close):
"""A turn the previous owner lost (hard kill) recovers ``unknown`` and pauses the FIFO until
someone Discards it, but the last viewer closed the session: nobody is left to. The viewer's
close is that decision, so the owner resolves it as Discard does (interrupted, never replayed,
transcript boundary written) and the drain moves on; a queued successor still runs before the
close. A viewer back first cancels the close and keeps the decision. Returns whether the
close may go on to its idle check (no ``unknown`` turn is left)."""
from hermes_state_runtime import list_session_admissions
rows = await asyncio.to_thread(list_session_admissions, authority.db, session_id=session_id)
unknown = [row for row in rows if row['status'] == 'unknown']
if not unknown:
return True
if close.reason in ORPHAN_REASONS:
return False # nobody closed it: the turn waits for its user's own Discard
from gateway.session_contract import SessionRef
ref = SessionRef(authority.profile_id, session_id)
for row in unknown:
if not _still_pending(authority, session_id, live, close):
return False
try:
await authority.resolve_unknown_as_owner(ref, row['admission_id'], row['generation'])
except RuntimeStoreError as exc:
logger.warning('Unknown turn %s on closed session %s left paused: %s',
row['admission_id'], session_id, exc.reason)
return False
logger.info('Unknown turn %s resolved interrupted: its session %s was closed by its last viewer',
row['admission_id'], session_id)
# The resolution also rescheduled the drain; the idle check below and its next idle point
# are both safe (the close is claimed exactly once).
return _still_pending(authority, session_id, live, close)
def _still_pending(authority, session_id, live, close):
return not live.subscribers and authority.pending_closes.get(session_id) is close
async def _requeue_if_busy(authority, session_id, live, target, close):
"""The stamp was refused. If input was admitted after the idle check, the close goes back to the
drain's next idle point; any other refusal (row already ended) leaves nothing to finish."""
if not await asyncio.to_thread(_busy, authority, session_id, target):
if session_id not in authority.pending_closes:
await asyncio.to_thread(forget_persisted_close, authority, session_id, close.record) # already ended
return
if live.subscribers or session_id in authority.pending_closes:
return # a reattach cancelled it, or a newer close replaced it
authority.pending_closes[session_id] = close
# The drain may have passed its idle point while the close was out of the map.
await settle_viewer_close(authority, session_id)
async def _run_close_lifecycle(authority, live, target, platform, reason):
"""What main's client ran at exit and ``/new``, in the process that owns the session: the
memory providers' end-of-session commit (the cached agent is evicted at a boundary, as a
gateway ``/new`` does) and the finalize hooks under the owning profile's scope."""
runner = authority.runner
evict = getattr(runner, '_evict_cached_agent_at_boundary', None)
if evict is not None:
evict(live.route)
finalize = getattr(runner, '_finalize_session_off_loop', None)
if finalize is None:
return
messages = await finalize(session_id=target, platform=platform, reason=reason, session_key=live.route)
if messages:
# No viewer is left to show them (session.detach carries no messages yet).
logger.info('Session-end plugin message(s) for %s dropped: no viewer', target)
reset = getattr(runner, '_reset_session_off_loop', None)
if reason in _RESET_REASONS and reset is not None:
# The gateway's own /new arguments. The viewer opens its next session only after this
# detach, so the new id is not known here.
await reset(session_key=live.route, session_id=None, reason='new_session', platform=platform,
old_session_id=target, new_session_id=None)