Files
hermes-agent/gateway/session_stall.py
Teknium 3c7069bdcb refactor(gateway): dead-code removal, helper unification, defensive-layer collapse and rationale-preserving comment compaction across 40 modules
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control,
hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__,
relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state,
streaming_tts_consumer, turn_lease and small modules.

- HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table
- WebSocketRelayTransport._handle_frame -> frame-handler table
- GatewayAuthorizationMixin: unified adapter setting/flag/extra readers
- dead symbols removed (verified zero references): RoomLinkProbe/select_room_link,
  relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead,
  BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/
  _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env,
  _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind,
  _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests.
- lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
2026-09-02 13:30:50 -07:00

96 lines
3.2 KiB
Python

"""Gateway session stall notification policy.
Consumes the shared activity observation contract from ``agent.session_activity``
/ ``AIAgent.get_activity_summary()`` as the **single progress source**. This
module owns only the notify-once policy for "pending inbound + stale progress";
it never derives a parallel progress clock from turn-start or inbound timestamps.
Boundaries (keep separate): ``gateway/shutdown_watchdog.py`` is process /
event-loop liveness; ``gateway/delivery_ledger.py`` is outbound delivery
obligations. Pending inbound here is a stall *policy gate* (a queued follow-up
exists), not an obligation and not a progress timestamp. Timeout / kill / retry
policy stay in their own components.
"""
from __future__ import annotations
import math
from typing import Any, Mapping, Optional
def should_emit_session_stall_notification(
*,
timeout_seconds: float,
idle_seconds: Optional[float],
has_pending_inbound: bool,
already_notified: bool,
) -> bool:
"""Return True when a stall warning should be sent for this session."""
return (
timeout_seconds > 0
and has_pending_inbound
and not already_notified
and idle_seconds is not None
and idle_seconds >= timeout_seconds
)
def should_clear_session_stall_notification(
*,
timeout_seconds: float,
idle_seconds: Optional[float],
has_pending_inbound: bool,
) -> bool:
"""Return True when a prior stall notice may be cleared (episode ended)."""
if not has_pending_inbound or timeout_seconds <= 0:
return True
# Unknown progress: hold the latch. Do not treat observation gaps as recovery.
return idle_seconds is not None and idle_seconds < timeout_seconds
def format_session_stall_notification(idle_seconds: float) -> str:
"""User-facing stall warning (ASCII minutes)."""
mins = max(1, int(idle_seconds // 60))
return f"⚠️ Agent session appears stalled (last activity {mins} min ago). Try /new to reset."
def _finite_float(value: Any) -> Optional[float]:
"""``value`` as a finite float, or None (bools are rejected as non-numeric)."""
if value is None or isinstance(value, bool):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def resolve_session_idle_seconds_from_activity(
activity: Optional[Mapping[str, Any]],
*,
now: Optional[float] = None,
) -> Optional[float]:
"""Idle seconds from a shared activity snapshot only.
Prefers ``seconds_since_activity`` when present and finite; otherwise derives
from ``last_activity_at`` / ``last_activity_ts``. Returns None when there is
no usable progress timestamp — callers must not fall back to turn-start or
pending-inbound clocks.
"""
if not activity:
return None
idle = _finite_float(activity.get("seconds_since_activity"))
if idle is not None:
return max(0.0, idle)
ts = activity.get("last_activity_at")
when = _finite_float(activity.get("last_activity_ts") if ts is None else ts)
if when is None:
return None
if now is None:
import time as _time
now = _time.time()
return max(0.0, float(now) - when)