mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
521 lines
22 KiB
Python
521 lines
22 KiB
Python
"""IM inbound dedupe and run idempotency across replicas, restarts and late redeliveries.
|
|
|
|
The inbound dedupe store absorbs a redelivery only inside its 10-minute window
|
|
and only while the store remembers the key: an in-process store forgets it on
|
|
restart, and the shared Postgres store fails open on a database error. Past
|
|
that window the manager used to start a second run for a message the Gateway
|
|
had already admitted. Every keyed run request now carries an ``Idempotency-Key``
|
|
derived from the inbound dedupe key, with a human message id derived from the
|
|
same key so the retry's input is identical: the Gateway reuses the admitted run
|
|
and the redelivery attaches to its outcome instead of running the turn twice.
|
|
|
|
Discord gets a dedupe key for direct messages (no guild) and one that does not
|
|
depend on replica-local thread routing, and two replicas that each create a
|
|
thread for one new conversation converge on the first one bound.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
from langgraph_sdk.errors import ConflictError
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.channels.dedupe_store import MemoryInboundDedupeStore
|
|
from app.channels.manager import ALREADY_ADMITTED_MESSAGE, THREAD_BUSY_MESSAGE, ChannelManager
|
|
from app.channels.message_bus import InboundMessage, MessageBus, OutboundMessage
|
|
from app.channels.store import JsonChannelStore, SqlChannelStore
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.channel_thread_bindings import ChannelThreadBindingRow
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def _manager(*, dedupe: MemoryInboundDedupeStore | None = None, store=None, get_stream_bridge=None) -> ChannelManager:
|
|
return ChannelManager(
|
|
bus=MessageBus(),
|
|
store=store or JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"),
|
|
inbound_dedupe_store=dedupe or MemoryInboundDedupeStore(),
|
|
max_concurrency=1,
|
|
shutdown_grace_period_seconds=0,
|
|
get_stream_bridge=get_stream_bridge,
|
|
)
|
|
|
|
|
|
def _client(thread_id: str = "thread-1") -> MagicMock:
|
|
client = MagicMock()
|
|
client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.threads.update = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.threads.delete = AsyncMock(return_value=None)
|
|
client.runs.wait = AsyncMock(return_value={"messages": [{"type": "human", "content": "hi"}, {"type": "ai", "content": "fresh answer"}]})
|
|
client.runs.create = AsyncMock(return_value={"run_id": "run-1", "status": "pending"})
|
|
return client
|
|
|
|
|
|
def _slack(event_id: str = "evt-1", *, team_id: str | None = "T1") -> InboundMessage:
|
|
metadata = {"event_id": event_id}
|
|
if team_id:
|
|
metadata["team_id"] = team_id
|
|
return InboundMessage(channel_name="slack", chat_id="C1", user_id="U1", text="hi", metadata=metadata)
|
|
|
|
|
|
def _feishu() -> InboundMessage:
|
|
return InboundMessage(channel_name="feishu", chat_id="oc-1", user_id="ou-1", text="hi", metadata={"message_id": "om-1"})
|
|
|
|
|
|
def _github() -> InboundMessage:
|
|
return InboundMessage(
|
|
channel_name="github",
|
|
chat_id="acme/repo",
|
|
user_id="alice",
|
|
owner_user_id="alice",
|
|
text="@bot review",
|
|
topic_id="7:reviewer",
|
|
workspace_id="acme/repo",
|
|
metadata={"message_id": "d1:alice:reviewer", "agent_name": "reviewer"},
|
|
)
|
|
|
|
|
|
def _discord(*, chat_id: str, channel_id: str, guild_id: str | None, message_id: str = "1300000000000000001", topic_id: str | None = None) -> InboundMessage:
|
|
return InboundMessage(
|
|
channel_name="discord",
|
|
chat_id=chat_id,
|
|
user_id="42",
|
|
text="hi",
|
|
topic_id=topic_id,
|
|
metadata={"guild_id": guild_id, "channel_id": channel_id, "message_id": message_id},
|
|
)
|
|
|
|
|
|
def _outbounds(manager: ChannelManager) -> list[OutboundMessage]:
|
|
sent: list[OutboundMessage] = []
|
|
|
|
async def capture(msg: OutboundMessage) -> None:
|
|
sent.append(msg)
|
|
|
|
manager.bus.subscribe_outbound(capture)
|
|
return sent
|
|
|
|
|
|
def _stream(*parts):
|
|
def factory(*_args, **_kwargs):
|
|
async def gen():
|
|
for event, data in parts:
|
|
yield SimpleNamespace(event=event, data=data)
|
|
|
|
return gen()
|
|
|
|
return MagicMock(side_effect=factory)
|
|
|
|
|
|
def _request_headers(call) -> dict:
|
|
return dict(call.kwargs.get("headers") or {})
|
|
|
|
|
|
def _human_message(call) -> dict:
|
|
[message] = call.kwargs["input"]["messages"]
|
|
return message
|
|
|
|
|
|
# -- Discord dedupe key --------------------------------------------------------
|
|
|
|
|
|
async def test_discord_direct_message_gets_a_dedupe_key():
|
|
"""A DM has no guild, so it used to get no key at all and every replica ran it."""
|
|
dm = _discord(chat_id="1200000000000000009", channel_id="1200000000000000009", guild_id=None)
|
|
|
|
assert ChannelManager._inbound_dedupe_key(dm) == ("discord", "dm", "1200000000000000009", "1300000000000000001")
|
|
|
|
|
|
async def test_discord_key_does_not_depend_on_replica_local_thread_routing():
|
|
"""A replica that tracks thread T routes a reply in T to its parent channel;
|
|
one that does not treats T as an orphan and routes it to T itself. Both must
|
|
see one message."""
|
|
tracked = _discord(chat_id="parent-1", channel_id="thread-T", guild_id="guild-1", topic_id="thread-T")
|
|
orphaned = _discord(chat_id="thread-T", channel_id="thread-T", guild_id="guild-1", topic_id="thread-new")
|
|
|
|
assert ChannelManager._inbound_dedupe_key(tracked) == ChannelManager._inbound_dedupe_key(orphaned)
|
|
assert ChannelManager._inbound_dedupe_key(tracked) == ("discord", "guild-1", "thread-T", "1300000000000000001")
|
|
|
|
|
|
async def test_discord_guild_channel_key_keeps_its_shape():
|
|
msg = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
|
|
|
|
assert ChannelManager._inbound_dedupe_key(msg) == ("discord", "guild-1", "chan-1", "1300000000000000001")
|
|
|
|
|
|
async def test_worker_runs_a_message_the_adapter_already_claimed_and_drops_its_redelivery():
|
|
"""An adapter-claimed message has its key recorded already; the worker must
|
|
not read its own claim as a duplicate, but a second delivery still is one."""
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
claimed = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
|
|
assert await manager.bus.claim_inbound(claimed) is True
|
|
assert claimed.dedupe_claimed is True
|
|
redelivery = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
|
|
assert await manager.bus.claim_inbound(redelivery) is False
|
|
|
|
await manager.start()
|
|
try:
|
|
await manager.bus.publish_inbound(claimed)
|
|
await manager.bus.publish_inbound(_discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1"))
|
|
await asyncio.wait_for(manager.bus.join_inbound(), timeout=5)
|
|
finally:
|
|
await manager.stop()
|
|
|
|
assert client.runs.wait.await_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize("cancelled", [False, True], ids=["grace-expired", "shutdown-cancelled"])
|
|
async def test_shutdown_gives_back_the_claim_of_a_queued_message_it_discards(cancelled):
|
|
"""An adapter-claimed message waiting in the queue owns a dedupe key no
|
|
worker recorded. Shutdown discards it unprocessed, so it must release the
|
|
key, or a peer drops the redelivery for the rest of the TTL."""
|
|
dedupe = MemoryInboundDedupeStore()
|
|
manager = _manager(dedupe=dedupe)
|
|
manager._client = client = _client()
|
|
busy = asyncio.Event()
|
|
|
|
async def run_forever(*_args, **_kwargs):
|
|
busy.set()
|
|
await asyncio.Event().wait()
|
|
|
|
client.runs.wait = AsyncMock(side_effect=run_forever)
|
|
await manager.start()
|
|
await manager.bus.publish_inbound(_slack("evt-busy"))
|
|
await asyncio.wait_for(busy.wait(), timeout=5)
|
|
queued = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
|
|
assert await manager.bus.claim_inbound(queued) is True
|
|
await manager.bus.publish_inbound(queued)
|
|
|
|
if cancelled:
|
|
manager._shutdown_grace_period_seconds = 30
|
|
stopping = asyncio.create_task(manager.stop())
|
|
await asyncio.sleep(0) # stop() is now waiting out the grace period
|
|
stopping.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await stopping
|
|
else:
|
|
await manager.stop()
|
|
|
|
assert ChannelManager._inbound_dedupe_key(queued) not in dedupe._store
|
|
if cancelled:
|
|
await manager.stop()
|
|
|
|
|
|
# -- Idempotency-Key on IM run requests ----------------------------------------
|
|
|
|
|
|
async def test_wait_request_carries_an_idempotency_key_stable_across_redeliveries():
|
|
clients = []
|
|
for _ in range(2):
|
|
# A fresh dedupe store per delivery: the window expired, or the process restarted.
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
await manager._handle_message(_slack())
|
|
clients.append(client)
|
|
|
|
first, retry = (client.runs.wait.call_args for client in clients)
|
|
key = _request_headers(first)["Idempotency-Key"]
|
|
assert key == ChannelManager._run_idempotency_key(_slack())
|
|
assert _request_headers(retry)["Idempotency-Key"] == key
|
|
# The Gateway reuses a keyed run only for an identical input, so the
|
|
# human message id must be the same on the redelivery too.
|
|
assert _human_message(first) == _human_message(retry)
|
|
uuid.UUID(_human_message(first)["id"])
|
|
|
|
|
|
async def test_distinct_messages_get_distinct_keys_and_message_ids():
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
|
|
await manager._handle_message(_slack("evt-1"))
|
|
await manager._handle_message(_slack("evt-2"))
|
|
|
|
first, second = client.runs.wait.call_args_list
|
|
assert _request_headers(first)["Idempotency-Key"] != _request_headers(second)["Idempotency-Key"]
|
|
assert _human_message(first)["id"] != _human_message(second)["id"]
|
|
|
|
|
|
async def test_message_without_a_dedupe_key_sends_no_idempotency_key():
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
|
|
await manager._handle_message(_slack(team_id=None))
|
|
await manager._handle_message(_slack(team_id=None))
|
|
|
|
first, second = client.runs.wait.call_args_list
|
|
assert "Idempotency-Key" not in _request_headers(first)
|
|
assert _human_message(first)["id"] != _human_message(second)["id"]
|
|
|
|
|
|
async def test_stream_request_carries_the_idempotency_key():
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
client.runs.stream = _stream(("values", {"messages": [{"type": "human", "content": "hi"}, {"type": "ai", "content": "streamed"}]}))
|
|
|
|
await manager._handle_message(_feishu())
|
|
|
|
assert _request_headers(client.runs.stream.call_args)["Idempotency-Key"] == ChannelManager._run_idempotency_key(_feishu())
|
|
|
|
|
|
async def test_fire_and_forget_create_carries_the_idempotency_key():
|
|
import app.gateway.github.run_policy # noqa: F401 — registers GitHub's fire-and-forget policy
|
|
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
|
|
await manager._handle_message(_github())
|
|
|
|
assert _request_headers(client.runs.create.call_args)["Idempotency-Key"] == ChannelManager._run_idempotency_key(_github())
|
|
|
|
|
|
async def test_idempotency_key_rides_along_with_the_owner_headers():
|
|
import app.gateway.github.run_policy # noqa: F401
|
|
|
|
msg = _github()
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
headers = _request_headers(client.runs.create.call_args)
|
|
assert headers["Idempotency-Key"] == ChannelManager._run_idempotency_key(msg)
|
|
assert headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME) == "alice"
|
|
|
|
|
|
# -- attaching a redelivery to the admitted run --------------------------------
|
|
|
|
|
|
def _state_with_turns(manager_msg: InboundMessage) -> dict:
|
|
"""Thread state where this message's turn sits between an older and a later turn."""
|
|
ours = str(ChannelManager._human_message_id(manager_msg))
|
|
return {
|
|
"values": {
|
|
"messages": [
|
|
{"type": "human", "id": "older", "content": "older question"},
|
|
{"type": "ai", "content": "older answer"},
|
|
{"type": "human", "id": ours, "content": "hi"},
|
|
{"type": "ai", "content": "the admitted run's answer"},
|
|
{"type": "human", "id": "later", "content": "a later question"},
|
|
{"type": "ai", "content": "a later answer"},
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
async def test_reused_wait_run_replies_with_this_messages_turn_from_thread_state():
|
|
"""``/wait`` on a reused run returns status only (the thread head may be a
|
|
later run). The manager reads this message's own turn instead."""
|
|
msg = _slack()
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
client.runs.wait = AsyncMock(return_value={"status": "success", "error": None})
|
|
client.threads.get_state = AsyncMock(return_value=_state_with_turns(msg))
|
|
sent = _outbounds(manager)
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
assert [out.text for out in sent] == ["the admitted run's answer"]
|
|
|
|
|
|
async def test_reused_wait_run_still_in_flight_says_it_is_already_handled_and_keeps_the_key():
|
|
msg = _slack()
|
|
dedupe = MemoryInboundDedupeStore()
|
|
manager = _manager(dedupe=dedupe)
|
|
manager._client = client = _client()
|
|
client.runs.wait = AsyncMock(return_value={"status": "running", "error": None})
|
|
sent = _outbounds(manager)
|
|
assert await manager._is_duplicate_inbound(msg) is False
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
|
|
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
|
|
|
|
|
|
async def test_reused_stream_with_a_replay_gap_replies_from_thread_state():
|
|
msg = _feishu()
|
|
manager = _manager()
|
|
manager._client = client = _client()
|
|
client.runs.stream = _stream(("gap", {"code": "stream_replay_gap", "recovery": "reload_durable_state"}))
|
|
client.threads.get_state = AsyncMock(return_value=_state_with_turns(msg))
|
|
sent = _outbounds(manager)
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
assert [out.text for out in sent if out.is_final] == ["the admitted run's answer"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"detail",
|
|
[
|
|
"Idempotency-Key already used with a different request",
|
|
"Run 3f2a is not active on this worker and cannot be streamed",
|
|
],
|
|
ids=["input-mismatch", "foreign-active-run"],
|
|
)
|
|
async def test_conflict_from_an_admitted_keyed_run_is_not_reported_as_a_busy_thread(detail):
|
|
"""A 409 that names the keyed run (re-ingested attachments changed the
|
|
input; the run is live on a peer) means the message was already admitted:
|
|
keep the key and do not tell the user to retry."""
|
|
msg = _slack()
|
|
dedupe = MemoryInboundDedupeStore()
|
|
manager = _manager(dedupe=dedupe)
|
|
manager._client = client = _client()
|
|
request = httpx.Request("POST", "http://gateway/api/threads/thread-1/runs/wait")
|
|
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
|
|
sent = _outbounds(manager)
|
|
assert await manager._is_duplicate_inbound(msg) is False
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
|
|
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
|
|
|
|
|
|
async def test_busy_thread_conflict_keeps_its_reply_and_releases_the_key():
|
|
msg = _slack()
|
|
dedupe = MemoryInboundDedupeStore()
|
|
manager = _manager(dedupe=dedupe)
|
|
manager._client = client = _client()
|
|
request = httpx.Request("POST", "http://gateway/api/threads/thread-1/runs/wait")
|
|
detail = "Thread thread-1 already has an active run"
|
|
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
|
|
sent = _outbounds(manager)
|
|
assert await manager._is_duplicate_inbound(msg) is False
|
|
|
|
await manager._handle_message(msg)
|
|
|
|
assert [out.text for out in sent] == [THREAD_BUSY_MESSAGE]
|
|
assert ChannelManager._inbound_dedupe_key(msg) not in dedupe._store
|
|
|
|
|
|
@pytest.mark.parametrize(("status", "watched"), [("pending", True), ("running", True), ("success", False), ("error", False)])
|
|
async def test_followup_watcher_only_follows_a_live_run(status, watched):
|
|
"""A redelivered GitHub event gets the admitted run back; once that run has
|
|
ended its stream may be gone, and a watcher would wait on it forever."""
|
|
import app.gateway.github.run_policy # noqa: F401
|
|
|
|
manager = _manager(get_stream_bridge=lambda: MagicMock())
|
|
manager._client = client = _client()
|
|
client.runs.create = AsyncMock(return_value={"run_id": "run-1", "status": status})
|
|
spawned: list[str] = []
|
|
manager._watch_run_and_drain_followups = AsyncMock(side_effect=lambda thread_id, run_id, msg: spawned.append(run_id))
|
|
|
|
await manager._handle_message(_github())
|
|
await asyncio.sleep(0)
|
|
|
|
assert spawned == (["run-1"] if watched else [])
|
|
|
|
|
|
async def test_redelivery_after_new_rebinds_the_conversation_meets_the_admitted_run(tmp_path):
|
|
"""``/new`` points the conversation at a fresh thread, so a late redelivery
|
|
is requested there. It carries the same key, which the Gateway scopes by
|
|
owner only for this internal caller, and its 409 must not start a run or
|
|
read as a busy thread."""
|
|
engine = await _shared_sql_store(tmp_path)
|
|
try:
|
|
store = SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False))
|
|
msg = _slack()
|
|
first = _manager(store=store)
|
|
first._client = first_client = _client("thread-old")
|
|
await first._handle_message(msg)
|
|
await first._create_thread(_client("thread-new"), _slack("evt-new-command")) # /new
|
|
|
|
dedupe = MemoryInboundDedupeStore() # the dedupe window expired
|
|
redelivery = _manager(dedupe=dedupe, store=store)
|
|
redelivery._client = client = _client("thread-unused")
|
|
request = httpx.Request("POST", "http://gateway/api/threads/thread-new/runs/wait")
|
|
detail = "Idempotency-Key already used with a different request"
|
|
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
|
|
sent = _outbounds(redelivery)
|
|
assert await redelivery._is_duplicate_inbound(msg) is False
|
|
|
|
await redelivery._handle_message(msg)
|
|
|
|
assert client.runs.wait.call_args.args[0] == "thread-new"
|
|
assert _request_headers(client.runs.wait.call_args)["Idempotency-Key"] == _request_headers(first_client.runs.wait.call_args)["Idempotency-Key"]
|
|
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
|
|
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
# -- converging on one thread across replicas ----------------------------------
|
|
|
|
|
|
async def _shared_sql_store(tmp_path: Path):
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{(tmp_path / 'bindings.db').as_posix()}")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all, tables=[ChannelThreadBindingRow.__table__])
|
|
return engine
|
|
|
|
|
|
async def test_replica_that_lost_the_first_binding_adopts_the_winner_and_discards_its_thread(tmp_path):
|
|
engine = await _shared_sql_store(tmp_path)
|
|
try:
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
manager_a, manager_b = _manager(store=SqlChannelStore(factory)), _manager(store=SqlChannelStore(factory))
|
|
client_a, client_b = _client("thread-a"), _client("thread-b")
|
|
msg = _slack()
|
|
|
|
assert await manager_a._get_or_create_thread(client_a, msg) == ("thread-a", True)
|
|
# B looked the conversation up before A's binding committed.
|
|
manager_b._lookup_thread_id = AsyncMock(return_value=None)
|
|
assert await manager_b._get_or_create_thread(client_b, msg) == ("thread-a", False)
|
|
|
|
client_b.threads.delete.assert_awaited_once()
|
|
assert client_b.threads.delete.await_args.args == ("thread-b",)
|
|
assert await SqlChannelStore(factory).get_thread_id("slack", "C1") == "thread-a"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_new_command_still_rebinds_the_conversation(tmp_path):
|
|
engine = await _shared_sql_store(tmp_path)
|
|
try:
|
|
store = SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False))
|
|
manager = _manager(store=store)
|
|
msg = _slack()
|
|
await manager._get_or_create_thread(_client("thread-first"), msg)
|
|
|
|
assert await manager._create_thread(_client("thread-after-new"), msg) == "thread-after-new"
|
|
|
|
assert await store.get_thread_id("slack", "C1") == "thread-after-new"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_connection_bound_conversation_converges_through_the_repository(tmp_path):
|
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
|
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'connections.db'}", sqlite_dir=str(tmp_path))
|
|
try:
|
|
repo = ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("k"))
|
|
connection = await repo.upsert_connection(owner_user_id="alice", provider="slack", external_account_id="U1", workspace_id="T1")
|
|
msg = _slack()
|
|
msg.connection_id, msg.owner_user_id = connection["id"], "alice"
|
|
manager_a, manager_b = _manager(), _manager()
|
|
manager_a._connection_repo = manager_b._connection_repo = repo
|
|
client_b = _client("thread-b")
|
|
|
|
assert await manager_a._get_or_create_thread(_client("thread-a"), msg) == ("thread-a", True)
|
|
manager_b._lookup_thread_id = AsyncMock(return_value=None)
|
|
assert await manager_b._get_or_create_thread(client_b, msg) == ("thread-a", False)
|
|
|
|
assert client_b.threads.delete.await_args.args == ("thread-b",)
|
|
assert await repo.get_thread_id(connection["id"], "C1") == "thread-a"
|
|
finally:
|
|
await close_engine()
|