Files
hermes-agent/agent/session_activity.py
wdelarme 85a9d1c55a fix(compression): persist terminal compression activity stamps immediately
A compression attempt that ends by host timeout, cooldown/backoff block, or gateway
hygiene turn-hold writes its terminal activity stamp through _touch_activity without
force_persist. The durable SessionDB activity projection is rate-limited to one write
per SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS (60s), and the compression
heartbeat had just written 'context compression in progress' — so the terminal stamp
almost always lands inside the open window and is dropped.

Nothing writes after it: the turn is over, the worker is detached, the host may be
gone. sessions.last_activity_description stays 'context compression in progress' with
last_activity_provenance 'agent.compression' forever, next to a
compression_failure_error and a cooldown timestamp that prove the attempt terminated.
Every surface reading the durable row (chat lists, session listings, reconnect,
orphan reap, stall watchdog) shows a permanently stuck/compressing chat with no turn
running.

Fix at the single write path: _touch_activity bypasses the persist rate limit for any
terminal compression provenance, exactly as force_persist does. The rate limit itself
is unchanged for mid-compression heartbeats and every other writer, so no extra write
pressure is added to the contended SessionDB path — one extra write per terminated
compression attempt.

TERMINAL_COMPRESSION_PROVENANCES is deliberately separate from
conversation_compression._TERMINAL_COMPRESSION_PROVENANCES: that set answers 'may a
detached heartbeat overwrite this stamp?' and excludes TURNHOLD because the worker may
still be alive and adoptable. This set answers 'must this reach durable state now?',
which is true for turn-hold too.
2026-09-28 12:53:29 -05:00

117 lines
5.4 KiB
Python

"""Shared session activity observation contract: timestamp + bounded description/provenance, observation
only (notification, timeout, kill and retry policy live elsewhere). Provenance is a small closed enum of
*noun* sources; the default agent clock stamps ``unknown`` unless a writer passes ``provenance=``."""
from __future__ import annotations
import sys
import time
from contextlib import suppress
from enum import Enum
from typing import Any, Mapping, Optional
ACTIVITY_DESCRIPTION_MAX = 120
# Durable SessionDB heartbeat cadence. Contract: MUST stay >= 30s — the SessionDB write path is contended and
# this observation-only projection never justifies extra write pressure. A code constant on purpose (no config
# can make it a high-frequency writer); matches the kanban auto-heartbeat. force_persist and terminal
# compression stamps (see TERMINAL_COMPRESSION_PROVENANCES) are the only bypasses.
SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0
class ActivityProvenance(str, Enum):
"""Where a durable/in-memory activity stamp came from."""
UNKNOWN = "unknown"
# Compression writers: heartbeat, host timeout, cooldown, turn hold.
# See #72424.
AGENT_COMPRESSION = "agent.compression"
AGENT_COMPRESSION_TIMEOUT = "agent.compression_timeout"
AGENT_COMPRESSION_COOLDOWN = "agent.compression_cooldown"
AGENT_COMPRESSION_TURNHOLD = "agent.compression_turnhold"
# Provenances that END the user-visible "compressing" phase: the host's progress timeout, a cooldown/
# backoff block, and the gateway's hygiene turn-hold. Each is written once, at a terminal edge, and must
# reach the durable projection IMMEDIATELY — the heartbeat above wrote "context compression in progress"
# moments earlier, so a rate-limited terminal stamp leaves `sessions.last_activity_description` advertising
# a compression that has already stopped, with no later writer to correct it (the agent may be idle, the
# process gone). That is the permanently "stuck / compressing" chat users see.
#
# Distinct from ``conversation_compression._TERMINAL_COMPRESSION_PROVENANCES``, which answers a different
# question — "may a detached heartbeat still overwrite this stamp?" — and deliberately excludes TURNHOLD,
# because after a turn-hold the worker may still be alive and adoptable.
TERMINAL_COMPRESSION_PROVENANCES = frozenset(
{
ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
ActivityProvenance.AGENT_COMPRESSION_TURNHOLD,
}
)
def is_terminal_compression_provenance(provenance: Optional[ActivityProvenance | str]) -> bool:
"""True when this stamp ends a compression phase and must bypass the persist rate limit."""
return normalize_activity_provenance(provenance) in TERMINAL_COMPRESSION_PROVENANCES
def bound_activity_description(description: Optional[str]) -> str:
"""Clamp free-form activity text to the shared description budget."""
text = (description or "").strip()
return text if len(text) <= ACTIVITY_DESCRIPTION_MAX else text[: ACTIVITY_DESCRIPTION_MAX - 1] + "…"
def normalize_activity_provenance(provenance: Optional[ActivityProvenance | str]) -> ActivityProvenance:
"""Return a known provenance, or ``UNKNOWN`` when unset/unrecognized."""
if isinstance(provenance, ActivityProvenance):
return provenance
try:
return ActivityProvenance((provenance or "").strip())
except ValueError:
return ActivityProvenance.UNKNOWN
def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
"""``iteration N/M`` for user-facing status lines, or ``iteration N`` when the cap is unbounded.
``AIAgent.max_iterations`` defaults to ``sys.maxsize`` (unlimited), so printing the pair verbatim
shows ``iteration 3/9223372036854775807`` in busy acks, heartbeats and timeout diagnostics (#102806).
"""
try:
cap = int(max_iterations)
except (TypeError, ValueError):
cap = sys.maxsize
if cap >= sys.maxsize:
return f"iteration {api_call_count}"
return f"iteration {api_call_count}/{cap}"
def reset_session_activity_persist_window(agent: Any) -> None:
"""Clear the persist rate-limit so the next stamp writes through (terminal compression labels must not stick on mid-compress text)."""
with suppress(Exception):
agent._session_activity_last_persist_mono = 0.0
def build_activity_snapshot(
*,
last_activity_at: Optional[float],
last_activity_description: Optional[str],
last_activity_provenance: Optional[ActivityProvenance | str] = None,
now: Optional[float] = None,
extra: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
"""Build the shared activity snapshot (plus optional caller extras)."""
when = float(last_activity_at) if last_activity_at is not None else None
clock = float(now if now is not None else time.time())
desc = bound_activity_description(last_activity_description)
prov = normalize_activity_provenance(last_activity_provenance).value
return {
"last_activity_at": when,
"last_activity_description": desc,
"last_activity_provenance": prov,
"seconds_since_activity": round(clock - when, 1) if when is not None else None,
# Short aliases used by existing gateway/delegate readers.
"last_activity_ts": when, "last_activity_desc": desc, "description": desc, "provenance": prov,
**(extra or {}),
}