From 82193eb446dfeaf2d3161a5d88a99bcb239dca31 Mon Sep 17 00:00:00 2001 From: Hermes Agent <11388531+Lei-k@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:19:57 -0700 Subject: [PATCH 1/2] fix(state): delegate and branch children stop inheriting a compressed parent's gateway routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _INHERIT_PARENT_ROUTING_SQL copied session_key/chat_id/chat_type/thread_id/user_id/ display_name/origin_json/transport_profile onto ANY child whose parent row ended on 'compression'. A delegate or branch fork of such a parent is not that conversation's continuation, so it must not take over the route: two live rows holding one routing key lets peer recovery repoint gateway traffic into a subagent's transcript (#116322). Exclude children whose `_delegate_from` / `_branched_from` marker names the queried parent id. Value-match rather than presence: a compression continuation inherits model_config verbatim (marker included), so a presence check would misclassify it — the same idiom _NON_CONTINUATION_CHILD_FILTER_SQL already uses. Cherry-picked from #109699 (Lei-k) minus its unrelated tools/delegate_tool_dispatch.py nested-dispatch hunk; test relocated to tests/hermes_state/ where the suite now lives. --- hermes_state_sessions.py | 15 ++++- ...test_delegate_child_routing_inheritance.py | 56 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_state/test_delegate_child_routing_inheritance.py diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 5d82156ad63b..bb55b120f2bf 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -236,6 +236,15 @@ def _inherit_col_sql(col: str, extra: str = "") -> str: )) + "\n WHERE id = ? AND parent_session_id IS NOT NULL" ) +# A delegate/branch fork of a row that happens to have ended on compression is still not that +# conversation's continuation. Markers are matched against the QUERIED parent id rather than mere +# presence, for the same reason as _NON_CONTINUATION_CHILD_FILTER_SQL: a continuation inherits its +# parent's model_config verbatim, so presence-matching would misclassify it as a delegate. +_FORK_EDGE_EXCLUSION_SQL = "".join( + f"\n AND COALESCE({_sql_json_extract('model_config', f'$.{marker}')}, '')" + "\n != parent_session_id" + for marker in ("_delegate_from", "_branched_from") +) _INHERIT_PARENT_ROUTING_SQL = ( "UPDATE sessions\n SET " + _INHERIT_SEP.join(_inherit_col_sql(c) for c in ( @@ -248,6 +257,7 @@ def _inherit_col_sql(col: str, extra: str = "") -> str: " WHERE p.id = sessions.parent_session_id\n" " AND p.end_reason = 'compression'\n" " )" + + _FORK_EDGE_EXCLUSION_SQL ) @@ -276,7 +286,10 @@ def _inherit_parent_session_metadata(conn, session_id: str) -> None: """NULL-fill a child's cwd/git/profile from its parent (profile_name only within the same ``agent::`` namespace). Gateway routing columns are inherited ONLY by compression forks (a crash before the gateway re-records the peer would strand the child unroutable); delegate - children must NOT inherit them (peer recovery could repoint traffic into a subagent's session).""" + and branch children must NOT inherit them (peer recovery could repoint traffic into a + subagent's session), including when their parent row itself ended on compression — a long + batch outlives its coordinator's rotation, and two live rows holding one routing key is the + shape reported in #92859.""" conn.execute(_INHERIT_PARENT_META_SQL, (session_id,)) conn.execute(_INHERIT_PARENT_ROUTING_SQL, (session_id,)) diff --git a/tests/hermes_state/test_delegate_child_routing_inheritance.py b/tests/hermes_state/test_delegate_child_routing_inheritance.py new file mode 100644 index 000000000000..0da46d2caa40 --- /dev/null +++ b/tests/hermes_state/test_delegate_child_routing_inheritance.py @@ -0,0 +1,56 @@ +"""A delegate/branch child must never inherit its parent's gateway routing columns. + +``_inherit_parent_session_metadata`` states the rule in its own docstring — routing columns are +inherited ONLY across a compression fork, because "peer recovery could repoint traffic into a +subagent's session" — but the SQL gated on the PARENT's ``end_reason`` alone. A delegate child +whose gateway parent had already rotated on compression (long batch, queued child, detached unit) +therefore took the chat's ``session_key``/``chat_id``/``user_id``, leaving two live rows holding one +routing key (NousResearch/hermes-agent#92859). + +Real ``SessionDB`` on a temp path, no mocks: the contract asserted here is the RELATIONSHIP between +the two child kinds — a compression continuation keeps inheriting, a delegate/branch fork does not. +""" + +from __future__ import annotations + +import pytest + +from hermes_state import SessionDB + +ROUTING_COLUMNS = ("session_key", "chat_id", "chat_type", "thread_id", "user_id") + + +@pytest.fixture() +def db(tmp_path): + session_db = SessionDB(db_path=tmp_path / "state.db") + try: + yield session_db + finally: + session_db.close() + + +def _compressed_gateway_parent(db: SessionDB) -> dict: + """A gateway conversation that rotated on compression, i.e. the one case that inherits.""" + db.create_session( + "parent", source="telegram", session_key="agent:main:telegram:dm:42", + chat_id="42", chat_type="dm", thread_id="7", user_id="u1", + ) + db.end_session("parent", "compression") + return db.get_session("parent") + + +@pytest.mark.parametrize("marker", ["_delegate_from", "_branched_from"]) +def test_delegate_and_branch_children_do_not_take_over_the_parent_route(db: SessionDB, marker: str) -> None: + parent = _compressed_gateway_parent(db) + + db.create_session( + "worker", source="subagent", parent_session_id="parent", model_config={marker: "parent"}, + ) + db.create_session("continuation", source="telegram", parent_session_id="parent") + + worker, continuation = db.get_session("worker"), db.get_session("continuation") + for column in ROUTING_COLUMNS: + assert parent[column], f"fixture must seed {column}" + # The continuation IS the conversation; the worker is an internal transcript. + assert continuation[column] == parent[column], column + assert worker[column] is None, column From 945d95f161430fc39d96237c32c62a649d73338a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:22:06 -0700 Subject: [PATCH 2/2] test(state): a compression continuation of a branch child still inherits its route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the reason the exclusion matches the marker's VALUE against the parent id instead of its presence: a continuation copies model_config verbatim, so a `_branched_from` marker rides along. A presence check (#116377's variant) would leave the continuation unroutable — exactly the shape this test fails on against that branch. Salvages #109699 (@Lei-k). Supersedes #116377 (@strzhao). Co-authored-by: strzhao --- ...test_delegate_child_routing_inheritance.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/hermes_state/test_delegate_child_routing_inheritance.py b/tests/hermes_state/test_delegate_child_routing_inheritance.py index 0da46d2caa40..a4c80ee8be69 100644 --- a/tests/hermes_state/test_delegate_child_routing_inheritance.py +++ b/tests/hermes_state/test_delegate_child_routing_inheritance.py @@ -5,7 +5,7 @@ subagent's session" — but the SQL gated on the PARENT's ``end_reason`` alone. A delegate child whose gateway parent had already rotated on compression (long batch, queued child, detached unit) therefore took the chat's ``session_key``/``chat_id``/``user_id``, leaving two live rows holding one -routing key (NousResearch/hermes-agent#92859). +routing key (NousResearch/hermes-agent#116322, first reported as #92859). Real ``SessionDB`` on a temp path, no mocks: the contract asserted here is the RELATIONSHIP between the two child kinds — a compression continuation keeps inheriting, a delegate/branch fork does not. @@ -54,3 +54,22 @@ def test_delegate_and_branch_children_do_not_take_over_the_parent_route(db: Sess # The continuation IS the conversation; the worker is an internal transcript. assert continuation[column] == parent[column], column assert worker[column] is None, column + + +def test_compression_continuation_of_a_branch_child_keeps_inheriting(db: SessionDB) -> None: + """A continuation copies model_config verbatim — the branch marker comes along — so the + exclusion must match the marker's VALUE against the parent id, not its mere presence.""" + db.create_session("root", source="telegram") + db.create_session( + "branch", source="telegram", parent_session_id="root", model_config={"_branched_from": "root"}, + session_key="agent:main:telegram:dm:42", chat_id="42", chat_type="dm", user_id="u1", + ) + db.end_session("branch", "compression") + + db.create_session( + "continuation", source="telegram", parent_session_id="branch", model_config={"_branched_from": "root"}, + ) + + continuation = db.get_session("continuation") + assert continuation["session_key"] == "agent:main:telegram:dm:42" + assert continuation["chat_id"] == "42" and continuation["user_id"] == "u1"