Loop Executor
Loop executor and iteration journaling.
The loop executor lets a workflow repeat a group body up to a configurable maximum number of iterations, with an optional break condition, inter-iteration delay, and configurable policy for when the cap is reached without breaking. Each iteration produces a durable journal entry in step_executions so a mid-loop crash can resume from the last completed iteration rather than starting over.
Loops require the loop node type. In the node palette, expand the collapsed Utility Nodes section at the bottom and click or drag the Loop button to add one to the canvas. The palette seeds the loop with a fresh empty group body that you then populate with nodes. The loop node references the group by groupId - you can also author or edit it directly in the YAML pane. Use loops when a task requires repeated execution of a fixed sub-graph - for example, polling an external API until a condition is met, or running an agent multiple times until it reaches a success state.
How it works
A loop node wraps a typed W2 group - a named sub-graph with declared input/output ports. When the DAG executor encounters a loop node, it delegates to executeLoopNode before the step executor would otherwise run. The loop executor:
- Loads any previously-persisted iteration journal entries (for crash recovery / resume).
- Runs the group body for each iteration via a
LoopExecutorAdapter. - After each iteration, evaluates the optional
breakConditiontemplate expression. - Persists a
LoopIterationJournalEntrytostep_executionskeyed as<loopNodeId>#iter-<N>. - Applies
delayBetweenif configured. - When
maxIterationsis reached without a break, applies theonMaxIterationspolicy.
The loop executor is pure - it never touches the database or Redis directly. Persistence and body execution are injected as a LoopExecutorAdapter, which the production runtime (run-instance.ts) wires with a database-backed implementation.
Loop configuration
A loop node is authored in the workflow YAML definition:
# workflow/v2 snippet - loop node
- id: poll-until-ready
type: loop
name: Poll until job ready
loopConfig:
groupId: check-job-status # must match a spec.groups[].id
maxIterations: 20
delayBetween: 30000 # 30 seconds between iterations
breakCondition: "nodes.check-job-status.output.ready"
onMaxIterations: fail # fail | continue | escalateProp
Type
breakCondition expressions
The breakCondition is evaluated as a template expression after every successfully-completed iteration. It has access to the same namespaces as any workflow template, plus a loop-specific augmentation:
| Expression | Resolves to |
|---|---|
nodes.<groupId>.output.<field> | Most recent iteration's body output field |
nodes.<loopId>.output.iteration | Current iteration number (1-based) |
nodes.<loopId>.output.iterations[N].<field> | Output of iteration N (0-based) |
nodes.<loopId>.output.last.<field> | Most recent iteration's body output (alias) |
trigger.* | Workflow trigger event data |
Values false, 'false', '0', 'null', 'undefined', and empty string are treated as falsy - everything else halts the loop.
# Break when the body reports done=true
breakCondition: "nodes.check-job-status.output.done"
# Break after the third successful attempt
breakCondition: "nodes.poll-loop.output.iteration >= 3 && nodes.check-job-status.output.ready"A template resolution failure (unresolvable reference, parse error) is treated as "do not break this iteration" - the loop continues rather than crashing. This is intentional: a body that hasn't set an output field yet shouldn't halt the loop prematurely on early iterations.
Aggregate output shape
After the loop completes (break, max-iterations, or continue), the loop node's output is a LoopAggregateOutput object accessible to downstream nodes via nodes.<loopId>.output:
interface LoopAggregateOutput {
iterationsRun: number // total iterations that ran
iterations: unknown[] // per-iteration body outputs (0-based)
last: unknown // most recent iteration's body output
terminationReason:
| 'break' // breakCondition matched
| 'max-iterations' // hit cap without breaking
| 'body-failure' // a body iteration failed
brokeAtIteration?: number // iteration that triggered the break (if reason='break')
}Downstream nodes reference this as:
# In a downstream action node's config template
body: "{{ nodes.poll-until-ready.output.last.report }}"
totalRuns: "{{ nodes.poll-until-ready.output.iterationsRun }}"The wrapped group is also addressable directly via nodes.<groupId>.output.* against the most recent iteration.
Iteration journal
Each iteration's result is persisted to step_executions using the synthetic step ID convention <loopNodeId>#iter-<N>. This means:
- The live session inspector displays each iteration as a first-class step in the execution timeline.
- The memoization layer treats completed iterations identically to other steps.
- A crash mid-loop can resume from iteration
priorEntries.length + 1rather than restarting from iteration 1.
// Synthetic step ID - keyed in step_executions
loopIterationStepId('poll-until-ready', 3) === 'poll-until-ready#iter-3'The journal entry shape mirrors a normal step execution record:
interface LoopIterationJournalEntry {
iteration: number // 1-based
stepId: string // '<loopNodeId>#iter-<N>'
loopNodeId: string
groupId: string
status: 'completed' | 'failed' | 'skipped' | 'waiting'
output?: unknown
error?: { message: string; code?: string }
startedAt: Date
completedAt: Date
durationMs: number
brokeAfter?: boolean // true when breakCondition matched after this iteration
}Crash recovery and resume
The loop executor's persistence contract guarantees that every iteration that completes - including ones followed by a crash during delayBetween - produces a durable journal record before the loop advances. On resume:
loadCompletedIterationsloads allcompletedjournal entries for the loop node within the current instance.- Prior iteration outputs are replayed into the
iterations[]aggregate without re-running the body. - If a prior entry has
brokeAfter: true, the loop returns immediately withstatus: 'completed'. - Otherwise, execution starts at
priorEntries.length + 1.
Body failures and gate suspension
If a body iteration returns status: 'failed', the loop halts immediately and returns status: 'failed' with a LOOP_BODY_FAILURE error code. The first failing body step's error bubbles up as the loop's own error message.
If a body iteration returns status: 'waiting' (the body encountered a gate), the loop executor bubbles up status: 'waiting'. The surrounding runtime handles gate suspension at the loop boundary - when the gate clears, the resume path re-enters the loop body for that iteration.
onMaxIterations policies in detail
Related pages
- DAG Executor - tier computation, memoization, and condition branching
- Workflow Gates - suspension and resume semantics (used by loops that contain gates)
- Workflow Groups - the group sub-graph that a loop node wraps
- Workflow Expressions - template syntax for
breakCondition