fix(agent): stop delegate cascade from deleting the parent session

_collect_delegate_child_ids() walks the _delegate_from marker chain to
gather delegate subagents for cascade deletion, but started its visited
set empty. When the chain loops back onto a parent — a delegation cycle,
or a parent that is also another parent's delegate child when several ids
are deleted together — that parent was collected as one of its own
descendants and then permanently deleted, along with all of its messages,
by _delete_delegate_children().

Seed the visited set with the parent ids so they can never be re-collected,
and exclude them from the returned child set. Callers (delete_session,
bulk delete) remove the parents separately, so this only prevents the
unintended parent deletion; legitimate child collection is unchanged.

Add regression tests (in-memory sqlite) covering single/multi-level
delegate chains, the parent_session_id+marker branch, untagged children
(orphan-don't-delete contract), and the cycle case that previously leaked
the parent into the deletion set.

Fixes #49148
This commit is contained in:
JackJin 2026-06-20 01:11:09 +08:00 committed by Teknium
parent e581740aa1
commit 56255f83f7
2 changed files with 115 additions and 3 deletions

View file

@ -75,8 +75,16 @@ def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
orchestrator subagent's own delegate children go too (FK safety).
"""
df = _delegate_from_json()
found: set[str] = set()
frontier = [sid for sid in parent_ids if sid]
seeds = {sid for sid in parent_ids if sid}
# Seed the visited set with the parents themselves. A delegation marker
# chain can loop back onto a parent — a cycle, or a parent that is also
# another parent's delegate child when several ids are deleted at once —
# and without this guard that parent would be collected as one of its own
# descendants and cascade-deleted along with all of its messages. Callers
# delete the parents separately, so parents must never appear in the
# returned child set. (#49148)
found: set[str] = set(seeds)
frontier = list(seeds)
while frontier:
ph = ",".join("?" * len(frontier))
cursor = conn.execute(
@ -86,7 +94,8 @@ def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
)
frontier = [row["id"] for row in cursor.fetchall() if row["id"] not in found]
found.update(frontier)
return list(found)
# Return only the discovered children — never the parents themselves.
return [sid for sid in found if sid not in seeds]
def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:

View file

@ -0,0 +1,103 @@
"""Regression tests for delegate-child cascade collection (#49148).
`_collect_delegate_child_ids` walks the ``_delegate_from`` marker chain to
find delegate subagents that should be cascade-deleted with their parent.
The parents themselves are deleted separately by the callers, so they must
never appear in the collected child set. A delegation cycle (or a parent
that is also another parent's delegate child) used to leak the parent into
the deletion set, permanently deleting the parent session and its messages.
"""
import json
import sqlite3
from hermes_state import _collect_delegate_child_ids, _delete_delegate_children
def _make_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE sessions ("
" id TEXT PRIMARY KEY,"
" parent_session_id TEXT,"
" model_config TEXT)"
)
conn.execute("CREATE TABLE messages (session_id TEXT)")
return conn
def _add_session(conn, sid, *, delegate_from=None, parent_session_id=None, messages=0):
model_config = json.dumps({"_delegate_from": delegate_from}) if delegate_from else None
conn.execute(
"INSERT INTO sessions (id, parent_session_id, model_config) VALUES (?, ?, ?)",
(sid, parent_session_id, model_config),
)
for _ in range(messages):
conn.execute("INSERT INTO messages (session_id) VALUES (?)", (sid,))
class TestCollectDelegateChildIds:
def test_collects_delegate_child_excludes_parent(self):
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", delegate_from="P")
result = _collect_delegate_child_ids(conn, ["P"])
assert "C" in result
assert "P" not in result
def test_multilevel_chain_collects_all_descendants(self):
conn = _make_conn()
_add_session(conn, "O")
_add_session(conn, "A", delegate_from="O")
_add_session(conn, "B", delegate_from="A")
result = set(_collect_delegate_child_ids(conn, ["O"]))
assert result == {"A", "B"} # parent O excluded, both descendants in
def test_parent_session_id_branch_with_marker_collected(self):
# Second OR clause: parent_session_id match AND _delegate_from present.
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", parent_session_id="P", delegate_from="something")
assert _collect_delegate_child_ids(conn, ["P"]) == ["C"]
def test_untagged_child_not_collected(self):
# No _delegate_from marker -> orphan-don't-delete contract.
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", parent_session_id="P")
assert _collect_delegate_child_ids(conn, ["P"]) == []
def test_cycle_terminates_and_excludes_parent(self):
# The #49148 bug: A and B reference each other via _delegate_from.
# Collection must terminate and never return the seed parent A.
conn = _make_conn()
_add_session(conn, "A", delegate_from="B")
_add_session(conn, "B", delegate_from="A")
result = _collect_delegate_child_ids(conn, ["A"])
assert "A" not in result # parent never collected as its own child
assert result == ["B"]
class TestDeleteDelegateChildrenPreservesParent:
def test_cycle_does_not_delete_parent_or_its_messages(self):
conn = _make_conn()
_add_session(conn, "A", delegate_from="B", messages=3)
_add_session(conn, "B", delegate_from="A", messages=2)
removed = _delete_delegate_children(conn, ["A"])
assert "A" not in removed
# Parent A and its messages survive; only delegate child B is gone.
assert conn.execute("SELECT COUNT(*) FROM sessions WHERE id='A'").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM messages WHERE session_id='A'").fetchone()[0] == 3
assert conn.execute("SELECT COUNT(*) FROM sessions WHERE id='B'").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM messages WHERE session_id='B'").fetchone()[0] == 0