From b75783e6dc3ae881b49062a61d2e4fe3fc8fd36c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:25:56 +0530 Subject: [PATCH] fix(state): heal NULL active rows on every startup, not just pre-v12 DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair UPDATE ('SET active = 1 WHERE active IS NULL') was gated at schema_version < 12, so already-v12+ databases — the exact population hit by #51646, where the reconciler-added active column lacks its NOT NULL DEFAULT 1 — never healed rows written as NULL by the pre-fix INSERTs. Move the idempotent repair into unconditional startup so historical gateway transcripts become visible again after upgrading. Follow-up to the salvage of #59832 by @HexLab98. --- hermes_state.py | 29 +++++++++++--------- tests/test_hermes_state.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 0a506c85a49..6e1300f5de4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1381,6 +1381,23 @@ class SessionDB: # column (idx_messages_session_active) — same ordering constraint. cursor.executescript(DEFERRED_INDEX_SQL) + # Heal NULL ``active`` rows unconditionally on every startup. + # On real-world DBs the reconciler-added ``active`` column can lack + # its NOT NULL DEFAULT 1 (older reconciler builds reconstructed the + # type without the default — see #51646: PRAGMA shows + # (17,'active','INTEGER',0,None,0) in the wild), so INSERTs that + # omitted the column wrote NULL and the ``WHERE active = 1`` + # transcript loaders hid the whole history. The INSERTs now set + # active=1 explicitly; this idempotent repair un-hides rows written + # before the fix. It was previously gated at ``current_version < + # 12`` which never re-ran for already-v12+ databases. + try: + cursor.execute( + "UPDATE messages SET active = 1 WHERE active IS NULL" + ) + except sqlite3.OperationalError: + pass + fts5_available = self._sqlite_supports_fts5(cursor) fts_migrations_complete = True if not fts5_available: @@ -1495,18 +1512,6 @@ class SessionDB: fts_migrations_complete = False else: fts_migrations_complete = False - if current_version < 12: - # v12: messages.active flag for rewind/undo soft-deletion. - # The declarative reconcile_columns() above adds the - # column itself; this UPDATE is belt-and-suspenders to - # ensure any rows that pre-existed the ADD COLUMN have - # active=1 rather than NULL. - try: - cursor.execute( - "UPDATE messages SET active = 1 WHERE active IS NULL" - ) - except sqlite3.OperationalError: - pass if current_version < 16: # v16: tag delegate subagent rows so pickers stay clean after # parent deletes that used to orphan them (parent_session_id → NULL). diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 8e0231b4c41..4ad888442af 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -717,6 +717,60 @@ class TestMessageStorage: finally: session_db.close() + def test_startup_heals_null_active_rows(self, tmp_path): + """Rows written as active=NULL before the fix are un-hidden on startup. + + The repair UPDATE used to be gated at schema_version < 12, so + already-v12+ databases (the exact population hit by #51646) never + healed their historical NULL rows. It now runs on every startup. + """ + db_path = tmp_path / "legacy_state.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER); + INSERT INTO schema_version VALUES (12); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT, started_at REAL, ended_at REAL, + message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, + title TEXT, parent_session_id TEXT, model_config TEXT + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, + tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, + timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, + reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, + codex_reasoning_items TEXT, codex_message_items TEXT, + platform_message_id TEXT, observed INTEGER DEFAULT 0 + ); + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + """ + ) + # Default-less active column, as seen in the wild (#51646 PRAGMA). + conn.execute("ALTER TABLE messages ADD COLUMN active INTEGER") + conn.execute("ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0") + conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'discord', 1.0)" + ) + # A row written by the pre-fix INSERT: active is NULL. + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES ('s1', 'user', 'old hidden turn', 1.0)" + ) + conn.commit() + conn.close() + + session_db = SessionDB(db_path=db_path) + try: + active = session_db._conn.execute( + "SELECT active FROM messages WHERE content = 'old hidden turn'" + ).fetchone()[0] + assert active == 1 + assert len(session_db.get_messages_as_conversation("s1")) == 1 + finally: + session_db.close() + def test_append_message_accepts_explicit_timestamp(self, db): db.create_session(session_id="s1", source="telegram") event_ts = 1777383653.0