mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(state): orphan children instead of cascade-deleting in prune/delete (#6513)
prune_sessions and delete_session only handled direct children when satisfying the parent_session_id FK constraint. Multi-level chains (A -> B -> C) caused IntegrityError because deleting B while C still referenced it was blocked by the FK. Fix: NULL out parent_session_id for any session whose parent is about to be deleted. This orphans children instead of cascade-deleting them, which also respects the prune retention window — newer child sessions are no longer deleted just because an ancestor is old. Reported by Aaryan2304 in PR #6463.
This commit is contained in:
parent
851857e413
commit
a94099908a
2 changed files with 99 additions and 25 deletions
|
|
@ -1235,10 +1235,10 @@ class SessionDB:
|
||||||
self._execute_write(_do)
|
self._execute_write(_do)
|
||||||
|
|
||||||
def delete_session(self, session_id: str) -> bool:
|
def delete_session(self, session_id: str) -> bool:
|
||||||
"""Delete a session, its child sessions, and all their messages.
|
"""Delete a session and all its messages.
|
||||||
|
|
||||||
Child sessions (subagent runs, compression continuations) are deleted
|
Child sessions are orphaned (parent_session_id set to NULL) rather
|
||||||
first to satisfy the ``parent_session_id`` foreign key constraint.
|
than cascade-deleted, so they remain accessible independently.
|
||||||
Returns True if the session was found and deleted.
|
Returns True if the session was found and deleted.
|
||||||
"""
|
"""
|
||||||
def _do(conn):
|
def _do(conn):
|
||||||
|
|
@ -1247,15 +1247,12 @@ class SessionDB:
|
||||||
)
|
)
|
||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
return False
|
return False
|
||||||
# Delete child sessions first (FK constraint)
|
# Orphan child sessions so FK constraint is satisfied
|
||||||
child_ids = [r[0] for r in conn.execute(
|
conn.execute(
|
||||||
"SELECT id FROM sessions WHERE parent_session_id = ?",
|
"UPDATE sessions SET parent_session_id = NULL "
|
||||||
|
"WHERE parent_session_id = ?",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchall()]
|
)
|
||||||
for cid in child_ids:
|
|
||||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
|
|
||||||
conn.execute("DELETE FROM sessions WHERE id = ?", (cid,))
|
|
||||||
# Delete the session itself
|
|
||||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
||||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||||
return True
|
return True
|
||||||
|
|
@ -1264,9 +1261,9 @@ class SessionDB:
|
||||||
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
|
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
|
||||||
"""Delete sessions older than N days. Returns count of deleted sessions.
|
"""Delete sessions older than N days. Returns count of deleted sessions.
|
||||||
|
|
||||||
Only prunes ended sessions (not active ones). Child sessions whose
|
Only prunes ended sessions (not active ones). Child sessions outside
|
||||||
parents are being pruned are deleted first to satisfy the
|
the prune window are orphaned (parent_session_id set to NULL) rather
|
||||||
``parent_session_id`` foreign key constraint.
|
than cascade-deleted.
|
||||||
"""
|
"""
|
||||||
cutoff = time.time() - (older_than_days * 86400)
|
cutoff = time.time() - (older_than_days * 86400)
|
||||||
|
|
||||||
|
|
@ -1284,17 +1281,16 @@ class SessionDB:
|
||||||
)
|
)
|
||||||
session_ids = set(row["id"] for row in cursor.fetchall())
|
session_ids = set(row["id"] for row in cursor.fetchall())
|
||||||
|
|
||||||
# Delete children first whose parents are in the prune set
|
if not session_ids:
|
||||||
# (avoids FK constraint errors)
|
return 0
|
||||||
for sid in list(session_ids):
|
|
||||||
child_ids = [r[0] for r in conn.execute(
|
# Orphan any sessions whose parent is about to be deleted
|
||||||
"SELECT id FROM sessions WHERE parent_session_id = ?",
|
placeholders = ",".join("?" * len(session_ids))
|
||||||
(sid,),
|
conn.execute(
|
||||||
).fetchall()]
|
f"UPDATE sessions SET parent_session_id = NULL "
|
||||||
for cid in child_ids:
|
f"WHERE parent_session_id IN ({placeholders})",
|
||||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
|
list(session_ids),
|
||||||
conn.execute("DELETE FROM sessions WHERE id = ?", (cid,))
|
)
|
||||||
session_ids.discard(cid) # don't double-delete
|
|
||||||
|
|
||||||
for sid in session_ids:
|
for sid in session_ids:
|
||||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||||
|
|
|
||||||
|
|
@ -663,6 +663,84 @@ class TestPruneSessions:
|
||||||
assert db.get_session("old_cli") is None
|
assert db.get_session("old_cli") is None
|
||||||
assert db.get_session("old_tg") is not None
|
assert db.get_session("old_tg") is not None
|
||||||
|
|
||||||
|
def test_prune_with_multilevel_chain(self, db):
|
||||||
|
"""Pruning old sessions orphans newer children instead of crashing on FK."""
|
||||||
|
old_ts = time.time() - 200 * 86400
|
||||||
|
recent_ts = time.time() - 10 * 86400
|
||||||
|
|
||||||
|
# Chain: A (old) -> B (old) -> C (recent) -> D (recent)
|
||||||
|
db.create_session(session_id="A", source="cli")
|
||||||
|
db.end_session("A", end_reason="compressed")
|
||||||
|
db.create_session(session_id="B", source="cli", parent_session_id="A")
|
||||||
|
db.end_session("B", end_reason="compressed")
|
||||||
|
db.create_session(session_id="C", source="cli", parent_session_id="B")
|
||||||
|
db.end_session("C", end_reason="compressed")
|
||||||
|
db.create_session(session_id="D", source="cli", parent_session_id="C")
|
||||||
|
db.end_session("D", end_reason="done")
|
||||||
|
|
||||||
|
# Backdate A and B to be old; C and D stay recent
|
||||||
|
for sid, ts in [("A", old_ts), ("B", old_ts), ("C", recent_ts), ("D", recent_ts)]:
|
||||||
|
db._conn.execute(
|
||||||
|
"UPDATE sessions SET started_at = ? WHERE id = ?", (ts, sid)
|
||||||
|
)
|
||||||
|
db._conn.commit()
|
||||||
|
|
||||||
|
# Should not raise IntegrityError
|
||||||
|
pruned = db.prune_sessions(older_than_days=90)
|
||||||
|
assert pruned == 2 # only A and B
|
||||||
|
assert db.get_session("A") is None
|
||||||
|
assert db.get_session("B") is None
|
||||||
|
# C and D survive, C is orphaned (parent_session_id NULL)
|
||||||
|
c = db.get_session("C")
|
||||||
|
assert c is not None
|
||||||
|
assert c["parent_session_id"] is None
|
||||||
|
d = db.get_session("D")
|
||||||
|
assert d is not None
|
||||||
|
assert d["parent_session_id"] == "C"
|
||||||
|
|
||||||
|
def test_prune_entire_old_chain(self, db):
|
||||||
|
"""All sessions in a chain are old — entire chain is pruned."""
|
||||||
|
old_ts = time.time() - 200 * 86400
|
||||||
|
|
||||||
|
db.create_session(session_id="X", source="cli")
|
||||||
|
db.end_session("X", end_reason="compressed")
|
||||||
|
db.create_session(session_id="Y", source="cli", parent_session_id="X")
|
||||||
|
db.end_session("Y", end_reason="compressed")
|
||||||
|
db.create_session(session_id="Z", source="cli", parent_session_id="Y")
|
||||||
|
db.end_session("Z", end_reason="done")
|
||||||
|
|
||||||
|
for sid in ("X", "Y", "Z"):
|
||||||
|
db._conn.execute(
|
||||||
|
"UPDATE sessions SET started_at = ? WHERE id = ?", (old_ts, sid)
|
||||||
|
)
|
||||||
|
db._conn.commit()
|
||||||
|
|
||||||
|
pruned = db.prune_sessions(older_than_days=90)
|
||||||
|
assert pruned == 3
|
||||||
|
for sid in ("X", "Y", "Z"):
|
||||||
|
assert db.get_session(sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteSessionOrphansChildren:
|
||||||
|
def test_delete_orphans_children(self, db):
|
||||||
|
"""Deleting a parent session orphans its children."""
|
||||||
|
db.create_session(session_id="parent", source="cli")
|
||||||
|
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||||||
|
db.create_session(session_id="grandchild", source="cli", parent_session_id="child")
|
||||||
|
|
||||||
|
# Should not raise IntegrityError
|
||||||
|
result = db.delete_session("parent")
|
||||||
|
assert result is True
|
||||||
|
assert db.get_session("parent") is None
|
||||||
|
# Child is orphaned, not deleted
|
||||||
|
child = db.get_session("child")
|
||||||
|
assert child is not None
|
||||||
|
assert child["parent_session_id"] is None
|
||||||
|
# Grandchild is untouched
|
||||||
|
grandchild = db.get_session("grandchild")
|
||||||
|
assert grandchild is not None
|
||||||
|
assert grandchild["parent_session_id"] == "child"
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Schema and WAL mode
|
# Schema and WAL mode
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue