Cron Scheduling skill for Codex
Scheduled jobs that run once, on time, visibly. Codex picks one scheduler per job (Kubernetes CronJob, systemd timer, Vercel or Cloudflare cron, EventBridge, or the app's job system), checks every expression and its DST behaviour with a bundled offline script, then adds an overlap lock, a hard timeout and a watermark window so retries and catch-up never double-process. Each job gets a heartbeat or last-success alert.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cron-scheduling Installs to ~/.agents/skills/cron-scheduling/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cron-scheduling The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cron-scheduling Installs to .agents/skills/cron-scheduling/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cron-scheduling.zipunzip -o cron-scheduling.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cron-scheduling PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cron-scheduling, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up scheduled jobs that run exactly when intended and exactly once - cron expressions, time zones and DST traps, overlap prevention with advisory locks, Redis locks or flock, catch-up after downtime, idempotent watermark windows, and dead-man's-switch monitoring - across crontab, systemd timers, Kubernetes CronJobs, GitHub Actions schedules, Vercel and Cloudflare cron, EventBridge Scheduler and in-app schedulers. Includes a script that prints next run times and DST hazards. Use when adding a nightly or periodic job, a job ran twice or never, or a schedule must follow local time.
Use it when
- Adding a periodic or nightly job to an app or cluster
- A scheduled job ran twice, overlapped itself or silently stopped
- A schedule must follow a local time zone across DST changes
- Moving in-process timers out of horizontally scaled web servers
Use something else when
- Work is triggered by events rather than the clock: use background-jobs
- The job is a multi-step workflow needing durable state: use temporal-workflows
- You only need a recurring report emailed to people: use scheduled-reports
How to check the result
Run the job twice concurrently and confirm the second skips, kill it mid-run and rerun without duplicates, and check the heartbeat alert fires when a run is skipped.
The mistake it prevents
Starting an in-process cron library inside every web replica, so each scaled-out instance runs the same job at the same minute.
Done well means: the job fires from exactly one scheduler, never runs twice at once, processes a well-defined time window so retries and catch-up are safe, behaves predictably across DST, and someone is alerted when it silently stops running.
Pick where the schedule lives
| Runtime | Default scheduler | Time zone | Notes |
|---|---|---|---|
| Kubernetes | CronJob | spec.timeZone (stable since 1.27) | concurrencyPolicy: Forbid, set startingDeadlineSeconds |
| Single Linux VM | systemd timer | OnCalendar=... Europe/Berlin or UTC | Persistent=true catches up after downtime |
| Legacy box | crontab | Host zone; CRON_TZ only in some cron implementations | Minimal env: absolute paths, explicit PATH |
| GitHub repo automation | Actions on.schedule | UTC | Runs can start late; avoid minute 0; public repos disable schedules after 60 days of inactivity |
| Vercel | crons in vercel.json | UTC | Hits a route; check Authorization: Bearer $CRON_SECRET |
| Cloudflare Workers | [triggers] crons in wrangler config | UTC | scheduled() handler |
| AWS | EventBridge Scheduler | ScheduleExpressionTimezone | 6-field cron(0 3 * * ? *) syntax |
| App with a job system | Sidekiq/Solid Queue recurring, Celery beat, BullMQ repeatable jobs, Laravel scheduler | Library-specific | Scheduler enqueues; workers run. Exactly one scheduler process |
Prefer the platform scheduler that enqueues work into your existing job system
(background-jobs) over in-process timers (node-cron, setInterval) inside web replicas:
three replicas means three runs. Long multi-step workflows belong in temporal-workflows.
Before changing anything
- Read
AGENTS.mdfor how the app is deployed and how many replicas run. - Find existing schedules:
rg -n "cron|schedule|OnCalendar|CronJob|recurring|beat_schedule|repeat:" -g '!node_modules'. - For each job write down: purpose, frequency, expected duration, what happens if it runs twice, what happens if it is skipped, and who must be alerted.
Expressions and time zones
┌ minute (0-59) ┌ hour (0-23) ┌ day of month (1-31) ┌ month (1-12) ┌ day of week (0-6, Sun=0)
17 3 * * * 03:17 daily
*/15 9-17 * * 1-5 every 15 min, 09:00-17:45, Mon-Fri
0 0 1 * * midnight on the 1st
0 9 1 * 1 09:00 on the 1st OR on any Monday (both day fields set = OR)
Rules:
- Schedule in UTC unless the business rule is explicitly local (“send at 08:00 Berlin time”).
- Local-time schedules between 01:00 and 03:00 hit DST: once a year the time does not exist (skipped or shifted, depending on the scheduler) and once it occurs twice.
- Spread load: use odd minutes (
17,43) instead of0, which every system on the planet also uses. - Per-user local times (“each user’s 9 am”) are not one cron line. Run every 15 minutes in UTC and select users whose local time falls in the window.
Check any expression before committing it (standard library, offline):
python3 scripts/cron_next.py "30 2 * * *" --tz Europe/London --count 5
python3 scripts/cron_next.py "*/10 * * * *" --tz UTC --fail-on-dst # exit 1 on DST hazards
It prints the next runs in local time and UTC and lists DST gaps and overlaps over the next
12 months. It exits 2 for invalid expressions or schedules that never fire (such as Feb 30).
For systemd, systemd-analyze calendar "Mon..Fri *-*-* 09:00:00 Europe/Berlin" does the same.
Make every run safe
1. Overlap lock
| Environment | Lock |
|---|---|
| Kubernetes CronJob | concurrencyPolicy: Forbid (plus an app-level lock if the job can also be triggered manually) |
| Shared Postgres | pg_try_advisory_lock on a fixed key, held by a dedicated connection |
| Shared Redis | SET lock:job:<name> <token> NX PX <ttl>, extend while running, compare-and-delete to release |
| Single host | flock -n /run/lock/<job>.lock <command> |
| Laravel | ->withoutOverlapping()->onOneServer() |
def run_exclusive(conn, name: str, fn):
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(hashtext(%s))", (f"job:{name}",))
if not cur.fetchone()[0]:
log.info("job %s already running, skipping", name)
return
try:
fn()
finally:
cur.execute("SELECT pg_advisory_unlock(hashtext(%s))", (f"job:{name}",))
Session-level advisory locks do not work through PgBouncer transaction pooling; use a direct
connection for the lock, or pg_try_advisory_xact_lock inside one transaction for short jobs.
2. Idempotent window, not “the last 24 hours”
-- job_runs(job text, window_end timestamptz, status text)
-- Process [last successful window_end, now() - safety_lag), then record the new window_end.
- A watermark table makes retries, catch-up and manual reruns safe: each run processes exactly the unprocessed window.
- Leave a safety lag (for example 5 minutes) for late-arriving rows and replica lag.
- Writes inside the job are upserts keyed by business IDs, so a crash mid-run can be rerun.
3. Timeouts and catch-up
- Give each run a hard timeout below the schedule interval (
activeDeadlineSeconds,TimeoutStartSec, job-level timeout). - Decide catch-up explicitly: systemd
Persistent=trueruns once after downtime; Kubernetes runs a missed schedule only withinstartingDeadlineSeconds(and stops scheduling after more than 100 misses without it). With a watermark, one late run covers all missed windows.
Kubernetes example
apiVersion: batch/v1
kind: CronJob
metadata: { name: invoice-reminders }
spec:
schedule: "17 7 * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
containers:
- name: job
image: registry.example.com/app:1.42.0
args: ["node", "dist/jobs/invoice-reminders.js"]
systemd example
# /etc/systemd/system/cleanup.timer
[Timer]
OnCalendar=*-*-* 03:17:00 UTC
Persistent=true
RandomizedDelaySec=120
[Install]
WantedBy=timers.target
The matching cleanup.service uses Type=oneshot and TimeoutStartSec=20min. Enabling a
timer on a production host changes that host: confirm with the user first.
Monitoring
A failed run that nobody notices and a run that never started look identical: silence.
- Dead man’s switch: ping a heartbeat monitor (Healthchecks.io, Cronitor, Sentry Cron Monitors, Better Stack) at start and on success; alert when no success arrives within interval + grace.
- Metric: export
job_last_success_timestamp_seconds{job="..."}and alert ontime() - job_last_success_timestamp_seconds > 2 * interval. - Log start, end, duration, rows processed and window bounds with one
run_id. - Alert on duration creeping toward the interval: overlaps start there.
Workflow checklist
- Inventory jobs with frequency, duration, double-run and skip consequences
- Choose one scheduler per job; remove in-process timers from web replicas
- Validate expressions and DST behaviour with
scripts/cron_next.py - Add overlap lock, timeout, and watermark-based idempotent window
- Add heartbeat or last-success metric with an alert
- Test: run twice concurrently (second skips), kill mid-run then rerun (no duplicates)
Gotchas
- Cron’s minimal environment: no
PATH, no shell profile,%must be escaped in crontab. - Both day-of-month and day-of-week set means OR, not AND.
- Containers default to UTC while developers think in local time; state the zone in the job name or comment.
- Celery beat or any scheduler process running in more than one replica duplicates every job.
- Jobs that send emails, charge cards or delete data need explicit user confirmation before the first production run, plus a dry-run mode that logs what would happen.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Cron Scheduling"
short_description: "Scheduled jobs that run once, on time, visibly"
default_prompt: "$cron-scheduling Add a nightly job that emails overdue invoice reminders at 8am Berlin time" scripts/cron_next.py
#!/usr/bin/env python3
"""Explain a 5-field cron expression: next run times in a time zone, plus DST hazards.
Usage:
python3 cron_next.py "30 2 * * *" --tz Europe/London --count 5
python3 cron_next.py "@hourly" --tz UTC --fail-on-dst
Supports *, lists (1,5), ranges (1-5), steps (*/15, 10-50/10), month and weekday names
(jan, mon), 7 = Sunday, and @yearly/@annually/@monthly/@weekly/@daily/@midnight/@hourly.
Standard cron semantics: when both day-of-month and day-of-week are restricted, a day
matches if EITHER matches.
Exit codes: 0 ok, 1 DST hazard found with --fail-on-dst, 2 invalid expression, zone,
or a schedule that never fires.
Standard library only (zoneinfo needs system tz data or the tzdata package).
"""
import argparse
import sys
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
MACROS = {"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *"}
MONTHS = {m: i for i, m in enumerate("jan feb mar apr may jun jul aug sep oct nov dec".split(), 1)}
DAYS = {d: i for i, d in enumerate("sun mon tue wed thu fri sat".split())}
FIELDS = [("minute", 0, 59, {}), ("hour", 0, 23, {}), ("day-of-month", 1, 31, {}),
("month", 1, 12, MONTHS), ("day-of-week", 0, 7, DAYS)]
def parse_field(text: str, name: str, lo: int, hi: int, names: dict) -> tuple[set[int], bool]:
values: set[int] = set()
for part in text.lower().split(","):
step = 1
if "/" in part:
part, step_s = part.split("/", 1)
if not step_s.isdigit() or int(step_s) == 0:
raise ValueError(f"{name}: bad step '{step_s}'")
step = int(step_s)
if part == "*":
start, end = lo, hi
elif "-" in part:
a, b = part.split("-", 1)
start, end = conv(a, name, names), conv(b, name, names)
else:
start = conv(part, name, names)
end = hi if step > 1 else start
if not (lo <= start <= hi and lo <= end <= hi) or start > end:
raise ValueError(f"{name}: '{part}' outside {lo}-{hi} or reversed")
values.update(range(start, end + 1, step))
return values, text != "*"
def conv(token: str, name: str, names: dict) -> int:
if token in names:
return names[token]
if token.isdigit():
return int(token)
raise ValueError(f"{name}: cannot parse '{token}'")
def parse(expr: str):
expr = MACROS.get(expr.strip().lower(), expr)
parts = expr.split()
if len(parts) != 5:
raise ValueError(f"expected 5 fields (min hour dom month dow), got {len(parts)}: '{expr}'")
parsed = [parse_field(p, *spec) for p, spec in zip(parts, FIELDS)]
dow, dow_restricted = parsed[4]
if 7 in dow:
dow = (dow - {7}) | {0}
return parsed[0][0], parsed[1][0], parsed[2], parsed[3][0], (dow, dow_restricted)
def day_matches(d: date, dom, months, dow) -> bool:
if d.month not in months:
return False
dom_set, dom_r = dom
dow_set, dow_r = dow
in_dom = d.day in dom_set
in_dow = (d.isoweekday() % 7) in dow_set
if dom_r and dow_r:
return in_dom or in_dow
return in_dom and in_dow
def classify(naive: datetime, tz: ZoneInfo) -> str:
"""Return 'ok', 'gap' (wall time does not exist) or 'fold' (wall time occurs twice)."""
a = naive.replace(tzinfo=tz, fold=0)
b = naive.replace(tzinfo=tz, fold=1)
if a.utcoffset() == b.utcoffset():
return "ok"
roundtrip = a.astimezone(timezone.utc).astimezone(tz).replace(tzinfo=None)
return "gap" if roundtrip != naive else "fold"
def iterate(spec, tz: ZoneInfo, start: datetime, days: int):
minutes, hours, dom, months, dow = spec
d0 = start.date()
for offset in range(days):
d = d0 + timedelta(days=offset)
if not day_matches(d, dom, months, dow):
continue
for h in sorted(hours):
for m in sorted(minutes):
naive = datetime(d.year, d.month, d.day, h, m)
if naive <= start:
continue
yield naive, classify(naive, tz)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("expr")
p.add_argument("--tz", default="UTC", help="IANA zone the scheduler evaluates cron in (default UTC)")
p.add_argument("--count", type=int, default=5)
p.add_argument("--from", dest="start", help="start time, ISO format, local to --tz (default now)")
p.add_argument("--fail-on-dst", action="store_true", help="exit 1 if any run in the next year hits a DST gap or overlap")
args = p.parse_args()
try:
tz = ZoneInfo(args.tz)
spec = parse(args.expr)
except (ValueError, ZoneInfoNotFoundError) as exc:
print(f"error: {exc}")
return 2
start = datetime.fromisoformat(args.start) if args.start else datetime.now(tz).replace(tzinfo=None, second=0, microsecond=0)
print(f"expr: {args.expr} zone: {args.tz}")
shown = 0
for naive, kind in iterate(spec, tz, start, days=366 * 5):
if kind == "gap":
continue
aware = naive.replace(tzinfo=tz, fold=0)
utc = aware.astimezone(timezone.utc)
note = " (wall time occurs twice: may run twice)" if kind == "fold" else ""
print(f" {aware.isoformat()} = {utc.strftime('%Y-%m-%dT%H:%MZ')}{note}")
shown += 1
if shown >= args.count:
break
if shown == 0:
print(" no runs in the next 5 years (impossible date such as Feb 30?)")
return 2
hazards = [(n, k) for n, k in iterate(spec, tz, start, days=366) if k != "ok"]
if hazards:
print("\nDST hazards in the next 12 months:")
for naive, kind in hazards[:10]:
what = "does not exist (skipped or shifted, scheduler-dependent)" if kind == "gap" else "is ambiguous (may run twice or once)"
print(f" {naive.isoformat(timespec='minutes')} local {what}")
if len(hazards) > 10:
print(f" ... {len(hazards) - 10} more")
print("Fix: schedule in UTC, or move the job outside the local 01:00-03:00 window.")
return 1 if args.fail_on_dst else 0
print("\nNo DST hazards in the next 12 months.")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.