Skip to content

How scheduling works

The scheduler’s whole job is to be boring and predictable. It reads plain cron expressions, fires the matching tasks when their time comes, and writes a row for every firing. No dependency graph, no leader election — one daemon owns its tasks and that’s it.

[tasks.heartbeat]
cron = "*/5 * * * *"
run = "/usr/local/bin/heartbeat"

cron takes the usual 5 fields — minute, hour, day-of-month, month, day-of-week — and the familiar shorthand aliases work too:

ExpressionMeaning
* * * * *every minute
*/5 * * * *every 5 minutes
0 * * * *top of every hour
0 2 * * *every day at 02:00
30 9 * * 1-5weekdays at 09:30
0 0 1 * *first of every month at midnight
@hourlytop of every hour
@daily / @midnightevery day at 00:00
@weeklySundays at 00:00
@monthly1st at 00:00
@yearly / @annuallyJanuary 1st at 00:00
@every 1h30mevery 90 minutes

Need finer than a minute? Prepend a seconds field for a 6-field spec. Your existing 5-field expressions are unchanged — they just fire on the :00 second, exactly as before.

ExpressionMeaning
*/30 * * * * *every 30 seconds (:00, :30)
15 * * * * *once a minute, at the :15
0 */5 * * * *every 5 minutes, on the :00

@every 30s is still around for a plain fixed interval that doesn’t care about where it lands on the wall clock.

Cron expressions get evaluated in whatever zone [scheduler] timezone says. Leave it unset and the daemon just uses the host’s system timezone.

You never have to guess which zone is actually in play: the resolved zone — and whether it came from your config or the system — is printed in the TUI startup banner and shown in the Web UI header.

# Daemon-wide default for every task without its own timezone.
[scheduler]
timezone = "Europe/Bratislava"
# Per-task override — any standard IANA timezone name works.
[tasks.nightly-backup]
cron = "30 2 * * *"
timezone = "Atlantic/Faroe"
SettingDefaultWhat it controls
[scheduler] timezone(system zone)Fallback for every task without its own timezone.
[tasks.<name>] timezone(inherits)Timezone for this task’s cron evaluation. Overrides the scheduler-wide default.

If the daemon doesn’t recognize the name you gave it — a typo, or a host with no IANA timezone data, like a stripped-down container image missing tzdata — config load fails and tells you exactly which scope is at fault. It never quietly falls back to UTC and leaves you wondering.

Across DST transitions, RunWisp makes sure a schedule fires once and only once:

  • Fall-back (clocks go back). The wall-clock instant that happens twice — say 02:30 — only fires once. The scheduler dedupes on the full wall-clock second (year, month, day, hour, minute, second), and the suppressed firing still gets a row in history.
  • Spring-forward (clocks jump ahead). If a cron like 0 2 * * * lands on a minute that simply doesn’t exist that day — the clock jumps straight from 01:59 to 03:00 — it fires once at the gap end (here, 03:00), the next valid instant, instead of being skipped to the next day.

UTC has no DST, so none of this touches it.

A cron tick produces a run, not just some side effect. Every firing gives you:

  • A row in SQLite with a fresh ULID.
  • triggered_by = "cron".
  • A captured stdout/stderr stream on disk.
  • A status that walks pending → running → ended, landing on one of success, failed, stopped, timeout, crashed, skipped, missed, log_overflow, queue_full, dst_skipped, daemon_stopped, or start_failed.

Whether the run starts right away is up to the task’s concurrency policy. Under the default on_overlap = "queue", a tick that fires while the last run is still going simply waits in line. Under on_overlap = "skip", it’s recorded as a skipped run and the schedule moves on. Either way the tick always lands in history — that’s the rule around here: nothing fires, and nothing fails, without leaving a trace.

Scheduled runs use the declared default for any per-execution parameter — there’s no one to fill in a form on a cron tick, so you set default on each param to tell the scheduler what to use.

While the daemon was down — a reboot, a deploy, a hard crash — some scheduled firings just didn’t happen. catch_up decides what to do about those the next time it starts up:

[tasks.metrics-rollup]
cron = "*/15 * * * *"
catch_up = "latest" # default
PolicyBehaviour on startup
latestIf any ticks were missed, fire one catch-up run. Default. Right for jobs where running twice is harmless.
allFire one run per missed tick, capped by max_catch_up_runs. Right when each tick processes a discrete slice.
skipDon’t re-run the missed ticks. Right for monitors and probes that just want fresh data — the gap is still recorded and alerted (see below).

“Missed” is measured from the timestamp of the task’s last recorded run. On the very first boot, that anchor is set the moment RunWisp first sees the task — so a fresh install won’t suddenly queue up a mountain of “catch-up” runs for ticks from before the daemon ever existed.

Picture catch_up = "all" on a * * * * * task with a daemon that was down for a day — that’s a huge backlog waiting to fire at startup. The cap is there so one long outage can’t bury the scheduler.

[tasks.metrics-rollup]
cron = "*/15 * * * *"
catch_up = "all"
max_catch_up_runs = 200 # at most 200 missed ticks fire on startup
ValueMeaning
omittedInherit the built-in default of 100. Tune up explicitly if each missed tick is real work.
N > 0Cap the backfill at N runs; older missed ticks are dropped.

Negative values are rejected. Zero means “use the default” — leave the key off for the same effect. The cap only matters for catch_up = "all"latest fires exactly one run no matter what, and skip fires none.

And when the cap does kick in, the daemon logs a warning naming the task, how many ticks were missed, the cap, and how many got dropped. The silenced ticks never just vanish (Prime Directive #1).

A run that fails is loud. A run that never happened used to be silent — and that’s exactly the kind of thing the daemon being down hides from you. So missed ticks get the same treatment as a failure.

On the restart that detects a gap, RunWisp records one browsable run with end_reason = "missed" and raises a failure-level alert (kind run.missed) naming the task and how many scheduled runs were missed. It reaches the bell — and any channel that already gets your failures — with no extra configuration. Detection is independent of catch_up: latest, all, and skip all detect, record, and alert. The policy only decides whether anything re-runs. Even when max_catch_up_runs caps the backfill, the alert reports the full detected count, not the smaller number that re-ran.

Some tasks miss ticks by design — a laptop that’s only on during the day, a task you only ever trigger by hand. For those, silence the alert with a single flag (the run row is still recorded — only the notification is suppressed):

[tasks.daytime-only]
cron = "*/30 9-17 * * 1-5"
notify_on_missed = false # expected to be down overnight; don't page
# Or quiet every task at once:
[defaults]
notify_on_missed = false

notify_on_missed defaults to true and inherits from [defaults] like the other per-task flags. See Per-task notifications for how it fits with notify_on_failure.

On startup: unfinished runs are marked crashed

Section titled “On startup: unfinished runs are marked crashed”

On a clean shutdown, the daemon waits for its running tasks to either finish or hit their timeout. On a hard crash — power loss, force kill — it doesn’t get the chance.

So the next time it starts, any run still on the books as running gets marked crashed with exit code -2. Those runs are not picked back up — resuming would mean knowing exactly where the process left off, and the daemon doesn’t. The normal scheduling and catchup logic above may then spin up a fresh run.

The upshot: every row in your history ends with a real result. You’ll never find one stuck on “running” just because the daemon vanished out from under it.

Same TOML, same clock, same firings — RunWisp’s scheduling is fully deterministic. Two tasks both set to cron = "0 2 * * *" fire at the exact same instant. That’s fine until thirty of them do it at once and the machine buckles under the stampede.

That’s what jitter is for. Give a task a window and RunWisp stops the stampede — without making anything wait that doesn’t have to:

[tasks.backup-a]
cron = "0 2 * * *"
jitter = "30m"
[tasks.backup-b]
cron = "0 2 * * *"
jitter = "30m"

RunWisp runs one jittered task at a time across the whole daemon. When the box is idle, a task starts right at its tick — no wasted delay. When several land together, like these two at 02:00, they don’t both jump: they take turns as the gate frees, and a bigger pile-up spreads its starts across the window instead of bursting. The window is the most a start can slip, not where it lands — whoever’s first in line still goes at 02:00.

The firing schedule stays deterministic — the same ticks fire on the same clock, and each task’s slot within its window is fixed at startup. What’s load-dependent is when a run actually starts, exactly like a queued run under the concurrency policy: the daemon shows you the cron tick as the next run, and the history records when it really fired. Just a kinder load curve, no hidden surprises.

See the jitter reference for the full picture, including the one-line [defaults] jitter that spreads your whole task set at once.

The scheduler reads runwisp.toml at startup, and again whenever you run runwisp reload (or send SIGHUP). Reload is the explicit way to pick up schedule edits without bouncing the daemon — it’s never automatic, there’s no file watcher.

A parse error at startup stops the boot cold: the daemon exits before it ever opens its port. The safe habit is to run runwisp validate --config <path> against the new file before you restart. (A bad edit handed to runwisp reload is rejected instead, and the running scheduler keeps its current entries.)

If a task disappears from the file, its schedule is dropped but its run history stays put. If a new task shows up, its schedule is added — and catchup does not apply to it, whether it appeared via a restart or a reload, since it didn’t exist in the previous config.

A couple of things the scheduler deliberately won’t do:

  • No clustering. One daemon owns its tasks. Point two daemons at the same TOML and they’ll both fire — that’s a setup mistake, not a feature.
  • No “every Nth tick” logic. Cron is the whole surface. If you want every other Tuesday, bake it into the cron (30 9 */2 * 2) or filter for it in your script.