fix(bot-mode): canonical-chat adoption survives busy profiles via exact-title lookup

The #90732 adoption scan used session.list's 200-row recency window. A busy
bot profile (group-chat traffic, routines, or accumulated fork spam) pushes
an older forever-chat past row 200, the scan misses it, and the mint path
re-enters the unique-title-conflict fork loop — same pathology, higher
trigger threshold.

Profile → Named Session is an exact registry (UNIQUE title index), so
consult it exactly:

- session.list gains a `title` param: indexed WHERE title = ? lookup,
  window-free, hidden rows resolve, archived/deny-listed do not,
  compression lineages resolve to the live tip (resolved_id), mirroring
  profiles.list's preferred_session resolver.
- findExistingCanonicalChat sends title: 'Bot Chat'. Older gateways ignore
  the unknown param and return the windowed listing — the local scan stays
  as the compatibility rung.
- Adoption opens the lineage tip (resolved_id) while pinning the durable id,
  same split as the preferred_session path.
This commit is contained in:
Teknium
2026-08-20 03:46:31 -07:00
parent 6d85d790e4
commit 21e9d4532f
2 changed files with 61 additions and 8 deletions
+18 -8
View File
@@ -3384,17 +3384,24 @@ async function openStoredBotChat(name, storedId, summary) {
/** Adopt-before-mint: the profile may already own a canonical Bot Chat that
* the pin lost track of (pin cleared during an outage, ui_meta rolled back,
* a fork squatting the title). The core UNIQUE title index guarantees at
* most ONE session titled "Bot Chat" per profile db, so a title scan is an
* exact registry lookup, not a heuristic. Minting while a "Bot Chat" row
* exists is always wrong twice over: it forks the forever-chat AND the new
* row can never take the (already held) canonical title, so the next
* identity check misreads it and forks again — the infinite-fork loop.
* include_hidden is required (canonical chats are always hidden); an older
* gateway without it simply finds nothing and we fall through to mint. */
* most ONE session titled "Bot Chat" per profile db — Profile → Named
* Session is an exact registry, so consult it exactly: `title` asks the
* gateway for an indexed WHERE title = ? lookup (window-free; a busy
* profile can push the forever-chat past any recency window, which would
* re-open the fork loop with a higher trigger threshold). Minting while a
* "Bot Chat" row exists is always wrong twice over: it forks the
* forever-chat AND the new row can never take the (already held) canonical
* title, so the next identity check misreads it and forks again — the
* infinite-fork loop. An older gateway ignores the unknown `title` param
* and returns the plain windowed listing instead — the pre-exact-lookup
* behavior — so the local scan below stays as the compatibility rung.
* include_hidden is required (canonical chats are always hidden); a gateway
* without it simply finds nothing and we fall through to mint. */
async function findExistingCanonicalChat(name) {
try {
const res = await host.request('session.list', {
profile: name,
title: 'Bot Chat',
limit: PROFILE_SESSION_LIST_LIMIT,
include_hidden: true
})
@@ -3424,7 +3431,10 @@ function createCanonicalChat(name) {
saveBotMeta(name, { chat: existing.id })
if (typeof host.openSession === 'function') {
await openStoredBotChat(name, existing.id, existing)
// The exact-lookup gateway reports the compression-lineage tip as
// resolved_id; the pin stays the durable row id (same split the
// preferred_session path uses).
await openStoredBotChat(name, existing.resolved_id || existing.id, existing)
}
return existing.id
+43
View File
@@ -177,6 +177,49 @@ def _(rid, params: dict) -> dict:
# their own source.
deny = frozenset({"kanban", "tool"})
# ``title``: EXACT-title registry lookup, not a listing. The core
# UNIQUE title index means at most one session per db carries a
# given exact title, so callers that treat a title as an identity
# key (Bot Mode's canonical "Bot Chat" — Profile → Named Session)
# get a window-free O(1) answer instead of scanning a recency
# window that a busy profile can push the row out of. Hidden rows
# resolve (canonical chats are born hidden); archived rows and
# deny-listed sources do not; compression lineages resolve to the
# live tip (``resolved_id``), mirroring profiles.list's
# preferred_session resolver. Older clients never send this param;
# newer clients falling back to older gateways just get the normal
# windowed listing back (the param is ignored) and scan it.
title_lookup = str(params.get("title") or "").strip()
if title_lookup:
row = db.get_session_by_title(title_lookup)
if (
not row
or row.get("archived")
or (row.get("source") or "").strip().lower() in deny
):
return _ok(rid, {"sessions": []})
try:
tip = db.resolve_resume_session_id(row["id"]) or row["id"]
except Exception:
tip = row["id"]
tip_row = (db.get_session(tip) or row) if tip != row["id"] else row
return _ok(
rid,
{
"sessions": [
{
"id": row["id"],
"resolved_id": tip,
"title": row.get("title") or "",
"preview": tip_row.get("preview") or "",
"started_at": row.get("started_at") or 0,
"message_count": tip_row.get("message_count") or 0,
"source": row.get("source") or "",
}
]
},
)
limit = int(params.get("limit", 200) or 200)
# ``include_hidden``: surfaces that OWN hidden sessions (the Bots
# pane's per-profile browser, plugin session pickers) need to list