Tasks vs Services
RunWisp sees work as one of two things: a task that runs and then exits, or a service that’s meant to stay up. Most of the time it’s obvious which one you’ve got — but the two behave differently under the hood, so it’s worth knowing what you’re signing up for.
The one-line rule
Section titled “The one-line rule”Does the command exit on its own once it’s done its job?
Yes →
[tasks.<name>]. No →[services.<name>].
A nightly backup, a health probe, a deploy hook — they do their thing and exit. Those are tasks. A queue worker, a metrics agent, anything that holds a connection open and loops waiting for events — they run until you stop them. Those are services.
And if you catch yourself writing while true; do ...; done inside a
[tasks.*], that’s a service wearing a task costume. Switch the header.
What changes when you switch the header
Section titled “What changes when you switch the header”| Field | [tasks.*] | [services.*] |
|---|---|---|
cron | ✅ schedule firings | ❌ rejected (services aren’t cron-driven) |
catch_up | ✅ latest / all / skip for missed ticks | ❌ N/A |
instances | ❌ rejected | ✅ N replicas, default 1, max 64 |
restart | ✅ never / on_failure; always is rejected | Forced to always — that’s the contract |
restart_delay | ❌ N/A | ✅ base delay before restart, default 1s |
restart_backoff | ❌ N/A | ✅ constant / linear / exponential, default exponential |
retry_attempts | ✅ retry the failing run before recording final status | ❌ services restart instead of retry |
retry_delay | ✅ base delay between retries | ❌ N/A |
retry_backoff | ✅ constant / linear / exponential | ❌ N/A |
on_overlap | ✅ default queue | ✅ default skip (overlap is unusual for an always-on service) |
group | ✅ default "Tasks" | ✅ default "Services" |
Everything else behaves identically on both sides — timeout,
graceful_stop, log_max_size, log_on_full, keep_runs, keep_for,
notify_on_failure, notify_on_success, description, api_trigger.
The only stragglers:
max_concurrent and queue_max
are task-only, and
healthy_after
is service-only.
What both share
Section titled “What both share”Whichever you pick, you get real runs out of it. Every invocation comes with:
- A unique ID and a row in the run history.
- A captured stdout/stderr stream, written to a per-task log file and tailed live in the Web UI.
- An end reason —
success,failed,stopped,timeout,crashed,skipped,log_overflow,missed,queue_full,dst_skipped,daemon_stopped, orstart_failed. - The same notification surface (per-task
notify_on_failure/notify_on_success, plus full[[notification_route]]matching).
That’s really the whole idea: switching from task to service changes how the process gets run, not what you can see or how you operate it.
Services scale horizontally with instances
Section titled “Services scale horizontally with instances”[services.api-worker]instances = 3run = "/usr/local/bin/worker"Each replica shows up as its own run, tagged instance_index 0, 1, 2.
They all share the same config, their logs are pooled per service, and
when one replica’s process exits the supervisor restarts just that one.
”Run N copies” on tasks vs services
Section titled “”Run N copies” on tasks vs services”Tasks scale with max_concurrent (a ceiling on overlapping runs);
services scale with instances (replicas that are always up). The thing
to go by is the shape of the work, not the number.
max_concurrent = 4on acron = "* * * * *"thumbnail-render task. Most ticks wrap up in seconds, but if a backlog hits, up to four can run at once. The 5th concurrent firing has to go throughon_overlap.instances = 4on a queue-worker service. Four worker processes stay glued to the queue forever, and if one crashes the supervisor brings it back. Cron doesn’t enter into it — the workers pull their own work.
Get this backwards and the failure modes are pretty predictable. Model a
long-lived worker as a task with max_concurrent = 3 and you get “one
copy holds the slot while the queue backs up.” Model a 30-second job as a
service with instances = 3 and you get “the supervisor restarts it 120
times an hour.”
Choosing on the edge cases
Section titled “Choosing on the edge cases”A few patterns are easy to second-guess. Here’s how to settle them:
A polling loop you’d rather write as cron
Section titled “A polling loop you’d rather write as cron”A while true; do work; sleep 60; done is almost always better as a
task with cron = "* * * * *" and on_overlap = "skip". Now every tick
gets its own run, exit code, and log file — you can see exactly when the
04:32 poll failed, and a crashed tick gets retried instead of quietly
looping back around on broken state.
The question that decides it: does each invocation start fresh? If yes, it’s a task. If the process is holding onto state that’s worth keeping between runs — a worker that’s joined a queue and would have to re-join on every restart, a chat-server connection, a search index it built up in memory — it’s a service.
A one-off you only ever trigger from the API or UI
Section titled “A one-off you only ever trigger from the API or UI”Still a task — just leave cron off. It shows up in the UI and takes
manual triggers from runwisp exec <name>, the Web UI’s Run Task
button, the TUI, or POST /api/tasks/{name}/run. Set
api_trigger = false if you want it to be cron-only and not runnable
over the API.
A task that “should never overlap”
Section titled “A task that “should never overlap””Tasks already default to on_overlap = "queue", so firings line up and
run in order. If overlap genuinely can’t happen for your workload — say
two runs would fight over a single resource — switch to
on_overlap = "skip". The rejected firing still lands in history as
end_reason = "skipped", so you can spot when a previous run ran long.
What you can’t do
Section titled “What you can’t do”- A task with
restart = "always". Rejected at startup — that’s what[services.<name>]is for. - A service with
cron. Services don’t have that field; they’re already running nonstop. - The same name on both sides. Tasks and services share one namespace, and the loader rejects duplicates with a clear error.