{"repo":"NousResearch/hermes-agent","public_id":"49148","display_label":"Delegate cascade deleting parent session bug fix","summary":"A critical bug in `_collect_delegate_child_ids` lacks a guard for `parent_ids`, causing delegate session cleanup to cascade and delete the parent session. The members describe the problem and a corresponding fix.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50329,"merged_at":"2026-06-21T19:09:16+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-21T19:09:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":49148,"pr_number":50329,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-21T19:09:16+00:00"}],"members":[{"kind":"issue","number":49148,"title":"[Critical Bug] `_collect_delegate_child_ids` missing `parent_ids` guard causes parent session cascade deletion and permanent data loss","url":null,"state":"closed"},{"kind":"pr","number":49178,"title":"fix(agent): stop delegate cascade from deleting the parent session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50329","number":50329},{"kind":"pr","value":"#49148","number":49148}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50329,"title":"fix(agent): stop delegate cascade from deleting the parent session","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_state.py","tests/test_delegate_cascade_49148.py"],"fix_hunks":[{"file":"hermes_state.py","hunks":["@@ -75,8 +75,16 @@ def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:\n     orchestrator subagent's own delegate children go too (FK safety).\n     \"\"\"\n     df = _delegate_from_json()\n-    found: set[str] = set()\n-    frontier = [sid for sid in parent_ids if sid]\n+    seeds = {sid for sid in parent_ids if sid}\n+    # Seed the visited set with the parents themselves. A delegation marker\n+    # chain can loop back onto a parent — a cycle, or a parent that is also\n+    # another parent's delegate child when several ids are deleted at once —\n+    # and without this guard that parent would be collected as one of its own\n+    # descendants and cascade-deleted along with all of its messages. Callers\n+    # delete the parents separately, so parents must never appear in the\n+    # returned child set. (#49148)\n+    found: set[str] = set(seeds)\n+    frontier = list(seeds)\n     while frontier:\n         ph = \",\".join(\"?\" * len(frontier))\n         cursor = conn.execute(","@@ -86,7 +94,8 @@ def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:\n         )\n         frontier = [row[\"id\"] for row in cursor.fetchall() if row[\"id\"] not in found]\n         found.update(frontier)\n-    return list(found)\n+    # Return only the discovered children — never the parents themselves.\n+    return [sid for sid in found if sid not in seeds]\n \n \n def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:"]},{"file":"tests/test_delegate_cascade_49148.py","hunks":["@@ -0,0 +1,103 @@\n+\"\"\"Regression tests for delegate-child cascade collection (#49148).\n+\n+`_collect_delegate_child_ids` walks the ``_delegate_from`` marker chain to\n+find delegate subagents that should be cascade-deleted with their parent.\n+The parents themselves are deleted separately by the callers, so they must\n+never appear in the collected child set. A delegation cycle (or a parent\n+that is also another parent's delegate child) used to leak the parent into\n+the deletion set, permanently deleting the parent session and its messages.\n+\"\"\"\n+\n+import json\n+import sqlite3\n+\n+from hermes_state import _collect_delegate_child_ids, _delete_delegate_children\n+\n+\n+def _make_conn():\n+    conn = sqlite3.connect(\":memory:\")\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\n+        \"CREATE TABLE sessions (\"\n+        \" id TEXT PRIMARY KEY,\"\n+        \" parent_session_id TEXT,\"\n+        \" model_config TEXT)\"\n+    )\n+    conn.execute(\"CREATE TABLE messages (session_id TEXT)\")\n+    return conn\n+\n+\n+def _add_session(conn, sid, *, delegate_from=None, parent_session_id=None, messages=0):\n+    model_config = json.dumps({\"_delegate_from\": delegate_from}) if delegate_from else None\n+    conn.execute(\n+        \"INSERT INTO sessions (id, parent_session_id, model_config) VALUES (?, ?, ?)\",\n+        (sid, parent_session_id, model_config),\n+    )\n+    for _ in range(messages):\n+        conn.execute(\"INSERT INTO messages (session_id) VALUES (?)\", (sid,))\n+\n+\n+class TestCollectDelegateChildIds:\n+    def test_collects_delegate_child_excludes_parent(self):\n+        conn = _make_conn()\n+        _add_session(conn, \"P\")\n+        _add_session(conn, \"C\", delegate_from=\"P\")\n+\n+        result = _collect_delegate_child_ids(conn, [\"P\"])\n+\n+        assert \"C\" in result\n+        assert \"P\" not in result\n+\n+    def test_multilevel_chain_collects_all_descendants(self):\n+        conn = _make_conn()\n+        _add_session(conn, \"O\")\n+        _add_session(conn, \"A\", delegate_from=\"O\")\n+        _add_session(conn, \"B\", delegate_from=\"A\")\n+\n+        result = set(_collect_delegate_child_ids(conn, [\"O\"]))\n+\n+        assert result == {\"A\", \"B\"}  # parent O excluded, both descendants in\n+\n+    def test_parent_session_id_branch_with_marker_collected(self):\n+        # Second OR clause: parent_session_id match AND _delegate_from present.\n+        conn = _make_conn()\n+        _add_session(conn, \"P\")\n+        _add_session(conn, \"C\", parent_session_id=\"P\", delegate_from=\"something\")\n+\n+        assert _collect_delegate_child_ids(conn, [\"P\"]) == [\"C\"]\n+\n+    def test_untagged_child_not_collected(self):\n+        # No _delegate_from marker -> orphan-don't-delete contract.\n+        conn = _make_conn()\n+        _add_session(conn, \"P\")\n+        _add_session(conn, \"C\", parent_session_id=\"P\")\n+\n+        assert _collect_delegate_child_ids(conn, [\"P\"]) == []\n+\n+    def test_cycle_terminates_and_excludes_parent(self):\n+        # The #49148 bug: A and B reference each other via _delegate_from.\n+        # Collection must terminate and never return the seed parent A.\n+        conn = _make_conn()\n+        _add_session(conn, \"A\", delegate_from=\"B\")\n+        _add_session(conn, \"B\", delegate_from=\"A\")\n+\n+        result = _collect_delegate_child_ids(conn, [\"A\"])\n+\n+        assert \"A\" not in result  # parent never collected as its own child\n+        assert result == [\"B\"]\n+\n+\n+class TestDeleteDelegateChildrenPreservesParent:\n+    def test_cycle_does_not_delete_parent_or_its_messages(self):\n+        conn = _make_conn()\n+        _add_session(conn, \"A\", delegate_from=\"B\", messages=3)\n+        _add_session(conn, \"B\", delegate_from=\"A\", messages=2)\n+\n+        removed = _delete_delegate_children(conn, [\"A\"])\n+\n+        assert \"A\" not in removed\n+        # Parent A and its messages survive; only delegate child B is gone.\n+        assert conn.execute(\"SELECT COUNT(*) FROM sessions WHERE id='A'\").fetchone()[0] == 1\n+        assert conn.execute(\"SELECT COUNT(*) FROM messages WHERE session_id='A'\").fetchone()[0] == 3\n+        assert conn.execute(\"SELECT COUNT(*) FROM sessions WHERE id='B'\").fetchone()[0] == 0\n+        assert conn.execute(\"SELECT COUNT(*) FROM messages WHERE session_id='B'\").fetchone()[0] == 0\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}