fix(bot-mode): resurrect canonical Bot Chat archived by recoverable reasons on reopen (#92687)

This commit is contained in:
kshitijk4poor
2026-08-24 00:49:55 +05:30
parent 96df952529
commit bef31fb06b
4 changed files with 168 additions and 3 deletions
+31
View File
@@ -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).
@@ -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
# ---------------------------------------------------------------------------
+11 -1
View File
@@ -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:
+10
View File
@@ -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")