Engineering

Introducing controller workflows: not a loop, not a graph, but a reconcile loop

The industry is arguing about whether agents should run as loops or graphs. I think both miss the same thing: give the agent a task list and let it reconcile.

Yota Hamada
Share

Introducing controller workflows: not a loop, not a graph, but a reconcile loop

TL;DR A loop lets the model decide when it is finished. A graph fixes every path up front and rots into a state machine nobody can maintain. A controller workflow is the third shape: you declare the tasks and what done means, and the agent reconciles against them, the way a Kubernetes controller reconciles a spec.

Dagu is a workflow engine. You write the steps and what each one depends on, and it works out the order. For most workflows that is exactly right.

Then there is the work where the order is a consequence rather than a plan.

Think about work that has to get past a person. Something drafts a design, a reviewer reads it, and the answer is not pass or fail. It is "this does not handle back-pressure," so the design gets redone. The next review says the data model is wrong, so the schema work has to be redone before the design can be touched again.

A graph can branch on an exit code. It cannot branch on what the review actually said. You know every action involved, and you know what "approved" means. The order is the part you cannot write down, because the order is whatever the feedback turns out to require.

You can force it into a graph anyway. I have done it. What you get is a router step, a retry policy tuned by superstition, three branches left over from an incident last quarter, and a shell condition nobody wants to touch. The graph stops describing the work and starts describing the exceptions.

Loops, graphs, and controllers

In June, Peter Steinberger told a few million people to stop prompting coding agents and start designing loops that prompt agents. Six weeks later, he was asking whether we were still talking about loops or had moved on to graphs. The terminology had changed quickly, but the design problem had not.

Both camps are describing something real. I do not think either answer is maintainable.

A graph of agent behaviour is a state machine. Somebody has to keep it correct as the work changes, and that somebody is a human reading a diagram of decisions a model will make. Every incident adds a node. Every exception adds an edge. Six months in, nobody can tell whether a branch is load-bearing or a fossil, and nobody dares delete it. I have never seen a hand-maintained agent behaviour graph age well, and I do not expect to.

Loops make the opposite tradeoff. A loop has no advance plan for a failed action to invalidate. But a bare loop is blunt. The agent decides when it is done. You cannot define "finished" independently or reserve specific decisions for deterministic steps. That is fine for a coding agent chewing on a repository. It is not enough for an operations workflow.

What I want sits between them: a task list and a reconcile loop.

Yes, a reconcile loop is still a loop. That is the point. The disagreement was never really loop versus graph, it was whether the termination condition belongs to the model or to you. A bare loop gives it to the model. A graph tries to answer it by drawing every path in advance. A reconcile loop keeps the loop and takes back the termination condition.

Declare what must be true when the run is over. Let the agent read those completion criteria and decide which tasks to work on, in what order, and how many times. The loop can adjust when an action fails, but it terminates against criteria you wrote rather than whenever the model feels finished.

The useful bit turned out to be that the agent does not have to complete every task. It has to settle them. If a task is not needed, the controller skips it and records why. The run can still succeed. Without that distinction, the first implementation was worse than useless.

This is why I call it a controller and not an agent or a planner. A Kubernetes controller does not execute a plan. It observes the world, compares it to a declared spec, and acts to close the gap, over and over, until there is no gap left. That is the same loop. The declared spec is the task list. The actions are your steps.

A workflow engine already knows how to handle retries, queues, schedules, artifacts, logs, and human approval. Inside that engine, the boundary between delegated and deterministic work becomes a line in YAML rather than an architectural argument. Your steps stay ordinary steps. Only the ordering is handed over.

The shape

Set type: controller. Your steps stop being a plan and become a catalog of actions. A new tasks field declares what must be true when the run is over.

Two reviewers have to sign off on a piece of code. One cares about simplicity, one about correctness, and a change that satisfies one can upset the other.

type: controller

env:
  - CHECKOUT: /srv/checkout

working_dir: ${CHECKOUT}

llm:
  provider: openrouter
  model: anthropic/claude-sonnet-4.5
  system: |
    Get top_n.py past both reviews.

    When a review fails, pass its finding to revise, then ask that same
    reviewer again. A revision made for one reviewer can break the other, so a
    review you already passed has to be run again after any later revision.

steps:
  - name: review_simplicity
    description: Review top_n.py for simplicity only. Prints PASS, or FAIL with what to change.
    action: harness.run
    with:
      provider: opencode
      prompt: |
        Read top_n.py. Judge simplicity only: needless cleverness, indirection,
        or an idiom that a plainer one would replace. Ignore correctness.
        Print exactly one line: "PASS" or "FAIL: <what to change>".

  - name: review_correctness
    description: Review top_n.py for correctness only. Prints PASS, or FAIL with the bug.
    action: harness.run
    with:
      provider: opencode
      prompt: |
        Read top_n.py. Judge correctness only: does it do what the docstring
        says, for every input. Ignore style.
        Print exactly one line: "PASS" or "FAIL: <the bug>".

  - name: revise
    description: >-
      Edit top_n.py to resolve one review finding. Pass the finding text the
      review just reported.
    action: dag.run
    with: { dag: revise }

tasks:
  - name: simplicity_passed
    description: Finished when the most recent simplicity review returned PASS on the current top_n.py.
  - name: correctness_passed
    description: Finished when the most recent correctness review returned PASS on the current top_n.py.

---

name: revise
description: Edit top_n.py to resolve one review finding.
working_dir: ${CHECKOUT}
params:
  - finding: ""
steps:
  - name: edit
    action: harness.run
    with:
      provider: opencode
      prompt: |
        Edit top_n.py to resolve exactly this review finding and change
        nothing else:

        ${finding}

        Print one line describing the edit you made.

There is no depends anywhere. There cannot be: ordering is the thing being delegated. Each turn the model picks one action, watches what happened, and picks again.

revise takes a finding, and nothing fills it in. The controller does, from the review it just read. That is the whole feedback path: a reviewer prints a line, the controller carries that line to the editor, and the editor changes one thing. No file passes between them.

Every action is a coding agent, so nothing here is a stand-in. The starting code was clever and wrong on the same line:

def top_n(items, n):
    """Return the n largest items, largest first."""
    return sorted(items, key=lambda x: -x)[:n - 1]

Running it:

1  ▶ review_simplicity    FAIL: replace key=lambda x: -x with reverse=True
2  ▶ review_correctness   FAIL: [:n-1] returns n-1 items instead of n
3  ▶ revise               changed [:n - 1] to [:n]
4  ▶ review_correctness   FAIL   #2   key=lambda crashes on non-numeric items
5  ▶ revise               #2   replaced the key with reverse=True, clamped the slice
6  ▶ review_correctness   #3
7  ▶ review_simplicity    PASS   #2
8  ✓ correctness_passed   completed
9  ✓ simplicity_passed    completed
def top_n(items, n):
    """Return the n largest items, largest first."""
    return sorted(items, reverse=True)[:max(n, 0)]

Turn 4 is the interesting one. The correctness reviewer had already been given a fix and came back with a different objection, one that overlapped the simplicity finding from turn 1. Turn 7 is the other half: simplicity failed at the start, two revisions happened for a different reviewer, and it gets asked again before its task is settled.

Nobody wrote that order down. What is written down is one paragraph telling the controller how reviews relate to revisions, and two tasks phrased as conditions on the current state of the file. Writing it as a graph means an edge from each reviewer to revise and back again, and a rule to stop it cycling.

Turn 6 shows the failure mode you accept here: that reviewer printed nothing at all, and an agent that says nothing looks exactly like one that approved. Put output_schema on a review step if you want silence to fail the run instead.

Four ways a goal can end

The first version got task completion wrong.

That version had one tool, complete_task, and the instruction to call it when the criteria were met. Then I ran it against a task that was moot, something like signing a Windows installer for a project that ships no Windows build.

The model had no honest move. The rules said complete a task only when its criteria are satisfied, and the criteria could never be satisfied. It burned three turns running an unrelated action, then gave up and marked the task complete anyway with an apologetic reason.

That is a design bug, not a model failure. So there is now one tool, set_task_status, and four states:

Status Meaning Effect on the run
completed The criteria are met. Task settled.
skipped It turned out there was nothing to do. Run still succeeds.
failed It cannot be achieved. Run fails, naming the task.
open Undo a decision later work invalidated. Back into the loop.

The run ends when no task is open, not when every task is completed. That lets a workflow carry a task that is only sometimes relevant. The agent decides on the day whether it applies, instead of you encoding that judgment as a precondition you have to keep true forever.

The run log needs separate skipped and failed states. "We did not need to do this" and "we could not do this" are different outcomes, and a log that cannot tell them apart is not worth auditing.

After the change, the model skipped the moot task on the first turn and explained why. It did not run an action.

Letting it ask

Some questions cannot be written down in advance either. The controller can call ask_user with a question of its own wording, and the run waits exactly like a declared human task.

Waiting here is durable. The run goes to waiting, the process exits, and the worker slot is released. Hours later someone answers, the run resumes on a fresh process, and the controller picks the conversation back up with the answer as its next observation. A workflow can wait for a person without leaving a script running.

dagu human-task complete <dag> --run-id <id> --step ask_user \
  --inputs-json '{"answer":"staging-eu, never production"}'

This uses the existing human-task machinery, including its completion and resume paths. Every controller carries a synthesized human task, and the tool opens it with whatever the model wrote.

Testing exposed a guardrail I had not expected to need. The model asked the same question twice in different words, and each question paused the run until a person responded. Putting prior answers back into the prompt on every turn fixed it. The conversation history alone was not enough, because a model that re-reads its instructions each turn will re-derive the same need to ask.

Reading the run

A controller has no dependency edges, so a graph of it is a row of disconnected boxes. Rendering that would be a waste of the space.

The Status tab shows a decision timeline instead. One row per turn, in the order things actually happened, with attempt numbers, durations, and a link to the child run each action produced. A Tasks tab shows each goal, its status, and the reason the controller gave for settling it. The full transcript, including every tool result the model saw, is in the Chat tab.

I would not put an agentic run near production if I could not reconstruct it later. The run records each decision, why the controller made it, and what happened next.

The objections

"This is just an agent loop with a task list." Mostly, yes. The loop is not the interesting part. What matters is where it runs. A controller run gets durable suspension, retries, queues, schedules, artifacts, an audit trail, and human approval because it is a DAG run, not because I built any of that for agents. An agent framework asks you to rebuild those, and they are the parts that take years to get right.

"The model will do something expensive or destructive." It can only choose from the actions you declared. It cannot invent a step, write its own shell command, or reach anything you did not put in the catalog. The blast radius of a controller workflow is the set of steps in the file, which is the same blast radius that file has as a graph. What the model controls is the order, not the capability.

"Do not put non-determinism in production." I agree with the instinct, which is why type: graph stays the default and this is opt-in per workflow. The non-determinism is confined to ordering. Every action is as deterministic as it ever was, and anything you come to trust can move into a sub-workflow and stop being a decision at all. Where the instinct is simply right, the next section is the honest answer.

When not to use it

If you can draw the graph, draw the graph.

type: graph is faster, cheaper, and reproducible. It calls no model and follows the same path every time. A controller workflow costs one model call per turn and will not produce an identical trace twice.

Reach for a controller when the order genuinely depends on what happens: on what a reviewer says, on what an earlier step produced, on how many times something has already failed. That is a real category of work, and it is smaller than it looks.

There are limits in place because the failure mode of an unbounded agent is not a crash, it is a large bill. An action runs at most five times per run. A run asks at most five questions. The turn limit defaults to fifty and is yours to lower. Hitting a limit fails the run and names what was left open.

Availability

Controller workflows land in v2.11. The documentation is at Controller Workflows.

The llm block above names OpenRouter, but nothing here depends on it. provider takes openai, anthropic, gemini, openrouter, zai, opencode, or local, and a base_url points any of them at a proxy or an OpenAI-compatible server you run yourself, so a controller can reconcile against a model on your own hardware. Providers & Endpoints has the credential variable each one reads.

Everything else about the run is unchanged. It is still a DAG run, with the same logs, history, retries, artifacts, queues, and status semantics. You can schedule it or make it a child of another workflow. It will decline to ask questions as a child, since nobody is watching a sub-workflow.

The graph is still the right default. This is for the work that never fit in one.

· · ·

Written by

Yota Hamada

Contributor to the Dagu project.

More from Yota Hamada