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
This commit is contained in:
1 parent
e168b67e42
commit
52201ac9f7
2 files changed
+183
-2
No files matched your search
+24
-2
@@ -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:
|
||||
|
||||
@@ -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'))
|
||||
Reference in new issue
Block a user