Rensei docs
Runtime

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:

  1. Loads any previously-persisted iteration journal entries (for crash recovery / resume).
  2. Runs the group body for each iteration via a LoopExecutorAdapter.
  3. After each iteration, evaluates the optional breakCondition template expression.
  4. Persists a LoopIterationJournalEntry to step_executions keyed as <loopNodeId>#iter-<N>.
  5. Applies delayBetween if configured.
  6. When maxIterations is reached without a break, applies the onMaxIterations policy.

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 | escalate

Prop

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:

ExpressionResolves to
nodes.<groupId>.output.<field>Most recent iteration's body output field
nodes.<loopId>.output.iterationCurrent 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 + 1 rather 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:

  1. loadCompletedIterations loads all completed journal entries for the loop node within the current instance.
  2. Prior iteration outputs are replayed into the iterations[] aggregate without re-running the body.
  3. If a prior entry has brokeAfter: true, the loop returns immediately with status: 'completed'.
  4. 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

On this page