From 3e2d758816b72a0151cfaad8933bb073a74871f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:42:32 -0500 Subject: [PATCH] feat(desktop): fire cron jobs from the dashboard backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron scheduler tick loop only ran inside `hermes gateway run`, but the desktop app spawns a `hermes dashboard` backend with no gateway — so any cron a user created in the app was saved and never fired (silently). Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard` is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never double-fires alongside a real gateway. --- apps/desktop/electron/main.cjs | 6 ++++ hermes_cli/web_server.py | 46 ++++++++++++++++++++++++++++- tests/hermes_cli/test_web_server.py | 35 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3ea31b2720f..09e5dfac6b6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4274,6 +4274,9 @@ async function spawnPoolBackend(profile, entry) { HERMES_HOME, ...backend.env, HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', HERMES_WEB_DIST: webDist }, shell: backend.shell, @@ -4415,6 +4418,9 @@ async function startHermes() { HERMES_HOME, ...backend.env, HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', HERMES_WEB_DIST: webDist }, shell: backend.shell, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8afb820988d..6bf554a98f0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -102,11 +102,55 @@ _log = logging.getLogger(__name__) # when the same module is used across TestClient instances or uvicorn reloads. # --------------------------------------------------------------------------- +def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60) -> None: + """Tick the cron scheduler from inside the desktop dashboard backend. + + The scheduler tick loop normally lives in ``hermes gateway run`` — but the + desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron + a user creates in the app would never fire. We run a minimal ticker here + (no live adapters; delivery falls back to the per-platform send path). + + Cross-process safe: ``cron.scheduler.tick`` takes the ``cron/.tick.lock`` + file lock, so this never double-fires alongside a real gateway on the same + HERMES_HOME — whichever process grabs the lock first wins the tick. + """ + from cron.scheduler import tick as cron_tick + + _log.info("Desktop cron ticker started (interval=%ds)", interval) + # Tick once up front (catches jobs due at launch), then on the interval. + while not stop_event.is_set(): + try: + cron_tick(verbose=False, sync=False) + except Exception as e: + _log.debug("Desktop cron tick error: %s", e) + stop_event.wait(interval) + + @asynccontextmanager async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() - yield + + # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, + # since the app has no gateway running the scheduler. Server `hermes + # dashboard` is unaffected — it relies on its own gateway. + cron_stop: "threading.Event | None" = None + cron_thread: "threading.Thread | None" = None + if os.getenv("HERMES_DESKTOP") == "1": + cron_stop = threading.Event() + cron_thread = threading.Thread( + target=_start_desktop_cron_ticker, + args=(cron_stop,), + daemon=True, + name="desktop-cron-ticker", + ) + cron_thread.start() + + try: + yield + finally: + if cron_stop is not None: + cron_stop.set() def _get_event_state(app: "FastAPI"): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 60d2b7b5c18..bb3085eff22 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4264,3 +4264,38 @@ class TestValidateProviderCredential: def test_empty_value_rejected(self): data = self._post("OPENAI_API_KEY", " ").json() assert data["ok"] is False + + +class TestDesktopCronTicker: + """The dashboard backend fires cron jobs itself only when desktop-spawned.""" + + def _client(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app + + return TestClient(app) + + def test_ticker_runs_when_desktop(self, monkeypatch, _isolate_hermes_home): + import threading + import cron.scheduler as sched + + called = threading.Event() + monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set()) + monkeypatch.setenv("HERMES_DESKTOP", "1") + + with self._client(): + assert called.wait(3.0), "expected cron tick under HERMES_DESKTOP=1" + + def test_ticker_skipped_without_desktop(self, monkeypatch, _isolate_hermes_home): + import threading + import cron.scheduler as sched + + called = threading.Event() + monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set()) + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + + with self._client(): + assert not called.wait(0.5), "ticker must not run outside the desktop app"