fix(bot-mode): resurrect canonical Bot Chat archived by recoverable reasons on reopen (#92687)
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user