Skip to content

fix(desktop): don't start a cron scheduler when a live gateway owns cron (#52202) - #52259

Open
JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/desktop-cron-gateway-race
Open

fix(desktop): don't start a cron scheduler when a live gateway owns cron (#52202)#52259
JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/desktop-cron-gateway-race

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #52202

Summary

When a launchd/systemd hermes gateway run service and the desktop app share one HERMES_HOME, two in-process cron schedulers race. The desktop dashboard backend (HERMES_DESKTOP=1) starts its own scheduler in hermes_cli/web_server.py::_start_desktop_cron_ticker, alongside the gateway's. They coordinate 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 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 generic provider timeout. Fallback chain was exhausted or unavailable. It is intermittent — a coin flip on which process grabs the lock each tick.

Failure flow

                 cron/.tick.lock  (winner-takes-tick — mutual exclusion only,
                        |          NOT a guarantee of execution capability)
        +---------------+----------------+
        v                                v
  [gateway run]                     [desktop backend]  HERMES_DESKTOP=1
  adapters = LIVE, loop = LIVE      adapters = None, loop = None
  inference env OK                  inference env absent (spawned by GUI)
        |                                |
        v                                v
   run_one_job -> run_job           run_one_job -> run_job
        |                                |
   delivers via LIVE adapter -> OK  no live adapter -> cold/standalone path
                                         |  stalls until the timeout fires
                                         v
                                 failure summarized + delivered:
                                 "provider timeout. Fallback chain was
                                  exhausted or unavailable."

Root cause

  1. web_server.py::_lifespan started the ticker whenever HERMES_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).
  2. 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 per HERMES_HOME.
  3. Latent secondary bug: in the standalone delivery path (cron/scheduler.py::_deliver_result), the primary asyncio.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 600s for a trivial no_agent job. For transparency: in the code, _DEFAULT_SCRIPT_TIMEOUT = 120 (not 600), and the script timeout applies to the script, not delivery. The number 600 matches HERMES_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. Under HERMES_DESKTOP=1, call is_gateway_running() first:

  • Live gateway detected → the desktop stays a passive observer and never starts its own scheduler. The gateway becomes the sole cron executor for that HERMES_HOME.
  • No gateway (desktop-only install) → the ticker starts as before.
  • Detection error → assume no gateway and start the ticker (safe failure direction: a desktop-only install never loses cron).
if os.getenv("HERMES_DESKTOP") == "1":
    gateway_alive = False
    try:
        from gateway.status import is_gateway_running
        gateway_alive = is_gateway_running()  # PID file + runtime lock, with stale cleanup
    except Exception:
        gateway_alive = False
    if gateway_alive:
        _log.info(
            "Desktop backend: live gateway detected — not starting an own cron "
            "scheduler (the gateway is the sole cron executor for this HERMES_HOME)."
        )
    else:
        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()

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 by asyncio.wait_for(..., timeout=30), mirroring the budget the threadpool fallback already had.

def _bounded_send():
    return asyncio.wait_for(
        _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content,
                          thread_id=thread_id, media_files=media_files),
        timeout=30,
    )
coro = _bounded_send()
try:
    result = asyncio.run(coro)
except RuntimeError:
    coro.close()  # avoid "coroutine was never awaited"
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
        future = pool.submit(asyncio.run, _bounded_send())
        result = future.result(timeout=35)

Behavior matrix

Scenario Before After
Gateway + desktop, gateway wins tick OK OK (unchanged)
Gateway + desktop, desktop wins tick stalls → timeout → failure delivered desktop never executes; gateway is sole executor ✅
Desktop only (no gateway) runs cron runs cron (unchanged) ✅
Gateway only OK OK (unchanged)
Server hermes dashboard (no HERMES_DESKTOP) n/a branch not reached ✅

Cross-platform

The liveness check reuses the same primitives the gateway itself uses, each with explicit per-OS handling:

  • _pid_exists: psutil → ctypes OpenProcess/WaitForSingleObject (Windows) / os.kill(pid, 0) (POSIX).
  • Runtime lock: 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

  • New test_ticker_skipped_when_gateway_alive: with HERMES_DESKTOP=1 and a live gateway, the desktop ticker must not run.
  • Existing TestDesktopCronTicker, tests/cron/test_scheduler.py, and tests/cron/test_scheduler_provider.py pass (79 fix-related tests green on the rebased base).
python -m pytest tests/hermes_cli/test_web_server.py::TestDesktopCronTicker -q
python -m pytest tests/cron/test_scheduler.py tests/cron/test_scheduler_provider.py -q

Alternatives considered

Alternative Why not
Hand off delivery to the gateway via IPC Much more surface/complexity; a new channel to maintain
Validate adapter capability in the lock winner Treats the symptom, not the duplicate schedulers; inference env is hard to auto-detect reliably
Desktop never executes jobs Breaks the desktop-only install, where the ticker is the only executor

Known caveats (not regressions)

  • Divergent HERMES_HOME/profile between gateway and desktop: if they run in different homes, the desktop may not see the gateway and start the ticker; .tick.lock is anchored on the shared default root, so the race could recur in that edge case. Pre-existing. Optional mitigation: re-check liveness per tick.
  • Startup race: if the desktop comes up before the gateway, the check sees "no gateway" and starts the ticker. Optional mitigation: periodic re-check.

A standalone write-up is also added in docs/fixes/issue-52202-desktop-cron-gateway-race.md.

…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.
@alt-glitch alt-glitch added type/bug Something isn't working comp/cron Cron scheduler and job management comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jun 25, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

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.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing the desktop/gateway ownership collision. The premise remains valid on current main: hermes_cli/web_server.py:136-154 documents that the desktop ticker has no live adapters, while _lifespan starts it unconditionally at hermes_cli/web_server.py:197-205; the gateway independently starts its adapter-backed provider at gateway/run.py:20831-20846.

Problems

  • The proposed liveness check runs only during desktop startup. If desktop starts first and the gateway starts later, both schedulers remain active. That leaves the PR's stated one-scheduler invariant unmet; the PR body also identifies this startup race.
  • The cron/scheduler.py delivery hunk predates current-main hardening in the same block. cron/scheduler.py:1889-1955 now contains shutdown and per-target fallback handling from 242c9639a8 and 8aab8be50c; preserve those paths when carrying over the timeout bound.

Suggested changes

  • Re-check gateway ownership during desktop ticking and stop the desktop ticker when the gateway becomes live; cover desktop-first/gateway-second startup.
  • Add a direct standalone-send timeout test while retaining the current delivery fallback behavior.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
claude added 2 commits July 15, 2026 12:59
…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.
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

@teknium1 done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop dashboard cron scheduler races the gateway; when it wins a tick it has no live adapter and delivery hangs until script_timeout (600s)

4 participants