From ec8b4b2d6fea14c5cff838d64d796de4828b5cc4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:20:03 -0700 Subject: [PATCH] fix(gateway): recheck the heartbeat guard after an awaited edit Review finding: the guard ran before `edit_message` was awaited; a restart notice sent during that await followed by a failed edit produced a fresh "Working" fallback bubble after the notice. Recheck before the fallback send; the notifier ends instead. --- gateway/run_turn.py | 6 +++++ tests/gateway/test_busy_session_ack.py | 37 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/gateway/run_turn.py b/gateway/run_turn.py index ff05205106..c04e91bc84 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -3854,6 +3854,12 @@ class GatewayTurnMixin: logger.debug("Heartbeat edit failed: %s", _ee) _notify_res = None if not (_notify_res and getattr(_notify_res, "success", False)): + # The edit above awaited; a drain/restart notice may have gone out meanwhile, and + # a fresh "Working" bubble after it reads as a contradiction (#10990). + if not self._should_emit_long_running_notification( + session_key, agent_holder[0], _executor_task_holder[0] + ): + break _notify_res = await _notify_adapter.send( source.chat_id, _heartbeat_text, metadata=_interim_metadata(_non_conversational_metadata(_status_thread_metadata, platform=source.platform)), diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index b4d01fb606..f1562fa4ae 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -518,6 +518,43 @@ class TestLongRunningNotificationOwnership: "sess", original_agent, executor_task=None ) is False + @pytest.mark.asyncio + async def test_restart_during_heartbeat_edit_sends_no_fallback_bubble(self, monkeypatch): + """The guard is rechecked after the awaited edit: a restart that begins while the edit is + in flight must not be followed by a fresh "Working" send when that edit fails (#10990).""" + import asyncio + from types import SimpleNamespace + from gateway.run import GatewayRunner + from gateway.turn_context import TurnContext + + monkeypatch.setenv("HERMES_AGENT_NOTIFY_INTERVAL", "0.01") + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + runner._draining = runner._restart_requested = False + adapter = MagicMock() + first_send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="hb-1")) + adapter.send = first_send + + async def _edit_then_restart(*a, **k): + runner._restart_requested = True # restart notice goes out while the edit is awaited + return SimpleNamespace(success=False) + + adapter.edit_message = AsyncMock(side_effect=_edit_then_restart) + runner._adapter_for_source = lambda source: adapter + runner._agent_activity_summary = staticmethod(lambda agent: None) + agent = MagicMock() + runner._running_agents["sess"] = agent + disp = MagicMock() + disp._display_surface_mode.return_value = "on" + disp.resolve_display_setting.return_value = False + ctx = TurnContext(source=SimpleNamespace(chat_id="c", platform="telegram"), session_key="sess") + ctx.agent_holder[0] = agent + + await asyncio.wait_for(runner._run_agent_notify_long_running(disp, ctx, [None]), 5) + + assert first_send.await_count == 1 # the original heartbeat only + adapter.edit_message.assert_awaited_once() + @pytest.mark.parametrize("flag", ["_draining", "_restart_requested"]) def test_notification_stops_once_shutdown_or_restart_begins(self, flag): """After the restart/shutdown notice a heartbeat would contradict it (#10990)."""