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:
1 parent
35464be634
commit
1165bd9ca2
2 files changed
+89
No files matched your search
@@ -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 = []
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user