{"repo":"NousResearch/hermes-agent","public_id":"5122","display_label":"Session list cleanup for subagents and compression","summary":"These issues and PRs address two related session list problems: orphaned subagent sessions appearing as unwanted entries, and compression-related continuation sessions not surfacing correctly in lists and dashboards.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5309},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-20T10:07:51+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":5122,"pr_number":5309,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5122,"pr_number":12960,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":23307,"pr_number":12960,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":5122,"title":"bug: delegate_task subagent sessions pollute session list as orphan entries","url":null,"state":"closed"},{"kind":"pr","number":5123,"title":"fix: link subagent sessions to parent and hide from session list","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5309","number":5309}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5309,"title":"fix: link subagent sessions to parent and hide from session list","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_state.py","run_agent.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"hermes_state.py","hunks":["@@ -787,6 +787,7 @@ def list_sessions_rich(\n         exclude_sources: List[str] = None,\n         limit: int = 20,\n         offset: int = 0,\n+        include_children: bool = False,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"List sessions with preview (first user message) and last active timestamp.\n ","@@ -795,10 +796,16 @@ def list_sessions_rich(\n         last_active (timestamp of last message).\n \n         Uses a single query with correlated subqueries instead of N+2 queries.\n+\n+        By default, child sessions (subagent runs, compression continuations)\n+        are excluded.  Pass ``include_children=True`` to include them.\n         \"\"\"\n         where_clauses = []\n         params = []\n \n+        if not include_children:\n+            where_clauses.append(\"s.parent_session_id IS NULL\")\n+\n         if source:\n             where_clauses.append(\"s.source = ?\")\n             params.append(source)","@@ -1229,22 +1236,38 @@ def _do(conn):\n         self._execute_write(_do)\n \n     def delete_session(self, session_id: str) -> bool:\n-        \"\"\"Delete a session and all its messages. Returns True if found.\"\"\"\n+        \"\"\"Delete a session, its child sessions, and all their messages.\n+\n+        Child sessions (subagent runs, compression continuations) are deleted\n+        first to satisfy the ``parent_session_id`` foreign key constraint.\n+        Returns True if the session was found and deleted.\n+        \"\"\"\n         def _do(conn):\n             cursor = conn.execute(\n                 \"SELECT COUNT(*) FROM sessions WHERE id = ?\", (session_id,)\n             )\n             if cursor.fetchone()[0] == 0:\n                 return False\n+            # Delete child sessions first (FK constraint)\n+            child_ids = [r[0] for r in conn.execute(\n+                \"SELECT id FROM sessions WHERE parent_session_id = ?\",\n+                (session_id,),\n+            ).fetchall()]\n+            for cid in child_ids:\n+                conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (cid,))\n+                conn.execute(\"DELETE FROM sessions WHERE id = ?\", (cid,))\n+            # Delete the session itself\n             conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (session_id,))\n             conn.execute(\"DELETE FROM sessions WHERE id = ?\", (session_id,))\n             return True\n         return self._execute_write(_do)\n \n     def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:\n-        \"\"\"\n-        Delete sessions older than N days. Returns count of deleted sessions.\n-        Only prunes ended sessions (not active ones).\n+        \"\"\"Delete sessions older than N days. Returns count of deleted sessions.\n+\n+        Only prunes ended sessions (not active ones).  Child sessions whose\n+        parents are being pruned are deleted first to satisfy the\n+        ``parent_session_id`` foreign key constraint.\n         \"\"\"\n         cutoff = time.time() - (older_than_days * 86400)\n ","@@ -1260,7 +1283,19 @@ def _do(conn):\n                     \"SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL\",\n                     (cutoff,),\n                 )\n-            session_ids = [row[\"id\"] for row in cursor.fetchall()]\n+            session_ids = set(row[\"id\"] for row in cursor.fetchall())\n+\n+            # Delete children first whose parents are in the prune set\n+            # (avoids FK constraint errors)\n+            for sid in list(session_ids):\n+                child_ids = [r[0] for r in conn.execute(\n+                    \"SELECT id FROM sessions WHERE parent_session_id = ?\",\n+                    (sid,),\n+                ).fetchall()]\n+                for cid in child_ids:\n+                    conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (cid,))\n+                    conn.execute(\"DELETE FROM sessions WHERE id = ?\", (cid,))\n+                    session_ids.discard(cid)  # don't double-delete\n \n             for sid in session_ids:\n                 conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (sid,))"]},{"file":"run_agent.py","hunks":["@@ -530,6 +530,7 @@ def __init__(\n         skip_context_files: bool = False,\n         skip_memory: bool = False,\n         session_db=None,\n+        parent_session_id: str = None,\n         iteration_budget: \"IterationBudget\" = None,\n         fallback_model: Dict[str, Any] = None,\n         credential_pool=None,","@@ -1025,6 +1026,7 @@ def __init__(\n         \n         # SQLite session store (optional -- provided by CLI or gateway)\n         self._session_db = session_db\n+        self._parent_session_id = parent_session_id\n         self._last_flushed_db_idx = 0  # tracks DB-write cursor to prevent duplicate writes\n         if self._session_db:\n             try:","@@ -1038,6 +1040,7 @@ def __init__(\n                         \"max_tokens\": max_tokens,\n                     },\n                     user_id=None,\n+                    parent_session_id=self._parent_session_id,\n                 )\n             except Exception as e:\n                 # Transient SQLite lock contention (e.g. CLI and gateway writing"]},{"file":"tools/delegate_tool.py","hunks":["@@ -251,6 +251,7 @@ def _child_thinking(text: str) -> None:\n         clarify_callback=None,\n         thinking_callback=child_thinking_cb,\n         session_db=getattr(parent_agent, '_session_db', None),\n+        parent_session_id=getattr(parent_agent, 'session_id', None),\n         providers_allowed=parent_agent.providers_allowed,\n         providers_ignored=parent_agent.providers_ignored,\n         providers_order=parent_agent.providers_order,\n"]}]},{"kind":"pr","number":10683,"title":"fix(state): show compression continuations in session list and dashboard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"22efc81cd","number":0},{"kind":"commit","value":"22efc81cd7f660bb3192ccb91aef91dfb22ca38d","number":0},{"kind":"pr","value":"#12960","number":12960}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12960,"title":"fix(sessions): surface compression tips in session lists and resume lookups","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_state.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -693,6 +693,10 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:\n     - If it looks like a session ID (contains underscore + hex), try direct lookup first.\n     - Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).\n     - Falls back to the other method if the first doesn't match.\n+    - If the resolved session is a compression root, follow the chain forward\n+      to the latest continuation. Users who remember the old root ID (e.g.\n+      from an exit summary printed before the bug fix, or from notes) get\n+      resumed at the live tip instead of a stale parent with no messages.\n     \"\"\"\n     try:\n         from hermes_state import SessionDB","@@ -701,14 +705,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:\n \n         # Try as exact session ID first\n         session = db.get_session(name_or_id)\n+        resolved_id: Optional[str] = None\n         if session:\n-            db.close()\n-            return session[\"id\"]\n+            resolved_id = session[\"id\"]\n+        else:\n+            # Try as title (with auto-latest for lineage)\n+            resolved_id = db.resolve_session_by_title(name_or_id)\n+\n+        if resolved_id:\n+            # Project forward through compression chain so resumes land on\n+            # the live tip instead of a dead compressed parent.\n+            try:\n+                resolved_id = db.get_compression_tip(resolved_id) or resolved_id\n+            except Exception:\n+                pass\n \n-        # Try as title (with auto-latest for lineage)\n-        session_id = db.resolve_session_by_title(name_or_id)\n         db.close()\n-        return session_id\n+        return resolved_id\n     except Exception:\n         pass\n     return None"]},{"file":"hermes_state.py","hunks":["@@ -723,6 +723,42 @@ def get_next_title_in_lineage(self, base_title: str) -> str:\n \n         return f\"{base} #{max_num + 1}\"\n \n+    def get_compression_tip(self, session_id: str) -> Optional[str]:\n+        \"\"\"Walk the compression-continuation chain forward and return the tip.\n+\n+        A compression continuation is a child session where:\n+        1. The parent's ``end_reason = 'compression'``\n+        2. The child was created AFTER the parent was ended (started_at >= ended_at)\n+\n+        The second condition distinguishes compression continuations from\n+        delegate subagents or branch children, which can also have a\n+        ``parent_session_id`` but were created while the parent was still live.\n+\n+        Returns the session_id of the latest continuation in the chain, or the\n+        input ``session_id`` if it isn't part of a compression chain (or if the\n+        input itself doesn't exist).\n+        \"\"\"\n+        current = session_id\n+        # Bound the walk defensively — compression chains this deep are\n+        # pathological and shouldn't happen in practice. 100 = plenty.\n+        for _ in range(100):\n+            with self._lock:\n+                cursor = self._conn.execute(\n+                    \"SELECT id FROM sessions \"\n+                    \"WHERE parent_session_id = ? \"\n+                    \"  AND started_at >= (\"\n+                    \"      SELECT ended_at FROM sessions \"\n+                    \"      WHERE id = ? AND end_reason = 'compression'\"\n+                    \"  ) \"\n+                    \"ORDER BY started_at DESC LIMIT 1\",\n+                    (current, current),\n+                )\n+                row = cursor.fetchone()\n+            if row is None:\n+                return current\n+            current = row[\"id\"]\n+        return current\n+\n     def list_sessions_rich(\n         self,\n         source: str = None,","@@ -730,6 +766,7 @@ def list_sessions_rich(\n         limit: int = 20,\n         offset: int = 0,\n         include_children: bool = False,\n+        project_compression_tips: bool = True,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"List sessions with preview (first user message) and last active timestamp.\n ","@@ -741,6 +778,14 @@ def list_sessions_rich(\n \n         By default, child sessions (subagent runs, compression continuations)\n         are excluded.  Pass ``include_children=True`` to include them.\n+\n+        With ``project_compression_tips=True`` (default), sessions that are\n+        roots of compression chains are projected forward to their latest\n+        continuation — one logical conversation = one list entry, showing the\n+        live continuation's id/message_count/title/last_active. This prevents\n+        compressed continuations from being invisible to users while keeping\n+        delegate subagents and branches hidden. Pass ``False`` to return the\n+        raw root rows (useful for admin/debug UIs).\n         \"\"\"\n         where_clauses = []\n         params = []","@@ -791,8 +836,77 @@ def list_sessions_rich(\n                 s[\"preview\"] = \"\"\n             sessions.append(s)\n \n+        # Project compression roots forward to their tips. Each row whose\n+        # end_reason is 'compression' has a continuation child; replace the\n+        # surfaced fields (id, message_count, title, last_active, ended_at,\n+        # end_reason, preview) with the tip's values so the list entry acts\n+        # as the live conversation. Keep the root's started_at to preserve\n+        # chronological ordering by original conversation start.\n+        if project_compression_tips and not include_children:\n+            projected = []\n+            for s in sessions:\n+                if s.get(\"end_reason\") != \"compression\":\n+                    projected.append(s)\n+                    continue\n+                tip_id = self.get_compression_tip(s[\"id\"])\n+                if tip_id == s[\"id\"]:\n+                    projected.append(s)\n+                    continue\n+                tip_row = self._get_session_rich_row(tip_id)\n+                if not tip_row:\n+                    projected.append(s)\n+                    continue\n+                # Preserve the root's started_at for stable sort order, but\n+                # surface the tip's identity and activity data.\n+                merged = dict(s)\n+                for key in (\n+                    \"id\", \"ended_at\", \"end_reason\", \"message_count\",\n+                    \"tool_call_count\", \"title\", \"last_active\", \"preview\",\n+                    \"model\", \"system_prompt\",\n+                ):\n+                    if key in tip_row:\n+                        merged[key] = tip_row[key]\n+                merged[\"_lineage_root_id\"] = s[\"id\"]\n+                projected.append(merged)\n+            sessions = projected\n+\n         return sessions\n \n+    def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]:\n+        \"\"\"Fetch a single session with the same enriched columns as\n+        ``list_sessions_rich`` (preview + last_active). Returns None if the\n+        session doesn't exist.\n+        \"\"\"\n+        query = \"\"\"\n+            SELECT s.*,\n+                COALESCE(\n+                    (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)\n+                     FROM messages m\n+                     WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL\n+                     ORDER BY m.timestamp, m.id LIMIT 1),\n+                    ''\n+                ) AS _preview_raw,\n+                COALESCE(\n+                    (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),\n+                    s.started_at\n+                ) AS last_active\n+            FROM sessions s\n+            WHERE s.id = ?\n+        \"\"\"\n+        with self._lock:\n+            cursor = self._conn.execute(query, (session_id,))\n+            row = cursor.fetchone()\n+        if not row:\n+            return None\n+        s = dict(row)\n+        raw = s.pop(\"_preview_raw\", \"\").strip()\n+        if raw:\n+            text = raw[:60]\n+            s[\"preview\"] = text + (\"...\" if len(raw) > 60 else \"\")\n+        else:\n+            s[\"preview\"] = \"\"\n+        return s\n+\n     # =========================================================================\n     # Message storage\n     # ========================================================================="]},{"file":"tests/test_hermes_state.py","hunks":["@@ -1381,6 +1381,178 @@ def test_preview_newlines_collapsed(self, db):\n         assert \"Line one Line two\" in sessions[0][\"preview\"]\n \n \n+class TestCompressionChainProjection:\n+    \"\"\"Tests for lineage-aware list_sessions_rich — compressed conversations\n+    surface as their live continuation tip, not the dead parent root.\n+    \"\"\"\n+\n+    def _build_compression_chain(self, db, t0: float):\n+        \"\"\"Helper: builds root -> delegate -> compression-child -> tip chain.\n+\n+        Returns (root_id, delegate_id, mid_id, tip_id).\n+        \"\"\"\n+        import time as _time\n+        # Root that gets compressed\n+        db.create_session(\"root1\", \"cli\")\n+        db._conn.execute(\"UPDATE sessions SET started_at=? WHERE id=?\", (t0, \"root1\"))\n+        db.append_message(\"root1\", \"user\", \"help me refactor auth\")\n+\n+        # Delegate subagent spawned while root1 was live (before it ended)\n+        db.create_session(\"delegate1\", \"cli\", parent_session_id=\"root1\")\n+        db._conn.execute(\n+            \"UPDATE sessions SET started_at=?, ended_at=? WHERE id=?\",\n+            (t0 + 600, t0 + 650, \"delegate1\"),\n+        )\n+        db.append_message(\"delegate1\", \"user\", \"delegate task\")\n+\n+        # root1 compressed at t0+1800\n+        t_compress_root = t0 + 1800\n+        db._conn.execute(\n+            \"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?\",\n+            (t_compress_root, \"compression\", \"root1\"),\n+        )\n+\n+        # Continuation mid created 1s after parent ended\n+        db.create_session(\"mid1\", \"cli\", parent_session_id=\"root1\")\n+        db._conn.execute(\n+            \"UPDATE sessions SET started_at=? WHERE id=?\",\n+            (t_compress_root + 1, \"mid1\"),\n+        )\n+        db.append_message(\"mid1\", \"user\", \"continuing\")\n+\n+        # mid1 also compressed\n+        t_compress_mid = t_compress_root + 1800\n+        db._conn.execute(\n+            \"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?\",\n+            (t_compress_mid, \"compression\", \"mid1\"),\n+        )\n+\n+        # Tip — latest continuation\n+        db.create_session(\"tip1\", \"cli\", parent_session_id=\"mid1\")\n+        db._conn.execute(\n+            \"UPDATE sessions SET started_at=? WHERE id=?\",\n+            (t_compress_mid + 1, \"tip1\"),\n+        )\n+        db.append_message(\"tip1\", \"user\", \"latest message\")\n+\n+        db._conn.commit()\n+        return (\"root1\", \"delegate1\", \"mid1\", \"tip1\")\n+\n+    def test_get_compression_tip_walks_full_chain(self, db):\n+        import time as _time\n+        self._build_compression_chain(db, _time.time() - 3600)\n+        assert db.get_compression_tip(\"root1\") == \"tip1\"\n+        assert db.get_compression_tip(\"mid1\") == \"tip1\"\n+        assert db.get_compression_tip(\"tip1\") == \"tip1\"\n+\n+    def test_get_compression_tip_returns_self_for_uncompressed(self, db):\n+        db.create_session(\"solo\", \"cli\")\n+        assert db.get_compression_tip(\"solo\") == \"solo\"\n+\n+    def test_get_compression_tip_skips_delegate_children(self, db):\n+        \"\"\"Delegate subagents have parent_session_id set but were created\n+        BEFORE the parent ended. They must not be followed as compression\n+        continuations — the started_at >= ended_at guard handles this.\n+        \"\"\"\n+        import time as _time\n+        self._build_compression_chain(db, _time.time() - 3600)\n+        # delegate1 is a child of root1 but NOT a compression continuation.\n+        # root1's tip must be tip1 (via mid1), not delegate1.\n+        assert db.get_compression_tip(\"root1\") == \"tip1\"\n+\n+    def test_list_surfaces_tip_for_compressed_root(self, db):\n+        \"\"\"The list must show the tip's id/message_count/preview in place of\n+        the root row, so users can see and resume the live conversation.\n+        \"\"\"\n+        import time as _time\n+        self._build_compression_chain(db, _time.time() - 3600)\n+        # Add an uncompressed root for comparison.\n+        db.create_session(\"solo\", \"cli\")\n+        db.append_message(\"solo\", \"user\", \"standalone\")\n+        db._conn.commit()\n+\n+        sessions = db.list_sessions_rich(source=\"cli\", limit=20)\n+        ids = [s[\"id\"] for s in sessions]\n+        # Only top-level conversations appear: tip1 (projected from root1) + solo.\n+        # Delegate children, mid1, and the dead root1 must NOT be in the list.\n+        assert \"tip1\" in ids\n+        assert \"solo\" in ids\n+        assert \"root1\" not in ids\n+        assert \"mid1\" not in ids\n+        assert \"delegate1\" not in ids\n+\n+        tip_row = next(s for s in sessions if s[\"id\"] == \"tip1\")\n+        # The row surfaces the tip's identity but preserves the root's start\n+        # timestamp for stable ordering and lineage tracking.\n+        assert tip_row[\"_lineage_root_id\"] == \"root1\"\n+        assert tip_row[\"preview\"].startswith(\"latest message\")\n+        assert tip_row[\"ended_at\"] is None  # tip is still live\n+        assert tip_row[\"end_reason\"] is None\n+\n+    def test_list_without_projection_returns_raw_root(self, db):\n+        \"\"\"project_compression_tips=False returns the raw parent-NULL root\n+        rows — useful for admin/debug UIs.\n+        \"\"\"\n+        import time as _time\n+        self._build_compression_chain(db, _time.time() - 3600)\n+        sessions = db.list_sessions_rich(\n+            source=\"cli\", limit=20, project_compression_tips=False\n+        )\n+        ids = [s[\"id\"] for s in sessions]\n+        assert \"root1\" in ids\n+        assert \"tip1\" not in ids\n+\n+        root_row = next(s for s in sessions if s[\"id\"] == \"root1\")\n+        assert root_row[\"end_reason\"] == \"compression\"\n+        assert \"_lineage_root_id\" not in root_row\n+\n+    def test_list_preserves_sort_by_started_at(self, db):\n+        \"\"\"Chronological ordering uses the ROOT's started_at (conversation\n+        start), not the tip's. This keeps lineage entries stable in the list\n+        even as new compressions push the tip forward in time.\n+        \"\"\"\n+        import time as _time\n+        t0 = _time.time() - 3600\n+        self._build_compression_chain(db, t0)\n+\n+        # Create a newer standalone session that should sort above the lineage\n+        # if we used tip.started_at, but below if we correctly use root.started_at.\n+        t_between = t0 + 120  # between root1 and its compression\n+        db.create_session(\"newer\", \"cli\")\n+        db._conn.execute(\"UPDATE sessions SET started_at=? WHERE id=?\", (t_between, \"newer\"))\n+        db.append_message(\"newer\", \"user\", \"newer session started after root1\")\n+        db._conn.commit()\n+\n+        sessions = db.list_sessions_rich(source=\"cli\", limit=20)\n+        ids_in_order = [s[\"id\"] for s in sessions]\n+        # 'newer' started AFTER root1 but BEFORE tip1's actual started_at.\n+        # Correct ordering (by root started_at): newer > tip1's lineage entry.\n+        assert ids_in_order.index(\"newer\") < ids_in_order.index(\"tip1\")\n+\n+    def test_list_handles_broken_chain_gracefully(self, db):\n+        \"\"\"A compression root with no child (e.g. DB corruption or a partial\n+        end_session call that didn't finish creating the child) must not\n+        crash the list — it should fall back to surfacing the root as-is.\n+        \"\"\"\n+        import time as _time\n+        t0 = _time.time() - 100\n+        db.create_session(\"orphan\", \"cli\")\n+        db._conn.execute(\"UPDATE sessions SET started_at=? WHERE id=?\", (t0, \"orphan\"))\n+        db._conn.execute(\n+            \"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?\",\n+            (t0 + 10, \"compression\", \"orphan\"),\n+        )\n+        db._conn.commit()\n+\n+        sessions = db.list_sessions_rich(source=\"cli\", limit=10)\n+        ids = [s[\"id\"] for s in sessions]\n+        assert \"orphan\" in ids\n+        row = next(s for s in sessions if s[\"id\"] == \"orphan\")\n+        # No tip means no projection — row stays raw.\n+        assert \"_lineage_root_id\" not in row\n+        assert row[\"end_reason\"] == \"compression\"\n+\n+\n # =========================================================================\n # Session source exclusion (--source flag for third-party isolation)\n # =========================================================================\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}