diff --git a/gateway/run_adapters.py b/gateway/run_adapters.py index 6c16c7d298..2b8b940f7f 100644 --- a/gateway/run_adapters.py +++ b/gateway/run_adapters.py @@ -827,6 +827,7 @@ class GatewayAdapterLifecycleMixin: Each profile connects under its own HERMES_HOME + secret scope; credential/listener collisions are refused here — the only point seeing every profile's credentials together.""" from gateway.run import MultiplexConfigError, _multiplex_profile_homes + from gateway.run_profile_reconcile import profile_serve_signature if not self._multiplex_on(): return 0 try: @@ -837,9 +838,12 @@ class GatewayAdapterLifecycleMixin: connected = 0 claimed = self._primary_resource_claims(active) profile_homes = _multiplex_profile_homes(self.config) + self._served_profile_signatures = {} for profile_name, profile_home in profile_homes: if profile_name == active: continue # handled by the primary startup loop + # Preserve changes made while the initial connection is awaiting I/O. + self._served_profile_signatures[profile_name] = profile_serve_signature(profile_home) try: connected += await self._start_one_profile_adapters(profile_name, profile_home, claimed) except MultiplexConfigError: diff --git a/gateway/run_profile_reconcile.py b/gateway/run_profile_reconcile.py index f433c126dc..4f69115f45 100644 --- a/gateway/run_profile_reconcile.py +++ b/gateway/run_profile_reconcile.py @@ -116,6 +116,9 @@ class GatewayProfileReconcileMixin: result["removed"].append(name) claimed = self._live_resource_claims(active) for name in added + changed: + # Only acknowledge the configuration observed before connecting; + # a setup save during an awaited handshake needs another scan. + scan_signature = profile_serve_signature(current[name]) try: connected = await self._start_one_profile_adapters(name, current[name], claimed) except MultiplexConfigError as exc: @@ -125,7 +128,7 @@ class GatewayProfileReconcileMixin: except Exception: logger.error("[MULTIPLEX] Failed to start adapters for profile '%s'", name, exc_info=True) connected = 0 - sigs[name] = profile_serve_signature(current[name]) + sigs[name] = scan_signature if name in added: logger.info("[MULTIPLEX] Now serving profile '%s' (%s adapter(s) connected; %s)", name, connected, reason) result["added"].append(name) diff --git a/tests/gateway/test_profile_signature_during_connect.py b/tests/gateway/test_profile_signature_during_connect.py new file mode 100644 index 0000000000..823f591577 --- /dev/null +++ b/tests/gateway/test_profile_signature_during_connect.py @@ -0,0 +1,73 @@ +"""A config saved while a profile connects must remain pending for the next scan.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform +from gateway.run import GatewayRunner + + +@pytest.mark.asyncio +@pytest.mark.parametrize("startup", [False, True]) +async def test_config_saved_during_connect_is_rescanned(tmp_path, monkeypatch, startup): + home = tmp_path / ".hermes" + profile = home / "profiles" / "worker" + profile.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + (profile / "config.yaml").write_text("model: {default: test}\n", encoding="utf-8") + secrets = profile / ".env" + secrets.write_text("DISCORD_BOT_TOKEN=discord-test\n", encoding="utf-8") + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner._running = True + runner._primary_profile_name = "default" + ( + runner.adapters, + runner._profile_adapters, + runner._failed_platforms, + runner._profile_failed_platforms, + ) = {}, {}, {}, {} + runner.pairing_store, runner.pairing_stores = MagicMock(), {} + runner._busy_text_modes_by_profile, runner._busy_input_modes_by_profile = {}, {} + runner._register_config_hooks = lambda *a, **kw: None + runner._configure_profile_adapter = lambda *a: None + runner._sync_voice_mode_state_to_adapter = lambda *a: None + runner._restore_secondary_completion_ledgers = lambda *a: None + runner._adapter_credential_claim = lambda *a: None + runner._adapter_listener_claim = lambda *a: None + runner._create_adapter = lambda platform, config: SimpleNamespace(platform=platform) + runner._note_served_profiles([("default", home)]) + connected = [] + + async def connect(adapter, platform): + connected.append(platform) + if platform == Platform.DISCORD: + # Configuration was already read; a second setup operation finishes while + # the first adapter is awaiting its transport handshake. + secrets.write_text( + "DISCORD_BOT_TOKEN=discord-test\nTELEGRAM_BOT_TOKEN=telegram-test\n", + encoding="utf-8", + ) + return True + + async def after_added(profiles): + pass + + runner._connect_initial_adapter_with_timeout = connect + runner._after_profiles_added = after_added + if startup: + await runner._start_secondary_profile_adapters() + else: + await runner.reconcile_served_profiles() + assert connected == [Platform.DISCORD] + await runner.reconcile_served_profiles() + assert connected == [Platform.DISCORD, Platform.TELEGRAM] + await runner.reconcile_served_profiles() + assert connected == [Platform.DISCORD, Platform.TELEGRAM]