Skip to content

fix(agent): heal a session row deleted under a live agent (#123583) - #123641

Closed
strzhao wants to merge 1 commit into
NousResearch:mainfrom
strzhao:forge/forge-123583-session-row-flag
Closed

strzhao wants to merge 1 commit into
NousResearch:mainfrom
strzhao:forge/forge-123583-session-row-flag

Conversation

@strzhao

@strzhao strzhao commented Sep 26, 2026

Copy link
Copy Markdown

Fixes #123583

Root cause

_ensure_db_session treats the cached _session_db_created flag as proof the row exists (run_agent.py:350), sets it once at first creation (run_agent.py:378), and never re-checks it. The flush path only retries row creation while the flag is False (agent/session_persistence.py:407-408).

Every store-side path that removes a session row is real and none is visible to the cached agent:

  • hermes sessions delete → db.delete_session(...) (hermes_cli/sessions_cmd.py:561, :587)
  • Desktop/web delete (hermes_cli/web_routers/sessions.py:715)
  • tui-gateway session close/delete (tui_gateway/methods_session.py:1071)
  • bulk prune (hermes_state_gateway.py:401)
  • profile-repair move / in-place store rebuild (detaches or replaces rows under the same FK rule)

After any of these, every later turn's flush fails the FK and is dropped with one WARNING per turn (Session DB append_message failed: FOREIGN KEY constraint failed). The agent keeps answering normally; the durable transcript silently stops growing; and because the failed transaction leaves no rows behind, there is nothing to find in the store after the fact.

Fix

Follows the maintainer triage direction on the issue (teknium1, 2026-09-26):

Classification (hermes_state_errors.py): new session_row_missing cause in PERSISTENCE_ERROR_CAUSES — matched by sqlite_errorcode == SQLITE_CONSTRAINT_FOREIGNKEY (787) when the code survives, else the RPC-wrapped "foreign key constraint" phrase — so the turn-end explanation names the real failure instead of "unknown".

Heal (agent/session_persistence.py::_db_flush_failed): on that cause — drop the stale _session_db_created flag, reset the flush markers (_flushed_db_message_ids = set(), _last_flushed_db_idx = 0), strip the per-message persisted markers, call _ensure_db_session(), and replay once within the flush's existing _adoption_budget (mirrors _db_flush_adopt_compression_tip). Because the row deletion erased the session's message rows with it, the replay is FULL — the whole in-memory transcript lands on the recreated row, not just the current tail. If row creation also fails, return False without appending into a guaranteed rollback (scenario B) — fail-open, batch stays unmarked for the next flush. No new fail-closed path.

Cost: ~+32 lines in one file + a 2-line cause registration, reusing the flush's existing single-retry machinery; no store-layer changes (the agent already holds every identity field via _ensure_db_session, which is why store-side upserts mint source='unknown' phantoms and the agent-side layer is the class fix).

Tests

tests/agent/test_session_row_under_live_agent_persist.py — real AIAgent + real SessionDB at a temp path, real store API (delete_session, the one hermes sessions delete/Desktop delete call):

  1. RED before the fix: turn 1 lands (2 rows), delete_session removes the row, turn 2 flush returns False with the FK warning and 0 rows written — the exact silent-drop signature from the issue (maintainer's live probe: sqlite_errorcode=787).
  2. GREEN: the same sequence heals in-flush — row recreated with full identity (profile/routing carried by _ensure_db_session), cause set to session_row_missing, and the FULL 4-message transcript lands (not just the deleted-then-retried tail).
  3. Stability: a second deletion round keeps healing (no one-shot-only recovery).
  4. Scenario B: when row creation fails too, the flush returns False and the flag stays False — no append into a guaranteed rollback.

Adjacency: 73 existing persistence/flush/scheduler tests pass unchanged (compression persistence, in-place persist markers, rotation boundary, oneshot resume, CLI shutdown flush, session activity, incremental turns).

Relationship to other open work

The two open PRs matching _session_db_created (#122704, #72694) both change acp_adapter/session.py (ACP persist-ownership and /compress semantics) — different files, no overlap with this fix's flush-classification leg; this PR deliberately does not touch ACP semantics.

…ch#123583)

`_ensure_db_session` trusts the cached `_session_db_created` flag as proof
the row exists, and the flush path only retries row creation while that
flag is False. Any store-side removal of the row under a live agent —
`hermes sessions delete`, the Desktop/web delete, bulk prune, a
profile-repair move, an in-place store rebuild — leaves the flag stale, so
every later turn's append fails the FK and is dropped with one WARNING per
turn. The agent keeps answering; the durable transcript silently stops
growing, and because the failed transaction leaves no rows behind there is
no post-hoc trace in the store.

Classification (`hermes_state_errors.py`): add the `session_row_missing`
cause — matched by `SQLITE_CONSTRAINT_FOREIGNKEY` (787) when the code
survives, else the RPC-wrapped phrase — so the turn-end explanation names
the real failure instead of "unknown".

Heal (`agent/session_persistence.py::_db_flush_failed`): on that cause,
drop the stale flag, reset the flush markers, call `_ensure_db_session()`,
and replay once within the flush's existing `_adoption_budget`. Because the
deletion erased the session's message rows too, the replay clears the
per-message persisted markers so the FULL in-memory transcript lands on the
recreated row, not just the current tail (mirrors
`_db_flush_adopt_compression_tip`). If row creation also fails, return
False without appending into a guaranteed rollback — fail-open, batch stays
unmarked for the next flush. No new fail-closed path.
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 26, 2026

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes conclusion (GitHub does not allow a formal change request from this account)

Motivation

This PR addresses a real persistence failure. A live agent can keep _session_db_created=True after another surface deletes its session row. The next transcript append then fails the session foreign key. The current code logs the failure and drops every later durable turn. Recreating the row in the agent layer is the right boundary because the agent owns the complete session identity.

Approach

The patch adds a typed session_row_missing cause. _db_flush_failed() clears the stale row flag and flush cursors, calls _ensure_db_session(), and uses the existing one-retry budget. It also tries to clear all per-message durable markers so the deleted transcript can be rebuilt. This reuses the existing row creation and retry machinery.

Concrete changes

agent/session_persistence.py adds foreign-key recovery to the flush failure owner. hermes_state_errors.py adds the new cause. tests/agent/test_session_row_under_live_agent_persist.py covers row recreation, repeated deletion, and failed recreation with a real SessionDB.

Risk to main

There is one P1 blocker. The retry keeps the original conversation_history, and _db_flush_collect() treats matching object identities as already durable. The production turn builder uses messages = list(conversation_history), which preserves those identities. After deletion removes the row and all messages, the recovery still skips the historical prefix and writes only the new tail. A real-store regression reproduced this exact result: the call returned True, but only turn two was stored instead of turn one, answer one, and turn two.

There is also one P2 copy defect. The new typed cause has no entry in hermes_state_user_copy._STORAGE_FAILURES or agent.turn_explainers._PERSISTENCE_CAUSE_EXPLANATIONS. If recreation or the bounded retry fails, users still receive generic database-open, disk, or lock advice.

Validation at ad97047da0f9801a4fdca4478bfd68085f02c3a8: 52 committed and adjacent persistence tests passed; 7 storage-copy tests passed; Ruff passed; Python compilation passed; the Windows footgun scan passed; and git diff --check passed. No native Windows validation was performed. The focused reviewer regression failed as described above. No hosted checks are reported. Current upstream main equals the PR base.

Overall assessment

Request changes. Keep the agent-owned recovery design. Make the forced retry ignore the old-history identity shortcut, and add a regression using messages = list(history) plus a new tail. Add cause-specific user copy and tests for both error-copy consumers. The issue still has a maintainer decision open about whether explicit deletion should recreate the same session id or rotate it.

English verdict: REQUEST_CHANGES at exact head ad97047da0f9801a4fdca4478bfd68085f02c3a8. The recovery silently restores only the new tail in the real turn shape, so it does not yet prevent the reported transcript loss.

return True
except Exception as e:
if _db_flush_failed(self, e, batch_rows, _adoption_budget):
if _db_flush_failed(self, e, batch_rows, _adoption_budget, messages):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — The forced replay still skips the deleted historical prefix. This recursive call preserves conversation_history. _db_flush_collect() builds history_ids from it and skips any matching message object as already durable. The production turn builder uses messages = list(conversation_history), so those dict identities match even though deleting the session row also deleted every message row. A real SessionDB reproduction returned True but stored only the new turn two row, not the prior turn one / answer one rows. Pass a recovery signal that disables the history-identity shortcut (for example, retry with conversation_history=None) and add a regression with messages = list(history) plus a new tail.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #124117. The heal retry replays the history prefix via an explicit replay flag that stays pending (keyed to the session id) until a write succeeds. A real-SessionDB probe now stores all four rows. The kept test pins it, including a failed recreate and a failed retry.

Comment thread hermes_state_errors.py
PERSISTENCE_ERROR_CAUSES = (
"locked", "compression", "compression_closed", "turn_lease", "corrupt", "fts_index",
"replaced", "deleted_wal", "disk", "unknown",
"replaced", "deleted_wal", "disk", "session_row_missing", "unknown",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Add user-facing handling for the new cause. session_row_missing is now a public persistence cause, but neither hermes_state_user_copy._STORAGE_FAILURES nor agent.turn_explainers._PERSISTENCE_CAUSE_EXPLANATIONS handles it. If row creation or the bounded retry fails, describe_storage_failure() says the database could not be opened and recommends doctor --fix, while the turn explainer suggests disk or lock trouble. Add cause-specific entries and tests for both consumers so the new classification produces the promised diagnosis.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #124117. session_row_missing now has entries in both hermes_state_user_copy and agent.turn_explainers.

@jonpol01 jonpol01 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent-side heal for #123583: on an FK reject in agent/session_persistence.py::_db_flush_failed, drop the stale _session_db_created, reset the flush cursors, re-run run_agent.py::AIAgent._ensure_db_session, and replay once within the existing _adoption_budget. That is the layer the triage on #123583 picked, and it mirrors _db_flush_adopt_compression_tip. Verified with scripts/run_tests.sh -j 2 tests/agent/test_session_row_under_live_agent_persist.py: 3 failed on main d0288be5b3 (fix files reverted, PR test kept) and 3 passed on head ad97047da0. Adjacent files on head (test_compression_closed_adoption, test_flush_diverts_on_corrupt_state_db, test_in_place_persist_marker, test_turn_completion_explainer, test_compression_persistence, test_rotation_flush_persisted_boundary, tests/hermes_state/test_deleted_wal_generation_guard, test_state_db_corrupt_quarantine, test_storage_health_latch): 90 passed, 0 failed, 13 skipped. ehz0ah's P1 (the history-identity shortcut skips the deleted prefix) and P2 (no user copy for the new cause) still stand, and nothing below repeats them. Preflight: #113999 edits the same spot in _db_flush_failed (right after classify_persistence_error(e)) and adds a row after (("locked", "busy"), "locked") in _PERSISTENCE_CAUSE_BY_PHRASE, so both files will conflict textually. #120862 touches _db_flush_write in the same file, in a separate hunk. #123725 is the entry-side half (a delete is refused while a turn lease is live) and complements this PR.

  1. [nonblocking] The heal never succeeds for a live delegate child whose parent row was deleted — agent/session_persistence.py:325 (_db_flush_failed, heal branch). Risk/repro, with a real SessionDB, a real AIAgent(session_id="C", parent_session_id="P") and the _delegate_from marker that tools/delegate_tool.py:277 sets: flush one turn, then db.delete_session("P"), which cascades delegate children through _delete_delegate_children. Head output: {"t2": false, "t3": false, "C_row": false, "C_rows": 0, "cause": "session_row_missing"}, plus Session DB creation failed (will retry next turn): FOREIGN KEY constraint failed and ...could not be recreated; will retry next flush on every flush. db.delete_sessions(["P", "C"]) (the bulk endpoint) and two single deletes give the same result. The recreate passes parent_session_id=self._parent_session_id (run_agent.py:375), which points at the deleted P, so the row's own sessions.parent_session_id FK rejects it on every retry. The comment at :327 calls this transient, but it is permanent for that agent's lifetime. The store already has a rule for a surviving child of a deleted row: delete_session nulls its parent_session_id (hermes_state_sessions.py:1577-1578), and import_moved_session drops a missing parent the same way (hermes_state_profile_repair.py:217-219). Suggested fix: when the heal's create fails and the parent row is gone, create this row once without the parent. Do it for the create call only; don't clear agent._parent_session_id itself, because agent/turn_facade.py:66-70 and agent/turn_context.py:766 pass it to the relay and to plugin hooks as the subagent's parent.
  2. [nonblocking] No docs for the user-visible change — agent/session_persistence.py:316. After this PR, a session deleted with hermes sessions delete, the Desktop delete or --delete-after-verified comes back under the same id on the owning agent's next flush if an agent still has it open. website/docs/user-guide/sessions.md ("Delete a Session", and the --delete-after-verified paragraph at :459, which says deletion is verified inside the delete transaction) describes deletion as final. If the maintainers answer the open same-id-or-rotate question on #123583 with same-id, add one sentence there and to the zh-Hans mirror website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md, and put a "Docs:" line in the PR body.

Minor

  • Test count (tests/agent/test_session_row_under_live_agent_persist.py:75): there are three tests. The root AGENTS.md sets 1–2 invariant tests per fix. test_flush_recovers_when_row_deleted_between_turns_twice runs the same path as the first test with a one-message list. Please confirm it can be folded into the first test as a second delete round.
  • Heal predicate vs classifier (agent/session_persistence.py:309-310): the heal matches errorcode 787 or "foreign key constraint", but classify_persistence_error matches only "foreign key constraint failed" and never reads the errorcode (hermes_state_errors.py:290 does the errorcode check only for the lock codes). Keying the branch on agent._last_persistence_error_cause == "session_row_missing" (set two lines above) and moving the 787 check into the classifier would give the heal and the turn-end explanation one definition. The classifier docstring (hermes_state_errors.py:269-276) lists every bucket and doesn't list the new one yet.
  • PR body lists "a profile-repair move" as a trigger. hermes sessions repair-profiles --apply refuses while a gateway owning any touched store is live (hermes_cli/sessions_repair_profiles.py:25). For a live CLI or TUI agent on the source store, this heal re-creates the moved row in the store the repair just emptied, which leaves the duplicate that the hermes_state_profile_repair.py module docstring (:17-20) leaves for the next run to settle. Please confirm that is intended.

Related — #123725 (entry-side delete refusal on a live turn lease; with it, finding 1's parent delete is refused only while P holds a live lease and only on the surfaces it guards), #113999 (same function and cause table, see preflight), #44266 (open; swallows the FK in the single-row append_message, a different direction from this PR's).

agent._flushed_db_message_ids = set()
agent._last_flushed_db_idx = 0
agent._session_db_created = False
agent._ensure_db_session()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nonblocking] The heal never succeeds for a live delegate child whose parent row was deleted.

Repro on this head: a real SessionDB and AIAgent(session_id="C", parent_session_id="P") with the _delegate_from marker (tools/delegate_tool.py:277). Flush one turn, then db.delete_session("P"), which cascades delegate children. Result: {"t2": false, "t3": false, "C_row": false, "C_rows": 0}, with Session DB creation failed (will retry next turn): FOREIGN KEY constraint failed on every flush. _ensure_db_session recreates with parent_session_id=self._parent_session_id (run_agent.py:375), which still points at the deleted P, so the row's own sessions.parent_session_id FK rejects every retry. This isn't the transient case the comment below describes.

Suggested fix: when this create fails and the parent row is gone, create the row once without the parent. That is the rule delete_session applies to surviving children (hermes_state_sessions.py:1577-1578) and import_moved_session applies to a missing parent (hermes_state_profile_repair.py:217-219). Scope it to the create call; agent._parent_session_id itself feeds the relay and plugin hooks (agent/turn_facade.py:66-70, agent/turn_context.py:766).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #124117. When the recreate fails and the parent row is gone, it creates the row once without the parent for that call only; _parent_session_id is kept for the relay and hooks.

# cached agent, so the cached `_session_db_created` flag is stale and every later append hits
# the FK). The deletion already erased the session's message rows with it, so the durable
# transcript is empty: drop the stale flag, reset the flush markers, and replay the FULL
# in-memory transcript onto the recreated row — not just the current tail (#123583).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nonblocking] No docs for the user-visible change. A session deleted while an agent still has it open (hermes sessions delete, the Desktop delete, --delete-after-verified) now reappears under the same id on the owning agent's next flush. website/docs/user-guide/sessions.md ("Delete a Session", and :459) describes deletion as final. If the maintainers pick same-id on #123583, add one sentence there and to the zh-Hans mirror (website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md), and put a "Docs:" line in the PR body.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in #124117: one sentence in website/docs/user-guide/sessions.md and the zh-Hans mirror.

@kshitijk4poor

Copy link
Copy Markdown

Thanks @strzhao. Your heal lands through #124117 with authorship kept. Follow-ups there, in response to the review threads:

  • the retry replays the full history prefix (ehz0ah's P1);
  • the replay stays pending until a write succeeds;
  • only a confirmed-missing row is healed;
  • a muted-turn heal hides only that turn's rows;
  • a delegate child with a deleted parent is recreated (jonpol01);
  • session_row_missing gets its own user copy (ehz0ah's P2);
  • en and zh-Hans docs note the same-id recreate (jonpol01).

Closing in favour of #124117.

@kshitijk4poor

Copy link
Copy Markdown

Merged: #124117 landed on main as 819c624.

@strzhao

strzhao commented Sep 26, 2026

Copy link
Copy Markdown
Author

Thanks @kshitijk4poor — clean landing, and the follow-ups folded into #124117 (full-prefix replay, replay-pending-until-write, confirmed-missing-only heal, plus the muted-turn and delegate-child edges) each close a real gap. This deployment tracks main, so once 819c624 soaks here I'm happy to report back if that's useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

5 participants