{"repo":"NousResearch/hermes-agent","public_id":"6672","display_label":"OpenViking memory plugin session handling corrections","summary":"These items address bugs in the OpenViking memory plugin, including stale session IDs after rotation, absent session initialization, unwritten memory persistence, and an on_session_switch hook. A secondary theme covers notifying memory providers more broadly when sessions switch.","subsystem":"agent","counts":{"members":18,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":4,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10463},"resolution":{"state":"partially_resolved","by":"pr-10463,pr-17409,pr-30317,pr-48042"},"freshness":{"state":"verified","last_verified_at":"2026-06-17T21:04:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10326797385620916},"fix_edges":[{"issue_number":6672,"pr_number":17409,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8705,"pr_number":10463,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8742,"pr_number":10463,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":28296,"pr_number":48042,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6672,"title":"feat(memory): notify memory providers on in-chat /resume and /branch session switches","url":null,"state":"closed"},{"kind":"issue","number":8705,"title":"openviking memory plugin: initialize() uses Hermes session_id without creating an OpenViking session first","url":null,"state":"closed"},{"kind":"issue","number":17998,"title":"[Bug]: viking_remember creates empty sessions — data never persisted","url":null,"state":"closed"},{"kind":"issue","number":28296,"title":"OpenVikingMemoryProvider missing on_session_switch — session ID goes stale after /new","url":null,"state":"closed"},{"kind":"issue","number":30947,"title":"bug(memory): OpenViking plugin never updates _session_id on session rotation (/new, /reset, /resume)","url":null,"state":"closed"},{"kind":"pr","number":9104,"title":"fix(memory): create OpenViking session server-side instead of reusing Hermes session_id","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10463","number":10463},{"kind":"pr","value":"#10396","number":10396}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":10463,"title":"fix(openviking): correct account default + commit session on /new and compress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","plugins/memory/openviking/__init__.py","run_agent.py","scripts/release.py","tests/agent/test_memory_provider.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -4100,6 +4100,8 @@ def new_session(self, silent=False):\n                 self.agent.flush_memories(self.conversation_history)\n             except (Exception, KeyboardInterrupt):\n                 pass\n+            # Trigger memory extraction on the old session before session_id rotates.\n+            self.agent.commit_memory_session(self.conversation_history)\n             self._notify_session_boundary(\"on_session_finalize\")\n         elif self.agent:\n             # First session or empty history — still finalize the old session"]},{"file":"plugins/memory/openviking/__init__.py","hunks":["@@ -10,8 +10,9 @@\n Config via environment variables (profile-scoped via each profile's .env):\n   OPENVIKING_ENDPOINT  — Server URL (default: http://127.0.0.1:1933)\n   OPENVIKING_API_KEY   — API key (required for authenticated servers)\n-  OPENVIKING_ACCOUNT   — Tenant account (default: root)\n+  OPENVIKING_ACCOUNT   — Tenant account (default: default)\n   OPENVIKING_USER      — Tenant user (default: default)\n+  OPENVIKING_AGENT   — Tenant agent (default: hermes)\n \n Capabilities:\n   - Automatic memory extraction on session commit (6 categories)","@@ -80,11 +81,12 @@ class _VikingClient:\n     \"\"\"Thin HTTP client for the OpenViking REST API.\"\"\"\n \n     def __init__(self, endpoint: str, api_key: str = \"\",\n-                 account: str = \"\", user: str = \"\"):\n+                 account: str = \"\", user: str = \"\", agent: str = \"\"):\n         self._endpoint = endpoint.rstrip(\"/\")\n         self._api_key = [redacted:secret-assignment]\n-        self._account = account or os.environ.get(\"OPENVIKING_ACCOUNT\", \"root\")\n+        self._account = account or os.environ.get(\"OPENVIKING_ACCOUNT\", \"default\")\n         self._user = user or os.environ.get(\"OPENVIKING_USER\", \"default\")\n+        self._agent = agent or os.environ.get(\"OPENVIKING_AGENT\", \"hermes\")\n         self._httpx = _get_httpx()\n         if self._httpx is None:\n             raise ImportError(\"httpx is required for OpenViking: pip install httpx\")","@@ -94,6 +96,7 @@ def _headers(self) -> dict:\n             \"Content-Type\": \"application/json\",\n             \"X-OpenViking-Account\": self._account,\n             \"X-OpenViking-User\": self._user,\n+            \"X-OpenViking-Agent\": self._agent,\n         }\n         if self._api_key:\n             h[\"X-API-Key\"] = self._api_key","@@ -282,20 +285,44 @@ def get_config_schema(self):\n             },\n             {\n                 \"key\": \"api_key\",\n-                \"description\": \"OpenViking API key\",\n+                \"description\": \"OpenViking API key (leave blank for local dev mode)\",\n                 \"secret\": True,\n                 \"env_var\": \"OPENVIKING_API_KEY\",\n             },\n+            {\n+                \"key\": \"account\",\n+                \"description\": \"OpenViking tenant account ID ([default], used when local mode, OPENVIKING_API_KEY is empty)\",\n+                \"default\": \"default\",\n+                \"env_var\": \"OPENVIKING_ACCOUNT\",\n+            },\n+            {\n+                \"key\": \"user\",\n+                \"description\": \"OpenViking user ID within the account ([default], used when local mode, OPENVIKING_API_KEY is empty)\",\n+                \"default\": \"default\",\n+                \"env_var\": \"OPENVIKING_USER\",\n+            },\n+            {\n+                \"key\": \"agent\",\n+                \"description\": \"OpenViking agent ID within the account ([hermes], useful in multi-agent mode)\",\n+                \"default\": \"hermes\",\n+                \"env_var\": \"OPENVIKING_AGENT\",\n+            },\n         ]\n \n     def initialize(self, session_id: str, **kwargs) -> None:\n         self._endpoint = os.environ.get(\"OPENVIKING_ENDPOINT\", _DEFAULT_ENDPOINT)\n         self._api_key = [redacted:secret-assignment]\"OPENVIKING_API_KEY\", \"\")\n+        self._account = os.environ.get(\"OPENVIKING_ACCOUNT\", \"default\")\n+        self._user = os.environ.get(\"OPENVIKING_USER\", \"default\")\n+        self._agent = os.environ.get(\"OPENVIKING_AGENT\", \"hermes\")\n         self._session_id = session_id\n         self._turn_count = 0\n \n         try:\n-            self._client = _VikingClient(self._endpoint, self._api_key)\n+            self._client = _VikingClient(\n+                self._endpoint, self._api_key,\n+                account=self._account, user=self._user, agent=self._agent,\n+            )\n             if not self._client.health():\n                 logger.warning(\"OpenViking server at %s is not reachable\", self._endpoint)\n                 self._client = None","@@ -325,7 +352,8 @@ def system_prompt_block(self) -> str:\n                 \"(abstract/overview/full), viking_browse to explore.\\n\"\n                 \"Use viking_remember to store facts, viking_add_resource to index URLs/docs.\"\n             )\n-        except Exception:\n+        except Exception as e:\n+            logger.warning(\"OpenViking system_prompt_block failed: %s\", e)\n             return (\n                 \"# OpenViking Knowledge Base\\n\"\n                 f\"Active. Endpoint: {self._endpoint}\\n\"","@@ -351,7 +379,10 @@ def queue_prefetch(self, query: str, *, session_id: str = \"\") -> None:\n \n         def _run():\n             try:\n-                client = _VikingClient(self._endpoint, self._api_key)\n+                client = _VikingClient(\n+                    self._endpoint, self._api_key,\n+                    account=self._account, user=self._user, agent=self._agent,\n+                )\n                 resp = client.post(\"/api/v1/search/find\", {\n                     \"query\": query,\n                     \"top_k\": 5,","@@ -386,7 +417,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n \n         def _sync():\n             try:\n-                client = _VikingClient(self._endpoint, self._api_key)\n+                client = _VikingClient(\n+                    self._endpoint, self._api_key,\n+                    account=self._account, user=self._user, agent=self._agent,\n+                )\n                 sid = self._session_id\n \n                 # Add user message","@@ -442,7 +476,10 @@ def on_memory_write(self, action: str, target: str, content: str) -> None:\n \n         def _write():\n             try:\n-                client = _VikingClient(self._endpoint, self._api_key)\n+                client = _VikingClient(\n+                    self._endpoint, self._api_key,\n+                    account=self._account, user=self._user, agent=self._agent,\n+                )\n                 # Add as a user message with memory context so the commit\n                 # picks it up as an explicit memory during extraction\n                 client.post(f\"/api/v1/sessions/{self._session_id}/messages\", {"]},{"file":"run_agent.py","hunks":["@@ -3040,6 +3040,18 @@ def shutdown_memory_provider(self, messages: list = None) -> None:\n             except Exception:\n                 pass\n     \n+    def commit_memory_session(self, messages: list = None) -> None:\n+        \"\"\"Trigger end-of-session extraction without tearing providers down.\n+        Called when session_id rotates (e.g. /new, context compression);\n+        providers keep their state and continue running under the old\n+        session_id — they just flush pending extraction now.\"\"\"\n+        if not self._memory_manager:\n+            return\n+        try:\n+            self._memory_manager.on_session_end(messages or [])\n+        except Exception:\n+            pass\n+\n     def close(self) -> None:\n         \"\"\"Release all resources held by this agent instance.\n ","@@ -6826,6 +6838,8 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token\n             try:\n                 # Propagate title to the new session with auto-numbering\n                 old_title = self._session_db.get_session_title(self.session_id)\n+                # Trigger memory extraction on the old session before it rotates.\n+                self.commit_memory_session(messages)\n                 self._session_db.end_session(self.session_id, \"compression\")\n                 old_session_id = self.session_id\n                 self.session_id = f\"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}\""]},{"file":"scripts/release.py","hunks":["@@ -197,6 +197,8 @@\n     \"[redacted:email]\": \"zhouboli\",\n     \"[redacted:email]\": \"tomqiaozc\",\n     \"[redacted:email]\": \"xinbenlv\",\n+    \"[redacted:email]\": \"ZaynJarvis\",\n+    \"[redacted:email]\": \"ZaynJarvis\",\n }\n \n "]},{"file":"tests/agent/test_memory_provider.py","hunks":["@@ -695,3 +695,44 @@ def test_fenced_block_separates_user_from_recall(self):\n         fence_end = combined.index(\"</memory-context>\")\n         assert \"Alice\" in combined[fence_start:fence_end]\n         assert combined.index(\"weather\") < fence_start\n+\n+\n+# ---------------------------------------------------------------------------\n+# AIAgent.commit_memory_session — routes to MemoryManager.on_session_end\n+# ---------------------------------------------------------------------------\n+\n+\n+class _CommitRecorder(FakeMemoryProvider):\n+    \"\"\"Provider that records on_session_end calls for assertions.\"\"\"\n+\n+    def __init__(self, name=\"recorder\"):\n+        super().__init__(name)\n+        self.end_calls = []\n+\n+    def on_session_end(self, messages):\n+        self.end_calls.append(list(messages or []))\n+\n+\n+class TestCommitMemorySessionRouting:\n+    def test_on_session_end_fans_out(self):\n+        mgr = MemoryManager()\n+        builtin = _CommitRecorder(\"builtin\")\n+        external = _CommitRecorder(\"openviking\")\n+        mgr.add_provider(builtin)\n+        mgr.add_provider(external)\n+\n+        msgs = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        mgr.on_session_end(msgs)\n+\n+        assert builtin.end_calls == [msgs]\n+        assert external.end_calls == [msgs]\n+\n+    def test_on_session_end_tolerates_failure(self):\n+        mgr = MemoryManager()\n+        builtin = FakeMemoryProvider(\"builtin\")\n+        bad = _CommitRecorder(\"bad-provider\")\n+        bad.on_session_end = lambda m: (_ for _ in ()).throw(RuntimeError(\"boom\"))\n+        mgr.add_provider(builtin)\n+        mgr.add_provider(bad)\n+\n+        mgr.on_session_end([])  # must not raise\n"]}]},{"kind":"pr","number":17409,"title":"feat(memory): notify providers on mid-process session_id rotation (#6672)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/memory_manager.py","agent/memory_provider.py","cli.py","gateway/run.py","plugins/memory/hindsight/__init__.py","run_agent.py","tests/agent/test_memory_session_switch.py","tests/cli/test_branch_command.py","tests/gateway/test_resume_command.py","tests/run_agent/test_memory_sync_interrupted.py"]},{"kind":"pr","number":18002,"title":"fix(openviking): commit explicit remember memories","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18320","number":18320},{"kind":"pr","value":"#17998","number":17998}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":18320,"title":"fix(openviking): migrate all endpoints from /api/v1/ to v0.2.x paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17998","number":17998}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":28445,"title":"fix(openviking): implement on_session_switch hook (#28296)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":28627,"title":"fix(openviking): add on_session_switch to update cached session_id","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28445","number":28445}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":29664,"title":"fix(openviking): bypass broken extraction pipeline in viking_remember","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":29733,"title":"fix(openviking): store memories via content/write API instead of session messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30317","number":30317},{"kind":"pr","value":"#17998","number":17998},{"kind":"pr","value":"#29664","number":29664}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30317,"title":"fix(openviking): store memories via content/write API instead of session messages","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true},{"kind":"pr","number":30985,"title":"fix(openviking): implement on_session_switch to update cached session state","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28445","number":28445},{"kind":"pr","value":"#28296","number":28296},{"kind":"pr","value":"#30947","number":30947},{"kind":"pr","value":"#28296","number":28296}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":47662,"title":"fix(openviking): harden session switching and writes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28296","number":28296},{"kind":"pr","value":"#48042","number":48042},{"kind":"pr","value":"#47973","number":47973},{"kind":"pr","value":"#41945","number":41945},{"kind":"pr","value":"#48042","number":48042}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48042,"title":"fix(openviking): implement on_session_switch hook + harden session writes (salvage #47662)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/openviking/__init__.py","tests/openviking_plugin/test_openviking.py","tests/plugins/memory/test_openviking_provider.py"],"fix_hunks":[{"file":"plugins/memory/openviking/__init__.py","hunks":["@@ -31,10 +31,11 @@\n import os\n import tempfile\n import threading\n+import time\n import uuid\n import zipfile\n from pathlib import Path\n-from typing import Any, Dict, List, Optional\n+from typing import Any, Callable, Dict, List, Optional, Set\n from urllib.parse import urlparse\n from urllib.request import url2pathname\n ","@@ -46,6 +47,8 @@\n \n _DEFAULT_ENDPOINT = \"http://127.0.0.1:1933\"\n _TIMEOUT = 30.0\n+_SESSION_DRAIN_TIMEOUT = 10.0\n+_DEFERRED_COMMIT_TIMEOUT = (_TIMEOUT * 2) + 5.0\n _REMOTE_RESOURCE_PREFIXES = (\"http://\", \"https://\", \"git@\", \"ssh://\", \"git://\")\n \n # Maps the viking_remember `category` enum to a viking:// subdirectory.","@@ -432,10 +435,35 @@ def __init__(self):\n         self._api_key = \"\"\n         self._session_id = \"\"\n         self._turn_count = 0\n-        self._sync_thread: Optional[threading.Thread] = None\n+        # Guards the (_session_id, _turn_count) pair. sync_turn runs on the\n+        # MemoryManager's background sync executor while on_session_end /\n+        # on_session_switch run on the caller's thread, so the snapshot+reset\n+        # of the turn counter and the session-id rotation must be atomic\n+        # against a concurrent increment. See hermes-agent#28296 review.\n+        self._session_state_lock = threading.Lock()\n+        # Commit only after session writes drain. The set is keyed by the sid\n+        # the writer is POSTing under (snapshotted at spawn), so on_session_end\n+        # / on_session_switch see every still-alive writer for that sid even\n+        # if later writes have replaced the latest-tracked thread.\n+        self._inflight_writers: Dict[str, Set[threading.Thread]] = {}\n+        self._inflight_lock = threading.Lock()\n+        self._deferred_commit_sids: Set[str] = set()\n+        self._deferred_commit_threads: Set[threading.Thread] = set()\n+        self._deferred_commit_lock = threading.Lock()\n+        self._committed_session_ids: Set[str] = set()\n+        self._committed_session_lock = threading.Lock()\n         self._prefetch_result = \"\"\n         self._prefetch_lock = threading.Lock()\n         self._prefetch_thread: Optional[threading.Thread] = None\n+        # All prefetch threads ever spawned (daemon, short-lived). Tracked so\n+        # shutdown() can drain them and rapid re-queues don't orphan a still-\n+        # running thread by overwriting the single _prefetch_thread slot.\n+        self._prefetch_threads: Set[threading.Thread] = set()\n+        # Set on shutdown so deferred-commit / writer finalizers stop issuing\n+        # network writes against a torn-down provider.\n+        self._shutting_down = False\n+        # Drop prefetch results from older switch generations.\n+        self._prefetch_generation = 0\n \n     @property\n     def name(self) -> str:","@@ -549,6 +577,12 @@ def queue_prefetch(self, query: str, *, session_id: str = \"\") -> None:\n         if not self._client or not query:\n             return\n \n+        # Drop prefetch results from older switch generations.\n+        with self._prefetch_lock:\n+            gen = self._prefetch_generation\n+\n+        holder: List[threading.Thread] = []\n+\n         def _run():\n             try:\n                 client = _VikingClient(","@@ -557,7 +591,7 @@ def _run():\n                 )\n                 resp = client.post(\"/api/v1/search/find\", {\n                     \"query\": query,\n-                    \"top_k\": 5,\n+                    \"limit\": 5,\n                 })\n                 result = resp.get(\"result\", {})\n                 parts = []","@@ -571,14 +605,237 @@ def _run():\n                             parts.append(f\"- [{score:.2f}] {abstract} ({uri})\")\n                 if parts:\n                     with self._prefetch_lock:\n+                        if gen != self._prefetch_generation:\n+                            return\n                         self._prefetch_result = \"\\n\".join(parts)\n             except Exception as e:\n                 logger.debug(\"OpenViking prefetch failed: %s\", e)\n+            finally:\n+                with self._prefetch_lock:\n+                    if holder:\n+                        self._prefetch_threads.discard(holder[0])\n \n-        self._prefetch_thread = threading.Thread(\n+        thread = threading.Thread(\n             target=_run, daemon=True, name=\"openviking-prefetch\"\n         )\n-        self._prefetch_thread.start()\n+        holder.append(thread)\n+        with self._prefetch_lock:\n+            self._prefetch_thread = thread\n+            self._prefetch_threads.add(thread)\n+        thread.start()\n+\n+    def _spawn_writer(self, sid: str, target: Callable[[], None], name: str) -> None:\n+        \"\"\"Spawn a daemon writer tracked in _inflight_writers[sid].\n+\n+        Tracking is keyed by sid (not by a single latest-thread slot) so that\n+        on_session_end / on_session_switch can drain every still-alive writer\n+        for the session being committed.\n+        \"\"\"\n+        holder: List[threading.Thread] = []\n+\n+        def _wrapped():\n+            try:\n+                target()\n+            finally:\n+                with self._inflight_lock:\n+                    workers = self._inflight_writers.get(sid)\n+                    if workers is not None:\n+                        workers.discard(holder[0])\n+                        if not workers:\n+                            self._inflight_writers.pop(sid, None)\n+\n+        thread = threading.Thread(target=_wrapped, daemon=True, name=name)\n+        holder.append(thread)\n+        with self._inflight_lock:\n+            self._inflight_writers.setdefault(sid, set()).add(thread)\n+        thread.start()\n+\n+    def _drain_finalizers(self, timeout: float) -> bool:\n+        \"\"\"Join every in-flight async session finalizer within a timeout.\n+\n+        The switch-path commit runs on a daemon finalizer thread so it never\n+        blocks the caller's command thread; this lets shutdown and tests wait\n+        for those commits deterministically. Returns True if all drained.\n+        \"\"\"\n+        deadline = time.monotonic() + timeout\n+        while True:\n+            with self._deferred_commit_lock:\n+                workers = [t for t in self._deferred_commit_threads if t.is_alive()]\n+            if not workers:\n+                return True\n+            remaining = deadline - time.monotonic()\n+            if remaining <= 0:\n+                return False\n+            for t in workers:\n+                slice_left = deadline - time.monotonic()\n+                if slice_left <= 0:\n+                    break\n+                # Floor the per-join wait so a thread whose join() returns\n+                # instantly while still reporting alive can't hot-spin this loop.\n+                t.join(timeout=min(slice_left, 0.05))\n+\n+    def _drain_writers(self, sid: str, timeout: float) -> bool:\n+        \"\"\"Join every in-flight writer for sid within a shared timeout budget.\n+\n+        Returns True if all writers drained, False if any are still alive when\n+        the budget runs out. Callers use the False return to skip the commit.\n+        \"\"\"\n+        if not sid:\n+            return True\n+        deadline = time.monotonic() + timeout\n+        while True:\n+            with self._inflight_lock:\n+                workers = [t for t in self._inflight_writers.get(sid, ()) if t.is_alive()]\n+            if not workers:\n+                return True\n+            remaining = deadline - time.monotonic()\n+            if remaining <= 0:\n+                return False\n+            for t in workers:\n+                slice_left = deadline - time.monotonic()\n+                if slice_left <= 0:\n+                    break\n+                t.join(timeout=slice_left)\n+\n+    def _new_client(self) -> _VikingClient:\n+        return _VikingClient(\n+            self._endpoint,\n+            self._api_key,\n+            account=self._account,\n+            user=self._user,\n+            agent=self._agent,\n+        )\n+\n+    @staticmethod\n+    def _text_part(content: str) -> Dict[str, str]:\n+        return {\"type\": \"text\", \"text\": content}\n+\n+    @classmethod\n+    def _turn_batch_payload(cls, user_content: str, assistant_content: str) -> Dict[str, Any]:\n+        return {\n+            \"messages\": [\n+                {\"role\": \"user\", \"parts\": [cls._text_part(user_content)]},\n+                {\"role\": \"assistant\", \"parts\": [cls._text_part(assistant_content)]},\n+            ]\n+        }\n+\n+    @classmethod\n+    def _post_session_turn(\n+        cls,\n+        client: _VikingClient,\n+        sid: str,\n+        user_content: str,\n+        assistant_content: str,\n+    ) -> None:\n+        client.post(\n+            f\"/api/v1/sessions/{sid}/messages/batch\",\n+            cls._turn_batch_payload(user_content, assistant_content),\n+        )\n+\n+    def _session_has_pending_tokens(self, sid: str) -> bool:\n+        try:\n+            response = self._client.get(f\"/api/v1/sessions/{sid}\")\n+        except Exception:\n+            return False\n+        session = self._unwrap_result(response)\n+        if not isinstance(session, dict):\n+            return False\n+        try:\n+            return int(session.get(\"pending_tokens\") or 0) > 0\n+        except (TypeError, ValueError):\n+            return False\n+\n+    def _has_committed_session(self, sid: str) -> bool:\n+        with self._committed_session_lock:\n+            return sid in self._committed_session_ids\n+\n+    def _mark_session_committed(self, sid: str) -> None:\n+        with self._committed_session_lock:\n+            self._committed_session_ids.add(sid)\n+\n+    def _session_needs_commit(self, sid: str, turn_count: int) -> bool:\n+        # Already-committed sessions never need a second commit, regardless of\n+        # the turn counter — a racing sync_turn can re-increment _turn_count\n+        # after a commit+reset, so the committed-guard must win over turn_count.\n+        if self._has_committed_session(sid):\n+            return False\n+        if turn_count > 0:\n+            return True\n+        return self._session_has_pending_tokens(sid)\n+\n+    def _commit_session(self, sid: str, turn_count: int, *, context: str) -> bool:\n+        try:\n+            self._client.post(f\"/api/v1/sessions/{sid}/commit\")\n+            self._mark_session_committed(sid)\n+            logger.info(\"OpenViking session %s committed %s (%d turns)\", sid, context, turn_count)\n+            return True\n+        except Exception as e:\n+            logger.warning(\"OpenViking session commit failed for %s: %s\", sid, e)\n+            return False\n+\n+    def _finalize_session_async(self, sid: str, turn_count: int, *, context: str) -> None:\n+        \"\"\"Drain the old session's writers and commit it on a daemon thread.\n+\n+        Used by on_session_switch (and the deferred-commit fallback) so the\n+        potentially-multi-second drain + pending-token GET + commit POST never\n+        runs on the caller's command thread. Deduped by sid so a rapid second\n+        switch can't stack two finalizers for the same session, and a no-op\n+        once shutdown has begun so we don't POST against a torn-down client.\n+        \"\"\"\n+        if not sid:\n+            return\n+        with self._deferred_commit_lock:\n+            if self._shutting_down or sid in self._deferred_commit_sids:\n+                return\n+            self._deferred_commit_sids.add(sid)\n+\n+        holder: List[threading.Thread] = []\n+\n+        def _finalize() -> None:\n+            try:\n+                if self._shutting_down:\n+                    return\n+                if not self._drain_writers(sid, timeout=_DEFERRED_COMMIT_TIMEOUT):\n+                    logger.warning(\n+                        \"OpenViking writer for %s still alive after drain — \"\n+                        \"leaving session uncommitted\",\n+                        sid,\n+                    )\n+                    return\n+                if self._shutting_down:\n+                    return\n+                if self._session_needs_commit(sid, turn_count):\n+                    self._commit_session(sid, turn_count, context=context)\n+            finally:\n+                with self._deferred_commit_lock:\n+                    self._deferred_commit_sids.discard(sid)\n+                    if holder:\n+                        self._deferred_commit_threads.discard(holder[0])\n+\n+        thread = threading.Thread(\n+            target=_finalize,\n+            daemon=True,\n+            name=f\"openviking-finalize-{sid}\",\n+        )\n+        holder.append(thread)\n+        with self._deferred_commit_lock:\n+            self._deferred_commit_threads.add(thread)\n+        thread.start()\n+\n+    def _invalidate_prefetch_state(self) -> None:\n+        # Bump the generation under the same lock used by prefetch workers so\n+        # late results from an older session are discarded deterministically.\n+        with self._prefetch_lock:\n+            self._prefetch_generation += 1\n+            self._prefetch_result = \"\"\n+            # Join EVERY tracked prefetch thread, not just the latest slot — a\n+            # rapid re-queue can leave an older thread for the abandoned session\n+            # still running (consistent with shutdown()).\n+            workers = [t for t in self._prefetch_threads if t.is_alive()]\n+        for t in workers:\n+            t.join(timeout=3.0)\n+        with self._prefetch_lock:\n+            self._prefetch_result = \"\"\n \n     def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = \"\") -> None:\n         \"\"\"Record the conversation turn in OpenViking's session (non-blocking).\"\"\"","@@ -589,37 +846,39 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n         if not user_content:\n             return\n \n-        self._turn_count += 1\n+        # Snapshot the sid and bump the turn counter atomically so a\n+        # concurrent on_session_switch/on_session_end can't interleave its\n+        # snapshot+reset between the read and the increment (lost turn) and so\n+        # the turn is unambiguously attributed to the session it targets.\n+        with self._session_state_lock:\n+            sid = str(session_id or self._session_id).strip()\n+            if not sid:\n+                return\n+            self._turn_count += 1\n \n         def _sync():\n             try:\n-                client = _VikingClient(\n-                    self._endpoint, self._api_key,\n-                    account=self._account, user=self._user, agent=self._agent,\n+                client = self._new_client()\n+                self._post_session_turn(\n+                    client,\n+                    sid,\n+                    user_content[:4000],\n+                    assistant_content[:4000],\n                 )\n-                sid = self._session_id\n-\n-                # Add user message\n-                client.post(f\"/api/v1/sessions/{sid}/messages\", {\n-                    \"role\": \"user\",\n-                    \"content\": user_content[:4000],  # trim very long messages\n-                })\n-                # Add assistant message\n-                client.post(f\"/api/v1/sessions/{sid}/messages\", {\n-                    \"role\": \"assistant\",\n-                    \"content\": assistant_content[:4000],\n-                })\n             except Exception as e:\n-                logger.debug(\"OpenViking sync_turn failed: %s\", e)\n-\n-        # Wait for any previous sync to finish before starting a new one\n-        if self._sync_thread and self._sync_thread.is_alive():\n-            self._sync_thread.join(timeout=5.0)\n-\n-        self._sync_thread = threading.Thread(\n-            target=_sync, daemon=True, name=\"openviking-sync\"\n-        )\n-        self._sync_thread.start()\n+                logger.debug(\"OpenViking sync_turn failed, reconnecting: %s\", e)\n+                try:\n+                    client = self._new_client()\n+                    self._post_session_turn(\n+                        client,\n+                        sid,\n+                        user_content[:4000],\n+                        assistant_content[:4000],\n+                    )\n+                except Exception as retry_error:\n+                    logger.warning(\"OpenViking sync_turn failed: %s\", retry_error)\n+\n+        self._spawn_writer(sid, _sync, name=\"openviking-sync\")\n \n     def on_session_end(self, messages: List[Dict[str, Any]]) -> None:\n         \"\"\"Commit the session to trigger memory extraction.","@@ -630,20 +889,98 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:\n         if not self._client:\n             return\n \n-        # Wait for any pending sync to finish first — do this before the\n-        # turn_count check so the last turn's messages are flushed even if\n-        # the count hasn't been incremented yet.\n-        if self._sync_thread and self._sync_thread.is_alive():\n-            self._sync_thread.join(timeout=10.0)\n+        # Snapshot sid + turn count atomically against a concurrent sync_turn\n+        # increment. on_session_end runs at teardown so the drain+commit stays\n+        # synchronous here (we want it to land before the process exits), but\n+        # the counter read must still be consistent.\n+        with self._session_state_lock:\n+            sid = self._session_id\n+            turn_count = self._turn_count\n+\n+        # Commit only after session writes drain.\n+        if not self._drain_writers(sid, timeout=_SESSION_DRAIN_TIMEOUT):\n+            logger.warning(\n+                \"OpenViking writer for %s still alive after drain — skipping commit\",\n+                sid,\n+            )\n+            return\n \n-        if self._turn_count == 0:\n+        if not self._session_needs_commit(sid, turn_count):\n             return\n \n-        try:\n-            self._client.post(f\"/api/v1/sessions/{self._session_id}/commit\")\n-            logger.info(\"OpenViking session %s committed (%d turns)\", self._session_id, self._turn_count)\n-        except Exception as e:\n-            logger.warning(\"OpenViking session commit failed: %s\", e)\n+        if self._commit_session(sid, turn_count, context=\"on session end\"):\n+            # Mark clean so a follow-up on_session_switch skips its own commit.\n+            with self._session_state_lock:\n+                if self._session_id == sid:\n+                    self._turn_count = 0\n+\n+    def on_session_switch(\n+        self,\n+        new_session_id: str,\n+        *,\n+        parent_session_id: str = \"\",\n+        reset: bool = False,\n+        **kwargs,\n+    ) -> None:\n+        \"\"\"Commit the old session and rotate cached state to the new session_id.\n+\n+        Fires on /resume, /branch, /reset, /new, and context compression.\n+        Without this hook, ``_session_id`` stays stuck at the value\n+        ``initialize()`` cached, so subsequent ``sync_turn()`` writes land in\n+        the already-closed old session and ``on_session_end()`` tries to\n+        commit it a second time. The new session never accumulates messages,\n+        and memory extraction never fires for it. See hermes-agent#28296.\n+\n+        Flushes any in-flight sync under the old session_id, commits the old\n+        session if it has pending turns (same extraction semantics as\n+        ``on_session_end``), drains and clears any stale prefetch result,\n+        then rotates ``_session_id`` and resets ``_turn_count``.\n+        \"\"\"\n+        new_id = str(new_session_id or \"\").strip()\n+        if not new_id or not self._client:\n+            return\n+\n+        rewound = bool(kwargs.get(\"rewound\"))\n+\n+        # Rotate cached session state synchronously (cheap, in-memory) and\n+        # snapshot the old session under the lock so a concurrent sync_turn\n+        # either lands fully before the rotation (counted under old) or fully\n+        # after (counted under new) — never split. The OLD session's commit\n+        # (drain + pending-token GET + commit POST, potentially many seconds)\n+        # is then offloaded so /new, /branch, /resume, /undo never block the\n+        # caller's command thread (cf. the end-of-turn-sync offload in #41945).\n+        with self._session_state_lock:\n+            old_session_id = self._session_id\n+            old_turn_count = self._turn_count\n+            rotate = not (rewound or new_id == old_session_id)\n+            if rotate:\n+                self._session_id = new_id\n+                self._turn_count = 0\n+\n+        # Invalidate stale prefetch OUTSIDE the session lock — it takes its own\n+        # _prefetch_lock and may join a prefetch thread for up to 3s, which we\n+        # must not do while holding the session lock (would block sync_turn and\n+        # risk lock-ordering coupling).\n+        self._invalidate_prefetch_state()\n+\n+        if not rotate:\n+            # Same-session rewind (/undo) or no-op rotation: no commit, no\n+            # counter reset — just the prefetch invalidation above.\n+            logger.debug(\n+                \"OpenViking on_session_switch invalidated state without rotation: \"\n+                \"session=%s rewound=%s\",\n+                old_session_id, rewound,\n+            )\n+            return\n+\n+        # Drain + commit the OLD session off the command thread.\n+        if old_session_id:\n+            self._finalize_session_async(old_session_id, old_turn_count, context=\"on switch\")\n+\n+        logger.debug(\n+            \"OpenViking on_session_switch: old=%s new=%s parent=%s reset=%s\",\n+            old_session_id, new_id, parent_session_id, reset,\n+        )\n \n     def _build_memory_uri(self, subdir: str) -> str:\n         \"\"\"Build a viking:// memory URI under the configured user/agent/subdir.\"\"\"","@@ -704,11 +1041,28 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:\n             return tool_error(str(e))\n \n     def shutdown(self) -> None:\n-        # Wait for background threads to finish\n-        for t in (self._sync_thread, self._prefetch_thread):\n-            if t and t.is_alive():\n+        # Stop deferred finalizers from issuing new commits against a\n+        # torn-down client, then drain everything still in flight.\n+        self._shutting_down = True\n+        # Wait for every in-flight writer across all tracked sessions.\n+        with self._inflight_lock:\n+            all_workers = [\n+                t for workers in self._inflight_writers.values() for t in workers\n+            ]\n+        with self._deferred_commit_lock:\n+            deferred_workers = list(self._deferred_commit_threads)\n+        with self._prefetch_lock:\n+            prefetch_workers = list(self._prefetch_threads)\n+        for t in all_workers:\n+            if t.is_alive():\n+                t.join(timeout=5.0)\n+        for t in deferred_workers:\n+            if t.is_alive():\n+                t.join(timeout=5.0)\n+        for t in prefetch_workers:\n+            if t.is_alive():\n                 t.join(timeout=5.0)\n-        # Clear atexit reference so it doesn't double-commit\n+        # Clear atexit reference so it doesn't double-commit.\n         global _last_active_provider\n         if _last_active_provider is self:\n             _last_active_provider = None","@@ -767,7 +1121,7 @@ def _tool_search(self, args: dict) -> str:\n         if args.get(\"scope\"):\n             payload[\"target_uri\"] = args[\"scope\"]\n         if args.get(\"limit\"):\n-            payload[\"top_k\"] = args[\"limit\"]\n+            payload[\"limit\"] = args[\"limit\"]\n \n         resp = self._client.post(\"/api/v1/search/find\", payload)\n         result = resp.get(\"result\", {})"]},{"file":"tests/openviking_plugin/test_openviking.py","hunks":["@@ -150,7 +150,7 @@ def test_queue_prefetch_searches_only_slash_skill_user_instruction(self, monkeyp\n         assert RecordingVikingClient.calls == [\n             (\n                 \"/api/v1/search/find\",\n-                {\"query\": \"make a skill for release triage\", \"top_k\": 5},\n+                {\"query\": \"make a skill for release triage\", \"limit\": 5},\n             )\n         ]\n ","@@ -181,7 +181,7 @@ def test_queue_prefetch_searches_only_skill_bundle_user_instruction(self, monkey\n         assert RecordingVikingClient.calls == [\n             (\n                 \"/api/v1/search/find\",\n-                {\"query\": \"fix the failing retrieval test\", \"top_k\": 5},\n+                {\"query\": \"fix the failing retrieval test\", \"limit\": 5},\n             )\n         ]\n ","@@ -223,17 +223,25 @@ def test_sync_turn_stores_only_slash_skill_user_instruction(self, monkeypatch):\n         )\n \n         provider.sync_turn(skill_message, \"Done.\")\n-        assert provider._sync_thread is not None\n-        provider._sync_thread.join(timeout=5.0)\n+        assert provider._drain_writers(\"session-1\", timeout=5.0)\n \n         assert RecordingVikingClient.calls == [\n             (\n-                \"/api/v1/sessions/session-1/messages\",\n-                {\"role\": \"user\", \"content\": \"make a skill for release triage\"},\n-            ),\n-            (\n-                \"/api/v1/sessions/session-1/messages\",\n-                {\"role\": \"assistant\", \"content\": \"Done.\"},\n+                \"/api/v1/sessions/session-1/messages/batch\",\n+                {\n+                    \"messages\": [\n+                        {\n+                            \"role\": \"user\",\n+                            \"parts\": [\n+                                {\"type\": \"text\", \"text\": \"make a skill for release triage\"},\n+                            ],\n+                        },\n+                        {\n+                            \"role\": \"assistant\",\n+                            \"parts\": [{\"type\": \"text\", \"text\": \"Done.\"}],\n+                        },\n+                    ]\n+                },\n             ),\n         ]\n ","@@ -251,7 +259,8 @@ def test_sync_turn_skips_slash_skill_without_user_instruction(self, monkeypatch)\n \n         provider.sync_turn(skill_message, \"Done.\")\n \n-        assert provider._sync_thread is None\n+        assert provider._turn_count == 0\n+        assert provider._inflight_writers == {}\n         assert RecordingVikingClient.calls == []\n \n "]},{"file":"tests/plugins/memory/test_openviking_provider.py","hunks":["@@ -5,7 +5,16 @@\n \n import pytest\n \n-from plugins.memory.openviking import OpenVikingMemoryProvider, _VikingClient\n+from plugins.memory.openviking import (\n+    OpenVikingMemoryProvider,\n+    _DEFERRED_COMMIT_TIMEOUT,\n+    _VikingClient,\n+)\n+\n+\n+def _clear_openviking_tenant_env(monkeypatch):\n+    for name in (\"OPENVIKING_ACCOUNT\", \"OPENVIKING_USER\", \"OPENVIKING_AGENT\"):\n+        monkeypatch.delenv(name, raising=False)\n \n \n def test_tool_search_sorts_by_raw_score_across_buckets():","@@ -66,6 +75,21 @@ def test_tool_search_sorts_missing_raw_score_after_negative_scores():\n     assert result[\"total\"] == 3\n \n \n+def test_tool_search_sends_limit_not_legacy_top_k():\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._client.post.return_value = {\n+        \"result\": {\"memories\": [], \"resources\": [], \"skills\": [], \"total\": 0}\n+    }\n+\n+    provider._tool_search({\"query\": \"session switch\", \"limit\": 7})\n+\n+    provider._client.post.assert_called_once()\n+    payload = provider._client.post.call_args.args[1]\n+    assert payload[\"limit\"] == 7\n+    assert \"top_k\" not in payload\n+\n+\n def test_tool_add_resource_uploads_existing_local_file(tmp_path):\n     sample = tmp_path / \"sample.md\"\n     sample.write_text(\"# Local resource\\n\", encoding=\"utf-8\")","@@ -371,7 +395,8 @@ def test_viking_client_headers_send_tenant_when_default():\n     assert headers[\"Authorization\"] == \"Bearer test-key\"\n \n \n-def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default():\n+def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monkeypatch):\n+    _clear_openviking_tenant_env(monkeypatch)\n     # Empty account/user strings fall back to \"default\" via the constructor.\n     # Headers are sent even for the default value — ROOT API keys need them.\n     client = _VikingClient(","@@ -402,6 +427,7 @@ def test_viking_client_headers_sent_with_real_tenant_values():\n \n \n def test_viking_client_health_sends_auth_headers(monkeypatch):\n+    _clear_openviking_tenant_env(monkeypatch)\n     client = _VikingClient(\n         \"https://example.com\",\n         api_key=\"[redacted:secret-assignment]\",","@@ -420,3 +446,676 @@ def capture_get(url, **kwargs):\n     assert client.health() is True\n     assert captured[\"url\"] == \"https://example.com/health\"\n     assert captured[\"headers\"][\"Authorization\"] == \"Bearer test-key\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# on_session_switch — flush + commit + rotate behavior (hermes-agent#28296)\n+# ---------------------------------------------------------------------------\n+\n+def _make_provider_with_session(session_id: str, turn_count: int):\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._session_id = session_id\n+    provider._turn_count = turn_count\n+    return provider\n+\n+\n+def test_on_session_switch_commits_old_session_and_rotates_id():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=3)\n+\n+    provider.on_session_switch(\"new-sid\", parent_session_id=\"old-sid\")\n+\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_on_session_switch_skips_commit_for_empty_old_session():\n+    \"\"\"No turns accumulated → nothing to extract → no commit call.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=0)\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_on_session_switch_commits_pending_tokens_without_turn_count():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=0)\n+    provider._client.get.return_value = {\"result\": {\"pending_tokens\": 42}}\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    provider._client.get.assert_called_once_with(\"/api/v1/sessions/old-sid\")\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_on_session_switch_rewound_same_session_only_invalidates_prefetch():\n+    provider = _make_provider_with_session(\"same-sid\", turn_count=3)\n+    provider._prefetch_generation = 9\n+    provider._prefetch_result = \"stale recall\"\n+\n+    provider.on_session_switch(\"same-sid\", rewound=True)\n+\n+    provider._client.get.assert_not_called()\n+    provider._client.post.assert_not_called()\n+    assert provider._session_id == \"same-sid\"\n+    assert provider._turn_count == 3\n+    assert provider._prefetch_generation == 10\n+    assert provider._prefetch_result == \"\"\n+\n+\n+def test_on_session_switch_clears_stale_prefetch_result():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=1)\n+    provider._prefetch_result = \"stale recall from old session\"\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    assert provider._prefetch_result == \"\"\n+\n+\n+def test_on_session_switch_waits_for_inflight_sync_thread():\n+    \"\"\"In-flight sync_turn write must drain before the commit fires —\n+    otherwise the commit can race the last message write.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+\n+    join_calls = []\n+\n+    class FakeThread:\n+        def __init__(self):\n+            self._alive = True\n+\n+        def is_alive(self):\n+            return self._alive\n+\n+        def join(self, timeout=None):\n+            join_calls.append(timeout)\n+            # Simulate a worker that finishes within the join window.\n+            self._alive = False\n+\n+    provider._inflight_writers[\"old-sid\"] = {FakeThread()}\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    assert join_calls, \"expected on_session_switch to join the in-flight sync thread\"\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+\n+\n+def test_on_session_switch_noop_on_empty_new_id():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=5)\n+\n+    provider.on_session_switch(\"\")\n+    provider.on_session_switch(\"   \")\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._session_id == \"old-sid\"\n+    assert provider._turn_count == 5\n+\n+\n+def test_on_session_switch_noop_when_client_missing():\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = None\n+    provider._session_id = \"old-sid\"\n+    provider._turn_count = 4\n+\n+    # Must not raise even though no client is configured.\n+    provider.on_session_switch(\"new-sid\")\n+\n+    # State stays untouched — provider is effectively disabled.\n+    assert provider._session_id == \"old-sid\"\n+    assert provider._turn_count == 4\n+\n+\n+def test_sync_turn_captures_session_id_before_worker_runs():\n+    \"\"\"Worker must use the session id snapshotted at sync_turn() call time, not\n+    re-read self._session_id later — otherwise a delayed worker can write the\n+    previous turn's messages into the rotated-in NEW session.\"\"\"\n+    import threading\n+\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    provider._session_id = \"old-sid\"\n+\n+    started = threading.Event()\n+    release = threading.Event()\n+    captured_paths = []\n+    captured_payloads = []\n+\n+    def fake_post(path, payload=None, **kwargs):\n+        started.set()\n+        release.wait(timeout=2.0)\n+        captured_paths.append(path)\n+        captured_payloads.append(payload)\n+        return {}\n+\n+    # Patch _VikingClient inside the worker by stubbing post on a client\n+    # the constructor will produce. Easiest path: monkeypatch the class.\n+    real_client_cls = _VikingClient\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            pass\n+\n+        def post(self, path, payload=None, **kwargs):\n+            return fake_post(path, payload, **kwargs)\n+\n+    import plugins.memory.openviking as _mod\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.sync_turn(\"u\", \"a\")\n+        # Wait until the worker is parked inside the first post call.\n+        assert started.wait(timeout=2.0), \"worker never entered post()\"\n+        # Rotate the provider's session id while the worker is mid-flight.\n+        provider._session_id = \"new-sid\"\n+        release.set()\n+        for t in list(provider._inflight_writers.get(\"old-sid\", set())):\n+            t.join(timeout=2.0)\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    # The whole turn must target the OLD session id as a single ordered batch.\n+    assert captured_paths == [\"/api/v1/sessions/old-sid/messages/batch\"]\n+    assert captured_payloads == [{\n+        \"messages\": [\n+            {\"role\": \"user\", \"parts\": [{\"type\": \"text\", \"text\": \"u\"}]},\n+            {\"role\": \"assistant\", \"parts\": [{\"type\": \"text\", \"text\": \"a\"}]},\n+        ]\n+    }]\n+\n+\n+def test_sync_turn_retries_batch_write_with_fresh_client():\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    provider._session_id = \"sid-1\"\n+\n+    clients = []\n+    captured = []\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            self.index = len(clients)\n+            clients.append(self)\n+\n+        def post(self, path, payload=None, **kwargs):\n+            if self.index == 0:\n+                raise RuntimeError(\"transient\")\n+            captured.append((path, payload))\n+            return {}\n+\n+    import plugins.memory.openviking as _mod\n+    real_client_cls = _mod._VikingClient\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.sync_turn(\"u\", \"a\")\n+        assert provider._drain_writers(\"sid-1\", timeout=2.0)\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    assert len(clients) == 2\n+    assert captured == [(\n+        \"/api/v1/sessions/sid-1/messages/batch\",\n+        {\n+            \"messages\": [\n+                {\"role\": \"user\", \"parts\": [{\"type\": \"text\", \"text\": \"u\"}]},\n+                {\"role\": \"assistant\", \"parts\": [{\"type\": \"text\", \"text\": \"a\"}]},\n+            ]\n+        },\n+    )]\n+\n+\n+def test_sync_turn_noop_when_session_id_blank():\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._session_id = \"\"\n+\n+    provider.sync_turn(\"u\", \"a\")\n+\n+    # No turn counted, no worker spawned.\n+    assert provider._turn_count == 0\n+    assert provider._inflight_writers == {}\n+\n+\n+def test_on_session_end_marks_session_clean_after_successful_commit():\n+    \"\"\"After a successful commit on_session_end must reset _turn_count so a\n+    subsequent on_session_switch (fired by /new and compression right after\n+    commit_memory_session) skips its commit instead of double-committing.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=3)\n+\n+    provider.on_session_end([])\n+\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    assert provider._turn_count == 0\n+\n+\n+def test_on_session_end_keeps_dirty_when_commit_fails():\n+    \"\"\"If the commit fails, leave _turn_count > 0 so on_session_switch retries\n+    rather than silently dropping extraction for the old session.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=3)\n+    provider._client.post.side_effect = RuntimeError(\"commit boom\")\n+\n+    provider.on_session_end([])\n+\n+    assert provider._turn_count == 3\n+\n+\n+def test_on_session_end_commits_pending_tokens_without_turn_count():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=0)\n+    provider._client.get.return_value = {\"result\": {\"pending_tokens\": 42}}\n+\n+    provider.on_session_end([])\n+\n+    provider._client.get.assert_called_once_with(\"/api/v1/sessions/old-sid\")\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+\n+\n+def test_end_then_switch_does_not_double_commit():\n+    \"\"\"Mirrors the /new and compression call order: commit_memory_session\n+    (→ on_session_end) immediately followed by on_session_switch. The switch\n+    must NOT issue a second commit on the same session id.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+\n+    provider.on_session_end([])\n+    provider.on_session_switch(\"new-sid\", parent_session_id=\"old-sid\")\n+\n+    # Exactly one commit call, on the OLD session, fired by on_session_end.\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_end_then_switch_with_pending_tokens_does_not_double_commit():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=0)\n+    provider._client.get.return_value = {\"result\": {\"pending_tokens\": 42}}\n+\n+    provider.on_session_end([])\n+    provider.on_session_switch(\"new-sid\", parent_session_id=\"old-sid\")\n+\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_session_needs_commit_guard_wins_over_stale_turn_count():\n+    \"\"\"Regression for hermes-agent#28296 review (M3): once a session is marked\n+    committed, _session_needs_commit must return False even if turn_count is\n+    still positive. A racing sync_turn can re-increment _turn_count after the\n+    commit+reset; without the guard ordering, a follow-up finalizer would\n+    double-commit the same session. The committed-guard must be checked BEFORE\n+    the turn_count>0 shortcut.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=5)\n+    provider._mark_session_committed(\"old-sid\")\n+\n+    # turn_count is a (stale) 5 but the session is already committed.\n+    assert provider._session_needs_commit(\"old-sid\", 5) is False\n+    # An uncommitted session with turns still needs a commit.\n+    assert provider._session_needs_commit(\"fresh-sid\", 5) is True\n+\n+\n+def test_on_session_switch_swallows_commit_failure():\n+    \"\"\"Commit-on-switch must not propagate exceptions: a failing commit on the\n+    old session must still allow the rotate to the new session to complete,\n+    otherwise subsequent sync_turn writes would land in the wrong session.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+    provider._client.post.side_effect = RuntimeError(\"commit boom\")\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Hung-writer protection: the sync worker can outlive the bounded join\n+# because each OpenViking POST has _TIMEOUT=30s and there are two per turn.\n+# Committing while late writes are still in flight would orphan them past\n+# the commit boundary — they would never be extracted.\n+# ---------------------------------------------------------------------------\n+\n+class _HungThread:\n+    \"\"\"Thread stand-in that stays alive across joins.\"\"\"\n+\n+    def is_alive(self):\n+        return True\n+\n+    def join(self, timeout=None):\n+        # Pretend the join timed out — worker still running.\n+        return None\n+\n+\n+def test_on_session_end_skips_commit_when_sync_worker_outlives_join():\n+    \"\"\"If the sync worker is still alive after the 10s join, the commit must\n+    be skipped — late writes from the worker would otherwise land in an\n+    already-committed session and never be extracted. Leave _turn_count\n+    intact so the session stays marked dirty.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=3)\n+    provider._inflight_writers[\"old-sid\"] = {_HungThread()}\n+\n+    provider.on_session_end([])\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._turn_count == 3\n+\n+\n+def test_on_session_switch_skips_commit_when_sync_worker_outlives_join():\n+    \"\"\"Same hazard on the switch path. Rotation must still proceed (the new\n+    session needs to start) but the old-session commit is skipped to avoid\n+    orphaning the worker's late writes past commit.\"\"\"\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+    provider._inflight_writers[\"old-sid\"] = {_HungThread()}\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Orphaned-writer hazard: commit must wait for ALL writers for the session,\n+# not just the latest tracked one. sync_turn's bounded rate-limit can drop a\n+# still-alive previous worker — that dropped writer keeps POSTing under the\n+# old sid and would otherwise land its writes past the commit boundary.\n+# ---------------------------------------------------------------------------\n+\n+def test_on_session_end_waits_for_all_writers_not_just_latest():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+    provider._inflight_writers[\"old-sid\"] = {_HungThread()}\n+\n+    provider.on_session_end([])\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._turn_count == 2\n+\n+\n+def test_on_session_switch_waits_for_all_writers_not_just_latest():\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+    provider._inflight_writers[\"old-sid\"] = {_HungThread()}\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    provider._client.post.assert_not_called()\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+\n+\n+def test_on_session_switch_does_not_block_caller_on_slow_drain():\n+    \"\"\"Regression for hermes-agent#28296 review (H1): on_session_switch must\n+    NOT run the old-session drain/commit on the caller's thread. /new, /branch,\n+    /resume, /undo call this synchronously on the command thread, so a slow\n+    writer drain (up to _SESSION_DRAIN_TIMEOUT/_DEFERRED_COMMIT_TIMEOUT) or a\n+    wedged commit POST must not stall the user-facing command. The rotation is\n+    cheap and synchronous; the commit is offloaded. Mirrors the #41945\n+    'do not block the turn thread' contract.\"\"\"\n+    import threading\n+    import time\n+\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+\n+    drain_entered = threading.Event()\n+    release_drain = threading.Event()\n+\n+    def slow_drain(sid, timeout):\n+        drain_entered.set()\n+        # Simulate a writer that takes a long time to drain.\n+        release_drain.wait(timeout=10.0)\n+        return True\n+\n+    provider._drain_writers = slow_drain\n+\n+    start = time.monotonic()\n+    provider.on_session_switch(\"new-sid\")\n+    elapsed = time.monotonic() - start\n+\n+    # The caller returned promptly with state already rotated, even though the\n+    # drain is still parked on the finalizer thread.\n+    assert elapsed < 1.0, f\"on_session_switch blocked the caller for {elapsed:.2f}s\"\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+    assert drain_entered.wait(timeout=2.0), \"finalizer never started draining\"\n+    # No commit yet — drain is still blocked off-thread.\n+    provider._client.post.assert_not_called()\n+    # Let the finalizer finish so it doesn't leak past the test.\n+    release_drain.set()\n+    assert provider._drain_finalizers(timeout=5.0)\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+\n+\n+def test_on_session_switch_defers_old_commit_to_finalizer_thread():\n+    \"\"\"The switch path rotates session state synchronously (cheap, in-memory)\n+    but offloads the old-session drain + commit onto a daemon finalizer so the\n+    caller's command thread (/new, /branch, /resume) never blocks on the up-to\n+    -_DEFERRED_COMMIT_TIMEOUT drain or the commit POST. See hermes-agent#28296\n+    review (the #41945 'do not block the turn thread' contract).\"\"\"\n+    import threading\n+\n+    provider = _make_provider_with_session(\"old-sid\", turn_count=2)\n+    committed = threading.Event()\n+    drain_timeouts = []\n+\n+    def fake_post(path):\n+        committed.set()\n+        return {}\n+\n+    def fake_drain(sid, timeout):\n+        drain_timeouts.append(timeout)\n+        return True\n+\n+    provider._client.post.side_effect = fake_post\n+    provider._drain_writers = fake_drain\n+\n+    provider.on_session_switch(\"new-sid\")\n+\n+    # Rotation is synchronous and immediate — the new session is live at once.\n+    assert provider._session_id == \"new-sid\"\n+    assert provider._turn_count == 0\n+    # The old-session commit lands on the finalizer thread, not inline.\n+    assert committed.wait(timeout=5.0), \"old session was not finalized off-thread\"\n+    provider._client.post.assert_called_once_with(\"/api/v1/sessions/old-sid/commit\")\n+    # The finalizer drains with the deferred (longer) budget, not inline 10s.\n+    assert drain_timeouts == [_DEFERRED_COMMIT_TIMEOUT]\n+\n+\n+def test_sync_turn_tracks_writer_under_session_id():\n+    \"\"\"Every sync_turn writer must register under its captured sid so the\n+    drain at end/switch sees it even if a later sync_turn replaces the\n+    latest-tracked reference.\"\"\"\n+    import threading\n+\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    provider._session_id = \"sid-1\"\n+\n+    release = threading.Event()\n+    started = threading.Event()\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            pass\n+\n+        def post(self, path, payload=None, **kwargs):\n+            started.set()\n+            release.wait(timeout=2.0)\n+            return {}\n+\n+    import plugins.memory.openviking as _mod\n+    real_client_cls = _mod._VikingClient\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.sync_turn(\"u\", \"a\")\n+        assert started.wait(timeout=2.0), \"worker never entered post()\"\n+        assert len(provider._inflight_writers.get(\"sid-1\", set())) == 1\n+        release.set()\n+        for t in list(provider._inflight_writers.get(\"sid-1\", set())):\n+            t.join(timeout=2.0)\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    # Worker should have removed itself from the inflight set on exit.\n+    assert provider._inflight_writers.get(\"sid-1\", set()) == set()\n+\n+\n+# ---------------------------------------------------------------------------\n+# on_memory_write: explicit memory writes use content/write and stay outside\n+# the session transcript/commit boundary.\n+# ---------------------------------------------------------------------------\n+\n+def test_on_memory_write_uses_content_write_independent_of_session_rotation():\n+    import threading\n+\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    provider._session_id = \"old-sid\"\n+\n+    in_ctor = threading.Event()\n+    release = threading.Event()\n+    done = threading.Event()\n+    captured_paths = []\n+    captured_payloads = []\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            in_ctor.set()\n+            release.wait(timeout=2.0)\n+\n+        def post(self, path, payload=None, **kwargs):\n+            captured_paths.append(path)\n+            captured_payloads.append(payload)\n+            done.set()\n+            return {}\n+\n+    import plugins.memory.openviking as _mod\n+    real_client_cls = _mod._VikingClient\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.on_memory_write(\"add\", \"user\", \"remember this\")\n+        assert in_ctor.wait(timeout=2.0), \"worker never entered ctor\"\n+        # Rotate provider's session id while the worker is parked. Memory writes\n+        # must not become session messages in either the old or new session.\n+        provider._session_id = \"new-sid\"\n+        release.set()\n+        assert done.wait(timeout=2.0), \"worker never reached post()\"\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    assert captured_paths == [\"/api/v1/content/write\"]\n+    assert captured_payloads[0][\"content\"] == \"remember this\"\n+    assert captured_payloads[0][\"mode\"] == \"create\"\n+    assert captured_payloads[0][\"uri\"].startswith(\n+        \"viking://user/usr/agent/hermes/memories/preferences/mem_\"\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Prefetch staleness: a prefetch worker that finishes AFTER a session switch\n+# must drop its result instead of repopulating the new session with stale\n+# recall from the old generation. Bump the generation directly (rather than\n+# calling on_session_switch, whose own join blocks on the test worker) so\n+# the test isolates the generation-gating behavior.\n+# ---------------------------------------------------------------------------\n+\n+def test_queue_prefetch_drops_result_when_generation_changed_mid_flight():\n+    import threading\n+\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    provider._session_id = \"old-sid\"\n+\n+    started = threading.Event()\n+    release = threading.Event()\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            pass\n+\n+        def post(self, path, payload=None, **kwargs):\n+            started.set()\n+            release.wait(timeout=2.0)\n+            return {\n+                \"result\": {\n+                    \"memories\": [\n+                        {\"uri\": \"viking://memories/old\", \"score\": 0.9,\n+                         \"abstract\": \"stale from old session\"},\n+                    ],\n+                    \"resources\": [],\n+                }\n+            }\n+\n+    import plugins.memory.openviking as _mod\n+    real_client_cls = _mod._VikingClient\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.queue_prefetch(\"anything\")\n+        assert started.wait(timeout=2.0), \"prefetch worker never entered post()\"\n+        # Simulate a session switch by bumping the generation directly.\n+        # The worker captured the pre-bump generation when it was spawned.\n+        provider._prefetch_generation += 1\n+        release.set()\n+        if provider._prefetch_thread:\n+            provider._prefetch_thread.join(timeout=2.0)\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    # The stale result from the pre-bump generation must NOT have been written\n+    # into the new generation's prefetch slot.\n+    assert provider._prefetch_result == \"\"\n+\n+\n+def test_queue_prefetch_sends_limit_not_legacy_top_k():\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+\n+    captured_payloads = []\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            pass\n+\n+        def post(self, path, payload=None, **kwargs):\n+            captured_payloads.append(payload)\n+            return {\"result\": {\"memories\": [], \"resources\": []}}\n+\n+    import plugins.memory.openviking as _mod\n+    real_client_cls = _mod._VikingClient\n+    _mod._VikingClient = StubClient\n+    try:\n+        provider.queue_prefetch(\"anything\")\n+        if provider._prefetch_thread:\n+            provider._prefetch_thread.join(timeout=2.0)\n+    finally:\n+        _mod._VikingClient = real_client_cls\n+\n+    assert captured_payloads == [{\"query\": \"anything\", \"limit\": 5}]\n+    assert \"top_k\" not in captured_payloads[0]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}