From a3fb48b2ceb382ade3ecbb99e2cfa475b4c8abcb Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 4 Jun 2026 22:12:21 +0530 Subject: [PATCH] fix(state): keep /branch sessions visible after parent reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /branch (aka /fork) sessions vanished from /resume and /sessions. Both surfaces funnel through list_sessions_rich(include_children=False), which hid any session with a parent_session_id unless identified as a branch via a heuristic — parent.end_reason == 'branched' AND child.started_at >= parent.ended_at. Two ways that heuristic failed: 1. CLI/gateway branches: once the parent was reopened (e.g. resumed) and re-ended with a different end_reason (tui_shutdown overwriting 'branched'), the heuristic stopped matching and the branch was hidden permanently. 2. TUI branches (tui_gateway session.branch): the TUI never ends the parent as 'branched' — it creates the child while the parent is still live — so the heuristic NEVER matched and TUI branches were hidden from the moment they were created (this is the macOS desktop app's primary symptom). Fix: persist a stable '_branched_from' marker in the branch session's model_config at creation time across ALL THREE branch paths (CLI cli.py, gateway gateway/run.py, and TUI tui_gateway/server.py), and OR a json_extract(model_config, '$._branched_from') IS NOT NULL check into the list_sessions_rich filter. The marker is immutable across the parent's lifecycle, so the branch stays visible regardless of how/whether the parent is ended. The legacy end_reason heuristic is kept (OR'd) so pre-existing branches remain visible. Subagent/compression children (no marker, parent not 'branched') stay correctly hidden. Fixes #20856. Approach by liuhao1024 (PR #20864); reimplemented on current main, extended to the TUI branch path (which the original missed), with regression tests for the reopen+re-end scenario and the TUI marker persistence. --- cli.py | 7 +++- gateway/run.py | 7 +++- hermes_state.py | 20 ++++++--- tests/test_hermes_state.py | 38 +++++++++++++++++ tests/tui_gateway/test_protocol.py | 65 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 6 +++ 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index 03ed1df00..d429e14fc 100644 --- a/cli.py +++ b/cli.py @@ -7166,7 +7166,11 @@ class HermesCLI: 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 @@ class HermesCLI: model_config={ "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, + "_branched_from": parent_session_id, }, parent_session_id=parent_session_id, ) diff --git a/gateway/run.py b/gateway/run.py index 6d2f65987..7887ec23c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13956,12 +13956,17 @@ class GatewayRunner: 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: diff --git a/hermes_state.py b/hermes_state.py index 1a3a4ff4e..9c67779a6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1598,13 +1598,23 @@ class SessionDB: 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'" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f5e4f69ae..8d0b55775 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2716,6 +2716,44 @@ class TestListSessionsRich: 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") diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index daa3a9145..9a6b7d30b 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -613,6 +613,71 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server, ] +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 = {} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5ac7ccf5d..ace784135 100644 --- a/tui_gateway/server.py +++ b/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), )