orchestratr
patterns

tournament

when N candidates are too many for one judge, run pairwise brackets with byes; winners advance until one remains.

when a single judge would have to compare too many candidates at once, run a bracket instead. each match is a pairwise comparison; winners advance; the pool halves each round until one remains. this is the pipeline shape scaled to large N.

the scenario

generate-and-filter puts every draft in one judge prompt. that works for a handful. past that, a single judgment over the whole set gets unreliable and the prompt gets long. a tournament keeps every comparison to two candidates: judge A against B, advance the winner, repeat. the number of matches is linear in N; the number of rounds is logarithmic.

the annotated recipe

import { orcr } from "@orchestratr/sdk";

async function tournament(candidates: string[]): Promise<string> {
  return orcr.scope("tournament", async () => {
    let pool = candidates;
    for (let round = 1; pool.length > 1; round++) {
      const next: string[] = [];
      for (let i = 0; i < pool.length; i += 2) {
        if (i + 1 >= pool.length) { next.push(pool[i]); continue; }   // bye
        const verdict = await orcr.ask({
          agent: "claude", path: `round_${round}/match_${i / 2}`,
          prompt: `Which is better, A or B? Reply exactly A or B.\n` +
                  `--- A ---\n${pool[i]}\n--- B ---\n${pool[i + 1]}`,
        });
        next.push(verdict.trim().startsWith("B") ? pool[i + 1] : pool[i]);
      }
      pool = next;
    }
    return pool[0];
  });
}

what each piece is doing:

  • the outer for runs one round per halving; it continues while pool.length > 1.
  • the inner for steps through the pool in pairs (i += 2). each pair is one match.
  • byes are the odd-one-out rule. when i + 1 >= pool.length, there is no partner, so pool[i] advances unopposed with next.push(pool[i]); continue;. an odd pool never drops a candidate.
  • path composition encodes the bracket. round_${round}/match_${i / 2} gives every match a unique, readable path: tournament/round_1/match_0, tournament/round_1/match_1, tournament/round_2/match_0. the round and match indices are path segments, so nothing collides across rounds.
  • the judge replies A or B; verdict.trim().startsWith("B") ? pool[i + 1] : pool[i] reads the winner. anything that is not B (including a malformed answer) advances A.
  • pool = next reduces the pool for the next round. when one candidate remains, pool[0] is the winner.

matches here run sequentially

this recipe awaits each match before the next. that keeps the code simple and the pool reduction obvious. to speed up a wide round, wrap the inner loop's matches in Promise.all so a whole round runs in parallel; the path scheme already keeps their paths distinct.

primitives it exercises

failure and cleanup

  • the tie-break is deterministic. startsWith("B") ? B : A means A wins unless the judge explicitly says B. a blank, malformed, or injected answer never stalls the bracket; it just advances A. every match resolves to exactly one winner.
  • singleton scope. the tournament root is unique among live agents, so a second concurrent bracket fails fast with state_conflict (path_in_use). when you need concurrent brackets, parameterize the scope with a job id, for example orcr.scope("tournament_" + jobId, ...). see identity and paths.
  • every match is gc: "immediate" (via ask), so panes close as each verdict is captured. nothing to reap.

run it

orcr scaffold tournament-workflow && cd tournament-workflow
# paste the recipe into workflow.ts, then call tournament([...]) from run()
npx tsx workflow.ts

the recipe exports tournament(candidates); the CI fixture calls it with four strings and prints the winner.

next: loop-until-done takes any of these flows durable, handing remaining work to a scheduled loop.

On this page