Files
hermes-agent/gateway/session_state.py
kshitij aaf9688519 refactor(gateway): extract the hygiene recovery gate and forward the failure reason
Follow-up to c0d974b19 (#79741). Three review findings against that commit,
none of which change the escalation behaviour it shipped.

1. The recovery decision lived inline in `_handle_message_with_agent`, a
   ~2000-line async method, so the only way to pin it was a test that read
   `inspect.getsource(...)` and asserted on substrings. AGENTS.md bans reading
   source in tests outright and names this exact situation: "if the logic lives
   inline in a god-file (gateway/run.py) and extracting it feels disruptive:
   that's the actual signal to do the extraction, not to regex around it."

   Those tests were not merely stylistically wrong, they were actively harmful.
   One asserted the substring `_new_tokens < _approx_tokens` was PRESENT -- so
   it passed while the gate had the bug that substring represents, and had to be
   edited when the gate was fixed. It failed on correct code and passed on
   broken code, in one assertion.

   Extracted `hygiene_compaction_recovered()` as a module-level pure predicate
   and replaced the three source-reading tests with eight direct unit tests.
   The extraction immediately earned itself: the new tests caught a `NameError`
   (the predicate called `compression_made_progress` while the module bound it
   under an alias) that a source-text assertion cannot see, because the symbol
   is spelled correctly in the source and only fails at runtime.

2. The gate inferred "did the transcript actually get rewritten" from a numeric
   side effect -- the degenerate "did not rotate or compact in place" path
   (#21301) reuses the pre-compression counts -- when the booleans
   `_hyg_rotated` / `_hyg_in_place` were already in scope and explicitly set
   False on that path. The predicate now takes them directly, so a future edit
   that re-estimates instead of reusing the old counts cannot silently defeat
   the escalation.

3. `_record_hygiene_cooldown` passed no `error` to
   `record_compression_failure_cooldown`, which writes `compression_failure_error`
   unconditionally -- so a hygiene failure clobbered to NULL whatever reason the
   in-conversation path had recorded, and readers then show the user "unknown
   error" (agent/manual_compression_feedback.py, gateway/slash_commands.py). The
   reason was already in hand at both call sites. Pre-existing from #74136 but
   amplified by escalation: a blank reason on a 45-minute cooldown is far more
   user-visible than on a 5-minute one.

Also: the ladder docstring described the compressor's absolute 60/300/900s
ladder while the constant is multipliers (1, 3, 9); the config docs still
described `hygiene_failure_cooldown_seconds` as a flat interval rather than the
first rung of a capped ladder; and `PersistentState.hygiene_failure_streak` now
documents that it is process-local by design -- keying on `session_key` is what
survives compaction rotation, which the persisted `compression_*_streak`
columns cannot express since they key on the rotating `session_id`. Making it
durable is a schema change, tracked on #79624 rather than smuggled in here.

Also replaces the file's hand-written `_Runner` stub with
`object.__new__(GatewayRunner)` (already the idiom elsewhere in the same file).
The stub reimplemented `_session_state` and `_peek_session_state`, so the tests
exercised copies that could drift from production; using the real class
immediately made one assertion stronger -- on a fresh runner `_sessions` does not
exist at all until something materialises it, so the reset provably did not even
create the map.

A review pass on this follow-up then caught that the CALL SITE was still
unbound: deleting the whole `if not _hyg_aborted: if
hygiene_compaction_recovered(...)` block left every ladder test green, because
the unit tests prove the predicate correct without proving it is wired in. The
merged commit had the same gap and its only cover was the banned source-reading
test. `test_session_hygiene_forces_in_place_compaction_with_bound_session_db`
now spies the reset on a genuine in-place compaction, so deleting the wiring
fails. Two earlier attempts at this test did NOT close the gap -- asserting on
streak VALUES passes either way, since the streak is 0 whether or not the gate
ran; only a positive spy assertion on a recovering run detects the deletion.

Same pass also corrected an overstatement: point (2) is hardening, not a live
bug. The degenerate path also sets `_new_count = _msg_count` and `_new_tokens =
_approx_tokens`, and `compression_made_progress(n, n, t, t)` is always False, so
the merged code already declined to reset there. A 200k-trial fuzz over the
reachable state space found zero behavioural disagreements between the merged
gate and this one. The guard's value is surviving a future edit that stops
reusing those counts.

Tests: 28 in tests/gateway/test_hygiene_failure_cooldown_ladder.py (8 new unit
tests for the predicate, 3 for reason forwarding, 3 source-reading tests
deleted). All 5 mutations caught -- including one that restores the hand-rolled
comparison and one that removes the rotated/in_place guard. Two mutations
initially SURVIVED and exposed vacuous tests of my own: the no-rewrite test used
counts the progress predicate already rejects, so it passed without binding the
guard at all; it now passes counts that read as progress on their own, proving
the guard is what rejects them. gateway hygiene + session-state + agent
compression-progress suites: 54 passed; ruff clean.

Refs #79624
2026-08-06 15:04:53 +05:30

477 lines
19 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.
#
# PROCESS-LOCAL, deliberately: `PersistentState` means "survives turn and
# boundary resets", NOT "survives a restart" — this field has no disk flush
# (unlike `pending_command_text` above, #72680), so a gateway restart drops
# escalation back to rung 1 while the DB-backed deadline itself survives
# (#74136). Keying on `session_key` rather than `session_id` is what buys
# correctness across compaction ROTATION (the sid changes, the chat does
# not), which the persisted `compression_*_streak` columns cannot express
# since they key on sid. Making this durable is tracked on #79624 as a
# schema-level follow-up.
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.",
)