# NousResearch/hermes-agent · Branch session visibility bug after parent reopen

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #39214. Verify it addresses your case.
- **Recommended:** read PR #39214 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-39214`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #20856: /branch (fork) sessions vanish from session listings after parent is closed

## Prior & competing PRs

- **#21089: fix(state): keep /branch sessions visible after parent reopen**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#20864`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#39214: fix(state): keep /branch sessions visible after parent reopen (salvage #20864)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -7166,7 +7166,11 @@ def _handle_branch_command(self, cmd_original: str) -> None:
             except Exception:
                 pass
     
    -        # Create the new session with parent link
    +        # Create the new session with parent link.
    +        # Persist a stable ``_branched_from`` marker in model_config so
    +        # list_sessions_rich() can keep the branch visible in /resume and
    +        # /sessions even after the parent is reopened and re-ended with a
    +        # different end_reason (e.g. tui_shutdown overwriting 'branched').
             try:
                 self._session_db.create_session(
                     session_id=new_session_id,
    ```

    ```diff
    @@ -7175,6 +7179,7 @@ def _handle_branch_command(self, cmd_original: str) -> None:
                     model_config={
                         "max_iterations": self.max_turns,
                         "reasoning_config": self.reasoning_config,
    +                    "_branched_from": parent_session_id,
                     },
                     parent_session_id=parent_session_id,
                 )
    ```
  - `gateway/run.py`

    ```diff
    @@ -13956,12 +13956,17 @@ async def _handle_branch_command(self, event: MessageEvent) -> str:
     
             parent_session_id = current_entry.session_id
     
    -        # Create the new session with parent link
    +        # Create the new session with parent link.
    +        # Persist a stable ``_branched_from`` marker in model_config so
    +        # list_sessions_rich() keeps the branch visible in /resume and
    +        # /sessions even after the parent is reopened and re-ended with a
    +        # different end_reason (e.g. tui_shutdown overwriting 'branched').
             try:
                 self._session_db.create_session(
                     session_id=new_session_id,
                     source=source.platform.value if source.platform else "gateway",
                     model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None,
    +                model_config={"_branched_from": parent_session_id},
                     parent_session_id=parent_session_id,
                 )
             except Exception as e:
    ```
  - `hermes_state.py`

    ```diff
    @@ -1598,13 +1598,23 @@ def list_sessions_rich(
             params = []
     
             if not include_children:
    -            # Show root sessions and branch sessions (whose parent ended with
    -            # end_reason='branched' before the child was created), while still
    -            # hiding sub-agent runs and compression continuations (which also
    -            # carry a parent_session_id but were spawned while the parent was
    -            # still live — i.e., started_at < parent.ended_at).
    +            # Show root sessions and branch sessions, while still hiding
    +            # sub-agent runs and compression continuations (which also carry a
    +            # parent_session_id but were spawned while the parent was still
    +            # live — i.e., started_at < parent.ended_at).
    +            #
    +            # Branch sessions are identified two ways, OR'd for robustness:
    +            #   1. A stable ``_branched_from`` marker in model_config, written
    +            #      by /branch at creation time. This survives the parent being
    +            #      reopened and re-ended with a different end_reason (e.g.
    +            #      tui_shutdown overwriting 'branched'), which otherwise hides
    +            #      the branch — see issue #20856.
    +            #   2. The legacy heuristic (parent ended with 'branched' before the
    +            #      child started), covering branch sessions created before the
    +            #      marker existed.
                 where_clauses.append(
                     "(s.parent_session_id IS NULL"
    +                " OR json_extract(s.model_config, '$._branched_from') IS NOT NULL"
                     " OR EXISTS (SELECT 1 FROM sessions p"
                     "            WHERE p.id = s.parent_session_id"
                     "            AND p.end_reason = 'branched'"
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -2716,6 +2716,44 @@ def test_branch_session_visible_in_list(self, db):
             ids = [s["id"] for s in sessions]
             assert "branch" in ids, "Branch session should be visible in default list"
     
    +    def test_branch_session_visible_after_parent_reopen_and_reend(self, db):
    +        """Branch sessions stay visible after the parent is reopened and re-ended.
    +
    +        Regression for issue #20856: /branch (aka /fork) sessions vanished from
    +        /resume and /sessions once the parent was reopened (e.g. resumed) and
    +        re-ended with a different end_reason — tui_shutdown overwriting
    +        'branched' — which broke the legacy end_reason heuristic. The stable
    +        _branched_from marker in model_config keeps them visible.
    +        """
    +        import json as _json
    +
    +        db.create_session("parent", "cli")
    +        db.end_session("parent", "branched")
    +        db.create_session(
    +            "branch",
    +            "cli",
    +            model_config={"_branched_from": "parent"},
    +            parent_session_id="parent",
    +        )
    +        db.append_message("branch", "user", "Exploring the alternative approach")
    +
    +        # Marker is persisted at creation time.
    +        branch_row = db.get_session("branch")
    +        cfg = _json.loads(branch_row["model_config"]) if branch_row["model_config"] else {}
    +        assert cfg.get("_branched_from") == "parent"
    +
    +        # Visible immediately after branching.
    +        assert "branch" in [s["id"] for s in db.list_sessions_rich()]
    +
    +        # Parent reopened + re-ended with a different reason (the bug trigger).
    +        db.reopen_session("parent")
    +        db.end_session("parent", "tui_shutdown")
    +
    +        # Branch must STILL be visible — the marker survives the parent's
    +        # end_reason churn, unlike the legacy 'branched' heuristic.
    +        ids = [s["id"] for s in db.list_sessions_rich()]
    +        assert "branch" in ids, "Branch should stay visible after parent re-end"
    +
         def test_subagent_session_still_hidden(self, db):
             """Sub-agent children (parent NOT ended with 'branched') remain hidden."""
             db.create_session("root", "cli")
    ```
  - `tests/tui_gateway/test_protocol.py`

    ```diff
    @@ -613,6 +613,71 @@ def close(self):
         ]
     
     
    +def test_session_branch_persists_branched_from_marker(server, monkeypatch):
    +    """TUI /branch must persist a _branched_from marker so the branch stays
    +    visible in /resume and /sessions.
    +
    +    Regression for issue #20856: the TUI branch leaves the parent live (it
    +    never ends it with end_reason='branched'), so list_sessions_rich's legacy
    +    heuristic never surfaces it — the stable model_config marker is the only
    +    thing that keeps a TUI branch visible.
    +    """
    +    create_calls = []
    +
    +    class _DB:
    +        def get_session_title(self, _key):
    +            return "parent-title"
    +
    +        def get_next_title_in_lineage(self, base):
    +            return f"{base} 2"
    +
    +        def create_session(self, new_key, **kwargs):
    +            create_calls.append((new_key, kwargs))
    +            return new_key
    +
    +        def append_message(self, **_kwargs):
    +            return None
    +
    +        def set_session_title(self, _key, _title):
    +            return None
    +
    +    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    +    monkeypatch.setattr(server, "_resolve_model", lambda: "test/model")
    +    monkeypatch.setattr(server, "_new_session_key", lambda: "20260101_000001_child0")
    +    monkeypatch.setattr(
    +        server,
    +        "_make_agent",
    +        lambda _sid, key, session_id=None: types.SimpleNamespace(
    +            model="test/model", session_id=session_id or key
    +        ),
    +    )
    +    monkeypatch.setattr(server, "_init_session", lambda *_a, **_k: None)
    +    monkeypatch.setattr(server, "_set_session_context", lambda *_a, **_k: [])
    +    monkeypatch.setattr(server, "_clear_session_context", lambda *_a, **_k: None)
    +    monkeypatch.setattr(server, "_session_cwd", lambda _s: "/tmp/branch-cwd")
    +
    +    parent_sid = "parent01"
    +    parent_key = "20260101_000000_parent"
    +    server._sessions[parent_sid] = {
    +        "session_key": parent_key,
    +        "history": [{"role": "user", "content": "hello"}],
    +        "history_lock": threading.Lock(),
    +        "cols": 80,
    +    }
    +
    +    resp = server.handle_request(
    +        {"id": "b1", "method": "session.branch", "params": {"session_id": parent_sid}}
    +    )
    +
    +    assert "error" not in resp, resp
    +    assert len(create_calls) == 1
    +    new_key, kwargs = create_calls[0]
    +    assert new_key == "20260101_000001_child0"
    +    assert kwargs["parent_session_id"] == parent_key
    +    # The marker — without it the branch is invisible in /resume and /sessions.
    +    assert kwargs["model_config"] == {"_branched_from": parent_key}
    +
    +
     def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
         captured = {}
     
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -3702,6 +3702,12 @@ def _(rid, params: dict) -> dict:
                 new_key,
                 source="tui",
                 model=_resolve_model(),
    +            # Stable _branched_from marker so list_sessions_rich() keeps the
    +            # branch visible in /resume and /sessions. The TUI branch leaves
    +            # the parent live (no end_reason='branched'), so the legacy
    +            # end_reason heuristic never matches it — the marker is the only
    +            # thing that surfaces TUI branches. See issue #20856.
    +            model_config={"_branched_from": old_key},
                 parent_session_id=old_key,
                 cwd=_session_cwd(session),
             )
    ```
- **#20864: fix(state): keep /branch sessions visible after parent reopen**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `1a4e8f704`, `#39214`, `#20856`

## Fix status

- Merged to main in PR #39214 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-04`.
- Subsystem: `hermes_cli`
