|
11 | 11 |
|
12 | 12 | import pytest |
13 | 13 |
|
14 | | -from cli_agent_orchestrator.api.main import app, flow_daemon, opencode_inbox_delivery_daemon |
| 14 | +from cli_agent_orchestrator.api.main import ( |
| 15 | + app, |
| 16 | + flow_daemon, |
| 17 | + inbox_reconciliation_daemon, |
| 18 | + opencode_inbox_delivery_daemon, |
| 19 | +) |
15 | 20 | from cli_agent_orchestrator.models.terminal import Terminal |
| 21 | +from cli_agent_orchestrator.services import inbox_service |
16 | 22 | from cli_agent_orchestrator.utils.skills import SkillNameError |
17 | 23 |
|
18 | 24 | # ── Health endpoint ────────────────────────────────────────────────── |
@@ -995,6 +1001,35 @@ async def fake_sleep(_seconds): |
995 | 1001 | assert mock_to_thread.await_args.args[1] is registry |
996 | 1002 |
|
997 | 1003 |
|
| 1004 | +class TestInboxReconciliationDaemon: |
| 1005 | + """Tests for the provider-agnostic inbox reconciliation sweep (issue #131).""" |
| 1006 | + |
| 1007 | + @pytest.mark.asyncio |
| 1008 | + async def test_sweep_runs_one_iteration_then_cancels(self): |
| 1009 | + """Daemon sleeps, runs the sync sweep in a thread, then handles cancellation.""" |
| 1010 | + sleep_calls = 0 |
| 1011 | + registry = MagicMock() |
| 1012 | + mock_to_thread = AsyncMock() |
| 1013 | + |
| 1014 | + async def fake_sleep(_seconds): |
| 1015 | + nonlocal sleep_calls |
| 1016 | + sleep_calls += 1 |
| 1017 | + if sleep_calls > 1: |
| 1018 | + raise asyncio.CancelledError |
| 1019 | + |
| 1020 | + with ( |
| 1021 | + patch("asyncio.sleep", new=fake_sleep), |
| 1022 | + patch("asyncio.to_thread", mock_to_thread), |
| 1023 | + ): |
| 1024 | + with pytest.raises(asyncio.CancelledError): |
| 1025 | + await inbox_reconciliation_daemon(registry) |
| 1026 | + |
| 1027 | + mock_to_thread.assert_awaited_once() |
| 1028 | + # The sweep, not some other sync function, must be the dispatched work. |
| 1029 | + assert mock_to_thread.await_args.args[0] is inbox_service.reconcile_orphaned_messages |
| 1030 | + assert mock_to_thread.await_args.args[1] is registry |
| 1031 | + |
| 1032 | + |
998 | 1033 | # ── lifespan ───────────────────────────────────────────────────────── |
999 | 1034 |
|
1000 | 1035 |
|
@@ -1030,6 +1065,43 @@ async def fake_daemon(): |
1030 | 1065 | mock_observer.stop.assert_called_once() |
1031 | 1066 | mock_observer.join.assert_called_once() |
1032 | 1067 |
|
| 1068 | + @pytest.mark.asyncio |
| 1069 | + async def test_lifespan_cancels_inbox_reconciliation_on_shutdown(self): |
| 1070 | + """The reconciliation sweep task is cancelled when the server stops (issue #131).""" |
| 1071 | + from cli_agent_orchestrator.api.main import lifespan |
| 1072 | + |
| 1073 | + mock_observer = MagicMock() |
| 1074 | + reconcile_cancelled = {"value": False} |
| 1075 | + |
| 1076 | + async def fake_flow_daemon(): |
| 1077 | + await asyncio.sleep(3600) |
| 1078 | + |
| 1079 | + async def fake_reconcile(registry): |
| 1080 | + try: |
| 1081 | + await asyncio.sleep(3600) |
| 1082 | + except asyncio.CancelledError: |
| 1083 | + reconcile_cancelled["value"] = True |
| 1084 | + raise |
| 1085 | + |
| 1086 | + with ( |
| 1087 | + patch("cli_agent_orchestrator.api.main.setup_logging"), |
| 1088 | + patch("cli_agent_orchestrator.api.main.init_db"), |
| 1089 | + patch("cli_agent_orchestrator.api.main.cleanup_old_data"), |
| 1090 | + patch( |
| 1091 | + "cli_agent_orchestrator.api.main.PollingObserver", |
| 1092 | + return_value=mock_observer, |
| 1093 | + ), |
| 1094 | + patch("cli_agent_orchestrator.api.main.flow_daemon", fake_flow_daemon), |
| 1095 | + patch( |
| 1096 | + "cli_agent_orchestrator.api.main.inbox_reconciliation_daemon", |
| 1097 | + fake_reconcile, |
| 1098 | + ), |
| 1099 | + ): |
| 1100 | + async with lifespan(app): |
| 1101 | + pass |
| 1102 | + |
| 1103 | + assert reconcile_cancelled["value"] is True |
| 1104 | + |
1033 | 1105 |
|
1034 | 1106 | # ── main() entry point ─────────────────────────────────────────────── |
1035 | 1107 |
|
|
0 commit comments