2026-02-02 19:01:51 -08:00
|
|
|
"""
|
|
|
|
|
Cron subcommand for hermes CLI.
|
|
|
|
|
|
2026-03-14 19:18:10 -07:00
|
|
|
Handles standalone cron management commands like list, create, edit,
|
|
|
|
|
pause/resume/run/remove, status, and tick.
|
2026-02-02 19:01:51 -08:00
|
|
|
"""
|
|
|
|
|
|
2026-03-14 19:18:10 -07:00
|
|
|
import json
|
2026-05-23 06:49:01 +03:00
|
|
|
import re
|
2026-02-02 19:01:51 -08:00
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
2026-03-14 19:18:10 -07:00
|
|
|
from typing import Iterable, List, Optional
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
2026-02-20 23:23:32 -08:00
|
|
|
from hermes_cli.colors import Colors, color
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-05-23 06:49:01 +03:00
|
|
|
# Patterns that indicate a cron job targets the gateway lifecycle.
|
|
|
|
|
# Matches commands that restart/stop the gateway or its service manager.
|
2026-05-30 21:02:53 -07:00
|
|
|
# Deliberately specific — a bare "gateway ... restart" catch-all would block
|
|
|
|
|
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
|
|
|
|
|
# the API gateway logs and report restart events").
|
2026-05-23 06:49:01 +03:00
|
|
|
_GATEWAY_LIFECYCLE_PATTERNS = re.compile(
|
|
|
|
|
r"(?i)"
|
|
|
|
|
r"(hermes\s+gateway\s+(restart|stop|start))"
|
|
|
|
|
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
|
2026-06-02 18:34:26 +03:00
|
|
|
r"|(systemctl\s+(-\S+\s+)*(restart|stop|start)\s+.*hermes)"
|
2026-05-23 06:49:01 +03:00
|
|
|
r"|(p?kill\s+.*hermes.*gateway)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _contains_gateway_lifecycle_command(text: str) -> bool:
|
|
|
|
|
"""Return True if *text* contains a gateway lifecycle command pattern."""
|
|
|
|
|
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-03-14 19:18:10 -07:00
|
|
|
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
|
|
|
|
|
if skills is None:
|
|
|
|
|
if single_skill is None:
|
|
|
|
|
return None
|
|
|
|
|
raw_items = [single_skill]
|
|
|
|
|
else:
|
|
|
|
|
raw_items = list(skills)
|
|
|
|
|
|
|
|
|
|
normalized: List[str] = []
|
|
|
|
|
for item in raw_items:
|
|
|
|
|
text = str(item or "").strip()
|
|
|
|
|
if text and text not in normalized:
|
|
|
|
|
normalized.append(text)
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cron_api(**kwargs):
|
|
|
|
|
from tools.cronjob_tools import cronjob as cronjob_tool
|
|
|
|
|
|
|
|
|
|
return json.loads(cronjob_tool(**kwargs))
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 14:03:02 +10:00
|
|
|
def _active_cron_provider_name() -> str:
|
|
|
|
|
"""Name of the resolved cron scheduler provider ('builtin', 'chronos', …).
|
|
|
|
|
|
|
|
|
|
Best-effort + offline (``resolve_cron_scheduler`` reads config and the
|
|
|
|
|
provider's ``is_available()`` contract forbids network). Returns 'builtin'
|
|
|
|
|
on any failure so callers fall back to the historical ticker-based checks.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from cron.scheduler_provider import resolve_cron_scheduler
|
|
|
|
|
|
|
|
|
|
return resolve_cron_scheduler().name or "builtin"
|
|
|
|
|
except Exception:
|
|
|
|
|
return "builtin"
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 23:29:50 -07:00
|
|
|
def _warn_if_gateway_not_running() -> None:
|
|
|
|
|
"""Warn that scheduled jobs won't fire unless the gateway 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.
|
2026-06-29 14:03:02 +10:00
|
|
|
|
|
|
|
|
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.
|
2026-06-23 23:29:50 -07:00
|
|
|
"""
|
|
|
|
|
try:
|
2026-06-29 14:03:02 +10:00
|
|
|
if _active_cron_provider_name() != "builtin":
|
|
|
|
|
return
|
|
|
|
|
|
2026-06-23 23:29:50 -07:00
|
|
|
from hermes_cli.gateway import find_gateway_pids
|
|
|
|
|
|
|
|
|
|
if find_gateway_pids():
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
# If we can't determine gateway state, stay quiet rather than nag.
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
|
|
|
|
|
print(color(" Start it with: hermes gateway install", Colors.DIM))
|
|
|
|
|
print(color(" sudo hermes gateway install --system # Linux servers", Colors.DIM))
|
|
|
|
|
print(color(" Check status: hermes cron status", Colors.DIM))
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
def cron_list(show_all: bool = False):
|
|
|
|
|
"""List all scheduled jobs."""
|
|
|
|
|
from cron.jobs import list_jobs
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
jobs = list_jobs(include_disabled=show_all)
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
if not jobs:
|
|
|
|
|
print(color("No scheduled jobs.", Colors.DIM))
|
2026-03-14 19:18:10 -07:00
|
|
|
print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM))
|
2026-02-02 19:01:51 -08:00
|
|
|
return
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
print()
|
|
|
|
|
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
|
|
|
|
|
print(color("│ Scheduled Jobs │", Colors.CYAN))
|
|
|
|
|
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
|
|
|
|
|
print()
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
for job in jobs:
|
2026-03-30 19:05:34 -07:00
|
|
|
job_id = job.get("id", "?")
|
2026-02-02 19:01:51 -08:00
|
|
|
name = job.get("name", "(unnamed)")
|
|
|
|
|
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
|
2026-03-14 19:18:10 -07:00
|
|
|
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
|
2026-02-02 19:01:51 -08:00
|
|
|
next_run = job.get("next_run_at", "?")
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-06-04 19:13:11 -07:00
|
|
|
# `repeat` may be present-but-null in the job record (e.g. a one-shot
|
|
|
|
|
# job persisted with "repeat": null), so coalesce to {} rather than
|
|
|
|
|
# relying on the dict-default, which only applies to a missing key.
|
|
|
|
|
repeat_info = job.get("repeat") or {}
|
2026-02-02 19:01:51 -08:00
|
|
|
repeat_times = repeat_info.get("times")
|
|
|
|
|
repeat_completed = repeat_info.get("completed", 0)
|
2026-03-14 19:18:10 -07:00
|
|
|
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
deliver = job.get("deliver", ["local"])
|
|
|
|
|
if isinstance(deliver, str):
|
|
|
|
|
deliver = [deliver]
|
|
|
|
|
deliver_str = ", ".join(deliver)
|
2026-03-14 19:18:10 -07:00
|
|
|
|
|
|
|
|
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
|
|
|
|
|
if state == "paused":
|
|
|
|
|
status = color("[paused]", Colors.YELLOW)
|
|
|
|
|
elif state == "completed":
|
|
|
|
|
status = color("[completed]", Colors.BLUE)
|
|
|
|
|
elif job.get("enabled", True):
|
2026-02-02 19:01:51 -08:00
|
|
|
status = color("[active]", Colors.GREEN)
|
2026-03-14 19:18:10 -07:00
|
|
|
else:
|
|
|
|
|
status = color("[disabled]", Colors.RED)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
print(f" {color(job_id, Colors.YELLOW)} {status}")
|
|
|
|
|
print(f" Name: {name}")
|
|
|
|
|
print(f" Schedule: {schedule}")
|
|
|
|
|
print(f" Repeat: {repeat_str}")
|
|
|
|
|
print(f" Next run: {next_run}")
|
|
|
|
|
print(f" Deliver: {deliver_str}")
|
2026-03-14 19:18:10 -07:00
|
|
|
if skills:
|
|
|
|
|
print(f" Skills: {', '.join(skills)}")
|
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
|
|
|
script = job.get("script")
|
|
|
|
|
if script:
|
|
|
|
|
print(f" Script: {script}")
|
feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)
* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
2026-05-04 12:31:01 -07:00
|
|
|
if job.get("no_agent"):
|
|
|
|
|
print(f" Mode: {color('no-agent', Colors.DIM)} (script stdout delivered directly)")
|
feat(cron): per-job workdir for project-aware cron runs (#15110)
Cron jobs can now specify a per-job working directory. When set, the job
runs as if launched from that directory: AGENTS.md / CLAUDE.md /
.cursorrules from that dir are injected into the system prompt, and the
terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD).
When unset, old behaviour is preserved (no project context files, tools
use the scheduler's cwd).
Requested by @bluthcy.
## Mechanism
- cron/jobs.py: create_job / update_job accept 'workdir'; validated to
be an absolute existing directory at create/update time.
- cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD
at it and flip skip_context_files to False before building the agent.
Restored in finally on every exit path.
- cron/scheduler.py tick: workdir jobs run sequentially (outside the
thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs
still run in the parallel pool unchanged.
- tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py:
expose 'workdir' via the cronjob tool and 'hermes cron create/edit
--workdir ...'. Empty string on edit clears the field.
## Validation
- tests/cron/test_cron_workdir.py (21 tests): normalize, create, update,
JSON round-trip via cronjob tool, tick partition (workdir jobs run on
the main thread, not the pool), run_job env toggle + restore in finally.
- Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py,
test_config_cwd_bridge.py, test_worktree.py): 314/314 passed.
- Live smoke: hermes cron create --workdir $(pwd) works; relative path
rejected; list shows 'Workdir:'; edit --workdir '' clears.
2026-04-24 05:07:01 -07:00
|
|
|
workdir = job.get("workdir")
|
|
|
|
|
if workdir:
|
|
|
|
|
print(f" Workdir: {workdir}")
|
2026-04-07 22:49:01 -07:00
|
|
|
|
|
|
|
|
# Execution history
|
|
|
|
|
last_status = job.get("last_status")
|
|
|
|
|
if last_status:
|
|
|
|
|
last_run = job.get("last_run_at", "?")
|
|
|
|
|
if last_status == "ok":
|
|
|
|
|
status_display = color("ok", Colors.GREEN)
|
|
|
|
|
else:
|
|
|
|
|
status_display = color(f"{last_status}: {job.get('last_error', '?')}", Colors.RED)
|
|
|
|
|
print(f" Last run: {last_run} {status_display}")
|
|
|
|
|
|
|
|
|
|
delivery_err = job.get("last_delivery_error")
|
|
|
|
|
if delivery_err:
|
|
|
|
|
print(f" {color('⚠ Delivery failed:', Colors.YELLOW)} {delivery_err}")
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
print()
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-06-23 23:29:50 -07:00
|
|
|
_warn_if_gateway_not_running()
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def cron_tick():
|
2026-02-21 16:21:19 -08:00
|
|
|
"""Run due jobs once and exit."""
|
2026-02-02 19:01:51 -08:00
|
|
|
from cron.scheduler import tick
|
2026-02-21 16:21:19 -08:00
|
|
|
tick(verbose=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cron_status():
|
|
|
|
|
"""Show cron execution status."""
|
|
|
|
|
from cron.jobs import list_jobs
|
|
|
|
|
from hermes_cli.gateway import find_gateway_pids
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-21 16:21:19 -08:00
|
|
|
print()
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-06-29 14:03:02 +10:00
|
|
|
provider = _active_cron_provider_name()
|
|
|
|
|
if provider != "builtin":
|
|
|
|
|
# An external provider (e.g. Chronos) does NOT run the in-process 60s
|
|
|
|
|
# ticker — it arms one external one-shot per job and is fired by a
|
|
|
|
|
# NAS-mediated webhook, so between fires there is intentionally NO
|
|
|
|
|
# ticker thread and NO heartbeat file. Reporting the ticker-heartbeat
|
|
|
|
|
# staleness here would always say "stalled / not firing" on a perfectly
|
|
|
|
|
# healthy Chronos instance. Report the provider instead and skip the
|
|
|
|
|
# ticker-liveness heuristics entirely.
|
|
|
|
|
print(color(
|
|
|
|
|
f"✓ Cron provider: {provider} — jobs fire via the managed scheduler, "
|
|
|
|
|
"not the in-process ticker.",
|
|
|
|
|
Colors.GREEN,
|
|
|
|
|
))
|
|
|
|
|
print(color(
|
|
|
|
|
" (No ticker heartbeat is expected for an external provider; "
|
|
|
|
|
"due jobs are delivered by an authenticated webhook.)",
|
|
|
|
|
Colors.DIM,
|
|
|
|
|
))
|
|
|
|
|
print()
|
|
|
|
|
_print_active_jobs_summary(list_jobs(include_disabled=False))
|
|
|
|
|
print()
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-21 16:21:19 -08:00
|
|
|
pids = find_gateway_pids()
|
|
|
|
|
if pids:
|
fix(cron): keep ticker alive on BaseException + heartbeat-aware status
The in-process cron ticker (cron/scheduler_provider.py) caught only
`Exception` and logged at DEBUG, so a `SystemExit`/`KeyboardInterrupt`
raised from a misbehaving provider SDK or agent retry path killed the
ticker thread silently. The gateway PROCESS stayed up, so `hermes cron
status` — which only checks `find_gateway_pids()` — kept reporting
"✓ jobs will fire automatically" while no jobs ever fired (#32612,
#32895).
This makes ticker death survivable and detectable:
- The ticker loop now catches `BaseException` and logs at ERROR with a
traceback, so a single bad tick no longer tears the thread down and
the failure is visible in the gateway log.
- The loop records a heartbeat (`cron/ticker_heartbeat`, epoch seconds)
on startup and after every tick — best-effort, never raised into the
loop. Both ticker entry points (the gateway and the desktop fallback
in web_server.py) funnel through `InProcessCronScheduler.start`, so one
heartbeat site covers both.
- `hermes cron status` now reads the heartbeat age: if the gateway is
running but the heartbeat is stale (> 200s, i.e. several missed ~60s
ticks), it reports the ticker as STALLED and suggests a restart instead
of falsely claiming jobs will fire. A missing heartbeat (older build /
never ran) is treated as "unknown", not "dead".
Adds tests for BaseException survival, per-iteration heartbeat recording,
heartbeat round-trip/age, staleness detection, and silent-write-failure.
Salvaged from #49660 (BaseException survival on current structure),
extended with the heartbeat + honest-status reporting that the earlier
(pre-refactor) watchdog PRs #35616 and #33849 proposed.
Fixes #32612
Fixes #32895
Co-authored-by: banditburai <promptsiren@gmail.com>
Co-authored-by: sweetcornna <96944678+sweetcornna@users.noreply.github.com>
2026-06-21 12:47:48 +05:30
|
|
|
# The gateway PROCESS is alive — but the cron ticker THREAD inside it
|
|
|
|
|
# can die silently, or stay alive while every tick fails. Check both
|
|
|
|
|
# the liveness heartbeat and the last-successful-tick marker so we
|
|
|
|
|
# don't report "will fire" when the ticker is dead or failing
|
|
|
|
|
# (#32612, #32895).
|
|
|
|
|
from cron.jobs import (
|
|
|
|
|
get_ticker_heartbeat_age,
|
|
|
|
|
get_ticker_success_age,
|
|
|
|
|
TICKER_INTERVAL_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Allow ~3 missed ticker iterations (+ a little slack) before declaring
|
|
|
|
|
# trouble. Derived from the shared interval constant so this threshold
|
|
|
|
|
# tracks the ticker cadence instead of assuming a hardcoded 60s.
|
|
|
|
|
STALE_AFTER = TICKER_INTERVAL_SECONDS * 3 + 20 # = 200s at the 60s default
|
|
|
|
|
hb_age = get_ticker_heartbeat_age()
|
|
|
|
|
ok_age = get_ticker_success_age()
|
|
|
|
|
|
|
|
|
|
if hb_age is not None and hb_age > STALE_AFTER:
|
|
|
|
|
# No heartbeat at all → the ticker thread is gone.
|
|
|
|
|
print(color(
|
|
|
|
|
"⚠ Gateway is running but the cron ticker looks STALLED — "
|
|
|
|
|
f"no heartbeat for {int(hb_age)}s (expected every ~60s).",
|
|
|
|
|
Colors.YELLOW,
|
|
|
|
|
))
|
|
|
|
|
print(f" PID: {', '.join(map(str, pids))}")
|
|
|
|
|
print(" Cron jobs may NOT be firing. Restart: hermes gateway restart")
|
|
|
|
|
elif hb_age is not None and ok_age is not None and ok_age > STALE_AFTER:
|
|
|
|
|
# Loop is alive (fresh heartbeat) but no tick has SUCCEEDED in a
|
|
|
|
|
# long time → ticks are failing every iteration.
|
|
|
|
|
print(color(
|
|
|
|
|
"⚠ Gateway and cron ticker are running, but no tick has "
|
|
|
|
|
f"succeeded in {int(ok_age)}s — ticks may be failing.",
|
|
|
|
|
Colors.YELLOW,
|
|
|
|
|
))
|
|
|
|
|
print(f" PID: {', '.join(map(str, pids))}")
|
|
|
|
|
print(" Check the gateway log for 'Cron tick error'.")
|
|
|
|
|
else:
|
|
|
|
|
print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN))
|
|
|
|
|
print(f" PID: {', '.join(map(str, pids))}")
|
|
|
|
|
if hb_age is not None:
|
|
|
|
|
print(f" Ticker heartbeat: {int(hb_age)}s ago")
|
2026-02-21 16:21:19 -08:00
|
|
|
else:
|
|
|
|
|
print(color("✗ Gateway is not running — cron jobs will NOT fire", Colors.RED))
|
|
|
|
|
print()
|
|
|
|
|
print(" To enable automatic execution:")
|
2026-03-14 21:17:41 -07:00
|
|
|
print(" hermes gateway install # Install as a user service")
|
|
|
|
|
print(" sudo hermes gateway install --system # Linux servers: boot-time system service")
|
2026-02-21 16:21:19 -08:00
|
|
|
print(" hermes gateway # Or run in foreground")
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-21 16:21:19 -08:00
|
|
|
print()
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-06-29 14:03:02 +10:00
|
|
|
_print_active_jobs_summary(list_jobs(include_disabled=False))
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _print_active_jobs_summary(jobs) -> None:
|
|
|
|
|
"""Print the '<N> active job(s)' + next-run line shared by every status
|
|
|
|
|
path (built-in ticker AND external provider)."""
|
2026-02-21 16:21:19 -08:00
|
|
|
if jobs:
|
|
|
|
|
next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")]
|
|
|
|
|
print(f" {len(jobs)} active job(s)")
|
|
|
|
|
if next_runs:
|
|
|
|
|
print(f" Next run: {min(next_runs)}")
|
|
|
|
|
else:
|
|
|
|
|
print(" No active jobs")
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-03-14 19:18:10 -07:00
|
|
|
def cron_create(args):
|
2026-05-23 06:49:01 +03:00
|
|
|
# Defense: reject cron jobs that contain gateway lifecycle commands.
|
|
|
|
|
# Prevents agents from scheduling their own restart/stop, which creates
|
|
|
|
|
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
|
|
|
|
|
prompt = getattr(args, "prompt", None) or ""
|
|
|
|
|
script = getattr(args, "script", None)
|
|
|
|
|
combined = prompt
|
|
|
|
|
if script:
|
|
|
|
|
try:
|
2026-05-30 21:02:53 -07:00
|
|
|
script_text = Path(script).read_text(encoding="utf-8")
|
2026-05-23 06:49:01 +03:00
|
|
|
combined = f"{combined}\n{script_text}"
|
|
|
|
|
except (OSError, UnicodeDecodeError):
|
|
|
|
|
pass
|
|
|
|
|
if _contains_gateway_lifecycle_command(combined):
|
|
|
|
|
print(color(
|
|
|
|
|
"Blocked: cron job contains a gateway lifecycle command "
|
|
|
|
|
"(restart/stop/kill).\n"
|
|
|
|
|
"This is blocked to prevent restart loops (#30719).\n"
|
|
|
|
|
"Use `hermes gateway restart` from a shell outside the gateway.",
|
|
|
|
|
Colors.RED,
|
|
|
|
|
))
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-03-14 19:18:10 -07:00
|
|
|
result = _cron_api(
|
|
|
|
|
action="create",
|
|
|
|
|
schedule=args.schedule,
|
|
|
|
|
prompt=args.prompt,
|
|
|
|
|
name=getattr(args, "name", None),
|
|
|
|
|
deliver=getattr(args, "deliver", None),
|
|
|
|
|
repeat=getattr(args, "repeat", None),
|
|
|
|
|
skill=getattr(args, "skill", None),
|
|
|
|
|
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
|
|
|
script=getattr(args, "script", None),
|
feat(cron): per-job workdir for project-aware cron runs (#15110)
Cron jobs can now specify a per-job working directory. When set, the job
runs as if launched from that directory: AGENTS.md / CLAUDE.md /
.cursorrules from that dir are injected into the system prompt, and the
terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD).
When unset, old behaviour is preserved (no project context files, tools
use the scheduler's cwd).
Requested by @bluthcy.
## Mechanism
- cron/jobs.py: create_job / update_job accept 'workdir'; validated to
be an absolute existing directory at create/update time.
- cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD
at it and flip skip_context_files to False before building the agent.
Restored in finally on every exit path.
- cron/scheduler.py tick: workdir jobs run sequentially (outside the
thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs
still run in the parallel pool unchanged.
- tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py:
expose 'workdir' via the cronjob tool and 'hermes cron create/edit
--workdir ...'. Empty string on edit clears the field.
## Validation
- tests/cron/test_cron_workdir.py (21 tests): normalize, create, update,
JSON round-trip via cronjob tool, tick partition (workdir jobs run on
the main thread, not the pool), run_job env toggle + restore in finally.
- Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py,
test_config_cwd_bridge.py, test_worktree.py): 314/314 passed.
- Live smoke: hermes cron create --workdir $(pwd) works; relative path
rejected; list shows 'Workdir:'; edit --workdir '' clears.
2026-04-24 05:07:01 -07:00
|
|
|
workdir=getattr(args, "workdir", None),
|
feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)
* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
2026-05-04 12:31:01 -07:00
|
|
|
no_agent=getattr(args, "no_agent", False) or None,
|
2026-03-14 19:18:10 -07:00
|
|
|
)
|
|
|
|
|
if not result.get("success"):
|
|
|
|
|
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
|
|
|
|
|
return 1
|
|
|
|
|
print(color(f"Created job: {result['job_id']}", Colors.GREEN))
|
|
|
|
|
print(f" Name: {result['name']}")
|
|
|
|
|
print(f" Schedule: {result['schedule']}")
|
|
|
|
|
if result.get("skills"):
|
|
|
|
|
print(f" Skills: {', '.join(result['skills'])}")
|
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
|
|
|
job_data = result.get("job", {})
|
|
|
|
|
if job_data.get("script"):
|
|
|
|
|
print(f" Script: {job_data['script']}")
|
feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)
* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
2026-05-04 12:31:01 -07:00
|
|
|
if job_data.get("no_agent"):
|
|
|
|
|
print(" Mode: no-agent (script stdout delivered directly)")
|
feat(cron): per-job workdir for project-aware cron runs (#15110)
Cron jobs can now specify a per-job working directory. When set, the job
runs as if launched from that directory: AGENTS.md / CLAUDE.md /
.cursorrules from that dir are injected into the system prompt, and the
terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD).
When unset, old behaviour is preserved (no project context files, tools
use the scheduler's cwd).
Requested by @bluthcy.
## Mechanism
- cron/jobs.py: create_job / update_job accept 'workdir'; validated to
be an absolute existing directory at create/update time.
- cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD
at it and flip skip_context_files to False before building the agent.
Restored in finally on every exit path.
- cron/scheduler.py tick: workdir jobs run sequentially (outside the
thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs
still run in the parallel pool unchanged.
- tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py:
expose 'workdir' via the cronjob tool and 'hermes cron create/edit
--workdir ...'. Empty string on edit clears the field.
## Validation
- tests/cron/test_cron_workdir.py (21 tests): normalize, create, update,
JSON round-trip via cronjob tool, tick partition (workdir jobs run on
the main thread, not the pool), run_job env toggle + restore in finally.
- Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py,
test_config_cwd_bridge.py, test_worktree.py): 314/314 passed.
- Live smoke: hermes cron create --workdir $(pwd) works; relative path
rejected; list shows 'Workdir:'; edit --workdir '' clears.
2026-04-24 05:07:01 -07:00
|
|
|
if job_data.get("workdir"):
|
|
|
|
|
print(f" Workdir: {job_data['workdir']}")
|
2026-03-14 19:18:10 -07:00
|
|
|
print(f" Next run: {result['next_run_at']}")
|
2026-06-23 23:29:50 -07:00
|
|
|
_warn_if_gateway_not_running()
|
2026-03-14 19:18:10 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cron_edit(args):
|
feat(cron): support name-based lookup for job operations
Cron mutation operations (run/pause/resume/remove) and 'hermes cron edit'
now accept a job name in addition to the hex ID, with case-insensitive
matching. Before this, 'hermes cron run my_job_name' died with
'Job with ID my_job_name not found' and forced the user to look up the
hex ID first.
The original PR matched by name but silently picked the first match when
two jobs shared a name. This version refuses to act on an ambiguous name
and surfaces every matching job (id, name, schedule, next_run_at) so the
caller can pick a specific ID.
- cron/jobs.py:
- get_job() stays ID-only (preserves existing call-site semantics for
web_server/api_server/curator/scheduler/test code that always passes
real IDs).
- resolve_job_ref() is the new name-or-ID resolver, used by pause/
resume/trigger/remove_job. Exact ID match wins over a name match
even if a different job's name happens to equal that ID. Ambiguous
name match raises AmbiguousJobReference with all candidate IDs.
- tools/cronjob_tools.py: dispatch site uses resolve_job_ref, surfaces
ambiguous matches as a structured error with the matching IDs.
- hermes_cli/cron.py: 'cron edit' uses resolve_job_ref so editing by
name works and ambiguous names are reported with IDs.
- tests/cron/test_jobs.py: new TestResolveJobRef covering ID match,
case-insensitive name match, ID-wins-over-name, ambiguous refusal,
and that pause/resume/trigger/remove all refuse on ambiguity.
Closes #2627
2026-05-15 01:33:12 -07:00
|
|
|
from cron.jobs import AmbiguousJobReference, resolve_job_ref
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
job = resolve_job_ref(args.job_id)
|
|
|
|
|
except AmbiguousJobReference as exc:
|
|
|
|
|
print(color(str(exc), Colors.RED))
|
|
|
|
|
for m in exc.matches:
|
|
|
|
|
print(f" {m['id']} (name: {m.get('name')!r})")
|
|
|
|
|
return 1
|
2026-03-14 19:18:10 -07:00
|
|
|
if not job:
|
|
|
|
|
print(color(f"Job not found: {args.job_id}", Colors.RED))
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")]))
|
|
|
|
|
replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None))
|
|
|
|
|
add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or []
|
|
|
|
|
remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or [])
|
|
|
|
|
|
|
|
|
|
final_skills = None
|
|
|
|
|
if getattr(args, "clear_skills", False):
|
|
|
|
|
final_skills = []
|
|
|
|
|
elif replacement_skills is not None:
|
|
|
|
|
final_skills = replacement_skills
|
|
|
|
|
elif add_skills or remove_skills:
|
|
|
|
|
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
|
|
|
|
|
for skill in add_skills:
|
|
|
|
|
if skill not in final_skills:
|
|
|
|
|
final_skills.append(skill)
|
|
|
|
|
|
|
|
|
|
result = _cron_api(
|
|
|
|
|
action="update",
|
|
|
|
|
job_id=args.job_id,
|
|
|
|
|
schedule=getattr(args, "schedule", None),
|
|
|
|
|
prompt=getattr(args, "prompt", None),
|
|
|
|
|
name=getattr(args, "name", None),
|
|
|
|
|
deliver=getattr(args, "deliver", None),
|
|
|
|
|
repeat=getattr(args, "repeat", None),
|
|
|
|
|
skills=final_skills,
|
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
|
|
|
script=getattr(args, "script", None),
|
feat(cron): per-job workdir for project-aware cron runs (#15110)
Cron jobs can now specify a per-job working directory. When set, the job
runs as if launched from that directory: AGENTS.md / CLAUDE.md /
.cursorrules from that dir are injected into the system prompt, and the
terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD).
When unset, old behaviour is preserved (no project context files, tools
use the scheduler's cwd).
Requested by @bluthcy.
## Mechanism
- cron/jobs.py: create_job / update_job accept 'workdir'; validated to
be an absolute existing directory at create/update time.
- cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD
at it and flip skip_context_files to False before building the agent.
Restored in finally on every exit path.
- cron/scheduler.py tick: workdir jobs run sequentially (outside the
thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs
still run in the parallel pool unchanged.
- tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py:
expose 'workdir' via the cronjob tool and 'hermes cron create/edit
--workdir ...'. Empty string on edit clears the field.
## Validation
- tests/cron/test_cron_workdir.py (21 tests): normalize, create, update,
JSON round-trip via cronjob tool, tick partition (workdir jobs run on
the main thread, not the pool), run_job env toggle + restore in finally.
- Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py,
test_config_cwd_bridge.py, test_worktree.py): 314/314 passed.
- Live smoke: hermes cron create --workdir $(pwd) works; relative path
rejected; list shows 'Workdir:'; edit --workdir '' clears.
2026-04-24 05:07:01 -07:00
|
|
|
workdir=getattr(args, "workdir", None),
|
feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)
* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
2026-05-04 12:31:01 -07:00
|
|
|
no_agent=getattr(args, "no_agent", None),
|
2026-03-14 19:18:10 -07:00
|
|
|
)
|
|
|
|
|
if not result.get("success"):
|
|
|
|
|
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
updated = result["job"]
|
|
|
|
|
print(color(f"Updated job: {updated['job_id']}", Colors.GREEN))
|
|
|
|
|
print(f" Name: {updated['name']}")
|
|
|
|
|
print(f" Schedule: {updated['schedule']}")
|
|
|
|
|
if updated.get("skills"):
|
|
|
|
|
print(f" Skills: {', '.join(updated['skills'])}")
|
|
|
|
|
else:
|
|
|
|
|
print(" Skills: none")
|
feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.
- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
_format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
execution, path resolution, error handling, prompt injection, tool API
Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
|
|
|
if updated.get("script"):
|
|
|
|
|
print(f" Script: {updated['script']}")
|
feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)
* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
2026-05-04 12:31:01 -07:00
|
|
|
if updated.get("no_agent"):
|
|
|
|
|
print(" Mode: no-agent (script stdout delivered directly)")
|
feat(cron): per-job workdir for project-aware cron runs (#15110)
Cron jobs can now specify a per-job working directory. When set, the job
runs as if launched from that directory: AGENTS.md / CLAUDE.md /
.cursorrules from that dir are injected into the system prompt, and the
terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD).
When unset, old behaviour is preserved (no project context files, tools
use the scheduler's cwd).
Requested by @bluthcy.
## Mechanism
- cron/jobs.py: create_job / update_job accept 'workdir'; validated to
be an absolute existing directory at create/update time.
- cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD
at it and flip skip_context_files to False before building the agent.
Restored in finally on every exit path.
- cron/scheduler.py tick: workdir jobs run sequentially (outside the
thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs
still run in the parallel pool unchanged.
- tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py:
expose 'workdir' via the cronjob tool and 'hermes cron create/edit
--workdir ...'. Empty string on edit clears the field.
## Validation
- tests/cron/test_cron_workdir.py (21 tests): normalize, create, update,
JSON round-trip via cronjob tool, tick partition (workdir jobs run on
the main thread, not the pool), run_job env toggle + restore in finally.
- Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py,
test_config_cwd_bridge.py, test_worktree.py): 314/314 passed.
- Live smoke: hermes cron create --workdir $(pwd) works; relative path
rejected; list shows 'Workdir:'; edit --workdir '' clears.
2026-04-24 05:07:01 -07:00
|
|
|
if updated.get("workdir"):
|
|
|
|
|
print(f" Workdir: {updated['workdir']}")
|
2026-03-14 19:18:10 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _job_action(action: str, job_id: str, success_verb: str) -> int:
|
|
|
|
|
result = _cron_api(action=action, job_id=job_id)
|
|
|
|
|
if not result.get("success"):
|
|
|
|
|
print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED))
|
|
|
|
|
return 1
|
|
|
|
|
job = result.get("job") or result.get("removed_job") or {}
|
|
|
|
|
print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN))
|
|
|
|
|
if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"):
|
|
|
|
|
print(f" Next run: {result['job']['next_run_at']}")
|
|
|
|
|
if action == "run":
|
fix(cron): execute job immediately on action='run'
`cronjob(action='run')` (and `hermes cron run`) only set `next_run_at = now`
and returned success, relying on the scheduler ticker to actually execute the
job on its next tick. When no gateway/ticker is running — a CLI-only setup, or
the Windows case in #41037 — the job never executed: `run` reported success,
but `last_run_at` stayed null forever, no output, no delivery.
A manual `run` should actually run. `_execute_job_now` now:
- **claims the job via `claim_job_for_fire`** — the same at-most-once CAS the
scheduler/external-provider fire path uses. This both advances `next_run_at`
for recurring jobs and blocks a concurrently-running gateway ticker from
double-firing the same job; if the claim is lost, the run is skipped (the
tool reports `execution_skipped`). This closes the double-fire race that a
bare `advance_next_run` left open (a tick whose `get_due_jobs` already
captured the job between trigger and advance would still fire it).
- **delegates firing to `run_one_job`** — the single shared
execute→save→deliver→mark body the ticker and external providers use — so
failure delivery, `[SILENT]` handling, and live-adapter delivery stay
identical across paths and can't drift. (The original salvage re-implemented
this sequence inline and had already dropped failure delivery + `[SILENT]`.)
The tool response carries `executed`, `execution_success`, and either
`execution_error` or `execution_skipped`. The `hermes cron run` CLI message no
longer claims "It will run on the next scheduler tick" — it reports the actual
"Ran now: succeeded/failed" outcome (or the skip).
Salvaged from #41130 by @kyssta-exe (authorship preserved); reworked to reuse
`claim_job_for_fire` + `run_one_job` per review rather than re-implementing the
fire sequence inline. Adds tests for the claim-then-fire path, claim-lost skip,
failure reporting, and exception capture.
Fixes #41037
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-21 13:10:54 +05:30
|
|
|
job = result.get("job", {})
|
|
|
|
|
if job.get("executed"):
|
|
|
|
|
outcome = "succeeded" if job.get("execution_success") else "failed"
|
|
|
|
|
print(f" Ran now: {outcome}.")
|
|
|
|
|
elif job.get("execution_skipped"):
|
|
|
|
|
print(f" {job['execution_skipped']}")
|
|
|
|
|
else:
|
|
|
|
|
print(" It will run on the next scheduler tick.")
|
2026-03-14 19:18:10 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
def cron_command(args):
|
|
|
|
|
"""Handle cron subcommands."""
|
|
|
|
|
subcmd = getattr(args, 'cron_command', None)
|
2026-03-14 19:18:10 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
if subcmd is None or subcmd == "list":
|
|
|
|
|
show_all = getattr(args, 'all', False)
|
|
|
|
|
cron_list(show_all)
|
2026-03-14 19:18:10 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
if subcmd == "status":
|
2026-02-21 16:21:19 -08:00
|
|
|
cron_status()
|
2026-03-14 19:18:10 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
if subcmd == "tick":
|
|
|
|
|
cron_tick()
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
if subcmd in {"create", "add"}:
|
|
|
|
|
return cron_create(args)
|
|
|
|
|
|
|
|
|
|
if subcmd == "edit":
|
|
|
|
|
return cron_edit(args)
|
|
|
|
|
|
|
|
|
|
if subcmd == "pause":
|
|
|
|
|
return _job_action("pause", args.job_id, "Paused")
|
|
|
|
|
|
|
|
|
|
if subcmd == "resume":
|
|
|
|
|
return _job_action("resume", args.job_id, "Resumed")
|
|
|
|
|
|
|
|
|
|
if subcmd == "run":
|
|
|
|
|
return _job_action("run", args.job_id, "Triggered")
|
|
|
|
|
|
|
|
|
|
if subcmd in {"remove", "rm", "delete"}:
|
|
|
|
|
return _job_action("remove", args.job_id, "Removed")
|
|
|
|
|
|
|
|
|
|
print(f"Unknown cron command: {subcmd}")
|
|
|
|
|
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
|
|
|
|
|
sys.exit(1)
|