From 6ad0bc20f53d5fe240cc99ac0a105543aa895818 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:13:18 +0700 Subject: [PATCH] fix(sessions): let a compression continuation reclaim its base title When context compression rotates a session, the original is ended and the continuation is auto-numbered (e.g. "name" -> "name #2"). The session list projects the ended root behind its live tip, so the user never sees the predecessor. But set_session_title's uniqueness check compared against ALL sessions, so renaming the visible tip back to "name" dead-ended with "Title 'name' is already in use by session ". When the conflicting title is held by a compression ancestor of the session being renamed, transfer the title instead of raising: clear it from the ended predecessor and apply it to the continuation. Uniqueness is preserved (still exactly one session carries the title) and the parent-link lineage is untouched, so resume-by-title and tip projection keep working. Genuine conflicts with unrelated sessions, and with non-compression children (delegate/branch), still raise as before. --- hermes_state.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 36e5c91fe8a..2ca3c657d13 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1836,6 +1836,48 @@ class SessionDB: return cleaned + def _is_compression_ancestor( + self, conn, *, ancestor_id: str, descendant_id: str + ) -> bool: + """Return True if *ancestor_id* is a compression predecessor of + *descendant_id* (walking parent links up the continuation chain). + + Uses the same edge definition as :meth:`get_compression_tip`: a + parent → child edge counts as a compression continuation only when the + parent ended with ``end_reason = 'compression'`` and the child started + at or after the parent's ``ended_at`` (which distinguishes continuations + from delegate subagents / branch children that also carry a + ``parent_session_id``). + """ + if not ancestor_id or not descendant_id or ancestor_id == descendant_id: + return False + current = descendant_id + # Bound the walk defensively, mirroring get_compression_tip. + for _ in range(100): + row = conn.execute( + "SELECT parent_session_id, started_at FROM sessions WHERE id = ?", + (current,), + ).fetchone() + if row is None or not row["parent_session_id"]: + return False + parent_id = row["parent_session_id"] + parent = conn.execute( + "SELECT ended_at, end_reason FROM sessions WHERE id = ?", + (parent_id,), + ).fetchone() + if ( + parent is None + or parent["end_reason"] != "compression" + or parent["ended_at"] is None + or row["started_at"] is None + or row["started_at"] < parent["ended_at"] + ): + return False + if parent_id == ancestor_id: + return True + current = parent_id + return False + def set_session_title(self, session_id: str, title: str) -> bool: """Set or update a session's title. @@ -1854,9 +1896,29 @@ class SessionDB: ) conflict = cursor.fetchone() if conflict: - raise ValueError( - f"Title '{title}' is already in use by session {conflict['id']}" - ) + conflict_id = conflict["id"] + # A compression continuation is the live, projected-forward + # head of its conversation; its compressed predecessors are + # ended and hidden from the session list (list_sessions_rich + # projects roots → tip). When the title that "conflicts" is + # held by such a hidden ancestor, the user has no way to free + # it — renaming the visible tip back to the base name would + # dead-end with "already in use by ". + # Treat this as a transfer: move the title off the ancestor + # onto the continuation. Uniqueness is preserved (still only + # one session carries the exact title) and the parent-link + # lineage is untouched. + if self._is_compression_ancestor( + conn, ancestor_id=conflict_id, descendant_id=session_id + ): + conn.execute( + "UPDATE sessions SET title = NULL WHERE id = ?", + (conflict_id,), + ) + else: + raise ValueError( + f"Title '{title}' is already in use by session {conflict_id}" + ) cursor = conn.execute( "UPDATE sessions SET title = ? WHERE id = ?", (title, session_id),