From 714c2be56570b99852705bd17e404897f5dbff5c Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 18 Sep 2026 01:10:57 +0800 Subject: [PATCH] fix(state): keep reset forks from stealing the compression lineage tip A reset fork child (model_config._reset_from, or the legacy same-key heuristic) is a separate user-visible conversation that already lists as its own row, but the compression chain step only excluded branch/delegate/tool children. When a reset sibling ended later than the real continuation it won the last_active tiebreak, so the lineage tip projection landed on it: the newest session became invisible in every list and the reset sibling showed twice (#114271). Exclude _RESET_CHILD_SQL children in both forward-chain walkers (get_compression_chain's _CHAIN_STEP_SQL and the order_by_last_active CTE in list_sessions_rich), mirroring the resume path's exclusion. --- hermes_state_compression.py | 8 ++- hermes_state_sessions.py | 3 +- tests/hermes_state/test_hermes_state.py | 81 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/hermes_state_compression.py b/hermes_state_compression.py index 9f8415faba..4e1039ec0f 100644 --- a/hermes_state_compression.py +++ b/hermes_state_compression.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple from hermes_state_common import ( _BOUNDARY_END_REASONS, _COMPRESSION_LOCK_ROW_SQL as _LOCK_ROW_SQL, _ENDED_ROW_SQL, _ended_by_compression, - _sql_json_extract, _sql_session_last_active, is_automatic_end_reason) + _RESET_CHILD_SQL, _sql_json_extract, _sql_session_last_active, is_automatic_end_reason) # Log-record parity with the origin module (caplog tests pin "hermes_state"). logger = logging.getLogger("hermes_state") @@ -22,7 +22,10 @@ _COOLDOWN_ROW_SQL = ( "SELECT compression_failure_cooldown_until, compression_failure_error FROM sessions WHERE id = ?" ) -# One forward step of get_compression_chain: the preferred continuation child of ``?``. +# One forward step of get_compression_chain: the preferred continuation child of ``?``. A reset +# fork is a separate user-visible conversation (_LISTABLE_CHILD_SQL already surfaces it as its +# own row), so following it here would hijack the lineage tip projection onto the reset sibling +# and make the real continuation invisible (#114271). _CHAIN_STEP_SQL = f""" SELECT child.id FROM sessions parent @@ -31,6 +34,7 @@ _CHAIN_STEP_SQL = f""" AND parent.end_reason = 'compression' AND {_sql_json_extract('child.model_config', '$._branched_from')} IS NULL AND {_sql_json_extract('child.model_config', '$._delegate_from')} IS NULL + AND NOT ({_RESET_CHILD_SQL.format(a='child')}) AND COALESCE(child.source, '') != 'tool' ORDER BY CASE diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 3798669760..d1e37bcdd3 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -16,7 +16,7 @@ from agent.session_activity import ( from hermes_startup_watchdog import report_startup_progress from hermes_state_common import ( _LISTABLE_CHILD_SQL, _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _RECOVERABLE_END_REASONS, - _RECOVERABLE_END_REASONS_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview, + _RECOVERABLE_END_REASONS_SQL, _RESET_CHILD_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview, _sql_json_extract, _sql_session_last_active, _sql_session_last_active_by_id, escape_like as _escape_like, _SQL_IN_CHUNK, _id_chunks, _placeholders as _session_ids_placeholders, ) @@ -1260,6 +1260,7 @@ class SessionSessionsMixin: WHERE parent.end_reason = 'compression' AND {_sql_json_extract('child.model_config', '$._branched_from')} IS NULL AND {_sql_json_extract('child.model_config', '$._delegate_from')} IS NULL + AND NOT ({_RESET_CHILD_SQL.format(a='child')}) AND COALESCE(child.source, '') != 'tool' ), chain_max AS ( diff --git a/tests/hermes_state/test_hermes_state.py b/tests/hermes_state/test_hermes_state.py index f4f01c943f..2a370e4faa 100644 --- a/tests/hermes_state/test_hermes_state.py +++ b/tests/hermes_state/test_hermes_state.py @@ -3173,6 +3173,87 @@ class TestCompressionChainProjection: assert db.get_compression_tip("mid1") == "tip1" assert db.get_compression_tip("tip1") == "tip1" + def test_reset_fork_sibling_does_not_steal_tip_projection(self, db): + """A reset fork child (``model_config._reset_from``, ended LATER than the + real continuation) must not win the chain-step tiebreak. It is a separate + user-visible conversation that already lists as its own row, so letting + the lineage tip land on it hides the true continuation and shows the + reset sibling twice (#114271).""" + import time as _time + t0 = _time.time() - 3600 + + db.create_session("root1", "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1")) + db.append_message("root1", "user", "help me refactor auth") + t_compress_root = t0 + 1800 + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?", + (t_compress_root, "compression", "root1"), + ) + + db.create_session("mid1", "cli", parent_session_id="root1") + db._conn.execute( + "UPDATE sessions SET started_at=? WHERE id=?", (t_compress_root + 1, "mid1"), + ) + db.append_message("mid1", "user", "continuing") + t_compress_mid = t_compress_root + 1800 + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?", + (t_compress_mid, "compression", "mid1"), + ) + + # Real tip: closed by the startup orphan reap, LAST ACTIVE EARLIER than + # the reset fork, so the old tiebreak (last_active DESC) preferred the fork. + db.create_session("tip1", "cli", parent_session_id="mid1") + db._conn.execute( + "UPDATE sessions SET started_at=?, ended_at=?, end_reason=?, last_activity_at=? WHERE id=?", + (t_compress_mid + 1, t_compress_mid + 600, "startup_orphan_reap", + t_compress_mid + 600, "tip1"), + ) + db.append_message("tip1", "user", "latest message") + + # Reset fork of mid1: its own conversation, ended session_reset later. + db.create_session( + "reset1", "cli", parent_session_id="mid1", model_config={"_reset_from": "mid1"}, + ) + db._conn.execute( + "UPDATE sessions SET started_at=?, ended_at=?, end_reason=?, last_activity_at=? WHERE id=?", + (t_compress_mid + 2, t_compress_mid + 900, "session_reset", + t_compress_mid + 900, "reset1"), + ) + db.append_message("reset1", "user", "post reset talk") + db._conn.commit() + + # The chain/tip follow the real continuation, never the reset fork. + assert db.get_compression_tip("root1") == "tip1" + assert db.get_compression_tip("mid1") == "tip1" + + # Projection: the lineage surfaces as tip1; reset1 stays exactly its own + # single row instead of appearing twice (own row + hijacked projection). + sessions = db.list_sessions_rich(source="cli", limit=20) + ids = [s["id"] for s in sessions] + assert ids.count("reset1") == 1 + assert "tip1" in ids + assert "root1" not in ids and "mid1" not in ids + tip_row = next(s for s in sessions if s["id"] == "tip1") + assert tip_row["_lineage_root_id"] == "root1" + assert tip_row["preview"].startswith("latest message") + + # The order_by_last_active chain CTE must not fold the reset fork's + # later activity into the lineage either: a standalone session active + # between the tip and the fork still outranks the projected lineage row. + db.create_session("solo", "cli") + db._conn.execute( + "UPDATE sessions SET started_at=?, last_activity_at=? WHERE id=?", + (t_compress_mid + 300, t_compress_mid + 700, "solo"), + ) + db.append_message("solo", "user", "standalone") + db._conn.commit() + ordered = db.list_sessions_rich(source="cli", limit=20, order_by_last_active=True) + ordered_ids = [s["id"] for s in ordered] + assert ordered_ids.count("reset1") == 1 + assert ordered_ids.index("solo") < ordered_ids.index("tip1") + def test_list_serves_full_lineage_ids_for_projected_rows(self, db): """The projected tip row must carry every chain id. Root and tip alone are not enough client-side: a persisted tile or route can hold