Cron Scheduling
Responsibility
Let the agent "run automatically on schedule": timed reports, backups, briefings, etc., with no human present. The cron/ subsystem provides a scheduler, job store, execution records, a lifecycle guard, a blueprints/suggestions catalog, and per-job toolset isolation (timed jobs can disable some tools to prevent dangerous actions while unattended). The gateway drives the scheduler with a 60s heartbeat.
Design Motivation
Unattended operation is the core problem of this subsystem. The agent fires itself up at 3 a.m. — who's watching? Hermes answers with "progressive de-privilege" along the scheduling chain: toolsets strip interactive ones and cronjob (so cron can't spawn more cron) by default; lifecycle_guard blocks commands like systemctl restart hermes-gateway in scripts before scheduling; the executions table leaves an auditable record for every run, and on crash marks unknown rather than pretending success. The result: an agent that "runs itself" stays auditable and rollback-able.
Key Files
def run_job:2659— core that executes a single job_run_job_script / _run_job_script_with_claim_heartbeat:2113-2247— script-job execution + lease heartbeatruntime state & interruption:352-445—get_running_job_ids/mark_running_jobs_interrupted/_consume_interrupted_flagtoolsets resolution:143-210—_resolve_cron_disabled_toolsets/_resolve_cron_enabled_toolsets(tool isolation for timed jobs)thread pools:501-547—_get_parallel_pool/_get_sequential_pool_CronStorePaths / _current_cron_store:106-157— job store pathslock & output dir:251-360—_jobs_lock_file/_job_output_dir_normalize_job_record:426-461— job record normalizationexecution state machine:99-156—create_execution/mark_execution_running/finish_executionrecover_interrupted_executions:156-213— crash recovery +list_executions/latest_executioncheck_gateway_lifecycle:69-112— prevents timed scripts from accidentally altering gateway lifecycleblueprint_catalog— job blueprint catalogsuggestion_catalog/suggestions— cron suggestion generation_start_cron_ticker:22335— 60s heartbeat inside the gateway drives scheduling
Data Flow
- The gateway starts
_start_cron_ticker:22335, which triggers the scheduler every 60s. - The scheduler reads the job store (
cron/jobs.py:106) and selects due jobs by cron expression. - For each due job:
check_gateway_lifecycle:112scans the script to prevent accidental gateway-lifecycle changes- the job's toolsets are resolved (
cron/scheduler.py:210), typically disabling dangerous tools (see the same auto-deny idea in Subagent) - an execution is recorded via
create_execution(cron/executions.py:99) run_job(cron/scheduler.py:2659) actually runs: script jobs go through_run_job_script:2113; conversational jobs are fed torun_conversation:588
- On crash during execution, the next
recover_interrupted_executions(cron/executions.py:156) recovers the markers. - Results are delivered by the gateway (or mirrored to a designated platform).
The scheduler hardcodes three toolsets to strip, layered on top of the user's agent.disabled_toolsets from config.yaml:
def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
"""Toolsets a cron-spawned agent must never receive."""
disabled = ["cronjob", "messaging", "clarify"]
agent_cfg = (cfg or {}).get("agent") or {}
user_disabled = agent_cfg.get("disabled_toolsets") or []
for name in user_disabled:
name = str(name).strip()
if name and name not in disabled:
disabled.append(name)
return disabledcronjob is disabled so cron can't spawn more cron (a misfire could cascade into a pile of jobs); messaging / clarify are interactive — unattended, they'd just hang waiting for a person who isn't there. Script jobs go through _run_job_script, with the path locked inside HERMES_HOME/scripts/ to prevent traversal.
The execution state machine in executions.py has only claimed → running → completed/failed/unknown. A terminal state is written exactly once (finish_execution's UPDATE carries WHERE status IN ('claimed','running') protection — a duplicate write just returns None via rowcount=0). Crash recovery only marks executions "whose process is gone" as unknown: it doesn't fake success and doesn't auto-rerun. The subprocess may have already produced side effects, or may not have — that's left for the operator to investigate, rather than guessing failed and making people think a rerun fixes everything. lifecycle_guard turns systemctl restart hermes-gateway and pkill hermes gateway into regexes, scanning both at creation and right before execution. It concatenates the prompt and script together for the scan, closing the "say one thing in the prompt, do another in the script" split-bypass.
Boundaries and Failure
- Second-level drift and collateral damage: The 60s heartbeat means trigger time can be off by up to a minute.
mark_running_jobs_interrupteddoesn't distinguish which PID got killed — any job running at the kill moment gets interrupted, even one about to finish. - Side effects of the
unknownstate: Crash recovery marksunknownand doesn't rerun, but the script may have already sent messages or written files. Downstream code can't assume "not done." - Lease heartbeat stall:
_run_job_script_with_claim_heartbeatuses a dedicated thread to renew the lease. If the heartbeat thread itself stalls (e.g., a long GIL hold), another scheduler process may dispatch the same one-shot job again, producing duplicate side effects. - Prompt-injection blind spot: The assembled prompt (including skill content) is only scanned at runtime. A malicious skill gets caught only at scheduling time — invisible at job creation.
Summary
The cron subsystem focuses on "unattended safety": toolset isolation, a lifecycle guard against misoperations, and an execution state machine for crash recovery. Scheduling itself is driven by the gateway heartbeat; no external cron daemon is required.