From 3b94ec1be157a56de521df7fc6e7f14e8d4b719c 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] 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 5d82156ad6..bb55b120f2 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -236,6 +236,15 @@ _INHERIT_PARENT_META_SQL = ( )) + "\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 @@ _INHERIT_PARENT_ROUTING_SQL = ( " WHERE p.id = sessions.parent_session_id\n" " AND p.end_reason = 'compression'\n" " )" + + _FORK_EDGE_EXCLUSION_SQL ) @@ -276,7 +286,10 @@ class SessionSessionsMixin: """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 0000000000..0da46d2caa --- /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