fix(state): keep /branch sessions visible after parent reopen

/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.
This commit is contained in:
liuhao1024
2026-06-04 22:12:21 +05:30
committed by kshitij
parent d1367355d5
commit a3fb48b2ce
6 changed files with 136 additions and 7 deletions

7
cli.py
View File

@ -7166,7 +7166,11 @@ class HermesCLI:
except Exception: except Exception:
pass 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: try:
self._session_db.create_session( self._session_db.create_session(
session_id=new_session_id, session_id=new_session_id,
@ -7175,6 +7179,7 @@ class HermesCLI:
model_config={ model_config={
"max_iterations": self.max_turns, "max_iterations": self.max_turns,
"reasoning_config": self.reasoning_config, "reasoning_config": self.reasoning_config,
"_branched_from": parent_session_id,
}, },
parent_session_id=parent_session_id, parent_session_id=parent_session_id,
) )

View File

@ -13956,12 +13956,17 @@ class GatewayRunner:
parent_session_id = current_entry.session_id 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: try:
self._session_db.create_session( self._session_db.create_session(
session_id=new_session_id, session_id=new_session_id,
source=source.platform.value if source.platform else "gateway", 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=(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, parent_session_id=parent_session_id,
) )
except Exception as e: except Exception as e:

View File

@ -1598,13 +1598,23 @@ class SessionDB:
params = [] params = []
if not include_children: if not include_children:
# Show root sessions and branch sessions (whose parent ended with # Show root sessions and branch sessions, while still hiding
# end_reason='branched' before the child was created), while still # sub-agent runs and compression continuations (which also carry a
# hiding sub-agent runs and compression continuations (which also # parent_session_id but were spawned while the parent was still
# carry a parent_session_id but were spawned while the parent was # live — i.e., started_at < parent.ended_at).
# 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( where_clauses.append(
"(s.parent_session_id IS NULL" "(s.parent_session_id IS NULL"
" OR json_extract(s.model_config, '$._branched_from') IS NOT NULL"
" OR EXISTS (SELECT 1 FROM sessions p" " OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = s.parent_session_id" " WHERE p.id = s.parent_session_id"
" AND p.end_reason = 'branched'" " AND p.end_reason = 'branched'"

View File

@ -2716,6 +2716,44 @@ class TestListSessionsRich:
ids = [s["id"] for s in sessions] ids = [s["id"] for s in sessions]
assert "branch" in ids, "Branch session should be visible in default list" 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): def test_subagent_session_still_hidden(self, db):
"""Sub-agent children (parent NOT ended with 'branched') remain hidden.""" """Sub-agent children (parent NOT ended with 'branched') remain hidden."""
db.create_session("root", "cli") db.create_session("root", "cli")

View File

@ -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): def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
captured = {} captured = {}

View File

@ -3702,6 +3702,12 @@ def _(rid, params: dict) -> dict:
new_key, new_key,
source="tui", source="tui",
model=_resolve_model(), 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, parent_session_id=old_key,
cwd=_session_cwd(session), cwd=_session_cwd(session),
) )