Files
hermes-agent/tui_gateway/session_lifecycle.py
T
FalconOrtiz db96c8ced7 fix: admit Bot Chat deliveries through the live session owner
Adapt FalconOrtiz's owner-mailbox proposal from #101564 onto the current
notification poller and topical modules. The durable mailbox is cross-process
ingress only: the existing owner admits its normal prompt turn after the
current turn and human FIFO clear. Retain immutable receipts, stable admission
identities, capability and lease fencing, compression lineage, and disable
blind recovery replay of imported turns.

The original stale server hunks and expiring receipt protocol were rebuilt
rather than cherry-picked: the current facade decomposition and durable busy
admission contract differ. Credit the earlier owner-mailbox work in #100544
and durable producer work in #100319.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
Co-authored-by: 686f6c61 <github@00b.tech>
2026-09-07 16:48:29 -07:00

643 lines
34 KiB
Python

"""Session lifecycle: active-session slot leases, finalize/teardown/close, turn interrupt,
WS-orphan reap scheduling, transport-scoped close. Bodies are rebound onto server.py's
globals at install time (method_ctx.bind_module), so they reference server.py globals bare.
"""
from __future__ import annotations
import contextlib
from .method_ctx import bind_module
def _notify_session_boundary(event_type: str, session_id: str | None, platform: str | None = None) -> None:
"""Fire session lifecycle hooks with CLI parity."""
with contextlib.suppress(Exception):
from hermes_cli.lifecycle import finalize_session, invoke_hook
if event_type == "on_session_finalize":
finalize_session(session_id=session_id, platform=_resolve_agent_platform(platform))
else:
invoke_hook(event_type, session_id=session_id, platform=_resolve_agent_platform(platform))
_SESSION_OWNERSHIP_UNAVAILABLE = "Hermes could not safely reserve this session. Try again."
_AUTOMATIC_SESSION_END_REASONS = frozenset({"ws_orphan_reap", "ws_disconnect", "idle_timeout", "lru_evict", "tui_shutdown"})
def _claim_active_session_slot(
session_key: str, *, live_session_id: str, surface: str = "tui", profile_home: str | Path | None = None
) -> tuple[Any, str | None]:
try:
from hermes_cli.active_sessions import try_acquire_active_session
return try_acquire_active_session(
session_id=session_key, surface=surface, config=_load_cfg(), registry_home=profile_home,
metadata={"live_session_id": live_session_id, "bot_live_delivery_consumer": True},
track_liveness=str(surface or "").strip().lower() == "desktop")
except Exception as exc:
logger.warning("Failed to claim active session slot: %s", exc)
# Fail CLOSED: an errored claim has NOT proven the session unowned; lease-less = silent double-writer hole.
# Fail CLOSED regardless of surface: per-session exclusivity is a correctness guarantee (see
# PER_SESSION_EXCLUSIVE_SUBMIT), and a claim that errors out has NOT proven the session is unowned.
# Proceeding without a lease here is the silent double-writer hole flagged in the #94595 review
# (blocker 2).
return (None, _SESSION_OWNERSHIP_UNAVAILABLE)
def _ensure_active_session_slot(sid: str, session: dict) -> str | None:
"""Claim this session's cap slot on its first real turn; None when ok. session.create/resume deliberately
do NOT claim: tile paints, reconnect-resumes and abandoned drafts would hold invisible slots (no DB row)
that starve the messaging gateway sharing the cap. Anything holding a slot must be user-visible."""
if session.get("active_session_lease") is not None:
return None
lease, limit_message = _claim_active_session_slot(
str(session.get("session_key") or ""), live_session_id=sid,
surface=_session_source(session), profile_home=session.get("profile_home"))
if limit_message is None:
session["active_session_lease"] = lease
return limit_message
def _lease_retry(attempts: int, fn) -> Exception | None:
"""Call ``fn`` up to ``attempts`` times (50ms*n backoff: registry writes contend across processes); last
exception when every try failed, else None."""
for attempt in range(attempts):
try:
fn()
return None
except Exception as exc:
last_error = exc
if attempt + 1 < attempts:
time.sleep(0.05 * (attempt + 1))
return last_error
def _release_active_session_slot(session: dict | None) -> bool:
"""Release the session's lease (liveness-tracked leases get 3 tries); True when released."""
lease = session.get("active_session_lease") if session else None
if lease is None:
return True
if (err := _lease_retry(3 if getattr(lease, "track_liveness", False) else 1, lambda: lease.release())) is not None:
logger.warning("Failed to release active session slot", exc_info=err)
return False
if not (getattr(lease, "released", True) or not getattr(lease, "enabled", True)):
return False
if session.get("active_session_lease") is lease:
session.pop("active_session_lease", None)
return True
def _own_live_lease_ids(*, exclude=None) -> set[str]:
"""Snapshot leases still backed by this process's live session records."""
with _sessions_lock:
return {str(lease.lease_id) for session in _sessions.values()
if (lease := session.get("active_session_lease")) is not None and lease is not exclude}
@contextlib.contextmanager
def _other_runtime_lease_guard(session_id: str, session: dict):
"""Release this runtime and lock sibling ownership through the DB write. Yields True (another runtime owns
the lifecycle -> preserve) when the guard can't be loaded/entered in 3 tries: unknown ownership never ends a row."""
lease = session.get("active_session_lease")
try:
from hermes_cli.active_sessions import active_session_liveness_guard, release_active_session_liveness_guard
except Exception as exc:
logger.warning("Failed to load active session ownership guard; preserving session %s: %s", session_id, exc)
yield True
return
stack = contextlib.ExitStack()
active: list = []
own_live_lease_ids = _own_live_lease_ids(exclude=lease)
def _enter() -> None:
stack.close() # drop anything a half-failed previous attempt left behind
if lease is not None and getattr(lease, "enabled", False):
guard = release_active_session_liveness_guard(lease, session_id, own_live_lease_ids=own_live_lease_ids)
else:
guard = active_session_liveness_guard(
session_id, registry_home=session.get("profile_home"), own_live_lease_ids=own_live_lease_ids)
active[:] = [stack.enter_context(guard)]
if (last_error := _lease_retry(3, _enter)) is not None:
stack.close()
logger.warning("Failed to inspect active session leases; preserving session %s: %s", session_id, last_error)
yield True
return
try:
yield active[0]
finally:
stack.close()
if lease is not None and getattr(lease, "released", False) and session.get("active_session_lease") is lease:
session.pop("active_session_lease", None)
def _transfer_active_session_slot(sid: str, session: dict, *, new_session_id: str) -> bool:
if not new_session_id:
return False
lease = session.get("active_session_lease")
if lease is None:
return True
try:
from hermes_cli.active_sessions import transfer_active_session
if transfer_active_session(lease, session_id=new_session_id, metadata={
"live_session_id": sid, "bot_live_delivery_consumer": True}):
return True
except Exception:
logger.debug("Failed to transfer active session slot", exc_info=True)
if getattr(lease, "track_liveness", False):
return False
# Fallback (entry pruned / pid-check transiently failed): reserve the new slot BEFORE releasing the old one so
# a gateway at the cap can't grab the freed slot and leave this session lease-less; on failure KEEP the old lease.
# See #49041.
new_lease, limit_message = _claim_active_session_slot(
new_session_id, live_session_id=sid, surface=_session_source(session), profile_home=session.get("profile_home"))
if new_lease is None:
if limit_message:
logger.warning("Compression session lease re-anchor failed (kept old lease): sid=%s new_session_id=%s reason=%s",
sid, new_session_id, limit_message)
return False
if (old := session.pop("active_session_lease", None)) is not None and (err := _lease_retry(1, old.release)):
logger.debug("Failed to release stale active session slot", exc_info=err)
session["active_session_lease"] = new_lease
return True
# Sources this backend must never end in state.db: the messaging gateway owns those sessions and the TUI is only
# a viewer (ending one causes the Groundhog Day loop, see _finalize_session). Self-created/CLI sources are NOT gateway-owned.
# Sources the TUI backend itself creates ("tui", plus whatever a client passes as its own ``source``) and
# the CLI's own sessions are NOT gateway-owned. See #60609.
_NON_GATEWAY_SOURCES = frozenset({
"", "tui", "cli", "webui", "desktop", "cron", "kanban", "subagent", "test",
"local", "acp", "webhook", "api_server", "msgraph_webhook"})
def _is_gateway_owned_source(source: str) -> bool:
"""True when ``source`` resolves to a gateway ``Platform`` (enum member or plugin via ``Platform._missing_``, so
new platforms are covered automatically); self-owned Platform members (local/webhook/api_server) are excluded."""
src = (source or "").strip().lower()
if src in _NON_GATEWAY_SOURCES:
return False
try:
from gateway.config import Platform
Platform(src) # raises ValueError for arbitrary non-platform strings
return True
except Exception:
return False
def _lifecycle_own_sid(session: dict, sid_hint: str = "") -> str:
"""Live UI sid for ``session``: hint, stamped ``_sid``, else registry scan."""
own_sid = str(sid_hint or session.get("_sid") or "")
if not own_sid:
with contextlib.suppress(Exception), _sessions_lock:
own_sid = next((cand_sid for cand_sid, cand in _sessions.items() if cand is session), "")
return own_sid
def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
"""Best-effort finalize hook + memory commit; mirrors the CLI exit path so a force-quit mid-turn (double
Ctrl-C, terminal close, SIGHUP) loses nothing."""
if not session or session.get("_finalized"):
return
session["_finalized"] = True
if (history_ready := session.get("resume_history_ready")) is not None and not history_ready.is_set():
session["resume_history_error"] = "session resume cancelled"
history_ready.set()
_desktop_automatic_cleanup = (
end_reason in _AUTOMATIC_SESSION_END_REASONS and _session_source(session).strip().lower() == "desktop")
# Automatic Desktop cleanup releases its lease inside the lifecycle guard below; other paths keep force/end semantics.
if not _desktop_automatic_cleanup:
_release_active_session_slot(session)
if (stop_event := session.get("_notif_stop")) is not None:
stop_event.set()
agent = session.get("agent")
with (session.get("history_lock") or contextlib.nullcontext()):
history = list(session.get("history", []))
# Persist via ``_persist_session``'s marker-based dedup (gateway-shutdown flush contract). Do NOT pass
# ``conversation_history``: ``session["history"]`` and ``_session_messages`` alias the SAME list after a turn, so
# the flush would treat every message as durable and skip it — data loss when finalize is the sole persist path.
if hasattr(agent, "_persist_session") and (snapshot := getattr(agent, "_session_messages", None)):
with contextlib.suppress(Exception):
agent._persist_session(snapshot)
# interrupted=True so crash-recovery plugins can flush state (mirrors cli.py atexit).
if agent is not None:
with contextlib.suppress(Exception):
from hermes_cli.lifecycle import invoke_hook
invoke_hook(
"on_session_end", completed=False, interrupted=True,
session_id=getattr(agent, "session_id", None) or session.get("session_key", ""),
model=getattr(agent, "model", "unknown"), platform=getattr(agent, "platform", None) or "tui")
if agent is not None and history and hasattr(agent, "commit_memory_session"):
with contextlib.suppress(Exception):
agent.commit_memory_session(history)
session_key = session.get("session_key")
session_id = getattr(agent, "session_id", None) or session_key
_notify_session_boundary("on_session_finalize", session_id, _session_source(session))
# End the state.db row so it doesn't linger as a ghost in /resume. Use session_id (agent.session_id), not
# session_key: after compression the key may be the stale ended parent while session_id is the live continuation.
# Fix for #20001.
if _desktop_automatic_cleanup and not session_id:
_release_active_session_slot(session)
_lifecycle_guard = (_other_runtime_lease_guard(session_id, session)
if _desktop_automatic_cleanup and session_id else contextlib.nullcontext(False))
with _lifecycle_guard as _other_runtime_owns_lifecycle:
_tui_owns_lifecycle = not _other_runtime_owns_lifecycle
if _other_runtime_owns_lifecycle:
logger.info("Preserving session %s during %s: another backend owns an active lease", session_id, end_reason)
if session_id:
# The *session's* profile state.db (app-global remote mode), not the launch profile's.
with contextlib.suppress(Exception), _session_db(session) as db:
if db is not None:
# Never end gateway-originated sessions: Groundhog Day loop (gateway self-heals to the parent,
# compression splits back to the reaped child, forever).
if _is_gateway_owned_source((db.get_session(session_id) or {}).get("source", "")):
_tui_owns_lifecycle = False
elif _tui_owns_lifecycle:
db.end_session(session_id, end_reason)
# In-flight async delegations end WITH the session (no return address left). Always interrupt by THIS live UI
# sid; by durable session_key only when the TUI owns the lifecycle — a viewer tab must not kill gateway work.
with contextlib.suppress(Exception):
from tools.async_delegation import interrupt_for_session
interrupt_for_session(
session_key=str(session_key or "") if _tui_owns_lifecycle else "",
origin_ui_session_id=_lifecycle_own_sid(session), reason=end_reason)
# Close the slash-worker in this single ``_finalized``-guarded chokepoint (a direct caller can't leak it); idempotent.
with contextlib.suppress(Exception):
if worker := session.get("slash_worker"):
worker.close()
# End reasons where the BACKEND reclaimed a session the client never asked to close (else its next prompt fails
# against a forgotten id). Client-initiated reasons (``tui_close`` etc.) are deliberately absent.
_RECLAIM_END_REASONS = frozenset({"idle_timeout", "lru_evict", "ws_orphan_reap"})
def _announce_session_reclaimed(session: dict, end_reason: str) -> None:
"""Tell connected clients a session was reclaimed out from under them. Broadcast, not session-targeted: reap
paths run on timer threads with no contextvar binding and no live transport, so ``_emit`` would hit stdio."""
if end_reason not in _RECLAIM_END_REASONS:
return
try:
_broadcast_global_event("session.reclaimed", {
"session_id": str(session.get("_sid") or ""),
"stored_session_id": str(session.get("session_key") or ""),
"reason": end_reason})
except Exception:
logger.debug("session.reclaimed broadcast failed", exc_info=True)
def _teardown_session(session: dict | None, *, end_reason: str = "tui_close") -> None:
"""Fully tear down a session: finalize, unregister notifier, close agent (``session.close`` + WS reaper). The
slash-worker is closed in ``_finalize_session`` (the single chokepoint), NOT here. Idempotent via ``_finalized``."""
if not session:
return
_finalize_session(session, end_reason=end_reason)
_announce_session_reclaimed(session, end_reason)
with contextlib.suppress(Exception):
from tools.approval import unregister_gateway_notify
if key := session.get("session_key"):
unregister_gateway_notify(key)
with contextlib.suppress(Exception):
if hasattr(agent := session.get("agent"), "close"):
agent.close()
def _attach_worker(sid: str, session: dict, worker) -> None:
"""Store worker on session iff sid still maps to it, else close it (a concurrent teardown popped the session)."""
with _sessions_lock:
if _sessions.get(sid) is session:
session["slash_worker"] = worker
return
worker.close()
# Wall-clock timestamps, like session last_active; retained after close/reap.
_closed_session_activity: dict[str, float] = {}
def _pop_session_by_id(sid: str) -> dict | None:
"""Atomically detach one live session from the registry — the ownership claim for teardown (a concurrent
close/reaper no-ops). Separate from ``_teardown_session``: slow finalization must not run under the resume lock."""
with _sessions_lock:
session = _sessions.pop(sid, None)
if session is not None:
from hermes_constants import get_hermes_home
home = str(Path(session.get("profile_home") or get_hermes_home()).resolve())
last_active = time.time() if session.get("running") else float(session.get("last_active") or 0)
_closed_session_activity[home] = max(_closed_session_activity.get(home, 0), last_active)
session["_closing"] = True
session["_sid"] = sid # out of _sessions now, so teardown can't recover the live id by scanning
return session
def _teardown_popped_session(session: dict | None, *, end_reason: str = "tui_close") -> bool:
"""Finish a close after the caller has atomically detached the session."""
if session is None:
return False
run_thread = session.get("_run_thread")
if end_reason != "tui_shutdown" and run_thread is not None and run_thread is not threading.current_thread():
try:
if run_thread.is_alive():
run_thread.join(timeout=_TURN_SETTLE_BEFORE_CLOSE_SECONDS)
if run_thread.is_alive():
logger.warning(
"session turn thread still alive after %.1fs teardown grace", _TURN_SETTLE_BEFORE_CLOSE_SECONDS)
except Exception:
logger.debug("failed waiting for session turn thread", exc_info=True)
_teardown_session(session, end_reason=end_reason)
return True
def _close_session_by_id(
sid: str, *, end_reason: str = "tui_close", predicate: Callable[[dict], bool] | None = None) -> bool:
"""Idempotent teardown funnel for callers with no resume race (resume-sensitive callers pop under
``_session_resume_lock`` and call ``_teardown_popped_session`` after releasing it). Automatic reapers pass
``predicate`` to revalidate under ``_sessions_lock`` right before the claim, so a stale scan can't close a
session that reattached."""
with _sessions_lock: # RLock: predicate + claim in one critical section
current = _sessions.get(sid)
if predicate is not None and (current is None or not predicate(current)):
return False
session = _pop_session_by_id(sid)
return _teardown_popped_session(session, end_reason=end_reason)
def _ws_session_is_detached(session: dict | None) -> bool:
"""True if a live session is still bound to the disconnected-WS sentinel."""
return bool(session and not session.get("_finalized") and session.get("transport") is _detached_ws_transport)
def _ws_session_is_orphaned(session: dict | None) -> bool:
"""True if a WS session sits on ``_detached_ws_transport`` (where ``handle_ws`` parks disconnected clients), idle."""
return bool(_ws_session_is_detached(session) and not session.get("running"))
def _interrupt_session_turn(sid: str, session: dict, *, request_id: str | None = None) -> bool:
"""Apply the shared ``session.interrupt`` contract to one claimed session; returns whether the compute-host control
channel was used. The WS orphan reaper reuses this so a dead client gets the same partial-history/queue semantics."""
use_compute_host = _session_uses_compute_host(session)
should_interrupt = bool(session.get("running"))
run_thread_alive = False
if use_compute_host:
# The host owns the live turn (parent `running` can lag a blocked tool), so let it decide. Gate on
# `_compute_host_active`: HostSupervisor.interrupt() calls start(), so a lazy session would spawn a child to interrupt.
if should_interrupt or session.get("_compute_host_active"):
_get_compute_host_supervisor().interrupt(sid, request_id=request_id)
else:
run_thread_alive = (rt := session.get("_run_thread")) is not None and rt.is_alive()
with session["history_lock"]:
session["_turn_cancel_requested"] = True
session["queued_prompt"] = None
session.pop("queued_prompts", None)
session["_queued_prompt_generation"] = int(session.get("_queued_prompt_generation", 0)) + 1
if not use_compute_host:
if should_interrupt:
from agent.interrupt_compat import request_hard_interrupt
request_hard_interrupt(session.get("agent"))
if not run_thread_alive:
with session["history_lock"]:
if session.get("running"):
session["running"] = False
_clear_inflight_turn(session)
_clear_pending(sid)
with contextlib.suppress(Exception):
from tools.approval import resolve_gateway_approval
resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
return use_compute_host
def _session_has_active_delegations(sid: str, session: dict | None = None) -> bool:
"""True when UI session ``sid`` still owns live background work — by live UI sid AND, when the TUI owns the durable
lifecycle (never for gateway-viewer tabs), by session_key so a delegation from an earlier tab keeps it alive.
See #60609.
"""
if session is None:
with _sessions_lock:
session = _sessions.get(sid)
if not session:
return False
own_sid = _lifecycle_own_sid(session, sid)
owned_session_key = session_key = str(session.get("session_key") or "")
session_id = getattr(session.get("agent"), "session_id", None) or session_key
if session_id:
# Only when this session may end its durable row by key — never for gateway-originated sessions (TUI is a
# viewer there). Unknown DB state -> assume ownership.
with contextlib.suppress(Exception):
db = _get_db()
if db is not None and _is_gateway_owned_source((db.get_session(session_id) or {}).get("source", "")):
owned_session_key = ""
if not own_sid and not owned_session_key:
return False
try:
from tools.async_delegation import has_live_for_session
return has_live_for_session(session_key=owned_session_key, origin_ui_session_id=own_sid)
except Exception:
logger.debug("Failed to query active delegations for UI session %s", sid, exc_info=True)
return True # a transient registry/import failure must not become destructive cleanup
# One pending WS-orphan reap Timer per live sid; guarded by _sessions_lock. Cancelled by _cancel_ws_orphan_reap from
# every resume/reuse/transport-rebind path — else a reap on a reattached session triggers a reap->broadcast->resume storm.
_pending_ws_reaps: dict[str, threading.Timer] = {}
def _cancel_ws_orphan_reap(sid: str) -> None:
"""Cancel a pending WS-orphan reap for ``sid`` (client came back). Called from every path that re-binds a live
transport; closes the fired-but-not-run Timer race and stops dead Timers accumulating on flappy clients."""
with _sessions_lock:
timer = _pending_ws_reaps.pop(sid, None)
if timer is not None:
with contextlib.suppress(Exception):
timer.cancel()
def _reattach_refusal(rid, sid: str, session: dict) -> dict | None:
"""Under ``_session_resume_lock``: why a reattaching RPC (resume/activate/prompt.submit) must NOT rebind
``session`` — it is stale, or a client-gone interrupt is still settling and the reap Timer must keep
polling. None when the reattach may proceed."""
if _sessions.get(sid) is not session:
return _err(rid, 4007, "session no longer live; retry resume")
if session.get("_client_gone_interrupt_requested"):
return _err(rid, 4009, "session disconnect interrupt settling")
return None
def _rebind_live_transport(sid: str, session: dict, transport: Transport) -> None:
"""Point a live session at ``transport`` (caller holds ``history_lock``)."""
session["transport"] = transport
# Every transport that showed this session (pop-outs resume the same sid); on disconnect the last
# viewer becomes the transport instead of the drop sentinel.
session.setdefault("viewers", {})[transport] = time.time()
# See #83716.
if transport is not _detached_ws_transport:
_cancel_ws_orphan_reap(sid) # the client is back — a pending ws-orphan reap must not fire
def _ws_orphan_turn_activity_is_fresh(session: dict) -> bool:
"""Whether a detached RUNNING turn's activity clock (``_touch_activity``) is still fresh — the reaper must NOT
interrupt healthy detached work (closed laptop). Conservative: disabled threshold, missing/opaque agent, unreadable
summary or never-stamped clock all report NOT fresh (eligible for interrupt-at-grace) to keep the wedged-turn net.
Reuses the agent's existing activity summary (``_touch_activity`` is stamped by API waits, stream
tokens, and tool heartbeats — the same clock the turn-liveness watchdog samples; see
agent/turn_liveness.py). See #100325, #98028.
Isolated turns mirror that clock from the child under a unique dispatch token;
their monotonic samples keep aging even if the child or its pipe stalls.
"""
if _WS_ORPHAN_ACTIVITY_STALE_S <= 0:
return False
if session.get("_compute_host_turn_id"):
with session["history_lock"]:
stamp = session.get("_compute_host_activity_ns")
return (session.get("running", False) and isinstance(stamp, int)
and 0 <= (time.perf_counter_ns() - stamp) / 1_000_000_000 < _WS_ORPHAN_ACTIVITY_STALE_S)
if not callable(summary_fn := getattr(session.get("agent"), "get_activity_summary", None)):
return False
try:
elapsed = summary_fn().get("seconds_since_activity")
return elapsed is not None and float(elapsed) < _WS_ORPHAN_ACTIVITY_STALE_S
except Exception:
return False
def _schedule_ws_orphan_reap(
sid: str, *, delay_s: float | None = None, _expected_timer: threading.Timer | None = None,
) -> None:
"""After a grace window, reap session ``sid`` iff it's still orphaned. Called from the WS-disconnect path; a
reconnect or ``session.resume`` cancels the reap by re-binding a live transport. Disabled when grace is 0."""
if _WS_ORPHAN_REAP_GRACE_S <= 0:
return
def _reap() -> None:
# Serialize the re-check against session.resume (rebinds under _session_resume_lock). Claim teardown by popping
# under both locks, then release the resume lock before slow finalization. Order: resume_lock -> sessions_lock.
reschedule_delay = interrupt_session = session = None
with _session_resume_lock, _sessions_lock:
# Keep ownership through interrupt I/O and continuation registration. A cancelled
# callback may already be dispatched, but cannot act on a later detachment.
if _pending_ws_reaps.get(sid) is not timer:
return
current = _sessions.get(sid)
if current is None:
_pending_ws_reaps.pop(sid, None)
return
if not _ws_session_is_detached(current):
# This Timer is abandoning the interrupt claim because another
# writer moved the live record off the detached transport.
# Do not leave reattach RPCs fenced with 4009, or let this
# generation's settlement polls shorten a later detachment.
current.pop("_client_gone_interrupt_requested", None)
current.pop("_client_gone_interrupt_polls", None)
_pending_ws_reaps.pop(sid, None)
return
if _session_has_active_delegations(sid, current):
reschedule_delay = _WS_ORPHAN_REAP_GRACE_S
elif not current.get("running"):
session = _pop_session_by_id(sid)
elif not current.get("_client_gone_interrupt_requested") and _ws_orphan_turn_activity_is_fresh(current):
# Client-absent but producing: keep running detached (the sentinel buffers emits), re-check each grace.
logger.debug("client_gone sid=%s action=defer (turn activity fresh; stale threshold %.0fs)",
sid, _WS_ORPHAN_ACTIVITY_STALE_S)
reschedule_delay = _WS_ORPHAN_REAP_GRACE_S
else:
# Mid-turn detached sessions must never drop the single Timer: interrupt once after grace, then poll
# until turn-finalization settles.
polls = current["_client_gone_interrupt_polls"] = int(current.get("_client_gone_interrupt_polls") or 0) + 1
# See #85578.
if polls > _WS_ORPHAN_INTERRUPT_REAP_MAX_POLLS:
# Never settled inside the budget — force-reap rather than park forever.
logger.error(
"client_gone sid=%s: turn did not settle after %d interrupt polls (%.0fs) — force-reaping detached session",
sid, polls - 1, (polls - 1) * _WS_ORPHAN_INTERRUPT_REAP_POLL_S)
session = _pop_session_by_id(sid)
else:
if not current.get("_client_gone_interrupt_requested"):
current["_client_gone_interrupt_requested"] = True
interrupt_session = current
reschedule_delay = _WS_ORPHAN_INTERRUPT_REAP_POLL_S
if reschedule_delay is None:
_pending_ws_reaps.pop(sid, None)
if interrupt_session is not None:
try:
isolated = _interrupt_session_turn(sid, interrupt_session, request_id=f"client-gone-{sid}")
logger.info("client_gone sid=%s action=interrupt turn_isolation=%s", sid, isolated)
except Exception:
logger.exception("client_gone interrupt failed sid=%s", sid)
with _sessions_lock:
if (_sessions.get(sid) is interrupt_session
and _pending_ws_reaps.get(sid) is timer):
interrupt_session.pop("_client_gone_interrupt_requested", None)
if reschedule_delay is not None:
_schedule_ws_orphan_reap(sid, delay_s=reschedule_delay, _expected_timer=timer)
return
if session is not None and session.get("_client_gone_interrupt_requested"):
logger.info("client_gone sid=%s action=reap", sid)
_teardown_popped_session(session, end_reason="ws_orphan_reap")
with _sessions_lock:
if _expected_timer is not None and _pending_ws_reaps.get(sid) is not _expected_timer:
return
timer = threading.Timer(_WS_ORPHAN_REAP_GRACE_S if delay_s is None else max(0.0, delay_s), _reap)
timer.daemon = True
prior = _pending_ws_reaps.pop(sid, None)
_pending_ws_reaps[sid] = timer
if prior is not None:
with contextlib.suppress(Exception):
prior.cancel()
timer.start()
def _close_sessions_for_transport(transport, *, end_reason: str = "ws_disconnect") -> tuple[int, int]:
"""Single WS-disconnect teardown entry point: reap close_on_disconnect sessions (sidecar/dashboard) immediately;
re-point the rest at the detached transport (later emits miss the dead socket) for the grace-windowed WS-orphan
reaper. Returns ``(reaped, detached)`` counts."""
with _sessions_lock:
owned = [(sid, s) for sid, s in _sessions.items() if s.get("transport") is transport]
reaped = detached = 0
for sid, session in owned:
claimed_for_teardown = None
should_schedule_reap = False
# session.resume fast-path rebinds under _session_resume_lock: take it so a reconnect can't move the transport
# between check and claim.
with _session_resume_lock, _sessions_lock:
current = _sessions.get(sid)
if current is not session:
continue
if current.get("transport") is not transport:
# The reconnect owns this session now; drop only the old viewer registration.
(current.get("viewers") or {}).pop(transport, None)
continue
if current.get("close_on_disconnect"):
claimed_for_teardown = _pop_session_by_id(sid)
else:
# Point at the drop sentinel (NOT real stdio) so _ws_session_is_orphaned recognizes it; standalone
# `hermes --tui` keeps real _stdio. UNLESS another window (pop-out viewer) still shows the session:
# re-bind to the most recent surviving viewer instead.
viewers = current.get("viewers") or {}
# See #83716.
viewers.pop(transport, None)
live = [vt for vt, ts in sorted(viewers.items(), key=lambda kv: kv[1]) if not _transport_is_dead(vt)]
if live:
current["transport"] = live[-1]
else:
current["transport"] = _detached_ws_transport
current.pop("_client_gone_interrupt_requested", None)
current.pop("_client_gone_interrupt_polls", None)
should_schedule_reap = True
# Register before releasing the detachment claim: an old disconnect
# must not arm its first timer over a reconnect's newer detachment.
with contextlib.suppress(Exception):
_schedule_ws_orphan_reap(sid)
if claimed_for_teardown is not None:
reaped += _teardown_popped_session(claimed_for_teardown, end_reason=end_reason)
elif should_schedule_reap:
detached += 1
return reaped, detached
def register(server) -> None:
"""Publish this module's helpers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))