fix: prevent handoff leg data loss + surface state.db corruption to users

Two data-loss bugs reported by users:

1. /handoff CLI→gateway race (#88234): After /handoff completed, CLI
   cleanup called finalize_session on the session the gateway just
   reopened. This set end_reason on a row the gateway was actively
   writing to, causing the handoff leg to vanish from session history
   and breaking session_search recall. Fix: add _handed_off_session_ids
   module-level set (mirrors _single_query_finalize_attempted_session_ids
   pattern). _handle_handoff_command registers the session_id on
   completion; _should_emit_cleanup_session_finalize and
   _emit_interrupted_session_end check it before firing.

2. state.db corruption silent failure (#88235): When SessionDB init
   failed at gateway startup, the error stayed in logs — messages
   flowed but nothing was persisted, with no user-visible indication.
   Fix: store _session_db_init_error on GatewayRunner, broadcast a
   recovery-guidance message to all home channels via
   _send_session_db_warning_notifications() after the gateway connects.
   Also improved the 'corrupt' persistence cause wording in
   _format_turn_completion_explanation to include the full recovery
   path (hermes doctor --fix, sqlite3 .recover, backups).

Tests: 6 new tests for handoff cleanup race, 3 for corruption wording.
All existing CLI/turn-completion tests pass.
This commit is contained in:
kshitij
2026-08-17 13:19:58 +05:30
parent 00c12dac61
commit 59f302fef9
6 changed files with 322 additions and 3 deletions
+20 -1
View File
@@ -990,6 +990,13 @@ _cli_wake_owner = None
# the session boundary while the agent is still attached. If a signal lands in
# that narrow window, atexit cleanup must not emit that session finalization again.
_single_query_finalize_attempted_session_ids: set[str | None] = set()
# Session IDs that were handed off to the gateway via /handoff. The CLI
# process exits after a successful handoff, but the gateway now owns the
# session lifecycle — _run_cleanup must NOT call finalize_session on these,
# because doing so sets end_reason on a row the gateway just reopened and is
# actively writing to (#88234). The race made the handoff leg vanish from
# session history and broke session_search recall for the handed-off session.
_handed_off_session_ids: set[str | None] = set()
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
@@ -1279,11 +1286,19 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
# A session that was handed off to the gateway is now owned by the
# gateway process. The CLI must not finalize it on exit — that sets
# end_reason on a row the gateway reopened and is actively writing
# to, causing the handoff leg to vanish from session history (#88234).
if session_id is not None and session_id in _handed_off_session_ids:
return False
if not _single_query_finalize_attempted_session_ids:
return True
if session_id is None:
return False
return session_id not in _single_query_finalize_attempted_session_ids
if session_id in _single_query_finalize_attempted_session_ids:
return False
return True
def _notify_session_finalize(
@@ -1315,6 +1330,10 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
pass
session_id = getattr(agent, "session_id", None) or getattr(cli, "session_id", None)
# Don't emit session-end for a session that was handed off to the
# gateway — the gateway owns the lifecycle now (#88234).
if session_id in _handed_off_session_ids:
return
if session_id:
try:
cli.session_id = session_id