Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions hermes_cli/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,29 @@ def _active_cron_provider_name() -> str:


def _warn_if_gateway_not_running() -> None:
"""Warn that scheduled jobs won't fire unless the gateway is running.
"""Warn that scheduled jobs won't fire unless a ticker is running.

The cron ticker only runs inside the gateway (``_start_cron_ticker`` in
gateway/run.py); there is no standalone cron daemon. Without a running
gateway, ``next_run_at`` passes but jobs never fire and ``last_run_at``
stays null — the most common cron support report (#51038). Surfacing this
at create/list time, when the user is right there, prevents it.
The built-in cron ticker runs inside either the gateway
(``_start_cron_ticker`` in gateway/run.py) OR the desktop dashboard
backend (``_start_desktop_cron_ticker`` in hermes_cli/web_server.py,
started when ``HERMES_DESKTOP=1``). There is no standalone cron daemon.
Without ANY running ticker, ``next_run_at`` passes but jobs never fire
and ``last_run_at`` stays null — the most common cron support report
(#51038). Surfacing this at create/list time, when the user is right
there, prevents it.

An external provider (e.g. Chronos) fires jobs via a NAS-mediated webhook,
NOT the in-process ticker, so a momentarily-absent gateway process does not
mean jobs won't fire — the warning would be a false alarm. Stay quiet for
any non-builtin provider; the gateway-process heuristic only speaks to the
built-in ticker's trigger.

A fresh ticker heartbeat means a ticker IS running somewhere — most
commonly the desktop backend, whose ``hermes serve`` process is invisible
to ``find_gateway_pids`` (it matches only ``gateway run`` argv). In that
case the "gateway is not running" warning is itself a false alarm and
must be suppressed (issue #53119): the ticker is alive and jobs ARE
firing.
"""
try:
if _active_cron_provider_name() != "builtin":
Expand All @@ -86,8 +96,19 @@ def _warn_if_gateway_not_running() -> None:

if find_gateway_pids():
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please apply this same fresh-heartbeat fallback to cron_status(): current main only evaluates heartbeat state after find_gateway_pids() succeeds (hermes_cli/cron.py:248-266), and otherwise reports that jobs will not fire (:307-313). Without that companion path, desktop users receive conflicting create/list and status results.

# No gateway process — but the desktop backend runs its own ticker
# thread that is invisible to find_gateway_pids. A fresh heartbeat
# means a ticker IS alive, so jobs will fire; the warning would be
# a false positive. Mirror cron_status's staleness threshold so the
# two views agree on what "alive" means.
from cron.jobs import get_ticker_heartbeat_age, TICKER_INTERVAL_SECONDS

hb_age = get_ticker_heartbeat_age()
if hb_age is not None and hb_age <= TICKER_INTERVAL_SECONDS * 3 + 20:
return
except Exception:
# If we can't determine gateway state, stay quiet rather than nag.
# If we can't determine ticker state, stay quiet rather than nag.
return

print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
Expand Down Expand Up @@ -315,12 +336,35 @@ def cron_status():
if hb_age is not None:
print(f" Ticker heartbeat: {int(hb_age)}s ago")
else:
print(color("✗ Gateway is not running — cron jobs will NOT fire", Colors.RED))
print()
print(" To enable automatic execution:")
print(" hermes gateway install # Install as a user service")
print(" sudo hermes gateway install --system # Linux servers: boot-time system service")
print(" hermes gateway # Or run in foreground")
# No gateway process — but the desktop backend runs its own ticker
# thread that is invisible to find_gateway_pids. A fresh heartbeat
# means a ticker IS alive and jobs ARE firing, so the blanket "will
# NOT fire" report is itself a false positive (issue #53119).
# Mirror the same staleness threshold used by the create/list
# warning so the two views agree on what "alive" means.
from cron.jobs import get_ticker_heartbeat_age, TICKER_INTERVAL_SECONDS

STALE_AFTER = TICKER_INTERVAL_SECONDS * 3 + 20 # = 200s at 60s default
hb_age = get_ticker_heartbeat_age()
if hb_age is not None and hb_age <= STALE_AFTER:
# A ticker is alive (desktop backend), just not the gateway.
print(color(
"✓ Cron ticker is running — jobs will fire automatically",
Colors.GREEN,
))
print(f" Ticker heartbeat: {int(hb_age)}s ago")
print(color(
" (No gateway process detected; the ticker is hosted by the "
"desktop dashboard backend or another non-gateway process.)",
Colors.DIM,
))
else:
print(color("✗ Gateway is not running — cron jobs will NOT fire", Colors.RED))
print()
print(" To enable automatic execution:")
print(" hermes gateway install # Install as a user service")
print(" sudo hermes gateway install --system # Linux servers: boot-time system service")
print(" hermes gateway # Or run in foreground")

print()

Expand Down
172 changes: 169 additions & 3 deletions tests/hermes_cli/test_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,117 @@ def test_create_with_multiple_skills(self, tmp_cron_dir, capsys):


class TestGatewayNotRunningWarning:
"""`cron create` / `cron list` must warn when the gateway (and thus the
cron ticker) isn't running, since jobs only fire inside the gateway.
"""`cron create` / `cron list` must warn when no ticker is running.

The ticker can run in either the gateway (``gateway run``) or the
desktop dashboard backend (``hermes serve`` under ``HERMES_DESKTOP=1``).
Regression guard for #51038 — the most common cron 'jobs never fired'
report was simply a gateway that was never started.
report was simply a gateway that was never started — and #53119, where
the warning fired even on a healthy desktop backend whose ticker is
invisible to ``find_gateway_pids``.
"""

def test_create_warns_when_gateway_absent(self, tmp_cron_dir, capsys, monkeypatch):
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# No gateway AND no heartbeat → warn.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: None)
cron_command(
Namespace(
cron_command="create",
schedule="0 11 * * *",
prompt="Daily report",
name="Daily 1130",
deliver=None,
repeat=None,
skill=None,
skills=None,
script=None,
workdir=None,
no_agent=False,
)
)
out = capsys.readouterr().out
assert "Created job" in out
assert "Gateway is not running" in out

def test_create_silent_when_gateway_running(self, tmp_cron_dir, capsys, monkeypatch):
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [4242])
cron_command(
Namespace(
cron_command="create",
schedule="0 11 * * *",
prompt="Daily report",
name="Daily 1130",
deliver=None,
repeat=None,
skill=None,
skills=None,
script=None,
workdir=None,
no_agent=False,
)
)
out = capsys.readouterr().out
assert "Created job" in out
assert "Gateway is not running" not in out

def test_list_warns_when_gateway_absent(self, tmp_cron_dir, capsys, monkeypatch):
create_job(prompt="Daily report", schedule="0 11 * * *")
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# No gateway AND no heartbeat → warn.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: None)
cron_command(Namespace(cron_command="list", all=True))
out = capsys.readouterr().out
assert "Gateway is not running" in out

def test_create_silent_when_desktop_ticker_alive(self, tmp_cron_dir, capsys, monkeypatch):
"""No gateway process, but a fresh ticker heartbeat means the
desktop backend's ticker IS running. The warning is a false alarm
and must be suppressed (issue #53119).
"""
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# Fresh heartbeat — well under the 200s staleness threshold.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: 5.0)
cron_command(
Namespace(
cron_command="create",
schedule="0 11 * * *",
prompt="Daily report",
name="Daily 1130",
deliver=None,
repeat=None,
skill=None,
skills=None,
script=None,
workdir=None,
no_agent=False,
)
)
out = capsys.readouterr().out
assert "Created job" in out
assert "Gateway is not running" not in out

def test_list_silent_when_desktop_ticker_alive(self, tmp_cron_dir, capsys, monkeypatch):
"""The list view must also suppress the warning when a ticker
heartbeat is fresh, so `hermes cron list` on a desktop-only setup
doesn't falsely claim jobs won't fire (issue #53119).
"""
create_job(prompt="Daily report", schedule="0 11 * * *")
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: 12.0)
cron_command(Namespace(cron_command="list", all=True))
out = capsys.readouterr().out
assert "Daily report" in out
assert "Gateway is not running" not in out

def test_warns_when_heartbeat_stale(self, tmp_cron_dir, capsys, monkeypatch):
"""A stale heartbeat (well past the 200s threshold) means no live
ticker — the warning must fire even though the heartbeat file exists.
"""
create_job(prompt="Daily report", schedule="0 11 * * *")
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# 600s old — far past the 200s STALE_AFTER threshold.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: 600.0)
cron_command(Namespace(cron_command="list", all=True))
out = capsys.readouterr().out
assert "Gateway is not running" in out
Expand Down Expand Up @@ -149,6 +250,19 @@ def test_status_reports_provider_not_ticker_for_chronos(
# Still surfaces the active-job summary.
assert "active job(s)" in out

def test_status_unchanged_for_builtin(self, tmp_cron_dir, capsys, monkeypatch):
create_job(prompt="Ping", schedule="every 2m")
monkeypatch.setattr(
"hermes_cli.cron._active_cron_provider_name", lambda: "builtin"
)
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# No gateway process AND no heartbeat -> historical "not running" report.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: None)
cron_command(Namespace(cron_command="status"))
out = capsys.readouterr().out
# Built-in path is the historical ticker-based report.
assert "Gateway is not running" in out
assert "managed scheduler" not in out

def test_create_silent_for_chronos_even_without_gateway(
self, tmp_cron_dir, capsys, monkeypatch
Expand Down Expand Up @@ -179,6 +293,56 @@ def test_create_silent_for_chronos_even_without_gateway(
assert "Gateway is not running" not in out


class TestCronStatusDesktopTicker:
"""`cron status` must recognize a live desktop ticker when no gateway
process is visible, mirroring the create/list warning fix (issue #53119).

Before this fix, `cron status` evaluated heartbeat state only inside
``if pids:`` and otherwise unconditionally reported "will NOT fire" --
contradicting the create/list views that correctly suppressed the warning.
"""

def test_status_reports_ticker_when_desktop_alive_no_gateway(
self, tmp_cron_dir, capsys, monkeypatch
):
"""No gateway PID but a fresh heartbeat -> status reports the ticker
is running, NOT that jobs will not fire."""
create_job(prompt="Ping", schedule="every 2m")
monkeypatch.setattr(
"hermes_cli.cron._active_cron_provider_name", lambda: "builtin"
)
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# Fresh heartbeat -- well under the 200s staleness threshold.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: 8.0)
cron_command(Namespace(cron_command="status"))
out = capsys.readouterr().out
assert "Cron ticker is running" in out
assert "jobs will fire" in out
assert "Ticker heartbeat: 8s ago" in out
# Must NOT claim jobs will not fire.
assert "will NOT fire" not in out
assert "Gateway is not running" not in out
# Still surfaces the active-job summary.
assert "active job(s)" in out

def test_status_reports_not_firing_when_heartbeat_stale_no_gateway(
self, tmp_cron_dir, capsys, monkeypatch
):
"""No gateway PID and a STALE heartbeat -> status correctly reports
jobs will not fire (the ticker is dead)."""
create_job(prompt="Ping", schedule="every 2m")
monkeypatch.setattr(
"hermes_cli.cron._active_cron_provider_name", lambda: "builtin"
)
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
# 600s old -- far past the 200s STALE_AFTER threshold.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: 600.0)
cron_command(Namespace(cron_command="status"))
out = capsys.readouterr().out
assert "will NOT fire" in out
assert "Cron ticker is running" not in out


def test_cron_list_warns_when_gateway_not_running(monkeypatch, capsys):
monkeypatch.setattr(
"cron.jobs.list_jobs",
Expand All @@ -196,6 +360,8 @@ def test_cron_list_warns_when_gateway_not_running(monkeypatch, capsys):
)
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [])
monkeypatch.setattr(cron_cli, "_active_cron_provider_name", lambda: "builtin")
# No gateway AND no heartbeat file → warn.
monkeypatch.setattr("cron.jobs.get_ticker_heartbeat_age", lambda: None)

cron_cli.cron_list()

Expand Down