Tasks
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.