fix(api): find a reset local Bot Chat by its title for hermes peer dm

`hermes peer dm` looks up the peer's Bot Chat by exact title before every DM
(`_find_bot_chat`). The listing shows a reset LOCAL lineage as its latest,
untitled segment, so once the Bot Chat had been reset (the overflow auto-reset
from the previous commit, or /new) the lookup came back empty. The client then
tried to create a new Bot Chat, which collided with the title (HTTP 400, "Peer
already has a 'Bot Chat' session but it is hidden ...").

An empty exact-title lookup now falls back to the titled creation id when it
owns a reset local lineage, before the archive resurrection already there. The
chat door admits that id onto the lineage's current segment.

Live-pass finding on #140968 (package 10)
Refs #134889 (LC-23)
Refs #140719

(cherry picked from commit 652a6fc8545017bedca161543c23faed46ff87a7)
This commit is contained in:
Teknium authored and Teknium committed 2026-10-18 07:53:13 -07:00
1 parent 2e20b4de6e
commit 2cccb91d11
3 files changed
+34 -4

No files matched your search

+3 -3
View File
@@ -141,6 +141,7 @@ from gateway.platforms import api_server_room_grants as _room_grants
from gateway.platforms import api_server_runs as _api_runs
from gateway.platforms.api_server_openai_routes import OpenAICompatRoutesMixin
from gateway.platforms.api_server_memory_sessions import ApiServerMemorySessions
from gateway.platforms.api_server_bot_chat import reset_local_title_owner
from gateway.platforms.base import (
MEDIA_TAG_CLEANUP_RE, BasePlatformAdapter, SendResult, _terminal_sentinel_start, is_network_accessible,
validate_media_delivery_path)
@@ -3036,7 +3037,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
rows = [s for s in rows if (s.get("title") or "").strip() == title_filter]
return rows
sessions = await _list()
sessions = await _list() or (await reset_local_title_owner(db, title_filter, include_hidden))
if title_filter and not sessions:
# A canonical Bot Chat auto-archived by the orphan reaper would make `hermes peer dm`
# mint transient sessions: resurrect and re-list; deliberate archives stay put.
@@ -3057,8 +3058,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
sessions = await _list()
except Exception:
pass # resolution degrades to today's no-row behavior
# Back-filled pins arrive PAST the limit, so counting them would report
# another page that doesn't exist. Only the recency window decides.
# Back-filled pins arrive PAST the limit: counting them would report a page that doesn't exist.
windowed = sum(1 for s in sessions if not s.get("pinned"))
return web.json_response({
"object": "list", "data": [self._session_response(s) for s in sessions],
+20
View File
@@ -84,6 +84,26 @@ def _refusal(authority, tip, exc):
return reason, f'Bot Chat admission refused: {reason}'
async def reset_local_title_owner(db, title, include_hidden):
"""``[row]`` for the exact-title lookup of a LOCAL conversation that was reset (``/new``, the
compression-exhaustion auto-reset), else ``[]``. The listing shows a reset lineage as its latest,
untitled segment, so ``hermes peer dm`` stopped finding its Bot Chat and its create collided
with the title. The titled creation id still owns the FIFO and is what the chat door admits."""
return await asyncio.to_thread(_reset_local_title_owner, db, title, include_hidden) if title else []
def _reset_local_title_owner(db, title, include_hidden):
row = db.get_session_by_title(title)
if row is None or row.get('end_reason') != 'session_reset' or (row.get('hidden') and not include_hidden):
return []
from hermes_state_local import local_receipt
try:
lineage = local_receipt(db, row['id']).get('lineage') or []
except RuntimeStoreError:
return []
return [row] if len(lineage) > 1 else []
def bot_chat_tip(authority, session_id):
"""The canonical Bot Chat's tip row when *session_id* is in its lineage, else None."""
db = authority.db
@@ -72,6 +72,12 @@ def _free_port():
return s.getsockname()[1]
def _get(port, path):
req = urllib.request.Request(f'http://127.0.0.1:{port}{path}', headers={'Authorization': f'Bearer {KEY}'})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def _post(port, path, body, timeout=180):
req = urllib.request.Request(f'http://127.0.0.1:{port}{path}', data=json.dumps(body).encode(), method='POST',
headers={'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'})
@@ -115,6 +121,8 @@ def overflow_run(tmp_path_factory):
assert status == 201, created
sid = created['session']['id']
first = _post(api_port, f'/api/sessions/{sid}/chat', {'message': big})
# ``hermes peer dm`` resolves the Bot Chat by exact title before every DM (``_find_bot_chat``).
found = _get(api_port, '/api/sessions?limit=200&title=Bot+Chat&include_hidden=1')['data']
follow = _post(api_port, f'/api/sessions/{sid}/chat', {'message': 'short follow-up MKFOLLOW'})
finally:
model.shutdown()
@@ -125,7 +133,7 @@ def overflow_run(tmp_path_factory):
with closing(sqlite3.connect(f'file:{home / "state.db"}?mode=ro', uri=True)) as db:
rows = db.execute("SELECT session_id, role, substr(coalesce(content,''),1,24) FROM messages ORDER BY id").fetchall()
bot_chat = db.execute('SELECT end_reason FROM sessions WHERE id=?', (sid,)).fetchone()
return SimpleNamespace(first=first, follow=follow, log=log, rows=rows, rejected=model.rejected,
return SimpleNamespace(sid=sid, found=[row['id'] for row in found], first=first, follow=follow, log=log, rows=rows, rejected=model.rejected,
bot_chat_end=bot_chat[0])
@@ -141,6 +149,8 @@ def test_peer_dm_overflow_resets_the_local_bot_chat_and_answers_the_follow_up(ov
# The overflow is answered with its typed reason, and the Bot Chat was reset after that turn settled.
assert run.first[0] == 502 and run.first[1]['error']['code'] == 'context_overflow', run.first
assert run.bot_chat_end == 'session_reset', run.rows
# The client's title lookup still finds the reset Bot Chat (no title-collision create).
assert run.found == [run.sid], run.found
# The follow-up runs on the fresh transcript, is answered, and is stored once.
assert run.follow[0] == 200, (run.follow, run.rows)
assert run.follow[1]['message']['content'].startswith('ACK short follow-up'), run.follow