fix(#5626): ignore inactive compacted state-db rows in context replay

get_state_db_session_messages() returned archived (active=0) rows that in-place
compaction had marked inactive. WebUI reconciliation feeds this reader straight
into the next model-facing context (reconciled_state_db_messages_for_session,
prefer_context=True), so those archived rows got pulled back in — resurrecting
pre-compaction history and making every later turn re-trigger compression. Now,
WHEN the messages table exposes an `active` column, inactive rows are excluded by
default (`AND (active IS NULL OR active != 0)` — NULL/legacy rows preserved), with
an `include_inactive=True` escape hatch for explicit recovery/audit callers.
Schemas without the column are unaffected (guarded by `'active' in available`).

Contributor stage; ungated on arrival. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
nesquena-hermes committed 2026-07-05 08:59:21 +00:00
1 parent 35464be634
commit 1165bd9ca2
2 files changed
+89

No files matched your search

+12
View File
@@ -6143,6 +6143,7 @@ def get_state_db_session_messages(
stitch_continuations: bool = False,
profile=None,
since_timestamp=None,
include_inactive: bool = False,
) -> list:
"""Read messages for a Hermes session from state.db.
@@ -6158,6 +6159,13 @@ def get_state_db_session_messages(
raw state.db scan to rows at or after a sidecar-derived timestamp floor while
preserving the caller's normal merge/window logic. Full-history callers must
leave it unset.
When the messages table exposes an ``active`` column, inactive rows are
compacted/archived history and are intentionally excluded by default. WebUI
reconciliation feeds this reader straight into the next model context; pulling
``active=0`` archive rows back in resurrects pre-compaction history and can
make every later turn re-trigger compression. Pass ``include_inactive=True``
only for explicit recovery/audit views.
"""
try:
import sqlite3
@@ -6249,11 +6257,15 @@ def get_state_db_session_messages(
if since_ts is not None:
since_clause = " AND (timestamp IS NULL OR timestamp >= ?)"
params.append(since_ts)
active_clause = ""
if 'active' in available and not include_inactive:
active_clause = " AND (active IS NULL OR active != 0)"
cur.execute(f"""
SELECT {', '.join(selected)}, session_id
FROM messages
WHERE session_id IN ({placeholders})
{since_clause}
{active_clause}
ORDER BY timestamp ASC, id ASC
""", params)
msgs = []
+77
View File
@@ -0,0 +1,77 @@
import sqlite3
import api.models as models
from api.models import Session, get_state_db_session_messages, reconciled_state_db_messages_for_session
def _make_state_db(path):
conn = sqlite3.connect(str(path))
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT,
content TEXT,
timestamp REAL,
active INTEGER
)
"""
)
conn.executemany(
"INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?, ?, ?, ?, ?)",
[
("sid", "user", "archived prompt", 1.0, 0),
("sid", "assistant", "archived answer", 2.0, 0),
("sid", "assistant", "compacted summary", 3.0, 1),
("sid", "user", "live follow-up", 4.0, 1),
],
)
conn.commit()
conn.close()
def test_state_db_reader_excludes_inactive_compaction_archive_by_default(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_make_state_db(db)
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
messages = get_state_db_session_messages("sid")
assert [m["content"] for m in messages] == ["compacted summary", "live follow-up"]
def test_state_db_reader_can_include_inactive_for_explicit_recovery(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_make_state_db(db)
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
messages = get_state_db_session_messages("sid", include_inactive=True)
assert [m["content"] for m in messages] == [
"archived prompt",
"archived answer",
"compacted summary",
"live follow-up",
]
def test_reconciled_context_does_not_resurrect_inactive_archive_rows(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_make_state_db(db)
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
session = Session(
session_id="sid",
messages=[{"role": "user", "content": "full display transcript"}],
context_messages=[{"role": "assistant", "content": "compacted summary", "timestamp": 3.0}],
)
state_messages = get_state_db_session_messages("sid")
reconciled = reconciled_state_db_messages_for_session(
session,
prefer_context=True,
state_messages=state_messages,
)
assert [m["content"] for m in reconciled] == ["compacted summary", "live follow-up"]