Skip to content

Tasks vs Services

RunWisp models work as one of two things: a task that runs and exits, or a service that stays up. Picking the right one is almost always straightforward — but the schema enforces real differences, so it pays to know what you’re committing to.

Does the command exit on its own when it’s done its job?

Yes → [tasks.<name>]. No → [services.<name>].

A nightly backup, a health probe, a deploy hook — they run, they exit. Those are tasks. A queue worker, a metrics agent, a long-poll listener — they hold a connection, they loop, they expect to be killed by you. Those are services.

If you find yourself writing while true; do ...; done inside a [tasks.*], that’s a service in disguise — switch the section header.

Field[tasks.*][services.*]
cron✅ schedule firings❌ rejected (services aren’t cron-driven)
catch_uplatest / all / skip for missed ticks❌ N/A
instances❌ rejected✅ N replicas, default 1, max 64
restartnever / on_failure; always is rejectedForced to always — that’s the contract
restart_delay❌ N/A✅ base delay before restart, default 1s
restart_backoff❌ N/Aconstant / 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_backoffconstant / 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 — timeout, parallelism, log_max_size, log_on_full, keep_runs, keep_for, notify_on_failure, notify_on_success, description, api_trigger — works the same for both.

Both kinds are first-class runs in RunWisp. Each invocation gets:

  • A monotonic ULID and a row in SQLite.
  • A captured stdout/stderr stream, written to a per-task log file and available live via SSE in the Web UI.
  • Lifecycle status — pending → running → ended — with a terminal end reason of success, failed, stopped, timeout, crashed, skipped, or log_overflow.
  • The same notification surface (per-task notify_on_failure / notify_on_success, plus full [[notification_route]] matching).

That’s the point: switching from task to service changes the lifecycle model, 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 = 3
run = "/usr/local/bin/worker"

Each replica is its own visible run with replica_index 0, 1, 2. They share configuration, logs are unified per service, and each replica is restarted independently when its process exits. Bound: instances must be in [1, 64].

A few patterns are easy to second-guess. Here’s how to resolve them:

A polling loop you’d rather express as cron

Section titled “A polling loop you’d rather express as cron”

If your “service” is just while true; do work; sleep 60; done, you almost certainly want a task with cron = "* * * * *" and on_overlap = "skip". Reasons:

  • Each tick gets its own captured run, with its own exit code and log file — you can see exactly when the 04:32 poll failed.
  • A crashed run gets retried (retry_attempts) instead of silently re-entering the loop with broken state.
  • The schedule is reviewable in one place; sleep 60 buried in a script is not.

The deciding question isn’t “does it loop” — it’s does each invocation start cold?

  • Yes — every tick can start fresh, do its work, and exit. Use a task. The supervisor isn’t buying you anything.
  • No — the process caches data, holds open connections, or maintains consumer-group / subscription state you’d rather amortise across restarts. Use a service. Examples: a Kafka consumer (rebalance cost), a long-poll websocket listener (handshake cost), an LSP host (warm index).

A polling loop with no in-memory cache is the first answer; a Kafka consumer is the second.

A one-off that you only want to trigger from the API or UI

Section titled “A one-off that you only want to trigger from the API or UI”

That’s still a task. Omit cron. The task appears in the UI and accepts manual triggers via runwisp exec <name>, the REST API, or the TUI. Set api_trigger = false to make a task cron-only and not API-runnable.

Tasks already default to on_overlap = "queue" (FIFO). If overlap is genuinely impossible for your workload (e.g. competing for a single resource), use on_overlap = "skip" — the rejected firing is recorded in history as a failed run with the message “task already running”, so you can see when the previous run took too long.

  • A task with restart = "always". Rejected at config-load time — use [services.<name>] instead. The schema refuses to let you half-define an always-on process inside the task model.
  • A service with cron. The field doesn’t exist on [services.*]. Cron creates discrete firings; services already run continuously.
  • A name shared between [tasks.*] and [services.*]. Names are a single namespace. The loader rejects duplicates with a clear error.

These aren’t arbitrary — they’re the bits where the abstractions would leak. The error is the documentation.