diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index aee9e0caa0..223637b399 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -448,14 +448,18 @@ class SessionSessionsMixin: return changed def reopen_session(self, session_id: str) -> None: - """Clear ended_at/end_reason so a session can be resumed; first stamp markerless legacy reset - children that depend on the parent's mutable end_reason (WHERE shared with the listing predicate - so they cannot drift).""" + """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.""" def _do(conn): conn.execute( "UPDATE sessions AS child SET model_config = json_set(" "COALESCE(child.model_config, '{}'), '$._reset_from', child.parent_session_id) " f"WHERE child.parent_session_id = ? AND {_sql_json_extract('child.model_config', '$._reset_from')} IS NULL " + 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) " 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 784ba26e97..a921beae11 100644 --- a/tests/hermes_state/test_hermes_state.py +++ b/tests/hermes_state/test_hermes_state.py @@ -2896,6 +2896,75 @@ class TestListSessionsRich: ).fetchall() assert child_id not in {row["id"] for row in ephemeral} + def test_reopen_keeps_branch_provenance_out_of_legacy_reset_backfill(self, db): + """A same-key branch is never rewritten as a reset successor on reopen.""" + lane_key = "agent:main:telegram:dm:branch" + db.create_session("branch_parent", "telegram", session_key=lane_key) + db.create_session( + "branch_child", + "telegram", + session_key=lane_key, + parent_session_id="branch_parent", + model_config={"_branched_from": "branch_parent"}, + ) + db.end_session("branch_parent", "session_switch") + + db.reopen_session("branch_parent") + + child = db.get_session("branch_child") + assert child is not None + assert json.loads(child["model_config"]) == {"_branched_from": "branch_parent"} + assert "branch_child" in [row["id"] for row in db.list_sessions_rich(source="telegram")] + + def test_reopen_does_not_backfill_child_that_precedes_reset_boundary(self, db): + """A pre-marker branch cannot become a reset child after a later reopen cycle.""" + lane_key = "agent:main:telegram:dm:legacy-branch" + db.create_session("legacy_branch_parent", "telegram", session_key=lane_key) + db.create_session( + "legacy_branch_child", + "telegram", + session_key=lane_key, + parent_session_id="legacy_branch_parent", + ) + db._conn.execute( + "UPDATE sessions SET started_at = ? WHERE id = ?", (100.0, "legacy_branch_child") + ) + db._conn.commit() + db.end_session("legacy_branch_parent", "branched") + db.reopen_session("legacy_branch_parent") + db.end_session("legacy_branch_parent", "session_switch") + db._conn.execute( + "UPDATE sessions SET ended_at = ? WHERE id = ?", (200.0, "legacy_branch_parent") + ) + db._conn.commit() + + db.reopen_session("legacy_branch_parent") + + child = db.get_session("legacy_branch_child") + assert child is not None + assert child["model_config"] is None + + def test_reopen_still_freezes_markerless_legacy_reset_child(self, db): + """A genuine old reset child remains visible after its parent is reopened.""" + lane_key = "agent:main:telegram:dm:legacy-reset" + db.create_session("legacy_reset_parent", "telegram", session_key=lane_key) + db.end_session("legacy_reset_parent", "session_reset") + db.create_session( + "legacy_reset_child", + "telegram", + session_key=lane_key, + parent_session_id="legacy_reset_parent", + ) + + db.reopen_session("legacy_reset_parent") + + child = db.get_session("legacy_reset_child") + assert child is not None + assert json.loads(child["model_config"])["_reset_from"] == "legacy_reset_parent" + assert "legacy_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",