fix(relay): key auto-thread rename on prospective_thread_id, not per-chat cache (#77052)

The Discord semantic thread-rename lane resolved the target thread from
`_relay_auto_thread_info`, which read a single-slot-per-parent-chat cache
(`adapter._auto_thread_by_chat[chat_id]`, populated from connector
SendResult feedback). When two auto-threads spawned from the SAME parent
channel, the second send overwrote the first's slot and the title turn's
read raced the write — so only the FIRST thread in a channel ever got its
semantic rename. Staging repro 2026-08-02: message A's thread renamed to
"A Hundred Word Sword Story", sibling message B's thread stayed stuck at
the raw first-words name.

The connector now stamps `prospective_thread_id` on the inbound (the anchor
message id, which is the id of the thread it will auto-create) — shipped for
per-thread session keying. Reuse it here: it is deterministic and
per-message, so it names the EXACT thread even when several auto-threads
share one channel. `_relay_auto_thread_info` returns it directly (with an
empty initial-name marker) and never consults the collision-prone per-chat
cache; the connector's own created-name guard (`prefer_connector_created`)
still enforces no-clobber, so no initial name is needed gateway-side. The
send-result cache path stays as a fallback for older connectors that don't
stamp the field.

Tests: two new cases in test_relay_threads.py — prospective id wins over a
poisoned cache entry, and two sibling threads in one channel each rename to
their own thread id. Full gateway session + relay suites green (211 passed).
This commit is contained in:
Ben Barclay 2026-08-02 12:35:50 -07:00 committed by GitHub
parent c83ddd6a51
commit d0b87dad77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 82 additions and 3 deletions

View File

@ -19079,14 +19079,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
not exist at ingest, so no markers can be present and the native lane
check never matches on the relay title turn (staging repro
2026-07-29: initial titles fine, semantic renames never happened).
The connector reports where the reply actually landed on the send
result (contract §SendResult thread_id/auto_thread_name); the relay
adapter caches it per chat and this reads it back.
Preferred path: the connector stamps ``prospective_thread_id`` on the
inbound (the anchor message id, which IS the id of the thread it will
auto-create). It's deterministic and per-message, so it identifies the
EXACT thread even when several auto-threads spawn from one channel
unlike the send-result cache below, which held a single slot per parent
chat and so only the FIRST thread in a channel ever renamed (staging
repro 2026-08-02: thread A renamed, sibling thread B stuck at raw
text). The connector's own created-name guard (prefer_connector_created)
enforces no-clobber, so no initial name is needed here.
Fallback: the connector reports where the reply actually landed on the
send result (contract §SendResult thread_id/auto_thread_name); the
relay adapter caches it per chat and this reads it back kept for
older connectors that don't stamp prospective_thread_id.
"""
if source.platform != Platform.DISCORD or not source.chat_id:
return None
if not getattr(source, "delivered_via_upstream_relay", False):
return None
prospective = getattr(source, "prospective_thread_id", None)
if prospective:
# Deterministic per-thread identity; the empty initial-name marker
# signals the caller to rely on the connector-side no-clobber guard.
return (str(prospective), "")
adapter = self._adapter_for_source(source)
info_fn = getattr(adapter, "auto_thread_info_for_chat", None)
if not callable(info_fn):

View File

@ -348,6 +348,68 @@ def test_relay_channel_lane_shape_gate():
)
@pytest.mark.asyncio
async def test_relay_auto_thread_info_prefers_prospective_thread_id():
"""When the connector stamps prospective_thread_id, the rename lane uses it
directly (deterministic, per-thread) and does NOT consult the per-chat
send-result cache the empty initial-name marker defers no-clobber to the
connector's own created-name guard."""
from types import SimpleNamespace
adapter, _ = _adapter()
# Poison the per-chat cache with a DIFFERENT (stale sibling) thread to prove
# the prospective id wins and the cache is not read.
adapter._auto_thread_by_chat["chan-parent"] = ("th-STALE", "old words")
runner = _mk_runner_stub()(adapter)
src = SimpleNamespace(
**{**_relay_channel_source().__dict__, "prospective_thread_id": "th-B"}
)
assert runner._relay_auto_thread_info(src) == ("th-B", "")
@pytest.mark.asyncio
async def test_sibling_threads_in_one_channel_each_rename_to_own_thread():
"""Two auto-threads spawned from the SAME parent channel must each rename
to their OWN thread id. Before the prospective_thread_id fix the per-chat
cache held one slot, so only the first thread renamed (staging repro
2026-08-02: thread A renamed, sibling thread B stuck at raw text)."""
from types import SimpleNamespace
adapter, _ = _adapter()
renames: list = []
async def rename_thread(
thread_id,
name,
*,
only_if_current_name=None,
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
base = _relay_channel_source().__dict__
# A and B share the parent channel but carry distinct prospective thread ids.
src_a = SimpleNamespace(**{**base, "prospective_thread_id": "th-A"})
src_b = SimpleNamespace(**{**base, "prospective_thread_id": "th-B"})
await runner._rename_discord_auto_thread_for_session_title(
src_a, "sessA", "Sea Shanty Draft"
)
await runner._rename_discord_auto_thread_for_session_title(
src_b, "sessB", "Exotic Short Story"
)
# Each renamed ITS OWN thread, via the connector-owned guard, passing the
# parent channel id for tenant discriminator resolution.
assert renames == [
("th-A", "Sea Shanty Draft", True, "chan-parent"),
("th-B", "Exotic Short Story", True, "chan-parent"),
]
@pytest.mark.asyncio
async def test_title_rename_polls_feedback_that_arrives_late():
"""The auto-title races delivery: feedback lands AFTER the rename lane