Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,10 +461,47 @@ def _persist(self, state: SessionState) -> None:
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)

# Replace stored messages with current history atomically so a
# mid-rewrite failure rolls back and the previously persisted
# conversation is preserved (salvaged from #13675).
db.replace_messages(state.session_id, state.history)
# When the agent owns persistence to this same SessionDB it has
# already flushed the live transcript incrementally during
# run_conversation (append_message), and it preserves pre-compaction
# turns non-destructively via archive_and_compact() — keeping them on
# disk as searchable active=0/compacted=1 rows. Calling
# replace_messages() here would then be a redundant double-write that
# DELETEs exactly those archived rows (and, after a compression-driven
# id rotation where agent.session_id no longer equals
# state.session_id, clobbers the ended parent transcript) — silent
# data loss for any ACP conversation long enough to compress.
#
# Only fall back to the destructive atomic replace when the agent is
# NOT persisting itself to this DB (e.g. a test agent factory, or a
# fresh create/fork whose copied history the agent has not flushed
# yet). That path still rolls back on a mid-rewrite failure so the
# previously persisted conversation survives (salvaged from #13675).
agent = state.agent
agent_db = getattr(agent, "_session_db", None)
agent_owns_persistence = (
agent_db is not None
and agent_db is db
and bool(getattr(agent, "_session_db_created", False))
)
if not agent_owns_persistence:
# Even when the current agent doesn't "own" persistence, the
# session on disk may already carry compaction-archived rows —
# e.g. after a model switch or a /restore, both of which mint a
# fresh agent with _session_db_created=False (so the check above
# is False) yet leave the durable archived transcript in place.
# A full-history replace would DELETE those archived rows just
# like the owned-agent case. Guard against it: when archived
# rows exist, replace ONLY the live (active=1) set and leave the
# archived turns untouched; otherwise the destructive replace is
# safe (fresh create/fork with no archived history to lose).
try:
has_archived = db.has_archived_messages(state.session_id)
except Exception:
has_archived = False
db.replace_messages(
state.session_id, state.history, active_only=has_archived
)
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)

Expand Down
44 changes: 38 additions & 6 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3275,21 +3275,39 @@ def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, A
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
return inserted, tool_calls_total

def replace_messages(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Atomically replace every message for a session.
def replace_messages(
self,
session_id: str,
messages: List[Dict[str, Any]],
active_only: bool = False,
) -> None:
"""Atomically replace the stored messages for a session.

Used by transcript-rewrite flows such as /retry, /undo, and /compress.
The delete + reinsert sequence must commit as one transaction so a
mid-rewrite failure does not leave SQLite with a partial transcript.

DESTRUCTIVE: the prior rows are DELETEd (and drop out of the FTS index).
For compaction that must preserve the pre-compaction transcript under
the same id, use :meth:`archive_and_compact` instead.
DESTRUCTIVE by default: every row for the session is DELETEd (and drops
out of the FTS index). For compaction that must preserve the
pre-compaction transcript under the same id, use
:meth:`archive_and_compact` instead.

Pass ``active_only=True`` to replace ONLY the live (``active = 1``) rows,
leaving soft-archived rows (``active = 0`` — e.g. the ``compacted = 1``
turns that :meth:`archive_and_compact` keeps on disk for #38763
durability, or rewind/undo rows) untouched. Callers that share a session
id with an agent already running in-place compaction must use this so a
full-history rewrite doesn't wipe the rows the agent deliberately
archived. ``message_count``/``tool_call_count`` then track the live set,
matching :meth:`archive_and_compact`.
"""

active_clause = " AND active = 1" if active_only else ""

def _do(conn):
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (session_id,)
f"DELETE FROM messages WHERE session_id = ?{active_clause}",
(session_id,),
)
conn.execute(
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
Expand All @@ -3305,6 +3323,20 @@ def _do(conn):

self._execute_write(_do)

def has_archived_messages(self, session_id: str) -> bool:
"""Return True if the session has any soft-archived (``active = 0``) rows.

Used by callers (e.g. the ACP adapter's ``_persist``) that must decide
whether a full-history :meth:`replace_messages` would destroy durable
compaction-archived turns. Cheap existence probe — does not load rows.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1",
(session_id,),
)
return cursor.fetchone() is not None

def archive_and_compact(
self, session_id: str, compacted_messages: List[Dict[str, Any]]
) -> int:
Expand Down
118 changes: 118 additions & 0 deletions tests/acp/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,124 @@ def test_save_session_preserves_existing_messages_on_encode_failure(self, manage
assert messages[0]["content"] == "original"
assert isinstance(messages[0].get("timestamp"), (int, float))

def test_save_session_preserves_agent_archived_history(self, tmp_path):
"""Regression: ACP _persist must not destroy compression-archived rows.

When the agent owns persistence to the same SessionDB, it has already
flushed the transcript itself and used archive_and_compact() to keep
pre-compaction turns as searchable active=0/compacted=1 rows. A blind
replace_messages() here used to DELETE those archived rows (and the FTS
index entries with them) on every save — silent data loss for any ACP
conversation long enough to compress.
"""
db = SessionDB(tmp_path / "state.db")

def factory():
# Mimic a live ACP agent: it persists to *this* db and has already
# created its session row / flushed at least one turn.
return SimpleNamespace(
model="test-model",
_session_db=db,
_session_db_created=True,
)

manager = SessionManager(agent_factory=factory, db=db)
state = manager.create_session(cwd="/work")

# Simulate the agent's own persistence: it flushed the live transcript,
# then compression archived the pre-compaction turns and inserted a
# compacted summary as the new active set.
db.append_message(
session_id=state.session_id, role="user", content="archived needle"
)
db.archive_and_compact(
state.session_id, [{"role": "user", "content": "compacted summary"}]
)

# ACP's in-memory history only tracks the post-compaction (active) set.
state.history = [{"role": "user", "content": "compacted summary"}]
manager.save_session(state.session_id)

# The archived pre-compaction turn must survive and stay discoverable.
contents = [
m["content"]
for m in db.get_messages(state.session_id, include_inactive=True)
]
assert "archived needle" in contents
assert "compacted summary" in contents
hits = {r["session_id"] for r in db.search_messages("needle")}
assert state.session_id in hits

def test_save_session_still_replaces_when_agent_not_self_persisting(self, manager):
"""Agents that don't own DB persistence keep ACP as the source of truth.

The default fixture's MagicMock agent has a ``_session_db`` that is *not*
the manager's db, so the destructive replace path stays active and ACP
history overwrites cleanly (no orphaned rows from a prior save).
"""
state = manager.create_session()
db = manager._get_db()

state.history = [{"role": "user", "content": "v1"}]
manager.save_session(state.session_id)
assert [
m["content"] for m in db.get_messages_as_conversation(state.session_id)
] == ["v1"]

state.history = [{"role": "user", "content": "v2 replaced"}]
manager.save_session(state.session_id)
assert [
m["content"] for m in db.get_messages_as_conversation(state.session_id)
] == ["v2 replaced"]

def test_save_session_preserves_archived_rows_on_model_switch(self, tmp_path):
"""Regression (#50405 W1/W2): a save by a fresh, non-self-persisting
agent must not destroy compaction-archived rows.

Model switches and /restore mint a brand-new agent with
``_session_db_created=False`` (so it does NOT "own" persistence) and
then immediately call save_session. If the session had already
compacted, a blind full-history replace would DELETE the archived
active=0/compacted=1 rows — the same data loss the owned-agent guard
prevents. When archived rows exist, _persist must replace only the live
set (active_only) and leave the archived transcript intact.
"""
from types import SimpleNamespace

db = SessionDB(tmp_path / "state.db")
# Use a mock agent factory so create_session doesn't spin up a real
# AIAgent (which needs credentials and leaks provider-probe state across
# xdist workers). The factory's agent does NOT own persistence to db.
manager = SessionManager(
agent_factory=lambda: SimpleNamespace(model="m"), db=db
)
state = manager.create_session(cwd="/work")

# Session flushed a live turn, then compaction archived it.
db.append_message(
session_id=state.session_id, role="user", content="archived needle"
)
db.archive_and_compact(
state.session_id, [{"role": "user", "content": "compacted summary"}]
)

# Model switch: a fresh agent bound to THIS db but not yet self-created.
state.agent = SimpleNamespace(
model="new-model", _session_db=db, _session_db_created=False
)
state.history = [{"role": "user", "content": "compacted summary"}]
manager.save_session(state.session_id)

# Archived pre-compaction turn survives and stays discoverable.
contents = [
m["content"]
for m in db.get_messages(state.session_id, include_inactive=True)
]
assert "archived needle" in contents
assert "compacted summary" in contents
hits = {r["session_id"] for r in db.search_messages("needle")}
assert state.session_id in hits

def test_cleanup_clears_all(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
Expand Down
Loading