fix(desktop): don't start a cron scheduler when a live gateway owns cron (#52202) - #52259
Open
JoaoMarcos44 wants to merge 3 commits into
Open
fix(desktop): don't start a cron scheduler when a live gateway owns cron (#52202)#52259JoaoMarcos44 wants to merge 3 commits into
JoaoMarcos44 wants to merge 3 commits into
Conversation
…ron (NousResearch#52202) When a launchd/systemd `hermes gateway run` service and the desktop app share one HERMES_HOME, both started an in-process builtin scheduler. They coordinated via cron/.tick.lock (winner-takes-tick), which prevents double-fire but NOT capability: the desktop backend is spawned by the GUI and lacks the gateway's live adapters and inference env. When it won a tick for a job delivering to a live platform (e.g. Telegram), the job ran without an adapter and stalled until the timeout, delivering the generic "provider timeout. Fallback chain was exhausted or unavailable." It was intermittent — a coin flip on which process grabbed the lock each tick. Fix: under HERMES_DESKTOP=1, check is_gateway_running() before starting the ticker. If a live gateway is detected, the desktop stays a passive observer and never starts its own scheduler — restoring the invariant that exactly one scheduler executes jobs per HERMES_HOME. A desktop WITHOUT a gateway still runs cron itself (failure direction is safe: on any detection error we assume no gateway and run the ticker). Defense in depth: bound the standalone delivery send in _deliver_result with asyncio.wait_for(timeout=30). The primary asyncio.run() path was previously unbounded, so a wedged platform HTTP call could hang the cron worker indefinitely; the threadpool fallback already had a 30s budget, now mirrored on the primary path. Liveness check relies on the same cross-platform primitives the gateway uses (PID file + runtime lock, psutil/ctypes pid_exists), verified on Windows/macOS/Linux. Adds docs/fixes/issue-52202-desktop-cron-gateway-race.md.
Collaborator
Related: fix PR for #52202. Detects a live gateway before the desktop backend starts its own cron ticker (passive-observer when one exists, fail-open otherwise), plus a 30s bound on the standalone delivery send. Restores the one-scheduler-per-HERMES_HOME invariant. |
Contributor
|
Thanks for tracing the desktop/gateway ownership collision. The premise remains valid on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
…teway-race # Conflicts: # cron/scheduler.py
…t at startup The startup liveness check in _lifespan only covers gateway-first/ desktop-second: if the desktop cron ticker starts before a gateway comes up, nothing ever re-checks ownership, so both schedulers race on cron/.tick.lock forever once the gateway appears (desktop-first/ gateway-second — the invariant the PR states but didn't fully enforce). _start_desktop_cron_ticker now wires a can_dispatch gate (the same extension point GatewayRunner already uses for drain) that re-checks is_gateway_running() on every tick and sets stop_event once a gateway is detected, so the desktop ticker actually stops instead of continuing to poll as a dead-weight thread. Also resolves the cron/scheduler.py merge conflict against current main: this branch's bounded-timeout fix (_bounded_send, 30s/35s budgets) predated the interpreter-shutdown-graceful-skip and per-target fallback hardening from 242c963 and 8aab8be — both are preserved, with the timeout bound carried into the thread-pool fallback path too. Adds a direct standalone-send timeout test alongside the existing delivery-fallback tests.
Contributor
Author
|
@teknium1 done |
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #52202
Summary
When a launchd/systemd
hermes gateway runservice and the desktop app share oneHERMES_HOME, two in-process cron schedulers race. The desktop dashboard backend (HERMES_DESKTOP=1) starts its own scheduler inhermes_cli/web_server.py::_start_desktop_cron_ticker, alongside the gateway's. They coordinate viacron/.tick.lock(winner-takes-tick), which prevents double-fire but not capability: the desktop backend is spawned by the GUI and lacks the gateway's live adapters and inference env. When it wins a tick for a job delivering to a live platform (e.g. Telegram), the job runs without an adapter and stalls until a timeout, delivering the genericprovider timeout. Fallback chain was exhausted or unavailable.It is intermittent — a coin flip on which process grabs the lock each tick.Failure flow
Root cause
web_server.py::_lifespanstarted the ticker wheneverHERMES_DESKTOP=1, without checking for a live gateway — even though that check already exists and is used elsewhere (hermes_cli/cron.py::find_gateway_pids,gateway/status.py::is_gateway_running).cron/.tick.lock(cron/scheduler.py::tick) coordinates tick exclusion only, not capability. The winner may lack adapters/env — violating the invariant exactly one scheduler executes jobs perHERMES_HOME.cron/scheduler.py::_deliver_result), the primaryasyncio.run(coro)send was unbounded — a wedged platform HTTP call could hang the cron worker indefinitely.Note on the reported "600s"
The issue reports
Script timed out after 600sfor a trivialno_agentjob. For transparency: in the code,_DEFAULT_SCRIPT_TIMEOUT = 120(not 600), and the script timeout applies to the script, not delivery. The number 600 matchesHERMES_CRON_TIMEOUT(the agent inactivity limit, default 600), not the script timeout — so the report conflates two distinct timeouts. This does not weaken the fix: whichever timeout fires, it only fires because the desktop is executing a job it shouldn't. Removing that execution covers every branch (script / delivery / agent inactivity).Fix
1. Detect a live gateway before starting the ticker (primary)
hermes_cli/web_server.py, inside_lifespan. UnderHERMES_DESKTOP=1, callis_gateway_running()first:HERMES_HOME.This is the first option suggested in the issue ("detect a live gateway.pid / gateway lock and skip").
2. Bound the standalone delivery send (defense in depth)
cron/scheduler.py, in_deliver_result. The primary send is now bounded byasyncio.wait_for(..., timeout=30), mirroring the budget the threadpool fallback already had.Behavior matrix
hermes dashboard(noHERMES_DESKTOP)Cross-platform
The liveness check reuses the same primitives the gateway itself uses, each with explicit per-OS handling:
_pid_exists:psutil→ ctypesOpenProcess/WaitForSingleObject(Windows) /os.kill(pid, 0)(POSIX).msvcrt.locking(Windows) /fcntl.flock(POSIX).get_running_pid()does no subprocess/network calls (file I/O only) → no startup delay on any OS.Verified on Windows 11; valid for macOS (launchd) and Linux (systemd), on x86_64 and ARM64.
Tests
test_ticker_skipped_when_gateway_alive: withHERMES_DESKTOP=1and a live gateway, the desktop ticker must not run.TestDesktopCronTicker,tests/cron/test_scheduler.py, andtests/cron/test_scheduler_provider.pypass (79 fix-related tests green on the rebased base).Alternatives considered
Known caveats (not regressions)
.tick.lockis anchored on the shared default root, so the race could recur in that edge case. Pre-existing. Optional mitigation: re-check liveness per tick.A standalone write-up is also added in
docs/fixes/issue-52202-desktop-cron-gateway-race.md.