cron — Scheduled Task System

Scheduled Task System

A durable cron job scheduler for executing agent tasks on schedules. Jobs are natural-language prompts with delivery targets. The scheduler runs in the gateway process, firing jobs at configured times and routing outputs through a multi-platform delivery system.

🎯 Why This Module Exists

Without the cron module, users would have no way to schedule recurring agent tasks — every interaction with Hermes would require manual triggering. The core agent (run_agent.py) is designed for synchronous, on-demand execution: a user asks a question, the agent responds. But many valuable use cases are inherently periodic: daily news digests, hourly system health checks, weekly report generation, scheduled data collection. The cron module adds a time dimension to the agent, transforming it from a reactive tool into a proactive assistant. It handles schedule parsing (cron expressions, intervals, one-shots), job persistence across process restarts, delivery routing to 17+ platforms (Telegram, Discord, Slack, Matrix, etc.), and execution isolation so each job runs in a fresh session without state leakage. The module is intentionally separate from the agent core because scheduling, persistence, and delivery are daemon-level concerns that would bloat the lightweight CLI agent.

💡 Real-world analogy

Think of the cron module as a personal secretary who manages your calendar of recurring tasks. You tell the secretary "every morning at 9 AM, check the stock market and send me a summary on Telegram." The secretary writes this down in a planner (jobs.json), checks the clock every minute (tick()), and when the time comes, hands the task to an assistant (AIAgent), collects the result, and delivers it to your preferred channel. If the secretary goes on vacation (gateway restart), they pick up right where they left off from the planner.

3
Files
~1K
Lines of Code
4
Schedule Types
Python
Language

Cron Expressions Multi-Target Delivery Natural Language Jobs

Architecture

flowchart LR
  subgraph JobManagement["Job Management"]
    CJ["create_job()"] --> JS["jobs.json"]
    LJ["list_jobs()"] --> JS
    UJ["update_job()"] --> JS
    RJ["remove_job()"] --> JS
  end
  subgraph Scheduler["Scheduler Engine"]
    TK["tick()"] --> PS["parse_schedule()"]
    PS --> CN["compute_next_run()"]
    CN --> EX["Execute Job"]
  end
  subgraph Delivery["Delivery System"]
    EX --> DR["Delivery Router"]
    DR --> LO["Local Storage"]
    DR --> TG["Telegram"]
    DR --> DC["Discord"]
    DR --> SL["Slack"]
    DR --> MX["Matrix"]
  end
  JS --> TK

Data Flow

flowchart TD
  A["User creates job via CLI/API"] --> B["Job persisted to jobs.json"]
  B --> C["Gateway daemon calls tick() every 60s"]
  C --> D{"Job due?"}
  D -->|"interval: last_run + interval <= now"| E["Execute"]
  D -->|"cron: croniter.get_next() <= now"| E
  D -->|"once: run_at in grace window"| E
  D -->|"Not due"| C
  E --> F["Load optional skills"]
  F --> G["Run AIAgent in fresh session"]
  G --> H["Capture output"]
  H --> I{"Delivery target?"}
  I -->|"local"| J["Save to output/job_id/timestamp.md"]
  I -->|"origin"| K["Route to originating platform"]
  I -->|"platform:chat_id"| L["Send to specific channel"]
  J --> M["mark_job_run()"]
  K --> M
  L --> M

🚶 Step-by-Step Narrated Walkthrough

Follow a single cron job from creation through execution and delivery as it flows through the entire cron pipeline:

  1. Job Creation — User runs hermes cron create --schedule "0 9 * * *" --prompt "Summarize top tech news" --deliver telegram:daily_channel. The create_job() function generates a UUID, parses the schedule, and persists the job to ~/.hermes/cron/jobs.json.
  2. Schedule Parsing — parse_schedule("0 9 * * *") detects a 5-field cron expression, validates it with croniter, and returns {"kind": "cron", "expr": "0 9 * * *"}.
  3. Gateway Tick — The gateway daemon calls tick() every 60 seconds. It acquires ~/.hermes/cron/.tick.lock to prevent concurrent execution across multiple gateway instances.
  4. Due Check — compute_next_run() uses croniter to calculate the next fire time. At 09:00, the job is due. A dynamic grace period (half the schedule period, clamped to 120s–2h) handles missed ticks.
  5. Pre-run Script — If the job has an optional pre-run script, it executes first. Its stdout is prepended to the prompt (e.g., injecting live stock prices). Script failures are non-fatal.
  6. Agent Execution — A fresh AIAgent instance is created in an isolated session. The prompt and optional skills are passed in. No prior context or state leaks between runs.
  7. Output Capture — The agent's response is captured and saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md. Output is immutable — historical runs are never overwritten.
  8. Delivery Resolution — _resolve_delivery_target() maps telegram:daily_channel to a concrete platform + chat_id. If "origin" is specified, it falls back through live adapter, standalone HTTP, and home channel env vars.
  9. Message Delivery — The output is sent via the gateway's live adapter (if running, supports E2EE) or standalone HTTP. Delivery failures are logged but non-fatal; output is already saved locally.
  10. State Update — mark_job_run() updates last_run_at and computes the next run time. The updated job is persisted back to jobs.json via atomic temp-file + rename.

Key Components

CronScheduler (tick)

The main scheduler entry point, called every 60 seconds by the gateway daemon. Loads all jobs, checks which are due, and executes them. Uses a file-based lock (.tick.lock) to prevent concurrent tick execution across multiple gateway instances.

Schedule Parser

Parses four schedule formats into a structured dict:

def parse_schedule(schedule: str) -> Dict[str, Any]:
    # "every 30m"  -> {"kind": "interval", "minutes": 30}
    # "0 9 * * *"  -> {"kind": "cron", "expr": "0 9 * * *"}
    # "2026-02-03" -> {"kind": "once", "run_at": "2026-02-03T00:00:00"}
    # "30m"        -> {"kind": "once", "run_at": ""}

Delivery Router

Resolves delivery targets through a fallback chain: live adapter (if gateway running) → standalone HTTP → home channel environment variable. Supports 17+ platforms including Telegram, Discord, Slack, Matrix, and BlueBubbles. Platform names are validated against a _KNOWN_DELIVERY_PLATFORMS frozenset to prevent environment variable injection attacks.

Job Persistence

Jobs are stored in ~/.hermes/cron/jobs.json, atomic-written via temp file + rename to prevent corruption. Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md as immutable files. Corrupt JSON auto-repairs with strict=False reload.

🏗️ Design Decisions

DecisionChoice MadeAlternatives ConsideredWhy This Approach
Job store formatJSON file (jobs.json)SQLite database, YAML, RedisPortable, no dependencies, human-readable for debugging. Structure is flat enough that JSON suffices. Atomic write via temp+rename prevents corruption.
Scheduling enginecroniter for cron expressions + manual interval/one-shotAPScheduler, Celery Beat, systemd timerscroniter is a single lightweight dependency. APScheduler/Celery add significant overhead and process management complexity. systemd is not cross-platform.
Execution isolationFresh AIAgent session per jobShared session with context, persistent agent statePrevents state leakage between runs. Makes jobs reproducible. Shared state would cause unpredictable behavior when jobs interact.
One-shot grace window120-second window after scheduled timeExact-time-only, infinite windowJobs created at 14:00:05 with run_at=14:00:00 should still fire. Infinite window risks executing stale one-shots. 120s matches two tick cycles.
Delivery failure handlingNon-fatal; output always saved locallyRetry queue, dead-letter queueOutput preservation is the priority. Retries add complexity and risk duplicate delivery. Local save is the safety net; user can manually check.

🔬 Algorithm Spotlight

Schedule Resolution and Grace Windows

The Problem: The scheduler ticks every 60 seconds, but jobs might be due between ticks. One-shot jobs have a narrow fire window. Recurring jobs that miss their window (gateway was down) need to catch up without executing stale tasks.

The Approach: Each schedule type has its own resolution logic. Interval jobs compare last_run + interval against now. Cron jobs use croniter.get_next() to find the next fire time. One-shot jobs get a fixed 120-second grace window after their run_at time. Recurring jobs get a dynamic grace period equal to half their schedule period, clamped between 120 seconds and 2 hours.

Why This Algorithm: A fixed grace period would either miss infrequent jobs (too short) or re-execute frequent jobs (too long). The dynamic approach scales: a 5-minute job gets a 2.5-minute grace, while a daily job gets a 2-hour catch-up window.

graph TD
    A["tick() called"] --> B{"Schedule kind?"}
    B -->|"interval"| C["last_run + minutes <= now?"]
    B -->|"cron"| D["croniter.get_next() <= now?"]
    B -->|"once"| E["run_at in grace window?"]
    C -->|"Yes"| F["Execute Job"]
    D -->|"Yes"| F
    E -->|"Yes and not already run"| F
    C -->|"No"| G["Skip"]
    D -->|"No"| G
    E -->|"No or already run"| G
    F --> H["mark_job_run()"]
    H --> I["compute_next_run()"]
Trace through an example

Given: A one-shot job with run_at = "2026-04-15T14:00:00", current time is 14:00:45, last_run_at = None

Step 1: tick() loads the job and calls _recoverable_oneshot_run_at().

Step 2: Checks: last_run_at is None (never ran), so eligible. Computes grace boundary: 14:00:00 >= 14:00:45 - 120s = 13:58:45. Yes, within the 120-second grace window.

Step 3: Job executes. mark_job_run() sets last_run_at = "2026-04-15T14:00:45".

Step 4: On the next tick at 14:01:45, last_run_at is set, so _recoverable_oneshot_run_at() returns None. Job never fires again.

Result: The job fired despite being 45 seconds late, and the one-shot guarantee (run at most once) is preserved.

⚠️ What Could Go Wrong

Failure ModeTriggerDetectionRecovery / Mitigation
Duplicate job executionMultiple gateway instances call tick() simultaneouslyFile lock contention on .tick.lockFile-based lock ensures only one tick runs at a time. Second instance waits or skips.
Corrupt jobs.jsonPower loss during write, disk fullJSON parse error on loadAtomic write (temp file + rename) prevents partial writes. Auto-repair with strict=False handles control characters.
Delivery platform unavailableTelegram API down, Discord rate-limitedHTTP error from platform APINon-fatal; output saved locally. User can retrieve from ~/.hermes/cron/output/.
Pre-run script timeoutScript hangs or takes too longTimeout after script_timeout_seconds (120s)Script output is discarded; prompt runs without injection. Job still executes.
Missed one-shot windowGateway was down for > 120 seconds past run_atrun_at outside grace windowJob is permanently missed. Design choice: one-shots should not fire hours late with stale context.

⚡ Performance Profile

Hot Paths

The most performance-sensitive code paths in this module:

Resource Usage

Configuration Reference

Config KeyTypeDefaultDescription
schedulestring—Schedule expression: "30m", "every 2h", "0 9 * * *", or ISO timestamp
repeatint / nullnull (forever)Number of times to run. 1 = once, null = forever
deliverstring"local"Delivery target: "local", "origin", "telegram:chat_id", platform name
wrap_responsebooltrueWhether to wrap output with job header metadata
script_timeout_secondsint120Pre-run script timeout. Override via HERMES_CRON_SCRIPT_TIMEOUT

Directory Map

Source tree for cron
cron/
├── __init__.py                    ← Module init, public API exports
├── jobs.py                        ← Job CRUD, parse_schedule(), compute_next_run()
└── scheduler.py                   ← tick(), delivery routing, execution engine