feat(desktop): fire cron jobs from the dashboard backend

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.
This commit is contained in:
Brooklyn Nicholson 2026-06-06 12:42:32 -05:00
parent 628f9040df
commit 3e2d758816
3 changed files with 86 additions and 1 deletions

View file

@ -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,

View file

@ -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"):

View file

@ -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"