diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 4a8c70de..27eee2e8 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: 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/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/role: hermes-agent vault.hashicorp.com/agent-inject-secret-agent-api-key: kv/data/atlas/hermes/agent-tokens diff --git a/services/hermes/scripts/patch_web_session_activity.py b/services/hermes/scripts/patch_web_session_activity.py index cc729b91..e1a1825d 100644 --- a/services/hermes/scripts/patch_web_session_activity.py +++ b/services/hermes/scripts/patch_web_session_activity.py @@ -145,20 +145,18 @@ LATEST_SELECTION_AFTER = ''' children = {} except Exception: return 0.0 - # Old dashboard versions rewrote the URL to a child without retaining the - # root. Recover the oldest available ancestor so those existing live links - # can also follow a resumed parent or a newer delegated sibling. + # Old dashboard versions rewrote the URL to a delegated leaf without + # retaining its objective. Recover only the immediate objective parent. + # Climbing to the oldest ancestor mixes unrelated workstreams that happen + # to share a long-lived dashboard/handoff session. anchor = sid - ancestor_seen = {anchor} - while True: + if not children.get(anchor): parent = (rows_by_id.get(anchor) or {}).get("parent_session_id") - if not parent or parent in ancestor_seen: - break - anchor = parent - ancestor_seen.add(anchor) + if parent and parent in rows_by_id: + anchor = parent 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])] stack = [(sid, [sid])] seen = {sid} @@ -177,14 +175,22 @@ LATEST_SELECTION_AFTER = ''' children = {} # A durable parent can resume after a delegated reviewer exits. Prefer the # newest still-open member of the lineage instead of stranding the browser # 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: row, path = max(active, key=lambda item: (started(item[0]), len(item[1]))) return row.get("id") or sid, path leaves = [ 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])) return row.get("id") or sid, path diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 6b693cff..7f4cf08b 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -1160,7 +1160,9 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path): assert '"total_messages": total_messages' 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 "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 @@ -1179,19 +1181,19 @@ def test_web_session_lineage_returns_to_resumed_parent(): ) class FakeDB: - root = { - "id": "root", - "started_at": 1.0, - "ended_at": None, - } - - def get_session(self, _session_id): - return self.root + def get_session(self, session_id): + return next((row for row in rows if row["id"] == session_id), None) rows = [ { - "id": "root", + "id": "umbrella", "parent_session_id": None, + "started_at": 0.0, + "ended_at": None, + }, + { + "id": "root", + "parent_session_id": "umbrella", "started_at": 1.0, "ended_at": None, }, @@ -1218,10 +1220,32 @@ def test_web_session_lineage_returns_to_resumed_parent(): "review-2", ["root", "review-2"], ) - rows[1]["ended_at"] = 5.0 rows[2]["ended_at"] = 5.0 + rows[3]["ended_at"] = 5.0 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( tmp_path: Path, monkeypatch: pytest.MonkeyPatch