From 32c538851aa77fc613336c32d7536502421f31ce Mon Sep 17 00:00:00 2001 From: tachyon-r <291518778+tachyon-r@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:55:35 -0400 Subject: [PATCH] fix(desktop): yield single-profile cron to its running gateway (cherry picked from commit db92ff258f0138a251b2c4e14b32f2fa0b7b8d5b) --- hermes_cli/web_server.py | 4 ++- ...est_cron_multiplex_desktop_ticker_scope.py | 24 ++++++++++++----- .../test_desktop_cron_ticker_profiles.py | 26 ++++++++++++++----- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 72bfab2b4c..788e531b5d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -100,7 +100,9 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 from hermes_cli.profiles import profiles_to_serve profile_homes = list(profiles_to_serve(multiplex=True)) - if len(profile_homes) > 1: + if profile_homes: + # Even one profile needs the per-tick gateway gate; otherwise + # Desktop races its dedicated gateway for the same cron store. start_kwargs["profile_homes"] = profile_homes # Stand down, per tick, for a profile whose OWN gateway runs: # it ticks with live adapters, and the tick-lock race would diff --git a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py index a01aa0ae3c..1fcc20308e 100644 --- a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py +++ b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py @@ -15,6 +15,8 @@ import asyncio import threading from unittest.mock import patch +import pytest + def test_standalone_fallback_pool_keeps_profile_scope(tmp_path, monkeypatch): @@ -105,16 +107,18 @@ def test_multiplex_ticker_profile_gate_skips_rejected_profile(tmp_path): assert (orphan / "cron" / "ticker_last_success").exists() -def test_desktop_ticker_gates_on_profile_gateway_running(tmp_path, monkeypatch): - """The desktop ticker wires the gate to ``_check_gateway_running``.""" +@pytest.mark.parametrize("profile_count", [1, 2]) +def test_desktop_ticker_gates_on_profile_gateway_running(tmp_path, monkeypatch, profile_count): + """Desktop yields to each live gateway, including a single-profile install.""" from hermes_cli import web_server - homes = [("default", tmp_path / "default"), ("ops", tmp_path / "ops")] + homes = [("default", tmp_path / "default"), ("ops", tmp_path / "ops")][:profile_count] + running = {homes[-1][1]} monkeypatch.setattr( "hermes_cli.profiles.profiles_to_serve", lambda multiplex=False: list(homes) ) monkeypatch.setattr( - "hermes_cli.profiles._check_gateway_running", lambda home: home.name == "ops" + "hermes_cli.profiles._check_gateway_running", lambda home: home in running ) captured = {} @@ -133,7 +137,15 @@ def test_desktop_ticker_gates_on_profile_gateway_running(tmp_path, monkeypatch): web_server._start_desktop_cron_ticker(threading.Event(), interval=0) + assert captured.get("profile_homes") == homes gate = captured.get("profile_gate") assert gate is not None, "desktop ticker did not install a profile gate" - assert gate("default", tmp_path / "default") is True - assert gate("ops", tmp_path / "ops") is False + for name, home in homes: + assert gate(name, home) is (home not in running) + + # Re-evaluate on each tick: Desktop resumes fallback after gateway exit + # and stands down again if a gateway starts later. + running.clear() + assert all(gate(name, home) for name, home in homes) + running.update(home for _, home in homes) + assert not any(gate(name, home) for name, home in homes) diff --git a/tests/hermes_cli/test_desktop_cron_ticker_profiles.py b/tests/hermes_cli/test_desktop_cron_ticker_profiles.py index 20e69aa0de..b88a7d1150 100644 --- a/tests/hermes_cli/test_desktop_cron_ticker_profiles.py +++ b/tests/hermes_cli/test_desktop_cron_ticker_profiles.py @@ -70,19 +70,31 @@ def test_multi_profile_homes_passed_to_builtin(monkeypatch, _providers, tmp_path assert builtin.start_kwargs["profile_homes"] == homes -def test_single_profile_keeps_legacy_path(monkeypatch, _providers, tmp_path): - _sp, builtin = _providers +@pytest.mark.parametrize("gateway_running", [True, False]) +def test_single_profile_ticks_only_without_gateway(monkeypatch, tmp_path, gateway_running): + """Exercise Desktop startup through the real built-in scheduler loop.""" + from cron.scheduler_provider import InProcessCronScheduler + from hermes_constants import get_hermes_home import hermes_cli.profiles as profiles_mod + home = tmp_path / "root" + home.mkdir() + monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda **_kw: [("default", home)]) + monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda _home: gateway_running) monkeypatch.setattr( - profiles_mod, - "profiles_to_serve", - lambda **_kw: [("default", tmp_path / "root")], + "cron.scheduler_provider.resolve_cron_scheduler", InProcessCronScheduler ) + ticked = [] + monkeypatch.setattr( + "cron.scheduler.tick", lambda **_kw: ticked.append(get_hermes_home()) + ) + stop = threading.Event() + # One real scheduler cycle, with no wall-clock wait or job dispatch. + monkeypatch.setattr(stop, "wait", lambda _timeout: stop.set()) - ws._start_desktop_cron_ticker(threading.Event(), interval=9) + ws._start_desktop_cron_ticker(stop, interval=0) - assert builtin.start_kwargs == {"interval": 9} + assert ticked == ([] if gateway_running else [home]) def test_enumeration_failure_fails_open(monkeypatch, _providers):