NousResearch/hermes-agent

Branch session visibility bug after parent reopen

Cluster 20856 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #39214 on 4 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #39214. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #39214. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #39214 fix(state): keep /branch sessions visible after parent reopen (salvage #20864)

    merged to main

    files: cli.py, gateway/run.py, hermes_state.py, tests/test_hermes_state.py, tests/tui_gateway/test_protocol.py, tui_gateway/server.py

    Show the diff
    • cli.py
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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),
               )

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #20864 fix(state): keep /branch sessions visible after parent reopen · merged via another change → 1a4e8f7, #39214, #20856
  • #21089 fix(state): keep /branch sessions visible after parent reopen · other → #20864
Closed issues · 1
  • Closed#20856/branch (fork) sessions vanish from session listings after parent is closed
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#39214
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
4 Jun 2026

For your agent

The same facts, typed.