The session you deleted came back: two bugs and a new guard


You delete a session that went sideways in the desktop app, refresh the list, and there it is again. Or you delete one from the CLI and notice afterwards that the session never stores another reply: the agent keeps answering, the history stays empty, and the only trace is a single line in the log — Session DB append_message failed: FOREIGN KEY constraint failed. Both stories are real, and both came out of the same batch of fixes.

PR #125157, merged 2026-09-27, changes the behaviour itself: while a live turn owns a session, a delete is refused instead of being quietly undone.

Two real bugs first

Issue #123583 documented the first one in full: the session row disappears from the store while the agent object that owns it is still alive. Its internal _session_db_created flag still believes the row exists, so the flush calls append_message once, the foreign key rejects it, and the failure is only logged — no retry, and nothing observable afterwards, because a rollback is a rollback. That repeats on every turn. The chat looks completely normal while your durable transcript stops growing.

The second is its fix (PR #124117, salvaged from #123641): once a row is confirmed missing, the next flush recreates the session under the same id and replays the whole transcript prefix, not just the new turn. The first attempt at this only stored the new turn, because the history dicts looked “already saved” — the kind of detail that is always obvious in hindsight. The fix also insists that only a confirmed-missing row is healed; other write failures do not trigger a rebuild.

The new guard: refuse the delete, do not undo it

Surface Session held by a live turn Idle / compaction-closed
CLI sessions delete, export --delete-after-verified Refused, exit code 1, Cannot delete active session Deleted
Desktop single delete 409 Deleted
Desktop bulk delete Live roots skipped and reported in skipped_active, with one toast showing both counts Deleted
API server DELETE 409 Deleted
TUI session.delete 4023 (the existing “cannot delete an active session” code) Deleted
sessions prune Live rows spared (unchanged) Pruned

A few implementation details are worth knowing:

  • The guard is checked inside the delete’s own write transaction (the same _write_guards_reject / _guarded_ids path prune already used), so nothing can change between the check and the delete.
  • If a root session is idle but a delegate child still holds a live lease, the delete is refused too — the delete would cascade into that child.
  • Sessions that ended (for example, closed by compaction) with no turn owning them are still deletable.
  • Bulk delete does one batched guard check and only falls back to per-root checks when something is actually guarded.

What you will see

hermes sessions list                                   # find the target
hermes sessions delete 20260928_101500_ab12cd         # live -> Cannot delete active session, exit 1
hermes sessions export 20260928_101500_ab12cd --delete-after-verified   # export and verify, then delete
hermes sessions prune --older-than 30d                # live rows are always spared

In the TUI a live delete returns 4023; on the desktop or the API it is a 409. In practice the simplest path is to let the turn finish, or /stop it, and delete afterwards.

So is the “comeback” case still a problem?

It is handled, from both sides. The rebuild fix (#124117) covers “the row is gone but the conversation is not”: the session returns with its full history prefix on the next successful write, so a mid-flight delete no longer costs you the following turns. The guard (#125157) covers the other side: a delete either happens or tells you why, with no state in between where the UI shows it gone and the backend keeps writing.

If you suspect you already have residue from this class of bug, look at the store’s own statistics before doing anything drastic. Session boundaries explains when sessions are split and when they end, the state.db post covers database-level recovery, and the exact flags live in our hermes sessions reference.

Release status

All three changes merged on 2026-09-27, after the v0.21.5 release tag (v2026.9.24), so they are on main only: hermes update gets them.

The takeaway

“The session came back” and “the session is gone but still receiving turns” look like two different stories, but they are one: the delete never lined up with the turn that was using the session. The fix makes it explicit — refuse while the turn is live and tell the user why; rebuild a genuinely lost row on the next write. The only habit worth changing is small: before deleting a session, make sure its turn has actually ended.