From b6207cb5903607bf475881b1901f52a13e6133de Mon Sep 17 00:00:00 2001 From: KoNit-K <124019182+KoNit-K@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:23:43 +0800 Subject: [PATCH] fix(state): release archived Bot Chat title --- hermes_state_titles.py | 12 +++++++++++- .../hermes_state/test_canonical_title_guard.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/hermes_state_titles.py b/hermes_state_titles.py index 1305a2a22c..4c49aaad8c 100644 --- a/hermes_state_titles.py +++ b/hermes_state_titles.py @@ -97,7 +97,7 @@ class SessionTitlesMixin: return 0 if title: conflict = conn.execute( - "SELECT id FROM sessions WHERE title = ? AND id != ?", (title, session_id), + "SELECT id, archived, hidden FROM sessions WHERE title = ? AND id != ?", (title, session_id), ).fetchone() if conflict: conflict_id = conflict["id"] @@ -105,6 +105,16 @@ class SessionTitlesMixin: # user, so transfer it onto the tip (uniqueness + lineage kept). 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,)) + # A deliberately archived hidden Bot Chat is a retired registry + # entry, not a live identity. Retire its name in the same title + # transaction so a replacement can become the sole canonical row; + # the old session remains archived and otherwise untouched. + elif (title == self.CANONICAL_BOT_CHAT_TITLE and bool(conflict["archived"]) + and bool(conflict["hidden"])): + conn.execute( + "UPDATE sessions SET title = NULL, title_source = NULL WHERE id = ?", + (conflict_id,), + ) else: raise ValueError(f"Title '{title}' is already in use by session {conflict_id}") # CAS on the values just read (``IS`` is NULL-safe): a concurrent write between diff --git a/tests/hermes_state/test_canonical_title_guard.py b/tests/hermes_state/test_canonical_title_guard.py index 784825c25e..de75a5043e 100644 --- a/tests/hermes_state/test_canonical_title_guard.py +++ b/tests/hermes_state/test_canonical_title_guard.py @@ -61,6 +61,23 @@ def test_visible_session_titled_bot_chat_stays_renameable(db): assert db.get_session("ordinary")["title"] == "renamed away" +def test_deliberately_archived_canonical_chat_releases_name_for_replacement(db): + """Retiring a Bot Chat must not leave its registry name permanently locked.""" + retired = _make_canonical(db, "retired") + assert db.set_session_archived(retired, True) + + db.create_session("replacement", source="desktop") + assert db.set_session_title("replacement", SessionDB.CANONICAL_BOT_CHAT_TITLE) + assert db.set_session_hidden("replacement", True) + + # The retired row stays archived, while exact-title resolution reaches the + # replacement that Bot Mode will subsequently open. + assert db.get_session(retired)["archived"] + assert db.get_session(retired)["title"] is None + row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE) + assert row and row["id"] == "replacement" + + def test_auto_titler_still_cannot_touch_the_canonical_row(db): # Pre-existing provenance contract, re-pinned here: user-authority title # outranks derived/llm, so the turn-start auto-titler can never displace