Skip to content

Decouple cron-ticker liveness from tick execution (gateway heartbeat thread) - #39720

Closed
tjkang wants to merge 1 commit into
NousResearch:mainfrom
tjkang:heartbeat-decoupling
Closed

Decouple cron-ticker liveness from tick execution (gateway heartbeat thread)#39720
tjkang wants to merge 1 commit into
NousResearch:mainfrom
tjkang:heartbeat-decoupling

Conversation

@tjkang

@tjkang tjkang commented Jun 5, 2026

Copy link
Copy Markdown

Problem

cron/scheduler.py::tick() runs on a single in-process background thread
(gateway/run.py::_start_cron_ticker, 60s interval). It acquires
fcntl.flock(LOCK_EX) on <HERMES_HOME>/cron/.tick.lock and holds it for the
entire tick
, and tick() runs workdir/profile jobs sequentially
(intended invariant — those jobs mutate process-global _hermes_home/os.environ).

When a sequential job is long-running (e.g. a multi-minute browser+LLM pipeline),
the ticker thread is monopolised for that whole duration. Consequences:

  • The next 60s tick fails LOCK_NB and returns early → .tick.lock mtime freezes.
  • Any external liveness monitor keyed on .tick.lock mtime false-alarms
    "cron stopped" even though the gateway is perfectly healthy and the job is
    progressing normally.

The freeze is not a throughput bug (long sequential execution is by design).
The only real defect is that the liveness signal is coupled to the work thread.

Fix

A dedicated daemon thread bumps a separate <HERMES_HOME>/cron/.gateway.heartbeat
file mtime every 30s, independent of tick():

  • cron/scheduler.py: add _get_heartbeat_path() and heartbeat_loop(stop_event, alive_check, interval=30).
    The bump is a path-based touch + os.utime — unaffected by the flock held
    on the different .tick.lock inode, so it stays fresh mid-tick.
  • gateway/run.py: start the heartbeat thread next to the cron ticker and stop
    it on shutdown. The bump is gated on _gateway_alive(), which actively probes
    the event loop with loop.call_soon_threadsafe(event.set) + event.wait(timeout)
    — if the loop is genuinely hung the probe never returns, the heartbeat stops,
    and the monitor correctly fires.

.tick.lock keeps its original meaning ("a tick actually ran"), so tick deadlocks
remain detectable; .gateway.heartbeat answers the distinct question "is the
gateway event loop alive?".

Why a separate file (not reusing .tick.lock)

Reusing .tick.lock for the heartbeat would conflate two signals — a healthy
event loop with a stuck/deadlocked cron tick would then be indistinguishable.
A distinct file preserves both diagnostics.

Invariants preserved

  • tick() lock acquisition/release and the sequential/parallel job partition are
    unchanged (byte-for-byte).
  • The heartbeat never bumps unconditionally — the _gateway_alive() gate is a
    hard requirement, otherwise an always-on heartbeat would permanently mask real
    outages from the health monitor.

Tests

Unit test covers: heartbeat stays fresh while a long job holds the tick lock;
alive_check()==False ⇒ no bump (hung-loop gate); prompt exit on stop_event;
path-based auto-create.

Notes for adopters

External monitors should treat .gateway.heartbeat as the gateway-liveness
signal and .tick.lock as the tick-progress signal: a stale .tick.lock with a
fresh .gateway.heartbeat means a long job is in progress (no alarm).

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery labels Jun 5, 2026
A long-running sequential cron job monopolises the single cron-ticker
thread and holds the .tick.lock flock for its whole duration, freezing
the lock's mtime. External liveness monitors keyed on that mtime then
false-alarm "cron stopped" even though the gateway is healthy.

Add a dedicated daemon heartbeat thread that bumps a separate
<HERMES_HOME>/cron/.gateway.heartbeat file every 30s, gated on an
event-loop liveness probe so a genuinely hung loop still trips the
monitor. tick() locking and the sequential/parallel job partition are
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the careful separation of tick progress from gateway liveness. This is now redundant on current main.

  • Current gateway ticks call cron_tick(..., sync=False) in cron/scheduler_provider.py:179.
  • Workdir/sequential jobs are queued to a persistent single-worker pool without blocking the ticker (cron/scheduler.py:3704-3717); async ticker mode returns without awaiting jobs (cron/scheduler.py:3759-3782) and then releases .tick.lock (cron/scheduler.py:3783-3794).
  • Commit 475bb334a30318960f5cfe40e6ad7d9d910e13e8 implemented that non-blocking sequential dispatch after this PR's base, removing the lock-mtime freeze described here.
  • The PR's gateway startup target has also been superseded by provider-based startup in gateway/run.py:20831-20846.

Automated hermes-sweeper review.

@teknium1 teknium1 closed this Jul 14, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:implemented-on-main Sweeper: behavior already present on current main type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants