cron-watchdog
Monitor all Hermes cron jobs, detect missed runs (reboot, scheduler skip), and auto-re-fire them. Stays silent when healthy, reports when it catches something. Companion watchdog for the Hermes schedu
SKILL.mdskills/devops/cron-watchdog/SKILL.md
GitHubCron Watchdog
A proactive watchdog that ensures every enabled Hermes cron job runs when it should, even after restarts, scheduler hiccups, or brief downtime. Runs as a high-frequency cron job itself that inspects all other jobs and re-fires any that missed their window.
Core Pattern
Create a single cron job (interval: 15-30 min) that:
- Lists all cron jobs via
cronjob(action='list') - Parses each job's schedule to compute the expected run interval
- If the time since
last_run_atexceeds ~1.5x the expected interval, the job was missed - Re-fires the missed job with
cronjob(action='run', job_id='...') - Reports only when action is taken (silent when healthy)
Detection Heuristics
Parse the schedule field from cronjob(action='list'):
| Schedule Pattern | Expected Interval | Mis Fire Threshold |
|---|---|---|
every Xm |
X minutes | 1.5 × X minutes |
every Xh |
X hours | 1.5 × X hours |
0 H * * * (daily at H:00) |
24 hours | 26 hours |
0 H1,H2 * * * (twice daily) |
~12 hours (gap between H1-H2) | 16 hours |
0 H * * D (weekly on day D) |
7 days | 8 days |
| ISO timestamp (one-shot) | N/A — skip, never re-fire | N/A |
For daily-at-time cron expressions (0 6 * * *), compute whether the job should have fired today. If last_run_at is from yesterday (or earlier) and the current time is past the scheduled hour, it was missed.
Timezone Awareness
All Hermes cron timestamps are in the configured timezone (ET for the operator's setup). Use the current time consistently when computing gaps. Account for DST changes — cron uses local wall-clock time, so a daily-at-6AM job always means 6AM local regardless of DST.
What to Skip (Never Re-Fire)
| Job Type | Reason |
|---|---|
| Your own watchdog job | Infinite loop |
State != scheduled |
Paused, completed, cancelled intentionally |
No last_run_at |
New/never ran; not a miss |
no_agent=true script jobs |
Scripts run on their own schedule via the scheduler's timer; the watchdog lacks the script context and re-running could cause duplicates |
Jobs with state=completed |
One-shot jobs that already finished |
Jobs with state=paused |
User intentionally stopped them |
NEVER_PAUSE Blacklist (for Cron Guardian)
When a model-outage watchdog auto-pauses jobs, it must NEVER pause infrastructure jobs or the system deadlocks:
NEVER_PAUSE = [
"Cron Guardian", "Guardian Angel", "Cron Watchdog",
"Hermes Self-Healer", "model-health-watchdog", "welcome-back-briefing",
"tor-circuit-rotation", "Hermes System Backup", "PIM Ingestion",
"PIM Ingestion & Sync", "refresh-firefox-cookies",
]
This blacklist is shared between the Cron Watchdog and the Cron Guardian. If you're building a cron-based auto-pause system, always include this check.
Re-Fire Process
# Pseudocode for the detection + re-fire loop
for job in all_jobs:
if not should_skip(job) and is_missed(job):
cronjob(action='run', job_id=job.job_id)
log(f"Re-fired {job.name} — missed because {reason}")
When re-firing:
- Use
cronjob(action='run', job_id='...')to trigger an immediate run - The job runs in a fresh agent session exactly like a scheduled tick
- If the job has attached skills, they're loaded automatically
- If the job has a
workdir, it runs from that directory
Reporting
- STAY SILENT when everything is on track (no missed jobs detected)
- REPORT when you re-fire one or more jobs:
Cron Watchdog — Missed Jobs Detected
| Job | Missed Since | Reason | Action |
|---|---|---|---|
| website-landlord-daily-build | Jun 22 → Jun 23 (6AM) | Computer restart — missed 6AM window | ✅ Re-fired |
| morning-brief | Jun 23 (6AM) | Computer restart | ✅ Re-fired |
Next check in 15 min.
Initial Setup
# Create the watchdog cron job
# Run: cronjob(action='create', schedule='every 15m', ...)
# Or use the Hermes CLI:
# hermes cron create --name "Cron Watchdog" --schedule "every 15m" --prompt "..."
name: Cron Watchdog — detect & re-fire missed jobs
schedule: every 15m
prompt: |-
[Self-contained watchdog prompt with detection logic]
enabled_toolsets: ["cronjob"]
deliver: origin # Deliver back to the chat where it was set up
Pitfalls
- Watchdog itself is skipped — the watchdog must skip re-firing itself by name or by detecting it's the current running job.
- No-agent script jobs are skipped — these run via script execution, not the LLM loop. The scheduler handles them on its own timer. Re-running them without the full context could cause duplicates or race conditions.
- Computer restart gap — when the computer is off during a scheduled window, the Hermes scheduler does not auto-catch-up missed jobs. The watchdog handles this.
- next_run_at can be misleading — after a miss, the scheduler reschedules
next_run_atto the next future slot, not "right now." Don't rely onnext_run_atbeing in the past as the sole detector. - Stale hardcoded date in prompt — If the watchdog prompt has a literal date string like "Current time: approximately June 23, 2026", it will blind the watchdog to all future misses. The prompt MUST reference the current time dynamically. If the watchdog stops detecting misses, check for hardcoded dates in the prompt and the watchdog's reference prompt file. Fix by replacing the literal date with a statement like "Use the current system time — do NOT use a hardcoded date."
- "No model configured" cascade — When all cron jobs fail simultaneously with "no model configured", the root cause is usually one of: (a)
config.yamlhasmodel.default: ''(empty string), (b)HERMES_MODELenv var is empty, (c) the default model provider config is broken. Jobs that DO have explicit per-job models (model=deepseek-v4-flashin the job record) will still fail if the prompt references a stale date or the job record's model was set after the failed run (the error output reflects state-at-run-time, not current state). Fix: setmodel.defaultin config.yaml, or add explicit per-job models viacronjob action=update job_id=<id> model=<name> provider=<provider>. - Don't over-fire — if a job missed 2 cycles (e.g., 48h gap for a daily job), only re-fire once. A single re-fire handles the catch-up; the next scheduled tick covers the rest.
- Output noise — if the watchdog fires every 15 minutes and repeatedly detects the same missed job (because re-running it is still failing), it'll spam. Add a cooldown: skip jobs that were re-fired in the last N hours.
- Schedule string parsing — cron expressions can be complex. Use simple regex heuristics for known patterns rather than a full cron parser. The
every Xm/hformat is trivial;0 H * * *is the common cron format.
Corruption: guardian_state.json contains invalid data
The Cron Guardian state file ~/AppData/Local/hermes/cron/guardian_state.json can
become corrupted — containing just 0 instead of valid JSON. This happens after
force-kills or crashes during file writes.
Symptoms: Cron Guardian reports Error loading guardian state in its log.
State transitions (pause/resume) are lost. The guardian starts fresh with
state: WAITING, potentially re-pausing jobs unnecessarily.
Detection: cat ~/AppData/Local/hermes/cron/guardian_state.json shows
just 0 instead of {"state": "WAITING", ...}.
Fix: Reinitialize the state file:
import json
from pathlib import Path
state = Path.home() / "AppData/Local/hermes/cron/guardian_state.json"
state.write_text(json.dumps({
"state": "WAITING",
"last_healthy": "2026-01-01T00:00:00",
"consecutive_failures": 0,
"last_transition": "repaired"
}))
Corruption: UTF-8 BOM in jobs.json
The cron database file ~/AppData/Local/hermes/cron/jobs.json can get a UTF-8 BOM (\\xef\\xbb\\xbf) prepended, causing all cron jobs to stop firing.
Symptoms: cronjob(action='list') errors with Cron database corrupted and unrepairable: Unexpected UTF-8 BOM. Gateway log shows repeated ERROR cron.jobs: Failed to auto-repair jobs.json: Unexpected UTF-8 BOM and Cron tick error.
Detection: file ~/AppData/Local/hermes/cron/jobs.json reports Unicode text, UTF-8 (with BOM) text instead of JSON text. Or xxd shows efbb bf at offset 0.
Fix: Strip the BOM bytes from the file:
python -c "
path=r'C:\\Users\\<you>\\AppData\\Local\\hermes\\cron\\jobs.json'
with open(path,'rb') as f: d=f.read()
if d[:3]==b'\\xef\\xbb\\xbf':
with open(path,'wb') as f: f.write(d[3:])
import json
with open(path) as f: json.load(f)
print('BOM stripped, file valid')
"
Duplicate Dist-Info Corruption
If SQLAlchemy-dependent cron jobs suddenly fail with:
ImportError: cannot import name 'getcurrent' from 'greenlet' (unknown location)
See references/python-duplicate-distinfo.md for diagnosis and fix.
This is a common issue after pip upgrades that install duplicate
package metadata without cleaning up old versions.
Finding a Cron Job's Output Directory (Self-Discovery)
When a cron agent needs to read its own output or a sibling's (e.g., for dedup or error verification) but doesn't know the job ID, search all output directories for your task title. Each output file's first line is # Cron Job: <Title>:
# Search the latest file in each output dir for a unique title keyword
for dir in ~/AppData/Local/hermes/cron/output/*/; do
f=$(ls -t "$dir" 2>/dev/null | head -1)
[ -n "$f" ] && head -1 "$dir/$f" 2>/dev/null | grep -q "C2C\|YourTask" && echo "${dir##*/}"
done
Replace the grep pattern with a distinctive word from your own cron task title. Once found, the directory name (e.g., 582ca95572a2) is the job ID for all subsequent read_file or tail calls against that job's output tree. Cache it in a cron environment variable to skip discovery on future runs.
Handling Stale state: error Jobs
Cron jobs can get stuck in state: error even after the underlying issue is resolved. This prevents them from running on schedule. The error state persists in the job record and must be explicitly cleared.
Detection: Look for jobs where state: error appears in the cronjob(action='list') output. These jobs may have last_status: error or last_status: ok with state: error (a stale flag).
Recovery (pause/resume):
# Pause clears the error flag, resume re-enables
cronjob(action='pause', job_id='<job-id>') # state → paused
cronjob(action='resume', job_id='<job-id>') # state → scheduled
This is the only way to clear state: error on a job that no longer has the root cause present. The pause/resume cycle resets the scheduler's state machine for that job.
Pitfall — "OK but error": A job can have last_status: ok (last run succeeded) but state: error (a stale flag). This happens when the error occurred on a different run than the most recent one. Don't confuse state (scheduler state machine) with last_status (most recent run outcome). Pause/resume fixes both.
Pitfall — Don't re-fire errored jobs blindly: Before clearing the error state, verify the job's output to understand why it errored. Read its most recent output file from ~/AppData/Local/hermes/cron/output/<job_id>/. If the error was transient (network blip, timeout), pause/resume is safe. If the error is structural (missing script, bad config), fix that first, then pause/resume.
Pitfall — Run vs resume semantics: cronjob(action='run') triggers an immediate run but does NOT clear state: error. The job remains in error state even after a successful run. Always use pause/resume to clear the state flag.
Discord Failure Spam: Prevention + Retroactive Purge
Failing cron jobs with deliver: discord:* (or origin on a Discord-bound session) post an error message to the channel on EVERY tick — a broken every-4h job spams 6 messages/day per job indefinitely. the operator's standing rule: zero failed-cron messages in Discord, ever — jobs must work and only report back when there's something actionable.
Triage (do this first)
cronjob(action='list')— find jobs wherelast_status: errororlast_delivery_erroris non-null.- Remove junk/test jobs outright (
cronjob action=remove): one-letter names,echo hi/say helloprompts, smoke-test leftovers. - For real jobs you want to keep, stop the spam immediately by switching delivery to local:
cronjob(action='update', job_id='<id>', deliver='local'). The job keeps running and reporting locally; Discord stays clean. - Fix the root cause (missing script, bad model config, dead API key, adapter down) before ever restoring Discord delivery.
Common last_delivery_error values and what they mean:
| Error | Meaning | Fix |
|---|---|---|
platform 'discord' not configured/enabled |
Job targets a Discord channel but the gateway's Discord adapter is down/not enabled | Switch to local until the adapter is fixed |
Discord API error (429) |
Rate limited — too many jobs posting to the same channel | Consolidate jobs or local-deliver |
'charmap' codec can't encode character '\u2705' |
Report contains emoji the delivery path can't encode | Strip emoji from the report template or local-deliver |
Proactive Delivery Management (Prevent Spam Before It Starts)
The most effective way to keep Discord clean is to set the right delivery target BEFORE jobs start spamming. Classify every cron job on creation or during audits:
| Job Category | Deliver Setting | Examples |
|---|---|---|
| Revenue/actionable output | origin |
C2C Hunter, Land Sales, AI Sharp picks, options |
| Pulse/heartbeat/monitor | local |
the operator's Pulse, Morning Brief, Flash Intel, Weekly Strategy |
| Error-prone jobs | local |
Any job with last_status: error |
| no_agent script jobs | Auto-silent | Empty stdout = no delivery; output only when there's data |
Running an Audit
- List all jobs:
cronjob(action='list') - Group them by type (revenue vs heartbeat vs monitor)
- For each LLM-driven job with
deliver: origin:- Is this revenue or actionable? → keep
origin - Is this a routine status check / pulse / heartbeat? → switch to
local - Has it ever errored? → switch to
local
- Is this revenue or actionable? → keep
- Set delivery:
cronjob(action='update', job_id='<id>', deliver='local')
no_agent Script Rule
no_agent=True script jobs ALREADY self-suppress — empty stdout = absolutely no delivery. Only non-empty output triggers delivery. This is the most efficient pattern for watchdog and data-collection jobs. When creating a new cron job that checks for something periodically, prefer no_agent=True with a script that outputs nothing when there's nothing to report.
Audit Results Reference
In the operator's setup (Jul 2026), the audit classified 88 jobs into:
- 59 local — pulses, heartbeats, monitors, errored jobs, background services
- 11 origin — revenue jobs (C2C Hunter, Land Sales, AI Sharp picks, options, strategy bots)
- 14 channel-specific — pulses that go to specific Discord channels (dev-lead-pulse, skills-lead-pulse, etc.)
- 4 errored → local — stopped immediately
Prevention doesn't remove the backlog of error messages already sitting in channels. Use scripts/cleanup_discord_cron_messages.py (packaged with this skill): a discord.py bot script that scans every text channel's history (last 30 days), matches bot-authored messages against failure patterns (cron keywords + error keywords, ❌/🔴/⚠️ markers, HTTP error codes, Cronjob Response: headers), and deletes them.
python "${USER_HOME}/AppData/Local/hermes/skills/devops/cron-watchdog/scripts/cleanup_discord_cron_messages.py"
- Requires
DISCORD_BOT_TOKENin~/.hermes/.envor~/AppData/Local/hermes/.env, withmessage_content+guildsintents. - Expect heavy 429 rate limiting — discord.py auto-retries; a full-server purge of hundreds of messages takes 5+ minutes. Run with a long timeout or in the background. It's resumable — already-deleted messages stay deleted, so re-running continues where it left off.
- Only deletes bot-authored messages; user messages are never touched.
Verification
After creating the watchdog:
- Wait for its first run (should be within 15 min)
- Check that it lists all enabled jobs
- Confirm it correctly identifies recently missed jobs (like the daily website build)
- Verify re-firing actually triggers the job (check the job's last_run_at updates)
- Confirm the watchdog stays silent when no jobs are missed
To test manually:
# Simulate: run the watchdog immediately
cronjob(action='run', job_id='<watchdog-job-id>')
References
references/production-watchdog-prompt.md— the exact prompt used in the operator's production cron watchdog, with full detection heuristics and edge cases.references/model-aware-cron-guardian.md— model availability watchdog that auto-pauses/resumes cron jobs during API outages. Complementary to this skill: Guardian prevents error spam (pauses before jobs fail), Watchdog catches missed runs (re-fires after recovery).- Guardian Angel (
guardian-angelskill) — companion process-health watchdog that monitors the Hermes Agent and Gateway processes. While cron-watchdog handles schedule-level monitoring (missed jobs), Guardian Angel handles process-level monitoring (crash, hang, error burst). The two are complementary: cron-watchdog re-fires jobs, Guardian Angel restarts the gateway. - Model Provider Routing (
model-provider-routingskill) — centralized model configuration that supplies API endpoints and keys to cron-guardian.py and all other scripts. All model provider settings (base URL, API key env var, model name) now flow through this one config.