# Closed-loop feedback

Use this when an automated step should keep iterating until a separate
reviewer node approves the result: the canonical "AI judge sends a draft back
for another pass until it's good enough" shape. Roscoe treats cycles as a
first-class pattern. The runner enforces a per-loop iteration cap and can
short-circuit a stuck loop when consecutive iterations produce identical
results.

## The workflow

A two-node loop where a `draft` step produces work and a `review` judge gates
the exit. The back-edge from `review` re-enters `draft` when the verdict is
negative; `maxIterations` on `draft` bounds the loop so it can't run forever.

```yaml
name: reply-revision-loop
version: 1
description: Draft a customer reply and revise it until an AI reviewer signs off, or fail at the cap.
initial: draft
inputs:
  - name: email
    type: string
    description: The customer's email needing a reply.
states:
  draft:
    type: ai_agent
    label: Draft the customer reply
    model: claude-sonnet-5
    prompt: |
      Draft a reply to this customer email. Incorporate the reviewer's latest
      feedback: {{ review.reasoning || "" }}

      Customer email: {{ email }}
    maxIterations: 5
    on:
      done: review

  review:
    type: ai_judge
    label: Review the reply
    model: claude-sonnet-5
    prompt: |
      Does this reply meet our tone and policy guidelines? Reply true (send)
      or false (revise), and include reasoning the next draft can act on.
    validator:
      kind: boolean
    detectStall: true
    on:
      'true': approved
      'false': draft

  approved:
    type: end
    label: Approved
    outcome: success
```

The `|| ""` fallback in `draft`'s prompt matters on the first pass: `review`
hasn't run yet at that point, and the loader rejects a template reference to
a node that isn't guaranteed to have run before the one reading it. A
fallback satisfies that check and reads as an empty string until the first
verdict exists.

What happens at runtime:

1. `draft` runs, produces a reply. `done` routes to `review`.
2. `review` returns `false` (needs more work). The `false` transition is a
   **back-edge**: it points to a node already on the current path. The
   runner re-enters `draft` and increments its visit counter.
3. The loop runs up to `maxIterations: 5` attempts. If the 6th entry into
   `draft` is required, the run fails with a structured error pointing at
   the gate. If `review` returns the same verdict three times in a row,
   `detectStall: true` short-circuits the loop even earlier.

## The cap

Set `maxIterations: N` on **at least one node** in any automated cycle. The
cap can sit on the draft node (as above), on the reviewer, or on a different
node in a larger cycle: every iteration of the loop must pass through it, so
the count is correct regardless of where you put it. Common choice: put it
on the node whose retries cost the most (the draft node, usually).

Limits: integer between 1 and 50. Without a cap, the loader rejects the
workflow with `closed-loop cycle (...) has no maxIterations on any
participating node`. One exception: a cycle containing a `human` node is
exempt, because every iteration requires an explicit user action (`approve`,
`retry`, `abort`), and the human's pace is already a practical cap. Self-loops
still need one; they're the simplest case and the easiest to forget.

## Stall detection

`detectStall: true` on a gate node turns on output fingerprinting. Each
visit, the runner records a normalised digest of the node's output and
chosen transition. Three identical digests in a row → the run fails as
**stalled** before the cap fires. Useful when an AI judge gets stuck in a
"reject because still bad" rut where each iteration looks the same.

Fingerprinting is whitespace-collapsed, case-insensitive, and order-
independent for JSON objects. A judge that rejects with literally identical
reasoning three times stalls; a judge that rejects with new specifics each
time keeps going up to the cap.

`detectStall` is available on every node type that also takes
`maxIterations`: `script`, `ai_agent`, `ai_judge`, `consensus`,
`round_robin`, `map`, `condition`, and `subworkflow`. Most useful on
reviewer-shaped nodes; not useful on a draft node whose output legitimately
changes every pass.

## What you see in the run view

The web UI surfaces three affordances when a workflow contains a cycle.
Loop-back edges render dashed, with a `↻` icon in the transition-label pill,
so back-edges read distinctly from forward flow. The loop-target node shows
a small loop badge: `max 5` (or `no cap`) at author time, `3/5` while a run
is iterating, and `5/5` in crimson once the loop hits its cap. Failed runs
render an Alert banner with the structured `__loopError` payload (the
offending node, the cap, and the last output) for triage.

## Failure shapes

A failed loop run persists a `__loopError` object in the run context:

```jsonc
{
  "kind": "iteration_exhausted",
  "nodeId": "draft",
  "cap": 5,
  "attempts": 5,
  "lastOutput": { "response": "..." },
  "message": "Iteration limit reached at node \"draft\" after 5 attempts",
}
```

```jsonc
{
  "kind": "stalled",
  "nodeId": "review",
  "attempts": 3,
  "lastFingerprint": "...",
  "lastOutput": { "result": false, "reasoning": "..." },
  "message": "Node \"review\" stalled — produced an identical result 3 times in a row",
}
```

Both are read by the run-view banner and available via the REST/MCP run
detail endpoints for programmatic handling.

## Multi-node cycles

The cap and stall semantics work the same for cycles of any size. Three-node
example (`plan → fix → review → plan`):

```yaml
initial: plan
states:
  plan:
    type: ai_agent
    label: Plan the work
    model: claude-haiku-4-5
    prompt: 'Plan the changes given {{ review.reasoning || "" }}.'
    on:
      done: fix
  fix:
    type: ai_agent
    label: Apply the plan
    model: claude-sonnet-5
    prompt: 'Apply this plan: {{ plan.response }}.'
    on:
      done: review
  review:
    type: ai_judge
    label: Review the result
    model: claude-sonnet-5
    prompt: 'Does this meet the brief?'
    validator: { kind: boolean }
    maxIterations: 10
    on:
      'true': shipped
      'false': plan
  shipped:
    type: end
    label: Shipped
    outcome: success
```

Cap on `review` (the loop-target of the back-edge) bounds the whole cycle.
The runner records `iterationIndex` for every node visit, so the run
timeline shows each node's full visit history rather than only its most
recent pass.

## Variations

- **Multiple back-edges to the same target.** Several branches can route
  back to the same draft node; they merge into one cycle, and the cap on the
  target bounds all of them together.
- **Cap on the reviewer instead of the draft node.** Functionally identical
  for most workflows. Put the cap where the constraint reads most naturally
  ("review at most 5 times" vs "draft at most 5 times").
- **Human-in-the-loop variant.** Replace the AI judge with a `human` node.
  The cap requirement drops automatically because the loop can't run
  unattended.
- **Remember work across iterations.** A `script` node in the loop can use
  `$ROSCOE_RUN_DIR` (a temporary folder unique to the run) as loop memory:
  read a file at the top of each pass, update it, write it back. It's keyed
  to the run, so concurrent runs never clobber each other, unlike the old
  hazard of writing to the workflow-wide `$ROSCOE_WORKFLOW_DIR`. See
  [/docs/nodes/script](/docs/nodes/script).

## Try it locally

Ready-to-run fixtures covering every permutation live in
`packages/core/test-fixtures/closed-loop/`:

| Fixture                           | What it demonstrates                              |
| --------------------------------- | ------------------------------------------------- |
| `01-self-loop-exits.yaml`         | A single-node retry loop that exits naturally     |
| `02-self-loop-cap-exhausted.yaml` | `iteration_exhausted` when the loop never exits   |
| `03-two-node-bounded.yaml`        | Canonical `gate ↔ fix` shape exiting cleanly      |
| `04-two-node-cap-exhausted.yaml`  | Same shape, gate never passes, cap fires          |
| `05-three-node-cycle.yaml`        | Multi-node cycle with the cap on `review`         |
| `06-stall-detected.yaml`          | `detectStall` short-circuits identical verdicts   |
| `07-stall-output-varies.yaml`     | `detectStall` allows progress when output changes |
| `08-human-gated.yaml`             | Human-gated cycle that doesn't need a cap         |

Each fixture takes a counter file via `--var counterFile=/tmp/...` so it can
be run repeatedly without manual state cleanup.

## See also

- [/docs/nodes/ai-judge](/docs/nodes/ai-judge) — the most common reviewer
  node type.
- [/docs/nodes/consensus](/docs/nodes/consensus) — when a single reviewer
  isn't enough.
- [/docs/recipes/multi-step-analysis](/docs/recipes/multi-step-analysis) —
  the linear "summarize → verify → loop on reject" recipe this generalises.
- [/docs/authoring/yaml-structure](/docs/authoring/yaml-structure) — the
  cycle integrity rules the loader enforces.
