Files
hermes-agent/tests/test_state_db_fts_segment_collision_probe.py
Teknium d8cf5da7ac fix(state): make the FTS write-health probe flush segments and catch IntegrityError
`_db_opens_cleanly` drove one probe row through the messages_fts* triggers
and rolled back. FTS5 only buffers that row in an in-memory segment until
commit, so the probe never wrote to `<fts>_idx`/`_data` and could not hit a
stale `messages_fts_trigram_idx` row waiting at the next segid — the class
where PRAGMA integrity_check, the FTS5 integrity-check command and MATCH all
report clean while every committed append fails with
`IntegrityError: constraint failed`. The probe also caught only
OperationalError; IntegrityError is a DatabaseError sibling, so even a
colliding probe would have escaped and been reported as healthy.

Now the probe issues `INSERT INTO <fts>(<fts>) VALUES('flush')` for every
FTS family inside the rolled-back transaction (capability / not-built errors
stay benign), catches sqlite3.DatabaseError, and always rolls back in a
finally. `hermes doctor` and `hermes sessions repair --check-only` surface
the corruption and `repair_state_db_schema` heals it via the FTS rebuild
strategy (verified with a real stale-segid fixture).

Refs #100227
Reported-by: #100227
2026-09-11 06:37:27 -07:00

77 lines
3.1 KiB
Python

"""Segment-dependent FTS5 trigram corruption (#100227).
A stale ``messages_fts_trigram_idx`` row sitting at the segid FTS5 allocates next makes every
committed append fail with ``IntegrityError: constraint failed`` while ``PRAGMA integrity_check``,
the FTS5 ``integrity-check`` command and ``MATCH`` all report healthy. The write probe must
report it (and the repair must heal it) without any mocked detector.
"""
import sqlite3
import time
import uuid
from pathlib import Path
import pytest
from hermes_state import SessionDB
from hermes_state_repair import _db_opens_cleanly, repair_state_db_schema
def _build_db_with_trigram(db_path: Path) -> str:
db = SessionDB(db_path=db_path)
if not db._trigram_available:
db.close()
pytest.skip("trigram tokenizer unavailable in this SQLite build")
sid = db.create_session(session_id=str(uuid.uuid4()), source="cli")
for i in range(60):
db.append_message(sid, role="user", content=f"quick brown fox {i} lorem ipsum dolor {i * 7}")
db.close()
return sid
def _plant_stale_trigram_segment(db_path: Path) -> None:
"""Leave an index row at the next free segid: the shape an aborted segment write leaves behind."""
conn = sqlite3.connect(str(db_path), isolation_level=None)
used = {r[0] for r in conn.execute("SELECT segid FROM messages_fts_trigram_idx")}
stale = next(s for s in range(1, 1 << 20) if s not in used)
conn.execute("INSERT INTO messages_fts_trigram_idx(segid, term, pgno) VALUES (?, X'', 2)", (stale,))
conn.close()
def _real_append_fails(db_path: Path, sid: str) -> bool:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
(sid, "user", "zebra yak xylophone wombat", time.time()))
return False
except sqlite3.IntegrityError:
return True
finally:
conn.close()
def test_write_probe_reports_segment_collision_that_integrity_check_misses(tmp_path):
db_path = tmp_path / "state.db"
sid = _build_db_with_trigram(db_path)
_plant_stale_trigram_segment(db_path)
assert sqlite3.connect(str(db_path)).execute("PRAGMA integrity_check").fetchall() == [("ok",)]
assert _real_append_fails(db_path, sid), "fixture must break real appends"
reason = _db_opens_cleanly(db_path)
assert reason is not None and "constraint failed" in reason
# The probe rolls back: it must not have added rows or moved the FTS state.
assert sqlite3.connect(str(db_path)).execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 1
def test_repair_heals_segment_collision_and_restores_appends(tmp_path):
db_path = tmp_path / "state.db"
sid = _build_db_with_trigram(db_path)
_plant_stale_trigram_segment(db_path)
report = repair_state_db_schema(db_path, backup=False)
assert report.get("repaired"), report
assert _db_opens_cleanly(db_path) is None
assert not _real_append_fails(db_path, sid)
with SessionDB(db_path=db_path) as db:
assert db._conn.execute("SELECT COUNT(*) FROM messages WHERE session_id = ?", (sid,)).fetchone()[0] == 61