diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index f66d71c62e6d..cd3385b588a6 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -364,7 +364,19 @@ def _run_agent( agent.stream_delta_callback = None agent.tool_gen_callback = None - return agent.chat(prompt) or "" + response = agent.chat(prompt) or "" + + # Join memory-provider daemon threads (e.g. Honcho's honcho-sync, + # honcho-prewarm-dialectic, honcho-context-prefetch) before the process + # exits. Without this, CPython's Py_FinalizeEx force-terminates the + # still-running daemon threads via pthread_exit(), and glibc converts + # that forced unwind into abort() → SIGABRT → exit 134. (#37632) + try: + agent.shutdown_memory_provider() + except Exception: + pass # memory providers are strictly best-effort + + return response def _oneshot_clarify_callback(question: str, choices=None) -> str: diff --git a/tests/cli/test_oneshot_memory_shutdown.py b/tests/cli/test_oneshot_memory_shutdown.py new file mode 100644 index 000000000000..cb02f551255b --- /dev/null +++ b/tests/cli/test_oneshot_memory_shutdown.py @@ -0,0 +1,75 @@ +"""Regression test for #37632: oneshot mode must call shutdown_memory_provider. + +Without this, Honcho memory daemon threads (honcho-sync, honcho-prewarm-dialectic, +honcho-context-prefetch) remain blocked in httpx I/O when the process exits. +CPython's Py_FinalizeEx force-terminates them via pthread_exit(), and glibc +converts that into abort() → SIGABRT → exit 134. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestOneshotMemoryShutdown: + """shutdown_memory_provider() must be called after agent.chat() in oneshot mode.""" + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + @patch("hermes_cli.config.load_config") + @patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=None) + @patch("hermes_cli.oneshot.get_fallback_chain", return_value=None) + @patch("run_agent.AIAgent") + def test_run_agent_calls_shutdown_after_chat( + self, MockAIAgent, mock_fb, mock_sdb, mock_load_config, mock_resolve + ): + """_run_agent must call agent.shutdown_memory_provider() after chat().""" + mock_load_config.return_value = {"model": {"default": "test-model", "provider": "test"}} + mock_resolve.return_value = { + "api_key": "test-key", + "base_url": "http://test", + "provider": "test", + "api_mode": "chat_completions", + "credential_pool": None, + } + + mock_agent = MagicMock() + mock_agent.chat.return_value = "test response" + MockAIAgent.return_value = mock_agent + + from hermes_cli.oneshot import _run_agent + + result = _run_agent("test prompt") + + assert result == "test response" + mock_agent.chat.assert_called_once_with("test prompt") + mock_agent.shutdown_memory_provider.assert_called_once() + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + @patch("hermes_cli.config.load_config") + @patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=None) + @patch("hermes_cli.oneshot.get_fallback_chain", return_value=None) + @patch("run_agent.AIAgent") + def test_run_agent_shutdown_does_not_crash_on_error( + self, MockAIAgent, mock_fb, mock_sdb, mock_load_config, mock_resolve + ): + """shutdown_memory_provider raising must not crash _run_agent.""" + mock_load_config.return_value = {"model": {"default": "test-model", "provider": "test"}} + mock_resolve.return_value = { + "api_key": "test-key", + "base_url": "http://test", + "provider": "test", + "api_mode": "chat_completions", + "credential_pool": None, + } + + mock_agent = MagicMock() + mock_agent.chat.return_value = "test response" + mock_agent.shutdown_memory_provider.side_effect = RuntimeError("boom") + MockAIAgent.return_value = mock_agent + + from hermes_cli.oneshot import _run_agent + + # Must not raise despite shutdown_memory_provider failing + result = _run_agent("test prompt") + assert result == "test response" + mock_agent.shutdown_memory_provider.assert_called_once()