Nan Gao 085924a3d0
fix(channels): run each IM message once across instances, restarts and late redeliveries (#7104)
* fix(channels): run each IM message once across instances, restarts and late redeliveries

Inbound IM messages were deduplicated only by the 10-minute inbound dedupe
store (#7103). Discord direct messages had no key at all, a Discord thread
reply got an instance-dependent key, a Discord thread was created before
dedupe ran, a redelivery past the window started a second run, and two
instances racing on a conversation's first messages bound it to two
threads.

- Discord messages are keyed (discord, guild_id or "dm", posting channel,
  message_id); the posting channel is the same on every instance.
- MessageBus.claim_inbound lets an adapter record the manager's dedupe key
  before a side effect; Discord claims it on the Gateway loop before
  _create_thread, marks the published message dedupe_claimed, and gives the
  claim back when the message is never committed.
- runs.wait / runs.stream / runs.create send an Idempotency-Key derived from
  the dedupe key, and keyed human message ids are uuid5 of it (a uuid4 per
  attempt made every keyed retry a 409). A reused run's status-only /wait or
  stream gap is resolved to this message's own turn via threads.get_state;
  409s about the message's own keyed run keep the key instead of reading as
  a busy thread; an ended reused GitHub run gets no follow-up watcher.
- A conversation's first thread binding is claimed insert-if-absent
  (claim_thread_id on channel_conversations and channel_thread_bindings);
  the loser adopts the winner and deletes the empty thread it minted.
  /new and /agent use still rebind.

Docs: IM_CHANNEL_CONNECTIONS.md, CONFIGURATION.md, channels AGENTS.md,
CHANGELOG (en/zh).

* fix(channels): give back Discord dedupe claims for messages never processed

Review round 1 on #7104. A Discord claim records the manager's dedupe key
before the message is queued, so every path that drops the message unrun
must release it, or a peer refuses the redelivery for the rest of the TTL:

- The hand-off task releases the claim on cancellation too (stop() drains it
  while it resolves the connection identity), via await_drained so the
  cancellation still propagates.
- ChannelManager._discard_pending_inbound releases the key of a claimed
  message that shutdown drops from the queue, after the grace period and on
  a cancelled shutdown.
- The adapter tracks claims until they are committed. A release refused
  because stop() already closed cross-loop intake, or a hand-off cancelled
  before it ran, leaves the claim tracked, and stop() releases it last.

* fix(gateway): keep IM run idempotency stable when /new rebinds the conversation

Review round 2 on #7104. The Gateway scoped every Idempotency-Key by owner
and thread. /new rebinds an IM conversation to a fresh thread, so a late
redelivery of a message admitted on the old thread was requested on the new
one, got a different scoped key and started a second run.

- Keys from trusted internal callers (the IM channel manager) are scoped by
  owner only (`internal-run:` prefix); browser and API keys keep the
  owner-and-thread scope (`http-run:`).
- A key that resolves to a run of another thread or user raises
  RunIdempotencyKeyMismatch, a RuntimeError subclass so in-process launchers
  see the same type, and start_run answers it with 409 "Idempotency-Key
  already used with a different request" instead of a 500. The channel
  manager already treats that 409 as the message's admitted run.

Docs: API.md, IM_CHANNEL_CONNECTIONS.md, gateway AGENTS.md (reworded to stay
under its budget), CHANGELOG (en/zh).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:57:19 +08:00

152 lines
7.2 KiB
Python

"""SQL repository for ``channel_thread_bindings`` (SQLite or PostgreSQL).
Concurrency contract, shared with ``app.channels.store.SqlChannelStore``:
- ``upsert`` is one dialect-native ``INSERT ... ON CONFLICT (key) DO UPDATE``
that replaces ``thread_id`` / ``user_id`` / ``updated_at`` and keeps the
existing ``created_at`` — the overwrite semantics the JSON store had — so two
replicas recording the same conversation never produce two rows and never
lose the first stamp.
- ``claim`` is the first binding of a conversation: ``INSERT ... ON CONFLICT
DO NOTHING`` plus a read of the winning row in the same transaction, so two
replicas that each created a thread for one new conversation converge on
the first one bound.
- ``insert_missing`` is the one-time import primitive: ``INSERT ... ON CONFLICT
DO NOTHING`` in bounded batches, returning only the rows it actually inserted,
so two replicas importing the same ``store.json`` concurrently can neither
duplicate nor clobber a binding that already exists (including one a peer
wrote live between the emptiness check and the insert).
- ``delete_prefix`` removes ``key == prefix`` and every ``key`` starting with
``prefix + ":"`` (``startswith(autoescape=True)``, so ``%`` / ``_`` in a chat
id match literally) — exact parity with the JSON ``remove()`` that cleared a
chat's base mapping and all its topics by key prefix, independent of how a
legacy key was split into components.
Database errors propagate: the manager cannot route an IM message without the
database anyway, and failing loudly beats silently splitting a conversation
across two threads.
"""
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.channel_thread_bindings.model import ChannelThreadBinding, ChannelThreadBindingRow
#: Rows per ``INSERT`` statement during the import; keeps bind-parameter counts small on both dialects.
IMPORT_BATCH_SIZE = 500
_COLUMNS = (
ChannelThreadBindingRow.key,
ChannelThreadBindingRow.channel_name,
ChannelThreadBindingRow.chat_id,
ChannelThreadBindingRow.topic_id,
ChannelThreadBindingRow.thread_id,
ChannelThreadBindingRow.user_id,
ChannelThreadBindingRow.created_at,
ChannelThreadBindingRow.updated_at,
)
def _insert_for(session: AsyncSession):
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
return pg_insert
if dialect == "sqlite":
return sqlite_insert
raise ValueError(f"Unsupported channel thread binding database dialect: {dialect}")
def _values(binding: ChannelThreadBinding) -> dict[str, object]:
return {
"key": binding.key,
"channel_name": binding.channel_name,
"chat_id": binding.chat_id,
"topic_id": binding.topic_id,
"thread_id": binding.thread_id,
"user_id": binding.user_id,
"created_at": binding.created_at,
"updated_at": binding.updated_at,
}
def _binding(row) -> ChannelThreadBinding:
key, channel_name, chat_id, topic_id, thread_id, user_id, created_at, updated_at = row
return ChannelThreadBinding(key, channel_name, chat_id, topic_id, thread_id, user_id or "", float(created_at), float(updated_at))
class SqlChannelThreadBindingRepository:
"""Persistence facade for ``channel_thread_bindings``."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def get_thread_id(self, key: str) -> str | None:
async with self._sf() as session:
return (await session.execute(select(ChannelThreadBindingRow.thread_id).where(ChannelThreadBindingRow.key == key))).scalar_one_or_none()
async def upsert(self, binding: ChannelThreadBinding) -> None:
"""Create or overwrite the binding; an existing row keeps its ``created_at``."""
async with self._sf() as session:
stmt = _insert_for(session)(ChannelThreadBindingRow).values(**_values(binding))
stmt = stmt.on_conflict_do_update(
index_elements=[ChannelThreadBindingRow.key],
set_={"thread_id": stmt.excluded.thread_id, "user_id": stmt.excluded.user_id, "updated_at": stmt.excluded.updated_at},
)
await session.execute(stmt)
await session.commit()
async def claim(self, binding: ChannelThreadBinding) -> str:
"""Insert the binding unless its key exists; return the ``thread_id`` the key is bound to."""
async with self._sf() as session:
await session.execute(_insert_for(session)(ChannelThreadBindingRow).values(**_values(binding)).on_conflict_do_nothing(index_elements=[ChannelThreadBindingRow.key]))
bound = (await session.execute(select(ChannelThreadBindingRow.thread_id).where(ChannelThreadBindingRow.key == binding.key))).scalar_one()
await session.commit()
return bound
async def delete(self, key: str) -> bool:
async with self._sf() as session:
result = await session.execute(delete(ChannelThreadBindingRow).where(ChannelThreadBindingRow.key == key))
await session.commit()
return result.rowcount > 0
async def delete_prefix(self, prefix: str) -> int:
"""Delete ``prefix`` itself and every ``prefix:*`` key; returns the number of rows removed."""
async with self._sf() as session:
predicate = or_(ChannelThreadBindingRow.key == prefix, ChannelThreadBindingRow.key.startswith(prefix + ":", autoescape=True))
result = await session.execute(delete(ChannelThreadBindingRow).where(predicate))
await session.commit()
return int(result.rowcount or 0)
async def list(self, channel_name: str | None = None) -> list[ChannelThreadBinding]:
stmt = select(*_COLUMNS).order_by(ChannelThreadBindingRow.created_at, ChannelThreadBindingRow.key)
if channel_name:
stmt = stmt.where(ChannelThreadBindingRow.channel_name == channel_name)
async with self._sf() as session:
rows = (await session.execute(stmt)).all()
return [_binding(row) for row in rows]
async def count(self) -> int:
async with self._sf() as session:
return int((await session.execute(select(func.count()).select_from(ChannelThreadBindingRow))).scalar_one())
async def insert_missing(self, bindings: Iterable[ChannelThreadBinding]) -> int:
"""``INSERT ... ON CONFLICT DO NOTHING`` in batches; returns how many rows this call inserted."""
rows = [_values(binding) for binding in bindings]
if not rows:
return 0
inserted = 0
async with self._sf() as session:
insert = _insert_for(session)
for start in range(0, len(rows), IMPORT_BATCH_SIZE):
batch = rows[start : start + IMPORT_BATCH_SIZE]
result = await session.execute(insert(ChannelThreadBindingRow).values(batch).on_conflict_do_nothing(index_elements=[ChannelThreadBindingRow.key]))
inserted += int(result.rowcount or 0)
await session.commit()
return inserted