Compare commits

...

1 Commits

Author SHA1 Message Date
donrhmexe
f0b325b9f9 fix: link subagent sessions to parent and hide from session list
Subagent sessions spawned by delegate_task were created with
parent_session_id=NULL and source=cli, making them indistinguishable
from user sessions in hermes sessions list and /resume.

Changes:
- delegate_tool.py: pass parent_agent.session_id to child agent
- run_agent.py: accept parent_session_id param, pass to create_session
- hermes_state.py list_sessions_rich: filter parent_session_id IS NULL
  by default (opt-in include_children=True for callers that need them)
- hermes_state.py delete_session: delete child sessions first (FK)
- hermes_state.py prune_sessions: delete children before parents (FK)

session_search already handles parent_session_id correctly — child
sessions are filtered from recent list and resolved to parent root
in full-text search results.

Fixes #5122
2026-04-05 12:44:31 -07:00
3 changed files with 44 additions and 5 deletions

View File

@@ -787,6 +787,7 @@ class SessionDB:
exclude_sources: List[str] = None, exclude_sources: List[str] = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
include_children: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp. """List sessions with preview (first user message) and last active timestamp.
@@ -795,10 +796,16 @@ class SessionDB:
last_active (timestamp of last message). last_active (timestamp of last message).
Uses a single query with correlated subqueries instead of N+2 queries. Uses a single query with correlated subqueries instead of N+2 queries.
By default, child sessions (subagent runs, compression continuations)
are excluded. Pass ``include_children=True`` to include them.
""" """
where_clauses = [] where_clauses = []
params = [] params = []
if not include_children:
where_clauses.append("s.parent_session_id IS NULL")
if source: if source:
where_clauses.append("s.source = ?") where_clauses.append("s.source = ?")
params.append(source) params.append(source)
@@ -1229,22 +1236,38 @@ 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 and all its messages. Returns True if found.""" """Delete a session, its child sessions, and all their messages.
Child sessions (subagent runs, compression continuations) are deleted
first to satisfy the ``parent_session_id`` foreign key constraint.
Returns True if the session was found and deleted.
"""
def _do(conn): def _do(conn):
cursor = conn.execute( cursor = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,) "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
) )
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
return False return False
# Delete child sessions first (FK constraint)
child_ids = [r[0] for r in conn.execute(
"SELECT id FROM sessions WHERE parent_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
return self._execute_write(_do) return self._execute_write(_do)
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). Only prunes ended sessions (not active ones). Child sessions whose
parents are being pruned are deleted first to satisfy the
``parent_session_id`` foreign key constraint.
""" """
cutoff = time.time() - (older_than_days * 86400) cutoff = time.time() - (older_than_days * 86400)
@@ -1260,7 +1283,19 @@ class SessionDB:
"SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL", "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
(cutoff,), (cutoff,),
) )
session_ids = [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
# (avoids FK constraint errors)
for sid in list(session_ids):
child_ids = [r[0] for r in conn.execute(
"SELECT id FROM sessions WHERE parent_session_id = ?",
(sid,),
).fetchall()]
for cid in child_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
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,))

View File

@@ -530,6 +530,7 @@ class AIAgent:
skip_context_files: bool = False, skip_context_files: bool = False,
skip_memory: bool = False, skip_memory: bool = False,
session_db=None, session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None, iteration_budget: "IterationBudget" = None,
fallback_model: Dict[str, Any] = None, fallback_model: Dict[str, Any] = None,
credential_pool=None, credential_pool=None,
@@ -1025,6 +1026,7 @@ class AIAgent:
# SQLite session store (optional -- provided by CLI or gateway) # SQLite session store (optional -- provided by CLI or gateway)
self._session_db = session_db self._session_db = session_db
self._parent_session_id = parent_session_id
self._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes self._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
if self._session_db: if self._session_db:
try: try:
@@ -1038,6 +1040,7 @@ class AIAgent:
"max_tokens": max_tokens, "max_tokens": max_tokens,
}, },
user_id=None, user_id=None,
parent_session_id=self._parent_session_id,
) )
except Exception as e: except Exception as e:
# Transient SQLite lock contention (e.g. CLI and gateway writing # Transient SQLite lock contention (e.g. CLI and gateway writing

View File

@@ -251,6 +251,7 @@ def _build_child_agent(
clarify_callback=None, clarify_callback=None,
thinking_callback=child_thinking_cb, thinking_callback=child_thinking_cb,
session_db=getattr(parent_agent, '_session_db', None), session_db=getattr(parent_agent, '_session_db', None),
parent_session_id=getattr(parent_agent, 'session_id', None),
providers_allowed=parent_agent.providers_allowed, providers_allowed=parent_agent.providers_allowed,
providers_ignored=parent_agent.providers_ignored, providers_ignored=parent_agent.providers_ignored,
providers_order=parent_agent.providers_order, providers_order=parent_agent.providers_order,