Skip to content

[tasks.*]

A task is a unit of work that runs and exits. The TOML key (backup-db in [tasks.backup-db]) is the task name — it appears in the CLI, the API, the Web UI, and the on-disk log path. Names must be unique across both [tasks.*] and [services.*].

Only two fields are truly required: the table itself and run. Everything else inherits from [defaults] or has a sensible built-in default.

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

That’s a complete, valid task: it fires every five minutes, captures stdout/stderr to a log file, and persists each run to SQLite.

KeyDefaultWhat it does
table keyrequiredThe task name. Used in CLI (runwisp exec <name>), API, log paths.
runrequiredShell command. Multi-line OK with TOML triple-quotes ("""…""").
description(empty)Human-readable description shown in the UI and TUI.
group"Tasks"UI grouping label. Set to share a section header with related tasks.
api_triggertrueAllow manual triggers from CLI / API / UI. Set false to make the task cron-only.
KeyDefaultWhat it does
cron(none)5-field cron expression. Supports @hourly, @every 1h30m.
timezoneinheritedIANA timezone for this task’s cron evaluation (e.g. "Europe/Bratislava"). Falls back to [scheduler] timezone, which itself falls back to the host’s system zone if unset.
catch_up"latest"What to do for missed firings on startup: latest, all, or skip.
max_catch_up_runs100Cap on catch-up runs when catch_up = "all". Positive integer. Zero and negatives are rejected at config load.

Without a cron, the task is manual-only — it runs only when triggered explicitly. That’s a valid pattern for one-shot deploy hooks and similar.

See How scheduling works for the full cron grammar and DST behaviour, and the catchup table.

The default api_trigger = true lets you run the task on demand from the CLI (runwisp exec <name>), the REST API (POST /api/tasks/{name}/run), the Web UI’s Run Task button, and the TUI’s r keybinding.

Setting api_trigger = false says only the scheduler may start this task. Use it for tasks that are dangerous to fire off-schedule — overnight rebuilds, idempotency-fragile data jobs, anything that coordinates with another schedule and would do harm if double-fired by a curious operator.

[tasks.nightly-merge]
cron = "0 4 * * *"
api_trigger = false # only fires from cron, never from the UI / API / CLI
run = "/usr/local/bin/merge-shards"

What changes when api_trigger = false:

  • A POST /api/tasks/{name}/run returns 403 Forbidden with the message API triggering disabled for this task.
  • runwisp exec <name> surfaces the same error and exits non-zero.
  • Cron firings, retries, and on_overlap behaviour are unaffected: the scheduler is not “API triggering” and runs the task normally.

api_trigger is independent of cron. A task with api_trigger = false and no cron is a dead task — it can never start. The loader does not reject this combination today, but it’s almost certainly a mistake.

KeyDefaultWhat it does
max_concurrent1Maximum overlapping runs of this task. Positive integer; hard internal cap of 1024.
on_overlap"queue"What happens when a new firing arrives at the max_concurrent limit: queue / skip / terminate.
queue_max100When on_overlap = "queue", cap on pending firings. New firings past the cap record end_reason = "queue_full". Positive integer; hard internal cap of 10000. 0 rejected.

Most cron-style work wants max_concurrent = 1. See Concurrency policies for when each policy is right.

KeyDefaultWhat it does
retry_attempts0Additional attempts after the initial failure. Non-negative integer; hard internal cap of 100.
retry_delay"5s"Base delay between attempts. Only consulted when retry_attempts > 0.
retry_backoff"constant"Curve applied to retry_delay: constant, linear, or exponential.
restart"never"What to do after a run ends: "never" or "on_failure". "always" is rejected on tasks — use [services.*] for an always-on process.
timeoutinherited from [defaults]Per-attempt wall-clock cap. Unset means no timeout.
graceful_stop"5s"SIGTERM grace period before SIGKILL on timeout, on_overlap = "terminate", manual stop, and daemon shutdown. Set "0s" for insta-kill.

A retry only fires for failed / timeout / crashed. Manual stops and on_overlap = "terminate" end the chain — see the terminate ⇄ retries callout. Full table of formulas on the Retries & timeouts page.

graceful_stop is process-group-wide: a SIGTERM is delivered to the task’s process group, every descendant gets the same window, and any survivors are SIGKILL’d together. If graceful_stop exceeds [daemon] shutdown_timeout the daemon emits a boot-time warning — naming the task — because daemon shutdown will SIGKILL the survivor before the per-task window expires.

KeyDefaultWhat it does
log_max_size100MBPer-run log cap. Units: b, kb, mb, gb, tb. Bare 0, negative sizes, and malformed strings are rejected.
log_on_full"drop_old"What to do at the cap: drop_new, drop_old, kill_task.
keep_runsinherited from [defaults]Keep the N most recent runs for this task. Positive integer; hard internal cap of 1 000 000. Omit to inherit; 0 and negatives rejected.
keep_forinherited from [defaults]Delete runs older than this. Omit to inherit; zero and negatives rejected.

Duration fields throughout runwisp.toml accept s (seconds), m (minutes), h (hours), d (days), and w (weeks) — e.g. "30s", "5m", "36h", "30d", "2w". Mixed forms like "1h30m" work too.

Both keep_runs and keep_for apply if both are set; the stricter one wins in practice. See Logs & retention for the full picture, including the .prev rotation lifecycle and the fixed one-hour cleanup cadence.

KeyDefaultWhat it does
notify_on_failure(none)Notifier IDs to alert on run.failed / run.timeout / run.crashed.
notify_on_success(none)Notifier IDs to alert on run.succeeded.

Each entry is a notifier id declared in a [[notifier]] block. Whatever channels are listed in [notify] append_notifiers (default: ["inapp"]) are added automatically — set append_notifiers = [] to opt out, or ["slack-ops"] to send every failure to that channel instead. See Per-task notifications for the field’s full behaviour.

These two fields are symmetric across [tasks.*] and [services.*] — same field names, same semantics, same duplicate-removal rules. The Per-task notifications page covers both surfaces and is the canonical reference; the [services.*] notifications section points back here.

The config loader refuses these at startup so they can’t fail silently later:

  • restart = "always" — use [services.*].
  • instances = N — services-only field.
  • A task name that’s also used by [services.*] — names share one namespace.
  • Empty or missing run.
[tasks.process-event-queue]
group = "Workers"
description = "Drain the queue every 10 minutes with retries"
cron = "*/10 * * * *"
on_overlap = "skip" # never two queue drainers at once
timeout = "9m" # die before the next firing
graceful_stop = "30s" # this drainer needs more than the 5s default
retry_attempts = 3
retry_delay = "2s"
retry_backoff = "exponential"
keep_runs = 200
keep_for = "14d"
notify_on_failure = ["slack-ops"]
run = """
set -eu
trap 'echo "draining gracefully"; /usr/local/bin/process-queue --drain' TERM
echo "Draining queue at $(date -Iseconds)"
/usr/local/bin/process-queue
"""

For a quick start, run runwisp in an empty directory — it offers to scaffold a minimal runwisp.toml for you.