{"repo":"NousResearch/hermes-agent","public_id":"65602","display_label":"Fix duplicate session title integrity errors on startup","summary":"These issues and pull requests address catching IntegrityError exceptions and repairing duplicate session titles when a UNIQUE index constraint is violated, without data loss, during application startup.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65689,"merged_at":"2026-07-16T14:20:17+00:00"},"resolution":{"state":"resolved","by":"pr-65689"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T14:20:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":65602,"pr_number":65689,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T14:20:17+00:00"}],"members":[{"kind":"issue","number":65602,"title":"fix: catch IntegrityError on sessions.title UNIQUE index creation","url":null,"state":"closed"},{"kind":"pr","number":65628,"title":"fix: catch IntegrityError on sessions.title UNIQUE index creation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65689","number":65689},{"kind":"pr","value":"#65636","number":65636}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":65636,"title":"fix(state): repair duplicate session titles without data loss on startup (#65602)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65689","number":65689}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65689,"title":"fix(state): repair duplicate session titles without data loss on startup (#65602)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_state.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"hermes_state.py","hunks":["@@ -1777,12 +1777,39 @@ def _init_schema(self):\n                     (SCHEMA_VERSION,),\n                 )\n \n-        # Unique title index — always ensure it exists\n+        # Unique title index — always ensure it exists. Older databases may\n+        # contain duplicate aliases from before the constraint was enforced;\n+        # preserve every session while letting the newest one retain the alias.\n+        title_index_sql = (\n+            \"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique \"\n+            \"ON sessions(title) WHERE title IS NOT NULL\"\n+        )\n         try:\n-            cursor.execute(\n-                \"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique \"\n-                \"ON sessions(title) WHERE title IS NOT NULL\"\n-            )\n+            cursor.execute(title_index_sql)\n+        except sqlite3.IntegrityError:\n+            # The index is an optimization — its creation must never abort\n+            # opening the database, so the repair itself is also guarded.\n+            try:\n+                cursor.execute(\n+                    \"\"\"UPDATE sessions AS older\n+                       SET title = NULL\n+                       WHERE title IS NOT NULL\n+                         AND EXISTS (\n+                             SELECT 1 FROM sessions AS newer\n+                             WHERE newer.title = older.title\n+                               AND newer.rowid > older.rowid\n+                         )\"\"\"\n+                )\n+                logger.warning(\n+                    \"Cleared %d duplicate session title(s) while restoring the unique index\",\n+                    cursor.rowcount,\n+                )\n+                cursor.execute(title_index_sql)\n+            except sqlite3.Error:\n+                logger.exception(\n+                    \"Could not repair duplicate session titles; \"\n+                    \"unique title index not created\"\n+                )\n         except sqlite3.OperationalError:\n             pass  # Index already exists\n "]},{"file":"tests/test_hermes_state.py","hunks":["@@ -3046,6 +3046,81 @@ def test_title_survives_end_session(self, db):\n         assert session[\"ended_at\"] is not None\n \n \n+class TestSessionTitleIndexRepair:\n+    @staticmethod\n+    def _seed_legacy_database(tmp_path, *, duplicate_titles):\n+        db_path = tmp_path / \"legacy_titles.db\"\n+        session_db = SessionDB(db_path=db_path)\n+        session_db.create_session(\"older\", \"cli\")\n+        session_db.append_message(\"older\", role=\"user\", content=\"keep older message\")\n+        session_db.create_session(\"newer\", \"cli\")\n+        session_db.append_message(\n+            \"newer\", role=\"assistant\", content=\"keep newer message\"\n+        )\n+        session_db.create_session(\"unique\", \"cli\")\n+        session_db.set_session_title(\"unique\", \"unique-title\")\n+        session_db.close()\n+\n+        with sqlite3.connect(db_path) as conn:\n+            conn.execute(\"DROP INDEX idx_sessions_title_unique\")\n+            if duplicate_titles:\n+                conn.execute(\n+                    \"UPDATE sessions SET title = 'shared-title' \"\n+                    \"WHERE id IN ('older', 'newer')\"\n+                )\n+\n+        return db_path\n+\n+    def test_duplicate_titles_are_repaired_without_deleting_sessions(self, tmp_path):\n+        db_path = self._seed_legacy_database(tmp_path, duplicate_titles=True)\n+\n+        reopened = SessionDB(db_path=db_path)\n+        try:\n+            conn = reopened._conn\n+            assert conn is not None\n+            rows = {\n+                row[\"id\"]: row\n+                for row in conn.execute(\n+                    \"SELECT id, title FROM sessions ORDER BY rowid\"\n+                ).fetchall()\n+            }\n+            assert set(rows) == {\"older\", \"newer\", \"unique\"}\n+            assert rows[\"older\"][\"title\"] is None\n+            assert rows[\"newer\"][\"title\"] == \"shared-title\"\n+            assert rows[\"unique\"][\"title\"] == \"unique-title\"\n+            assert reopened.get_messages(\"older\")[0][\"content\"] == \"keep older message\"\n+            assert reopened.get_messages(\"newer\")[0][\"content\"] == \"keep newer message\"\n+            index = conn.execute(\n+                \"SELECT sql FROM sqlite_master \"\n+                \"WHERE type = 'index' AND name = 'idx_sessions_title_unique'\"\n+            ).fetchone()\n+            assert index is not None\n+        finally:\n+            reopened.close()\n+\n+    def test_repaired_index_rejects_future_duplicate_title(self, tmp_path):\n+        db_path = self._seed_legacy_database(tmp_path, duplicate_titles=True)\n+\n+        reopened = SessionDB(db_path=db_path)\n+        try:\n+            reopened.create_session(\"future\", \"cli\")\n+            with pytest.raises(ValueError, match=\"already in use\"):\n+                reopened.set_session_title(\"future\", \"shared-title\")\n+        finally:\n+            reopened.close()\n+\n+    def test_clean_legacy_database_keeps_existing_titles(self, tmp_path):\n+        db_path = self._seed_legacy_database(tmp_path, duplicate_titles=False)\n+\n+        reopened = SessionDB(db_path=db_path)\n+        try:\n+            assert reopened.get_session_title(\"unique\") == \"unique-title\"\n+            assert reopened.get_session_title(\"older\") is None\n+            assert reopened.get_session_title(\"newer\") is None\n+        finally:\n+            reopened.close()\n+\n+\n class TestSessionTitleLineage:\n     \"\"\"Renaming a compression continuation back to its base title must succeed\n     by transferring the title off the ended, hidden predecessor.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}