orchestratr
patterns

fan-out & merge

review every changed file in parallel with one cheap agent each, then a synthesizer merges the per-file findings into one report.

run one agent per item across a computed list, all at once, then hand every result to a single synthesizer. this is the parallel shape, the most common orchestration workflow.

the scenario

a pull request touches several files. you want each file reviewed independently, in parallel, then the findings merged into one prioritized report with overlaps removed. each reviewer is a cheap one-shot agent that writes its findings to a file and exits; the synthesizer reads them all and produces the summary.

the annotated recipe

import { orcr } from "@orchestratr/sdk";
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";

const files = execSync("git diff --name-only main", { encoding: "utf8" }).trim()
  .split("\n").filter(Boolean);
if (files.length === 0) { console.log("No changed files."); process.exit(0); }

await orcr.scope("review", async () => {
  const reviewers = await Promise.all(files.map((f, i) =>
    orcr.agent.run({
      agent: "claude", path: `fanout/file_${i}`, gc: "immediate",
      prompt: `Review the diff of ${f} against main for bugs and risky changes.
               Expand the environment variable ORCR_AGENT_DATA_DIR and write your
               findings to $ORCR_AGENT_DATA_DIR/response.md, then say DONE.`,
    })));

  // settles when every reviewer finishes: gc:immediate → ended (completed)
  await orcr.agent.wait("fanout/*");    // relative to the review scope

  const findings = await Promise.all(reviewers.map(async r =>
    `## ${r.path}\n` + await readFile(`${r.dataDir}/response.md`, "utf8")));

  const summary = await orcr.ask({
    agent: "codex", path: "merge/synthesizer",
    prompt: `Merge these per-file review findings into one prioritized report,
             deduplicating overlaps:\n\n${findings.join("\n\n")}`,
  });
  console.log(summary);
});

what each piece is doing:

  • the fan-out is Promise.all over files.map(...). each orcr.agent.run returns a handle immediately, so all reviewers are spawned before any awaits complete, and they run concurrently up to the concurrency caps.
  • the path on each run interpolates the index, so reviewer i lands at fanout/file_0, fanout/file_1, and so on under the review scope: review/fanout/file_0, review/fanout/file_1. distinct paths are mandatory; two agents cannot share one live path.
  • gc: "immediate" closes each reviewer's pane the moment its final response is captured. no parked panes to clean up.
  • the file convention. each reviewer writes structured output to $ORCR_AGENT_DATA_DIR/response.md. the prompt tells the agent to expand the environment variable; the caller reads the same location from the handle's dataDir. the completion sentinel (then say DONE) tells the agent when it is finished. see data & file conventions.
  • orcr.agent.wait("fanout/*") blocks until every direct child of review/fanout settles. for gc: "immediate" agents that means ended (completed). the pattern is relative to the scope; * matches one whole segment, so it catches file_0 through file_N but nothing nested deeper.
  • the synthesizer reads each response.md off disk (not from a transcript) and merges them with a single orcr.ask on a different provider.

the glob is whole-segment

fanout/* matches direct children only. to wait on everything under the scope at any depth you would use fanout/**. quote patterns when you type them in a shell so it does not expand them against real files. see path grammar & glob patterns.

primitives it exercises

failure and cleanup

gc: "immediate" is the whole cleanup story here: each reviewer self-cleans as soon as it finishes, so there is nothing to reap even if the batch is large. the synthesizer is an ask, which is also gc: "immediate" under the hood.

two things to know:

  • if a reviewer's response.md is missing when you read it, readFile throws. because the flow is not wrapped in killOnThrow here, add it if you spawn a large batch and want a thrown read to tear the batch down: orcr.scope("review", async () => { ... }, { killOnThrow: true }).
  • wait("fanout/*") snapshots its targets at invocation. reviewers spawned after the wait starts are not included, which is why the fan-out completes before the wait is called.

run it

orcr scaffold review-workflow && cd review-workflow
# paste the recipe into workflow.ts, replacing the generated example
npx tsx workflow.ts

watch it live by splitting a pane running orcr top "review/**" beside yourself; the tree lights up as each reviewer starts and settles. see monitor with orcr top.

this is also the first-workflow tutorial, built step by step. next: generate-and-filter fans one prompt across providers instead of many files across one.

On this page