Markov has no built-in loop construct. Instead, workflows loop by recursively invoking themselves, with gate steps controlling when to stop. This follows the Markov chain philosophy: each iteration evaluates the current state and decides the next transition.
A loop requires three workflows:
main → iterate → do_work
↑ │
└───────────┘ (recurse if gate says continue)
iteratedo_work if continuing, increments a counter, then calls itself againentrypoint: main
vars:
iteration: 0
max_iterations: 10
rules:
- name: loop_continue
description: "Keep looping while under the limit"
salience: 10
when: "iteration < max_iterations"
action: continue
set_fact:
loop_done: false
- name: loop_exit
description: "Stop when iteration limit is reached"
salience: 100
when: "iteration >= max_iterations"
action: skip
set_fact:
loop_done: true
workflows:
- name: main
steps:
- name: start
type: shell_exec
params:
command: "echo 'starting loop (max={{ max_iterations }})'"
- name: run_loop
workflow: iterate
vars:
iteration: "{{ iteration }}"
- name: iterate
vars:
iteration: null
steps:
- name: loop_gate
type: gate
rules:
- loop_continue
- loop_exit
- name: run_work
workflow: do_work
when: "not loop_done"
vars:
iteration: "{{ iteration }}"
- name: increment
type: set_fact
when: "not loop_done"
vars:
iteration: "{{ iteration + 1 }}"
- name: next_iteration
workflow: iterate
when: "not loop_done"
vars:
iteration: "{{ iteration }}"
- name: do_work
vars:
iteration: null
steps:
- name: show_iteration
type: shell_exec
params:
command: "echo ' working on iteration {{ iteration }}'"
main calls iterate with iteration: 0iterate evaluates the gate — loop_continue fires (iteration 0 < 10), sets loop_done: falsedo_work runs for this iterationset_fact increments iteration to 1iterate calls itself with iteration: 1loop_exit fires (salience 100 > 10, so it wins), sets loop_done: truewhen: "not loop_done" skips the remaining steps, and recursion stopsThe exit rule needs a higher salience than the continue rule. When iteration >= max_iterations, both rules’ conditions could potentially interact — the higher salience ensures the exit action wins:
- name: loop_continue
salience: 10 # Lower priority
when: "iteration < max_iterations"
action: continue
- name: loop_exit
salience: 100 # Higher priority — wins when it fires
when: "iteration >= max_iterations"
action: skip
When a gate rule fires and has set_fact, those values are merged into the workflow context. Downstream when conditions can reference them:
# Gate sets: loop_done: true
# Downstream step checks it:
- name: run_work
when: "not loop_done" # Skipped when loop_done is true
Since sub-workflows get a copy of the parent context, the incremented iteration value must be explicitly passed back into the recursive call:
- name: increment
type: set_fact
vars:
iteration: "{{ iteration + 1 }}"
- name: next_iteration
workflow: iterate
vars:
iteration: "{{ iteration }}" # Pass the new value
Use a gate-controlled loop to retry an operation until it succeeds:
vars:
attempt: 0
max_retries: 5
rules:
- name: retry_continue
salience: 10
when: "attempt < max_retries and not succeeded"
action: continue
set_fact:
should_retry: true
- name: retry_exhausted
salience: 100
when: "attempt >= max_retries"
action: skip
set_fact:
should_retry: false
- name: retry_success
salience: 200
when: "succeeded == true"
action: skip
set_fact:
should_retry: false
workflows:
- name: retry_loop
vars:
attempt: null
steps:
- name: retry_gate
type: gate
rules:
- retry_continue
- retry_exhausted
- retry_success
- name: try_operation
type: shell_exec
when: "should_retry"
params:
command: "curl -sf https://api.example.com/health"
register: health_check
- name: check_result
type: set_fact
when: "should_retry"
vars:
succeeded: "{{ health_check.exit_code == 0 }}"
attempt: "{{ attempt + 1 }}"
- name: wait
type: shell_exec
when: "should_retry and not succeeded"
params:
command: "sleep 10"
- name: next_attempt
workflow: retry_loop
when: "should_retry and not succeeded"
vars:
attempt: "{{ attempt }}"