Skip to main content

Tasks

View Markdown

A task is one unit of work: send the SMS, call the model, query the bank. It is a suspend fun, it may do anything, and the orchestrator takes care of retrying it, timing it out and refusing to run it twice under one id.

data class GreetingInput(val greeting: String, val name: String)
data class Greeting(val text: String)

object GreetingTask : TaskHandler<GreetingInput, Greeting> {
override val type = taskType<GreetingInput, Greeting>("ComposeGreeting")

override suspend fun handle(input: GreetingInput, ctx: TaskContext): Greeting =
Greeting("${input.greeting}, ${input.name}!")
}

A task is started from application code, not only from inside a flow:

val greeting = orchestrator.run(GreetingTask.type, GreetingInput("Hello", "Ada"), options)

Contract: idempotent

Every engine retries. A retry means "run this again", not "run this once more if it didn't run". A handler that cannot be idempotent says so by throwing NonRetryableTaskError on the second attempt; ctx.attempt tells it which attempt it is on.

Heartbeats

ctx.heartbeat() tells the engine the task is alive. Two things ride on it:

  • Cancellation is delivered at the next heartbeat. A handler that never heartbeats cannot be cancelled, only timed out.
  • A dead worker is detected by a missed heartbeatTimeout, which turns a stuck attempt into a retried one.

Short tasks need neither. Anything that runs for more than a few seconds should heartbeat.

Type names

The name in taskType<I, O>("ComposeGreeting") is the wire identity: what the orchestrator stores, what the UI and CLI show, and what a worker registers. Inputs and outputs are plain data classes; the adapter owns serialisation.

Remember: a task is the only place side effects live.