"""Tests for KeyboardInterrupt handling in exit cleanup paths. ``except Exception`` does not catch ``KeyboardInterrupt`` (which inherits from ``BaseException``). A second Ctrl+C during exit cleanup must not abort remaining cleanup steps. These tests exercise the actual production code paths — not a copy of the try/except pattern. """ import atexit import weakref from unittest.mock import MagicMock, patch, call import pytest class TestHonchoAtexitFlush: """run_agent.py — _register_honcho_exit_hook atexit handler.""" def test_keyboard_interrupt_during_flush_does_not_propagate(self): """The atexit handler must swallow KeyboardInterrupt from flush_all().""" mock_manager = MagicMock() mock_manager.flush_all.side_effect = KeyboardInterrupt # Capture functions passed to atexit.register registered_fns = [] original_register = atexit.register def capturing_register(fn, *args, **kwargs): registered_fns.append(fn) # Don't actually register — we don't want side effects with patch("atexit.register", side_effect=capturing_register): from run_agent import AIAgent agent = object.__new__(AIAgent) agent._honcho = mock_manager agent._honcho_exit_hook_registered = False agent._register_honcho_exit_hook() # Our handler is the last one registered assert len(registered_fns) >= 1, "atexit handler was not registered" flush_handler = registered_fns[-1] # Invoke the registered handler — must not raise flush_handler() mock_manager.flush_all.assert_called_once() class TestCronJobCleanup: """cron/scheduler.py — end_session + close in the finally block.""" def test_keyboard_interrupt_in_end_session_does_not_skip_close(self): """If end_session raises KeyboardInterrupt, close() must still run.""" mock_db = MagicMock() mock_db.end_session.side_effect = KeyboardInterrupt from cron import scheduler job = { "id": "test-job-1", "name": "test cleanup", "prompt": "hello", "schedule": "0 9 * * *", "model": "test/model", } with patch("hermes_state.SessionDB", return_value=mock_db), \ patch.object(scheduler, "_build_job_prompt", return_value="hello"), \ patch.object(scheduler, "_resolve_origin", return_value=None), \ patch.object(scheduler, "_resolve_delivery_target", return_value=None), \ patch("dotenv.load_dotenv", return_value=None), \ patch("run_agent.AIAgent") as MockAgent: # Make the agent raise immediately so we hit the finally block MockAgent.return_value.run_conversation.side_effect = RuntimeError("boom") scheduler.run_job(job) mock_db.end_session.assert_called_once() mock_db.close.assert_called_once() def test_keyboard_interrupt_in_close_does_not_propagate(self): """If close() raises KeyboardInterrupt, it must not escape run_job.""" mock_db = MagicMock() mock_db.close.side_effect = KeyboardInterrupt from cron import scheduler job = { "id": "test-job-2", "name": "test close interrupt", "prompt": "hello", "schedule": "0 9 * * *", "model": "test/model", } with patch("hermes_state.SessionDB", return_value=mock_db), \ patch.object(scheduler, "_build_job_prompt", return_value="hello"), \ patch.object(scheduler, "_resolve_origin", return_value=None), \ patch.object(scheduler, "_resolve_delivery_target", return_value=None), \ patch("dotenv.load_dotenv", return_value=None), \ patch("run_agent.AIAgent") as MockAgent: MockAgent.return_value.run_conversation.side_effect = RuntimeError("boom") # Must not raise scheduler.run_job(job) mock_db.end_session.assert_called_once() mock_db.close.assert_called_once()