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:

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:

  1. Computes a stable task_id from the task name and its arguments.
  2. Looks in the recorded event log for a prior TASK_COMPLETED or TASK_FAILED with that task_id.
  3. If found, returns the recorded value without calling the function.
  4. If not found, calls the function, records TASK_STARTED, then TASK_COMPLETED with the return value (or runs the retry / fallback / rollback chain on failure).
  5. 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:

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

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?

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

Where this shows up