{"repo":"NousResearch/hermes-agent","public_id":"38315","display_label":"TUI resume creating duplicate live sessions fix","summary":"These issues and pull requests concern a bug where repeatedly resuming in the TUI gateway can create duplicate live sessions. The work focuses on reusing existing live sessions on resume rather than creating new ones.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39153},"resolution":{"state":"partially_resolved","by":"pr-39153"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T15:18:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":38320,"pr_number":39153,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":38315,"title":"Bug: repeated TUI resume can create duplicate live sessions","url":null,"state":"closed"},{"kind":"issue","number":38320,"title":"[Bug]: repeated TUI resume can create duplicate live sessions","url":null,"state":"closed"},{"kind":"pr","number":38330,"title":"fix(tui): reuse live session on resume","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39153","number":39153}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39153,"title":"fix(tui): reuse live session on resume (salvage #38330)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py","ui-tui/src/app/useSessionLifecycle.ts","ui-tui/src/gatewayTypes.ts"],"fix_hunks":[{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -394,6 +394,225 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):\n     ]\n \n \n+def test_session_resume_reuses_existing_live_session(server, monkeypatch):\n+    \"\"\"Repeated resume must not allocate duplicate live agents.\"\"\"\n+\n+    target = \"20260409_010101_abc123\"\n+    created_sids: list[str] = []\n+    closed_sids: list[str] = []\n+    first_agent_started = threading.Event()\n+    agent_can_finish = threading.Event()\n+\n+    class _DB:\n+        def get_session(self, _sid):\n+            return {\"id\": target}\n+\n+        def get_session_by_title(self, _title):\n+            return None\n+\n+        def reopen_session(self, _sid):\n+            return None\n+\n+        def get_messages_as_conversation(self, _sid, include_ancestors=False):\n+            return [\n+                {\"role\": \"user\", \"content\": \"hello\"},\n+                {\"role\": \"assistant\", \"content\": \"yo\"},\n+            ]\n+\n+    class _Worker:\n+        def close(self):\n+            pass\n+\n+    class _Agent:\n+        def __init__(self, sid, session_id):\n+            self.sid = sid\n+            self.model = \"test/model\"\n+            self.session_id = session_id\n+\n+        def close(self):\n+            closed_sids.append(self.sid)\n+\n+    def make_agent(sid, key, session_id=None):\n+        created_sids.append(sid)\n+        first_agent_started.set()\n+        assert agent_can_finish.wait(timeout=1)\n+        return _Agent(sid, session_id or key)\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    monkeypatch.setattr(server, \"_make_agent\", make_agent)\n+    monkeypatch.setattr(server, \"_SlashWorker\", lambda _key, _model: _Worker())\n+    monkeypatch.setattr(\n+        server,\n+        \"_start_notification_poller\",\n+        lambda _sid, _session: threading.Event(),\n+    )\n+    monkeypatch.setattr(server, \"_notify_session_boundary\", lambda *_args, **_kwargs: None)\n+    monkeypatch.setattr(server, \"_wire_callbacks\", lambda _sid: None)\n+    monkeypatch.setattr(server, \"_emit\", lambda *_args, **_kwargs: None)\n+    monkeypatch.setattr(\n+        server,\n+        \"_session_info\",\n+        lambda _agent, _session=None: {\"model\": \"test/model\"},\n+    )\n+\n+    fake_approval = types.SimpleNamespace(\n+        load_permanent_allowlist=lambda: None,\n+        register_gateway_notify=lambda *_args, **_kwargs: None,\n+    )\n+\n+    with patch.dict(sys.modules, {\"tools.approval\": fake_approval}):\n+        first_holder = {}\n+\n+        def resume_first():\n+            first_holder[\"resp\"] = server.handle_request(\n+                {\n+                    \"id\": \"first\",\n+                    \"method\": \"session.resume\",\n+                    \"params\": {\"session_id\": target, \"cols\": 100},\n+                }\n+            )\n+\n+        first_thread = threading.Thread(target=resume_first)\n+        first_thread.start()\n+        assert first_agent_started.wait(timeout=1)\n+\n+        second_holder = {}\n+\n+        def resume_second():\n+            second_holder[\"resp\"] = server.handle_request(\n+                {\n+                    \"id\": \"second\",\n+                    \"method\": \"session.resume\",\n+                    \"params\": {\"session_id\": target, \"cols\": 120},\n+                }\n+            )\n+\n+        second_thread = threading.Thread(target=resume_second)\n+        second_thread.start()\n+        agent_can_finish.set()\n+\n+        first_thread.join(timeout=1)\n+        second_thread.join(timeout=1)\n+        assert not first_thread.is_alive()\n+        assert not second_thread.is_alive()\n+        first = first_holder[\"resp\"]\n+        second = second_holder[\"resp\"]\n+\n+    assert \"error\" not in first\n+    assert \"error\" not in second\n+    # Both resumes resolve to the SAME single live session — the core invariant.\n+    assert second[\"result\"][\"session_id\"] == first[\"result\"][\"session_id\"]\n+    assert len(server._sessions) == 1\n+    assert [s.get(\"session_key\") for s in server._sessions.values()].count(target) == 1\n+    winner = first[\"result\"][\"session_id\"]\n+    # The agent build happens outside the resume lock, so a racing resume may\n+    # build a redundant agent; double-checked locking keeps only one live\n+    # session and closes any loser's agent (no worker/poller is wired for it).\n+    assert winner in created_sids\n+    survivors = [sid for sid in created_sids if sid not in closed_sids]\n+    assert survivors == [winner]\n+    assert all(sid == winner for sid in server._sessions)\n+\n+\n+def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch):\n+    \"\"\"Live resume should not reuse a stale ancestor-inclusive snapshot.\"\"\"\n+\n+    target = \"20260409_010101_child\"\n+    ancestor_history = [{\"role\": \"user\", \"content\": \"ancestor\"}]\n+    current_history = [\n+        {\"role\": \"user\", \"content\": \"current\"},\n+        {\"role\": \"assistant\", \"content\": \"current reply\"},\n+    ]\n+\n+    class _DB:\n+        def get_session(self, _sid):\n+            return {\"id\": target}\n+\n+        def get_session_by_title(self, _title):\n+            return None\n+\n+        def reopen_session(self, _sid):\n+            return None\n+\n+        def get_messages_as_conversation(self, _sid, include_ancestors=False):\n+            if include_ancestors:\n+                return ancestor_history + current_history\n+            return list(current_history)\n+\n+    class _Worker:\n+        def close(self):\n+            pass\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    monkeypatch.setattr(\n+        server,\n+        \"_make_agent\",\n+        lambda _sid, key, session_id=None: types.SimpleNamespace(\n+            model=\"test/model\", session_id=session_id or key\n+        ),\n+    )\n+    monkeypatch.setattr(server, \"_SlashWorker\", lambda _key, _model: _Worker())\n+    monkeypatch.setattr(\n+        server,\n+        \"_start_notification_poller\",\n+        lambda _sid, _session: threading.Event(),\n+    )\n+    monkeypatch.setattr(server, \"_notify_session_boundary\", lambda *_args, **_kwargs: None)\n+    monkeypatch.setattr(server, \"_wire_callbacks\", lambda _sid: None)\n+    monkeypatch.setattr(server, \"_emit\", lambda *_args, **_kwargs: None)\n+    monkeypatch.setattr(\n+        server,\n+        \"_session_info\",\n+        lambda _agent, _session=None: {\"model\": \"test/model\"},\n+    )\n+\n+    fake_approval = types.SimpleNamespace(\n+        load_permanent_allowlist=lambda: None,\n+        register_gateway_notify=lambda *_args, **_kwargs: None,\n+    )\n+\n+    with patch.dict(sys.modules, {\"tools.approval\": fake_approval}):\n+        first = server.handle_request(\n+            {\n+                \"id\": \"first\",\n+                \"method\": \"session.resume\",\n+                \"params\": {\"session_id\": target, \"cols\": 100},\n+            }\n+        )\n+\n+        assert \"error\" not in first\n+        sid = first[\"result\"][\"session_id\"]\n+        assert first[\"result\"][\"messages\"] == [\n+            {\"role\": \"user\", \"text\": \"ancestor\"},\n+            {\"role\": \"user\", \"text\": \"current\"},\n+            {\"role\": \"assistant\", \"text\": \"current reply\"},\n+        ]\n+\n+        with server._sessions[sid][\"history_lock\"]:\n+            server._sessions[sid][\"history\"] = current_history + [\n+                {\"role\": \"user\", \"content\": \"new live turn\"},\n+                {\"role\": \"assistant\", \"content\": \"new live reply\"},\n+            ]\n+\n+        second = server.handle_request(\n+            {\n+                \"id\": \"second\",\n+                \"method\": \"session.resume\",\n+                \"params\": {\"session_id\": target, \"cols\": 120},\n+            }\n+        )\n+\n+    assert \"error\" not in second\n+    assert second[\"result\"][\"session_id\"] == sid\n+    assert second[\"result\"][\"messages\"] == [\n+        {\"role\": \"user\", \"text\": \"ancestor\"},\n+        {\"role\": \"user\", \"text\": \"current\"},\n+        {\"role\": \"assistant\", \"text\": \"current reply\"},\n+        {\"role\": \"user\", \"text\": \"new live turn\"},\n+        {\"role\": \"assistant\", \"text\": \"new live reply\"},\n+    ]\n+\n+\n def test_make_agent_accepts_list_system_prompt(server, monkeypatch):\n     captured = {}\n "]},{"file":"tui_gateway/server.py","hunks":["@@ -128,6 +128,7 @@ def _thread_panic_hook(args):\n _cfg_cache: dict | None = None\n _cfg_mtime: float | None = None\n _cfg_path = None\n+_session_resume_lock = threading.Lock()\n try:\n     _slash_timeout = float(os.environ.get(\"HERMES_TUI_SLASH_TIMEOUT_S\") or \"45\")\n except (ValueError, TypeError):","@@ -2979,6 +2980,10 @@ def _(rid, params: dict) -> dict:\n     target = params.get(\"session_id\", \"\")\n     if not target:\n         return _err(rid, 4006, \"session_id required\")\n+    try:\n+        cols = int(params.get(\"cols\", 80))\n+    except (TypeError, ValueError):\n+        cols = 80\n     db = _get_db()\n     if db is None:\n         return _db_unavailable_error(rid, code=5000)","@@ -2989,6 +2994,25 @@ def _(rid, params: dict) -> dict:\n             target = found[\"id\"]\n         else:\n             return _err(rid, 4007, \"session not found\")\n+    # Fast path: if the session is already live, reuse it under the lock.\n+    with _session_resume_lock:\n+        live = _find_live_session_by_key(target)\n+        if live is not None:\n+            sid, session = live\n+            payload = _live_session_payload(\n+                sid,\n+                session,\n+                cols=cols,\n+                touch=True,\n+                transport=current_transport() or _stdio_transport,\n+            )\n+            payload[\"resumed\"] = target\n+            return _ok(rid, payload)\n+\n+    # Build the agent OUTSIDE the lock — _make_agent can block for seconds\n+    # (MCP discovery, prompt/skill build, AIAgent construction). Holding\n+    # _session_resume_lock across it would stall session.close on the main\n+    # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs.\n     sid = uuid.uuid4().hex[:8]\n     _enable_gateway_prompts()\n     try:","@@ -2997,15 +3021,46 @@ def _(rid, params: dict) -> dict:\n         display_history = db.get_messages_as_conversation(\n             target, include_ancestors=True\n         )\n+        display_history_prefix = display_history[\n+            : max(0, len(display_history) - len(history))\n+        ]\n         messages = _history_to_messages(display_history)\n         tokens = [redacted:secret-assignment]\n         try:\n             agent = _make_agent(sid, target, session_id=target)\n         finally:\n             _clear_session_context(tokens)\n-        _init_session(sid, target, agent, history, cols=int(params.get(\"cols\", 80)))\n     except Exception as e:\n         return _err(rid, 5000, f\"resume failed: {e}\")\n+\n+    # Double-checked locking: another concurrent resume may have created the\n+    # live session while we were building. Re-check under the lock; if it won,\n+    # discard our just-built agent and reuse theirs (no worker/poller wired yet).\n+    with _session_resume_lock:\n+        live = _find_live_session_by_key(target)\n+        if live is not None:\n+            try:\n+                if hasattr(agent, \"close\"):\n+                    agent.close()\n+            except Exception:\n+                pass\n+            other_sid, other_session = live\n+            payload = _live_session_payload(\n+                other_sid,\n+                other_session,\n+                cols=cols,\n+                touch=True,\n+                transport=current_transport() or _stdio_transport,\n+            )\n+            payload[\"resumed\"] = target\n+            return _ok(rid, payload)\n+        try:\n+            _init_session(sid, target, agent, history, cols=cols)\n+            if sid in _sessions:\n+                _sessions[sid][\"display_history_prefix\"] = display_history_prefix\n+        except Exception as e:\n+            return _err(rid, 5000, f\"resume failed: {e}\")\n+        session = _sessions.get(sid) or {}\n     return _ok(\n         rid,\n         {","@@ -3013,7 +3068,12 @@ def _(rid, params: dict) -> dict:\n             \"resumed\": target,\n             \"message_count\": len(messages),\n             \"messages\": messages,\n-            \"info\": _session_info(agent, _sessions.get(sid)),\n+            \"info\": _session_info(agent, session),\n+            \"inflight\": None,\n+            \"running\": False,\n+            \"session_key\": target,\n+            \"started_at\": float(session.get(\"created_at\") or time.time()),\n+            \"status\": \"idle\",\n         },\n     )\n ","@@ -3106,6 +3166,15 @@ def _session_live_item(sid: str, session: dict, current_sid: str = \"\") -> dict:\n     }\n \n \n+def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None:\n+    for sid, session in list(_sessions.items()):\n+        if session.get(\"_finalized\"):\n+            continue\n+        if str(session.get(\"session_key\") or \"\") == session_key:\n+            return sid, session\n+    return None\n+\n+\n def _fallback_session_info(session: dict) -> dict:\n     agent = session.get(\"agent\")\n     if agent is not None:","@@ -3119,6 +3188,41 @@ def _fallback_session_info(session: dict) -> dict:\n     }\n \n \n+def _live_session_payload(\n+    sid: str,\n+    session: dict,\n+    *,\n+    cols: int | None = None,\n+    touch: bool = False,\n+    transport: Transport | None = None,\n+) -> dict:\n+    with session[\"history_lock\"]:\n+        if cols is not None:\n+            session[\"cols\"] = cols\n+        if transport is not None:\n+            session[\"transport\"] = transport\n+        if touch:\n+            session[\"last_active\"] = time.time()\n+        history = list(session.get(\"display_history_prefix\") or []) + list(\n+            session.get(\"history\") or []\n+        )\n+        inflight = _inflight_snapshot(session)\n+        running = bool(session.get(\"running\"))\n+    payload = {\n+        \"info\": _fallback_session_info(session),\n+        \"message_count\": len(history),\n+        \"messages\": _history_to_messages(history),\n+        \"running\": running,\n+        \"session_id\": sid,\n+        \"session_key\": session.get(\"session_key\") or sid,\n+        \"started_at\": float(session.get(\"created_at\") or time.time()),\n+        \"status\": _session_live_status(sid, session),\n+    }\n+    if inflight:\n+        payload[\"inflight\"] = inflight\n+    return payload\n+\n+\n @method(\"session.active_list\")\n def _(rid, params: dict) -> dict:\n     \"\"\"Return live TUI sessions in this gateway process.","@@ -3152,27 +3256,9 @@ def _(rid, params: dict) -> dict:\n     if err:\n         return err\n \n-    with session[\"history_lock\"]:\n-        session[\"last_active\"] = time.time()\n-        history = list(session.get(\"display_history\") or session.get(\"history\") or [])\n-        inflight = _inflight_snapshot(session)\n-        running = bool(session.get(\"running\"))\n-    status = _session_live_status(sid, session)\n-    payload = {\n-        \"info\": _fallback_session_info(session),\n-        \"message_count\": len(history),\n-        \"messages\": _history_to_messages(history),\n-        \"running\": running,\n-        \"session_id\": sid,\n-        \"session_key\": session.get(\"session_key\") or sid,\n-        \"started_at\": float(session.get(\"created_at\") or time.time()),\n-        \"status\": status,\n-    }\n-    if inflight:\n-        payload[\"inflight\"] = inflight\n     return _ok(\n         rid,\n-        payload,\n+        _live_session_payload(sid, session, touch=True),\n     )\n \n ","@@ -3558,28 +3644,32 @@ def _(rid, params: dict) -> dict:\n @method(\"session.close\")\n def _(rid, params: dict) -> dict:\n     sid = params.get(\"session_id\", \"\")\n-    session = _sessions.pop(sid, None)\n-    if not session:\n+    current = _sessions.get(sid)\n+    if not current:\n         return _ok(rid, {\"closed\": False})\n-    _finalize_session(session)\n-    try:\n-        from tools.approval import unregister_gateway_notify\n+    with _session_resume_lock:\n+        session = _sessions.pop(sid, None)\n+        if not session:\n+            return _ok(rid, {\"closed\": False})\n+        _finalize_session(session)\n+        try:\n+            from tools.approval import unregister_gateway_notify\n \n-        unregister_gateway_notify(session[\"session_key\"])\n-    except Exception:\n-        pass\n-    try:\n-        agent = session.get(\"agent\")\n-        if agent and hasattr(agent, \"close\"):\n-            agent.close()\n-    except Exception:\n-        pass\n-    try:\n-        worker = session.get(\"slash_worker\")\n-        if worker:\n-            worker.close()\n-    except Exception:\n-        pass\n+            unregister_gateway_notify(session[\"session_key\"])\n+        except Exception:\n+            pass\n+        try:\n+            agent = session.get(\"agent\")\n+            if agent and hasattr(agent, \"close\"):\n+                agent.close()\n+        except Exception:\n+            pass\n+        try:\n+            worker = session.get(\"slash_worker\")\n+            if worker:\n+                worker.close()\n+        except Exception:\n+            pass\n     return _ok(rid, {\"closed\": True})\n \n "]},{"file":"ui-tui/src/app/useSessionLifecycle.ts","hunks":["@@ -302,38 +302,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {\n           return\n         }\n \n-        closeSession(getUiState().sid === id ? null : getUiState().sid).then(() =>\n-          gw\n-            .request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })\n-            .then(raw => {\n-              const r = asRpcResult<SessionResumeResponse>(raw)\n-\n-              if (!r) {\n-                sys('error: invalid response: session.resume')\n-\n-                return patchUiState({ status: 'ready' })\n-              }\n-\n-              resetSession()\n-              setSessionStartedAt(Date.now())\n-\n-              const resumed = toTranscriptMessages(r.messages)\n-\n-              setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)\n-              writeActiveSessionFile(r.resumed ?? r.session_id)\n-              patchUiState({\n-                info: r.info ?? null,\n-                sid: r.session_id,\n-                status: 'ready',\n-                usage: usageFrom(r.info ?? null)\n-              })\n-              setTimeout(() => scrollRef.current?.scrollToBottom(), 0)\n-            })\n-            .catch((e: Error) => {\n-              sys(`error: ${e.message}`)\n-              patchUiState({ status: 'ready' })\n+        const previousSid = getUiState().sid\n+\n+        gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })\n+          .then(raw => {\n+            const r = asRpcResult<SessionResumeResponse>(raw)\n+\n+            if (!r) {\n+              sys('error: invalid response: session.resume')\n+\n+              return patchUiState({ status: 'ready' })\n+            }\n+\n+            const info = r.info ?? null\n+            const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting')\n+\n+            resetSession()\n+            setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now())\n+\n+            const resumed = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]\n+\n+            setHistoryItems(info ? [introMsg(info), ...resumed] : resumed)\n+            writeActiveSessionFile(r.resumed ?? r.session_id)\n+            patchUiState({\n+              busy: running,\n+              info,\n+              sid: r.session_id,\n+              status: statusFromLiveSession(r.status, running),\n+              usage: usageFrom(info)\n             })\n-        )\n+            hydrateLiveSessionInflight(r.inflight)\n+\n+            if (previousSid && previousSid !== r.session_id) {\n+              void closeSession(previousSid)\n+            }\n+\n+            setTimeout(() => scrollRef.current?.scrollToBottom(), 0)\n+          })\n+          .catch((e: Error) => {\n+            sys(`error: ${e.message}`)\n+            patchUiState({ status: 'ready' })\n+          })\n       })\n     },\n     [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys]"]},{"file":"ui-tui/src/gatewayTypes.ts","hunks":["@@ -122,11 +122,15 @@ export interface SessionCreateResponse {\n }\n \n export interface SessionResumeResponse {\n+  inflight?: null | SessionInflightTurn\n   info?: SessionInfo\n   message_count?: number\n   messages: GatewayTranscriptMessage[]\n   resumed?: string\n+  running?: boolean\n   session_id: string\n+  started_at?: number\n+  status?: LiveSessionStatus\n }\n \n export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working'\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}