Mentu

Patterns

Patterns

The runner derives execution layers from depends_on. If no step in the recipe declares a dependency, steps run in file order, one after another. As soon as one step declares a dependency, steps are grouped into layers by dependency depth. Every step in a layer runs concurrently, and the next layer starts only after the whole previous layer has finished. Everything below is a shape you get from that one rule, and every transcript on this page came from running the recipe.

Sequence

The default. Each step depends on the one before it, and the last step proves the artifact the first one produced.

{
  "name": "hello-notes",
  "type": "sequence",
  "steps": [
    {
      "label": "write",
      "backend": "shell",
      "prompt": "mkdir -p notes && printf 'runner verified\n' > notes/hello.md && echo WRITE_COMPLETE",
      "completion_keyword": "WRITE_COMPLETE",
      "expected_changes": ["notes/hello.md"],
      "timeout": 60
    },
    {
      "label": "prove",
      "backend": "shell",
      "depends_on": ["write"],
      "prompt": "echo PROVE_COMPLETE",
      "completion_keyword": "PROVE_COMPLETE",
      "verify": {
        "grep_present": [
          { "file": "notes/hello.md", "pattern": "runner verified" }
        ]
      },
      "timeout": 60
    }
  ]
}
░░░░░░░░░░░░░░░░░░░░   0% 0/2 steps
⏵ 1/2    write                   shell            0s  running
WRITE_COMPLETE
✓ 1/2    write                   shell            0s  ok
██████████░░░░░░░░░░  50% 1/2 steps
⏵ 2/2    prove                   shell            0s  running
PROVE_COMPLETE
✓ 2/2    prove                   shell            0s  ok
 
✓ hello-notes · 2 step(s) · ok

Fan out and join

Two steps with no dependency between them land in the same layer and run together. A third names both and cannot start until the layer closes.

{
  "name": "fanout",
  "type": "sequence",
  "steps": [
    {
      "label": "probe-a",
      "backend": "shell",
      "prompt": "echo A_COMPLETE",
      "completion_keyword": "A_COMPLETE",
      "verify": { "git_clean_outside": ["notes/"] },
      "timeout": 60
    },
    {
      "label": "probe-b",
      "backend": "shell",
      "prompt": "echo B_COMPLETE",
      "completion_keyword": "B_COMPLETE",
      "verify": { "git_clean_outside": ["notes/"] },
      "timeout": 60
    },
    {
      "label": "join",
      "backend": "shell",
      "depends_on": ["probe-a", "probe-b"],
      "prompt": "mkdir -p notes && printf 'both probes closed\n' > notes/summary.md && echo JOIN_COMPLETE",
      "completion_keyword": "JOIN_COMPLETE",
      "expected_changes": ["notes/summary.md"],
      "timeout": 60
    }
  ]
}
░░░░░░░░░░░░░░░░░░░░   0% 0/3 steps
⏵ 1/3    probe-a                 shell            0s  running
⏵ 2/3    probe-b                 shell            0s  running
A_COMPLETE
B_COMPLETE
✓ 1/3    probe-a                 shell            0s  ok
✓ 2/3    probe-b                 shell            0s  ok
█████████████░░░░░░░  67% 2/3 steps
⏵ 3/3    join                    shell            0s  running
JOIN_COMPLETE
✓ 3/3    join                    shell            0s  ok
 
✓ fanout · 3 step(s) · ok

The two probes started before either printed anything, and their output can arrive in either order from one run to the next. Only the joining step declares expected_changes, which is deliberate. See the caveat below.

Use --max-parallel N, or max_parallel in the recipe, to cap how wide a layer runs.

Concurrent steps must not declare expected_changes

Steps in one layer share a git working tree, and each one commits when it closes. Two simultaneous committers race on the git index, and the loser's commit fails, which marks that step failed even though its work succeeded. A step can also see a neighbour's uncommitted write as its own drift and quarantine it. Three steps in one layer, each declaring its own file, produced this:

✗ 1/4    wa                      shell            0s  failed · exit 0
✗ 2/4    wb                      shell            0s  failed · exit 0
✓ 3/4    wc                      shell            0s  ok · Quarantined changes outside expected_changes: r/b.md

Give concurrent steps verify.git_clean_outside instead. It bounds the writes without committing anything, and a later serial step does the commit. That is the shape the fan-out example above uses.

The runner does not give steps their own worktrees. Every step in a run works in the same checkout.

Compound

A compound recipe runs whole recipes as nodes, with depends_on between them. Each child is an ordinary recipe file, runnable on its own.

{
  "name": "pipeline-demo",
  "type": "compound",
  "steps": [],
  "recipes": [
    { "label": "gather", "recipe": "collect" },
    { "label": "write-up", "recipe": "summarize", "depends_on": ["gather"] }
  ]
}
░░░░░░░░░░░░░░░░░░░░   0% 0/2 recipes
⏵ gather                  recipe           0s  running
░░░░░░░░░░░░░░░░░░░░   0% 0/1 steps
⏵ 1/1    collect                 shell            0s  running
COLLECT_COMPLETE
✓ 1/1    collect                 shell            0s  ok
✓ gather                  recipe           0s  ok
██████████░░░░░░░░░░  50% 1/2 recipes
⏵ write-up                recipe           0s  running
░░░░░░░░░░░░░░░░░░░░   0% 0/1 steps
⏵ 1/1    summarize               shell            0s  running
SUMMARIZE_COMPLETE
✓ 1/1    summarize               shell            0s  ok
✓ write-up                recipe           0s  ok
 
✓ pipeline-demo · 2 step(s) · ok

The empty steps key is required. A compound, pipeline, or parallel recipe without it fails to load with a decoding error that does not name the field.

pipeline and parallel take the same recipes array and differ in how the nodes are scheduled: compound honors depends_on as a graph, pipeline runs the nodes one after another in file order, and parallel runs them all together, capped by max_parallel. pipeline and parallel still refuse a depends_on that names an unknown node, but they do not schedule by it.

Picking up a partial run

Long runs fail in the middle. Both recovery commands read the run record rather than starting over:

mentu-recipes resume <run-id>
mentu-recipes retry-step <run-id> <step-label>

resume skips steps already marked success or warn_bookkeeping and reruns the rest, appending to the existing events.jsonl. retry-step marks one step pending and resumes from there, leaving completed dependencies alone. Here a second step failed its verify.commands check, the missing file was put in place, and the run was resumed:

⊘ 1/2    first                                        already complete
██████████░░░░░░░░░░  50% 1/2 steps
⏵ 2/2    second                  shell            0s  running
SECOND_DONE
✓ 2/2    second                  shell            0s  ok
 
✓ two · ok

Worked examples

Three runnable recipes are documented with full transcripts under examples/ in the repository. The recipe files themselves live in that repository's .mentu/recipes/, and examples/run-demo.sh <name> copies them into a throwaway git workspace under $TMPDIR and runs one there, so the commits the runner makes stay out of your clone. The script runs the binary from a local swift build by default, or the one named by MENTU_RECIPES_BIN. All three use the shell backend and need no credentials.

© 2026 Mentu.