From 52201ac9f7552bd06d67571ec0821f9e7615c0d4 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 14 Aug 2026 09:43:07 -0300 Subject: [PATCH] fix(session): recognize compression continuation across sub-second timestamp overlap _is_continuation_session() required float(child.started_at) >= float(parent.ended_at). A compression/cli_close handoff can persist the continuation row a few ms BEFORE the parent's ended_at lands (write-order race), so the strict comparison returned False despite every other piece of evidence (direct parent_session_id link, same source, end_reason=compression/cli_close, non-fork). The backend then labeled the continuation as relationship_type=child_session, causing duplicate same-title sidebar rows and spurious child-session entries (#6931). Allow a small tolerance (2s) for handoff-consistent timing; beyond it, an exact title match on the parent/child rows still confirms the same handoff. Fork, cross-source, and non-compression/cli_close guards are preserved, and materially overlapping children remain separate child sessions. Adds API-level regression tests (sub-second overlap forms one lineage; material overlap stays a child session; same-title evidence beyond tolerance) plus focused unit coverage of the classifier. Closes #6931 --- api/agent_sessions.py | 26 +++- tests/test_session_lineage_metadata_api.py | 159 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index c0a5078f..927f7063 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -53,6 +53,15 @@ MESSAGING_SOURCES = { CLI_MIN_UNTITLED_MESSAGE_COUNT = 6 CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 +# Sub-second scheduling/write-order races during a compression/cli_close +# handoff can persist the continuation row with ``started_at`` a few +# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance +# below lets those rows still classify as the next segment; the fork / +# cross-source / end_reason guards in ``_is_continuation_session`` already +# rule out unrelated rows, so a small window cannot collapse genuinely +# concurrently started children. +CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 + SOURCE_LABELS = { 'acp': 'ACP', 'api_server': 'API', @@ -312,7 +321,8 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: by ``hermes -c`` also records a new child session; for sidebar projection it should continue the same visible conversation rather than becoming a separate child-session row. Plain parent/child links that started before the - parent's ended boundary remain child sessions. + parent's ended boundary remain child sessions. A small scheduling/write-order + overlap between the handoff boundary timestamps is tolerated (#6931). Do not collapse lineage across raw sources. A WebUI session that continues from a Telegram/CLI/etc. parent must remain visible as its own surface-owned @@ -336,9 +346,21 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: # continuations when no boundary timestamp is available. return True try: - return float(child.get('started_at') or 0) >= float(ended_at) + child_started = float(child.get('started_at') or 0) + parent_ended = float(ended_at) except (TypeError, ValueError): return False + if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS: + return True + # Timestamps disagree beyond the tolerance. Independent evidence can still + # identify the same handoff: a compression/cli_close continuation carries + # the parent's conversation title verbatim (the #6931 rows had identical + # titles), while a genuinely new or forked session gets its own + # auto-numbered/user title. Requiring an exact title match keeps unrelated + # same-source children separate even when they overlap the parent. + parent_title = str(parent.get('title') or '').strip() + child_title = str(child.get('title') or '').strip() + return bool(parent_title and child_title and parent_title == child_title) def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None: diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index 22635e57..f79848ce 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -693,3 +693,162 @@ def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_fil ] finally: conn.close() + + +def test_compression_continuation_tolerates_sub_second_timestamp_overlap(_isolate): + """#6931: a compression continuation recorded a few ms BEFORE the parent's + ended_at (write-order race) is still one lineage in /api/sessions.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_race_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_race_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_race_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + # child.started_at lands 0.1s BEFORE parent.ended_at — the #6931 race. + _insert_state_row( + conn, + "lineage_race_tip", + parent="lineage_race_root", + started_at=t0 + 5 - 0.1, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_race_tip"] + assert tip.get("parent_session_id") == "lineage_race_root" + assert tip.get("_lineage_root_id") == "lineage_race_root" + assert tip.get("_lineage_tip_id") == "lineage_race_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_materially_overlapping_compression_child_stays_child_session(_isolate): + """#6931: a child that started long before the compression parent ended + (beyond tolerance, no matching title) must remain a separate child.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_overlap_root", title="Root title", updated_at=t0) + _save_webui_session("lineage_overlap_child", title="Child title", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_overlap_root", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended — a genuine concurrent child. + _insert_state_row( + conn, + "lineage_overlap_child", + parent="lineage_overlap_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + child = rows["lineage_overlap_child"] + assert child.get("relationship_type") == "child_session" + assert child.get("parent_session_id") == "lineage_overlap_root" + assert "_lineage_root_id" not in child + assert "_compression_segment_count" not in child + finally: + conn.close() + + +def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_isolate): + """#6931: a direct same-source child with the parent's exact title is still + a continuation even when the boundary timestamps disagree beyond the + tolerance — timestamp is not the only gate.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_title_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_title_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_title_root", + title="Shared conversation", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended but carries the exact title. + _insert_state_row( + conn, + "lineage_title_tip", + title="Shared conversation", + parent="lineage_title_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_title_tip"] + assert tip.get("_lineage_root_id") == "lineage_title_root" + assert tip.get("_lineage_tip_id") == "lineage_title_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_continuation_classification_timestamp_tolerance_and_guards(): + """#6931 focused unit coverage of _is_continuation_session: tolerance, + title evidence fallback, and preserved fork/cross-source/end_reason guards.""" + from api.agent_sessions import _is_continuation_session + + def make_parent(**over): + row = { + 'source': 'webui', + 'end_reason': 'compression', + 'ended_at': 1000.0, + 'title': 'Shared conversation', + } + row.update(over) + return row + + def make_child(**over): + row = { + 'source': 'webui', + 'started_at': 1000.05, + 'title': 'Shared conversation', + } + row.update(over) + return row + + # Normal non-overlapping ordering: continuation. + assert _is_continuation_session(make_parent(), make_child()) + # Sub-second overlap (observed -0.06..-0.07s in #6931): continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=999.95)) + # Overlap inside the 2s tolerance: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=998.5)) + # Overlap beyond tolerance but exact title evidence: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=950.0)) + # Overlap beyond tolerance with a different title: separate child. + assert not _is_continuation_session( + make_parent(), make_child(started_at=950.0, title='Another conversation') + ) + # Fork guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, session_source='fork') + ) + # Cross-source guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, source='telegram') + ) + # Non-compression/cli_close parents never continue. + assert not _is_continuation_session( + make_parent(end_reason='user_stop'), make_child(started_at=999.95) + ) + # Missing/unparsable boundary timestamps degrade to False (no crash). + assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child()) + assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number'))