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.

  • Backend & APIs
  • $cron-scheduling
  • 186 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cron-scheduling

Installs to ~/.agents/skills/cron-scheduling/ for every project on this machine.

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

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.

~/.agents/skills/cron-scheduling/SKILL.md

--- name: cron-scheduling description: Sets up scheduled jobs that run exactly when intended and exactly once - cron expressions, time zones and DST traps, overlap prevention with advisory… license: MIT ---

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

RuntimeDefault schedulerTime zoneNotes
KubernetesCronJobspec.timeZone (stable since 1.27)concurrencyPolicy: Forbid, set startingDeadlineSeconds
Single Linux VMsystemd timerOnCalendar=... Europe/Berlin or UTCPersistent=true catches up after downtime
Legacy boxcrontabHost zone; CRON_TZ only in some cron implementationsMinimal env: absolute paths, explicit PATH
GitHub repo automationActions on.scheduleUTCRuns can start late; avoid minute 0; public repos disable schedules after 60 days of inactivity
Vercelcrons in vercel.jsonUTCHits a route; check Authorization: Bearer $CRON_SECRET
Cloudflare Workers[triggers] crons in wrangler configUTCscheduled() handler
AWSEventBridge SchedulerScheduleExpressionTimezone6-field cron(0 3 * * ? *) syntax
App with a job systemSidekiq/Solid Queue recurring, Celery beat, BullMQ repeatable jobs, Laravel schedulerLibrary-specificScheduler 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

  1. Read AGENTS.md for how the app is deployed and how many replicas run.
  2. Find existing schedules: rg -n "cron|schedule|OnCalendar|CronJob|recurring|beat_schedule|repeat:" -g '!node_modules'.
  3. 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 of 0, 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

EnvironmentLock
Kubernetes CronJobconcurrencyPolicy: Forbid (plus an app-level lock if the job can also be triggered manually)
Shared Postgrespg_try_advisory_lock on a fixed key, held by a dedicated connection
Shared RedisSET lock:job:<name> <token> NX PX <ttl>, extend while running, compare-and-delete to release
Single hostflock -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=true runs once after downtime; Kubernetes runs a missed schedule only within startingDeadlineSeconds (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 on time() - 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.yamlgenerated from this page

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.py157 lines, 6.3 KB
#!/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.

esc