2776813df3
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in the focused modules that define them. This commit is the ONLY thing keeping the old paths alive, so external plugins have time to update. It is deliberately a single, unsquashed commit: git revert <this sha> removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does. What it adds (see COMPAT_MANIFEST.md, compat_manifest.json): - 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file - 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import, so no import cycles; facades that already had `__getattr__` get a chained one - 592 third-party/stdlib names the old modules used to expose, with their original import statements - 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them) - 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/ relay_runtime, tools/environments/modal_utils) - private names (`_x`) get no pointer: they were never API (3,792 skipped) Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves; the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
247 lines
9.8 KiB
Python
247 lines
9.8 KiB
Python
"""Session heartbeats — recurring re-entry prompts for the current session.
|
||
|
||
Session-scoped and in-process (CLI or gateway must be running); durable cross-process scheduling stays
|
||
``hermes cron``. Invariants (mirrors goals.py): injection is a plain user message — no system-prompt
|
||
mutation or toolset swap, so prompt caching stays intact — and a real user message always wins:
|
||
heartbeats only fire into an idle session with an empty input queue."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from dataclasses import asdict, dataclass
|
||
from typing import Any, Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MIN_INTERVAL_SECONDS = 60 # floor: re-entering more often than once a minute is a busy-loop, not a heartbeat
|
||
POLL_SECONDS = 5.0 # how often drivers poll for due heartbeats; not user-facing
|
||
|
||
HEARTBEAT_PROMPT_TEMPLATE = (
|
||
"[Heartbeat — recurring instruction, fires every {interval}]\n{prompt}\n\n"
|
||
"If there is nothing meaningful to do or report for this instruction "
|
||
"right now, reply briefly that nothing has changed and stop — do not invent work."
|
||
)
|
||
|
||
_INTERVAL_RE = re.compile(
|
||
r"^\s*(?:every\s+)?(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)\s*$", re.IGNORECASE)
|
||
|
||
_UNIT_SECONDS = {
|
||
**dict.fromkeys(("s", "sec", "secs", "second", "seconds"), 1),
|
||
**dict.fromkeys(("m", "min", "mins", "minute", "minutes"), 60),
|
||
**dict.fromkeys(("h", "hr", "hrs", "hour", "hours"), 3600),
|
||
**dict.fromkeys(("d", "day", "days"), 86400),
|
||
}
|
||
|
||
# field -> (coercer, default used when the stored value is missing/falsy)
|
||
_STATE_FIELDS = {
|
||
"prompt": (str, ""), "interval_seconds": (int, 0), "status": (str, "active"),
|
||
"created_at": (float, 0.0), "last_fired_at": (float, 0.0), "fire_count": (int, 0),
|
||
}
|
||
|
||
|
||
def parse_interval(text: str) -> Optional[int]:
|
||
"""Parse ``10m`` / ``every 2h`` / ``every 90 minutes`` into seconds.
|
||
|
||
None when not an interval; below ``MIN_INTERVAL_SECONDS`` returns -1 so callers can tell "too small" apart.
|
||
"""
|
||
m = _INTERVAL_RE.match(text) if text else None
|
||
if not m:
|
||
return None
|
||
seconds = int(float(m.group(1)) * _UNIT_SECONDS[m.group(2).lower()])
|
||
return -1 if seconds < MIN_INTERVAL_SECONDS else seconds
|
||
|
||
|
||
def format_interval(seconds: int) -> str:
|
||
"""Human-readable interval (``600`` → ``10m``)."""
|
||
seconds = int(seconds)
|
||
units = ((86400, "d"), (3600, "h"), (60, "m"))
|
||
return next((f"{seconds // unit}{suffix}" for unit, suffix in units if seconds % unit == 0), f"{seconds}s")
|
||
|
||
|
||
@dataclass
|
||
class HeartbeatState:
|
||
"""Serializable per-session heartbeat."""
|
||
|
||
prompt: str
|
||
interval_seconds: int
|
||
status: str = "active" # active | paused | cleared
|
||
created_at: float = 0.0
|
||
last_fired_at: float = 0.0
|
||
fire_count: int = 0
|
||
|
||
def to_json(self) -> str:
|
||
return json.dumps(asdict(self), ensure_ascii=False)
|
||
|
||
@classmethod
|
||
def from_json(cls, raw: str) -> "HeartbeatState":
|
||
data = json.loads(raw)
|
||
return cls(**{name: coerce(data.get(name) or default) for name, (coerce, default) in _STATE_FIELDS.items()})
|
||
|
||
def is_due(self, now: Optional[float] = None) -> bool:
|
||
if self.status != "active" or not self.prompt or self.interval_seconds <= 0:
|
||
return False
|
||
return (time.time() if now is None else now) - (self.last_fired_at or self.created_at) >= self.interval_seconds
|
||
|
||
def render_prompt(self) -> str:
|
||
return HEARTBEAT_PROMPT_TEMPLATE.format(interval=format_interval(self.interval_seconds), prompt=self.prompt)
|
||
|
||
|
||
def _get_session_db() -> Optional[Any]:
|
||
"""Persistence goes through the goals module's per-HERMES_HOME cached SessionDB (one shared connection)."""
|
||
try:
|
||
from hermes_cli.goals import _get_session_db as _goals_db
|
||
return _goals_db()
|
||
except Exception as exc: # pragma: no cover
|
||
logger.debug("HeartbeatManager: SessionDB bootstrap failed (%s)", exc)
|
||
return None
|
||
|
||
|
||
def load_heartbeat(session_id: str) -> Optional[HeartbeatState]:
|
||
db = _get_session_db() if session_id else None
|
||
if db is None:
|
||
return None
|
||
try:
|
||
raw = db.get_meta(f"heartbeat:{session_id}")
|
||
except Exception as exc:
|
||
logger.debug("HeartbeatManager: get_meta failed: %s", exc)
|
||
return None
|
||
try:
|
||
state = HeartbeatState.from_json(raw) if raw else None
|
||
except Exception as exc:
|
||
logger.warning("HeartbeatManager: could not parse stored heartbeat for %s: %s", session_id, exc)
|
||
return None
|
||
return None if state is None or state.status == "cleared" else state
|
||
|
||
|
||
def save_heartbeat(session_id: str, state: HeartbeatState) -> None:
|
||
if not session_id:
|
||
return
|
||
db = _get_session_db()
|
||
if db is None:
|
||
from hermes_cli.goals import _warn_dropped_write
|
||
_warn_dropped_write("HeartbeatManager", "heartbeat", session_id)
|
||
return
|
||
try:
|
||
db.set_meta(f"heartbeat:{session_id}", state.to_json())
|
||
except Exception as exc:
|
||
logger.debug("HeartbeatManager: set_meta failed: %s", exc)
|
||
|
||
|
||
class HeartbeatManager:
|
||
"""Per-session heartbeat state + due-tick decisions; the surface CLI + gateway talk to.
|
||
|
||
Drivers (CLI thread / gateway task) call :meth:`due_prompt` on a poll cadence while the session is
|
||
idle; a non-None return is the user-role message to inject.
|
||
"""
|
||
|
||
def __init__(self, session_id: str):
|
||
self.session_id = session_id
|
||
self._state: Optional[HeartbeatState] = load_heartbeat(session_id)
|
||
|
||
@property
|
||
def state(self) -> Optional[HeartbeatState]:
|
||
return self._state
|
||
|
||
def has_heartbeat(self) -> bool:
|
||
return self._state is not None and self._state.status in {"active", "paused"}
|
||
|
||
def is_active(self) -> bool:
|
||
return self._state is not None and self._state.status == "active"
|
||
|
||
def status_line(self) -> str:
|
||
s = self._state
|
||
if s is None:
|
||
return "No heartbeat. Set one with /heartbeat every <interval> <prompt>."
|
||
every = format_interval(s.interval_seconds)
|
||
fired = f", fired {s.fire_count}×" if s.fire_count else ""
|
||
if s.status == "active":
|
||
next_in = max(0, int((s.last_fired_at or s.created_at) + s.interval_seconds - time.time()))
|
||
return f"♥ Heartbeat (every {every}, next in ~{next_in}s{fired}): {s.prompt}"
|
||
icon = "⏸ " if s.status == "paused" else ""
|
||
return f"{icon}Heartbeat ({s.status}, every {every}{fired}): {s.prompt}"
|
||
|
||
def set(self, prompt: str, interval_seconds: int) -> HeartbeatState:
|
||
prompt = (prompt or "").strip()
|
||
if not prompt:
|
||
raise ValueError("heartbeat prompt is empty")
|
||
interval_seconds = int(interval_seconds)
|
||
if interval_seconds < MIN_INTERVAL_SECONDS:
|
||
raise ValueError(f"interval must be at least {MIN_INTERVAL_SECONDS}s")
|
||
self._state = HeartbeatState(prompt=prompt, interval_seconds=interval_seconds, status="active",
|
||
created_at=time.time())
|
||
save_heartbeat(self.session_id, self._state)
|
||
return self._state
|
||
|
||
def _set_status(self, status: str, *, reanchor: bool = False) -> Optional[HeartbeatState]:
|
||
if not self._state:
|
||
return None
|
||
self._state.status = status
|
||
if reanchor:
|
||
self._state.last_fired_at = time.time()
|
||
save_heartbeat(self.session_id, self._state)
|
||
return self._state
|
||
|
||
def pause(self) -> Optional[HeartbeatState]:
|
||
return self._set_status("paused")
|
||
|
||
def resume(self) -> Optional[HeartbeatState]:
|
||
# Re-anchor so resuming doesn't instantly fire a stale tick.
|
||
return self._set_status("active", reanchor=True)
|
||
|
||
def clear(self) -> bool:
|
||
cleared = self._set_status("cleared") is not None
|
||
self._state = None
|
||
return cleared
|
||
|
||
def due_prompt(self, now: Optional[float] = None) -> Optional[str]:
|
||
"""Return the injection prompt if the heartbeat is due, else None.
|
||
|
||
The fire is recorded immediately (before the turn runs) so overlapping polls or a long turn can never
|
||
double-fire the same tick. Missed ticks coalesce: the anchor resets to NOW, not the theoretical
|
||
schedule.
|
||
"""
|
||
s = self._state
|
||
if s is None or not s.is_due(now):
|
||
return None
|
||
s.last_fired_at = now if now is not None else time.time()
|
||
s.fire_count += 1
|
||
save_heartbeat(self.session_id, s)
|
||
return s.render_prompt()
|
||
|
||
|
||
def migrate_heartbeat_to_session(old_session_id: str, new_session_id: str) -> bool:
|
||
"""Carry a heartbeat across a compression session rotation (copy to child, archive parent, never raise).
|
||
|
||
Same shape as ``goals.migrate_goal_to_session``.
|
||
"""
|
||
if not old_session_id or not new_session_id or old_session_id == new_session_id:
|
||
return False
|
||
try:
|
||
state = load_heartbeat(old_session_id)
|
||
if state is None or load_heartbeat(new_session_id) is not None:
|
||
return False
|
||
save_heartbeat(new_session_id, state)
|
||
state.status = "cleared"
|
||
save_heartbeat(old_session_id, state)
|
||
return True
|
||
except Exception as exc: # pragma: no cover - defensive
|
||
logger.debug("HeartbeatManager: migration failed: %s", exc)
|
||
return False
|
||
|
||
|
||
__all__ = [
|
||
"HeartbeatState", "HeartbeatManager", "parse_interval", "format_interval", "load_heartbeat", "save_heartbeat",
|
||
"migrate_heartbeat_to_session", "HEARTBEAT_PROMPT_TEMPLATE", "MIN_INTERVAL_SECONDS", "POLL_SECONDS",
|
||
]
|
||
|
||
|
||
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
||
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
||
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
||
# The whole block is removed by reverting the commit that added it.
|
||
from typing import Dict # noqa: F401,E402
|
||
# ---- END PLUGIN-COMPAT ----
|