From 5ffdf823aec9d2a8022400db00923b9b6aae4593 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:29:03 -0700 Subject: [PATCH] fix: compare legacy reset children against the parent's started_at, not ended_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reopen backfill guard `child.started_at >= parent.ended_at` compared against the parent's CURRENT end boundary. A genuine markerless legacy reset child whose parent was later reopened and re-ended has started_at earlier than that second boundary, so it was no longer frozen with `_reset_from`; once end_reason cleared it dropped out of /sessions as ephemeral — the multi-cycle gateway-peer shape the issue describes. Comparing against the parent's started_at still rejects children that predate the parent while keeping every earlier-boundary reset child; the marker and source exclusions are unchanged. Review finding: cycled parent's earlier reset child lost its `_reset_from` stamp and vanished from the session list on reopen. --- hermes_state_sessions.py | 9 +++++---- tests/hermes_state/test_hermes_state.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 223637b399..7f65916994 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -448,9 +448,10 @@ class SessionSessionsMixin: return changed def reopen_session(self, session_id: str) -> None: - """Clear ended_at/end_reason so a session can be resumed; first freeze only legacy reset children - whose creation order proves they followed the reset. The shared legacy predicate keeps list-time - compatibility broad, while this durable backfill must not overwrite explicit fork provenance.""" + """Clear ended_at/end_reason so a session can be resumed; first freeze markerless legacy reset + children, skipping explicit fork/delegate provenance and children that predate the parent itself. + The guard compares against the parent's started_at, not its current ended_at: a parent that was + reopened and re-ended later still owns reset children from its earlier boundaries.""" def _do(conn): conn.execute( "UPDATE sessions AS child SET model_config = json_set(" @@ -459,7 +460,7 @@ class SessionSessionsMixin: f"AND {_sql_json_extract('child.model_config', '$._branched_from')} IS NULL " f"AND {_sql_json_extract('child.model_config', '$._delegate_from')} IS NULL " "AND COALESCE(child.source, '') != 'tool' " - "AND child.started_at >= (SELECT p.ended_at FROM sessions p WHERE p.id = child.parent_session_id) " + "AND child.started_at >= (SELECT p.started_at FROM sessions p WHERE p.id = child.parent_session_id) " f"AND {_legacy_reset_child_sql('child', _session_ids_placeholders(_RESET_END_REASONS))}", (session_id, *_RESET_END_REASONS), ) diff --git a/tests/hermes_state/test_hermes_state.py b/tests/hermes_state/test_hermes_state.py index 7d58c4bdd0..cc783b735c 100644 --- a/tests/hermes_state/test_hermes_state.py +++ b/tests/hermes_state/test_hermes_state.py @@ -2944,6 +2944,31 @@ class TestListSessionsRich: assert child is not None assert child["model_config"] is None + def test_reopen_backfills_legacy_reset_child_of_cycled_parent(self, db): + """A markerless reset child from an earlier boundary is still frozen after the parent was + reopened and re-ended later (its started_at precedes the parent's current ended_at).""" + lane_key = "agent:main:telegram:dm:cycled" + db.create_session("cycled_parent", "telegram", session_key=lane_key) + db.end_session("cycled_parent", "session_reset") + db.create_session( + "cycled_reset_child", "telegram", session_key=lane_key, parent_session_id="cycled_parent" + ) + db._conn.execute( + "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", ("cycled_parent",) + ) + db._conn.commit() + db.end_session("cycled_parent", "session_switch") + db._conn.execute( + "UPDATE sessions SET ended_at = ended_at + 100 WHERE id = ?", ("cycled_parent",) + ) + db._conn.commit() + + db.reopen_session("cycled_parent") + + child = db.get_session("cycled_reset_child") + assert json.loads(child["model_config"]) == {"_reset_from": "cycled_parent"} + assert "cycled_reset_child" in [row["id"] for row in db.list_sessions_rich(source="telegram")] + def test_reset_parent_does_not_surface_unrelated_child(self, db): db.create_session( "reset_parent",