From 6ad0bc20f53d5fe240cc99ac0a105543aa895818 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:13:18 +0700 Subject: [PATCH 1/3] 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 36e5c91fe8a1..2ca3c657d133 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), From 65d050cf0e94a2c435db4c2f8d46a2952515193e Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:13:24 +0700 Subject: [PATCH 2/3] test(sessions): cover title reclaim across a compression lineage Regression tests for renaming a compression continuation back to its base title: single- and multi-level chains transfer the title off the ended predecessor, while unrelated sessions and non-compression children (created while the parent was live) still raise the uniqueness conflict. --- tests/test_hermes_state.py | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index e4650ed5dc79..1d727132a8c3 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2065,6 +2065,89 @@ class TestSessionTitle: assert session["ended_at"] is not None +class TestSessionTitleLineage: + """Renaming a compression continuation back to its base title must succeed + by transferring the title off the ended, hidden predecessor. + + After a context compaction the original session is ended and projected + behind its live tip in the session list (list_sessions_rich), so the user + cannot see or free it. Without lineage-aware handling, renaming the visible + tip back to the base name dead-ends with "already in use by ". + """ + + def _make_compression_chain(self, db, t0, *, root="root", tip="tip"): + db.create_session(root, "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, root)) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 100, root), + ) + db.create_session(tip, "cli", parent_session_id=root) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, tip)) + db._conn.commit() + + def test_rename_continuation_back_to_base_transfers_title(self, db): + import time as _time + self._make_compression_chain(db, _time.time() - 3600) + db.set_session_title("root", "fingerprint-scanner") + db.set_session_title("tip", "fingerprint-scanner #2") + + # User renames the visible tip back to the base name — must succeed. + assert db.set_session_title("tip", "fingerprint-scanner") is True + assert db.get_session("tip")["title"] == "fingerprint-scanner" + # Title transferred off the hidden ancestor — no duplicate titles. + assert db.get_session("root")["title"] is None + + def test_transfer_walks_multi_level_chain(self, db): + import time as _time + t0 = _time.time() - 7200 + # root (compression) -> mid (compression) -> tip + self._make_compression_chain(db, t0, root="root", tip="mid") + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 300, "mid"), + ) + db.create_session("tip", "cli", parent_session_id="mid") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 400, "tip")) + db._conn.commit() + + db.set_session_title("root", "deep-dive") + assert db.set_session_title("tip", "deep-dive") is True + assert db.get_session("tip")["title"] == "deep-dive" + assert db.get_session("root")["title"] is None + + def test_unrelated_session_still_conflicts(self, db): + db.create_session("a", "cli") + db.create_session("b", "cli") + db.set_session_title("a", "shared") + with pytest.raises(ValueError, match="already in use"): + db.set_session_title("b", "shared") + # The unrelated holder keeps its title. + assert db.get_session("a")["title"] == "shared" + + def test_non_compression_child_still_conflicts(self, db): + """A child whose parent did NOT end via compression (delegate/branch + spawned while the parent was live) is not a continuation, so renaming it + to the parent's title must still raise.""" + import time as _time + t0 = _time.time() - 3600 + db.create_session("parent", "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "parent")) + db.create_session("child", "cli", parent_session_id="parent") + # Child started BEFORE parent ended, and parent ended for a non- + # compression reason — not a continuation edge. + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 10, "child")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='user_exit' WHERE id=?", + (t0 + 100, "parent"), + ) + db._conn.commit() + db.set_session_title("parent", "shared") + with pytest.raises(ValueError, match="already in use"): + db.set_session_title("child", "shared") + + class TestSanitizeTitle: """Tests for SessionDB.sanitize_title() validation and cleaning.""" From 8c70346e33e34d204ecf9ef1c29e8d374182d56c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:37:39 +0530 Subject: [PATCH 3/3] refactor(sessions): express compression-ancestor check as one recursive CTE _is_compression_ancestor walked parent links in a 100-hop Python loop issuing two SELECTs per hop and hand-re-encoded the compression continuation edge a fourth time. Collapse it into a single recursive CTE that reuses the canonical _COMPRESSION_CHILD_SQL fragment (already shared by _ephemeral_child_sql and set_session_archived), so the edge definition lives in exactly one place. The UNION recursion also dedups visited nodes, making it cycle-safe without the defensive hop cap. Behavior is unchanged (all TestSessionTitleLineage + existing title-command tests pass). --- hermes_state.py | 55 ++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 2ca3c657d133..8847593d47c1 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1842,41 +1842,36 @@ class SessionDB: """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 + The continuation edge is the canonical one shared with + :func:`_ephemeral_child_sql` / :meth:`set_session_archived` + (``_COMPRESSION_CHILD_SQL``): a parent → child edge counts only when the parent ended with ``end_reason = 'compression'`` and the child started - at or after the parent's ``ended_at`` (which distinguishes continuations + at or after the parent's ``ended_at``, which distinguishes continuations from delegate subagents / branch children that also carry a - ``parent_session_id``). + ``parent_session_id``. Expressed as a single recursive CTE rather than a + per-hop Python walk so the edge definition lives in exactly one place. """ 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 + # Walk parent links up from the descendant, following only compression + # continuation edges, and check whether ancestor_id is reached. + edge = _COMPRESSION_CHILD_SQL.format(a="child") + row = conn.execute( + f""" + WITH RECURSIVE ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE {edge} + ) + SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1 + """, + (descendant_id, ancestor_id, descendant_id), + ).fetchone() + return row is not None def set_session_title(self, session_id: str, title: str) -> bool: """Set or update a session's title.