fix(mcp): avoid replaying historical events on startup (#13414)
EventBridge initialized each session's last_seen timestamp to 0.0, so the first poll after 'hermes mcp serve' starts treated every saved user/assistant message in state.db as a fresh events_poll event. The fix establishes a per-session timestamp baseline on startup via _establish_baseline(), recording the latest existing message timestamp without emitting events. Only messages written after the baseline are delivered on subsequent polls. Also hoists _ts_float to module-level (needed by _establish_baseline) and adds ImportError fallbacks for hermes_constants imports so the bridge works in environments where the module isn't on the path. Salvage of #13414 by @afurm, re-applied by @HeLLGURD in #41239. Co-authored-by: afurm <afurm@users.noreply.github.com>
This commit is contained in:
+81
-26
@@ -62,9 +62,11 @@ except ImportError:
|
||||
|
||||
def _get_sessions_dir() -> Path:
|
||||
"""Return the sessions directory using HERMES_HOME."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "sessions"
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "sessions"
|
||||
except ImportError:
|
||||
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "sessions"
|
||||
|
||||
|
||||
def _get_session_db():
|
||||
@@ -192,9 +194,13 @@ def _load_sessions_index_from_json() -> dict:
|
||||
|
||||
def _load_channel_directory() -> dict:
|
||||
"""Load the cached channel directory for available targets."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
directory_file = get_hermes_home() / "channel_directory.json"
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
directory_file = get_hermes_home() / "channel_directory.json"
|
||||
except ImportError:
|
||||
directory_file = Path(
|
||||
os.environ.get("HERMES_HOME", Path.home() / ".hermes")
|
||||
) / "channel_directory.json"
|
||||
|
||||
if not directory_file.exists():
|
||||
return {}
|
||||
@@ -292,6 +298,21 @@ class QueueEvent:
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _ts_float(ts) -> float:
|
||||
"""Normalize a message timestamp (epoch int/float or ISO string) to float."""
|
||||
if isinstance(ts, (int, float)):
|
||||
return float(ts)
|
||||
if isinstance(ts, str) and ts:
|
||||
try:
|
||||
return float(ts)
|
||||
except ValueError:
|
||||
try:
|
||||
return datetime.fromisoformat(ts).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
class EventBridge:
|
||||
"""Background poller that watches SessionDB for new messages and
|
||||
maintains an in-memory event queue with waiter support.
|
||||
@@ -318,6 +339,13 @@ class EventBridge:
|
||||
"""Start the background polling thread."""
|
||||
if self._running:
|
||||
return
|
||||
# Snapshot existing history BEFORE the poll loop starts so pre-existing
|
||||
# messages are not replayed as new events on startup (#13414). Sessions
|
||||
# that first appear afterwards are absent from the baseline and default
|
||||
# to last_seen=0.0 in _poll_once, so new-conversation delivery is
|
||||
# preserved. Unit tests that drive _poll_once directly bypass start()
|
||||
# and still observe first-poll delivery.
|
||||
self._establish_baseline()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
@@ -419,6 +447,46 @@ class EventBridge:
|
||||
self._queue.pop(0)
|
||||
self._new_event.set()
|
||||
|
||||
def _establish_baseline(self) -> None:
|
||||
"""Record the latest per-session message timestamp and the current
|
||||
state.db mtime WITHOUT emitting events, so startup does not replay
|
||||
history (#13414).
|
||||
|
||||
Only sessions that already exist at startup are baselined; a session
|
||||
that first appears afterwards is absent here and defaults to
|
||||
last_seen=0.0 in _poll_once, so a brand-new conversation's first
|
||||
message is still delivered on its state.db-change tick.
|
||||
"""
|
||||
db = _get_session_db()
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
db_file = get_hermes_home() / "state.db"
|
||||
except ImportError:
|
||||
db_file = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||
try:
|
||||
self._state_db_mtime = db_file.stat().st_mtime if db_file.exists() else 0.0
|
||||
except OSError:
|
||||
self._state_db_mtime = 0.0
|
||||
try:
|
||||
self._cached_sessions_index = _load_sessions_index()
|
||||
except Exception:
|
||||
self._cached_sessions_index = {}
|
||||
for session_key, entry in self._cached_sessions_index.items():
|
||||
session_id = entry.get("session_id", "")
|
||||
if not session_id:
|
||||
continue
|
||||
try:
|
||||
messages = db.get_messages(session_id)
|
||||
except Exception:
|
||||
continue
|
||||
all_ts = [_ts_float(m.get("timestamp", 0)) for m in (messages or ())]
|
||||
if all_ts:
|
||||
latest = max(all_ts)
|
||||
if latest > 0.0:
|
||||
self._last_poll_timestamps[session_key] = latest
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Background loop: poll SessionDB for new messages."""
|
||||
db = _get_session_db()
|
||||
@@ -444,9 +512,11 @@ class EventBridge:
|
||||
eliminating the old dual-file (sessions.json + state.db) race that
|
||||
could drop brand-new conversations (#8925).
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
db_file = get_hermes_home() / "state.db"
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
db_file = get_hermes_home() / "state.db"
|
||||
except ImportError:
|
||||
db_file = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||
|
||||
try:
|
||||
db_mtime = db_file.stat().st_mtime if db_file.exists() else 0.0
|
||||
@@ -478,23 +548,8 @@ class EventBridge:
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
# Normalize timestamps to float for comparison
|
||||
def _ts_float(ts) -> float:
|
||||
if isinstance(ts, (int, float)):
|
||||
return float(ts)
|
||||
if isinstance(ts, str) and ts:
|
||||
try:
|
||||
return float(ts)
|
||||
except ValueError:
|
||||
# ISO string — parse to epoch
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(ts).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
# Find messages newer than our last seen timestamp
|
||||
# Find messages newer than our last seen timestamp (see the
|
||||
# module-level _ts_float helper for timestamp normalization).
|
||||
new_messages = []
|
||||
for msg in messages:
|
||||
ts = _ts_float(msg.get("timestamp", 0))
|
||||
|
||||
Reference in New Issue
Block a user