Skip to content

fix(gateway): keep busy follow-ups from being dropped, merged across senders or reordered - #129126

Draft
iainlane wants to merge 285 commits into
NousResearch:mainfrom
iainlane:fix/gateway-queue-debounce-handoff
Draft

iainlane wants to merge 285 commits into
NousResearch:mainfrom
iainlane:fix/gateway-queue-debounce-handoff

Conversation

@iainlane

@iainlane iainlane commented Sep 30, 2026 •

Copy link
Copy Markdown

Stack

All PRs below target main. Nesting records merge order for the composed prerequisites and their dependents. A dependent must follow every prerequisite shown. Each linked description lists the other dependencies.

Change

Queue mode preserves each sender’s input, reply target and control context in arrival order. One merge predicate checks sender identity, the complete reply context, gateway control permissions and plugin metadata. Debounced bursts use FIFO admission; independent buffered events remain separate when an adapter has no runner. Deferred dispatch returns to the front before retrying.

Only consecutive compatible photos and captions coalesce. Pending voice and document input remain independent, and an orphaned FIFO tail is promoted before new input. The third-sender repair preserves AhmetArif0’s authorship. The shared batching, delayed Telegram delivery, contributor IDs and padded-media handling from the prerequisite remain intact.

Photo, startup and grace assertions compare complete events, including contributor identities. The cancelled concurrent-processor fixture closes its retained handler coroutine after processor tasks complete.

History and review

This revision has 13 own contributions above the actual composed prerequisite. Every complete author and committer header, timestamp, timezone, message and trailer is preserved. History has one parent per commit. Defects found during integration are folded into the commits that introduced the affected code; later contributions retain their own credit. The current revision contains 285 contributions: the actual published prerequisite retains 272 signed records, followed by these 13 original contributions. The captured upstream base is 3637c512fc3211bdccb61b7140d09ac1018508ba.

Review the own contribution. The comparison against main also includes the composed prerequisites.

Earlier validation

The composed queue has 279 distinct passing cases across 14 complete files: 250 from the unchanged files and 29 from the corrected photo file. The batching subset contains 106 passing cases. Four reaction-follow-up and deferred-admission files pass all 93 cases after the source-builder fixture repair. Three reaction and read-policy files pass all 45 cases. These are source-aware outcomes from separate canonical runs, with one worker, zero retries and strict runtime and pytest warnings. Earlier failed wrappers remain recorded; this is not a single fresh 417-case run.

Scoped Ruff checking of all 85 changed Python paths passes. The before/after type comparison records 1,562 candidate diagnostics: 1,560 match complete statements from the actual published suppliers or current media base, and two have explicit proofs for unchanged inherited contracts. It reports zero unqualified new findings. The overall type and formatter checks remain non-green because of inherited findings. No new native Matrix execution or hosted CI pass is claimed.

Current compatibility and validation

This successor uses the independently published #129160 prerequisite at 627fb2227c402f619e699eb2afd9214dd6e9180d. Seven complete cumulative snapshots compose automatically. The six later snapshots require one type-checking import block to retain both the current prerequisite comment and the original BasePlatformAdapter import. Only the original eighth contribution has an appendix explaining that join. Every original author and committer header, timestamp, timezone, complete message and contribution order remains intact. None of these 13 records is empty.

The ten complete affected busy-followup, FIFO, batching, photo and Telegram admission files pass all 280 assertions on both the original published head and the current candidate. Both runs use the canonical runner with all four resource and thread warnings treated as errors, zero retries and no selected subsets. Complete shutdown output has zero resource warnings. All 9,229 original and 9,282 candidate tracked Python, configuration, lock and runner inputs match their Git objects before and after execution. A sandbox control stopped before any test child because the runner could not create its isolated scratch directory; that control is retained separately and contributes no passing cases.

Full typing covers 16 complete affected and caller files. The original, actual prerequisite and candidate have 281, 285 and 286 diagnostics, respectively. Every complete candidate lexical and statement operand, including its occurrence count and relative span, matches the union of the original and prerequisite operands. There are zero introduced findings. Scoped Ruff checking passes; the same 14 formatter paths remain inherited. The overall typing and formatting checks remain non-green. The exact candidate merges cleanly with both fixed upstream references, including captured 3637c512fc3211bdccb61b7140d09ac1018508ba, and its own diff passes the whitespace check. No fresh native service execution or hosted workflow pass is claimed.

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 30, 2026
Matrix permalinks change with each triggering event. Rendering one in
the pinned session prompt made the prompt stale or changed its bytes
every turn, which breaks conversation prompt caching.

Put the link in the current model-facing user message and remove it from
the durable transcript, including queued turns. Respect effective PII
redaction when adding the note. Percent-encode room IDs, event IDs and
the via server so delimiters cannot change the matrix.to URL structure.

Preserve the Matrix and Discord source-note handling when integrating
the upstream prefix removal change. Use removeprefix after selecting
the platform-specific note, so each transport removes only its own
generated prefix.
The live Matrix exchange now checks the model request for a permalink to
the event sent by the independent client. It also verifies that the link
is absent from the system prompt, which must remain stable across turns.
Record the exchange duration separately from container setup time.
Room version 12 IDs have no server component. A permalink built only
from the room ID therefore omitted the server hint, which can prevent a
client outside the room from locating it.

Use the joined bot account's server when the room ID has no domain. The
builder still leaves via absent when neither server is known.
A room ID can identify a server that no longer participates in the
room. Its server may also include a port or IPv6 address, which the
last-colon parser truncated to an invalid via hint.

Use the joined bot account's server for inbound source links. When only
a room ID is available, parse its full server after the first colon.
The same parse also corrects the room's session scope.
In a thread, every turn's source link pointed at the thread root. The
root was chosen as a stable anchor while the link lived in the pinned
session prompt, which is per thread. The link now goes on each user
turn, so the reason no longer applies, and the model never received the
ID of the message that triggered the turn. A request to cite "this
message" produced a link to the root, which may be another user's
message.

Build the per-turn link from the triggering event in all cases. A
matrix.to link to an event inside a thread opens that thread in current
clients, and the session prompt already gives the thread root.
A room-ID permalink needs via servers so that a reader outside the room
can join through one of them. The link carried only the bot's server.
When that server was unreachable from the reader's server, the link
failed.

Choose up to three servers as the Matrix specification recommends for
room-ID permalinks. The first is the server of the highest-power joined
member at power level 50 or above; room version 12 creators count as
the highest power. The rest are the servers with the most joined
members, and IP literals are never chosen. The adapter reads members,
power levels and the create event from the mautrix state store, which
sync keeps current, so building a link makes no request. When the
store has no joined members for the room, use the bot's server and then
the room ID's server.

Server ACLs are not applied, because the state store does not keep
m.room.server_acl. Move the builder and the selection into a sibling
module, and emit one via parameter per server.
The session prompt stopped rendering the Matrix source link when the
link moved to the current user turn. Two prompt tests still asserted
that the prompt omitted the link when it was unset or redacted. No code
path produces that output any more, so neither test could fail because
of this feature. The prompt-stability test covers the remaining
contract, and the per-turn tests cover the note and its PII
suppression.

Restore the comment on the per-turn notes. strip_inbound_source_note
removes a note only when it is the first thing in the message, so a
later prefix added outside the notes would leave the note in the
persisted transcript.
Choosing source-link via servers made every inbound message await
get_power_levels and get_create on the client's state store. The
project-context isolation and voice tests gave the adapter a MagicMock
store with only get_members awaitable, so all six inbound tests in
those files failed with "'MagicMock' object can't be awaited".

Production always uses mautrix's MemoryStateStore. Give both fakes a
real MemoryStateStore in which the bot and the sender have joined the
test rooms. The real store implements every state query, so these tests
keep working when the adapter starts reading other room state. The
stores are filled with MemoryStateStore.deserialize because the helpers
are synchronous and some are called inside a running event loop.
The effective-state parent added a description to the matrix_read limit
argument: "Maximum number of events to read". In this layer the same
argument also bounds the members kind, which returns members rather
than events, and the pins kind. Say that the limit applies to events or
members so the model can tell that it bounds member lists too.
The merged matrix_read description said that events are listed oldest
first and that `errors` lists events that could not be decrypted. Both
sentences came from the history reads. A pins read returns events in
the order of the room's m.room.pinned_events list, and its errors also
cover pins that could not be fetched, are not visible messages, came
back as another event, or whose edit was redacted. A model that read
the description could tell a user that a deleted pin was an encrypted
message.

Limit the ordering sentence to room and thread reads, say that pins
follow the room's pinned order, and say that `errors` lists events that
could not be read or decrypted, with the reason. History reads already
returned non-decryption errors such as "event content changed", so the
wider wording is also accurate for them.
iainlane and others added 11 commits October 1, 2026 12:23
The fake model server for the live gateway listened on 0.0.0.0, so any
machine on the developer's network could send it requests while a test
ran. A stray request would consume the scripted reply or appear in
main_requests(), and the macOS firewall can ask whether to accept
incoming connections.

The gateway container reaches the fake as host.docker.internal. With a
daemon on this host, as on Linux, the test network's bridge gateway is
an address of this host, so the fake now listens on that address and
host.docker.internal resolves to it. A daemon in a VM, such as Docker
Desktop or OrbStack, does not expose that address on the host, and its
host-gateway forwards to the host's loopback interface, so the fake
listens on 127.0.0.1 there. The fixture chooses by trying to bind the
gateway address.

In OrbStack's Linux VM, a server bound to a network's gateway address
answered a container on that network through the host.docker.internal
mapping. On macOS the bind fails with EADDRNOTAVAIL, and the plain
exchange passes over 127.0.0.1.
The gateway fixture yielded as soon as the gateway logged
"Matrix: joined <room>". The Matrix adapter joins the invited room
shortly after it connects, while the gateway is still in startup
restore. During startup restore the gateway queues inbound messages and
replays them when restore finishes, so a test's first message could
reach the gateway through that replay. A scratch live test that sent
its message as soon as the fixture yielded was queued in two of three
runs.

The replay can reach the adapter while the adapter's original dispatch
of the same event still holds the session guard: for Matrix, the guard
stays held during the typing-stop request after the handler has queued
the event. The adapter then handles the replay as a second message
while the session is busy, and the bot's first reply is the "Interrupting
current task" notice. One native run of the oversized-media test failed
this way.

The gateway logs "Press Ctrl+C to stop" after startup restore has
finished. The fixture now waits for that line as well as for the join,
so a test's first message is dispatched directly. With this check the
same scratch test was dispatched directly in three of three runs.

When a test that uses the fixture fails, a report hook adds the last 200
lines of the gateway log to the failure report. The fixture deletes the
gateway home at teardown, so the log of a failed run was lost before.
LiveGateway now includes the home so that the hook can find the log.
_host_user() compared hermes_platform's os_family() with "windows", but
os_family() returns sys.platform, which is "win32" on Windows. The
Windows branch therefore never ran, and on a Windows host with Docker
the gateway fixture called os.getuid(), which Windows does not have.
Compare with "win32", as the conftest already does with "darwin".

The matrix-live workflow's path filter omitted code that the gateway
image copies and loads at run time: providers/, which agent/ imports;
plugins/ outside the Matrix adapter (the plugin loader, model
providers, memory and context engines); locales/, which agent/i18n.py
reads; tui_gateway/, which gateway/ imports; and skills/, which the
gateway syncs into its home at start-up. A change to any of them could
break the live tests without running the workflow. Add all five.

Rename HERMES_TEST_MATRIX_IMAGE to HERMES_TEST_MATRIX_GATEWAY_IMAGE.
The suite uses Synapse, Ryuk and gateway images, and "Matrix image"
reads most naturally as the homeserver.
When an explicit reply (m.in_reply_to) had a body starting with "> ",
the adapter treated every leading "> " line as the legacy reply
fallback. It removed those lines from the message text and reported
them as the text of the replied-to message. Matrix 1.13 (MSC2781)
removed reply fallbacks, and current clients such as Element X send a
reply exactly as the user typed it. A user who began a reply with a
quotation therefore lost the quotation, and the agent saw it as the
message being replied to. Element also sets m.in_reply_to, with
is_falling_back, on ordinary thread messages, so a quote at the start
of a thread message was lost in the same way.

Treat the leading quote as a fallback only when the event has one: the
HTML body starts with an <mx-reply> element, or the plain quote block
starts with the quoted sender's pill ("> <@user:server>", or
"> * <@user:server>" for an emote) and ends with a blank line. Older
clients generate both forms. The reply-context parse and the mention
strip under require_mention use the same check, so a quote without a
fallback stays in the text with any bot mention removed, as it does in
a message that is not a reply.

The plain-body check is deliberately narrower than the spec. The
spec's stripping note still says to remove every leading "> " line of
a reply without condition, and that rule cannot tell a fallback from a
quote now that clients no longer send fallbacks. Ruma's
remove_plain_reply_fallback, which matrix-rust-sdk and Element X use,
narrows it in the same way: it strips only a body that starts with
"> <" or "> * <". The HTML check follows the spec's rule for
formatted_body, which applies only when it starts with <mx-reply>.

A hand-typed quote that starts with a pill and ends with a blank line
has the same shape as a plain fallback and is still stripped.
A reply to an m.emote message has a fallback that starts with
"> * <@user:server>". The regex that parses the fallback's pill
expected "<" straight after "> ", so it did not match this form. The
reply lost its author (reply_to_author_id and reply_to_author_name were
None), and the quoted text kept "* <@user:server>" at its start.

Accept the optional "* " before the pill. The fallback detector
already recognised the emote form with a second regex that described
the same line, so both now use the one pattern.
Rapid Telegram messages can be combined before dispatch, but one event
has only one set of reply fields. When two messages quote different
messages, batching their text keeps only the first quote. The busy-text
debounce also overwrote the quote ID with a later inbound message ID.

Split ingress batches when their reply contexts differ. Dispatch the
earlier event through Telegram's tracked inbound redispatch queue so a
concurrent disconnect preserves the event for reconnect. A metadata-free
continuation after a near-limit chunk inherits the first chunk's quote,
which Telegram clients can omit from later parts of a split message.

Compare reply context during busy-text debounce as well, and keep the
quote ID distinct from the inbound message that an answer should target.

(cherry picked from commit 348bef0)
Messages that arrive while a turn is running can be merged into one
pending event. merge_pending_message_event joins a follow-up into the
pending slot, and with busy_text_mode queue, _queue_text_debounce first
joins a burst of text into one buffered event. A merged event has one
set of reply_to_* fields, and neither path copied them from the
incoming message. If a user sent "one" and then replied to an earlier
message with "two", the next turn had no reply pointer, so the agent
could not tell which message "two" referred to. The debounce also
dropped a quoted image from the incoming media_urls.

The previous commit, from NousResearch#65526, stops the debounce from merging
messages whose reply contexts differ in any way. That keeps two reply
targets apart, but it also splits a plain message from a reply that
follows it, and merging those two would lose nothing. The debounce has
two places where a queued turn can wait: the pending slot and its own
buffer. Every split uses one of them, and a third message that fits
neither was dropped.

Copy the reply context instead where that loses nothing.
MessageEvent.absorb_reply_context gives an event without a reply
context the incoming one. Both branches of merge_pending_message_event
and the debounce buffer call it, and the buffer now also takes the
incoming media, as _enqueue_text_event does.
MessageEvent.reply_context_conflicts is true only when both events
reply to a message and their reply contexts differ. The debounce keeps
those messages as separate turns, as it already does for messages from
different senders. If the pending slot and the buffer both reply to
other messages, a third message from the same sender joins the buffer.
The buffer keeps its own reply context, so the third message's text
reaches the agent but its reply context does not.

merge_pending_message_event has to store every event in the single
pending slot. When two reply contexts conflict there, the event keeps
the first one, which is the rule that NousResearch#62088 applies to Matrix merges.

NousResearch#66027 made the newest message's reply context replace the older one.
That drops the older reply target, and it clears a quote when the
newest message has none, so this change does not adopt it. Its
coverage of both merge_pending_message_event branches and of the
debounce is kept.

Co-authored-by: Hilo-Hilo <57138830+Hilo-Hilo@users.noreply.github.com>
Busy-text debounce copied incoming attachment paths and MIME types but
omitted media_text_inlined. A false flag means the attachment's text is
not in the message. Losing that flag changes it to the legacy inlined
state when the gateway builds document context.

Pad legacy flag lists to the existing attachment count before appending
new media. Append incoming flags and pad any legacy incoming entries as
well, so each merged attachment keeps its own text-inlining contract.

Consolidate the reply and media checks into two parameterized behavior
contracts that compare complete events. Telegram dispatch checks await
tracked tasks with zero batching delays instead of assuming that a
short wall-clock sleep is enough under runner load.

Three attachment cases reproduced missing flags before the repair and
pass with the aligned lists. The contracts also retain the documented
single-slot fallback for adapters without separate-turn storage.
Matrix thread continuations include an m.in_reply_to pointer for
clients that do not render threads. The adapter treated that fallback
pointer as a user reply, so it attributed the continuation to the
thread root.

Parse relations into explicit reply, thread fallback and edit fields.
Use the parsed reply target for inbound context and the thread root
for session routing. Malformed relation content now yields an empty
relation instead of failing message handling.
A Matrix reply may omit the legacy inline quote. The gateway then knew
only the target event ID and could not tell the agent what message the
user referenced or who wrote it.

Resolve reply targets from a bounded cache or the room event API. Apply
edits to cached text and keep redactions as empty cache entries so a
later reply cannot revive deleted content. Decrypt fetched events when
the room uses E2EE, and label media bodies so filenames are not treated
as spoken text.

Attach the target author's name and authorisation verdict to the inbound
event. The gateway marks fetched content from an unauthorised author as
unverified background and renders Matrix quotes on one line. The adapter
never fetches a remote target for a sender it rejects.

Co-authored-by: Wessel <2385844+weebl2000@users.noreply.github.com>
Co-authored-by: uuedxz <249543386+uuedxz@users.noreply.github.com>
Co-authored-by: Simon Sharp <234941529+simonamsharp@users.noreply.github.com>
An explicit Matrix reply could identify its parent but gave the agent
only a filename when that parent was an image. Threaded messages in a
fresh gateway session lacked the earlier messages that explain them.

Resolve reply targets through a bounded room-event cache, decrypting
when needed. Download quoted images through the existing Matrix media
path and attach their cached files to the triggering message. Read the
thread root and recent relations for a new threaded session, then
attribute and bound the prior messages before adding them to the user
turn. Add external thread text after local @ reference expansion so it
cannot trigger a file read.

Use mautrix.api.Method.GET for the relations request because mautrix
0.21.1 treats a string method as a non-GET request and adds a body.
Cache successful outbound messages by event ID for immediate replies,
and preserve reply context when a later text chunk joins a batch.
Mark inline fallback quotes as unverified because their author pills
come from the sender and can claim the bot's identity.

Expose a config.yaml limit for thread backfill and preserve room state
context when both appear.

Co-authored-by: Wessel <2385844+weebl2000@users.noreply.github.com>
iainlane and others added 13 commits October 4, 2026 16:11
The reaction follow-up layer now includes the complete action and
effective-event prerequisites. Text intake and event construction pass
reply_parent explicitly, while reaction tools require joined room state
and an authorised requester. The old callbacks rejected the keyword,
and the queued identity test failed before exercising context restoration.

Accept the reply context in those callbacks and configure the queued
fixture with joined membership and both intended requesters. Remove the
duplicated identity argument introduced when replaying the metadata test.
The admission, cursor, FIFO and A/B/A identity assertions remain intact.

The adjacent background-task control also created an unawaited coroutine
because its AsyncMock treated toolsets_for_source as asynchronous. Give
that synchronous method an explicit MagicMock, like the existing media
extractors. The warning reproduces on the original published branch under
strict coroutine checks; the corrected control and the composed fixture
checks pass all 103 cases.
Move reaction watch registration, delivery tracking and admission into a
topical mixin before changing their interaction with effective reply
contexts. The adapter exceeds the project file-size threshold; the mixin
keeps the existing behaviour and gateway bindings together.

The plugin loader imports each adapter under a profile-owned namespace.
A class imported through the source path has a different identity, so
check the shared Matrix mixin when registering a delivery. A real plugin
loader invariant verifies watch registration across A/B/A profile homes.
A reaction to the last chunk of a split final reply loses the beginning
of the logical answer when gateway preparation refreshes the concrete
reply event. After an encrypted restart, the follow-up therefore quotes
the reacted chunk instead of the saved final excerpt.

Record the delivered prefix in delivery order, with digests of the final
visible bodies. Persist those bounded dependencies with the watch and
propagate their retained effective states through the existing turn
snapshot. Reuse the exact saved excerpt while the visible prefix remains
unchanged. Current edits replace the quotation, and redactions or content
errors prevent stale text from entering the model input. The reacted
target is checked separately even when it follows the quoted prefix.

The regressions reproduce the restart loss and stale prefix quotation
on the previous head. The strict seven-file gate passes 172 tests with
the actual Matrix SDK, four workers and retries disabled.
A cancelled reaction handler deletes its single-use watch before the
gateway admits the follow-up. Cancellation during the busy-session lookup
leaves no queued input and no acknowledged sync event, so replaying the
same reaction cannot resume the conversation.

Reserve the watch for that reaction instead of deleting it immediately.
Release the reservation when admission fails before the gateway consumes
the event. Delete it after admission, including cancellation while a
queued event's durable acknowledgement finishes. Persist the reaction ID
so a restart can retry the same event without allowing a different
reaction to consume the watch. Claim tokens make completion conditional
on the reservation's current owner.

The regression cancels the real busy admission path after its session
lookup, then retries through the native sync dispatcher. It also checks
reservation replacement after reopening the store and cancellation after
FIFO admission while the durable write is pending. The strict eight-file
gate passes 175 tests with the actual Matrix SDK and no retries.

The reaction-admission tests created SessionStores but finished with
their shared SQLite handles still open. All three cancellation cases
passed, followed by six unclosed database warnings at interpreter exit.

Close each test-owned store after cancellation and task gathering in the
existing finally block. The cancellation and exactly-once assertions stay
unchanged. The three complete child cases now exit without unclosed
database warnings.
The reaction follow-up contract registered the gateway authorisation
callback, then expected the adapter's direct user check to deny every
caller. Matrix now delegates that check to the live callback so pairing
revocations take effect immediately. Global, platform and pairing grants
therefore fail the old assertion before the reaction path runs.

Expect the direct check to match the configured grant. Keep the actual
follow-up admission assertion and the denied case, which retains its
reaction watch without dispatching a turn.

Historical attribution for original source a4d3478
(audit record 314; historical approval position 315). Public PR NousResearch#126281
already includes this assertion correction at its captured head
1369cea. This empty commit records the
missing original contribution credit. The source tree is unchanged.
Reaction sends retain every delivered event ID, and final edits update
effective content after successful delivery. Those outcomes can still
report success when the adapter exceeds Matrix's byte budget.

Check the complete receipt result for multibyte chunks. Check that a
final edit fits the content budget without changing its complete
effective content, finalization flag or successful cache update.

On the unchanged public source, these contracts reproduce a 16-byte
chunk under a 12-byte limit and a 59,494-byte formatted edit under a
45,000-byte content limit. Both pass in the combined upstream and
reaction implementation.
Restoring every accepted follow-up separately preserves FIFO input, but
it also separates a parked photo from the compatible goal continuation
that should consume it. The photo then completes with a separate turn
while the continuation remains pending.

At the recursion limit, merge compatible photo and text input through
the existing recorded merger and attach its processing lifecycle to the
consuming event. Conflicting replies, security contexts and execution
owners still use accepted FIFO restoration. That path retains every
previously admitted input without repeating capacity admission.

All 93 original lifecycle routes now pass, including merged photos and
orphan cancellation. The queued profile and reply contracts use the
actual TurnContext model, so the fixture includes the receipt state
without replacing its identity or profile assertions.

Source-commit: a51649262e4290a32bdd2f64e29d704b7425747d
Integration-note: A fresh processing task previously promoted its reservation
to accepted queued input before the runner decided whether to merge it.
Preserve the existing accepted bit when a queued reservation is reused,
but create fresh processing reservations with accepted=False. This keeps
fresh photos with compatible pending input and retains accepted FIFO
restoration for previously queued records. The actual processing contract
reproduces the fresh-input failure and retains the accepted-input control.
The qualified 111-case gate excludes only the two independently diagnosed
shutdown observer cases; the complete gate follows their owner correction.
The shared batching implementation is in the platform facade, which is
already about 4,700 lines long. A new reply-boundary hook would add more
behaviour to that facade.

Move the existing batching methods to BaseTextBatchingMixin before
changing their behaviour. Keep every method body unchanged and type the
mixin receiver. Update the gateway instructions to specify the new
implementation path.

The fixed upstream annotation cleanup overlaps the extracted text-batch
methods. Keep the complete methods in their topical module and apply
the same unquoted event annotations there. Their bodies are unchanged.
Gate finding BR8 r1 2c [Medium]: the new reply-context conflict rule made
the debounce's pending-slot merge reachable for one sender. one->X,
two->Y, three->X left pending='one\nthree' and buffer='two', so the agent
saw "three" before "two". Same-sender text now stays in the debounce
buffer; only a different sender may merge into the pending slot.
The shared text batcher appends text and media without absorbing reply
context. A plain message followed by a quoted message loses the quote,
and two different reply targets become one turn with the first target.

Use the existing boundary hook to dispatch incompatible reply contexts
as separate batches. Absorb the complete reply context when one event
has no quote, and pad attachment flags before extending media URLs.
Keep the shared dispatch cancellation policy for completed batches.

WeCom now promotes the current batch after enqueueing. Looking up the
previous batch would change the attachment event that a reply boundary
has already dispatched.

Keep the upstream unquoted event annotations and format the extracted
module consistently. Method bodies and assertions are unchanged.
The Telegram adapter contains the teardown of text, photo and album
buffers in a large transport facade. Reply-boundary deliveries need the
same teardown policy.

Move the existing cancellation method into TelegramDelayedDeliveryMixin
before extending it. Keep the method body and adapter method resolution
unchanged, and type the mixin receiver.
A reply boundary starts a flush outside the ordinary delivery maps.
Delivery teardown therefore misses a boundary that has already popped
its event and is awaiting dispatch. Cancelling a boundary before its
coroutine starts can also lose the event in its private temporary map.

Keep boundary events and tasks in a dedicated buffer. Include that
buffer in delivery cancellation, salvage and clearing. Cancellation
requeues an in-flight event through the existing flush body; buffered
boundaries are salvaged before the later ordinary text batch.

The event-synchronised regression reproduces the missing earlier batch
and unfinished task before the fix. It checks both complete events and
that the boundary task has exited when teardown returns.
When busy_text_mode=queue and a debounce state for sender B is stuck in
the store (timer fired but the pending slot belongs to sender A, a
different sender), any message from a third sender C hit an early
`return` without being saved to the debounce store, pending_messages, or
anywhere else — permanently and silently dropped.

Remove the `_can_merge(existing_pending, event)` guard from the inner
conflict branch in `_queue_text_debounce` and unconditionally call
`merge_pending_message_event` instead. C's text is appended to the
pending slot (or placed there directly if empty), matching the
pre-debounce behaviour that always queued follow-ups regardless of
sender. Sender attribution is best-effort when 3+ senders collide
simultaneously; losing the message entirely is worse.

Adds a regression test that pins the 3-sender scenario: A in pending,
B stuck in the debounce store with task=None, C from a third sender —
C must survive in _pending_messages and B must remain in the store for
_drain_pending_after_session_command to recover.

(cherry picked from commit 2a7e3c5)

Relocation onto pinned upstream 343500:

The current copy inserts the pending-event merge call before the
third-sender branch returns. The preceding extraction moved
_queue_text_debounce to BaseTextDebounceMixin. Apply the same insertion
in gateway/platforms/base_text_debounce.py and retain its typed
receiver. BasePlatformAdapter inherits this method. Keep the existing
Base adapter and the rest of the extracted module unchanged.
@iainlane
iainlane force-pushed the fix/gateway-queue-debounce-handoff branch from 1bf60b5 to 398891c Compare October 5, 2026 21:07
Four routing tests pass a recording transport to RelayAdapter, whose
transport parameter requires the RelayTransport protocol. The fixture
only implements its inbound setter and outbound send, so the four
constructor calls fail static type checking even though their exercised
routing paths work.

Implement the complete structural protocol in the recording fixture.
Unused operations raise an assertion error so an unexpected call fails
the test. Keep the recorded action, platform and send result, and narrow
the captured dictionaries before the existing routing assertions.

The correction changes the inherited test fixture. It preserves the
existing contributor records and all original routing assertions.
The encrypted-file fixture returned a constant serialized payload, so
assigning its uploaded URL did not appear in later serialization. The
Matrix SDK serializes the URL stored on the encrypted-file object. The
adapter currently overwrites the outgoing URL, which concealed this
fixture difference in the existing upload test.

Read the fixture URL when serialization is called. Add an invariant
that compares the complete payload before and after URL assignment,
with only the URL overwritten. Keep the existing upload and routing
assertions.
Matrix receives ordinary m.reaction events, but reads and catch-up only
showed message events. The bundled m.relations summary cannot provide
annotation reactions.

Read bounded annotation children through the live Matrix client.
Decrypt encrypted reactions to verify their type, but use the outer
cleartext m.relates_to for the target and key even if the decrypted
payload specifies another relation. Discard redacted events and
deduplicate by sender and key. Present the actor and target in read and
catch-up context without starting a turn. Mark clipped keys and report
incomplete reads when keys, API failures or bounded pages prevent a
complete snapshot.

A five-second batch deadline keeps a slow homeserver from delaying a
turn for every earlier message. Completed lookups remain visible when
others time out. A separate Matrix client confirms that a reaction
appears in the next mentioned turn. Focused tests cover duplicate and
redacted reactions, encrypted events, long keys, lookup errors, and
the deadline.

Bind the real room-policy predicate in the read fixtures. Read admission
rechecks room policy after awaiting room identity, so the fixture must
provide the same adapter protocol as the production path.
The concurrent-update fixture creates three handler coroutines before
passing each to the processor. Cancelling the middle update while it
waits for its predecessor leaves that handler coroutine unawaited, so
the fixture fails under strict RuntimeWarning checks.

Keep references to the fixture-created coroutines and close the
cancelled handler after every processor task has finished. The existing
assertions still require the first and third handlers to complete in
order without executing the cancelled handler.
Source-permalink and reaction-context branches each test their own prompt
contract. Their combined adapter adds the trigger permalink to catch-up
messages, so the reaction fixtures need the SDK's asynchronous state-store
methods and complete expected source notes.

Merge both feature histories without changing their existing commits.
Preserve reaction author trust, room and thread context, transcript roles,
cache privacy and the source-permalink encoding and routing rules. Use the
real MemoryStateStore in the affected fixtures and retrieve SessionSource
from the final context tuple item. Check each complete catch-up prompt
against its explicit room, trigger event and via server, including the
first event of a batched turn.

The four composed gateway files cover 343 cases. The affected two files
pass all 264 cases after the fixture adaptation; the unchanged session and
project-isolation files passed their 79 cases. Scoped Ruff reports no lint
diagnostics. Existing formatter differences remain qualified separately.
Live Docker room and thread backfill validation is still outstanding.

This dependent layer preserves both parent histories. Its two live
backfill fixture adaptations do not claim complete original 858 delivery.

Historical composition qualification

The preceding message records the original two-parent composition.
This successor reapplies its fixture integration after the individually
preserved prerequisite, SDK and reaction contributions on captured main
648a309. Its history has one parent.
The original composition message remains complete for provenance; this
commit does not create a merge or establish live Matrix acceptance.
The original feature tests predate the canonical approval lifecycle,
permalink source fields, room policy owner argument and SDK send error
classification. Their doubles omitted asynchronous state-store reads,
exact approval request IDs and terminal card edits. Several complete
expected events also omitted the newly composed source and mention
metadata, so the full feature gate failed before checking its intended
behaviour.

Update those fixture bindings and complete expected values for the
current defining owners. Keep every original parameter row, delivery
attempt, payload, cancellation and authorization assertion. Thread
reconnect expectations retain confirmed delivery and cache identity.
Catch-up expectations include the permalink note before the new message.

The complete nine-file feature boundary passes all 808 cases with the
actual installed Matrix SDK, four workers and no file retries. The full
Matrix file finishes in 30.7 seconds, including the event-loop lookup
contract that timed out on the earlier incomplete source composition.

Original858 public source qualification

The preceding 808-case result records the original composition. It does
not describe a new run against this public tree. The published backfill
composition has 343 scoped cases, using 264 final affected cases and 79
unchanged earlier successes. Its inherited formatter and type findings
remain qualified, and live Docker validation remains pending.

The other feature contracts are represented by their existing public
owners. Their fixture expectations follow the actual approval, notice,
location, send-classification, read-policy and delivered-event APIs.
Expectations from the original later composition do not establish bugs
in standalone owners that use an earlier API. No combined six-module
endpoint or repeated native gate is required for this source credit.

This source-neutral contribution preserves the complete original
message and both original contributor dates. It records the distributed
source qualification without changing the published source tree.

Current linear successor qualification

The 343-case result above describes the earlier a76019d composition.
This contribution follows the verified single-parent successor a83881c
and keeps its source tree unchanged. The accepted seven-file gateway
gate for that successor passes 362 cases. Live Matrix validation,
private integration and whole-campaign completion remain separate.
The Matrix SDK retries rate-limit responses and connection timeouts
indefinitely by default. A failed request during account or room setup
can therefore stop the whole native module until the outer runner kills
it, without reporting the setup error.

Disable those retries and bound ordinary fixture requests to fifteen
seconds. Apply the same configuration to registration,
restored accounts and the encrypted observer. The fixtures now expose
the SDK error so the failing operation can be diagnosed directly.

A real loopback HTTP server reproduces the repeated rate-limit requests
through registration and restored accounts. Both paths now make one
request and return the expected error.
Matrix turns could read message history, but the agent could not
inspect the current room state, joined members, power levels or pinned
messages. Those details are available from the receiving bot and can
affect what the agent should say or attempt.

Extend the Matrix-only read tool with bounded inspection kinds. Fetch
state and members through the owning adapter, apply its existing room
and requester policy, and resolve pinned messages with the same
decryption path as event reads. Report missing pin content per event
so the other pins remain visible. Include the event type and creator
override when presenting permission thresholds.

A separate Matrix client verifies that the model receives the room
details and pinned message from a disposable Synapse instance.

Use the effective-state parent's client-events decoder and gateway
scheduling path throughout inspection and its tests. Recheck admission
after identity resolution, derive permission defaults from raw state,
and preserve cancellation while refreshing pinned content. The feature
and these interface migrations form one change so the initial commit
uses the current prerequisite APIs.
The dependency-rebinding fixtures installed a fake importer but left the
package-availability check unchanged. With a complete SDK installed,
ensure_matrix_deps skips the importer, so these fixtures failed without
exercising their intended success and refusal paths.

Report a missing Matrix package in the fixture alongside the existing
installation seam. The real rebind and failed-import assertions then
exercise the intended fresh-install boundary.
The Matrix adapter ignores emotes and does not register native sticker
events. Emotes describe the sender's action, while stickers use a
separate event type with a description and an image. The existing text
handler would interpret a slash-like action body as a gateway command,
so native actions need attributed text before command classification.

Accept both through the existing room, sender and mention gates. Prefix
emotes with their actor, preserve sticker descriptions, and download
sticker pixels through the bounded media path. Use the same labels in
effective reads, replies and thread history.

Queued stickers can be edited or redacted while media preparation is
pending. Add an optional media-event snapshot capability and retain the
source paths of image descriptions, so final model preparation removes
both withdrawn pixels and their derived text. Existing platform
snapshots remain compatible and previous conversation context remains
unchanged.

The two parameterised invariants reproduce six failures on the committed
parents and now cover native SDK events, gates, unavailable media and
withdrawal during asynchronous preparation. Matrix and adjacent gateway
checks pass against the pinned SDK source.
@iainlane
iainlane force-pushed the fix/gateway-queue-debounce-handoff branch 2 times, most recently from a12940c to fd5de45 Compare October 8, 2026 08:20
The fake HTTP API discarded the client_session supplied by the Matrix
adapter, so disconnect closed a mock while the real aiohttp session was
left open. A reconnect test also replaced disconnect with a stub and
left its new connection open after checking the guard. Strict resource
warnings expose both leaks.

Preserve the supplied session in the fake API. Use a wrapping disconnect
spy in the reconnect test and disconnect the replacement client after
the existing call assertion. The fake API and reconnect fixture predate
this branch, so this focused fixture maintenance is separate from the
corrections folded into their campaign contributors. Production code
and all original assertions are unchanged.
Pending-event merges and busy-text debounce share reply and attachment
semantics inside the large base adapter. Telegram's ingress overrides
also modify the same batching boundary. Extract those existing topics
before integrating the reviewed reply-context fixes.

Repoint in-tree imports and patch targets to the defining merge module.
The adapter classes inherit their existing methods, so timers, tracked
redispatch and delayed-delivery ownership keep their current behaviour.

The fixed upstream annotation cleanup overlaps the extracted debounce
state. Keep the state in its existing topical module and apply the same
unquoted task annotation there. The complete state and timer behaviour
remain unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.