"""Unit tests for timmy.vassal.orchestration_loop — VassalOrchestrator.""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from timmy.vassal.orchestration_loop import VassalCycleRecord, VassalOrchestrator # --------------------------------------------------------------------------- # VassalCycleRecord # --------------------------------------------------------------------------- class TestVassalCycleRecord: def test_defaults(self): record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") assert record.issues_fetched == 0 assert record.issues_dispatched == 0 assert record.errors == [] assert record.stuck_agents == [] assert record.house_warnings == [] assert record.finished_at == "" assert record.duration_ms == 0 def test_healthy_no_errors_no_warnings(self): record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") assert record.healthy is True def test_unhealthy_with_errors(self): record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") record.errors.append("something broke") assert record.healthy is False def test_unhealthy_with_house_warnings(self): record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") record.house_warnings.append("disk nearly full") assert record.healthy is False def test_dispatch_counters(self): record = VassalCycleRecord(cycle_id=2, started_at="2026-01-01T00:00:00") record.dispatched_to_claude = 3 record.dispatched_to_kimi = 1 record.dispatched_to_timmy = 2 assert ( record.dispatched_to_claude + record.dispatched_to_kimi + record.dispatched_to_timmy == 6 ) # --------------------------------------------------------------------------- # VassalOrchestrator — properties and get_status # --------------------------------------------------------------------------- class TestVassalOrchestratorProperties: def test_initial_state(self): orch = VassalOrchestrator() assert orch.cycle_count == 0 assert orch.is_running is False assert orch.history == [] def test_get_status_no_cycles(self): orch = VassalOrchestrator() status = orch.get_status() assert status["running"] is False assert status["cycle_count"] == 0 assert status["last_cycle"] is None def test_get_status_after_cycle(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") record.issues_fetched = 5 record.issues_dispatched = 3 orch._history.append(record) orch._cycle_count = 1 status = orch.get_status() assert status["cycle_count"] == 1 lc = status["last_cycle"] assert lc["cycle_id"] == 1 assert lc["issues_fetched"] == 5 assert lc["issues_dispatched"] == 3 assert lc["healthy"] is True def test_history_returns_copy(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") orch._history.append(record) h = orch.history h.clear() assert len(orch._history) == 1 # original unmodified # --------------------------------------------------------------------------- # _resolve_interval # --------------------------------------------------------------------------- class TestResolveInterval: def test_explicit_interval_used(self): orch = VassalOrchestrator(cycle_interval=42.0) assert orch._resolve_interval() == 42.0 def test_falls_back_to_settings(self): orch = VassalOrchestrator() mock_settings = MagicMock() mock_settings.vassal_cycle_interval = 120 with patch("timmy.vassal.orchestration_loop.logger"): with patch("config.settings", mock_settings): interval = orch._resolve_interval() assert interval == 120.0 def test_falls_back_to_default_on_exception(self): orch = VassalOrchestrator() with patch("builtins.__import__", side_effect=ImportError("no config")): # _resolve_interval catches all exceptions and returns 300 interval = orch._resolve_interval() assert interval == 300.0 # --------------------------------------------------------------------------- # run_cycle — happy path and graceful degradation # --------------------------------------------------------------------------- def _make_backlog_mocks(): """Return patched versions of all sub-step dependencies.""" mock_fetch = AsyncMock(return_value=[]) mock_triage = MagicMock(return_value=[]) mock_registry = MagicMock(return_value={}) mock_dispatch = AsyncMock() return mock_fetch, mock_triage, mock_registry, mock_dispatch class TestRunCycle: @pytest.mark.asyncio async def test_increments_cycle_count(self): orch = VassalOrchestrator(cycle_interval=0) with ( patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_backlog", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_agent_health", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_house_health", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._broadcast", new_callable=AsyncMock, ), ): await orch.run_cycle() await orch.run_cycle() assert orch.cycle_count == 2 @pytest.mark.asyncio async def test_record_appended_to_history(self): orch = VassalOrchestrator(cycle_interval=0) with ( patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_backlog", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_agent_health", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._step_house_health", new_callable=AsyncMock, ), patch( "timmy.vassal.orchestration_loop.VassalOrchestrator._broadcast", new_callable=AsyncMock, ), ): record = await orch.run_cycle() assert len(orch.history) == 1 assert orch.history[0].cycle_id == 1 assert record.finished_at != "" assert record.duration_ms >= 0 @pytest.mark.asyncio async def test_backlog_step_failure_recorded(self): orch = VassalOrchestrator(cycle_interval=0) async def bad_backlog(record): raise RuntimeError("gitea down") with ( patch.object(orch, "_step_backlog", side_effect=bad_backlog), patch.object(orch, "_step_agent_health", new_callable=AsyncMock), patch.object(orch, "_step_house_health", new_callable=AsyncMock), patch.object(orch, "_broadcast", new_callable=AsyncMock), ): record = await orch.run_cycle() # Errors from step failures bubble up through the step itself; # the test verifies the cycle still completes. assert record.cycle_id == 1 @pytest.mark.asyncio async def test_broadcast_called(self): orch = VassalOrchestrator(cycle_interval=0) broadcast_mock = AsyncMock() with ( patch.object(orch, "_step_backlog", new_callable=AsyncMock), patch.object(orch, "_step_agent_health", new_callable=AsyncMock), patch.object(orch, "_step_house_health", new_callable=AsyncMock), patch.object(orch, "_broadcast", broadcast_mock), ): await orch.run_cycle() broadcast_mock.assert_awaited_once() # --------------------------------------------------------------------------- # _step_backlog # --------------------------------------------------------------------------- class TestStepBacklog: @pytest.mark.asyncio async def test_no_issues_returns_early(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") mock_fetch = AsyncMock(return_value=[]) mock_triage = MagicMock(return_value=[]) with ( patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch), patch("timmy.vassal.backlog.triage_issues", mock_triage), patch("timmy.vassal.dispatch.get_dispatch_registry", MagicMock(return_value={})), ): await orch._step_backlog(record) assert record.issues_fetched == 0 assert record.issues_dispatched == 0 @pytest.mark.asyncio async def test_exception_adds_to_errors(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") with patch( "timmy.vassal.orchestration_loop.__import__", side_effect=ImportError("no backlog"), ): # Trigger failure by making fetch_open_issues raise with patch( "timmy.vassal.backlog.fetch_open_issues", AsyncMock(side_effect=RuntimeError("fetch failed")), ): await orch._step_backlog(record) assert any("backlog" in e for e in record.errors) @pytest.mark.asyncio async def test_dispatches_up_to_max(self): from timmy.vassal.backlog import AgentTarget orch = VassalOrchestrator(max_dispatch_per_cycle=2) record = VassalCycleRecord(cycle_id=1, started_at="now") issues = [] for i in range(5): issue = MagicMock() issue.number = i + 1 issue.agent_target = AgentTarget.CLAUDE issues.append(issue) mock_fetch = AsyncMock(return_value=issues) mock_triage = MagicMock(return_value=issues) mock_registry = MagicMock(return_value={}) mock_dispatch = AsyncMock() with ( patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch), patch("timmy.vassal.backlog.triage_issues", mock_triage), patch("timmy.vassal.dispatch.get_dispatch_registry", mock_registry), patch("timmy.vassal.dispatch.dispatch_issue", mock_dispatch), ): await orch._step_backlog(record) assert record.issues_dispatched == 2 assert record.issues_fetched == 5 @pytest.mark.asyncio async def test_already_dispatched_skipped(self): from timmy.vassal.backlog import AgentTarget orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") issue = MagicMock() issue.number = 42 issue.agent_target = AgentTarget.TIMMY mock_fetch = AsyncMock(return_value=[issue]) mock_triage = MagicMock(return_value=[issue]) mock_registry = MagicMock(return_value={42: "already done"}) mock_dispatch = AsyncMock() with ( patch("timmy.vassal.backlog.fetch_open_issues", mock_fetch), patch("timmy.vassal.backlog.triage_issues", mock_triage), patch("timmy.vassal.dispatch.get_dispatch_registry", mock_registry), patch("timmy.vassal.dispatch.dispatch_issue", mock_dispatch), ): await orch._step_backlog(record) mock_dispatch.assert_not_awaited() assert record.issues_dispatched == 0 # --------------------------------------------------------------------------- # _step_agent_health # --------------------------------------------------------------------------- class TestStepAgentHealth: @pytest.mark.asyncio async def test_stuck_agents_recorded(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") stuck = MagicMock() stuck.is_stuck = True stuck.agent = "claude" stuck.stuck_issue_numbers = [101, 102] not_stuck = MagicMock() not_stuck.is_stuck = False health_report = MagicMock() health_report.agents = [stuck, not_stuck] mock_get_report = AsyncMock(return_value=health_report) mock_nudge = AsyncMock(return_value=True) mock_settings = MagicMock() mock_settings.vassal_stuck_threshold_minutes = 60 with ( patch("timmy.vassal.agent_health.get_full_health_report", mock_get_report), patch("timmy.vassal.agent_health.nudge_stuck_agent", mock_nudge), patch("config.settings", mock_settings), ): await orch._step_agent_health(record) assert "claude" in record.stuck_agents assert record.nudges_sent == 2 @pytest.mark.asyncio async def test_exception_adds_to_errors(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") with patch( "timmy.vassal.agent_health.get_full_health_report", AsyncMock(side_effect=RuntimeError("health check failed")), ): await orch._step_agent_health(record) assert any("agent_health" in e for e in record.errors) # --------------------------------------------------------------------------- # _step_house_health # --------------------------------------------------------------------------- class TestStepHouseHealth: @pytest.mark.asyncio async def test_warnings_recorded(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") snapshot = MagicMock() snapshot.warnings = ["low disk", "high cpu"] snapshot.disk = MagicMock() snapshot.disk.percent_used = 50.0 with patch( "timmy.vassal.house_health.get_system_snapshot", AsyncMock(return_value=snapshot) ): await orch._step_house_health(record) assert record.house_warnings == ["low disk", "high cpu"] @pytest.mark.asyncio async def test_cleanup_triggered_above_80_percent(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") snapshot = MagicMock() snapshot.warnings = [] snapshot.disk = MagicMock() snapshot.disk.percent_used = 85.0 mock_cleanup = AsyncMock(return_value={"deleted_count": 7}) with ( patch( "timmy.vassal.house_health.get_system_snapshot", AsyncMock(return_value=snapshot) ), patch("timmy.vassal.house_health.cleanup_stale_files", mock_cleanup), ): await orch._step_house_health(record) assert record.cleanup_deleted == 7 mock_cleanup.assert_awaited_once() @pytest.mark.asyncio async def test_exception_adds_to_errors(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="now") with patch( "timmy.vassal.house_health.get_system_snapshot", AsyncMock(side_effect=OSError("no disk info")), ): await orch._step_house_health(record) assert any("house_health" in e for e in record.errors) # --------------------------------------------------------------------------- # _broadcast — best-effort, swallows errors # --------------------------------------------------------------------------- class TestBroadcast: @pytest.mark.asyncio async def test_successful_broadcast(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") record.finished_at = "2026-01-01T00:00:01" record.duration_ms = 100 mock_ws = MagicMock() mock_ws.broadcast = AsyncMock() mock_module = MagicMock() mock_module.ws_manager = mock_ws with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": mock_module}): await orch._broadcast(record) mock_ws.broadcast.assert_awaited_once() call_args = mock_ws.broadcast.call_args assert call_args[0][0] == "vassal.cycle" payload = call_args[0][1] assert payload["cycle_id"] == 1 @pytest.mark.asyncio async def test_import_error_swallowed(self): orch = VassalOrchestrator() record = VassalCycleRecord(cycle_id=1, started_at="2026-01-01T00:00:00") record.finished_at = "now" with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": None}): # Should not raise await orch._broadcast(record) # --------------------------------------------------------------------------- # start / stop # --------------------------------------------------------------------------- class TestStartStop: @pytest.mark.asyncio async def test_start_sets_running(self): orch = VassalOrchestrator(cycle_interval=9999) with patch.object(orch, "run_cycle", new_callable=AsyncMock): await orch.start() assert orch.is_running is True orch.stop() if orch._task and not orch._task.done(): orch._task.cancel() @pytest.mark.asyncio async def test_double_start_ignored(self): orch = VassalOrchestrator(cycle_interval=9999) with patch.object(orch, "run_cycle", new_callable=AsyncMock): await orch.start() task1 = orch._task await orch.start() # second call — should be ignored assert orch._task is task1 orch.stop() if orch._task and not orch._task.done(): orch._task.cancel() def test_stop_sets_not_running(self): orch = VassalOrchestrator() orch._running = True orch.stop() assert orch.is_running is False