fix(#5598): stabilize lineage grouping across live refreshes

This commit is contained in:
Rod Boev
2026-07-06 04:49:24 -04:00
parent f80e641a9a
commit 409be9bcd4
5 changed files with 159 additions and 5 deletions
@@ -0,0 +1,8 @@
| surface | command run | observed result | base/head |
| --- | --- | --- | --- |
| Mixed-source live refresh keeps one visible lineage representative | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_collapse.py::test_mixed_source_live_refresh_keeps_authoritative_tip_and_child_set PASSED` | base: base-fails, local timestamp ordering can pick different visible rows across the two refresh snapshots; head: head-passes, the test locks visible SID `tip`, badge kind `children`, and child SID `fork-child`. |
| Lineage-report cache does not survive authoritative tip or segment-count drift | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_collapse.py::test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch PASSED` | base: base-fails, root-only cache identity can reuse stale lineage reports after tip or count drift; head: head-passes, cache keys include tip identity and stale non-error reports are evicted on count mismatch. |
| Backend mixed-source child rows expose stable visible-parent authority | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_metadata_api.py::test_child_of_hidden_compression_segment_exposes_parent_lineage_root PASSED` | base: base-fails, child metadata exposed `_parent_lineage_root_id` without `_parent_lineage_tip_id`; head: head-passes, the child row exposes root `lineage_api_root` and tip `lineage_api_tip`. |
| Existing delegated-subagent stacking and suppression stay intact | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_5306_subagent_sidebar_flicker.py -v --timeout=60"` | `tests/test_5306_subagent_sidebar_flicker.py::test_5305_visible_parent_still_stacks_subagent_child PASSED` | base: base-passes, delegated-child stacking and suppression already pass; head: head-passes, `7 passed` in the preservation file. |
| Segment-count mismatch boundary value `cached=2 live=3` refreshes stale report | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | Boundary `cached=2 live=3` covered by `tests/test_session_lineage_collapse.py::test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch PASSED` | base: base-fails, boundary `cached=2 live=3` keeps a stale non-error cache entry; head: head-passes, boundary `cached=2 live=3` deletes the stale entry and fetches again. |
| Negative space, external parents still orphan their child rows | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_5306_subagent_sidebar_flicker.py -v --timeout=60"` | `tests/test_5306_subagent_sidebar_flicker.py::test_5305_external_parent_child_still_orphans PASSED` | base: base-passes, external-parent orphaning already passes; head: head-passes, the same `parentIsExternal=true` path still orphans child rows. |
+3
View File
@@ -1119,6 +1119,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
parent_root = _continuation_root_id(rows, parent_id)
if parent_root:
entry['_parent_lineage_root_id'] = parent_root
if parent_root not in lineage_tip_cache:
lineage_tip_cache[parent_root] = freshest_continuation_tip(parent_root)
entry['_parent_lineage_tip_id'] = lineage_tip_cache[parent_root][0]
continue
root_id, segment_count = continuation_root_and_depth(sid)
+28 -3
View File
@@ -5887,6 +5887,11 @@ function _sessionLineageContainsSession(s, sid){
return false;
}
function _authoritativeLineageTipId(s){
if(!s) return null;
return s._lineage_tip_id||s._parent_lineage_tip_id||null;
}
function _resolveSessionIdFromSidebarLineage(sid){
sid=String(sid||'').trim();
if(!sid||!Array.isArray(_allSessions)||!_allSessions.length) return sid||null;
@@ -5966,7 +5971,11 @@ function _pruneLineageReportCacheToVisibleSessions(sessions){
}
function _lineageReportCacheKey(s,lineageKey){
return lineageKey||_sidebarLineageKeyForRow(s)||null;
const key=lineageKey||_sidebarLineageKeyForRow(s)||null;
const tip=typeof _authoritativeLineageTipId==='function'
? _authoritativeLineageTipId(s)
: s&&(s._lineage_tip_id||s._parent_lineage_tip_id)||null;
return key&&tip&&tip!==key?`${key}::${tip}`:key;
}
function _lineageLocalSegmentCount(s){
@@ -5978,7 +5987,17 @@ function _lineageLocalSegmentCount(s){
function _lineageReportNeedsFetch(s,lineageKey,segmentCount){
const key=_lineageReportCacheKey(s,lineageKey);
if(!s||!s.session_id||!key) return false;
if(_lineageReportCache.has(key)||_lineageReportInflight.has(key)) return false;
const cached=_lineageReportCache.get(key);
const expectedCount=Number(segmentCount||0);
if(cached){
const cachedCount=Array.isArray(cached.segments)?cached.segments.length:0;
if(!cached.error&&expectedCount>0&&cachedCount>0&&cachedCount!==expectedCount){
_lineageReportCache.delete(key);
} else {
return false;
}
}
if(_lineageReportInflight.has(key)) return false;
return Number(segmentCount||0)>_lineageLocalSegmentCount(s);
}
@@ -6202,6 +6221,9 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
parentRow=resolved.row;
parentSegment=resolved.seg;
}
if(!parentRow&&child._parent_lineage_tip_id){
parentRow=visibleBySid.get(child._parent_lineage_tip_id)||null;
}
if(!parentRow&&child._parent_lineage_root_id){
parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null;
}
@@ -6306,7 +6328,10 @@ function _collapseSessionLineageForSidebar(sessions){
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
});
const chosen=sorted[0];
const tipIds=new Set(items.map(item=>typeof _authoritativeLineageTipId==='function'
? _authoritativeLineageTipId(item)
: item&&(item._lineage_tip_id||item._parent_lineage_tip_id)||null).filter(Boolean));
const chosen=sorted.find(item=>tipIds.has(item&&item.session_id))||sorted[0];
result.push({...chosen,_lineage_key:key,_lineage_collapsed_count:items.length,_lineage_segments:sorted});
}
return result;
+119 -2
View File
@@ -364,7 +364,7 @@ eval(extractFunc('_attachChildSessionsToSidebarRows'));
const raw = [
{{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
{{session_id:'tip', title:'Tip', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
{{session_id:'child', title:'Subtask', parent_session_id:'tip', relationship_type:'child_session', _parent_lineage_root_id:'root', updated_at:30, last_message_at:30}},
{{session_id:'child', title:'Subtask', parent_session_id:'tip', relationship_type:'child_session', _parent_lineage_root_id:'root', _parent_lineage_tip_id:'tip', updated_at:30, last_message_at:30}},
];
const collapsed = _collapseSessionLineageForSidebar(raw);
const attached = _attachChildSessionsToSidebarRows(collapsed, raw);
@@ -376,6 +376,72 @@ console.log(JSON.stringify(attached));
assert rows[0]["_child_sessions"][0]["session_id"] == "child"
def test_mixed_source_live_refresh_keeps_authoritative_tip_and_child_set():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sessionLineageContainsSession'));
eval(extractFunc('_authoritativeLineageTipId'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_sessionDisplayTitle'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
function summarize(raw) {{
const collapsed = _collapseSessionLineageForSidebar(raw);
const rows = _attachChildSessionsToSidebarRows(collapsed, raw);
const row = rows[0];
return {{
visibleSid: row.session_id,
badgeKind: row._child_session_count > 0 ? 'children' : 'prior-turns',
childSids: (row._child_sessions || []).map(child => child.session_id),
segmentSids: (row._lineage_segments || []).map(seg => seg.session_id),
}};
}}
const refreshA = [
{{session_id:'root', title:'Optimizing Hermes Development', updated_at:40, last_message_at:40, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
{{session_id:'tip', title:'Optimizing Hermes Development', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
{{session_id:'fork-child', title:'Optimizing Hermes Development child', parent_session_id:'root', relationship_type:'child_session', session_source:'fork', _parent_lineage_root_id:'root', _parent_lineage_tip_id:'tip', updated_at:50, last_message_at:50}},
];
const refreshB = [
{{session_id:'root', title:'Optimizing Hermes Development', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
{{session_id:'tip', title:'Optimizing Hermes Development', parent_session_id:'root', updated_at:60, last_message_at:60, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
{{session_id:'fork-child', title:'Optimizing Hermes Development child', parent_session_id:'root', relationship_type:'child_session', session_source:'fork', _parent_lineage_root_id:'root', _parent_lineage_tip_id:'tip', updated_at:50, last_message_at:50}},
];
console.log(JSON.stringify([summarize(refreshA), summarize(refreshB)]));
"""
assert json.loads(_run_node(source)) == [
{
"visibleSid": "tip",
"badgeKind": "children",
"childSids": ["fork-child"],
"segmentSids": ["root", "tip"],
},
{
"visibleSid": "tip",
"badgeKind": "children",
"childSids": ["fork-child"],
"segmentSids": ["tip", "root"],
},
]
def test_cross_surface_subagent_child_stacks_under_visible_webui_parent():
"""Delegate subagent rows are cross-source but still belong under their WebUI parent."""
@@ -1560,6 +1626,7 @@ function extractFunc(name) {{
const _lineageReportCache = new Map();
const _lineageReportInflight = new Map();
eval(extractFunc('_lineageReportCacheKey'));
eval(extractFunc('_authoritativeLineageTipId'));
eval(extractFunc('_lineageLocalSegmentCount'));
eval(extractFunc('_lineageReportNeedsFetch'));
const backendOnly = {{session_id:'tip', _lineage_key:'root', _compression_segment_count:25}};
@@ -1575,7 +1642,57 @@ const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25);
const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2);
console.log(JSON.stringify({{before, afterCache, fullLocal}}));
"""
assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False}
assert json.loads(_run_node(source)) == {"before": True, "afterCache": True, "fullLocal": False}
def test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
const _lineageReportCache = new Map();
const _lineageReportInflight = new Map();
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_authoritativeLineageTipId'));
eval(extractFunc('_lineageReportCacheKey'));
eval(extractFunc('_lineageLocalSegmentCount'));
eval(extractFunc('_lineageReportNeedsFetch'));
const oldTip = {{session_id:'old-tip', _lineage_key:'root', _lineage_tip_id:'old-tip', _compression_segment_count:2}};
const newTip = {{session_id:'new-tip', _lineage_key:'root', _lineage_tip_id:'new-tip', _compression_segment_count:3}};
const staleSameTip = {{session_id:'new-tip', _lineage_key:'root', _lineage_tip_id:'new-tip', _compression_segment_count:3}};
const oldKey = _lineageReportCacheKey(oldTip, 'root');
const newKey = _lineageReportCacheKey(newTip, 'root');
_lineageReportCache.set(oldKey, {{segments:[{{session_id:'old-tip'}}, {{session_id:'root'}}]}});
const tipChangedNeedsFetch = _lineageReportNeedsFetch(newTip, 'root', 3);
_lineageReportCache.set(newKey, {{segments:[{{session_id:'new-tip'}}, {{session_id:'root'}}]}});
const mismatchNeedsFetch = _lineageReportNeedsFetch(staleSameTip, 'root', 3);
console.log(JSON.stringify({{
oldKey,
newKey,
tipChangedNeedsFetch,
mismatchNeedsFetch,
newCacheRetained:_lineageReportCache.has(newKey),
}}));
"""
assert json.loads(_run_node(source)) == {
"oldKey": "root::old-tip",
"newKey": "root::new-tip",
"tipChangedNeedsFetch": True,
"mismatchNeedsFetch": True,
"newCacheRetained": False,
}
def test_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children():
@@ -217,6 +217,7 @@ def test_child_of_hidden_compression_segment_exposes_parent_lineage_root(_isolat
assert child.get("relationship_type") == "child_session"
assert child.get("parent_session_id") == "lineage_api_tip"
assert child.get("_parent_lineage_root_id") == "lineage_api_root"
assert child.get("_parent_lineage_tip_id") == "lineage_api_tip"
assert "_lineage_root_id" not in child
finally:
conn.close()