fix(gateway): adopt stranded bot sessions from the default store on profile resume

Pre-#93296, the desktop routed session RPCs by the focused tile, so a
profile bot's turns executed on the default backend and its canonical
session accumulated in the DEFAULT profile's state.db. Post-fix, the
profile backend correctly receives the resume — but its store has never
seen the session, so the same chat 4001s forever (unreachable instead
of misrouted). Live repro: Teknium's Developer bot, session c93770.

- hermes_state_portability: SessionDB.adopt_session_lineage_from() —
  composes the existing export_session_lineage()/import_sessions()
  primitives; donor rows are archived (never deleted) with
  end_reason=adopted_by_profile, which is deliberately NOT in
  RECOVERABLE_END_REASONS so canonical-lookup resurrection cannot undo
  an adoption. Idempotent (already-present ids skip).
- tui_gateway/methods_session: profile-scoped session.resume falls back
  to adoption from the default store right before the 4007; ids unknown
  to BOTH stores still 4007 exactly as before, and launch-profile
  resumes never consult the fallback.
- tests: 10 new (7 unit on the primitive incl. compression-lineage
  unit adoption + non-resurrectable archive; 3 handler-level through
  server.handle_request incl. the live repro shape); db-ownership
  leak test taught that the shared launch handle probe is by design.

Follow-up to #93296/#93311; part of #93091.
This commit is contained in:
kshitijk4poor
2026-08-24 05:53:19 +05:30
committed by Teknium
parent 8c8193dce5
commit 26a4f89ada
4 changed files with 389 additions and 4 deletions
+70
View File
@@ -9,8 +9,10 @@ module-level constants live in hermes_state_common.
"""
import logging
import contextlib
import json
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.skill_commands import SKILL_SCAFFOLD_SQL_LIKE
@@ -306,6 +308,74 @@ class SessionPortabilityMixin:
results.append({**session, "messages": messages})
return results
def adopt_session_lineage_from(
self,
donor_db: Any, # a full SessionDB (mixin cannot import it — cycle)
session_id: str,
*,
retire_donor: bool = True,
) -> Dict[str, Any]:
"""Adopt *session_id*'s full compression lineage from *donor_db* into
this store.
The stranded-bot-session heal (#93091 follow-up to #93296): before the
desktop routed session RPCs by their target session, a profile bot's
turns executed on whichever backend held window focus — usually the
default one — so the bot's canonical session rows and messages
accumulated in the DEFAULT profile's state.db. Once routing was fixed,
the profile backend correctly received the RPCs but had no such
session, so the same chat 4001'd for the opposite reason. This method
moves the conversation to where routing now looks for it.
Composition of existing primitives (no new import/export machinery):
``donor_db.export_session_lineage()`` -> ``self.import_sessions()``.
Import semantics apply unchanged: gateway routing, handoff, and live
activity fields are reset; already-present ids are skipped
(idempotent re-adoption after a partial run).
When ``retire_donor`` is True and at least one segment was imported
(or every segment already exists here), the donor rows are ARCHIVED —
never deleted — with ``end_reason='adopted_by_profile'`` so the
default profile's list stops advertising a conversation that now
lives elsewhere, while the bytes stay recoverable. The archive is
deliberately NOT in the recoverable set (agent_close/ws_orphan_reap):
canonical-lookup resurrection must not undo an adoption.
Returns the ``import_sessions`` result dict, plus ``adopted`` (bool)
and ``donor_retired`` (bool).
"""
payload = donor_db.export_session_lineage(session_id)
if not payload:
return {
"ok": False,
"adopted": False,
"donor_retired": False,
"error": f"session {session_id!r} not found in donor store",
}
segments = payload.get("segments") or [payload]
result = self.import_sessions([dict(seg) for seg in segments])
imported = int(result.get("imported") or 0)
skipped = int(result.get("skipped") or 0)
adopted = result.get("ok", False) and (imported + skipped) == len(segments)
donor_retired = False
if adopted and retire_donor:
for seg in segments:
seg_id = seg.get("id")
if not seg_id:
continue
with contextlib.suppress(Exception):
# First end_reason wins in end_session(); reopen first so
# the adoption boundary is stamped even on ended segments
# (e.g. 'compression' parents).
donor_db.reopen_session(seg_id)
donor_db.end_session(seg_id, "adopted_by_profile")
donor_db.set_session_archived(seg_id, True)
donor_retired = True
return {**result, "adopted": adopted, "donor_retired": donor_retired}
@staticmethod
def _import_text_or_none(value: Any, field: str) -> Optional[str]:
if value is None: