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.
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:
- Job Creation — User runs
hermes cron create --schedule "0 9 * * *" --prompt "Summarize top tech news" --deliver telegram:daily_channel. Thecreate_job()function generates a UUID, parses the schedule, and persists the job to~/.hermes/cron/jobs.json. - Schedule Parsing —
parse_schedule("0 9 * * *")detects a 5-field cron expression, validates it withcroniter, and returns{"kind": "cron", "expr": "0 9 * * *"}. - Gateway Tick — The gateway daemon calls
tick()every 60 seconds. It acquires~/.hermes/cron/.tick.lockto prevent concurrent execution across multiple gateway instances. - Due Check —
compute_next_run()usescroniterto 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. - 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.
- Agent Execution — A fresh
AIAgentinstance is created in an isolated session. The prompt and optional skills are passed in. No prior context or state leaks between runs. - 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. - Delivery Resolution —
_resolve_delivery_target()mapstelegram:daily_channelto a concrete platform + chat_id. If "origin" is specified, it falls back through live adapter, standalone HTTP, and home channel env vars. - 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.
- State Update —
mark_job_run()updateslast_run_atand computes the next run time. The updated job is persisted back tojobs.jsonvia 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
| Decision | Choice Made | Alternatives Considered | Why This Approach |
|---|---|---|---|
| Job store format | JSON file (jobs.json) | SQLite database, YAML, Redis | Portable, no dependencies, human-readable for debugging. Structure is flat enough that JSON suffices. Atomic write via temp+rename prevents corruption. |
| Scheduling engine | croniter for cron expressions + manual interval/one-shot | APScheduler, Celery Beat, systemd timers | croniter is a single lightweight dependency. APScheduler/Celery add significant overhead and process management complexity. systemd is not cross-platform. |
| Execution isolation | Fresh AIAgent session per job | Shared session with context, persistent agent state | Prevents state leakage between runs. Makes jobs reproducible. Shared state would cause unpredictable behavior when jobs interact. |
| One-shot grace window | 120-second window after scheduled time | Exact-time-only, infinite window | Jobs 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 handling | Non-fatal; output always saved locally | Retry queue, dead-letter queue | Output 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 Mode | Trigger | Detection | Recovery / Mitigation |
|---|---|---|---|
| Duplicate job execution | Multiple gateway instances call tick() simultaneously | File lock contention on .tick.lock | File-based lock ensures only one tick runs at a time. Second instance waits or skips. |
| Corrupt jobs.json | Power loss during write, disk full | JSON parse error on load | Atomic write (temp file + rename) prevents partial writes. Auto-repair with strict=False handles control characters. |
| Delivery platform unavailable | Telegram API down, Discord rate-limited | HTTP error from platform API | Non-fatal; output saved locally. User can retrieve from ~/.hermes/cron/output/. |
| Pre-run script timeout | Script hangs or takes too long | Timeout after script_timeout_seconds (120s) | Script output is discarded; prompt runs without injection. Job still executes. |
| Missed one-shot window | Gateway was down for > 120 seconds past run_at | run_at outside grace window | Job 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:
- tick() — every 60 seconds: Loads jobs.json, iterates all jobs, checks schedules. O(n) where n is job count. For typical deployments (< 100 jobs), this is sub-millisecond.
- croniter evaluation: O(1) per job.
croniter.get_next()is a mathematical computation, not a search. - Delivery routing: Channel name resolution may involve a network call (e.g., Discord channel lookup). Cached after first resolution.
Resource Usage
- Memory: Minimal — jobs.json is loaded into memory only during tick(). Typical size is < 100 KB even with hundreds of jobs.
- Disk: Output files grow over time. Each execution creates a ~1-10 KB markdown file. No automatic cleanup; users manage via
hermes cron clean. - I/O: One file read (jobs.json) + one atomic write per tick with due jobs. Output writes are append-only (new files, never modify existing).
Configuration Reference
| Config Key | Type | Default | Description |
|---|---|---|---|
schedule | string | — | Schedule expression: "30m", "every 2h", "0 9 * * *", or ISO timestamp |
repeat | int / null | null (forever) | Number of times to run. 1 = once, null = forever |
deliver | string | "local" | Delivery target: "local", "origin", "telegram:chat_id", platform name |
wrap_response | bool | true | Whether to wrap output with job header metadata |
script_timeout_seconds | int | 120 | Pre-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