hermes: scope stale activity lineage

This commit is contained in:
jenkins 2026-08-16 02:51:20 -03:00
parent 45225c41a8
commit eace0ed0df
3 changed files with 54 additions and 24 deletions

View File

@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary" ai.bstein.dev/config-rev: "20260816-activity-lineage-scope"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-agent-api-key: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-agent-api-key: kv/data/atlas/hermes/agent-tokens

View File

@ -145,20 +145,18 @@ LATEST_SELECTION_AFTER = ''' children = {}
except Exception: except Exception:
return 0.0 return 0.0
# Old dashboard versions rewrote the URL to a child without retaining the # Old dashboard versions rewrote the URL to a delegated leaf without
# root. Recover the oldest available ancestor so those existing live links # retaining its objective. Recover only the immediate objective parent.
# can also follow a resumed parent or a newer delegated sibling. # Climbing to the oldest ancestor mixes unrelated workstreams that happen
# to share a long-lived dashboard/handoff session.
anchor = sid anchor = sid
ancestor_seen = {anchor} if not children.get(anchor):
while True:
parent = (rows_by_id.get(anchor) or {}).get("parent_session_id") parent = (rows_by_id.get(anchor) or {}).get("parent_session_id")
if not parent or parent in ancestor_seen: if parent and parent in rows_by_id:
break anchor = parent
anchor = parent
ancestor_seen.add(anchor)
sid = anchor sid = anchor
root = db.get_session(sid) or {"id": sid} root = rows_by_id.get(sid) or db.get_session(sid) or {"id": sid}
descendants = [(root, [sid])] descendants = [(root, [sid])]
stack = [(sid, [sid])] stack = [(sid, [sid])]
seen = {sid} seen = {sid}
@ -177,14 +175,22 @@ LATEST_SELECTION_AFTER = ''' children = {}
# A durable parent can resume after a delegated reviewer exits. Prefer the # A durable parent can resume after a delegated reviewer exits. Prefer the
# newest still-open member of the lineage instead of stranding the browser # newest still-open member of the lineage instead of stranding the browser
# on the most recently created (but already finished) child. # on the most recently created (but already finished) child.
active = [item for item in descendants if item[0].get("ended_at") is None] # An ended objective can retain orphaned children whose process died before
# SessionDB recorded ended_at. Do not let those stale rows defeat a newer,
# completed repair/review branch. While the objective itself is live, an
# open child or the resumed parent still wins as before.
active = (
[item for item in descendants if item[0].get("ended_at") is None]
if root.get("ended_at") is None
else []
)
if active: if active:
row, path = max(active, key=lambda item: (started(item[0]), len(item[1]))) row, path = max(active, key=lambda item: (started(item[0]), len(item[1])))
return row.get("id") or sid, path return row.get("id") or sid, path
leaves = [ leaves = [
item for item in descendants item for item in descendants
if not any(child.get("id") in seen for child in children.get(item[0].get("id"), [])) if not children.get(item[0].get("id"))
] ]
row, path = max(leaves or descendants, key=lambda item: started(item[0])) row, path = max(leaves or descendants, key=lambda item: started(item[0]))
return row.get("id") or sid, path return row.get("id") or sid, path

View File

@ -1160,7 +1160,9 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
assert '"total_messages": total_messages' in patched assert '"total_messages": total_messages' in patched
assert "SELECT id, parent_session_id, started_at, ended_at" in patched assert "SELECT id, parent_session_id, started_at, ended_at" in patched
assert "newest still-open member of the lineage" in patched assert "newest still-open member of the lineage" in patched
assert "oldest available ancestor" in patched assert "immediate objective parent" in patched
assert "mixes unrelated workstreams" in patched
assert "orphaned children" in patched
assert 'item[0].get("ended_at") is None' in patched assert 'item[0].get("ended_at") is None' in patched
@ -1179,19 +1181,19 @@ def test_web_session_lineage_returns_to_resumed_parent():
) )
class FakeDB: class FakeDB:
root = { def get_session(self, session_id):
"id": "root", return next((row for row in rows if row["id"] == session_id), None)
"started_at": 1.0,
"ended_at": None,
}
def get_session(self, _session_id):
return self.root
rows = [ rows = [
{ {
"id": "root", "id": "umbrella",
"parent_session_id": None, "parent_session_id": None,
"started_at": 0.0,
"ended_at": None,
},
{
"id": "root",
"parent_session_id": "umbrella",
"started_at": 1.0, "started_at": 1.0,
"ended_at": None, "ended_at": None,
}, },
@ -1218,10 +1220,32 @@ def test_web_session_lineage_returns_to_resumed_parent():
"review-2", "review-2",
["root", "review-2"], ["root", "review-2"],
) )
rows[1]["ended_at"] = 5.0
rows[2]["ended_at"] = 5.0 rows[2]["ended_at"] = 5.0
rows[3]["ended_at"] = 5.0
assert select_active("root", FakeDB(), rows) == ("root", ["root"]) assert select_active("root", FakeDB(), rows) == ("root", ["root"])
rows[1]["ended_at"] = 9.0
rows.append(
{
"id": "orphaned-review",
"parent_session_id": "root",
"started_at": 6.0,
"ended_at": None,
}
)
rows.append(
{
"id": "accepted-review",
"parent_session_id": "root",
"started_at": 7.0,
"ended_at": 8.0,
}
)
assert select_active("review-1", FakeDB(), rows) == (
"accepted-review",
["root", "accepted-review"],
)
def test_api_activity_patch_coalesces_streaming_heartbeats( def test_api_activity_patch_coalesces_streaming_heartbeats(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch