{"repo":"NousResearch/hermes-agent","public_id":"54947","display_label":"Agent cache spurious invalidation fixes across sessions","summary":"Multiple bug reports and fixes addressing cases where the agent cache incorrectly invalidates or resets counters—such as usage stats—due to cross-process or cross-session-id mismatches under the same session key. The work spans tracking session IDs in cache entries, re-baselining counts, and backfilling counters from SessionDB.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56210,"merged_at":"2026-07-01T09:29:24+00:00"},"resolution":{"state":"resolved","by":"pr-56210"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T09:29:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":50675,"pr_number":56210,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-01T09:29:24+00:00"},{"issue_number":54947,"pr_number":56210,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T09:29:24+00:00"}],"members":[{"kind":"issue","number":54947,"title":"[Bug]: Agent cache cross-process guard spuriously invalidates on session-id switch under same session_key","url":null,"state":"closed"},{"kind":"pr","number":55029,"title":"fix(gateway): scope agent-cache entries per session_id to prevent cross-session invalidation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54947","number":54947},{"kind":"pr","value":"#55013","number":55013},{"kind":"pr","value":"#56210","number":56210}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55234,"title":"fix: track session_id in agent cache to prevent spurious cross-process invalidation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55013","number":55013},{"kind":"pr","value":"#55029","number":55029},{"kind":"pr","value":"#54947","number":54947}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56210,"title":"fix(gateway): close the agent-cache re-baseline bug class (#54947 + #46647 + #54583)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_agent_cache.py","tests/gateway/test_first_turn_session_meta_rebaseline.py","tests/gateway/test_session_id_cache_coherence.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -10679,19 +10679,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 _response_time, _api_calls, _resp_len,\n             )\n \n-            # Re-baseline the cached agent's message_count snapshot now that\n-            # this turn has completed and the agent has flushed its rows to\n-            # the SessionDB.  The cross-process coherence guard (#45966)\n-            # snapshots the count at agent-BUILD time (before this turn's own\n-            # writes) and never refreshes it on reuse — so without this, this\n-            # process's own turn would grow the count and the next turn would\n-            # see a mismatch and rebuild the agent every turn, destroying\n-            # prompt caching.  Refreshing here makes the guard fire only on a\n-            # DIFFERENT process's writes.  Uses the (possibly compaction-\n-            # updated) live session_id.  Fail-safe inside the helper.\n-            await self._refresh_agent_cache_message_count(\n-                session_key, session_entry.session_id\n-            )\n+            # NOTE: the cross-process cache-coherence re-baseline\n+            # (_refresh_agent_cache_message_count) is intentionally deferred\n+            # until AFTER this turn's transcript persistence block below — it\n+            # must include the first-turn `session_meta` marker row and the\n+            # compression session_id swap, both of which happen later.  See\n+            # the call site after the `update_session(...)` write.\n \n             # Successful turn — clear any stuck-loop counter for this session.\n             # This ensures the counter only accumulates across CONSECUTIVE","@@ -11077,6 +11070,28 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 last_prompt_tokens=agent_result.get(\"last_prompt_tokens\", 0),\n             )\n \n+            # Re-baseline the cached agent's message_count snapshot now that\n+            # ALL of this turn's transcript writes are done — the agent's\n+            # flushed user/assistant/tool rows AND the first-turn `session_meta`\n+            # marker appended above.  The cross-process coherence guard (#45966)\n+            # snapshots the count at agent-BUILD time (before this turn's own\n+            # writes) and never refreshes it on reuse, so without this the\n+            # process's own turn grows message_count and the next turn sees a\n+            # mismatch and rebuilds the agent — destroying prompt caching.\n+            #\n+            # This MUST run after the `session_meta` append: that row also\n+            # increments message_count, so re-baselining before it (the old\n+            # position) left the snapshot one short and the guard mis-fired on\n+            # turn 2 of EVERY fresh gateway conversation, rebuilding the cached\n+            # agent and busting the prompt cache.  Running here also uses the\n+            # compaction-updated session_id (the agent_result session_id swap\n+            # above), matching this function's documented contract.  Refreshing\n+            # here makes the guard fire only on a DIFFERENT process's writes.\n+            # Fail-safe inside the helper.\n+            await self._refresh_agent_cache_message_count(\n+                session_key, session_entry.session_id\n+            )\n+\n             # Intentional silence is a delivery decision, not a transcript\n             # mutation.  The agent's [SILENT]/NO_REPLY assistant turn above is\n             # still persisted in session history so later turns keep normal","@@ -14902,6 +14917,15 @@ async def _refresh_agent_cache_message_count(\n         only when the same agent is still cached (no rebuild/eviction raced\n         in between).  Fail-safe: any DB error leaves the snapshot as-is, which\n         at worst costs one unnecessary rebuild on the next turn.\n+\n+        When the cache entry records a ``session_id`` (4-tuple form, #54947)\n+        that differs from the current ``session_id`` — meaning the cache\n+        was built for a DIFFERENT conversation under the same ``session_key``\n+        — the snapshot is intentionally left untouched.  Overwriting it with\n+        the current session's count would corrupt the original conversation's\n+        baseline and cause the next switch back to fire the cross-process\n+        guard spuriously.  Fail-safe: the legacy 3-tuple shape (no\n+        ``session_id``) is still re-baselined as before.\n         \"\"\"\n         if self._session_db is None or not session_id:\n             return","@@ -14926,8 +14950,23 @@ async def _refresh_agent_cache_message_count(\n                 and len(cached) > 2\n                 and cached[0] is not _AGENT_PENDING_SENTINEL\n             ):\n+                # If the snapshot was taken for a different session_id\n+                # (same session_key, different conversation), leave the\n+                # snapshot alone — the current session_id's count belongs\n+                # to a different DB row (#54947).\n+                _snapshot_sid = cached[3] if len(cached) > 3 else None\n+                if _snapshot_sid is not None and _snapshot_sid != session_id:\n+                    return\n                 if cached[2] != _live:\n-                    _cache[session_key] = (cached[0], cached[1], _live)\n+                    if _snapshot_sid is None:\n+                        # Legacy 3-tuple: preserve the original 3-element\n+                        # shape so existing entries stay compatible with\n+                        # callers that index ``cached[2]`` directly.\n+                        _cache[session_key] = (cached[0], cached[1], _live)\n+                    else:\n+                        _cache[session_key] = (\n+                            cached[0], cached[1], _live, _snapshot_sid,\n+                        )\n \n     def _evict_cached_agent(self, session_key: str) -> None:\n         \"\"\"Remove a cached agent for a session (called on /new, /model, etc).","@@ -16634,9 +16673,25 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                     if cached and cached[1] == _sig:\n                         # cached[2] is the message_count at cache time;\n                         # stale when a second process appended rows.\n+                        # cached[3] (when present) is the session_id the\n+                        # snapshot was taken for — used to skip the guard\n+                        # when the active session_id differs (#54947).\n                         _cached_mc = cached[2] if len(cached) > 2 else None\n+                        _cached_sid = cached[3] if len(cached) > 3 else None\n+                        # If the snapshot belongs to a different session_id\n+                        # (same session_key, different conversation), the\n+                        # message_count comparison is meaningless — the\n+                        # counts track DIFFERENT DB rows.  REUSE the cached\n+                        # agent rather than rebuild and bust the prompt cache\n+                        # on every session switch (#54947).\n+                        _session_id_mismatch = (\n+                            _cached_sid is not None\n+                            and session_id is not None\n+                            and _cached_sid != session_id\n+                        )\n                         if (\n-                            _cached_mc is not None\n+                            not _session_id_mismatch\n+                            and _cached_mc is not None\n                             and _current_msg_count is not None\n                             and _current_msg_count != _cached_mc\n                         ):","@@ -16667,7 +16722,6 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                                 _xproc_evicted_agent = _ev_agent\n                         else:\n                             agent = cached[0]\n-                            reused_cached_agent = True\n                             # Refresh LRU order so the cap enforcement evicts\n                             # truly-oldest entries, not the one we just used.\n                             if hasattr(_cache, \"move_to_end\"):","@@ -16680,6 +16734,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                             # (cached agent may have been created with old config)\n                             agent.max_iterations = max_iterations\n                             logger.debug(\"Reusing cached agent for session %s\", session_key)\n+                            reused_cached_agent = True\n \n             # Lock released — now schedule cleanup of any cross-process-evicted\n             # agent on a daemon thread so memory-provider shutdown / socket","@@ -16737,7 +16792,14 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                 )\n                 if _cache_lock and _cache is not None:\n                     with _cache_lock:\n-                        _cache[session_key] = (agent, _sig, _current_msg_count)\n+                        # Record the session_id the snapshot was taken for\n+                        # alongside the message_count, so the cross-process\n+                        # guard can skip the (meaningless) count comparison\n+                        # when the active session_id later switches under\n+                        # the same session_key (#54947).\n+                        _cache[session_key] = (\n+                            agent, _sig, _current_msg_count, session_id,\n+                        )\n                         self._enforce_agent_cache_cap()\n                 logger.debug(\"Created new agent for session %s (sig=%s)\", session_key, _sig)\n ","@@ -18206,6 +18268,22 @@ def _stream_confirmed_final_delivery(\n                     except Exception:\n                         pass\n \n+                # Re-baseline the cached agent's message_count snapshot before\n+                # recursing into the in-band queued (/queue) follow-up turn.\n+                # The first turn has completed and flushed its own user +\n+                # assistant rows to the SessionDB, so the cross-process\n+                # coherence guard (#45966) — which this recursive _run_agent\n+                # call re-enters — would otherwise see the grown on-disk count\n+                # against the stale build-time snapshot and rebuild the agent\n+                # on THIS process's OWN writes, destroying the prompt-cache\n+                # prefix #46237 was merged to preserve.  The existing\n+                # re-baseline in _handle_message_with_agent only runs after the\n+                # whole _run_agent chain unwinds — too late for the in-band\n+                # follow-up.  Use the same (session_key, session_id) the\n+                # recursive call runs under so the snapshot matches exactly\n+                # what the follow-up's guard will consult.  Fail-safe in helper.\n+                await self._refresh_agent_cache_message_count(session_key, session_id)\n+\n                 followup_result = await self._run_agent(\n                     message=next_message,\n                     context_prompt=context_prompt,"]},{"file":"scripts/release.py","hunks":["@@ -217,6 +217,8 @@\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n     \"13277570+justin-cyhuang@users.noreply.github.com\": \"justin-cyhuang\",\n+    \"[redacted:email]\": \"Tranquil-Flow\",\n+    \"jason@hermes-jc\": \"jcjc81\",\n     \"290862769+friendshipisover@users.noreply.github.com\": \"friendshipisover\",\n     \"51421+MattKotsenas@users.noreply.github.com\": \"MattKotsenas\",\n     \"92324143+ypwcharles@users.noreply.github.com\": \"ypwcharles\","]},{"file":"tests/gateway/test_agent_cache.py","hunks":["@@ -1715,6 +1715,80 @@ def get_session(self, _sid):\n         with runner._agent_cache_lock:\n             assert runner._agent_cache[\"telegram:s1\"][2] == 5\n \n+    @pytest.mark.asyncio\n+    async def test_in_band_followup_reuses_cached_agent(self, tmp_path):\n+        \"\"\"Behavioral regression for the in-band queued (/queue) follow-up.\n+\n+        #46237 re-baselines the snapshot only on the EXTERNAL-turn boundary\n+        (in ``_handle_message_with_agent``, after the whole ``_run_agent``\n+        chain unwinds).  The recursive in-band follow-up re-enters the cache\n+        guard MID-CHAIN — while the cache still holds the build-time snapshot\n+        and the first turn has already flushed its own rows — so without a\n+        re-baseline at the follow-up boundary the guard sees the grown count\n+        and rebuilds the agent on THIS process's own writes, re-introducing\n+        the every-turn rebuild #46237 set out to fix, on the follow-up path.\n+\n+        Pins both halves at that boundary: WITHOUT the re-baseline the in-band\n+        follow-up would rebuild; WITH it the follow-up REUSES the warm agent.\n+        The guard's reuse decision (``_guard_would_reuse``) mirrors the real\n+        cache-hit guard, which reads ``get_session(session_id)`` with the same\n+        ``session_id`` the recursive ``_run_agent`` call is given.\n+        \"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        runner = self._runner_with_db(db)\n+        agent = object()\n+\n+        # First turn: cache miss -> build. Snapshot is the pre-turn count.\n+        _row = db.get_session(\"s1\")\n+        build_count = _row.get(\"message_count\", 0) if _row else 0\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (agent, \"sig\", build_count)\n+\n+        # First turn flushes its own user + assistant rows.\n+        db.append_message(\"s1\", role=\"user\", content=\"u\")\n+        db.append_message(\"s1\", role=\"assistant\", content=\"a\")\n+\n+        # Bug reproduction: re-entering the guard at the in-band follow-up\n+        # boundary WITHOUT the re-baseline sees the grown count and rebuilds.\n+        assert self._guard_would_reuse(runner, \"telegram:s1\", \"s1\") is False\n+\n+        # The fix: re-baseline at the follow-up boundary.\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+\n+        # The in-band follow-up now REUSES the cached, warm-prefix agent.\n+        assert self._guard_would_reuse(runner, \"telegram:s1\", \"s1\") is True\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:s1\"][0] is agent\n+\n+    def test_in_band_followup_rebaseline_precedes_recursion(self):\n+        \"\"\"Pin the FIX PLACEMENT in the production source.\n+\n+        The behavioral test above proves the re-baseline makes the in-band\n+        follow-up reuse the cached agent, but it calls the helper directly —\n+        it would still pass if the production call were deleted.  This guards\n+        the actual call site: the queued (/queue) follow-up recurses via\n+        ``followup_result = await self._run_agent(...)`` inside\n+        ``_run_agent_inner`` and the re-baseline MUST run BEFORE that\n+        recursion (running it only after, like the external-turn site, is too\n+        late for the in-band path — the follow-up would already have rebuilt).\n+        \"\"\"\n+        import inspect\n+        from gateway.run import GatewayRunner\n+\n+        # The recursion + pre-recursion re-baseline live in the extracted\n+        # ``_run_agent_inner`` (older trees had them inline in ``_run_agent``).\n+        src = inspect.getsource(GatewayRunner._run_agent_inner)\n+        marker = \"followup_result = await self._run_agent(\"\n+        assert marker in src, \"in-band queued follow-up recursion not found in _run_agent_inner\"\n+        before_recursion = src[: src.index(marker)]\n+        assert \"_refresh_agent_cache_message_count\" in before_recursion, (\n+            \"the in-band queued follow-up recursion must be preceded by a \"\n+            \"_refresh_agent_cache_message_count re-baseline, else the follow-up \"\n+            \"rebuilds the agent on this process's own first-turn writes\"\n+        )\n \n class TestCrossProcessInvalidationDefersCleanup:\n     \"\"\"#52197: cross-process cache invalidation must NOT run agent cleanup"]},{"file":"tests/gateway/test_first_turn_session_meta_rebaseline.py","hunks":["@@ -0,0 +1,264 @@\n+\"\"\"Regression: first-turn ``session_meta`` row must be re-baselined into the\n+agent cache's message_count snapshot.\n+\n+Bug\n+---\n+On a fresh gateway conversation the post-turn re-baseline\n+(``_refresh_agent_cache_message_count``) runs *before* the first-turn\n+``session_meta`` marker row is appended to the transcript:\n+\n+    gateway/run.py:\n+        ... agent run completes ...\n+        self._refresh_agent_cache_message_count(...)   # snapshot taken HERE\n+        ...\n+        if not history:                                # session_meta written LATER\n+            append_to_transcript({\"role\": \"session_meta\", ...})\n+\n+``append_to_transcript`` (no ``skip_db``) increments the session's\n+``message_count`` unconditionally (hermes_state.append_message), so the\n+snapshot ends up exactly +1 below the live on-disk count.\n+\n+The cross-process coherence guard (#45966) compares the live count against\n+that snapshot on the *next* inbound message, sees ``live != snapshot``,\n+mistakes this process's own ``session_meta`` write for a foreign write, and\n+**rebuilds the cached agent on turn 2 of every fresh conversation** — silently\n+busting the per-conversation prompt cache the cache exists to protect.\n+\n+The fix re-baselines AFTER all of this turn's transcript writes (including the\n+first-turn ``session_meta`` row), so the snapshot matches the live count and\n+the guard fires only on genuinely foreign writes.\n+\n+This drives the REAL ``_handle_message_with_agent`` against a REAL SessionDB\n+(the ``session_meta`` write actually increments ``message_count``) and asserts\n+the invariant: after a first turn, snapshot == live count → next turn reuses.\n+\"\"\"\n+\n+import sys\n+import threading\n+import types\n+from datetime import datetime\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+import gateway.run as gateway_run\n+from gateway.config import GatewayConfig, Platform\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource\n+\n+\n+SESSION_KEY = \"agent:main:telegram:group:-1001:12345\"\n+SESSION_ID = \"sess-first-turn\"\n+\n+\n+def _bootstrap(monkeypatch, tmp_path, db):\n+    \"\"\"GatewayRunner wired to a REAL SessionDB for count reads, mirroring the\n+    proven #42039 harness but with a live cache + real transcript counter.\"\"\"\n+    fake_dotenv = types.ModuleType(\"dotenv\")\n+    fake_dotenv.load_dotenv = lambda *args, **kwargs: None\n+    monkeypatch.setitem(sys.modules, \"dotenv\", fake_dotenv)\n+\n+    config = GatewayConfig()\n+    runner = gateway_run.GatewayRunner(config)\n+    runner.adapters = {}\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._is_user_authorized = lambda _source: True\n+    runner._set_session_env = lambda _context: None\n+    runner._handle_active_session_busy_message = AsyncMock(return_value=False)\n+    # REAL SessionDB behind the async facade the gateway holds — the\n+    # production re-baseline does ``await self._session_db.get_session(...)``,\n+    # so it must be the AsyncSessionDB wrapper, not the raw sync DB.\n+    from hermes_state import AsyncSessionDB\n+\n+    runner._session_db = AsyncSessionDB(db)\n+    runner._recover_telegram_topic_thread_id = lambda _source: None\n+    runner._cache_session_source = lambda _key, _source: None\n+    runner._is_session_run_current = lambda _key, _gen: True\n+    runner._begin_session_run_generation = lambda _key: 1\n+    runner._reply_anchor_for_event = lambda _event: None\n+    runner._get_guild_id = lambda _event: None\n+    runner._should_send_voice_reply = lambda *_a, **_kw: False\n+    runner.hooks = MagicMock()\n+    runner.hooks.emit = AsyncMock()\n+\n+    # Live agent cache (not a MagicMock) so the re-baseline actually rewrites\n+    # the snapshot tuple in place.\n+    runner._agent_cache = {}\n+    runner._agent_cache_lock = threading.Lock()\n+\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = SessionEntry(\n+        session_key=SESSION_KEY,\n+        session_id=SESSION_ID,\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"group\",\n+    )\n+    # Empty history → triggers the first-turn ``session_meta`` write path.\n+    runner.session_store.load_transcript.return_value = []\n+    runner.session_store.has_platform_message_id.return_value = False\n+    runner.session_store.update_session = MagicMock()\n+\n+    # Mirror the real SessionStore.append_to_transcript: forward non-skip_db\n+    # writes to the real DB so a ``session_meta`` row genuinely increments\n+    # message_count, exactly as in production. skip_db=True writes (the agent\n+    # already persisted them via _flush_messages_to_session_db) are no-ops here.\n+    def _append(session_id, message, skip_db=False):\n+        if not skip_db:\n+            db.append_message(\n+                session_id=session_id,\n+                role=message.get(\"role\", \"unknown\"),\n+                content=message.get(\"content\"),\n+            )\n+\n+    runner.session_store.append_to_transcript = MagicMock(side_effect=_append)\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(\n+        gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"fake\"}\n+    )\n+    monkeypatch.setattr(\n+        \"agent.model_metadata.get_model_context_length\",\n+        lambda *_args, **_kwargs: 100_000,\n+    )\n+    return runner\n+\n+\n+def _event():\n+    return MessageEvent(\n+        text=\"hello world\",\n+        source=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-1001\",\n+            chat_type=\"group\",\n+            user_id=\"12345\",\n+        ),\n+        message_id=\"msg-1\",\n+    )\n+\n+\n+def _source():\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_type=\"group\",\n+        user_id=\"12345\",\n+    )\n+\n+\n+def _live_count(db, session_id):\n+    row = db.get_session(session_id)\n+    return (row.get(\"message_count\", 0) if row else 0)\n+\n+\n[redacted:email]\n+async def test_first_turn_session_meta_is_captured_by_rebaseline(\n+    monkeypatch, tmp_path\n+):\n+    \"\"\"After a fresh first turn, the cache snapshot must equal the live\n+    message_count — including the first-turn ``session_meta`` row.\n+\n+    WITHOUT the fix the re-baseline snapshots the count *before* the\n+    session_meta append, leaving the snapshot one short; the cross-process\n+    guard then rebuilds the cached agent on turn 2 (prompt-cache churn).\n+    \"\"\"\n+    from hermes_state import SessionDB\n+\n+    db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+    db.create_session(SESSION_ID, source=\"telegram\")\n+\n+    runner = _bootstrap(monkeypatch, tmp_path, db)\n+\n+    # Cache snapshot taken at agent-BUILD time = count before this turn's\n+    # writes (a fresh session → 0). This is what the #45966 guard stores.\n+    build_count = _live_count(db, SESSION_ID)\n+    agent_obj = object()\n+    with runner._agent_cache_lock:\n+        runner._agent_cache[SESSION_KEY] = (agent_obj, \"sig\", build_count)\n+\n+    # Stubbed agent run: the gateway's own user/assistant rows are persisted\n+    # by the agent (skip_db=True downstream); only the session_meta marker is\n+    # written by the gateway with skip_db=False.\n+    runner._run_agent = AsyncMock(\n+        return_value={\n+            \"final_response\": \"Hi there!\",\n+            \"messages\": [\n+                {\"role\": \"user\", \"content\": \"hello world\"},\n+                {\"role\": \"assistant\", \"content\": \"Hi there!\"},\n+            ],\n+            \"tools\": [{\"name\": \"noop\"}],\n+            \"history_offset\": 0,\n+            \"last_prompt_tokens\": 0,\n+        }\n+    )\n+\n+    await runner._handle_message_with_agent(_event(), _source(), SESSION_KEY, 1)\n+\n+    # The first-turn session_meta row was written → live count advanced.\n+    live = _live_count(db, SESSION_ID)\n+    assert live == build_count + 1, (\n+        \"first-turn session_meta should increment message_count by exactly 1\"\n+    )\n+\n+    # THE INVARIANT: the cache snapshot must now equal the live count, so the\n+    # next turn's cross-process guard reuses the cached agent.\n+    with runner._agent_cache_lock:\n+        cached = runner._agent_cache[SESSION_KEY]\n+    snapshot = cached[2]\n+    assert snapshot == live, (\n+        f\"cache snapshot {snapshot} != live count {live}: the first-turn \"\n+        f\"session_meta write was not re-baselined, so the #45966 guard will \"\n+        f\"rebuild the cached agent on turn 2 and bust the prompt cache.\"\n+    )\n+    # And the cached agent instance must be untouched (never rebuilt).\n+    assert cached[0] is agent_obj\n+\n+\n[redacted:email]\n+async def test_next_turn_guard_reuses_cached_agent_after_first_turn(\n+    monkeypatch, tmp_path\n+):\n+    \"\"\"End-to-end consequence: with the snapshot correctly re-baselined, the\n+    production cross-process guard's reuse condition (live == snapshot) holds\n+    on turn 2 — no rebuild, prompt cache preserved.\"\"\"\n+    from hermes_state import SessionDB\n+\n+    db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+    db.create_session(SESSION_ID, source=\"telegram\")\n+\n+    runner = _bootstrap(monkeypatch, tmp_path, db)\n+    with runner._agent_cache_lock:\n+        runner._agent_cache[SESSION_KEY] = (\n+            object(), \"sig\", _live_count(db, SESSION_ID),\n+        )\n+\n+    runner._run_agent = AsyncMock(\n+        return_value={\n+            \"final_response\": \"Hi there!\",\n+            \"messages\": [\n+                {\"role\": \"user\", \"content\": \"hello world\"},\n+                {\"role\": \"assistant\", \"content\": \"Hi there!\"},\n+            ],\n+            \"tools\": [{\"name\": \"noop\"}],\n+            \"history_offset\": 0,\n+            \"last_prompt_tokens\": 0,\n+        }\n+    )\n+\n+    await runner._handle_message_with_agent(_event(), _source(), SESSION_KEY, 1)\n+\n+    # Replicate the production cache-hit guard's reuse decision exactly:\n+    # reuse iff live on-disk count == snapshot stored next to the agent.\n+    live = _live_count(db, SESSION_ID)\n+    with runner._agent_cache_lock:\n+        snapshot = runner._agent_cache[SESSION_KEY][2]\n+    would_reuse = (live == snapshot)\n+    assert would_reuse, (\n+        \"turn-2 cross-process guard would rebuild the cached agent because \"\n+        \"the first-turn session_meta write was not re-baselined into the \"\n+        \"snapshot — this is the prompt-cache regression under test.\"\n+    )"]},{"file":"tests/gateway/test_session_id_cache_coherence.py","hunks":["@@ -0,0 +1,318 @@\n+\"\"\"Regression tests for #54947 — cross-process guard must not invalidate the\n+agent cache when the active ``session_id`` differs from the snapshot's\n+``session_id``, even when both share the same ``session_key``.\n+\n+Bug\n+---\n+The cache key is the gateway ``session_key`` (e.g. ``agent:main:telegram:dm:USER_ID``)\n+which groups all DM sessions for that user. Different ``session_id``s (separate\n+conversation threads) can share a ``session_key``. When the user switches\n+between session_ids, the cached agent is shared, and the cross-process\n+coherence guard (``_cached_mc`` vs ``_current_msg_count``) treats different\n+sessions' ``message_count`` values as the same counter — invalidating the\n+agent on EVERY session switch and busting the per-conversation prompt cache.\n+\n+These tests pin the production guard's reuse decision across:\n+  L1 — session-id switch must REUSE (not invalidate) the cached agent.\n+  L2 — cache tuple records the snapshot's session_id.\n+  L3 — re-baseline skips the cache entry when session_id differs.\n+  L4 — same-session_id turns still re-baseline correctly (no regression\n+       of #45966 / #46237).\n+  L5 — legacy 2-tuples and pending sentinels are still untouched.\n+\n+All tests drive the REAL production helper (``_refresh_agent_cache_message_count``)\n+against a REAL ``SessionDB`` and exercise the cache-hit guard's logic with the\n+REAL cache lock, mirroring the structure used by\n+``TestAgentCacheMessageCountRebaseline``.\n+\"\"\"\n+\n+import threading\n+\n+import pytest\n+\n+from hermes_state import AsyncSessionDB\n+\n+\n+def _make_runner():\n+    \"\"\"Create a minimal GatewayRunner with just the cache infrastructure.\"\"\"\n+    from gateway.run import GatewayRunner\n+\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner._agent_cache = {}\n+    runner._agent_cache_lock = threading.Lock()\n+    return runner\n+\n+\n+def _guard_would_reuse(runner, session_key, session_id):\n+    \"\"\"Mirror the production cache-hit guard's reuse decision exactly\n+    AFTER the fix: reuse when the session_id matches the snapshot's\n+    session_id, OR when the entry is a legacy 2-tuple / pending sentinel.\n+\n+    Reuse iff any of:\n+      - cached session_id matches current session_id AND live count matches\n+        snapshot count (same-process turn OR no foreign write)\n+      - cached session_id differs from current session_id (different\n+        conversation, snapshot is from a different DB row → meaningless\n+        to compare, REUSE without invalidation)\n+      - entry is a 2-tuple (legacy opt-out of guard)\n+      - either side is None (unknown state → REUSE, fail-safe)\n+\n+    Invalidate iff:\n+      - cached session_id == current session_id AND\n+        cached_mc is not None AND live_mc is not None AND\n+        live_mc != cached_mc (genuine cross-process write on the SAME\n+        session — guard fires, agent rebuilds).\n+    \"\"\"\n+    try:\n+        # Mirror the production guard, which reads the sync underlying DB\n+        # (``self._session_db._db.get_session``) off the async facade.\n+        row = runner._session_db._db.get_session(session_id)\n+        live = row.get(\"message_count\", 0) if row else None\n+    except Exception:\n+        live = None\n+    with runner._agent_cache_lock:\n+        cached = runner._agent_cache.get(session_key)\n+\n+    if cached is None:\n+        return True  # no entry → cache miss → fresh build (not invalidation)\n+    # Legacy 2-tuple opts out of the guard.\n+    if len(cached) < 3:\n+        return True\n+    # Pending sentinel — treat as a no-op reuse.\n+    cached_sid = cached[3] if len(cached) > 3 else None\n+    cached_mc = cached[2]\n+\n+    # Snapshot belongs to a DIFFERENT session_id → comparison is\n+    # meaningless; REUSE without invalidation.\n+    if cached_sid is not None and session_id is not None and cached_sid != session_id:\n+        return True\n+\n+    # Same session_id: standard cross-process guard.\n+    invalidate = (\n+        cached_mc is not None\n+        and live is not None\n+        and live != cached_mc\n+    )\n+    return not invalidate\n+\n+\n+class TestSessionIdCacheCoherence:\n+    \"\"\"#54947 — guard must not invalidate the agent cache on session_id switch\n+    under the same session_key.\"\"\"\n+\n+    def test_session_id_switch_reuses_cached_agent(self, tmp_path):\n+        \"\"\"The reported bug: cache built from session A, switch to session B\n+        under the same session_key. The guard must REUSE the cached agent\n+        (the message_count comparison is meaningless across different\n+        session_ids), not rebuild and bust the prompt cache.\n+        \"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"sA\", source=\"telegram\")\n+        db.create_session(\"sB\", source=\"telegram\")\n+        # Make counts differ to make the bug observable.\n+        db.append_message(\"sA\", role=\"user\", content=\"hello from A\")\n+        db.append_message(\"sA\", role=\"assistant\", content=\"hi A\")\n+        db.append_message(\"sA\", role=\"user\", content=\"another from A\")\n+        # sA count = 3, sB count = 0\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+        agent = object()\n+\n+        # Build cache from session A (mc=3, sid=sA).\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:USER1\"] = (agent, \"sig\", 3, \"sA\")\n+\n+        # User switches to session B (mc=0, sid=sB) — same session_key.\n+        # Guard must NOT invalidate.\n+        assert _guard_would_reuse(runner, \"telegram:USER1\", \"sB\") is True, (\n+            \"BUG: cache was invalidated on session_id switch — \"\n+            \"the #54947 root cause is back.\"\n+        )\n+        # The original agent must still be in the cache.\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:USER1\"][0] is agent\n+\n+    @pytest.mark.asyncio\n+    async def test_same_session_id_turns_still_reuse(self, tmp_path):\n+        \"\"\"#46237 / #45966 invariant: consecutive same-session turns must\n+        REUSE the cached agent (prompt cache preserved).\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+        agent = object()\n+\n+        _row = db.get_session(\"s1\")\n+        build_count = _row.get(\"message_count\", 0) if _row else 0\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (agent, \"sig\", build_count, \"s1\")\n+\n+        reuses = 0\n+        for _ in range(1, 6):\n+            db.append_message(\"s1\", role=\"user\", content=\"u\")\n+            db.append_message(\"s1\", role=\"assistant\", content=\"a\")\n+            # Post-turn re-baseline (the #46237 fix).\n+            await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+            if _guard_would_reuse(runner, \"telegram:s1\", \"s1\"):\n+                reuses += 1\n+\n+        assert reuses == 5\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:s1\"][0] is agent\n+\n+    @pytest.mark.asyncio\n+    async def test_cross_process_write_still_invalidates(self, tmp_path):\n+        \"\"\"The original #45966 invariant must hold: a DIFFERENT process\n+        appending to the same session in the shared DB invalidates the\n+        cache (genuine cross-process write).\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+        agent = object()\n+\n+        with runner._agent_cache_lock:\n+            _row = db.get_session(\"s1\")\n+            runner._agent_cache[\"telegram:s1\"] = (\n+                agent, \"sig\", (_row.get(\"message_count\", 0) if _row else 0), \"s1\",\n+            )\n+\n+        # Our own turn + re-baseline → reuse next turn.\n+        db.append_message(\"s1\", role=\"user\", content=\"u\")\n+        db.append_message(\"s1\", role=\"assistant\", content=\"a\")\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+        assert _guard_would_reuse(runner, \"telegram:s1\", \"s1\") is True\n+\n+        # ANOTHER process (e.g. the desktop dashboard backend) appends a\n+        # turn to the SAME session in the shared DB.\n+        db.append_message(\"s1\", role=\"user\", content=\"external from dashboard\")\n+\n+        # Guard must invalidate.\n+        assert _guard_would_reuse(runner, \"telegram:s1\", \"s1\") is False\n+\n+    @pytest.mark.asyncio\n+    async def test_refresh_skips_when_session_id_differs(self, tmp_path):\n+        \"\"\"_refresh_agent_cache_message_count must NOT refresh the cached\n+        snapshot when the current session_id differs from the one the\n+        snapshot belongs to. Otherwise the snapshot gets overwritten with\n+        a different session's count, and the next switch back fires the\n+        guard (the original bug).\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"sA\", source=\"telegram\")\n+        db.create_session(\"sB\", source=\"telegram\")\n+        db.append_message(\"sA\", role=\"user\", content=\"x\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+        agent = object()\n+\n+        # Cache built from session A: (agent, sig, mc=1, sid=sA).\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:USER1\"] = (agent, \"sig\", 1, \"sA\")\n+\n+        # Someone (the call site at line 9540) calls the re-baseline with\n+        # the CURRENT session_id — which is sB after a switch. The\n+        # snapshot is from sA → must NOT be touched.\n+        await runner._refresh_agent_cache_message_count(\"telegram:USER1\", \"sB\")\n+\n+        with runner._agent_cache_lock:\n+            cached = runner._agent_cache[\"telegram:USER1\"]\n+            assert cached[2] == 1, (\n+                f\"BUG: snapshot was overwritten with sB's count: cached[2]={cached[2]}\"\n+            )\n+            assert cached[3] == \"sA\", (\n+                f\"BUG: snapshot's session_id was changed: cached[3]={cached[3]}\"\n+            )\n+            assert cached[0] is agent\n+\n+    @pytest.mark.asyncio\n+    async def test_refresh_refreshes_when_session_id_matches(self, tmp_path):\n+        \"\"\"Sanity: when the snapshot's session_id matches the current one,\n+        the re-baseline still runs and updates the count to the live value.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+        agent = object()\n+\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (agent, \"sig\", 0, \"s1\")\n+\n+        # s1's own turn flushes two rows.\n+        db.append_message(\"s1\", role=\"user\", content=\"u\")\n+        db.append_message(\"s1\", role=\"assistant\", content=\"a\")\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:s1\"][2] == 2\n+\n+    @pytest.mark.asyncio\n+    async def test_legacy_2tuple_and_pending_sentinel_untouched(self, tmp_path):\n+        \"\"\"Backward-compat: legacy 2-tuples and pending-sentinel 3-tuples\n+        are not affected by the fix. The 2-tuple opts out of the guard;\n+        the sentinel is left as-is by the re-baseline.\"\"\"\n+        from hermes_state import SessionDB\n+        from gateway.run import _AGENT_PENDING_SENTINEL\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        db.append_message(\"s1\", role=\"user\", content=\"hi\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+\n+        # Legacy 2-tuple — untouched.\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (object(), \"sig\")\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+        with runner._agent_cache_lock:\n+            assert len(runner._agent_cache[\"telegram:s1\"]) == 2\n+\n+        # Pending sentinel — untouched.\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (_AGENT_PENDING_SENTINEL, \"sig\", 0)\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:s1\"][0] is _AGENT_PENDING_SENTINEL\n+            assert runner._agent_cache[\"telegram:s1\"][2] == 0\n+\n+    @pytest.mark.asyncio\n+    async def test_legacy_3tuple_session_id_unknown_still_guarded(self, tmp_path):\n+        \"\"\"An entry in the OLD 3-tuple shape (agent, sig, mc) with no\n+        session_id — entries already in the cache from BEFORE the fix —\n+        must STILL be guarded by the cross-process check.  The fix only\n+        ADDS a session_id-aware skip path; it does not weaken the\n+        existing #45966 guard for entries that pre-date it.  When\n+        live count != snapshot count, the guard fires and the agent\n+        rebuilds (same behavior as before the fix for legacy entries).\n+        \"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB(db_path=tmp_path / \"sessions.db\")\n+        db.create_session(\"s1\", source=\"telegram\")\n+        db.append_message(\"s1\", role=\"user\", content=\"x\")\n+        runner = _make_runner()\n+        runner._session_db = AsyncSessionDB(db)\n+\n+        # Existing entry in old 3-tuple shape, no session_id recorded.\n+        # Snapshot is mc=0; live is mc=1 — guard must fire (invalidate).\n+        with runner._agent_cache_lock:\n+            runner._agent_cache[\"telegram:s1\"] = (object(), \"sig\", 0)\n+\n+        # No session_id on cached entry → standard cross-process check\n+        # still runs.  live (1) != snapshot (0) → invalidates.\n+        assert _guard_would_reuse(runner, \"telegram:s1\", \"s1\") is False\n+\n+        # After the re-baseline (same session_id) snapshot matches live.\n+        await runner._refresh_agent_cache_message_count(\"telegram:s1\", \"s1\")\n+        with runner._agent_cache_lock:\n+            assert runner._agent_cache[\"telegram:s1\"][2] == 1\n+        assert _guard_would_reuse(runner, \"telegram:s1\", \"s1\") is True\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}