فهرست منبع

Ask the session store whether a thread was already seeded

The once-per-thread marker lived in an in-memory deque while the thing it
describes — the thread's session — lives in state.db. After any gateway
restart the deque is empty but every thread's session still holds what the
first turn seeded, so the first turn into ANY thread in the room after a
restart re-fetched its history and prepended a duplicate of context the
session already had. One restart, up to 2000 duplicated characters per
thread, recurring on every restart.

The gateway already knows the answer: run.py hands every adapter a session
store at registration, and peek_session_id resolves a key against the
persistent routing index. Ask it instead of tracking a second, weaker copy
of the same fact. The deque stays as a timing pre-check — the session is
written after our dispatch returns, so two turns can race in between, which
the store cannot yet distinguish.

Fails open: no store wired means "no session" means seed. Losing the block
would answer a reply the agent cannot read, which is the bug this whole
change exists to fix; the cost of failing open is a duplicated reference
block once.

No session-reset path exists on this platform to special-case — /join and
/leave are the only commands and they ride DMs — so an emptied session
cannot strand a thread here. Recorded in AGENTS.md so a later reader does
not go looking for a reset case that cannot occur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Klumpp 12 ساعت پیش
والد
کامیت
26da1d96bc
4فایلهای تغییر یافته به همراه156 افزوده شده و 17 حذف شده
  1. 11 3
      AGENTS.md
  2. 1 1
      README.md
  3. 45 13
      adapter.py
  4. 99 0
      test_adapter.py

+ 11 - 3
AGENTS.md

@@ -110,10 +110,18 @@ changes must keep them, or consciously renegotiate the docs.**
   is labelled `… (this bot)` so the agent recognises it; authors off the
   allowlist are labelled `(unverified)` — auth gates the trigger message
   only, and the rest of a shared thread is reference text (fail closed for
-  authors never seen). Seeding happens once per thread; a failed lookup
-  leaves the thread unseeded so its next turn retries. Deliberately NOT
+  authors never seen). Seeding is once per SESSION, not per thread and not per process: the
+  in-memory deque alone would re-seed every thread in the room after each
+  restart (the marker dies with the process, the session does not), so
+  `_thread_session_exists` asks the gateway's persistent routing index via
+  `peek_session_id`, which answers correctly across restarts. The deque stays
+  as a timing pre-check for two turns racing before the first session write.
+  A failed lookup leaves the thread unseeded so its next turn retries. Not
   discarded on reconnect, unlike the roster: thread history is immutable, so
-  a refetch could only duplicate what the session already holds.
+  a refetch could only duplicate what the session already holds. Note there
+  is no session-reset path on this platform — `/join` and `/leave` are the
+  only commands and they ride DMs — so an emptied session cannot strand a
+  thread the way it would elsewhere; do not assume one exists.
 - **Room roster rides on every channel turn.** Threads hold isolated sessions
   and the agent only sees `channel_context` per dispatch, so each channel turn
   carries the current member roster — repetition is the point, deduplicating

+ 1 - 1
README.md

@@ -210,7 +210,7 @@ This matters most for a reply to something the bot posted itself. A cron job's m
 
 Messages in the thread from people outside `CHATTO_ALLOWED_USERS` are prefixed `(unverified)`. Only the message that triggers a turn passes the auth gate, so the rest of a shared thread is reference text, not trusted input; an author the adapter has never seen is marked unverified too. With `CHATTO_ALLOW_ALL_USERS=true` nothing is marked.
 
-Seeding happens once per thread — later turns in it need no block, because the session already holds that history. If the lookup fails, the turn proceeds without it and the next turn in that thread retries. Direct messages never thread and never seed.
+Seeding happens once per session, not per message and not per restart: later turns in the thread need no block because the session already holds that history, and neither does the first turn after the Hermes gateway restarts. If the lookup fails, the turn proceeds without it and the next turn in that thread retries. Direct messages never thread and never seed.
 
 ### Room Member Roster
 

+ 45 - 13
adapter.py

@@ -1215,6 +1215,7 @@ class ChattoAdapter(BasePlatformAdapter):
         room_id: str,
         thread_root_event_id: str,
         current_message_id: str,
+        source: Any,
     ) -> str:
         """The thread's earlier messages, delivered once per thread.
 
@@ -1230,10 +1231,24 @@ class ChattoAdapter(BasePlatformAdapter):
         that thread does not exist yet, so there is nothing to fetch. DMs never
         thread and never seed.
 
+        Seeding is asked of the session store, not just the in-memory deque:
+        the marker is process-local and dies on every restart, while the
+        thread's session lives in state.db — so a restart would otherwise
+        re-seed every thread in the room and duplicate a context the session
+        already holds. The deque remains as a cheap timing pre-check for two
+        turns racing before the first session is written.
+
         Best-effort by design, like the roster: a failed lookup leaves the
         thread unseeded so the next turn retries.
         """
-        if not thread_root_event_id or thread_root_event_id in self._seeded_threads:
+        if not thread_root_event_id:
+            return ""
+        if thread_root_event_id in self._seeded_threads:
+            return ""
+        if self._thread_session_exists(source):
+            # This thread already has a session, so its history is already in
+            # that session — recording it keeps a later turn from re-asking.
+            self._mark_thread_seeded(thread_root_event_id)
             return ""
         history = await self._thread_history(
             client, room_id=room_id, thread_root_event_id=thread_root_event_id
@@ -1313,6 +1328,20 @@ class ChattoAdapter(BasePlatformAdapter):
         """Record a thread whose history has been handed to its session."""
         self._seeded_threads.append(thread_root_event_id)
 
+    def _thread_session_exists(self, source: Any) -> bool:
+        """Whether the gateway already has a session for this source.
+
+        The gateway's own routing index is the authority on "does this thread's
+        conversation already exist" — it is persistent (state.db), so unlike
+        the in-memory deque it answers correctly after a restart. Returns False
+        when no store is wired (tests, or a startup path before
+        ``set_session_store`` runs) so we fail open and seed.
+        """
+        store = getattr(self, "_session_store", None)
+        if store is None:
+            return False
+        return store.peek_session_id(self._session_key_for(source)) is not None
+
     def _thread_context_block(self, messages: list[Message]) -> str:
         """Render earlier thread messages as the agent's reference block.
 
@@ -1734,10 +1763,23 @@ class ChattoAdapter(BasePlatformAdapter):
         # repeat is not deduplication but the point (see _roster_for_room).
         # The gateway prepends channel_context above the message text, so the
         # roster never mingles with what the user actually wrote.
+        source = self.build_source(
+            chat_id=room_id,
+            chat_name=self._room_names.get(message.room_id),
+            chat_type=chat_type_for_room_kind(room_kind),
+            user_id=message.actor_id,
+            user_name=user.login,  # use login, because display_name is changeable by anyone.
+            thread_id=thread_id,
+            message_id=source_message_id,
+            role_authorized=True,
+        )
+
         # Channel context rides ahead of the message text as read-only
         # reference: the roster names the audience, the thread block carries
         # what the thread already said. DMs get neither — there is no audience
-        # to name and no thread to catch up on.
+        # to name and no thread to catch up on. Built after the source, because
+        # the thread block asks the session store whether this thread's session
+        # already has the history (see _thread_context_for).
         context_blocks: list[str] = []
         if room_kind != RoomKind.DM:
             logger.debug(
@@ -1757,21 +1799,11 @@ class ChattoAdapter(BasePlatformAdapter):
                 room_id=room_id,
                 thread_root_event_id=thread_root_event_id or "",
                 current_message_id=message.id,
+                source=source,
             )
             if thread_context:
                 context_blocks.append(thread_context)
 
-        source = self.build_source(
-            chat_id=room_id,
-            chat_name=self._room_names.get(message.room_id),
-            chat_type=chat_type_for_room_kind(room_kind),
-            user_id=message.actor_id,
-            user_name=user.login,  # use login, because display_name is changeable by anyone.
-            thread_id=thread_id,
-            message_id=source_message_id,
-            role_authorized=True,
-        )
-
         # prepare a MessageEvent
         message_event = MessageEvent(
             text=message_body,

+ 99 - 0
test_adapter.py

@@ -2021,6 +2021,105 @@ class TestThreadContext:
         second = adapter.handle_message.await_args.args[0]
         assert "Nightly build finished" not in (second.channel_context or "")
 
+    async def test_a_restart_does_not_reseed_a_thread_whose_session_survived(self):
+        """The seeding marker is in-memory, the session is in state.db.
+
+        After a gateway restart the deque is empty while the thread's session
+        still holds everything the first turn seeded. Asking the session store
+        — not the deque — is what keeps the block one-shot across restarts.
+        """
+        before = self._adapter()
+        self._seed_channel(before)
+        cron = _make_message(
+            body="Nightly build finished",
+            message_id="cron-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        before._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[_thread_event(cron)])
+        )
+        await self._dispatch(
+            before, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
+        )
+        session_key = before._session_key_for(
+            before.handle_message.await_args.args[0].source
+        )
+
+        # A fresh adapter: the deque is gone, as it is after any restart. The
+        # session store still resolves the key — it is persistent.
+        after = self._adapter()
+        self._seed_channel(after)
+        assert list(after._seeded_threads) == []
+        after.set_session_store(
+            MagicMock(peek_session_id=MagicMock(return_value="sess-7"))
+        )
+        after._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[_thread_event(cron)])
+        )
+
+        await self._dispatch(
+            after, "@hermes_bot after restart", message_id="msg-2", thread_root="cron-1"
+        )
+
+        after._chatto_client.get_thread_events.assert_not_called()
+        assert after._chatto_client.get_thread_events.await_count == 0
+        resumed = after.handle_message.await_args.args[0]
+        assert "Nightly build finished" not in (resumed.channel_context or "")
+        # Sanity: the key the adapter asks about is the one the gateway used.
+        assert after._session_store.peek_session_id.call_args.args == (session_key,)
+
+    async def test_a_thread_whose_session_is_unknown_still_seeds(self):
+        """No session bound to the key means the session is empty — the exact
+        situation seeding exists for. Fails open: seed rather than answer a
+        reply the agent cannot read."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter.set_session_store(
+            MagicMock(peek_session_id=MagicMock(return_value=None))
+        )
+        cron = _make_message(
+            body="Nightly build finished",
+            message_id="cron-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        adapter._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[_thread_event(cron)])
+        )
+
+        await self._dispatch(
+            adapter, "@hermes_bot and the deploy?", thread_root="cron-1"
+        )
+
+        assert (
+            "Nightly build finished" in self._dispatched_event(adapter).channel_context
+        )
+
+    async def test_without_a_session_store_the_deque_still_guards_repeat_seeding(self):
+        """No store wired (tests, or a gateway path that has not registered it
+        yet) must not disable seeding's once-per-thread behaviour."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        cron = _make_message(
+            body="Nightly build finished",
+            message_id="cron-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        adapter._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[_thread_event(cron)])
+        )
+
+        await self._dispatch(
+            adapter, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
+        )
+        await self._dispatch(
+            adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
+        )
+
+        assert adapter._chatto_client.get_thread_events.await_count == 1
+
     async def test_the_bot_knows_the_seeded_cron_message_is_its_own(self):
         """The cron message is the bot's output that it never saw arrive —
         without the label the agent cannot tell 'I said this' from a third