Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""Regression test for #99477: a FAILED in-place compaction commit must roll
|
||||
the live transcript back to its pre-compression snapshot.
|
||||
|
||||
``compress()`` returns marker-swept copies (``_strip_persistence_markers``,
|
||||
#57491) and only the post-commit ``stamp_db_persisted_markers`` (#98450) makes
|
||||
them skippable by the append-only flush. ``archive_and_compact()`` is atomic,
|
||||
so when it raises — a concurrent queued-follow-up drain holding the write path,
|
||||
a lost compression lease, ``database is locked`` — NOTHING was archived and
|
||||
NOTHING was inserted: every pre-compaction row is still ``active = 1``.
|
||||
|
||||
Before this fix the rotation branch rolled the live list back but the in-place
|
||||
branch (the DEFAULT, ``compression_in_place`` defaults to True) did not, so the
|
||||
caller adopted the uncommitted, unstamped compacted list. The next
|
||||
``_persist_session`` walk then INSERTed the whole compacted transcript ON TOP of
|
||||
the rows it was supposed to replace: the active set ended up holding the summary
|
||||
AND the turns it summarized, the next resume reloaded both, the token count went
|
||||
UP, preflight fired again, and every failed attempt appended another copy of the
|
||||
protected head + tail (#99477: ~15 real turns stored as 3,814 rows, the first
|
||||
user message repeated 893 times).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _session_db(name):
|
||||
"""Real SessionDB on a temp file, closed before the directory is removed.
|
||||
|
||||
Windows holds the SQLite file open until the connection is closed, so
|
||||
letting TemporaryDirectory clean up first raises WinError 32 and masks the
|
||||
assertion result.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = SessionDB(db_path=Path(tmp) / name)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _make_agent(session_db, session_id):
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
session_db=session_db,
|
||||
session_id=session_id,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.compression_in_place = True
|
||||
agent._session_db_created = True
|
||||
|
||||
def _fake_compress(messages, current_tokens=None, focus_topic=None, force=False):
|
||||
# Mirrors the real compress() contract: marker-swept fresh dicts.
|
||||
return [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary of prior turns"},
|
||||
{"role": "assistant", "content": "kept reply 1"},
|
||||
{"role": "user", "content": "kept question"},
|
||||
{"role": "assistant", "content": "kept reply 2"},
|
||||
]
|
||||
|
||||
agent.context_compressor.compress = _fake_compress
|
||||
agent.context_compressor._last_compress_aborted = False
|
||||
agent.context_compressor._last_summary_error = None
|
||||
agent.context_compressor.compression_count = 1
|
||||
return agent
|
||||
|
||||
|
||||
def _seed(db, sid, n=8):
|
||||
db.create_session(sid, "gateway", model="test/model")
|
||||
for i in range(n):
|
||||
db.append_message(
|
||||
session_id=sid,
|
||||
role="user" if i % 2 == 0 else "assistant",
|
||||
content=f"seed msg {i}",
|
||||
)
|
||||
|
||||
|
||||
def _counts(db, sid):
|
||||
total = db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (sid,)
|
||||
).fetchone()[0]
|
||||
active = db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ? AND active = 1", (sid,)
|
||||
).fetchone()[0]
|
||||
return total, active
|
||||
|
||||
|
||||
class TestInPlaceCommitFailureRollback:
|
||||
def test_failed_commit_does_not_reinsert_the_transcript(self):
|
||||
"""archive_and_compact raises → live list must return to the snapshot."""
|
||||
from hermes_state import SessionDB
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
from agent.conversation_compression import compress_context
|
||||
|
||||
with _session_db("rollback.db") as db:
|
||||
sid = "20260831_120000_rollback"
|
||||
_seed(db, sid, n=8)
|
||||
agent = _make_agent(db, sid)
|
||||
|
||||
messages = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
|
||||
for i in range(8)
|
||||
]
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
assert _counts(db, sid) == (16, 16) # 8 seeded + 8 flushed
|
||||
|
||||
# The realistic trigger: a concurrent queued-follow-up drain holds
|
||||
# the write path, so the atomic commit raises instead of landing.
|
||||
# Raised from the DB method itself, so the compression path's own
|
||||
# error handling runs — nothing is stubbed out around it.
|
||||
def _locked(*_a, **_kw):
|
||||
raise RuntimeError("database is locked (concurrent drain)")
|
||||
|
||||
with patch.object(SessionDB, "archive_and_compact", _locked):
|
||||
compressed, _prompt = compress_context(
|
||||
agent, messages, approx_tokens=900_000, system_message="sys"
|
||||
)
|
||||
|
||||
# ── Precondition: the commit genuinely did not land. Without this
|
||||
# the assertions below would pass vacuously on a path that
|
||||
# actually compacted.
|
||||
assert _counts(db, sid) == (16, 16)
|
||||
|
||||
# ── The fix: the returned transcript is the pre-compression
|
||||
# snapshot, not the uncommitted compacted set.
|
||||
assert [m["content"] for m in compressed] == [
|
||||
f"m{i}" for i in range(8)
|
||||
], "failed in-place commit must not hand back the compacted set"
|
||||
assert all(
|
||||
m.get(_DB_PERSISTED_MARKER) for m in compressed
|
||||
), "restored rows must keep their persistence marker"
|
||||
|
||||
# ── The symptom: the post-compression persist walk must not
|
||||
# re-INSERT anything. Run it twice — multiple exit paths persist.
|
||||
agent._flush_messages_to_session_db(compressed)
|
||||
agent._flush_messages_to_session_db(compressed)
|
||||
assert _counts(db, sid) == (16, 16), (
|
||||
"a failed in-place compaction re-inserted the transcript"
|
||||
)
|
||||
|
||||
def test_successful_commit_still_compacts_in_place(self):
|
||||
"""The rollback must not fire when the commit landed (#98450 guard)."""
|
||||
from hermes_state import SessionDB
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER
|
||||
from agent.conversation_compression import compress_context
|
||||
|
||||
with _session_db("committed.db") as db:
|
||||
sid = "20260831_120001_committed"
|
||||
_seed(db, sid, n=8)
|
||||
agent = _make_agent(db, sid)
|
||||
agent._last_flushed_db_idx = 8
|
||||
|
||||
messages = [{"role": "user", "content": f"m{i}"} for i in range(8)]
|
||||
compressed, _prompt = compress_context(
|
||||
agent, messages, approx_tokens=900_000, system_message="sys"
|
||||
)
|
||||
|
||||
total, active = _counts(db, sid)
|
||||
assert active == len(compressed) == 4, "in-place commit must compact"
|
||||
assert total == 12 # 8 soft-archived + 4 active
|
||||
assert all(m.get(_DB_PERSISTED_MARKER) for m in compressed)
|
||||
|
||||
agent._flush_messages_to_session_db(compressed)
|
||||
assert _counts(db, sid) == (12, 4)
|
||||
Reference in New Issue
Block a user