mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-21 07:00:23 +00:00
fix(#5598): stabilize lineage grouping across live refreshes
This commit is contained in:
@@ -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. |
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user