Skip to content

fix(acp): stop _persist from deleting compression-archived history (salvage #50405) - #56342

Merged
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/50405-acp-archived-history
Jul 1, 2026
Merged

fix(acp): stop _persist from deleting compression-archived history (salvage #50405)#56342
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/50405-acp-archived-history

Conversation

@kshitijk4poor

@kshitijk4poor kshitijk4poor commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

ACP conversations long enough to compress no longer lose their pre-compaction transcript. _persist used to call the explicitly-DESTRUCTIVE db.replace_messages() on every save, which DELETEs all rows for the session — including the active=0 / compacted=1 turns that archive_and_compact() keeps on disk for session_search durability — and drops their FTS index entries.

Salvage of #50405 by @sasquatch9818, cherry-picked with authorship preserved, plus a maintainer follow-up widening the fix to the model-switch / restore save paths (with credit to @mrparker0980, whose competing PR #50306 supplied the active_only primitive).

Changes

  • acp_adapter/session.py (contributor commit): when the agent owns persistence to this same SessionDB (agent._session_db is db and _session_db_created), skip replace_messages() entirely — the agent already flushed the live transcript and archived pre-compaction turns non-destructively. This also sidesteps the compression id-rotation clobber (state.session_id staying on the ended parent).
  • hermes_state.py (follow-up): replace_messages(active_only=True) deletes/reinserts only live (active=1) rows, leaving soft-archived rows intact; has_archived_messages() is a cheap existence probe.
  • acp_adapter/session.py (follow-up, W1/W2): when the agent does not own persistence but the session already has archived rows on disk (model switch and _restore both mint a fresh agent with _session_db_created=False), replace active-only so the archived transcript survives; otherwise the destructive full replace stays (fresh create/fork has nothing to lose).

Why this approach (vs #50306)

#50306 fixes the base bug with active_only=True but always runs a delete+reinsert of the active set from the (possibly stale) state.history, and does not handle the compression id-rotation case. The owned-agent skip (this PR) handles the live path + id-rotation; the archived-rows guard handles the model-switch/restore paths #50306's unconditional replace also mishandles. #50306 will be closed as a partial duplicate with credit.

Validation

  • Targeted: tests/acp/test_session.py — 45 passed (incl. 3 regression tests: owned-agent archived-history, non-self-persist replace, and the new model-switch-on-compacted-session guard). tests/hermes_state/ — 340 passed.
  • E2E (real SessionDB + SessionManager, temp dir): owned-agent save preserves archived row + FTS; model switch on a compressed session preserves archived rows + FTS (W1); has_archived_messages probe correct; fresh non-owning session still gets the destructive replace; stale in-memory history doesn't clobber on-disk truth.
  • ruff clean vs origin/main.

sasquatch9818 and others added 2 commits July 1, 2026 17:04
ACP's SessionManager._persist() called db.replace_messages() on every
save. That delete-then-reinsert is destructive by design. The agent
backing each ACP session already persists to the same SessionDB itself:
it flushes turns incrementally via append_message and, on context
compression, preserves pre-compaction turns non-destructively through
archive_and_compact() as searchable active=0/compacted=1 rows.

So the per-save replace_messages() was a redundant double-write that
deleted exactly those archived rows (and their FTS entries). Worse,
after a compression-driven id rotation the agent's live head no longer
equals the ACP session id, so the replace overwrote the ended parent
transcript while new turns flowed to the new id — split-brain corruption
of one conversation. Any ACP conversation (VS Code / Zed / JetBrains)
long enough to compress lost history.

Now _persist skips the destructive replace when the agent owns
persistence to this DB (its _session_db is this db and its row exists),
relying on the agent's own incremental + archival flush. It still falls
back to the atomic replace when the agent is not self-persisting — test
agent factories, and fresh create/fork sessions whose copied history the
agent has not flushed yet — so the NousResearch#13675 rollback guarantee holds.

## What does this PR do?

Fixes silent history loss in ACP editor sessions. ACP _persist no longer
destroys the compression-archived transcript the agent already wrote.
Long enough conversations compress; that compression archives old turns
non-destructively; ACP then hard-deleted them on the next save. After an
id rotation it also clobbered the ended parent and split the
conversation across two ids. This change defers to the agent's own
persistence when it owns the DB and only uses the destructive replace
when nothing else is writing the transcript.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

## Changes Made

- `acp_adapter/session.py`: in `SessionManager._persist`, guard the
  `db.replace_messages()` call. Skip it when the agent owns persistence
  to this DB (`agent._session_db is db` and `agent._session_db_created`);
  otherwise keep the destructive atomic replace as the fallback.
- `tests/acp/test_session.py`: add a regression test proving archived
  (active=0/compacted=1) rows survive a save when the agent self-persists
  and stay FTS-searchable; add a test confirming the replace path still
  runs for agents that do not own DB persistence.

## How to Test

1. Run `pytest tests/acp/test_session.py -q` — 43 pass.
2. `test_save_session_preserves_agent_archived_history`: archive a turn
   via `archive_and_compact`, save, and confirm it survives and is found
   by `search_messages` (fails before this fix — replace_messages deleted
   it).
3. `test_save_session_still_replaces_when_agent_not_self_persisting`:
   confirm history still overwrites cleanly for non-self-persisting
   agents.

## Checklist

### Code

- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Follow-up widening the archived-history fix to the sibling save paths the
original PR did not cover. Model switches (_cmd_model, set_session_model) and
_restore mint a fresh AIAgent with _session_db_created=False, so the
agent-owns-persistence guard evaluates False and the blind full-history
replace_messages() fired — DELETEing the durable active=0/compacted=1 rows on
any compressed ACP session (same data-loss class the PR fixes, different
trigger).

- hermes_state.replace_messages: add active_only=True to delete/reinsert only
  the live (active=1) rows, leaving soft-archived rows untouched (idea adopted
  from the competing PR NousResearch#50306 by @mrparker0980, credited).
- hermes_state.has_archived_messages: cheap existence probe for active=0 rows.
- acp_adapter._persist: when the agent doesn't own persistence but the session
  already has archived rows on disk, replace active-only; otherwise the
  destructive full replace stays (fresh create/fork has nothing to lose).
- Regression test: model-switch save on a compacted session keeps the archived
  turn discoverable via get_messages(include_inactive=True) + search_messages.
@kshitijk4poor
kshitijk4poor force-pushed the salvage/50405-acp-archived-history branch from a0a76c1 to 1e02478 Compare July 1, 2026 11:34
@alt-glitch alt-glitch added type/bug Something isn't working comp/acp Agent Communication Protocol adapter sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P2 Medium — degraded but workaround exists labels Jul 1, 2026
@kshitijk4poor
kshitijk4poor merged commit 723ccda into NousResearch:main Jul 1, 2026
31 checks passed
@kshitijk4poor
kshitijk4poor deleted the salvage/50405-acp-archived-history branch August 5, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/acp Agent Communication Protocol adapter P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants