The task/workflow split
Why Flux has two primitives instead of one, what goes where, and how the split makes replay possible.
Flux has two decorators: @workflow and @task. Most agent frameworks ship with one primitive — CrewAI has agents and tasks but no replay model; LangGraph has nodes; older LangChain has chains. Why does Flux split the world in two?
The short answer: because durable replay needs a deterministic plan layered over recorded effects. If the plan and the effects live in the same function, you can’t replay safely. The split makes the contract between them explicit.
What goes in a workflow
The workflow function is the plan. It describes the order in which work happens: call task A, then in parallel call B and C, then if the result is X branch to D, otherwise to E. Workflow code is control flow — await, if, for, gather, function calls. It does not touch the outside world.
Concretely, the workflow function is what flux/workflow.py invokes inside workflow.__call__: it runs your function with an ExecutionContext, traps exceptions, and writes a checkpoint at the end. Every time the workflow is resumed — after a crash, after a pause, after the worker restarts — it re-runs from the top with the same recorded events available. Anything in the body that produces a different value the second time around will desync the replay.
That is the determinism constraint, covered in detail in Determinism. It is the reason workflow bodies can’t call requests.get(...), can’t read datetime.now(), can’t generate random numbers, and can’t reach into a database. Those things must move into tasks.
What workflow bodies can do:
awaittasks and collect results.- Branch on task results with
if/match. - Loop over inputs that came from the workflow’s own input or from a previous task.
- Call other workflows via the
call()built-in (see below). - Compose tasks with
asyncio.gather,parallel,pipeline, orGraph.
What goes in a task
A task is the unit of recorded effect. Anything that touches the outside world goes here: HTTP requests, database reads and writes, file I/O, LLM calls, calls to other services, reads of the system clock, generation of UUIDs. Anything whose result you couldn’t reproduce by re-running the function — anything non-deterministic — is a task.
The runtime contract is small. When a workflow awaits a task, Flux:
- Computes a stable
task_idfrom the task name and its arguments. - Looks in the recorded event log for a prior
TASK_COMPLETEDorTASK_FAILEDwith thattask_id. - If found, returns the recorded value without calling the function.
- If not found, calls the function, records
TASK_STARTED, thenTASK_COMPLETEDwith the return value (or runs the retry / fallback / rollback chain on failure). - Checkpoints the context.
That logic lives in flux/task.py::task.__call__. The key observation: from the workflow’s point of view, a task call returns either a fresh result (first run) or the recorded result (replay), and these are indistinguishable. The workflow body sees one value either way.
Tasks are also where the operational features attach. Retries, timeouts, fallbacks, rollbacks, caching, secret injection, output storage, auth checks — all of these are configured per-task via @task.with_options(...). The workflow body stays clean of them.
Why the split
Replay is the load-bearing requirement. If a workflow crashes after task 3 of 10, Flux needs to bring it back to life on a different worker an hour later and pick up at task 4. To do that, two things must hold:
- The plan re-evaluates the same way. If the workflow function produces a different control-flow path on replay, Flux can’t line up the recorded events with the new execution. Determinism guarantees the plan is stable.
- The effects are recorded. If a task already ran, Flux returns its recorded output rather than running it again. Idempotency-by-replay is what makes “resume after crash” safe even when the task did something irreversible.
These two requirements pull in opposite directions: the plan must be pure, the effects must be impure, and the only way to keep both is to put them in different places. Hence the split between workflow and task.
The alternative — one primitive that mixes plan and effect — is what most agent frameworks have. It works fine until the process crashes, at which point either the system loses state (CrewAI, LangChain) or it has to re-execute every side effect from the top (no idempotency). Flux’s split is the same trade Temporal makes; the deeper comparison lives in vs Temporal.
What the split gives you
- Retries are a config flag, not a code change. Add
retry_max_attempts=3to a task and Flux retries on exception, records each attempt as aTASK_RETRY_STARTEDevent, and resumes from the successful attempt on replay. The workflow body never sees the retry logic. - Idempotency is implicit. A completed
TASK_COMPLETEDevent is the source of truth. Replay returns the recorded value, so the task body is effectively invoked once pertask_idper execution, no matter how many times the workflow restarts. - Observability comes with the model. Every task is a span. Flux emits OpenTelemetry spans for each task call (when the
observabilityextra is enabled) and records every state transition as anExecutionEvent, so traces and event logs line up without extra instrumentation. - Composition reuses the same primitive. Tasks compose into workflows. Workflows compose into workflows:
flux.tasks.call(influx/tasks/call.py) is a task that calls another workflow via the HTTP API and waits for the result, so workflows-as-tasks fall out of the same model.
What the split costs
Discipline. You can’t sprinkle requests.get(...) into workflow code. You have to decide where the boundary is for every operation that has a side effect. Most engineers get this wrong once, in their first workflow, and then get it right.
The failure mode is silent at first: the inline workflow.run("input") path will happily execute non-deterministic workflow code as long as nothing crashes. The bug only surfaces under replay — after a pause-resume, after a worker eviction, after a deploy that restarts the worker mid-execution. By then the workflow has produced inconsistent state.
The fix is mechanical: pull the side-effect call into a @task and await it from the workflow. The bug class disappears once the boundary is right.
The gray zone
What about reads of small, durable values from inside a workflow body — env vars, hard-coded constants, the contents of a Pydantic model passed in as ctx.input?
- Hard-coded constants and
ctx.input: fine. They are the same on every replay by construction. - Imports and module-level state: fine, as long as the module is loaded the same way on every worker. Flux ships workflow source base64-encoded from server to worker and
exec-loads it under a synthetic module name, so module-level state is recomputed per-replay anyway. - Env vars: pragmatically fine for most readers. Env doesn’t usually change between checkpoint and resume. But the safe default is “if it could vary, move it into a task.” If a workflow’s behavior depends on
os.environ["FEATURE_FLAG"], wrap that read in a task so the value is recorded. - Logging: fine. Logs are not part of the recorded effect — they’re observability output and Flux doesn’t replay them.
The rule of thumb: if it’s I/O, it’s a task. If it’s pure computation over data the workflow already has, it can live in the workflow body. When in doubt, make it a task. The cost of a task call is one event-log row.
How other frameworks compare
Temporal uses the same split, with different names: workflows and activities. Workflow code is replay-deterministic; activities are the side-effect units. The split exists for the same reason — durable replay — and the design moves are mostly the same. The deeper comparison lives in vs Temporal.
CrewAI, older LangChain, LangGraph don’t split because they don’t have a replay model. There is no log to replay over, no concept of resuming an in-flight execution after a crash, no idempotency-by-replay. They get a simpler programming model — one primitive instead of two — at the cost of durability. If the process dies mid-run, the run is gone.
Prefect sits in between: it has a notion of task-as-recorded-unit but the workflow body isn’t held to determinism, and the durability semantics are best-effort rather than Temporal-class. The split is softer there.
The trade is the same shape every time. One primitive is simpler to teach and write; two primitives are the price of durable replay. Flux pays the two-primitive price because durable replay is the feature.
What to remember
- The workflow function is the plan; it must be deterministic and re-evaluable.
- A task is the unit of recorded effect; anything that touches the outside world is a task.
- The split exists because durable replay needs a pure plan over recorded effects.
- Retries, idempotency, observability, and composition fall out of the split for free.
- “If it’s I/O, it’s a task” is the rule of thumb.
Where this shows up
- The execution model — the replay loop that demands the split.
- Determinism — the constraint the split enforces.
- Defining tasks — the task API.
- Defining workflows — the workflow API.
- vs Temporal — the system that makes the same split.