diff --git a/hermes_state_common.py b/hermes_state_common.py index d302a47ae6..817625d112 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -201,6 +201,17 @@ def _legacy_reset_child_sql(alias: str, reasons_sql: str) -> str: f" AND {alias}.session_key != '' AND {alias}.session_key = p.session_key)") +def _non_continuation_child_sql(child: str = "", parent: str = "?") -> str: + """`` AND ...`` clauses rejecting children that are NOT compression continuations of *parent* + (branch/delegate/reset forks, tool sessions). Markers are bound to the parent id: continuations + inherit ``model_config`` verbatim, so a marker naming another row is inherited, not a fork. + ``child`` is the column prefix (``""``, ``"c."``); single owner so prune and compression agree.""" + return "".join( + f" AND COALESCE({_sql_json_extract(f'{child}model_config', f'$.{marker}')}, '') != {parent}\n" + for marker in ("_branched_from", "_delegate_from", "_reset_from") + ) + f" AND COALESCE({child}source, '') != 'tool'\n" + + # A reset starts a separate user-visible conversation though rows keep parent_session_id for lineage. # Stable marker, or the same-key fallback for pre-marker rows (exact key keeps subagent children out). _RESET_CHILD_SQL = (f"{_sql_json_extract('{a}.model_config', '$._reset_from')} IS NOT NULL" diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 26a9b642bc..bf9e1f767b 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from hermes_state_common import ( - AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _placeholders, _sql_json_extract, _sql_session_last_active, + AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _non_continuation_child_sql, _placeholders, _sql_session_last_active, escape_like as _escape_like ) from hermes_startup_watchdog import report_startup_progress @@ -75,14 +75,8 @@ _PRUNE_FILTERS = ( ) _PRUNE_FILTER_NAMES = frozenset(name for name, _, _ in _PRUNE_FILTERS) | {"archived", "include_pinned", "lineage_tips_only"} -# Child ``c`` continues compression-ended ``p``; a fork names ``p`` in its marker (compression copies -# ``model_config``, so a marker naming another row is inherited, not a fork of ``p``). -_CONTINUATION_EDGE_SQL = " AND ".join([ - "p.end_reason = 'compression'", - *(f"COALESCE({_sql_json_extract('c.model_config', f'$.{marker}')}, '') != p.id" - for marker in ("_branched_from", "_delegate_from", "_reset_from")), - "COALESCE(c.source, '') != 'tool'", -]) +# Child ``c`` continues compression-ended ``p`` (same predicate as compression's child lookup). +_CONTINUATION_EDGE_SQL = "p.end_reason = 'compression'\n" + _non_continuation_child_sql("c.", "p.id") def _continued_ancestors_sql(candidates_where: str) -> str: diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 74c861e660..ce2591cf41 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -17,7 +17,8 @@ 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_CHILD_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview, + _RECOVERABLE_END_REASONS_SQL, _RESET_CHILD_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _non_continuation_child_sql, + _shape_preview, _sql_in_window, _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, ) @@ -451,16 +452,9 @@ class SessionSessionsMixin: # quiet and its unkeyed successor (incident was ~60s; 15 min without spanning conversations). _ORPHAN_ADOPTION_MAX_GAP_S = 900.0 - # Children that are NOT compression continuations (branches, delegates, reset forks, tool - # sessions). Markers are bound to the queried parent id: continuations inherit model_config - # verbatim, so presence-matching misclassified them as delegates. Callers bind the parent id - # three times for this filter. - _NON_CONTINUATION_CHILD_FILTER_SQL = ( - f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._branched_from')}, '') != ?\n" - f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._delegate_from')}, '') != ?\n" - f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._reset_from')}, '') != ?\n" - " AND COALESCE({alias}source, '') != 'tool'\n" - ) + # Children that are NOT compression continuations (see _non_continuation_child_sql); presence- + # matching misclassified inherited markers as delegates. Callers bind the parent id three times. + _NON_CONTINUATION_CHILD_FILTER_SQL = _non_continuation_child_sql("{alias}") def end_session(self, session_id: str, end_reason: str) -> None: """Mark a session ended; the first end_reason wins (a compression split must keep