diff --git a/gateway/run.py b/gateway/run.py index 54b0b0ce709a..b6f21e43af39 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11203,16 +11203,30 @@ async def _flush_buffer() -> None: exit_code_raw = exit_code_path.read_text().strip() or "1" exit_code = int(exit_code_raw) if exit_code == 0: - await adapter.send(chat_id, "✅ Hermes update finished.", metadata=metadata) + result = await adapter.send( + chat_id, + "✅ Hermes update finished.", + metadata=metadata, + ) else: - await adapter.send( + result = await adapter.send( chat_id, "❌ Hermes update failed (exit code {}).".format(exit_code), metadata=metadata, ) + if result is not None and getattr(result, "success", True) is False: + logger.warning( + "Update final notification to %s was not delivered: %s", + session_key, + getattr(result, "error", "send returned success=False"), + ) + await asyncio.sleep(poll_interval) + continue logger.info("Update finished (exit=%s), notified %s", exit_code, session_key) except Exception as e: logger.warning("Update final notification failed: %s", e) + await asyncio.sleep(poll_interval) + continue # Cleanup for p in (pending_path, claimed_path, output_path, @@ -11402,7 +11416,25 @@ async def _send_update_notification(self) -> bool: msg = "✅ Hermes update finished successfully." else: msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." - await adapter.send(chat_id, msg, metadata=metadata) + result = await adapter.send(chat_id, msg, metadata=metadata) + if result is not None and getattr(result, "success", True) is False: + logger.warning( + "Post-update notification to %s:%s was not delivered: %s", + platform_str, + chat_id, + getattr(result, "error", "send returned success=False"), + ) + cleanup = False + active_pending_path = pending_path + try: + if claimed_path.exists(): + claimed_path.replace(pending_path) + except OSError as move_err: + logger.warning( + "Failed to preserve post-update notification markers: %s", + move_err, + ) + return False logger.info( "Sent post-update notification to %s:%s (exit=%s)", platform_str, diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index fa223a42fbde..d67f2db7a453 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -11,7 +11,7 @@ import pytest from gateway.config import Platform -from gateway.platforms.base import MessageEvent +from gateway.platforms.base import MessageEvent, SendResult from gateway.session import SessionSource @@ -763,6 +763,40 @@ async def test_cleans_up_files_after_notification(self, tmp_path): assert not output_path.exists() assert not exit_code_path.exists() + @pytest.mark.asyncio + async def test_send_result_failure_preserves_markers_for_retry(self, tmp_path): + """SendResult(success=False) não confirma entrega e mantém retry pendente.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending_path = hermes_home / ".update_pending.json" + output_path = hermes_home / ".update_output.txt" + exit_code_path = hermes_home / ".update_exit_code" + pending_path.write_text(json.dumps({ + "platform": "telegram", "chat_id": "111", "user_id": "222", + })) + output_path.write_text("✓ Done") + exit_code_path.write_text("0") + + mock_adapter = AsyncMock() + mock_adapter.send.return_value = SendResult( + success=False, + error="telegram timed out", + retryable=True, + ) + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + with patch("gateway.run._hermes_home", hermes_home): + result = await runner._send_update_notification() + + assert result is False + mock_adapter.send.assert_called_once() + assert pending_path.exists() + assert output_path.exists() + assert exit_code_path.exists() + assert not (hermes_home / ".update_pending.claimed.json").exists() + @pytest.mark.asyncio async def test_cleans_up_on_error(self, tmp_path): """Files are cleaned up even if notification fails.""" diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index 3a48ab9171fd..a409c82f8d9d 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -16,7 +16,7 @@ import pytest from gateway.config import Platform -from gateway.platforms.base import MessageEvent +from gateway.platforms.base import MessageEvent, SendResult from gateway.session import SessionSource @@ -412,6 +412,41 @@ async def test_cleans_up_on_completion(self, tmp_path): assert not output_path.exists() assert not exit_code_path.exists() + @pytest.mark.asyncio + async def test_keeps_markers_when_final_notification_send_fails(self, tmp_path): + """Falha retornada por adapter.send deve preservar estado para retry.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + pending_path = hermes_home / ".update_pending.json" + output_path = hermes_home / ".update_output.txt" + exit_code_path = hermes_home / ".update_exit_code" + pending_path.write_text(json.dumps(pending)) + output_path.write_text("done\n") + exit_code_path.write_text("0") + + mock_adapter = AsyncMock() + mock_adapter.send.return_value = SendResult( + success=False, + error="telegram timed out", + retryable=True, + ) + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + with patch("gateway.run._hermes_home", hermes_home): + await runner._watch_update_progress( + poll_interval=0.05, + stream_interval=0.1, + timeout=0.15, + ) + + assert pending_path.exists() + assert output_path.exists() + assert exit_code_path.exists() + @pytest.mark.asyncio async def test_failure_exit_code(self, tmp_path): """Non-zero exit code sends failure message."""