fix(state): heal NULL active rows on every startup, not just pre-v12 DBs

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.
This commit is contained in:
kshitijk4poor 2026-07-07 11:25:56 +05:30 committed by kshitij
parent 7445df1505
commit b75783e6dc
2 changed files with 71 additions and 12 deletions

View file

@ -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).

View file

@ -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