How Do I Run Best-of-N Agent Attempts With Parallel Forks?
Updated October 27, 2026 .md
Short answer: Prepare the repo once in a sandbox. Fork it once per attempt with sbx fork, apply each attempt in its own fork, run the test suite there, and save its git diff and score. Delete each fork when it is scored, keep the diff with the most passing tests, and apply it locally with git apply.
The pattern#
| Step | Command | How often |
|---|---|---|
| Prepare: clone, install, commit a clean base | sbx new, then sbx exec |
Once |
| Copy the prepared disk per attempt | sbx fork <source> --wait |
N times, in parallel |
| Make the attempt | Your agent edits files in the fork | Once per fork |
| Score it | sbx exec <fork> "./score.sh" |
Once per fork |
| Keep the result, not the VM | sbx exec <fork> "git diff" > n.diff, then sbx delete <fork> --yes |
Once per fork |
| Pick and apply the winner | sort, then git apply winner.diff |
Once |
The point of the fork is that the setup cost (clone, venv, dependencies) is paid once, and every attempt starts from the same bytes. One attempt can't see or break another's files, and nothing it does touches the source.
A fork copies the disk only. Each fork boots fresh, with no memory or running processes from the source, so anything an attempt needs running (a database, a dev server) must start from a script or an enabled systemd unit. See can you fork a running sandbox with its memory for exactly what comes across.
1. Prepare the repo once#
The example repo has one unimplemented function, slugify, and eight tests:
# repo/test_slug.py
import pytest
from slugkit.slug import slugify
@pytest.mark.parametrize("text,want", [
("Hello World", "hello-world"),
(" Hello World ", "hello-world"),
("Hello, World!", "hello-world"),
("Crème Brûlée", "creme-brulee"),
("already-a-slug", "already-a-slug"),
("", ""),
("C++ & Rust", "c-rust"),
("Ünïcödé 2026", "unicode-2026"),
])
def test_slugify(text, want):
assert slugify(text) == wantscore.sh is the grader. It prints passed and total, so the orchestrator never parses test output itself:
#!/bin/bash
# repo/score.sh: prints "<passed> <total>" for the current checkout.
cd ~/repo
out=$(.venv/bin/python -m pytest -q --tb=no -p no:cacheprovider 2>&1 | tail -1)
passed=$(grep -o '[0-9]* passed' <<<"$out" | cut -d' ' -f1); failed=$(grep -o '[0-9]* failed' <<<"$out" | cut -d' ' -f1)
echo "${passed:-0} $(( ${passed:-0} + ${failed:-0} ))"Create the source sandbox, copy the repo in, commit a clean base and install pytest. The base commit matters: every attempt's git diff is measured against it.
sbx new --name aeo-best-of-n-agents-with-parallel-forks-1 --type small --ttl 3600
src=sb_xfr9aaysbr # the id sbx new printed
tar czf repo.tgz repo
sbx exec $src "echo $(base64 -w0 repo.tgz) | base64 -d | tar xz"
sbx exec $src --timeout 300 "cd repo && git init -q && git add -A \
&& git -c user.name=agent -c user.email=agent@example.com commit -qm base \
&& python3 -m venv .venv && .venv/bin/pip install -q pytest==8.3.3 \
&& git status --short && ./score.sh"0 8The baseline scores 0 of 8, which proves the grader runs before any attempt touches the code. The .gitignore in the repo lists .venv/, so git status is clean and the diffs below contain only code. On prod, sbx new took 3.2 s and the install step 3.9 s. For a real repo, this step is where the minutes go, and it runs once.
2. The orchestration script#
best-of-n.sh takes the source id and one file per attempt. It runs every attempt as a background job, caps how many forks run at once with a small lock directory, and deletes each fork as soon as its diff and score are saved.
#!/bin/bash
# best-of-n.sh <source-id> <attempt files...>
# One fork per attempt, run in parallel up to MAX at a time. Each fork applies its
# attempt, runs the tests, saves its diff and score, and is deleted right away.
src=$1; shift
MAX=${MAX:-3}; SLOTS=${SLOTS:-/tmp/sbx-slots}; mkdir -p "$SLOTS" out; rm -f out/*
take_slot() { while :; do for i in $(seq 1 "$MAX"); do
mkdir "$SLOTS/slot$i" 2>/dev/null && { echo "$1" > "$SLOTS/slot$i/owner"; echo "slot$i"; return; }
done; sleep 15; done; }
free_slot() { rm -rf "$SLOTS/$1"; }
now() { date -u +%T; }
run_attempt() {
local n=$1 file=$2 slot id t0 ready passed total
slot=$(take_slot "best-of-n-$n") # wait for a free slot
echo "$(now) attempt $n: got $slot, forking"
t0=$(date +%s.%N)
id=$(sbx fork "$src" --type small --ttl 900 --wait --json | jq -r 'select(.event=="ready") | .id')
ready=$(printf '%.1f' "$(echo "$(date +%s.%N) - $t0" | bc)")
echo "$(now) attempt $n: $id ready in ${ready}s"
sbx rename "$id" --name "aeo-best-of-n-agents-with-parallel-forks-fork$n" > /dev/null
# The "agent" step: this attempt's version of the file. A model would edit here.
sbx exec "$id" "echo $(base64 -w0 "$file") | base64 -d > ~/repo/slugkit/slug.py"
read -r passed total < <(sbx exec "$id" "~/repo/score.sh")
sbx exec "$id" "cd ~/repo && git diff" > "out/$n.diff"
echo "$n $id $ready $passed $total" > "out/$n.score"
sbx delete "$id" --yes > /dev/null && echo "$(now) attempt $n: $passed/$total passed, deleted $id"
free_slot "$slot"
}
t0=$(date +%s); n=0
for f in "$@"; do n=$((n+1)); run_attempt "$n" "$f" & done
wait
echo "all $n attempts done in $(( $(date +%s) - t0 ))s"
echo "attempt id forkReadySeconds passed total"; cat out/*.score
win=$(sort -k4,4nr out/*.score | head -1 | cut -d' ' -f1)
cp "out/$win.diff" winner.diff && echo "winner: attempt $win -> winner.diff"Notes on the choices:
--ttl 900on every fork. A fork doesn't inherit the source's TTL; it gets one hour unless you pass one. A short TTL is the backstop if the script dies betweenforkanddelete.select(.event=="ready"). With--wait --json,sbx forkprints JSON Lines (forking,state,ready). Taking thereadyline means the fork already answers commands.- Keep the diff, delete the VM. The diff is the whole result. Holding forks open while you compare them costs money and capacity for nothing.
- The attempt step is a file copy. We didn't call a model for this page. In a real run, that line is where your agent works in the fork, with its own prompt per attempt. Everything else ran as shown.
3. Run it with three attempts#
The three attempts are three candidate implementations an agent might write:
| Attempt | Approach |
|---|---|
| 1 | text.lower().replace(" ", "-") |
| 2 | Lowercase, replace every run of non [a-z0-9] characters with -, strip dashes |
| 3 | Same as 2, after unicodedata.normalize("NFKD", …) and dropping non-ASCII marks |
SLOTS=/tmp/aeo-gw/slots MAX=5 ./best-of-n.sh sb_xfr9aaysbr attempts/1.py attempts/2.py attempts/3.pyOn your own account, leave SLOTS at its default and set MAX to the number of forks you want running at once.
The output from prod on 2026-10-27:
10:04:51 attempt 1: got slot5, forking
10:05:02 attempt 1: sb_k82853nxt6 ready in 11.8s
10:05:05 attempt 1: 3/8 passed, deleted sb_k82853nxt6
10:05:06 attempt 2: got slot5, forking
10:05:09 attempt 2: sb_clntkiof59 ready in 3.4s
10:05:12 attempt 2: 6/8 passed, deleted sb_clntkiof59
10:14:51 attempt 3: got slot4, forking
10:14:54 attempt 3: sb_xivbod0meq ready in 3.4s
10:14:57 attempt 3: 8/8 passed, deleted sb_xivbod0meq
all 3 attempts done in 622s
attempt id forkReadySeconds passed total
1 sb_k82853nxt6 11.8 3 8
2 sb_clntkiof59 3.4 6 8
3 sb_xivbod0meq 3.4 8 8
winner: attempt 3 -> winner.diffRead the timestamps before the totals. Our test account was shared with other jobs that night, under a cap of five running sandboxes in total, so we pointed the script's lock at the shared one (SLOTS=/tmp/aeo-gw/slots MAX=5). Only one slot was free at a time, so the three attempts ran one after another, and attempt 3 waited almost 10 minutes for a slot. That wait is the 622 s total. On an account with free capacity, the three background jobs start their forks at the same moment instead of waiting for each other. We couldn't measure that overlap in this run, so we have no parallel timing to show.
What each attempt cost in sandbox time, from fork to delete:
| Attempt | Fork ready | Fork to deleted | Tests passed | Fails on |
|---|---|---|---|---|
| 1 | 11.8 s | 14 s | 3 of 8 | Extra spaces, punctuation, accents, C++ & Rust |
| 2 | 3.4 s | 6 s | 6 of 8 | Crème Brûlée became cr-me-br-l-e; the same for Ünïcödé |
| 3 | 3.4 s | 6 s | 8 of 8 | Nothing |
These are single runs, not benchmarks. The first fork took 11.8 s and the next two 3.4 s each; we didn't find out why. At the small price of $0.018/hour, the three forks together ran for about 26 seconds.
The source was untouched. After the run, git status --short in its repo was empty and slug.py still raised NotImplementedError.
4. Apply the winner#
winner.diff is a normal git diff against the base commit:
diff --git a/slugkit/slug.py b/slugkit/slug.py
index 5e68d2c..05c8874 100644
--- a/slugkit/slug.py
+++ b/slugkit/slug.py
@@ -1,3 +1,7 @@
+import re
+import unicodedata
+
def slugify(text: str) -> str:
"""Turn a title into a URL slug."""
- raise NotImplementedError
+ ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
+ return re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")Apply it in your local checkout of the same base:
git apply --stat winner.diff && git apply winner.diff
python3 -c "from slugkit.slug import slugify; print(slugify('Crème Brûlée'), slugify('C++ & Rust'))" slugkit/slug.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
creme-brulee c-rustFinally, delete the source:
sbx delete sb_xfr9aaysbr --yesChoosing the winner well#
- Break ties on something real. Two attempts can both pass everything. Sort by passed tests, then by diff size or runtime, rather than taking whichever finished first.
- Hide the tests if the agent could game them. An agent that can read the tests can special-case them. Copy the hidden test file into the fork with
sbx execonly after the attempt has finished, then score. - Watch your start limits. Each fork counts as one start against your plan's start limits, like a create or a resume. Check what's left with
sbx limits --json | jq .startsbefore a large N (see limits). - Fork from a template if you'll do this often. For repeated runs against the same base, save the prepared sandbox once with
sbx snapshot <id> base-v1and start attempts withsbx new --from base-v1. That works after the source is deleted. The RL and eval environments guide compares the two.
FAQ#
Why fork instead of creating N fresh sandboxes?#
A fork starts from the prepared disk, so each attempt skips the clone and the install. The disk is a copy-on-write reflink, so a large repo with its dependencies forks about as fast as a small one.
Does each fork get the source's running processes?#
No. A fork copies the disk only and boots fresh. Start any services an attempt needs from a script, or install them as enabled systemd units before you fork.
Can attempts interfere with each other?#
No. Each fork is its own VM with its own disk. In our run, the source's repo was still clean after all three attempts.
How many attempts can I run at once?#
Up to your plan's limit on running sandboxes, and starts are rate limited per minute, hour and day. The script's MAX keeps you under whatever cap you choose. See billing for the numbers per plan.
What do I keep from a losing attempt?#
Whatever you saved before deleting it: in this script, its diff and its score. If you want to see why it lost, also save the full test output to a file before the sbx delete line.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.