"""Turn-liveness activity tracking for ``AIAgent`` (gateway watchdog + session activity persistence). ``_touch_activity`` is the single write path; persistence is rate-limited and never raises. Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged. """ import logging import os import threading import time from typing import Optional from agent.session_activity import ActivityProvenance # Same logger name as the origin module so log records / caplog filters are unchanged. logger = logging.getLogger("run_agent") class ActivityTrackingMixin: """Liveness timestamps/labels and rate-limited session activity persistence.""" def _liveness_activity_lock(self) -> "threading.Lock": """Shared lock for the activity clock and its generation counter. ``_touch_activity`` stamps under it and the liveness watchdog samples/commits under it, so a stall observation can never abort a turn that resumed in between. Lazy so ``__new__``-built doubles work. """ _lock = getattr(self, "_turn_liveness_activity_lock", None) if _lock is None: _lock = threading.Lock() self._turn_liveness_activity_lock = _lock return _lock def _touch_activity( self, desc: str, *, provenance: Optional[ActivityProvenance] = None, force_persist: bool = False, ) -> None: """Update the last-activity timestamp and description (thread-safe). Bumps a monotonic generation under ``_liveness_activity_lock`` so the watchdog can bind a stall observation to the exact ``(generation, timestamp)`` it sampled. Also bridges (rate-limited, best-effort) to the kanban heartbeat when this is a dispatcher-spawned worker, and to the durable SessionDB activity projection. ``provenance`` names special writers (compression); ``force_persist`` bypasses the SessionDB rate limit. """ from agent.session_activity import ( bound_activity_description, normalize_activity_provenance, reset_session_activity_persist_window, ) # Lazy per-instance lock, inline so SimpleNamespace doubles binding _touch_activity without the # class keep working (tests/run_agent/test_session_activity_persist.py). _clock_lock = getattr(self, "_turn_liveness_activity_lock", None) if _clock_lock is None: _clock_lock = threading.Lock() self._turn_liveness_activity_lock = _clock_lock with _clock_lock: self._turn_liveness_activity_generation = ( getattr(self, "_turn_liveness_activity_generation", 0) + 1 ) self._last_activity_ts = time.time() self._last_activity_desc = bound_activity_description(desc) self._last_activity_provenance = normalize_activity_provenance(provenance) # Real progress invalidates a reserved abort claim; an in-flight watchdog interrupt must abandon # itself at the final mutation edge. self._turn_liveness_abort_claim = None if os.environ.get("HERMES_KANBAN_TASK"): try: from tools.kanban_tools import ( heartbeat_current_worker_from_env, inject_new_comments_from_env, ) heartbeat_current_worker_from_env() # Fold any new operator notes into the running turn (OUT-OF-BAND # steer) so the user can talk to a live task without a restart. inject_new_comments_from_env(self) except Exception: # Never let the bridge break the loop; this guard covers import-time failures. pass if force_persist: reset_session_activity_persist_window(self) self._persist_session_activity_if_due() def _persist_session_activity_if_due(self) -> None: """Best-effort durable activity heartbeat for SessionDB consumers. Cadence pinned by ``SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS`` (config-independent). Fail-open: a failed write never raises into the agent loop. """ session_id = getattr(self, "session_id", None) session_db = getattr(self, "_session_db", None) if not session_id or session_db is None: return touch = getattr(session_db, "touch_session_activity", None) if not callable(touch): return from agent.session_activity import ( SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS, normalize_activity_provenance, ) now_mono = time.monotonic() last_mono = getattr(self, "_session_activity_last_persist_mono", 0.0) if (now_mono - last_mono) < SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS: return self._session_activity_last_persist_mono = now_mono try: touch( session_id, getattr(self, "_last_activity_ts", None), description=getattr(self, "_last_activity_desc", None), provenance=normalize_activity_provenance( getattr(self, "_last_activity_provenance", None) ), ) except Exception: # Heartbeat is observation-only; never let its I/O break the loop. logger.debug( "session activity heartbeat write failed (ignored)", exc_info=True, ) def _reset_activity_labels_after_turn(self) -> None: """Drop mid-turn activity labels once the turn is no longer running. Keeps ``_last_activity_ts`` so idle/watchdog clocks stay continuous across turns; clears description + provenance so idle agents / SessionDB listings stop advertising the last mid-turn stamp. """ from agent.session_activity import ActivityProvenance self._last_activity_desc = "" self._last_activity_provenance = ActivityProvenance.UNKNOWN session_id = getattr(self, "session_id", None) session_db = getattr(self, "_session_db", None) if not session_id or session_db is None: return clear = getattr(session_db, "clear_session_activity_labels", None) if not callable(clear): return try: clear(session_id) except Exception: # Never let durable cleanup I/O break turn teardown. pass