Files
hermes-agent/gateway/session_state.py
T
kshitij c0d974b19f fix(gateway): escalate the session-hygiene compaction cooldown on repeat failures
A gateway session whose summary model keeps timing out no longer retries
compaction on the same fixed interval forever.

The in-agent compressor already escalates repeat summary timeouts
60 -> 300 -> 900s (ContextCompressor.record_timeout_failure), but that ladder
reads the in-memory _consecutive_timeout_failures counter and
bind_session_state() zeroes it (context_compressor.py:1645). Session hygiene
constructs a FRESH AIAgent for every run (gateway/run.py:16820) and re-binds
state each time, so from the gateway that streak is structurally always 0 --
only the flat hygiene_failure_cooldown_seconds (300s) could ever be recorded.
Issue #79624 reported exactly that steady state: an oversized session
(1053 messages, ~119.5k tokens) whose aux model always timed out, re-attempting
compaction every 300s across five days until the reporter deleted the session
by hand.

Track the streak on PersistentState instead, which outlives the per-run agent
and is not cleared by turn/boundary resets, so consecutive hygiene failures
climb 300 -> 900 -> 2700s and then saturate. Both failure sites (progress
timeout and aborted compression) feed it; a real compression resets it, so a
session that recovers starts from the first rung again. The ladder multiplies
the configured base, so operators who tuned
hygiene_failure_cooldown_seconds keep their first rung. Per-session, so one
wedged chat cannot penalize other conversations.

Deliberately NOT changed, since each is a maintainer policy call rather than a
defect (all three are written up on #79624):
  - no durable failure-streak column, so escalation still resets on restart
  - the gateway 30s / in-agent 120s / aux-client 300s-floor timeout mismatch
  - no `hermes doctor` check or `hermes sessions list` marker for a session
    stuck in a compression-failure cooldown

Note the reported exit(1) is NOT a crash: it is the deliberate
_signal_initiated_shutdown path (gateway/run.py:26746-26751, #5646) that lets
systemd Restart=on-failure revive the gateway after a bare SIGTERM, and it
fires on every `systemctl restart` independently of compaction. The compaction
log lines appear after the shutdown line because the gateway-owned executor is
torn down with shutdown(wait=False, cancel_futures=True) (run.py:21164), so an
in-flight turn keeps logging during teardown. Full analysis on the issue.

Post-review hardening (Phase 2c + /simplify-code found five real defects in the
first cut):
  - the recovery gate hand-rolled `_new_tokens < _approx_tokens` when a canonical
    predicate already existed: `compression_made_progress` (agent/turn_context.py,
    #39548). They disagree on 3 of 5 cases -- the hand-rolled form misses a
    row-count win when the summary keeps the token estimate flat, misses one
    where the summary is slightly MORE verbose (so a genuinely recovered session
    would keep escalating forever), and counts a sub-5% wobble as recovery. Now
    reuses the shared predicate, promoted from `_compression_made_progress` to a
    public name with the old private name kept as a back-compat alias so the
    existing importer (tests/agent/test_protected_tail_pressure_61932.py) and any
    patcher of that symbol keep working.
  - the reset was gated on "not aborted", but the degenerate "did not rotate or
    compact in place" branch (#21301) is NOT aborted and yields zero reduction,
    so a session wedged there reset its streak every run and could never
    escalate -- silently defeating the fix. Now gated on real progress.
  - no absolute ceiling: base * 9 reaches 9h at an operator base of 3600s,
    indistinguishable from "compaction switched off". Added
    _HYGIENE_COOLDOWN_MAX_SECONDS = 3600, mirroring the in-file
    _RECONNECT_BACKOFF_CAP precedent.
  - the reset used the get-or-create accessor to write a 0 that was already 0,
    materialising a _sessions entry (never evicted). Now peeks.
  - the abort verdict was probed twice, leaving the reset/record mutual
    exclusion implicit; a future await between the probes would have broken it
    silently. Computed once into _hyg_aborted.

Tests: 19 new in tests/gateway/test_hygiene_failure_cooldown_ladder.py --
ladder escalation, saturation, the absolute cap, per-session isolation,
reset-on-recovery, custom/zero base, PersistentState scoping (a mutation moving
the field to TurnState fails), degraded runners, the progress gate, the exact
progress-predicate semantics the gate depends on, and end-to-end that the
escalated value is what reaches the state DB. All 12 mutations caught, including
ones that restore the flat cooldown (the original bug), ungate the reset, swap
the canonical predicate back for the hand-rolled comparison, remove the cap, and
share the streak globally; the harness hard-errors when a mutation cannot be
applied, since a silently no-op mutation check is worse than none -- an earlier
version of it WAS silently no-opping after a refactor. The gate's contract test
slices by AST node span rather than a fixed character count, which had already
truncated once as the block grew. gateway hygiene + session-state + the three
touched agent compression suites: 50 passed; ruff clean.

E2E with real imports demonstrates the premise rather than asserting it:
bind_session_state zeroes the in-agent counter, and the recorded deadlines go
300 -> 900 -> 2700 -> 2700 -> 2700s where they were previously a flat 300s.

Reported by @yucezerey (#79624), whose state.db column dump and
"deleting the session fixed it" datapoint made the real mechanism findable.
2026-08-06 05:00:48 +05:30

467 lines
18 KiB
Python

"""Per-session gateway state consolidated into one container.
GatewayRunner historically carried ~19 separate ``Dict[str, ...]`` attributes
keyed by session_key, each with its own ad-hoc lifecycle. Three failure
classes grew out of that shape:
1. Boundary drift — every conversation boundary carried a hand-copied
pop-list that went stale when a new dict was added (#48031, #58403,
#10702, #35809). Mitigated by the ``_CONVERSATION_SCOPED_STATE`` registry,
now structurally fixed: the fields live in one ``ConversationState``
dataclass with a single ``clear()``.
2. Turn-release drift — ad-hoc ``del self._running_agents[key]`` sites that
popped different subsets of the turn dicts. Mitigated by
``_release_running_agent_state``, now ``TurnState.clear()``.
3. Wholesale-reset races — lazy-init paths like
``self._session_reasoning_overrides = {}`` replaced the ENTIRE dict,
discarding concurrent sessions' entries when raced. Structurally
impossible now: state is per-session, resets touch one field of one
``SessionState``.
Scopes (placement follows where each dict is CLEARED today):
- ``SessionState.turn`` — reset at end of every running turn.
- ``SessionState.conversation`` — reset at conversation boundaries
(/new, /resume, auto-reset, expiry, compression-exhausted reset).
- ``SessionState.persistent`` — own lifecycles (approval resolution, update
prompt answer, native-image consumption); ``run_generation`` is monotonic
and NEVER reset (#28686).
Entries in ``GatewayRunner._sessions`` are never evicted (matching the old
dicts, most of which also leaked empty/stale entries for dead sessions —
see the migration table in the consolidating commit). Follow-up work may
add eviction of fully-default SessionStates.
"""
from __future__ import annotations
from collections.abc import MutableMapping
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
# Presence-sensitive sentinel: /fast stores "priority" or None (explicit
# normal), so key PRESENCE — not value truthiness — decides whether the
# override applies. ``_UNSET_TIER`` means "no override recorded".
_UNSET_TIER = object()
# Public alias for callers outside this module.
SERVICE_TIER_UNSET = _UNSET_TIER
@dataclass
class TurnState:
"""State scoped to one running gateway turn.
Cleared by ``GatewayRunner._release_running_agent_state`` (via
``clear()``) at every site that ends a running turn. ``lease_token`` /
``lease_generation`` are deliberately NOT cleared here — they are owned
by ``_release_turn_lease`` (#64934), which must release the registry
lease exactly once per acquiring turn.
"""
# Running AIAgent instance (or _AGENT_PENDING_SENTINEL); None = idle.
agent: Any = None
# Turn start timestamp (0.0 = not running).
started_ts: float = 0.0
# Cross-process active-session slot lease (None = none held).
lease: Any = None
# Last busy-ack timestamp (debounce; 0.0 = never acked).
busy_ack_ts: float = 0.0
# Held turn-lease token + the run generation that acquired it. The old
# ``_turn_lease_tokens`` dict was keyed by (session_key, generation) so a
# stale unwind could never free a newer turn's lease; the pair encoding
# preserves that: release/rebind only match when generation is current.
lease_token: Any = None
lease_generation: Optional[int] = None
def clear(self) -> None:
"""Reset the per-turn slot (agent / start ts / lease / busy-ack).
Mirrors the exact clear set of the old _release_running_agent_state:
_running_agents, _running_agents_ts, _active_session_leases (popped
by the caller so it can call ``lease.release()``), _busy_ack_ts.
"""
self.agent = None
self.started_ts = 0.0
self.lease = None
self.busy_ack_ts = 0.0
@dataclass
class ConversationState:
"""State scoped to one conversation (survives turns, not boundaries)."""
# /model per-session override (model/provider/api_key/base_url/api_mode).
model_override: Optional[Dict[str, Any]] = None
# /model --once restore snapshot.
one_turn_restore: Optional[Dict[str, Any]] = None
# /reasoning per-session override.
reasoning_override: Optional[Dict[str, Any]] = None
# /fast per-session override: "priority" or None; _UNSET_TIER = absent.
service_tier_override: Any = _UNSET_TIER
# Last successfully-resolved non-empty model (#35314 recovery).
last_resolved_model: str = ""
# /queue overflow FIFO (adapter slot holds the head).
queued_events: List[Any] = field(default_factory=list)
# Per-turn must-deliver sidecar notes (one-shot).
sidecar_notes: List[str] = field(default_factory=list)
# Pinned session-context bytes: (change_key, text).
ephemeral_pin: Optional[Tuple[Any, ...]] = None
# Last voice-channel context delivered (None = never delivered).
vc_last: Optional[str] = None
def clear(self) -> None:
"""Reset every conversation-scoped field to its default.
The structural successor of the ``_CONVERSATION_SCOPED_STATE``
pop-loop: adding a field here means every boundary clears it
automatically.
"""
self.model_override = None
self.one_turn_restore = None
self.reasoning_override = None
self.service_tier_override = _UNSET_TIER
self.last_resolved_model = ""
self.queued_events = []
self.sidecar_notes = []
self.ephemeral_pin = None
self.vc_last = None
@dataclass
class PersistentState:
"""State with its own lifecycle — NOT cleared by turn or boundary resets
wholesale (approvals/update prompts ARE cleared by the boundary
*security* funnel, but individually, matching the old behavior)."""
# Pending exec approval ({"command": ..., "pattern_key": ...}).
approvals: Optional[Dict[str, Any]] = None
# /update prompt awaiting a user response.
update_prompt_pending: bool = False
# Image paths staged for native (inline) attachment; consumed one-shot.
native_image_paths: List[str] = field(default_factory=list)
# Legacy runner-level pending message text (write-mostly; flushed to
# disk on shutdown — see #72680). NOTE: distinct from the adapter-level
# ``_pending_messages`` (Dict[str, MessageEvent]) in gateway/base.py,
# which is a different store that happens to share the old name.
pending_command_text: Optional[str] = None
# Monotonic run-generation counter (#28686). NEVER reset: clearing it
# would break stale-run detection.
run_generation: int = 0
# Consecutive session-hygiene compression failures for this session
# (#79624). The in-agent compressor escalates repeat timeouts via
# ContextCompressor._consecutive_timeout_failures, but hygiene builds a
# FRESH AIAgent per run and bind_session_state() zeroes that counter, so
# the in-agent ladder is structurally unreachable from the gateway.
# Tracking the streak here — outside the per-run agent — lets hygiene
# escalate its cooldown instead of retrying on a flat interval forever.
# Reset on a successful compression, not by turn/boundary resets.
hygiene_failure_streak: int = 0
@dataclass
class SessionState:
"""All per-session gateway state, grouped by lifecycle scope."""
turn: TurnState = field(default_factory=TurnState)
conversation: ConversationState = field(default_factory=ConversationState)
persistent: PersistentState = field(default_factory=PersistentState)
# ---------------------------------------------------------------------------
# Legacy dict-view adapters.
#
# Dozens of tests construct bare runners (object.__new__) and read/write the
# old dict attributes directly (``runner._running_agents = {}``,
# ``assert key in runner._pending_approvals``...). These views keep that
# surface working: each is a LIVE MutableMapping over one SessionState field
# across all sessions. Production code in gateway/run.py accesses state via
# ``self._session_state(key).<scope>.<field>`` — the views exist for tests
# and for the thin remaining mixin/adapter call sites documented in the
# consolidating commit.
# ---------------------------------------------------------------------------
class _FieldSpec:
"""Describes one legacy dict: scope attr, field name, absence test."""
__slots__ = ("scope", "name", "default", "is_present")
def __init__(
self,
scope: str,
name: str,
default: Callable[[], Any],
is_present: Callable[[Any], bool],
) -> None:
self.scope = scope
self.name = name
self.default = default
self.is_present = is_present
def _present_not_none(v: Any) -> bool:
return v is not None
def _present_nonzero(v: Any) -> bool:
return bool(v)
def _present_not_unset(v: Any) -> bool:
return v is not _UNSET_TIER
class SessionFieldView(MutableMapping):
"""Live dict-like view of one SessionState field across sessions."""
__slots__ = ("_runner", "_spec")
def __init__(self, runner: Any, spec: _FieldSpec) -> None:
self._runner = runner
self._spec = spec
# -- internals --------------------------------------------------------
def _sessions(self) -> Dict[str, SessionState]:
return self._runner.__dict__.get("_sessions") or {}
def _value(self, state: SessionState) -> Any:
return getattr(getattr(state, self._spec.scope), self._spec.name)
# -- mapping protocol ---------------------------------------------------
def __getitem__(self, key: str) -> Any:
state = self._sessions().get(key)
if state is None:
raise KeyError(key)
value = self._value(state)
if not self._spec.is_present(value):
raise KeyError(key)
return value
def __setitem__(self, key: str, value: Any) -> None:
state = self._runner._session_state(key)
setattr(getattr(state, self._spec.scope), self._spec.name, value)
def __delitem__(self, key: str) -> None:
state = self._sessions().get(key)
if state is None or not self._spec.is_present(self._value(state)):
raise KeyError(key)
setattr(
getattr(state, self._spec.scope),
self._spec.name,
self._spec.default(),
)
def __iter__(self) -> Iterator[str]:
for key, state in list(self._sessions().items()):
if self._spec.is_present(self._value(state)):
yield key
def __len__(self) -> int:
return sum(1 for _ in self)
def __contains__(self, key: object) -> bool:
state = self._sessions().get(key) # type: ignore[arg-type]
return state is not None and self._spec.is_present(self._value(state))
def clear(self) -> None: # avoid MutableMapping's popitem loop
for state in list(self._sessions().values()):
setattr(
getattr(state, self._spec.scope),
self._spec.name,
self._spec.default(),
)
# Mapping doesn't provide __eq__; tests compare against plain dicts.
def __eq__(self, other: object) -> bool:
if isinstance(other, (dict, MutableMapping)):
return dict(self.items()) == dict(other)
return NotImplemented
def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
return NotImplemented if result is NotImplemented else not result
def __repr__(self) -> str: # pragma: no cover - debug aid
return f"SessionFieldView({self._spec.scope}.{self._spec.name}, {dict(self.items())!r})"
class TurnLeaseTokenView(MutableMapping):
"""Legacy view of ``_turn_lease_tokens``: keyed by (session_key, generation).
The pair now lives on ``TurnState.lease_token`` / ``lease_generation``;
at most one held token exists per session key (the lease registry
serializes acquisition per session), so the single slot is equivalent to
the old tuple-keyed dict.
"""
__slots__ = ("_runner",)
def __init__(self, runner: Any) -> None:
self._runner = runner
def _sessions(self) -> Dict[str, SessionState]:
return self._runner.__dict__.get("_sessions") or {}
@staticmethod
def _split(key: Any) -> Tuple[str, int]:
if not isinstance(key, tuple) or len(key) != 2:
raise KeyError(key)
return key[0], key[1]
def __getitem__(self, key: Any) -> Any:
session_key, generation = self._split(key)
state = self._sessions().get(session_key)
if (
state is None
or state.turn.lease_token is None
or state.turn.lease_generation != generation
):
raise KeyError(key)
return state.turn.lease_token
def __setitem__(self, key: Any, value: Any) -> None:
session_key, generation = self._split(key)
state = self._runner._session_state(session_key)
state.turn.lease_token = value
state.turn.lease_generation = generation
def __delitem__(self, key: Any) -> None:
session_key, generation = self._split(key)
state = self._sessions().get(session_key)
if (
state is None
or state.turn.lease_token is None
or state.turn.lease_generation != generation
):
raise KeyError(key)
state.turn.lease_token = None
state.turn.lease_generation = None
def __iter__(self) -> Iterator[Tuple[str, Any]]:
for key, state in list(self._sessions().items()):
if state.turn.lease_token is not None:
yield (key, state.turn.lease_generation)
def __len__(self) -> int:
return sum(1 for _ in self)
def __eq__(self, other: object) -> bool:
if isinstance(other, (dict, MutableMapping)):
return dict(self.items()) == dict(other)
return NotImplemented
def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
return NotImplemented if result is NotImplemented else not result
# One spec per legacy dict attribute (see the consolidating commit's
# migration table for declared-line / clear-path provenance).
LEGACY_FIELD_SPECS: Dict[str, _FieldSpec] = {
"_running_agents": _FieldSpec("turn", "agent", lambda: None, _present_not_none),
"_running_agents_ts": _FieldSpec("turn", "started_ts", float, _present_nonzero),
"_active_session_leases": _FieldSpec("turn", "lease", lambda: None, _present_not_none),
"_busy_ack_ts": _FieldSpec("turn", "busy_ack_ts", float, _present_nonzero),
"_session_model_overrides": _FieldSpec(
"conversation", "model_override", lambda: None, _present_not_none
),
"_pending_one_turn_model_restores": _FieldSpec(
"conversation", "one_turn_restore", lambda: None, _present_not_none
),
"_session_reasoning_overrides": _FieldSpec(
"conversation", "reasoning_override", lambda: None, _present_not_none
),
"_session_service_tier_overrides": _FieldSpec(
"conversation", "service_tier_override", lambda: _UNSET_TIER, _present_not_unset
),
"_last_resolved_model": _FieldSpec(
"conversation", "last_resolved_model", str, _present_nonzero
),
"_queued_events": _FieldSpec("conversation", "queued_events", list, _present_nonzero),
"_pending_turn_sidecar_notes": _FieldSpec(
"conversation", "sidecar_notes", list, _present_nonzero
),
"_session_ephemeral_pin": _FieldSpec(
"conversation", "ephemeral_pin", lambda: None, _present_not_none
),
"_session_vc_last": _FieldSpec(
"conversation", "vc_last", lambda: None, _present_not_none
),
"_pending_approvals": _FieldSpec(
"persistent", "approvals", lambda: None, _present_not_none
),
"_update_prompt_pending": _FieldSpec(
"persistent", "update_prompt_pending", bool, _present_nonzero
),
"_pending_native_image_paths_by_session": _FieldSpec(
"persistent", "native_image_paths", list, _present_nonzero
),
"_pending_messages": _FieldSpec(
"persistent", "pending_command_text", lambda: None, _present_not_none
),
"_session_run_generation": _FieldSpec(
"persistent", "run_generation", int, _present_nonzero
),
}
def legacy_dict_property(attr_name: str) -> property:
"""Build a legacy dict-shaped @property for one migrated attribute.
Getter returns a live :class:`SessionFieldView`; setter accepts a plain
dict (the ubiquitous test pattern ``runner._X = {...}``), resetting the
field on every known session and then applying the given entries.
"""
spec = LEGACY_FIELD_SPECS[attr_name]
def fget(self: Any) -> SessionFieldView:
return SessionFieldView(self, spec)
def fset(self: Any, mapping: Optional[Dict[str, Any]]) -> None:
view = SessionFieldView(self, spec)
view.clear()
for key, value in (mapping or {}).items():
view[key] = value
def fdel(self: Any) -> None:
# ``del runner._busy_ack_ts`` in older tests simulated a runner
# without the attribute; the closest equivalent is "no entries".
SessionFieldView(self, spec).clear()
return property(
fget,
fset,
fdel,
doc=(
f"Legacy dict view over SessionState.{spec.scope}.{spec.name} "
"(kept for tests that access the pre-SessionState attribute)."
),
)
def legacy_lease_token_property() -> property:
"""Legacy (session_key, generation)-keyed view of held turn-lease tokens."""
def fget(self: Any) -> TurnLeaseTokenView:
return TurnLeaseTokenView(self)
def fset(self: Any, mapping: Optional[Dict[Tuple[str, int], Any]]) -> None:
view = TurnLeaseTokenView(self)
for key in list(view):
del view[key]
for key, value in (mapping or {}).items():
view[key] = value
def fdel(self: Any) -> None:
view = TurnLeaseTokenView(self)
for key in list(view):
del view[key]
return property(
fget,
fset,
fdel,
doc="Legacy (session_key, generation)-keyed turn-lease token view.",
)