mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
17e5f7244a
commit
4a8eeb5d1c
2 changed files with 175 additions and 5 deletions
|
|
@ -5338,8 +5338,17 @@ class TurnRunner:
|
|||
title,
|
||||
)
|
||||
elif self._runner._is_discord_auto_thread_lane(ctx.source) or (
|
||||
self._runner._relay_auto_thread_info(ctx.source) is not None
|
||||
self._runner._is_relay_discord_channel_lane(ctx.source)
|
||||
):
|
||||
# Relay note: the second predicate is shape-only (relay
|
||||
# Discord channel event). Whether the connector actually
|
||||
# auto-threaded our reply is only knowable AFTER delivery
|
||||
# (send-result feedback), which on the non-streaming lane
|
||||
# happens after this registration runs — so the callback
|
||||
# must be registered eagerly and the rename lane performs
|
||||
# the cache lookup at fire time (staging repro 2026-07-31:
|
||||
# gating registration on the cache read meant it never
|
||||
# registered and no thread_rename op was ever sent).
|
||||
maybe_auto_title_kwargs["title_callback"] = lambda title: self._runner._schedule_discord_semantic_thread_rename(
|
||||
ctx.source,
|
||||
effective_session_id,
|
||||
|
|
@ -18813,6 +18822,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
and bool(getattr(source, "auto_thread_initial_name", None))
|
||||
)
|
||||
|
||||
def _is_relay_discord_channel_lane(self, source: SessionSource) -> bool:
|
||||
"""Shape-only check: a relay-delivered Discord CHANNEL event whose
|
||||
reply the connector MAY auto-thread (title-turn registration gate).
|
||||
|
||||
Deliberately does NOT consult the send-result cache: at registration
|
||||
time (before delivery) the feedback can't exist yet. The rename lane
|
||||
polls the cache at fire time instead."""
|
||||
return (
|
||||
source.platform == Platform.DISCORD
|
||||
and bool(source.chat_id)
|
||||
and not source.thread_id
|
||||
and source.chat_type in ("group", "channel")
|
||||
and getattr(source, "delivered_via_upstream_relay", False) is True
|
||||
)
|
||||
|
||||
def _relay_auto_thread_info(
|
||||
self, source: SessionSource
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
|
|
@ -18882,7 +18906,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if relay_info is None and not await asyncio.to_thread(
|
||||
self._is_discord_auto_thread_lane, source
|
||||
):
|
||||
return
|
||||
# Relay title turn with no feedback captured at schedule time:
|
||||
# the auto-title thread races the delivery that produces the
|
||||
# connector's send-result feedback (thread_id + initial name).
|
||||
# Poll the adapter cache briefly before giving up — delivery is
|
||||
# typically milliseconds-to-seconds behind the title.
|
||||
if not self._is_relay_discord_channel_lane(source):
|
||||
return
|
||||
for _ in range(20): # up to ~10s
|
||||
relay_info = self._relay_auto_thread_info(source)
|
||||
if relay_info is not None:
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
if relay_info is None:
|
||||
# True miss: the connector did not auto-thread this reply
|
||||
# (policy off, DM, already-threaded, or send failed).
|
||||
return
|
||||
adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None
|
||||
if adapter is None:
|
||||
return
|
||||
|
|
@ -18918,9 +18957,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if not self._is_discord_auto_thread_lane(source):
|
||||
# Relay title turn: the source is the PARENT channel event (the
|
||||
# thread didn't exist at ingest, so no auto-thread markers). The
|
||||
# connector's send-result feedback tells us where the reply landed.
|
||||
# connector's send-result feedback tells us where the reply
|
||||
# landed — but the auto-title thread races the delivery that
|
||||
# produces it, so a cache miss HERE is not a verdict. Schedule
|
||||
# whenever the SHAPE matches; the async rename lane polls the
|
||||
# cache (with a bounded wait) and no-ops on a true miss.
|
||||
relay_info = self._relay_auto_thread_info(source)
|
||||
if relay_info is None:
|
||||
if relay_info is None and not self._is_relay_discord_channel_lane(
|
||||
source
|
||||
):
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue