Просмотр исходного кода

Derive session keys through the base class, and stop the room-keyed advice leaking

Four findings from the second review pass, plus the docs split they prompted.

_session_key_for built its key with build_session_key directly, which is what the
base class does *plus* two things: pinning the routing identity first, and
carrying the serving profile's namespace. The base class says why in
_session_key_profile's own docstring — ingress runs before the runner stamps
source.profile, so a key built without it puts every bot of a multiplexed gateway
into agent:main:. Ours named a session nobody was running, which is how a live
thread session came back "does not exist" (one full re-seed of that thread after
a restart) and would have made an edit's cancel_session_processing a no-op on
every secondary profile. Delegating is the whole fix and removes our copy of a
rule that can drift.

_not_joined_refusal keyed the remedy on CHANNEL, so UNSPECIFIED — which is what a
server that never sets kind returns, and which this plugin counts as a channel in
_room_answers_in and _refresh_rooms — was told to start a DM with the bot to get
it into a channel. That is the exact advice the function exists to prevent, handed
to every self-hosted server on an older build. Keyed on DM instead.

_seeded_through fell back to the trigger when the fetched window was empty,
recording a cursor the next fetch cannot find: the thread then reads as one that
grew past its cursor, logs that, and goes silent for good. It now records nothing,
so the next turn retries from the top.

Dead state from the cursor rewrite went with it: the map is dict[str, str] again
(nothing records None since the blind pass stopped using it as "unknowable"), the
isinstance branch that could not fire is gone, and the thread_sessions_per_user
caveat sits with the field it constrains instead of above _processing.

The vendored console scripts carried this checkout's venv path in their shebangs,
so the same refresh on another machine would rewrite five files for no reason.
vendor_chattolib.sh normalises them, and the tree is normalised with it.

Docs: the thread-seeding mechanism left README.md. It described how the frontier
is stored and why the shortcut fails — true, and of no use to someone deciding
whether to run the plugin. README keeps what a user observes and links to
CONCEPTS.md, which carries the derivation, the cost of each part and the wrong
turn; AGENTS.md keeps the rules and points there rather than repeating the
reasoning. AGENTS.md now says outright that README is for users and that a
sentence explaining how rather than what belongs in CONCEPTS.md.

Five mutations across the three behaviours, all caught. Two of the new tests
passed against mutated code at first: the empty-fetch test never dispatched at
all (get_message returned None, so the turn was dropped before seeding), and the
refusal test only covered CHANNEL, which the mutation happened to satisfy.
Paul Klumpp 2 недель назад
Родитель
Сommit
bd81562e27

+ 49 - 17
AGENTS.md

@@ -113,6 +113,39 @@ behaves, how to configure it. Unambiguous in practice means:
   server floor, and README.md has to move with it. ("Tested against Chatto
   v0.4.19" outlived its truth the moment the vendored copy became 0.4.20.)
 
+### Only what interests the user goes in README.md
+
+**A README is read by someone deciding whether to run this plugin, not by
+someone deciding how to change it. Precision about the plugin's own behaviour is
+the job; the mechanism behind it usually is not.**
+
+So before adding a paragraph, ask *what the reader can do differently with it*.
+A user needs to know that a reply into a thread reaches the agent with what the
+bot said earlier, and that a gateway restart does not lose that. They do not need
+to know that the frontier is a cursor stored under a session-metadata key, that
+persisting it costs a routing-index rewrite, or that the obvious shortcut for
+reconstructing it does not work. That last sentence is documentation of a
+decision, and a decision has an audience: whoever changes it next.
+
+The audiences differ, and so do their documents:
+
+| Reader | Document | Holds |
+| --- | --- | --- |
+| Decides whether to run it | `README.md` | Observable behaviour, configuration, capability limits |
+| Installs it | `after-install.md` | First steps, stated as they can be followed |
+| Changes it | `AGENTS.md` | The rules, written as rules |
+| Understands it | `CONCEPTS.md` | Why a rule looks the way it does — the derivation, the trade-offs, the wrong turns |
+
+The failure mode is not a missing paragraph, it is a README that reads like the
+design document: paragraphs nobody acts on, internals bleeding into user docs,
+and every mechanism needing an update in three places at once. When a README
+sentence starts explaining *how* rather than *what a user sees*, that is the
+signal to move it — and to keep the user-facing half, plus a pointer.
+
+`CONCEPTS.md` earns its place only under that split. It must not restate
+`AGENTS.md` (three copies of one rule drift, and AGENTS.md is the one that
+binds), and it must not document behaviour no user can observe.
+
 ### after-install.md
 
 **Hermes Agent displays `after-install.md` to the user right after plugin
@@ -177,23 +210,22 @@ changes must keep them, or consciously renegotiate the docs.**
   thread root to the last message ID the session reached; the cap evicts the
   least recently touched thread. The cursor passes the trigger as well as the
   delivered block — the trigger is in the session, so a cursor left behind it
-  would hand the next turn the previous question a second time. It is **also**
-  written to the session (`chatto.thread_seed_cursor`), because a restart drops
-  the in-memory map while the session survives — and the frontier is **not
-  derivable from the message list**, which is the trap: the newest message that
-  is not ours is the turn's own trigger (a person's message that just became this
-  turn), so rebuilding a partition from the fetch skips every bot message before
-  it, and the *previous* turn's trigger looks just as much like a message the
-  session holds as the bot's own output looks like one it does not. Persisting
-  costs a full routing-index rewrite per call, so it is paid only where it buys
-  something: on a turn that delivered the bot's own output (the one thing a later
-  turn cannot recover from the list), and on the turn after a first turn whose
-  cursor found no session to land on (`_unpersisted_threads`). A thread can
-  therefore be *blind* exactly once — first turn seeded nothing, no session yet,
-  restart before the second turn — and that pass re-delivers the bot's own
-  messages, leaves the people's messages to their own turns, and records the
-  frontier so no process after it is guessing. Prefer that bounded duplicate to
-  a permanent loss. A cursor that has fallen out of the fetched window means the
+  would hand the next turn the previous question a second time — and never
+  points past the fetched window, or the next fetch cannot find it again. The
+  cursor is **stored**, in memory and on the session
+  (`chatto.thread_seed_cursor`), because a session's frontier is not derivable
+  from the message list: a person who merely talked is in the session, the
+  bot's own output is in nobody's, and a past turn's trigger is
+  indistinguishable from the former. Rebuilding a partition from the fetch skips
+  the very output this exists to deliver — see CONCEPTS.md for how that goes
+  wrong and what each term of it costs. Writing the session copy costs a full
+  routing-index rewrite, so it is spent only on a turn that delivered the bot's
+  own output, and on the turn after a first turn whose cursor found no session to
+  land on (`_unpersisted_threads`). A thread can be *blind* exactly once — first
+  turn seeded nothing, no session yet, restart before the second turn — and that
+  pass re-delivers the bot's own messages, leaves the people's messages to their
+  own turns, and records the frontier; prefer that bounded duplicate to a
+  permanent loss. A cursor that has fallen out of the fetched window means the
   thread outgrew `THREAD_CONTEXT_MESSAGE_LIMIT`: deliver nothing rather than
   re-seed from the top, which would duplicate what the session holds. A failed
   lookup leaves the thread unseeded so its next turn retries. Not discarded on

+ 155 - 0
CONCEPTS.md

@@ -0,0 +1,155 @@
+# Concepts
+
+How the plugin works where the reasoning is not obvious from the code. The
+README describes what the plugin does; `AGENTS.md` states the rules a change has
+to honour. This file explains *why* a few of those rules look the way they do,
+for a reader who wants to understand the design before changing it.
+
+- [Thread sessions and the frontier](#thread-sessions-and-the-frontier)
+- [Configuration resolution](#configuration-resolution)
+- [One send path, two callers](#one-send-path-two-callers)
+
+## Thread sessions and the frontier
+
+### The problem
+
+A Chatto thread gets its own Hermes session, and **that session starts empty**.
+Meanwhile two kinds of message never become a turn of their own:
+
+- **The bot's own messages.** Chatto echoes a posted message back over the
+  realtime connection, and the adapter drops it as a self-event — correctly, since
+  the gateway is already running the turn that produced it.
+- **Anything the gateway did not dispatch**, chiefly a message that arrived while
+  it was down. README documents that there is no backfill.
+
+So a thread can contain messages that no session holds. The reported case: a cron
+job posts a build report into a channel, its echo is filtered, and an hour later
+someone replies to it. The thread it roots is new, its session is empty, and the
+agent would answer a message it cannot read.
+
+The fix is to hand the thread's earlier messages to the session — but only the
+ones it has not seen. Re-delivering the whole thread on every turn would fill the
+context with a conversation the session already holds.
+
+### The frontier
+
+That "only what it has not seen" boundary is a **cursor**: one opaque message ID
+per thread, meaning *the session holds this thread's history up to here*. Two
+facts about it drive everything else:
+
+1. **It has to advance past the turn's own trigger**, not just past the block
+   that was delivered. The trigger is the message text of the turn, so the session
+   holds it; a cursor left behind it hands the next turn the previous question a
+   second time, as an `[Earlier in this thread]` line beside a conversation that
+   already contains it.
+2. **It cannot point past the fetched window.** The fetch is capped at
+   `THREAD_CONTEXT_MESSAGE_LIMIT`, and a cursor the next fetch does not contain
+   cannot be found again. When the trigger is missing from the window, the cursor
+   records what was actually delivered instead.
+
+### Why the frontier is stored, not derived
+
+The cursor lives twice: in `_seeded_threads` for the running process, and on the
+session (`chatto.thread_seed_cursor`) so it survives a restart.
+
+An earlier version of this code did not persist it, on the grounds that
+`set_session_metadata` rewrites the whole routing index per call. Instead it
+rebuilt the partition from the fetch the turn makes anyway:
+
+> the newest message that is not ours is the last one the session certainly
+> holds, because every other person's message arrives as its own turn
+
+That premise is false, and the fetch it reasons over is the shape a thread reply
+*always* produces: the window ends with the turn's own trigger — a person's
+message that became this turn seconds ago. So the rebuilt partition lands on the
+trigger, the tail comes out empty, and every bot message before it is skipped.
+After a restart the agent stops seeing its own output in a thread, which is the
+one thing the whole feature exists to prevent.
+
+Excluding the trigger does not rescue it. The *previous* turn's trigger is just as
+much a person's message that became a turn, so the next restart skips again. A
+session's frontier is not a function of the message list at all: every candidate
+message is either a turn the session holds (person) or one it does not (bot), and
+the list alone cannot tell a past trigger from a bot message — only the stored
+frontier can.
+
+### When the write happens
+
+Persisting costs a full routing-index rewrite, so the write is spent only where it
+buys something — which is not every turn:
+
+- **A turn that delivered the bot's own output.** That is the one thing no later
+  turn can recover from the message list, so it is the one cursor that has to
+  outlive the process. A turn that carried only the trigger writes nothing: its
+  frontier *is* "the newest message that is not ours", which a restart can work
+  out for itself.
+- **The turn after a first turn whose cursor found no session to land on.** A
+  thread's first turn has no session yet, so the write is refused; that refusal is
+  remembered in `_unpersisted_threads` and retried, because a cursor that never
+  lands is exactly the cursor a restart needs.
+
+### The blind pass
+
+One case has no cursor anywhere. A thread's first turn seeded *nothing* — a reply
+that opens a fresh thread has no earlier messages — so there was nothing to write;
+there was no session to write it to either; and the process restarted before the
+second turn. The thread is blind exactly once.
+
+The blind pass re-delivers the bot's own messages, leaves people's messages to
+their own turns, and records the frontier so no later process is guessing. It can
+duplicate one block, in the rarer variant where that first seed *did* carry own
+output. Losing the output instead is the common case and permanent, so the
+bounded duplicate is the cheaper error.
+
+### What the cursor is looked up with
+
+The adapter derives session keys of its own — to find the cursor, to find the
+turn an edit should cancel — and a derived key is worthless unless it is
+byte-identical to the one the gateway built. So it delegates to
+`_source_session_key` instead of calling `build_session_key` itself. That matters
+more than it looks: ingress runs *before* the runner stamps the profile onto the
+event, so a key built without the profile namespace puts every bot of a
+multiplexed gateway into `agent:main:` and names a session nobody is running.
+
+### What this costs
+
+- One extra RPC per turn that delivers something: `get_thread_events`, capped at
+  20 messages. Quiet threads skip it — the cursor means there is nothing to fetch.
+- One session-metadata write per turn that delivered own output, plus one retry
+  after a thread's first turn. Not per turn.
+- `[Earlier in this thread]` is reference text, not trusted input: only the
+  trigger passes the auth gate.
+
+## Configuration resolution
+
+Every setting is one `ConfigField` on `ChattoConfiguration`, which derives three
+coordinated names: the `config.yaml` key, the `CHATTO_*` environment variable, and
+the attribute the adapter reads. Values resolve env var → `config.yaml` extra →
+default, and every path parses the raw value by the field's kind first — a `.env`
+line is a string whatever the field means, and `"false"` is true if nobody parses
+it.
+
+The non-obvious part is **which configuration a tool call sees**. The gateway
+multiplexes several serving profiles through one process, so a process-wide config
+cache would hand one profile another profile's credentials. Cached configurations
+are therefore keyed by the profile scope Hermes itself uses, and secrets come from
+the scope the gateway installed rather than from `os.environ` — under
+multiplexing the environment holds the *launch* profile's values.
+
+One consequence worth knowing: a configuration that resolved without a token is
+not cached at all. Caching it would pin "token not configured" for the life of the
+process, so a later call in a properly scoped context would keep being served the
+degraded configuration long after its scope came back.
+
+## One send path, two callers
+
+A user cannot tell whether the agent replied on its own or through a tool, so the
+two paths must not behave differently. Both go through `_deliver_chunks`, which
+owns the split at the 9900-character limit, the routing of a split answer as one
+answer (the first chunk opens or joins a thread, later chunks carry that root),
+and the stop-at-first-failure.
+
+The callers differ only in what they do with the result: `send()` shapes it into a
+`SendResult`, `_post_chunks` into a refusal that names which chunk failed and
+lists the IDs that went out — so a retry repeats only what is missing instead of
+duplicating a message the room already has.

+ 6 - 2
README.md

@@ -220,7 +220,11 @@ 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 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 — the plugin remembers how far each thread's session reached. 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.
+
+The plugin keeps track of how far each thread's session has read, so a turn only
+ever carries what the session is missing. [CONCEPTS.md](CONCEPTS.md#thread-sessions-and-the-frontier)
+explains how, and why the obvious shortcut for it does not work.
 
 ### Room Member Roster
 
@@ -386,7 +390,7 @@ Credentials never come from the model: no tool takes a server URL or a token as
 
 Long messages are split at the same limit as every other send (see [Message Splitting](#message-splitting)). If a chunk fails after earlier ones were delivered, the tool reports what it already sent plus a `partial: true` marker, so a retry can repeat only the missing part.
 
-A message the tool posts is the bot's own, so Chatto echoes it back and the adapter drops it as a self-event — it never becomes a turn of its own. In a thread, the next human message still reaches the agent with what it said: each turn seeds the thread with whatever landed since the last turn, so a `chatto_send_room` reply is in the session the next time someone speaks there. The gateway restarting in between does not change that: where the thread left off is remembered on the session, not just in the running process.
+A message the tool posts is the bot's own, so Chatto echoes it back and the adapter drops it as a self-event — it never becomes a turn of its own. In a thread, the next human message still reaches the agent with what it said: each turn seeds the thread with whatever landed since the last turn, so a `chatto_send_room` reply is in the session the next time someone speaks there, and a gateway restart in between does not change that.
 
 ## Troubleshooting
 

+ 77 - 58
adapter.py

@@ -56,7 +56,6 @@ from gateway.platforms.base import (
     get_inbound_media_max_bytes,
     validate_inbound_media_size,
 )
-from gateway.session import build_session_key
 
 # Chattolib imports (vendored)
 # Using vendored chattolib from vendor/chattolib/
@@ -387,8 +386,7 @@ class ChattoAdapter(BasePlatformAdapter):
         # the last message ID that was delivered. Thread sessions start empty, so
         # the first turn into a thread seeds what came before it — a cron message
         # whose echo we filtered as a self-event, or chatter that predates the
-        # bot joining. A None value means an earlier process seeded it and this
-        # one cannot tell how far it got.
+        # bot joining.
         #
         # The cursor is what makes later turns safe to seed again: the agent's
         # own messages never reach a session (their echo is filtered as a
@@ -398,17 +396,17 @@ class ChattoAdapter(BasePlatformAdapter):
         # duplicate context on every turn. Insertion-ordered and capped like the
         # seen-ring; unlike the roster this is deliberately NOT discarded on
         # reconnect, since thread history is immutable.
-        self._seeded_threads: dict[str, str | None] = {}
-        # Threads whose cursor is on its way to the session but has not landed:
-        # a thread's first turn has no session yet, and the second turn retries.
-        self._unpersisted_threads: set[str] = set()
+        #
         # Keyed by thread root, which assumes one session per thread. With
         # thread_sessions_per_user turned on, each participant gets their own
         # session while this map stays shared, so one participant's turn moves
         # the cursor past the other's messages and that session is seeded once
         # and never again. The flag is off by default and this is the price of
-        # switching it on; keying the map by session key would fix it and is
-        # noted next to _thread_context_for.
+        # switching it on; keying the map by session key would fix it.
+        self._seeded_threads: dict[str, str] = {}
+        # Threads whose cursor is on its way to the session but has not landed:
+        # a thread's first turn has no session yet, and the second turn retries.
+        self._unpersisted_threads: set[str] = set()
         # session_key -> message ID currently being processed there. Written
         # by on_processing_start, cleared by on_processing_complete; an edit
         # landing on the recorded ID is a mid-run correction.
@@ -1314,13 +1312,10 @@ class ChattoAdapter(BasePlatformAdapter):
         session_key = self._session_key_for(source)
 
         if thread_root_event_id in self._seeded_threads:
-            delivered_to = self._seeded_threads[thread_root_event_id]
-            if not isinstance(delivered_to, str):
-                return ""
             return self._seed_thread_tail(
                 thread_root_event_id,
                 history,
-                delivered_to,
+                self._seeded_threads[thread_root_event_id],
                 current_message_id,
                 session_key=session_key,
             )
@@ -1359,26 +1354,24 @@ class ChattoAdapter(BasePlatformAdapter):
                 len(own),
             )
             cursor = self._seeded_through(history, own, current_message_id)
-            self._record_thread_seeded(thread_root_event_id, cursor)
-            self._persist_thread_cursor(session_key, thread_root_event_id, cursor)
+            if cursor is not None:
+                self._record_thread_seeded(thread_root_event_id, cursor)
+                self._persist_thread_cursor(session_key, thread_root_event_id, cursor)
             return self._thread_context_block(own)
 
         # First turn into this thread: the session starts empty, so hand it the
         # whole thread before the reply.
         earlier = [m for m in history if m.id != current_message_id]
-        self._remember_thread_cursor(
-            session_key,
-            thread_root_event_id,
-            self._seeded_through(history, earlier, current_message_id),
-            delivered=earlier,
+        self._advance_thread_cursor(
+            session_key, thread_root_event_id, history, earlier, current_message_id
         )
         return self._thread_context_block(earlier)
 
     @staticmethod
     def _seeded_through(
         history: list[Message], delivered: list[Message], current_message_id: str
-    ) -> str:
-        """How far this turn's session now reaches, for the cursor.
+    ) -> str | None:
+        """How far this turn's session now reaches, or None if the window cannot say.
 
         The trigger is excluded from the *block* — it is the message text — but
         the session holds it, so the cursor passes it too. Leaving it behind
@@ -1387,10 +1380,41 @@ class ChattoAdapter(BasePlatformAdapter):
         contains it. Only when the fetched window does not list the trigger: a
         cursor beyond the window would be unreachable, so fall back to what was
         actually delivered.
+
+        None when neither is in the window — an empty fetch. The trigger is *not*
+        a usable fallback there: recording it leaves a cursor the next fetch
+        cannot find, which then reads as a thread that grew past its cursor and
+        mutes it for good.
         """
         if any(m.id == current_message_id for m in history):
             return current_message_id
-        return delivered[-1].id if delivered else current_message_id
+        return delivered[-1].id if delivered else None
+
+    def _advance_thread_cursor(
+        self,
+        session_key: str,
+        thread_root_event_id: str,
+        history: list[Message],
+        delivered: list[Message],
+        current_message_id: str,
+    ) -> None:
+        """Record the frontier this turn established, if the window holds one.
+
+        A turn that cannot name a position leaves the thread unrecorded, which is
+        the state its next turn retries from — strictly better than recording a
+        cursor that can never be found again.
+        """
+        cursor = self._seeded_through(history, delivered, current_message_id)
+        if cursor is None:
+            logger.debug(
+                "Chatto: thread %s delivered nothing the fetched window can "
+                "name a cursor from; left unrecorded for the next turn",
+                thread_root_event_id,
+            )
+            return
+        self._remember_thread_cursor(
+            session_key, thread_root_event_id, cursor, delivered=delivered
+        )
 
     def _seed_thread_tail(
         self,
@@ -1424,11 +1448,8 @@ class ChattoAdapter(BasePlatformAdapter):
         # The cursor moves even when the trigger was all that arrived: it is in
         # the session, and a cursor left behind it would hand the next turn the
         # previous question a second time.
-        self._remember_thread_cursor(
-            session_key,
-            thread_root_event_id,
-            self._seeded_through(history, unseen, current_message_id),
-            delivered=unseen,
+        self._advance_thread_cursor(
+            session_key, thread_root_event_id, history, unseen, current_message_id
         )
         return self._thread_context_block(unseen)
 
@@ -1497,7 +1518,7 @@ class ChattoAdapter(BasePlatformAdapter):
         self,
         session_key: str,
         thread_root_event_id: str,
-        cursor: str | None,
+        cursor: str,
         *,
         delivered: list[Message],
     ) -> None:
@@ -1527,7 +1548,7 @@ class ChattoAdapter(BasePlatformAdapter):
             self._persist_thread_cursor(session_key, thread_root_event_id, cursor)
 
     def _persist_thread_cursor(
-        self, session_key: str, thread_root_event_id: str, cursor: str | None
+        self, session_key: str, thread_root_event_id: str, cursor: str
     ) -> None:
         """Mirror the cursor onto the session, remembering a refusal.
 
@@ -1587,13 +1608,10 @@ class ChattoAdapter(BasePlatformAdapter):
         return self.me is not None and message.actor_id == self.me.id
 
     def _record_thread_seeded(
-        self, thread_root_event_id: str, last_delivered_id: str | None
+        self, thread_root_event_id: str, last_delivered_id: str
     ) -> None:
         """Note that this thread's session holds its history up to that message.
 
-        ``None`` records a frontier that cannot be established, which is not the
-        same as "not seeded": the thread stays silent rather than guessing.
-
         Re-inserting keeps the map insertion-ordered, which is what the cap below
         evicts from: the least recently touched thread.
         """
@@ -1786,19 +1804,16 @@ class ChattoAdapter(BasePlatformAdapter):
     def _session_key_for(self, source) -> str:
         """The gateway's own session key for this source.
 
-        Built with exactly the inputs ``handle_message`` uses, so lookups in
-        ``_processing`` and calls to ``cancel_session_processing`` hit the
-        same session the gateway is running.
+        Delegates to the base class rather than calling ``build_session_key``
+        itself. A hand-built key has to be right about two things this one was
+        not: the routing identity has to be pinned first (``_canonicalize``), and
+        the key has to carry the serving profile's namespace. Without the second,
+        a multiplexed gateway keys every bot into ``agent:main:`` and the derived
+        key names a session nobody is running — which made
+        ``_thread_session_exists`` answer False for a live thread session, and
+        would make ``cancel_session_processing`` a no-op on an edited message.
         """
-        return build_session_key(
-            source,
-            group_sessions_per_user=self.config.extra.get(
-                "group_sessions_per_user", True
-            ),
-            thread_sessions_per_user=self.config.extra.get(
-                "thread_sessions_per_user", False
-            ),
-        )
+        return self._source_session_key(source)
 
     async def _dispatch_message_edited(self, payload: Any) -> None:
         """Route an inbound edit according to the edit-dispatch contract.
@@ -4052,9 +4067,7 @@ def _pconfig_or_empty(pconfig: PlatformConfig | None) -> PlatformConfig:
     return GatewayPlatformConfig(enabled=True, extra={})
 
 
-def _room_is_writable(
-    cfg: ChattoConfiguration | None, room: Room, room_id: str
-) -> bool:
+def _room_is_writable(cfg: ChattoConfiguration | None, room: Room) -> bool:
     """Whether the bot may post into this room on its own initiative.
 
     Delegates to ``_room_answers_in`` so the outbound gate is the *same*
@@ -4062,21 +4075,27 @@ def _room_is_writable(
     be postable, and two copies of that rule would drift apart until the send
     tool became a way around the read-only promise.
     """
-    return _room_answers_in(cfg, room_id, room.kind)
+    return _room_answers_in(cfg, room.id, room.kind)
 
 
 def _not_joined_refusal(room: Room) -> _ToolRefusal:
     """The refusal for a room the bot can see but has not joined.
 
-    The two room kinds need different advice, and giving a channel the DM
-    remedy sends the user down a path that cannot work: a direct message to the
-    bot is not how you get it into a channel. For a channel the paths are an
-    invite in Chatto, or the ``/join`` the bot answers over DM.
+    The room kinds need different advice, and giving a channel the DM remedy
+    sends the user down a path that cannot work: a direct message to the bot is
+    not how you get it into a channel. For a channel the paths are an invite in
+    Chatto, or the ``/join`` the bot answers over DM.
+
+    The test is on ``DM`` rather than on ``CHANNEL`` on purpose: a server that
+    never sets ``kind`` reports ``UNSPECIFIED``, and this plugin counts an unknown
+    kind as a channel everywhere else (``_room_answers_in``, ``_refresh_rooms``).
+    Keying on ``CHANNEL`` would hand exactly the advice this function exists to
+    avoid to every self-hosted server on an older or looser build.
     """
     remedy = (
-        f"Invite the bot account in Chatto, or DM it `/join {room.id}`"
-        if room.kind == RoomKind.CHANNEL
-        else "Have someone start a DM with the bot account"
+        "Have someone start a DM with the bot account"
+        if room.kind == RoomKind.DM
+        else f"Invite the bot account in Chatto, or DM it `/join {room.id}`"
     )
     return _refuse(
         f"Room '{room.name or room.id}' ({room.id}) is not one the bot has "
@@ -4398,7 +4417,7 @@ async def _send_room(args: dict) -> str:
 
         # Posting into a room the plugin treats as read-only would break a
         # promise the README makes about unlisted rooms staying silent.
-        if not _room_is_writable(cfg, room, room.id):
+        if not _room_is_writable(cfg, room):
             raise _read_only_room_refusal(room.name or room.id)
 
         if create_thread and room.kind == RoomKind.DM:

+ 119 - 14
test_adapter.py

@@ -2098,6 +2098,49 @@ def _thread_event(message):
     )
 
 
+class TestSessionKey:
+    """The adapter derives session keys to look up sessions the gateway is
+    running: the thread-seeding cursor, the in-flight edit target.
+
+    A derived key has to be byte-identical to the gateway's, or every lookup
+    misses. The base class says so itself: ingress runs before the runner stamps
+    ``source.profile``, so a key built without that namespace puts every bot of a
+    multiplexed gateway into ``agent:main:`` and names a session nobody runs.
+    """
+
+    def _source(self, *, chat_id="room-1", thread_id="thread-9"):
+        source = MagicMock()
+        source.chat_type = "channel"
+        source.chat_id = chat_id
+        source.thread_id = thread_id
+        source.prospective_thread_id = None
+        source.platform = Platform(ChattoConstants.PLATFORM_NAME)
+        source.user_id = "user-1"
+        source.profile = None
+        return source
+
+    def test_the_key_carries_the_serving_profile(self):
+        adapter = _make_adapter()
+        adapter._owner_profile = "support"
+        source = self._source()
+
+        key = adapter._session_key_for(source)
+
+        assert key == "agent:support:chatto-platform:channel:room-1:thread-9", (
+            f"derived key is {key!r}: without the profile namespace it names a "
+            "different session than the gateway runs for a secondary profile"
+        )
+
+    def test_the_key_is_the_gateways_own(self):
+        """Not merely namespaced, but built the way the gateway builds it —
+        otherwise the namespace is right and everything else is still wrong."""
+        adapter = _make_adapter()
+        adapter._owner_profile = "support"
+        source = self._source()
+
+        assert adapter._session_key_for(source) == adapter._source_session_key(source)
+
+
 class _FakeSessionStore:
     """An in-memory session store with the real store's shape.
 
@@ -2841,6 +2884,61 @@ class TestThreadContext:
             "in the context, while repeating a turn the session already holds"
         )
 
+    async def test_an_empty_fetch_records_no_cursor_rather_than_an_unfindable_one(
+        self,
+    ):
+        """An empty window names no position.
+
+        The trigger is not a usable stand-in for one: recording it leaves a cursor
+        the next fetch cannot find, which then reads as a thread that outgrew its
+        cursor and mutes it for good. Leaving the thread unrecorded means the next
+        turn tries again from the top.
+        """
+        store = _FakeSessionStore()
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter.set_session_store(store)
+        # The thread fetch is empty and the root cannot be fetched either, so
+        # the window names no message at all. get_message answers per event: the
+        # trigger resolves (the turn dispatches normally), the root does not.
+        trigger = _make_message(
+            body="@hermes_bot anyone there?",
+            message_id="msg-1",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        adapter._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[])
+        )
+        adapter._chatto_client.get_message = AsyncMock(
+            side_effect=lambda room_id, event_id: (
+                trigger if event_id == "msg-1" else None
+            )
+        )
+        payload = _make_posted_payload(room_id="room-1", message_event_id="msg-1")
+        payload.thread_root_event_id = "t1"
+
+        await adapter._dispatch_message_posted(payload, payload.message_event_id)
+
+        assert adapter.handle_message.await_count == 1, (
+            "the turn did not dispatch at all, so this proves nothing about the cursor"
+        )
+        assert "t1" not in adapter._seeded_threads, (
+            "a cursor the fetched window cannot name was recorded; the next turn "
+            "would look for it, fail to find it, and take the thread silent"
+        )
+        # And the turn after it recovers instead of staying silent.
+        human = _make_message(
+            body="@hermes_bot still there?",
+            message_id="msg-2",
+            actor_id="user-1",
+            created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
+        )
+        adapter._chatto_client.get_thread_events = AsyncMock(
+            return_value=TimelinePage(events=[_thread_event(human)])
+        )
+        await self._dispatch(adapter, human.body, message_id="msg-2", thread_root="t1")
+        assert adapter._seeded_threads["t1"] == "msg-2"
+
     async def test_the_replying_message_is_not_seeded_into_its_own_context(self):
         """It is already the message text; repeating it in the block would tell
         the agent the user said it twice."""
@@ -4359,23 +4457,30 @@ class TestPluginToolHandlers:
         assert "has joined" in result["error"]
         client.post_message.assert_not_awaited()
 
-    async def test_the_unjoined_refusal_offers_a_remedy_that_works_for_a_channel(self):
+    async def test_the_unjoined_refusal_offers_a_remedy_that_works_for_a_channel(
+        self,
+    ):
         """A DM to the bot cannot get it into a channel — that advice would send
         the user down a path that does not exist. The remedy for a channel is an
         invite, or the /join the bot answers over DM."""
-        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
-        client = self._stub_client()
-        client.get_room = AsyncMock(
-            return_value=_make_room_state(room, is_member=False)
-        )
-        result, _, _ = await self._run(
-            _handle_chatto_send_room,
-            {"room_id": "room-1", "message": "hi"},
-            client=client,
-            optional_mention_rooms="room-1",
-        )
-        assert result["success"] is False
-        assert "/join room-1" in result["error"]
+        for kind in (RoomKind.CHANNEL, RoomKind.UNSPECIFIED):
+            room = _make_room("room-1", "Ops", kind)
+            client = self._stub_client()
+            client.get_room = AsyncMock(
+                return_value=_make_room_state(room, is_member=False)
+            )
+            result, _, _ = await self._run(
+                _handle_chatto_send_room,
+                {"room_id": "room-1", "message": "hi"},
+                client=client,
+                optional_mention_rooms="room-1",
+            )
+            assert result["success"] is False
+            assert "/join room-1" in result["error"], (
+                f"a {kind} room was told to start a DM with the bot: this plugin "
+                "counts an unknown kind as a channel everywhere else, so the "
+                "refusal has to as well"
+            )
 
     async def test_send_room_refuses_a_thread_root_from_another_room(self):
         """A stale or foreign root must not silently attach the message to a

+ 1 - 1
vendor/common/bin/httpx

@@ -1,4 +1,4 @@
-#!/home/paul/.paseo/worktrees/02u7s3t4/clingy-porcupine/.venv/bin/python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 import sys
 from httpx import main

+ 1 - 1
vendor/common/bin/idna

@@ -1,4 +1,4 @@
-#!/home/paul/.paseo/worktrees/02u7s3t4/clingy-porcupine/.venv/bin/python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 import sys
 from idna.cli import main

+ 1 - 1
vendor/platform/linux-aarch64/bin/websockets

@@ -1,4 +1,4 @@
-#!/home/paul/.paseo/worktrees/02u7s3t4/clingy-porcupine/.venv/bin/python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 import sys
 from websockets.cli import main

+ 1 - 1
vendor/platform/linux-x86_64/bin/websockets

@@ -1,4 +1,4 @@
-#!/home/paul/.paseo/worktrees/02u7s3t4/clingy-porcupine/.venv/bin/python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 import sys
 from websockets.cli import main

+ 1 - 1
vendor/platform/macos-arm64/bin/websockets

@@ -1,4 +1,4 @@
-#!/home/paul/.paseo/worktrees/02u7s3t4/clingy-porcupine/.venv/bin/python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 import sys
 from websockets.cli import main

+ 10 - 0
vendor_chattolib.sh

@@ -106,6 +106,16 @@ touch "$VENDOR_DIR/__init__.py"
 
 find "$VENDOR_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2> /dev/null || true
 
+# Die Konsolenskripte der Pakete (httpx, idna, websockets) bekommen von uv eine
+# Shebang mit dem absoluten Pfad des venv, aus dem gebaut wurde. Die landet so
+# im ausgelieferten Baum und macht ihn unreproduzierbar: derselbe Refresh auf
+# einem anderen Rechner ändert fünf Dateien, ohne dass sich am Code etwas
+# geändert hätte. Sie werden nie ausgeführt (der Baum kommt über sys.path in
+# sys.path-Einträge, nicht als Kommandos), also auf den portablen Shebang
+# normalisieren.
+echo "🔧 Normalisiere Shebangs der Konsolenskripte..."
+find "$VENDOR_DIR" -type f -path "*/bin/*" -exec sed -i '1s|^#!.*|#!/usr/bin/env python3|' {} + 2> /dev/null || true
+
 echo "🔍 Verifiziere Import für die aktuelle Plattform..."
 if python3 -c "
 import sys