From bef31fb06bbfae3e2ded660e0aceafe35399eca2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:49:55 +0530 Subject: [PATCH] fix(bot-mode): resurrect canonical Bot Chat archived by recoverable reasons on reopen (#92687) --- hermes_state.py | 31 +++++ .../test_profiles_list_canonical_session.py | 118 +++++++++++++++++- tui_gateway/methods_profiles.py | 12 +- tui_gateway/methods_session.py | 10 ++ 4 files changed, 168 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index a2b1f94fe0..0a019a0dc4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -8829,6 +8829,37 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) rowcount = self._execute_write(_do) return rowcount > 0 + # Accidental end reasons that recovery treats as resumable (see + # find_latest_gateway_session_for_peer and promote_to_session_reset, + # whose SQL literals must stay in sync with this tuple, and + # docs/session-lifecycle.md "recoverable accidental reasons"). + RECOVERABLE_END_REASONS = ("agent_close", "ws_orphan_reap") + + def unarchive_recoverable_session(self, session_id: str) -> bool: + """Un-archive a session that was archived by a recoverable accident. + + Registry-style lookups (Bot Mode's canonical "Bot Chat") use this to + resurrect a row the ws-orphan reaper (``ws_orphan_reap``) or older + agent cleanup (``agent_close``) archived: those ends are accidents, + not user intent, so the identity-scoped canonical chat must survive + them (#92687). Sessions archived with no end_reason or an explicit + boundary reason (user archived deliberately, ``session_reset``, …) + are left untouched — returns ``False`` for those, ``True`` only when + the row was archived for a recoverable reason and is now un-archived + (whole compression lineage, via :meth:`set_session_archived`). + """ + if not session_id: + return False + try: + row = self.get_session(session_id) + except Exception: + return False + if not row or not row.get("archived"): + return False + if (row.get("end_reason") or "") not in self.RECOVERABLE_END_REASONS: + return False + return self.set_session_archived(session_id, False) + def set_session_pinned(self, session_id: str, pinned: bool) -> bool: """Pin or unpin a session (and its whole compression lineage). diff --git a/tests/tui_gateway/test_profiles_list_canonical_session.py b/tests/tui_gateway/test_profiles_list_canonical_session.py index a2c2356ccc..abc53a6104 100644 --- a/tests/tui_gateway/test_profiles_list_canonical_session.py +++ b/tests/tui_gateway/test_profiles_list_canonical_session.py @@ -11,7 +11,9 @@ pin-verification contract is REMOVED (pointers dangle; names cannot). Contract under test: - Every profile row (with include_sessions on) carries ``canonical_session``: a summary dict when a "Bot Chat" row exists, ``None`` when it does not - (no row, denied internal source, archived). + (no row, denied internal source, deliberately archived; a row archived by + a recoverable accident — ws_orphan_reap/agent_close — is resurrected, + #92687). - Summary keys: ``id`` (the durable registry row), ``resolved_id`` (live compression tip; equal to ``id`` when uncompressed), ``root_title``, ``title``, ``preview`` (newest user/assistant text at the tip), @@ -45,7 +47,7 @@ def _db(profile_dir): def _add_session(db, sid, *, source="cli", title="", ts, text, hidden=False, - parent=None, end_reason=None): + parent=None, end_reason=None, archived=False): """Create one session with a single user message at an exact timestamp.""" db.create_session(sid, source, parent_session_id=parent) db.append_message(sid, "user", text, timestamp=ts) @@ -58,6 +60,9 @@ def _add_session(db, sid, *, source="cli", title="", ts, text, hidden=False, "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?", (ts + 1, end_reason, sid), ) + if archived: + db._conn.execute( + "UPDATE sessions SET archived = 1 WHERE id = ?", (sid,)) if hidden: db.set_session_hidden(sid, True) @@ -156,6 +161,115 @@ def test_canonical_session_resolves_compression_tip(home): assert "post-compression content" in canonical["preview"] +# --------------------------------------------------------------------------- +# Recoverable-archive resurrection (#92687) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("reason", ["ws_orphan_reap", "agent_close"]) +def test_canonical_session_archived_by_recoverable_reason_is_resurrected(home, reason): + # The ws-orphan reaper (and older agent cleanup) archives by ACCIDENT — + # the canonical forever-chat must come back with the SAME id, un-archived. + db = _db(home) + _add_session(db, "reaped1", title="Bot Chat", ts=1000, + text="surviving forever chat", hidden=True, + end_reason=reason, archived=True) + db.close() + + row = _row(_profiles({}), "default") + + canonical = row["canonical_session"] + assert canonical is not None + assert canonical["id"] == "reaped1" + assert "surviving forever chat" in canonical["preview"] + + # The archive flag was durably cleared, not just masked for this call. + db = _db(home) + try: + assert not db.get_session("reaped1")["archived"] + finally: + db.close() + + +def test_canonical_session_deliberately_archived_stays_archived(home): + # No recoverable end_reason ⇒ the user retired it on purpose: absent. + db = _db(home) + _add_session(db, "retired1", title="Bot Chat", ts=1000, + text="deliberately retired", hidden=True, archived=True) + db.close() + + row = _row(_profiles({}), "default") + assert row["canonical_session"] is None + + db = _db(home) + try: + assert db.get_session("retired1")["archived"] + finally: + db.close() + + +def test_canonical_session_archived_with_explicit_boundary_stays_archived(home): + # Explicit boundary reasons (session_reset) are not recoverable either. + db = _db(home) + _add_session(db, "reset1", title="Bot Chat", ts=1000, + text="reset boundary", hidden=True, + end_reason="session_reset", archived=True) + db.close() + + row = _row(_profiles({}), "default") + assert row["canonical_session"] is None + + +def test_non_canonical_archived_session_untouched_by_resurrection(home): + # Ordinary sessions keep today's behavior exactly: an archived + # non-canonical row stays archived even with a recoverable reason. + db = _db(home) + _add_session(db, "plain1", title="Scratch", ts=1000, text="scratch", + end_reason="ws_orphan_reap", archived=True) + _add_session(db, "chat1", title="Bot Chat", ts=2000, text="forever") + db.close() + + row = _row(_profiles({}), "default") + assert row["canonical_session"]["id"] == "chat1" + + db = _db(home) + try: + assert db.get_session("plain1")["archived"] + finally: + db.close() + + +def test_session_list_title_lookup_resurrects_recoverable_bot_chat(home, monkeypatch): + # The exact-title registry lookup (session.list title=) — the desktop's + # click-open path — must also resurrect instead of returning no rows. + db = _db(home) + _add_session(db, "reaped2", title="Bot Chat", ts=1000, + text="click target", hidden=True, + end_reason="ws_orphan_reap", archived=True) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + envelope = srv._methods["session.list"](1, {"title": "Bot Chat"}) + sessions = envelope["result"]["sessions"] + assert len(sessions) == 1 + assert sessions[0]["id"] == "reaped2" + + # The archive flag was durably cleared. + assert not db.get_session("reaped2")["archived"] + db.close() + + +def test_session_list_title_lookup_keeps_deliberate_archive_hidden(home, monkeypatch): + db = _db(home) + _add_session(db, "retired2", title="Bot Chat", ts=1000, + text="retired", hidden=True, archived=True) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + envelope = srv._methods["session.list"](1, {"title": "Bot Chat"}) + assert envelope["result"]["sessions"] == [] + assert db.get_session("retired2")["archived"] + db.close() + + # --------------------------------------------------------------------------- # Contract guards # --------------------------------------------------------------------------- diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 986155be64..d61d5c5d25 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -99,7 +99,17 @@ def _(rid, params: dict) -> dict: if (row.get("source") or "").strip().lower() in deny: return None if row.get("archived"): - return None + # An archived canonical row usually means the user + # deliberately retired it — report absent. But the + # ws-orphan reaper / older agent cleanup can archive it + # by accident (end_reason ws_orphan_reap / agent_close): + # the canonical chat is identity-scoped (the bot's + # forever conversation), so an accidental archive is + # user-visible amnesia. Resurrect those — un-archive and + # keep resolving — reusing the same recoverable-reason + # set as gateway stale-route recovery (#92687). + if not db.unarchive_recoverable_session(session_id): + return None try: tip = db.resolve_resume_session_id(session_id) or session_id except Exception: diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index d9bf97d6f9..345c65d057 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -192,6 +192,16 @@ def _(rid, params: dict) -> dict: title_lookup = str(params.get("title") or "").strip() if title_lookup: row = db.get_session_by_title(title_lookup) + if row and row.get("archived") and title_lookup == "Bot Chat": + # The canonical Bot Chat is identity-scoped: an archive + # stamped by the ws-orphan reaper or older agent cleanup + # (ws_orphan_reap / agent_close) is an accident, not user + # intent, and hiding the row here makes the desktop mint + # transient replacements forever (#92687). Resurrect it — + # same recoverable-reason set as stale-route recovery. + # Deliberate archives (no/explicit end_reason) still hide. + if db.unarchive_recoverable_session(row["id"]): + row = db.get_session_by_title(title_lookup) if ( not row or row.get("archived")