mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(buzz): scoped identity lock, negative name caching, sidebar registration
Follow-up on the salvaged #71610 commits: - acquire/release a scoped lock on relay_url+pubkey in connect/disconnect (IRC pattern) so two profiles can't drive one Buzz identity — duplicate replies and split de-dupe state; +2 tests - negative-cache _resolve_user_name failures so a profile-less pubkey doesn't re-hit 'users get' every poll sweep (flagged by @jethac on the PR) - register user-guide/messaging/buzz in website/sidebars.ts (page was unreachable — the #63359 trap)
This commit is contained in:
parent
e01d6650fe
commit
1b9377b1fd
4 changed files with 78 additions and 2 deletions
1
contributors/emails/rob@cocodelivery.com
Normal file
1
contributors/emails/rob@cocodelivery.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
rob-coco
|
||||
|
|
@ -360,6 +360,7 @@ class BuzzAdapter(BasePlatformAdapter):
|
|||
|
||||
# Runtime state
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._lock_key: Optional[str] = None
|
||||
# channel_id -> {"chat_type", "last_ts", "seen": OrderedDict[event_id, None]}
|
||||
self._channel_state: Dict[str, dict] = {}
|
||||
self._channel_names: Dict[str, str] = {}
|
||||
|
|
@ -421,6 +422,27 @@ class BuzzAdapter(BasePlatformAdapter):
|
|||
self._display_name = str(profiles[0].get("display_name") or "").strip()
|
||||
self._self_npub = hex_to_npub(self._self_pubkey) or ""
|
||||
|
||||
# Prevent two profiles from driving the same Buzz identity on the
|
||||
# same relay (duplicate replies, split de-dupe state). Mirrors the
|
||||
# IRC adapter's scoped-lock pattern.
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
lock_key = f"{self.relay_url}:{self._self_pubkey}"
|
||||
if not acquire_scoped_lock("buzz", lock_key):
|
||||
logger.error(
|
||||
"Buzz: identity %s… on %s already in use by another profile",
|
||||
self._self_pubkey[:8],
|
||||
self.relay_url,
|
||||
)
|
||||
self._set_fatal_error(
|
||||
"lock_conflict", "Buzz identity in use by another profile", retryable=False
|
||||
)
|
||||
return False
|
||||
self._lock_key = lock_key
|
||||
except ImportError:
|
||||
self._lock_key = None # status module not available (e.g. tests)
|
||||
|
||||
# Map channel ids to names and pick the watch set.
|
||||
code, out, err = await self._run_cli(["channels", "list"])
|
||||
if code != 0:
|
||||
|
|
@ -463,6 +485,15 @@ class BuzzAdapter(BasePlatformAdapter):
|
|||
async def disconnect(self) -> None:
|
||||
"""Stop the poll loop and drop runtime state."""
|
||||
self._mark_disconnected()
|
||||
lock_key = getattr(self, "_lock_key", None)
|
||||
if lock_key:
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
|
||||
release_scoped_lock("buzz", lock_key)
|
||||
except Exception:
|
||||
pass
|
||||
self._lock_key = None
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
|
|
@ -874,9 +905,15 @@ class BuzzAdapter(BasePlatformAdapter):
|
|||
return stripped.strip()
|
||||
|
||||
async def _resolve_user_name(self, pubkey: str) -> str:
|
||||
"""Resolve a pubkey to a display name (cached; falls back to npub prefix)."""
|
||||
"""Resolve a pubkey to a display name (cached; falls back to npub prefix).
|
||||
|
||||
Failures are cached too (negative caching): without it, every message
|
||||
from a profile-less pubkey re-runs ``users get`` each poll sweep,
|
||||
which amplifies badly when several adapter instances poll in one
|
||||
process.
|
||||
"""
|
||||
cached = self._user_names.get(pubkey)
|
||||
if cached:
|
||||
if cached is not None:
|
||||
return cached
|
||||
name = ""
|
||||
code, out, _err = await self._run_cli(["users", "get", "--pubkey", pubkey])
|
||||
|
|
|
|||
|
|
@ -704,6 +704,43 @@ class TestBuzzAdapterLifecycle:
|
|||
assert adapter._poll_task is None
|
||||
assert adapter.is_connected is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_releases_scoped_lock(self, monkeypatch):
|
||||
"""The identity lock taken in connect() must be released on disconnect."""
|
||||
import gateway.status as gateway_status
|
||||
|
||||
released = []
|
||||
monkeypatch.setattr(
|
||||
gateway_status,
|
||||
"release_scoped_lock",
|
||||
lambda platform, key: released.append((platform, key)),
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter._lock_key = "wss://relay.example:" + SELF_PUBKEY
|
||||
await adapter.disconnect()
|
||||
assert released == [("buzz", "wss://relay.example:" + SELF_PUBKEY)]
|
||||
assert adapter._lock_key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_when_identity_lock_held(self, monkeypatch):
|
||||
"""A second profile using the same relay+pubkey must fail fast."""
|
||||
import gateway.status as gateway_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_status, "acquire_scoped_lock", lambda platform, key: False
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter.cli_path = "/fake/buzz"
|
||||
monkeypatch.setattr(_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1test")
|
||||
cli = _ScriptedCli()
|
||||
cli.script(
|
||||
"users", "get",
|
||||
[{"pubkey": SELF_PUBKEY, "display_name": "Chip"}],
|
||||
)
|
||||
adapter._run_cli = cli
|
||||
assert await adapter.connect() is False
|
||||
assert adapter._lock_key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_info_uses_cached_names(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
|
|||
|
|
@ -656,6 +656,7 @@ const sidebars: SidebarsConfig = {
|
|||
'user-guide/messaging/mattermost',
|
||||
'user-guide/messaging/matrix',
|
||||
'user-guide/messaging/bluebubbles',
|
||||
'user-guide/messaging/buzz',
|
||||
'user-guide/messaging/photon',
|
||||
'user-guide/messaging/google_chat',
|
||||
'user-guide/messaging/line',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue