fix(gateway): relay semantic thread rename — register eagerly, poll send-result feedback at fire time (#75581)

Staging re-test (2026-07-31, post-74482 image roll): auto-created
threads still stuck on their initial titles; connector telemetry shows
zero thread_rename ops. Root cause is an ordering flaw in the 74482
consume path: BOTH the title-callback registration gate and the
schedule gate read the send-result feedback cache
(_relay_auto_thread_info) — but registration runs BEFORE delivery on
the non-streaming lane, and the auto-title thread races delivery even
when registration survives. The cache read can only succeed AFTER the
connector answers the send, so the rename lane deterministically
disqualified itself on the title turn.

Fix — decide shape early, facts late:
- New _is_relay_discord_channel_lane: SHAPE-only predicate (relay
  Discord channel event, no thread) used by the registration and
  schedule gates; no cache read before delivery.
- _rename_discord_auto_thread_for_session_title: on the relay lane,
  poll the adapter's feedback cache (0.5s ticks, ≤10s) — delivery is
  typically right behind the title. True miss (connector didn't
  auto-thread: policy off, DM, send failed) no-ops exactly as before.

Tests: shape-gate matrix; late-arriving feedback -> rename fires with
only_if_current_name guard; never-arriving feedback -> no-op. Relay
suite 174 passed.
This commit is contained in:
Ben Barclay 2026-07-31 11:55:05 -07:00 committed by GitHub
parent 17e5f7244a
commit 4a8eeb5d1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 175 additions and 5 deletions

View file

@ -23,7 +23,7 @@ from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from gateway.config import Platform, PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.command_manifest import build_relay_command_manifest
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
@ -212,3 +212,128 @@ async def test_auto_thread_feedback_is_bounded():
assert len(adapter._auto_thread_by_chat) <= 256
# Newest entries survive the bound.
assert adapter.auto_thread_info_for_chat("c299") == ("th-c299", "n")
# ── title-turn rename: registration shape-gate + fire-time cache poll ────
def _mk_runner_stub():
"""Minimal object carrying the three GatewayRunner methods under test."""
import asyncio as _asyncio
from gateway.run import GatewayRunner
class _Stub:
_is_relay_discord_channel_lane = GatewayRunner._is_relay_discord_channel_lane
_relay_auto_thread_info = GatewayRunner._relay_auto_thread_info
_is_discord_auto_thread_lane = GatewayRunner._is_discord_auto_thread_lane
_sanitize_discord_thread_title = GatewayRunner._sanitize_discord_thread_title
_rename_discord_auto_thread_for_session_title = (
GatewayRunner._rename_discord_auto_thread_for_session_title
)
def __init__(self, adapter):
self.adapters = {Platform.RELAY: adapter}
def _adapter_for_source(self, source):
return self.adapters.get(Platform.RELAY)
return _Stub
def _relay_channel_source():
from types import SimpleNamespace
return SimpleNamespace(
platform=Platform.DISCORD,
chat_id="chan-parent",
chat_type="group",
thread_id=None,
delivered_via_upstream_relay=True,
auto_thread_created=False,
auto_thread_initial_name=None,
)
def test_relay_channel_lane_shape_gate():
from types import SimpleNamespace
from gateway.config import Platform as P
stub = _mk_runner_stub()(adapter=None)
src = _relay_channel_source()
assert stub._is_relay_discord_channel_lane(src) is True
# thread events, DMs, and native (non-relay) events do not match
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "thread_id": "t1"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "chat_type": "dm"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "delivered_via_upstream_relay": False})
)
is False
)
@pytest.mark.asyncio
async def test_title_rename_polls_feedback_that_arrives_late():
"""The auto-title races delivery: feedback lands AFTER the rename lane
starts. The lane must poll the adapter cache and still rename."""
import asyncio
adapter, stub_conn = _adapter()
renames: list = []
async def rename_thread(thread_id, name, *, only_if_current_name=None, parent_chat_id=None):
renames.append((thread_id, name, only_if_current_name))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
async def land_feedback_late():
await asyncio.sleep(0.7) # past the first poll tick
adapter._auto_thread_by_chat["chan-parent"] = ("th-9", "Initial words")
task = asyncio.create_task(land_feedback_late())
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "Debugging the flux capacitor"
)
await task
assert renames == [("th-9", "Debugging the flux capacitor", "Initial words")]
@pytest.mark.asyncio
async def test_title_rename_true_miss_noops(monkeypatch):
"""No feedback ever arrives (connector didn't auto-thread): no rename."""
import gateway.run as run_mod
adapter, _ = _adapter()
renames: list = []
async def rename_thread(thread_id, name, **kw):
renames.append(thread_id)
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
# Shrink the poll loop for test speed: 20 ticks of 0.5s -> patch sleep.
orig_sleep = run_mod.asyncio.sleep
async def fast_sleep(_s):
await orig_sleep(0)
monkeypatch.setattr(run_mod.asyncio, "sleep", fast_sleep)
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "A title"
)
assert renames == []