mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
628f9040df
commit
3e2d758816
3 changed files with 86 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue