fix(gateway): use connector-owned no-clobber guard for relay thread rename + trace logs (#75912)

Live staging (2026-08-01): relay semantic thread rename still declined
silently despite both #74482 and #75581 deployed — thread kept its
initial-words name, session title generated fine. Root cause is the
no-clobber guard string mismatch (see paired gateway-gateway PR): the
gateway can't reproduce the thread's initial name byte-for-byte, so the
connector's only_if_current_name check always failed.

- relay rename lane now passes prefer_connector_created=True instead of
  the fragile initial-name string; the connector resolves the guard from
  its own created-name memory. Native-marker lane keeps the legacy
  only_if_current_name string (source carries the real initial name).
- rename_thread: prefer_connector_created param -> only_if_connector_created
  on the wire, precedence over the legacy string.
- INFO logs at rename dispatch (thread/lane/new_title) and result
  (applied=bool): the whole failure hunt needed telemetry the gateway
  never emitted — this makes the outcome visible in fly logs.

Tests: connector-guard wire shape + precedence over legacy string; the
title-turn race test updated to assert the connector-owned guard. Relay
suite 149 passed; ruff + footguns clean.
This commit is contained in:
Ben Barclay 2026-08-01 09:28:31 -07:00 committed by GitHub
parent 470cf66b03
commit fed098bbf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 78 additions and 11 deletions

View File

@ -2060,6 +2060,7 @@ class RelayAdapter(BasePlatformAdapter):
name: str,
*,
only_if_current_name: Optional[str] = None,
prefer_connector_created: bool = False,
parent_chat_id: Optional[str] = None,
) -> bool:
"""Best-effort thread rename via the connector's `thread_rename` op.
@ -2068,10 +2069,15 @@ class RelayAdapter(BasePlatformAdapter):
called by the SAME semantic-rename lane (run.py
_rename_discord_auto_thread_for_session_title), which fires only for
sources carrying the connector-stamped auto-thread markers.
``only_if_current_name`` crosses the wire; the CONNECTOR enforces the
no-clobber guard (it owns the platform read), failing safe on
platforms that can't read the current name. ``parent_chat_id`` is
the containing chat where the caller knows it (Telegram needs it);
No-clobber guard: prefer ``prefer_connector_created=True``, which asks
the CONNECTOR to enforce the guard from ITS OWN created-name memory
(only_if_connector_created) the gateway no longer has to reproduce
the thread's initial name byte-for-byte, which drifted on any
normalization difference and silently declined every relay rename.
``only_if_current_name`` is the legacy string guard, kept for the
native-marker lane and older connectors. ``parent_chat_id`` is the
containing chat where the caller knows it (Telegram needs it);
defaults to the thread id itself (Discord ignores chat_id).
"""
if self._transport is None or not self.descriptor.supports_op("thread_rename"):
@ -2087,7 +2093,9 @@ class RelayAdapter(BasePlatformAdapter):
"thread_name": cleaned[:100],
"metadata": self._with_scope(chat_id, None),
}
if only_if_current_name is not None:
if prefer_connector_created:
action["only_if_connector_created"] = True
elif only_if_current_name is not None:
action["only_if_current_name"] = str(only_if_current_name)
try:
result = await self._transport.send_outbound(

View File

@ -18962,18 +18962,36 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if rename_thread is None:
return
target_thread_id = relay_info[0] if relay_info else str(source.thread_id)
# Relay lane (relay_info present): ask the CONNECTOR to enforce the
# no-clobber guard from its own created-name memory — the gateway
# can't reliably reproduce the thread's initial name byte-for-byte
# (normalization drift silently declined every rename before this).
# Native-marker lane keeps the legacy string guard.
use_connector_guard = relay_info is not None
guard_name = (
relay_info[1]
if relay_info
None
if use_connector_guard
else getattr(source, "auto_thread_initial_name", None)
)
thread_name = self._sanitize_discord_thread_title(title)
logger.info(
"discord auto-thread rename: thread=%s lane=%s new_title=%r",
target_thread_id,
"relay" if use_connector_guard else "native",
thread_name,
)
try:
await rename_thread(
renamed = await rename_thread(
target_thread_id,
thread_name,
prefer_connector_created=use_connector_guard,
only_if_current_name=guard_name,
)
logger.info(
"discord auto-thread rename result: thread=%s applied=%s",
target_thread_id,
bool(renamed),
)
except Exception:
logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True)

View File

@ -118,6 +118,38 @@ async def test_rename_thread_parent_chat_and_gating():
assert gated_stub.sent == []
@pytest.mark.asyncio
async def test_rename_thread_prefers_connector_owned_guard():
"""The relay lane sends only_if_connector_created (connector resolves the
no-clobber guard from its own created-name memory) instead of the fragile
cross-repo only_if_current_name string."""
adapter, stub = _adapter()
ok = await adapter.rename_thread(
"th9", "Real Session Title", prefer_connector_created=True
)
assert ok is True
action = stub.sent[-1]
assert action["op"] == "thread_rename"
assert action["only_if_connector_created"] is True
# The fragile string guard is NOT sent when the connector owns the check.
assert "only_if_current_name" not in action
@pytest.mark.asyncio
async def test_rename_thread_connector_guard_takes_precedence_over_string():
"""prefer_connector_created wins even if a legacy string is also passed."""
adapter, stub = _adapter()
await adapter.rename_thread(
"th9",
"Title",
prefer_connector_created=True,
only_if_current_name="ignored initial words",
)
action = stub.sent[-1]
assert action["only_if_connector_created"] is True
assert "only_if_current_name" not in action
# ── the relay semantic-rename lane (marker parity) ───────────────────────
@ -291,8 +323,15 @@ async def test_title_rename_polls_feedback_that_arrives_late():
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))
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))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
@ -308,7 +347,9 @@ async def test_title_rename_polls_feedback_that_arrives_late():
src, "sess1", "Debugging the flux capacitor"
)
await task
assert renames == [("th-9", "Debugging the flux capacitor", "Initial words")]
# Relay lane uses the connector-owned guard (prefer_connector_created=True),
# not the fragile cross-repo initial-name string.
assert renames == [("th-9", "Debugging the flux capacitor", True)]
@pytest.mark.asyncio