{"repo":"NousResearch/hermes-agent","public_id":"20856","display_label":"Branch session visibility bug after parent reopen","summary":"These issues and fixes concern a bug where sessions created via /branch disappear from listings after their parent session is closed or reopened. Multiple fix attempts address the ordering of state operations around the branch and reopen flow.","subsystem":"hermes_cli","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":39214,"merged_at":"2026-06-04T17:07:21+00:00"},"resolution":{"state":"resolved","by":"pr-39214"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T17:07:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":20856,"pr_number":39214,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-04T17:07:21+00:00"}],"members":[{"kind":"issue","number":20856,"title":"/branch (fork) sessions vanish from session listings after parent is closed","url":null,"state":"closed"},{"kind":"pr","number":20864,"title":"fix(state): keep /branch sessions visible after parent reopen","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1a4e8f704","number":0},{"kind":"pr","value":"#39214","number":39214},{"kind":"pr","value":"#20856","number":20856}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21089,"title":"fix(state): keep /branch sessions visible after parent reopen","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20864","number":20864}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":39214,"title":"fix(state): keep /branch sessions visible after parent reopen (salvage #20864)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_state.py","tests/test_hermes_state.py","tests/tui_gateway/test_protocol.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7166,7 +7166,11 @@ def _handle_branch_command(self, cmd_original: str) -> None:\n         except Exception:\n             pass\n \n-        # Create the new session with parent link\n+        # Create the new session with parent link.\n+        # Persist a stable ``_branched_from`` marker in model_config so\n+        # list_sessions_rich() can keep the branch visible in /resume and\n+        # /sessions even after the parent is reopened and re-ended with a\n+        # different end_reason (e.g. tui_shutdown overwriting 'branched').\n         try:\n             self._session_db.create_session(\n                 session_id=new_session_id,","@@ -7175,6 +7179,7 @@ def _handle_branch_command(self, cmd_original: str) -> None:\n                 model_config={\n                     \"max_iterations\": self.max_turns,\n                     \"reasoning_config\": self.reasoning_config,\n+                    \"_branched_from\": parent_session_id,\n                 },\n                 parent_session_id=parent_session_id,\n             )"]},{"file":"gateway/run.py","hunks":["@@ -13956,12 +13956,17 @@ async def _handle_branch_command(self, event: MessageEvent) -> str:\n \n         parent_session_id = current_entry.session_id\n \n-        # Create the new session with parent link\n+        # Create the new session with parent link.\n+        # Persist a stable ``_branched_from`` marker in model_config so\n+        # list_sessions_rich() keeps the branch visible in /resume and\n+        # /sessions even after the parent is reopened and re-ended with a\n+        # different end_reason (e.g. tui_shutdown overwriting 'branched').\n         try:\n             self._session_db.create_session(\n                 session_id=new_session_id,\n                 source=source.platform.value if source.platform else \"gateway\",\n                 model=(self.config.get(\"model\", {}) or {}).get(\"default\") if isinstance(self.config, dict) else None,\n+                model_config={\"_branched_from\": parent_session_id},\n                 parent_session_id=parent_session_id,\n             )\n         except Exception as e:"]},{"file":"hermes_state.py","hunks":["@@ -1598,13 +1598,23 @@ def list_sessions_rich(\n         params = []\n \n         if not include_children:\n-            # Show root sessions and branch sessions (whose parent ended with\n-            # end_reason='branched' before the child was created), while still\n-            # hiding sub-agent runs and compression continuations (which also\n-            # carry a parent_session_id but were spawned while the parent was\n-            # still live — i.e., started_at < parent.ended_at).\n+            # Show root sessions and branch sessions, while still hiding\n+            # sub-agent runs and compression continuations (which also carry a\n+            # parent_session_id but were spawned while the parent was still\n+            # live — i.e., started_at < parent.ended_at).\n+            #\n+            # Branch sessions are identified two ways, OR'd for robustness:\n+            #   1. A stable ``_branched_from`` marker in model_config, written\n+            #      by /branch at creation time. This survives the parent being\n+            #      reopened and re-ended with a different end_reason (e.g.\n+            #      tui_shutdown overwriting 'branched'), which otherwise hides\n+            #      the branch — see issue #20856.\n+            #   2. The legacy heuristic (parent ended with 'branched' before the\n+            #      child started), covering branch sessions created before the\n+            #      marker existed.\n             where_clauses.append(\n                 \"(s.parent_session_id IS NULL\"\n+                \" OR json_extract(s.model_config, '$._branched_from') IS NOT NULL\"\n                 \" OR EXISTS (SELECT 1 FROM sessions p\"\n                 \"            WHERE p.id = s.parent_session_id\"\n                 \"            AND p.end_reason = 'branched'\""]},{"file":"tests/test_hermes_state.py","hunks":["@@ -2716,6 +2716,44 @@ def test_branch_session_visible_in_list(self, db):\n         ids = [s[\"id\"] for s in sessions]\n         assert \"branch\" in ids, \"Branch session should be visible in default list\"\n \n+    def test_branch_session_visible_after_parent_reopen_and_reend(self, db):\n+        \"\"\"Branch sessions stay visible after the parent is reopened and re-ended.\n+\n+        Regression for issue #20856: /branch (aka /fork) sessions vanished from\n+        /resume and /sessions once the parent was reopened (e.g. resumed) and\n+        re-ended with a different end_reason — tui_shutdown overwriting\n+        'branched' — which broke the legacy end_reason heuristic. The stable\n+        _branched_from marker in model_config keeps them visible.\n+        \"\"\"\n+        import json as _json\n+\n+        db.create_session(\"parent\", \"cli\")\n+        db.end_session(\"parent\", \"branched\")\n+        db.create_session(\n+            \"branch\",\n+            \"cli\",\n+            model_config={\"_branched_from\": \"parent\"},\n+            parent_session_id=\"parent\",\n+        )\n+        db.append_message(\"branch\", \"user\", \"Exploring the alternative approach\")\n+\n+        # Marker is persisted at creation time.\n+        branch_row = db.get_session(\"branch\")\n+        cfg = _json.loads(branch_row[\"model_config\"]) if branch_row[\"model_config\"] else {}\n+        assert cfg.get(\"_branched_from\") == \"parent\"\n+\n+        # Visible immediately after branching.\n+        assert \"branch\" in [s[\"id\"] for s in db.list_sessions_rich()]\n+\n+        # Parent reopened + re-ended with a different reason (the bug trigger).\n+        db.reopen_session(\"parent\")\n+        db.end_session(\"parent\", \"tui_shutdown\")\n+\n+        # Branch must STILL be visible — the marker survives the parent's\n+        # end_reason churn, unlike the legacy 'branched' heuristic.\n+        ids = [s[\"id\"] for s in db.list_sessions_rich()]\n+        assert \"branch\" in ids, \"Branch should stay visible after parent re-end\"\n+\n     def test_subagent_session_still_hidden(self, db):\n         \"\"\"Sub-agent children (parent NOT ended with 'branched') remain hidden.\"\"\"\n         db.create_session(\"root\", \"cli\")"]},{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -613,6 +613,71 @@ def close(self):\n     ]\n \n \n+def test_session_branch_persists_branched_from_marker(server, monkeypatch):\n+    \"\"\"TUI /branch must persist a _branched_from marker so the branch stays\n+    visible in /resume and /sessions.\n+\n+    Regression for issue #20856: the TUI branch leaves the parent live (it\n+    never ends it with end_reason='branched'), so list_sessions_rich's legacy\n+    heuristic never surfaces it — the stable model_config marker is the only\n+    thing that keeps a TUI branch visible.\n+    \"\"\"\n+    create_calls = []\n+\n+    class _DB:\n+        def get_session_title(self, _key):\n+            return \"parent-title\"\n+\n+        def get_next_title_in_lineage(self, base):\n+            return f\"{base} 2\"\n+\n+        def create_session(self, new_key, **kwargs):\n+            create_calls.append((new_key, kwargs))\n+            return new_key\n+\n+        def append_message(self, **_kwargs):\n+            return None\n+\n+        def set_session_title(self, _key, _title):\n+            return None\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    monkeypatch.setattr(server, \"_resolve_model\", lambda: \"test/model\")\n+    monkeypatch.setattr(server, \"_new_session_key\", lambda: \"20260101_000001_child0\")\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, \"_init_session\", lambda *_a, **_k: None)\n+    monkeypatch.setattr(server, \"_set_session_context\", lambda *_a, **_k: [])\n+    monkeypatch.setattr(server, \"_clear_session_context\", lambda *_a, **_k: None)\n+    monkeypatch.setattr(server, \"_session_cwd\", lambda _s: \"/tmp/branch-cwd\")\n+\n+    parent_sid = \"parent01\"\n+    parent_key = \"20260101_000000_parent\"\n+    server._sessions[parent_sid] = {\n+        \"session_key\": parent_key,\n+        \"history\": [{\"role\": \"user\", \"content\": \"hello\"}],\n+        \"history_lock\": threading.Lock(),\n+        \"cols\": 80,\n+    }\n+\n+    resp = server.handle_request(\n+        {\"id\": \"b1\", \"method\": \"session.branch\", \"params\": {\"session_id\": parent_sid}}\n+    )\n+\n+    assert \"error\" not in resp, resp\n+    assert len(create_calls) == 1\n+    new_key, kwargs = create_calls[0]\n+    assert new_key == \"20260101_000001_child0\"\n+    assert kwargs[\"parent_session_id\"] == parent_key\n+    # The marker — without it the branch is invisible in /resume and /sessions.\n+    assert kwargs[\"model_config\"] == {\"_branched_from\": parent_key}\n+\n+\n def test_make_agent_accepts_list_system_prompt(server, monkeypatch):\n     captured = {}\n "]},{"file":"tui_gateway/server.py","hunks":["@@ -3702,6 +3702,12 @@ def _(rid, params: dict) -> dict:\n             new_key,\n             source=\"tui\",\n             model=_resolve_model(),\n+            # Stable _branched_from marker so list_sessions_rich() keeps the\n+            # branch visible in /resume and /sessions. The TUI branch leaves\n+            # the parent live (no end_reason='branched'), so the legacy\n+            # end_reason heuristic never matches it — the marker is the only\n+            # thing that surfaces TUI branches. See issue #20856.\n+            model_config={\"_branched_from\": old_key},\n             parent_session_id=old_key,\n             cwd=_session_cwd(session),\n         )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}