From ad4680cf74d4252bf2b67a6b5fdbd0edd24a5427 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 19 Apr 2026 18:55:20 -0700 Subject: [PATCH] fix(ci): stub resolve_runtime_provider in cron wake-gate tests + shield update-check timeout test from thread race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additional CI failures surfaced when the first PR ran through GHA — both were pre-existing but blocked merge. 1) tests/cron/test_scheduler.py::TestRunJobWakeGate (3 tests) run_job calls resolve_runtime_provider BEFORE constructing AIAgent, so patching run_agent.AIAgent alone isn't enough — the resolver raises 'No inference provider configured' in hermetic CI (no API keys) and the test never reaches the mocked AIAgent. Added autouse fixture that stubs resolve_runtime_provider with a fake openrouter runtime. 2) tests/hermes_cli/test_update_check.py::test_get_update_result_timeout Observed on CI: assert 4950 is None. A background update-check thread (from an earlier test or hermes_cli.main's own prefetch_update_check call) raced a real git-fetch result (4950 commits behind origin/main) into banner._update_result during this test's wait(0.1). Wrap the test in patch.object(banner, 'check_for_updates', return_value=None) so any in-flight thread writes None rather than a real value. Validation: Under CI-parity env (env -i, no creds): 6/6 pass Broader suite (tests/hermes_cli + cron + gateway + run_agent/streaming + toolsets + discord_tool): 6033 passed, pre-existing failures in telegram_approval_buttons (3) and internal_event_bypass_pairing (1) are unrelated. --- tests/cron/test_scheduler.py | 24 +++++++++++++++++++++ tests/hermes_cli/test_update_check.py | 30 ++++++++++++++++++--------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index c083a4a8..b7bcbc9b 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1239,6 +1239,30 @@ class TestParseWakeGate: class TestRunJobWakeGate: """Integration tests for run_job wake-gate short-circuit.""" + @pytest.fixture(autouse=True) + def _stub_runtime_provider(self): + """Stub ``resolve_runtime_provider`` for wake-gate tests. + + ``run_job`` resolves the runtime provider BEFORE constructing + ``AIAgent``, so these tests must mock ``resolve_runtime_provider`` + in addition to ``AIAgent`` — otherwise in a hermetic CI env (no + API keys), the resolver raises and the test fails before the + patched AIAgent is ever reached. + """ + fake_runtime = { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "test-key", + "source": "stub", + "requested_provider": None, + } + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ): + yield + def _make_job(self, name="wake-gate-test", script="check.py"): """Minimal valid cron job dict for run_job.""" return { diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 84d54752..a29f938d 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -114,20 +114,30 @@ def test_prefetch_non_blocking(): def test_get_update_result_timeout(): - """get_update_result() returns None when check hasn't completed within timeout.""" + """get_update_result() returns None when check hasn't completed within timeout. + + Race protection: a background update-check thread from an earlier + test, or from hermes_cli.main's own prefetch_update_check(), could + write to module-level ``_update_result`` during this test's + ``wait(0.1)``. Observed on CI: a real git-fetch returned 4950 + commits-behind mid-test, failing ``assert 4950 is None``. Patching + ``check_for_updates`` for the duration of the test ensures any + in-flight thread writes ``None`` rather than a real fetch result. + """ import hermes_cli.banner as banner - # Reset module state — don't set the event - banner._update_result = None - banner._update_check_done = threading.Event() + with patch.object(banner, "check_for_updates", return_value=None): + # Fresh Event so we hit the timeout branch deterministically. + banner._update_result = None + banner._update_check_done = threading.Event() - start = time.monotonic() - result = banner.get_update_result(timeout=0.1) - elapsed = time.monotonic() - start + start = time.monotonic() + result = banner.get_update_result(timeout=0.1) + elapsed = time.monotonic() - start - # Should have waited ~0.1s and returned None - assert result is None - assert elapsed < 0.5 + # Should have waited ~0.1s and returned None + assert result is None + assert elapsed < 0.5 def test_invalidate_update_cache_clears_all_profiles(tmp_path):