Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion hermes_state_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
)


Expand Down Expand Up @@ -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:<ns>:`` 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,))

Expand Down
75 changes: 75 additions & 0 deletions tests/hermes_state/test_delegate_child_routing_inheritance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""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#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.
"""

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


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"
Loading