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.
The cron field
Section titled “The cron field”[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:
| Expression | Meaning |
|---|---|
* * * * * | every minute |
*/5 * * * * | every 5 minutes |
0 * * * * | top of every hour |
0 2 * * * | every day at 02:00 |
30 9 * * 1-5 | weekdays at 09:30 |
0 0 1 * * | first of every month at midnight |
@hourly | top of every hour |
@daily / @midnight | every day at 00:00 |
@weekly | Sundays at 00:00 |
@monthly | 1st at 00:00 |
@yearly / @annually | January 1st at 00:00 |
@every 1h30m | every 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.
| Expression | Meaning |
|---|---|
*/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.
Timezone
Section titled “Timezone”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"| Setting | Default | What 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.
DST behaviour
Section titled “DST behaviour”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.
What “fired” means
Section titled “What “fired” means”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 ofsuccess,failed,stopped,timeout,crashed,skipped,missed,log_overflow,queue_full,dst_skipped,daemon_stopped, orstart_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.
Missed ticks: catchup
Section titled “Missed ticks: catchup”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| Policy | Behaviour on startup |
|---|---|
latest | If any ticks were missed, fire one catch-up run. Default. Right for jobs where running twice is harmless. |
all | Fire one run per missed tick, capped by max_catch_up_runs. Right when each tick processes a discrete slice. |
skip | Don’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.
Capping the backfill: max_catch_up_runs
Section titled “Capping the backfill: max_catch_up_runs”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| Value | Meaning |
|---|---|
| omitted | Inherit the built-in default of 100. Tune up explicitly if each missed tick is real work. |
N > 0 | Cap 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).
Alerting on missed runs
Section titled “Alerting on missed runs”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 = falsenotify_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.
Predictable timing
Section titled “Predictable timing”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.
Reload semantics
Section titled “Reload semantics”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.
Intentional scope
Section titled “Intentional scope”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.