{"repo":"NousResearch/hermes-agent","public_id":"32234","display_label":"CLI /new session memory extraction blocking fix","summary":"These issues and PRs address a problem where the /new command hangs the TUI/CLI because memory extraction at session boundaries runs synchronously on the command path. Proposed fixes involve moving the extraction off-thread and adding a timeout.","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":61139,"merged_at":"2026-07-08T21:51:57+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-08T21:51:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":32234,"pr_number":61139,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T21:51:57+00:00"}],"members":[{"kind":"issue","number":32234,"title":"/new hangs the TUI/CLI on long sessions — synchronous memory extraction has no timeout","url":null,"state":"closed"},{"kind":"pr","number":16454,"title":"fix: run CLI new-session memory flush off-thread","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61139","number":61139}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32293,"title":"fix(cli): add 15s timeout for memory extraction in /new","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d8bc4f242f6e127a330afedb2ecdf210f3946316","number":0},{"kind":"pr","value":"#61139","number":61139},{"kind":"pr","value":"#32234","number":32234}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61139,"title":"fix(cli): run /new session-boundary memory extraction off the command path (#16454 salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/memory_manager.py","cli.py","tests/agent/test_memory_boundary_commit.py","tests/cli/test_cli_new_session.py"],"fix_hunks":[{"file":"agent/memory_manager.py","hunks":["@@ -783,6 +783,55 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:\n                     exc_info=True,\n                 )\n \n+    def commit_session_boundary_async(\n+        self,\n+        messages: List[Dict[str, Any]],\n+        *,\n+        new_session_id: str,\n+        parent_session_id: str = \"\",\n+        reason: str = \"new_session\",\n+    ) -> None:\n+        \"\"\"Queue old-session extraction + provider rebinding as ONE serialized task.\n+\n+        Session rotation (/new) must deliver ``on_session_end`` (end-of-session\n+        extraction — an LLM-bound call that can take seconds) strictly BEFORE\n+        ``on_session_switch`` (which rebinds provider-internal ``_session_id`` /\n+        turn buffers to the new session). Running extraction inline blocked the\n+        /new command for the whole LLM round-trip (#16454); running it on an\n+        ad-hoc thread raced the inline switch — providers key off internal\n+        state, so a late ``on_session_end`` ran against post-switch bindings\n+        (transcript misattributed to the new session id, double-ingest of the\n+        old turn buffer, new-session buffers cleared).\n+\n+        Submitting BOTH hooks as one task on the manager's single background\n+        worker gives both properties at a single chokepoint: the caller returns\n+        immediately, and the worker's FIFO order serializes end→switch against\n+        every other provider write (per-turn ``sync_all``, prefetches), which\n+        already share the same worker. If the executor is unavailable,\n+        ``_submit_background`` degrades to inline execution — the pre-#16454\n+        synchronous behavior, slow but correct.\n+        \"\"\"\n+        if not self._providers:\n+            return\n+        snapshot = list(messages or [])\n+\n+        def _run() -> None:\n+            try:\n+                self.on_session_end(snapshot)\n+            except Exception as e:  # pragma: no cover - on_session_end guards per-provider\n+                logger.warning(\"Session-boundary extraction failed: %s\", e)\n+            try:\n+                self.on_session_switch(\n+                    new_session_id,\n+                    parent_session_id=parent_session_id,\n+                    reset=True,\n+                    reason=reason,\n+                )\n+            except Exception as e:  # pragma: no cover - on_session_switch guards per-provider\n+                logger.warning(\"Session-boundary switch failed: %s\", e)\n+\n+        self._submit_background(_run)\n+\n     def on_session_switch(\n         self,\n         new_session_id: str,"]},{"file":"cli.py","hunks":["@@ -1110,6 +1110,19 @@ def _run_cleanup(*, notify_session_finalize: bool = True):\n             )\n     try:\n         if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):\n+            # A /new shortly before exit leaves its end→switch boundary task\n+            # (old-session extraction, LLM-bound) queued on the memory\n+            # manager's serialized worker. shutdown_all()'s drain only waits\n+            # ~5s and cancels queued tasks, so give pending work a bounded\n+            # head start via the manager's own barrier — otherwise a\n+            # \"/new then quit\" silently drops the old session's extraction.\n+            # The 30s exit watchdog remains the hard backstop.\n+            _mm = getattr(_active_agent_ref, '_memory_manager', None)\n+            if _mm is not None and hasattr(_mm, 'flush_pending'):\n+                try:\n+                    _mm.flush_pending(timeout=10)\n+                except Exception:\n+                    pass\n             # Forward the agent's own transcript so memory providers'\n             # ``on_session_end`` hooks see the real conversation instead of\n             # an empty list (#15165). ``_session_messages`` is set on","@@ -6956,17 +6969,69 @@ def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:\n             )\n             return False\n \n+    def _launch_session_boundary_memory_flush(\n+        self,\n+        history_snapshot: list,\n+        *,\n+        session_id: Optional[str] = None,\n+    ) -> Optional[list]:\n+        \"\"\"Stage old-session memory extraction so /new stays responsive.\n+\n+        The context-engine ``on_session_end`` boundary is delivered\n+        synchronously here: it is cheap (local state clear, no LLM call) and\n+        ordering-sensitive — it must land before ``reset_session_state()``\n+        rebinds the engine to the new session.\n+\n+        The memory-provider half (LLM-bound extraction, seconds) is NOT run\n+        here. The returned snapshot is handed by ``new_session()`` to\n+        ``MemoryManager.commit_session_boundary_async`` as a single\n+        end→switch task on the manager's serialized background worker, so\n+        extraction can never race the provider rebinding (providers key off\n+        internal ``_session_id`` state — a late ``on_session_end`` after\n+        ``on_session_switch`` would misattribute the old transcript to the\n+        new session).\n+\n+        Returns the history snapshot to queue, or ``None`` when there is\n+        nothing to extract (no agent / empty history / no memory manager).\n+        \"\"\"\n+        agent = getattr(self, \"agent\", None)\n+        if not agent or not history_snapshot:\n+            return None\n+\n+        engine = getattr(agent, \"context_compressor\", None)\n+        if engine is not None and hasattr(engine, \"on_session_end\"):\n+            try:\n+                engine.on_session_end(session_id or \"\", history_snapshot)\n+            except Exception:\n+                logger.debug(\n+                    \"Context engine on_session_end failed at /new boundary\",\n+                    exc_info=True,\n+                )\n+\n+        # No provider extraction to queue when no memory manager is\n+        # configured — new_session() falls back to the inline switch path.\n+        if getattr(agent, \"_memory_manager\", None) is None:\n+            return None\n+        return history_snapshot\n+\n     def new_session(self, silent=False, title=None):\n         \"\"\"Start a fresh session with a new session ID and cleared agent state.\"\"\"\n+        old_session_id = self.session_id\n+        _boundary_snapshot = None\n         if self.agent and self.conversation_history:\n-            # Trigger memory extraction on the old session before session_id rotates.\n-            self.agent.commit_memory_session(self.conversation_history)\n+            # Deliver the context-engine boundary synchronously and get back\n+            # the history snapshot for the deferred provider extraction —\n+            # queued below (after rotation) so /new never blocks on the\n+            # LLM-bound extraction call.\n+            _boundary_snapshot = self._launch_session_boundary_memory_flush(\n+                list(self.conversation_history),\n+                session_id=old_session_id,\n+            )\n             self._notify_session_boundary(\"on_session_finalize\")\n         elif self.agent:\n             # First session or empty history — still finalize the old session\n             self._notify_session_boundary(\"on_session_finalize\")\n \n-        old_session_id = self.session_id\n         if self._session_db and old_session_id:\n             # Flush any un-persisted messages from the current turn to the\n             # old session *before* rotating.  /new can be called mid-turn","@@ -7054,15 +7119,29 @@ def new_session(self, silent=False, title=None):\n             # per-session state (_session_turns, _turn_counter, _document_id).\n             # Fires BEFORE the plugin on_session_reset hook (shell hooks only\n             # see the new id; Python providers see the transition). See #6672.\n+            #\n+            # When the old session has history, end-of-session extraction\n+            # (LLM-bound, seconds) and this switch are queued as ONE task on\n+            # the memory manager's serialized worker — end strictly before\n+            # switch, without blocking /new (#16454). With no history there\n+            # is nothing to extract; switch inline as before.\n             try:\n                 _mm = getattr(self.agent, \"_memory_manager\", None)\n                 if _mm is not None:\n-                    _mm.on_session_switch(\n-                        self.session_id,\n-                        parent_session_id=old_session_id or \"\",\n-                        reset=True,\n-                        reason=\"new_session\",\n-                    )\n+                    if _boundary_snapshot:\n+                        _mm.commit_session_boundary_async(\n+                            _boundary_snapshot,\n+                            new_session_id=self.session_id,\n+                            parent_session_id=old_session_id or \"\",\n+                            reason=\"new_session\",\n+                        )\n+                    else:\n+                        _mm.on_session_switch(\n+                            self.session_id,\n+                            parent_session_id=old_session_id or \"\",\n+                            reset=True,\n+                            reason=\"new_session\",\n+                        )\n             except Exception:\n                 pass\n             self._notify_session_boundary(\"on_session_reset\")","@@ -7074,7 +7153,6 @@ def new_session(self, silent=False, title=None):\n                 print(\"(^_^)v New session started!\")\n \n \n-\n     def _consume_pending_resume_selection(self, text: str) -> bool:\n         \"\"\"Resolve a bare numeric reply that follows a bare ``/resume`` prompt.\n "]},{"file":"tests/agent/test_memory_boundary_commit.py","hunks":["@@ -0,0 +1,124 @@\n+\"\"\"Tests for MemoryManager.commit_session_boundary_async.\n+\n+The /new session boundary must deliver on_session_end (old-session\n+extraction) strictly BEFORE on_session_switch (provider rebinding to the\n+new session), without blocking the caller. Both hooks run as one task on\n+the manager's single serialized background worker.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import threading\n+import time\n+from typing import Any, Dict, List\n+\n+from agent.memory_manager import MemoryManager\n+from agent.memory_provider import MemoryProvider\n+\n+\n+class _RecordingProvider(MemoryProvider):\n+    \"\"\"Provider that records hook invocations with thread identity.\"\"\"\n+\n+    def __init__(self, end_delay: float = 0.0):\n+        self.calls: List[tuple] = []\n+        self._end_delay = end_delay\n+        self._caller_thread_ids: List[int] = []\n+\n+    # Required ABC surface (minimal no-ops)\n+    @property\n+    def name(self) -> str:\n+        return \"recorder\"\n+\n+    def is_available(self) -> bool:\n+        return True\n+\n+    def get_tool_schemas(self) -> List[Dict[str, Any]]:\n+        return []\n+\n+    def initialize(self, agent: Any = None, **kwargs) -> bool:  # type: ignore[override]\n+        return True\n+\n+    def build_system_prompt(self) -> str:  # type: ignore[override]\n+        return \"\"\n+\n+    def sync_turn(self, user_content: str, assistant_content: str, **kwargs) -> None:  # type: ignore[override]\n+        self.calls.append((\"sync_turn\", kwargs.get(\"session_id\", \"\")))\n+\n+    def on_session_end(self, messages: List[Dict[str, Any]]) -> None:\n+        if self._end_delay:\n+            time.sleep(self._end_delay)\n+        self._caller_thread_ids.append(threading.get_ident())\n+        self.calls.append((\"end\", list(messages)))\n+\n+    def on_session_switch(self, new_session_id: str, **kwargs) -> None:\n+        self.calls.append((\"switch\", new_session_id, kwargs.get(\"reset\")))\n+\n+\n+def _make_manager(provider: _RecordingProvider) -> MemoryManager:\n+    mm = MemoryManager()\n+    mm._providers.append(provider)  # bypass add_provider validation for the stub\n+    return mm\n+\n+\n+def test_boundary_commit_delivers_end_strictly_before_switch():\n+    \"\"\"Even with a slow (LLM-like) extraction, switch waits for end.\"\"\"\n+    provider = _RecordingProvider(end_delay=0.15)\n+    mm = _make_manager(provider)\n+\n+    msgs = [{\"role\": \"user\", \"content\": \"old turn\"}]\n+    t0 = time.monotonic()\n+    mm.commit_session_boundary_async(\n+        msgs, new_session_id=\"new-sid\", parent_session_id=\"old-sid\"\n+    )\n+    # Caller returns immediately — the slow extraction must not block /new.\n+    assert time.monotonic() - t0 < 0.1\n+\n+    assert mm.flush_pending(timeout=5)\n+\n+    kinds = [c[0] for c in provider.calls]\n+    assert kinds == [\"end\", \"switch\"], f\"ordering violated: {provider.calls}\"\n+    assert provider.calls[0] == (\"end\", msgs)\n+    assert provider.calls[1] == (\"switch\", \"new-sid\", True)\n+    # And it genuinely ran off the caller's thread.\n+    assert provider._caller_thread_ids[0] != threading.get_ident()\n+\n+\n+def test_boundary_commit_serializes_against_turn_syncs():\n+    \"\"\"The boundary task shares the single worker with sync_all — FIFO order\n+    means a queued boundary can't interleave into a later turn's sync.\"\"\"\n+    provider = _RecordingProvider(end_delay=0.05)\n+    mm = _make_manager(provider)\n+\n+    mm.commit_session_boundary_async(\n+        [{\"role\": \"user\", \"content\": \"old\"}],\n+        new_session_id=\"new-sid\",\n+    )\n+    mm.sync_all(\"next-session user msg\", \"assistant reply\", session_id=\"new-sid\")\n+\n+    assert mm.flush_pending(timeout=5)\n+\n+    kinds = [c[0] for c in provider.calls]\n+    assert kinds == [\"end\", \"switch\", \"sync_turn\"], f\"unexpected order: {provider.calls}\"\n+\n+\n+def test_boundary_commit_switch_still_fires_when_end_raises():\n+    \"\"\"A failing provider extraction must not strand providers on the old sid.\"\"\"\n+\n+    class _ExplodingEndProvider(_RecordingProvider):\n+        def on_session_end(self, messages):  # type: ignore[override]\n+            raise RuntimeError(\"provider extraction blew up\")\n+\n+    provider = _ExplodingEndProvider()\n+    mm = _make_manager(provider)\n+\n+    mm.commit_session_boundary_async([{\"role\": \"user\", \"content\": \"x\"}], new_session_id=\"new-sid\")\n+    assert mm.flush_pending(timeout=5)\n+\n+    assert (\"switch\", \"new-sid\", True) in provider.calls\n+\n+\n+def test_boundary_commit_noop_without_providers():\n+    mm = MemoryManager()\n+    # Must not create the executor or raise.\n+    mm.commit_session_boundary_async([{\"role\": \"user\", \"content\": \"x\"}], new_session_id=\"s\")\n+    assert mm._sync_executor is None"]},{"file":"tests/cli/test_cli_new_session.py","hunks":["@@ -175,6 +175,87 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path)\n     cli.agent._invalidate_system_prompt.assert_called_once()\n \n \n+def test_new_session_queues_boundary_commit_with_snapshot(tmp_path):\n+    \"\"\"/new hands the OLD session's history + ids to the memory manager's\n+    serialized boundary task instead of blocking on extraction inline.\"\"\"\n+    cli = _prepare_cli_with_active_session(tmp_path)\n+    old_session_id = cli.session_id\n+\n+    mm = MagicMock()\n+    cli.agent._memory_manager = mm\n+\n+    cli.process_command(\"/new\")\n+\n+    mm.commit_session_boundary_async.assert_called_once()\n+    args, kwargs = mm.commit_session_boundary_async.call_args\n+    assert args[0] == [{\"role\": \"user\", \"content\": \"hello\"}]\n+    assert kwargs[\"new_session_id\"] == cli.session_id\n+    assert kwargs[\"parent_session_id\"] == old_session_id\n+    assert kwargs[\"reason\"] == \"new_session\"\n+    # The queued path replaces the inline switch — not both.\n+    mm.on_session_switch.assert_not_called()\n+\n+\n+def test_new_session_without_history_switches_inline(tmp_path):\n+    \"\"\"No old-session history → nothing to extract → plain inline switch.\"\"\"\n+    cli = _prepare_cli_with_active_session(tmp_path)\n+    cli.conversation_history = []\n+\n+    mm = MagicMock()\n+    cli.agent._memory_manager = mm\n+\n+    cli.process_command(\"/new\")\n+\n+    mm.commit_session_boundary_async.assert_not_called()\n+    mm.on_session_switch.assert_called_once()\n+    _, kwargs = mm.on_session_switch.call_args\n+    assert kwargs[\"reset\"] is True\n+\n+\n+def test_new_session_delivers_context_engine_boundary_synchronously(tmp_path):\n+    \"\"\"The context-engine on_session_end must fire during /new itself.\n+\n+    It is cheap local state work and ordering-sensitive: it must land before\n+    reset_session_state() rebinds the engine to the new session. The LLM-bound\n+    provider extraction is what gets deferred, not this.\"\"\"\n+    cli = _prepare_cli_with_active_session(tmp_path)\n+    old_session_id = cli.session_id\n+\n+    engine_calls = []\n+    cli.agent.context_compressor.on_session_end = (\n+        lambda sid, msgs: engine_calls.append((sid, list(msgs)))\n+    )\n+\n+    cli.process_command(\"/new\")\n+\n+    assert engine_calls == [(old_session_id, [{\"role\": \"user\", \"content\": \"hello\"}])]\n+\n+\n+def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path):\n+    \"\"\"A '/new then quit' must not drop the queued old-session extraction.\n+\n+    _run_cleanup gives the manager's serialized worker a bounded drain via\n+    flush_pending() before shutdown_all()'s short-fuse drain runs.\"\"\"\n+    import cli as _cli_mod\n+\n+    agent = MagicMock()\n+    mm = MagicMock()\n+    mm.flush_pending.return_value = True\n+    agent._memory_manager = mm\n+    agent._session_messages = []\n+\n+    old_ref = _cli_mod._active_agent_ref\n+    _cli_mod._active_agent_ref = agent\n+    _cli_mod._cleanup_done = False\n+    try:\n+        _cli_mod._run_cleanup(notify_session_finalize=False)\n+    finally:\n+        _cli_mod._cleanup_done = True\n+        _cli_mod._active_agent_ref = old_ref\n+\n+    mm.flush_pending.assert_called_once_with(timeout=10)\n+\n+\n def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path):\n     from gateway.session_context import _VAR_MAP, get_session_env\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}