{"repo":"NousResearch/hermes-agent","public_id":"49041","display_label":"TUI compression and live session identity preservation","summary":"The members all concern the TUI's auto-compression affecting live session identity. One is an issue describing session lineage forking and messages mixing across sessions, and the others are pull requests (one referencing #49041) titled as fixes to preserve session identity across compression. Only the titles are available, so the details are not confirmed.","subsystem":"dashboard","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51509,"merged_at":"2026-06-23T20:34:48+00:00"},"resolution":{"state":"resolved","by":"pr-51509"},"freshness":{"state":"verified","last_verified_at":"2026-06-23T20:34:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49041,"pr_number":51509,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-23T20:34:48+00:00"}],"members":[{"kind":"issue","number":49041,"title":"TUI auto-compression can fork live session lineage and mix messages across sessions","url":null,"state":"closed"},{"kind":"pr","number":49086,"title":"fix(tui): preserve live session identity across compression","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":51509,"title":"fix(tui): preserve live session identity across compression (#49041)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/active_sessions.py","tests/hermes_cli/test_active_sessions.py","tests/tui_gateway/test_protocol.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/active_sessions.py","hunks":["@@ -78,7 +78,7 @@ def active_session_limit_message(active_count: int, max_sessions: int) -> str:\n \n \n def _state_dir() -> Path:\n-    return get_hermes_home() / \"runtime\"\n+    return Path(get_hermes_home()) / \"runtime\"\n \n \n def _state_path() -> Path:","@@ -311,6 +311,43 @@ def release_active_session(lease: ActiveSessionLease) -> None:\n         lease.released = True\n \n \n+def transfer_active_session(\n+    lease: ActiveSessionLease,\n+    *,\n+    session_id: str,\n+    metadata: Optional[dict[str, Any]] = None,\n+) -> bool:\n+    \"\"\"Move an existing lease to a new session id without dropping the slot.\"\"\"\n+    new_session_id = str(session_id or \"\")\n+    if not new_session_id:\n+        return False\n+    if lease.released:\n+        return False\n+    if not lease.enabled:\n+        lease.session_id = new_session_id\n+        return True\n+\n+    state_path = _state_path()\n+    with _FileLock(_lock_path()):\n+        entries = _prune_dead(_read_entries(state_path))\n+        updated = False\n+        for entry in entries:\n+            if str(entry.get(\"lease_id\") or \"\") != lease.lease_id:\n+                continue\n+            entry[\"session_id\"] = new_session_id\n+            entry[\"updated_at\"] = time.time()\n+            if metadata:\n+                entry[\"metadata\"] = {\n+                    str(k): v for k, v in metadata.items() if isinstance(k, str)\n+                }\n+            updated = True\n+            break\n+        if updated:\n+            _write_entries(state_path, entries)\n+            lease.session_id = new_session_id\n+        return updated\n+\n+\n def active_session_registry_snapshot() -> list[dict[str, Any]]:\n     \"\"\"Return the pruned active-session registry for diagnostics/tests.\"\"\"\n     state_path = _state_path()"]},{"file":"tests/hermes_cli/test_active_sessions.py","hunks":["@@ -113,6 +113,33 @@ def test_active_session_registry_prunes_dead_pids(tmp_path, monkeypatch):\n     lease.release()\n \n \n+def test_transfer_active_session_reanchors_existing_lease(tmp_path, monkeypatch):\n+    home = tmp_path / \".hermes\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+    lease, message = active_sessions.try_acquire_active_session(\n+        session_id=\"session-old\",\n+        surface=\"tui\",\n+        config={\"max_concurrent_sessions\": 1},\n+        metadata={\"live_session_id\": \"ui-1\"},\n+    )\n+\n+    assert message is None\n+    assert lease is not None\n+    assert active_sessions.transfer_active_session(\n+        lease,\n+        session_id=\"session-new\",\n+        metadata={\"live_session_id\": \"ui-1\"},\n+    )\n+\n+    snapshot = active_sessions.active_session_registry_snapshot()\n+    assert lease.session_id == \"session-new\"\n+    assert len(snapshot) == 1\n+    assert snapshot[0][\"session_id\"] == \"session-new\"\n+    assert snapshot[0][\"metadata\"] == {\"live_session_id\": \"ui-1\"}\n+    lease.release()\n+\n+\n def test_pid_alive_uses_safe_pid_exists_without_signalling(monkeypatch):\n     checked: list[int] = []\n "]},{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -734,6 +734,100 @@ def resume_second():\n     assert all(sid == winner for sid in server._sessions)\n \n \n+def test_session_resume_reuses_live_agent_after_compression_rotation(server, monkeypatch):\n+    \"\"\"Resume must match the live agent's current session_id, not stale session_key.\"\"\"\n+\n+    target = \"20260409_020202_child\"\n+    stale_parent = \"20260409_010101_parent\"\n+    sid = \"live-rotated\"\n+    server._sessions[sid] = {\n+        \"agent\": types.SimpleNamespace(model=\"test/model\", session_id=target),\n+        \"created_at\": 123.0,\n+        \"display_history_prefix\": [],\n+        \"history\": [{\"role\": \"assistant\", \"content\": \"live child\"}],\n+        \"history_lock\": threading.RLock(),\n+        \"last_active\": 123.0,\n+        \"running\": False,\n+        \"session_key\": stale_parent,\n+        \"transport\": server._stdio_transport,\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 resolve_resume_session_id(self, _target):\n+            return target\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\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+    result = server.handle_request(\n+        {\n+            \"id\": \"r1\",\n+            \"method\": \"session.resume\",\n+            \"params\": {\"session_id\": target, \"cols\": 100},\n+        }\n+    )\n+\n+    assert \"error\" not in result\n+    assert result[\"result\"][\"session_id\"] == sid\n+    assert result[\"result\"][\"session_key\"] == target\n+    assert len(server._sessions) == 1\n+\n+\n+def test_sync_session_key_after_compress_reanchors_active_session_lease(\n+    server, monkeypatch, tmp_path\n+):\n+    home = tmp_path / \".hermes\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+    from hermes_cli.active_sessions import (\n+        active_session_registry_snapshot,\n+        try_acquire_active_session,\n+    )\n+\n+    lease, message = try_acquire_active_session(\n+        session_id=\"session-old\",\n+        surface=\"tui\",\n+        config={\"max_concurrent_sessions\": 1},\n+        metadata={\"live_session_id\": \"ui-1\"},\n+    )\n+    assert message is None\n+    assert lease is not None\n+\n+    session = {\n+        \"active_session_lease\": lease,\n+        \"agent\": types.SimpleNamespace(session_id=\"session-new\"),\n+        \"session_key\": \"session-old\",\n+    }\n+    fake_approval = types.SimpleNamespace(\n+        disable_session_yolo=lambda *_args, **_kwargs: None,\n+        enable_session_yolo=lambda *_args, **_kwargs: None,\n+        is_session_yolo_enabled=lambda *_args, **_kwargs: False,\n+        register_gateway_notify=lambda *_args, **_kwargs: None,\n+        unregister_gateway_notify=lambda *_args, **_kwargs: None,\n+    )\n+    monkeypatch.setattr(server, \"_restart_slash_worker\", lambda *_args, **_kwargs: None)\n+\n+    with patch.dict(sys.modules, {\"tools.approval\": fake_approval}):\n+        server._sync_session_key_after_compress(\"ui-1\", session)\n+\n+    snapshot = active_session_registry_snapshot()\n+    assert session[\"session_key\"] == \"session-new\"\n+    assert lease.session_id == \"session-new\"\n+    assert [entry[\"session_id\"] for entry in snapshot] == [\"session-new\"]\n+    lease.release()\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 "]},{"file":"tui_gateway/server.py","hunks":["@@ -381,6 +381,59 @@ def _release_active_session_slot(session: dict | None) -> None:\n         logger.debug(\"Failed to release active session slot\", exc_info=True)\n \n \n+def _transfer_active_session_slot(\n+    sid: str,\n+    session: dict,\n+    *,\n+    new_session_id: str,\n+) -> bool:\n+    if not new_session_id:\n+        return False\n+    lease = session.get(\"active_session_lease\")\n+    if lease is None:\n+        return True\n+    try:\n+        from hermes_cli.active_sessions import transfer_active_session\n+\n+        if transfer_active_session(\n+            lease,\n+            session_id=new_session_id,\n+            metadata={\"live_session_id\": sid},\n+        ):\n+            return True\n+    except Exception:\n+        logger.debug(\"Failed to transfer active session slot\", exc_info=True)\n+\n+    # Fallback: the in-place transfer could not move the lease (entry pruned /\n+    # pid-check transiently failed). Reserve the new slot BEFORE releasing the\n+    # old one, so a concurrent gateway at the session cap cannot grab the freed\n+    # slot in a release-then-reacquire window and leave this session with no\n+    # lease at all (#49041 review). If the reserve fails, KEEP the old lease.\n+    new_lease, limit_message = _claim_active_session_slot(\n+        new_session_id,\n+        live_session_id=sid,\n+    )\n+    if new_lease is not None:\n+        old_lease = session.pop(\"active_session_lease\", None)\n+        if old_lease is not None:\n+            try:\n+                old_lease.release()\n+            except Exception:\n+                logger.debug(\"Failed to release stale active session slot\", exc_info=True)\n+        session[\"active_session_lease\"] = new_lease\n+        return True\n+    # Reserve failed — retain the existing lease rather than dropping it.\n+    if limit_message:\n+        logger.warning(\n+            \"Compression session lease re-anchor failed (kept old lease): \"\n+            \"sid=%s new_session_id=%s reason=%s\",\n+            sid,\n+            new_session_id,\n+            limit_message,\n+        )\n+    return False\n+\n+\n def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> None:\n     \"\"\"Best-effort finalize hook + memory commit for a session.\n ","@@ -2543,6 +2596,19 @@ def _sync_session_key_after_compress(\n     if not new_session_id or new_session_id == old_key:\n         return\n \n+    lease_reanchored = _transfer_active_session_slot(\n+        sid,\n+        session,\n+        new_session_id=new_session_id,\n+    )\n+    if not lease_reanchored:\n+        logger.warning(\n+            \"Compression session lease did not re-anchor: sid=%s old_session_id=%s new_session_id=%s\",\n+            sid,\n+            old_key,\n+            new_session_id,\n+        )\n+\n     try:\n         from tools.approval import (\n             disable_session_yolo,","@@ -4940,7 +5006,7 @@ def _session_live_title(session: dict, key: str) -> str:\n \n \n def _session_live_item(sid: str, session: dict, current_sid: str = \"\") -> dict:\n-    key = str(session.get(\"session_key\") or sid)\n+    key = _session_lookup_key(session, fallback=sid)\n     agent = session.get(\"agent\")\n     history = list(session.get(\"history\") or [])\n     status = _session_live_status(sid, session)","@@ -4964,11 +5030,21 @@ def _session_live_item(sid: str, session: dict, current_sid: str = \"\") -> dict:\n     }\n \n \n+def _session_lookup_key(session: dict, *, fallback: str = \"\") -> str:\n+    agent = session.get(\"agent\")\n+    return str(\n+        getattr(agent, \"session_id\", None)\n+        or session.get(\"session_key\")\n+        or fallback\n+        or \"\"\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+        if _session_lookup_key(session, fallback=sid) == session_key:\n             return sid, session\n     return None\n ","@@ -5012,7 +5088,7 @@ def _live_session_payload(\n         \"messages\": _history_to_messages(history),\n         \"running\": running,\n         \"session_id\": sid,\n-        \"session_key\": session.get(\"session_key\") or sid,\n+        \"session_key\": _session_lookup_key(session, fallback=sid),\n         \"started_at\": float(session.get(\"created_at\") or time.time()),\n         \"status\": _session_live_status(sid, session),\n     }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}