Branching execution
chidori.branch(variants) lets an agent fork itself mid-run into N
branches. Each branch explores a strategy from the same anchored state, runs
its own editable source, can pause, and returns an outcome so the agent
(or a human) can compare and pick one. Because the shared prefix is
identical across branches, a branch is a controlled experiment: the only
variable is the branch's code/input — and each branch streams as its own
subtree in an OTLP trace viewer for side-by-side comparison.
This exists because iterating on agents is uniquely painful: a run is a long chain of steps that are expensive (LLM/tool calls cost money and seconds), stochastic (same prompt → different output), and stateful (step N depends on all prior steps). The default loop — change code, re-run the whole thing — re-pays for the entire prefix, and because the model is stochastic the prefix comes out different, so you cannot tell whether your change helped or the randomness moved. Branching turns durable execution into the workflow agents actually want: anchor at a decision point, reuse the prefix for free (its result is the shared starting state), vary one thing per branch, run each branch and compare.
A branch is a separate continuation source run once — not a re-run of the
parent. Re-running the parent's source would re-reach chidori.branch and
recurse, so the prefix is handed over as state (the parent's captured VFS
plus an explicit input), not replayed. Branches act on the result of the
prefix; they don't re-derive it. Each branch is a new durable sub-run: the
orchestrator runs each variant's module on a fresh RuntimeContext seeded from
the parent's anchor, collects the outcomes, and returns them. The whole fan-out
is one recorded host call on the parent, so the parent's own replay returns the
outcomes from cache.
The orchestration lives in crates/chidori/src/runtime/host_branch.rs; the SDK
types live in sdk/typescript/src/agent.ts. See also
docs/architecture.md and
docs/captured-effects-vfs-crypto-timers.md.
The agent-facing API
type BranchVariant = {
/** Branch label (shown in outcomes + trace). Defaults to `branch-<k>`. */
label?: string;
/** Branch source module path, resolved like `callAgent` paths. Required. */
source: string;
/** State handed to the branch as its run input. Defaults to `{}`. */
input?: AgentJson;
};
type BranchOutcome = {
label: string;
branchId: string; // <parent run id>-op<branch seq>-branch-<k>
status: "completed" | "paused" | "failed";
output?: AgentJson; // when completed
pendingPrompt?: string; // when paused (e.g. a chidori.input prompt)
error?: string; // when failed
};
// On the chidori object:
branch(variants: BranchVariant[], options?: {
concurrency?: number; // max branches running live at once (cost cap)
}): Promise<BranchOutcome[]>;sourceis required: a branch runs its own continuation module, never a copy of the parent (which would re-reachchidori.branchand recurse). Paths resolve likecallAgentpaths — relative to the working directory.- At most 16 variants per call: every branch makes live host calls past the fork (real LLM/tool spend), so an unbounded fan-out is a cost hazard before it is a correctness one.
- Every variant is validated (and its source read) before any branch runs,
so a missing
sourceor a typo'd path fails the whole call without spending anything — and without recording anything. - Returns all outcomes (compare, don't merge). The agent runs its own
selection:
const best = outcomes.reduce(pick); - A
pausedoutcome carries abranchIdthe host can resume out-of-band (see below), keeping the JS surface a single awaited Promise.
How it works
The branch call executes inside the durable boundary as a single recorded
call whose result is the outcomes array — on parent replay it returns cached
and the branches never re-run. For each variant, the orchestrator:
- Reserves a disjoint
CallLogSequenceRangeviaParallelBranchManifest(width 10,000 per branch). The slot is derived from thebranchcall's own seq —slot = seq / (width × count) + 1,base = slot × width × count— so successive branch ops in one run get reserved blocks that grow linearly and stay disjoint from each other and from every earlier record. - Builds a fresh
RuntimeContextseeded with the parent's VFS snapshot, the reserved range as its sequence base, a call stack seeded with the parent'sbranchseq (so branch records nest), and the parent's OTEL run span (so branch spans stream under the parent'sbranchspan). - Runs the branch's source module with the variant's
input— live, through the same host-effect path as any run, under the samePolicyConfig. - Settles the outcome —
completedwith the module's return value,pausedwith the pending prompt when the branch suspended on a host op, orfailedwith the error — validates that every record the branch produced sits inside its reserved range, and folds the branch's records into the parent log. The fold advances the parent's counter past the reserved ranges the same wayabsorb_replayed_subtreedoes on replay, so live and replayed sequence numbering stay aligned.
Variants run in waves of options.concurrency worker threads (default 1 —
sequential; clamped to the variant count). Each branch gets its own JS VM and
context inside its thread; settling, validation, merging, and persistence
happen back on the parent thread, in variant order, after the wave joins — so
the durable log and the outcomes array are deterministic regardless of
completion order. Branches are in-process workers under the parent run's slot;
the server's run semaphore is not involved.
Nested chidori.branch inside a branch is rejected: a nested fork would
allocate sequence ranges outside the parent branch's reserved range. The
rejection surfaces as a failed outcome for that branch.
Tracing is free: branch records carry parent_seq = the branch call's seq,
so with OTEL_EXPORTER_OTLP_ENDPOINT set the operator sees the fork live as a
branch span with one child subtree per strategy, side by side.
The branch store
When the parent run persists (.chidori/runs/<run id>/), every branch sub-run
is persisted under it:
<run dir>/branches/op-<branch seq, zero-padded to 20 digits>/
anchor.json fork-time anchor: the parent VFS snapshot
branch-<k>/
source.ts the branch's own EDITABLE source copy
checkpoint.json the branch's call log (same shape as a run's)
branch.json metadata: label, id, status, pending input,
reserved sequence range, input, output/errorThe anchor and the per-branch source copies are written before the fan-out
runs, so even a crash mid-fan-out leaves re-runnable branch stores behind. The
branchId (<run>-op<seq>-branch-<k>) maps 1:1 to the store path.
Resume and edit-and-rerun
The store makes a branch independently operable out-of-band, after the parent has moved on:
# List a run's persisted branches and their states:
chidori branches <run-id>
# A branch paused on chidori.input()? Answer it:
chidori branch-resume <run-id> <branch-id> --value "blue"
# Edit a strategy and re-run ONLY that branch from the same anchored state:
$EDITOR .chidori/runs/<run-id>/branches/op-*/branch-001/source.ts
chidori branch-rerun <run-id> <branch-id>Both commands default their model to the one recorded in the parent run's
manifest (override with --model or CHIDORI_MODEL), and accept
--trusted/--untrusted for the branch's live gated effects — the same
posture flags as chidori run.
- Resume replays the branch's checkpoint with a synthetic
inputrecord at the pending seq (the same mechanism the server's/resumeuses), then runs the branch'ssource.tslive to its next outcome. Resume answersinput()pauses; approval/signal pauses are reported but not resumable out-of-band. - Edit-and-rerun discards the previous checkpoint and re-runs the branch
fresh from the parent anchor with whatever
source.tsnow contains. The anchored state (fork-time VFS + the variant'sinput) is identical to the original fork, so only the branch's code is the variable. Branch runs never go through the run manifest's source-hash gate — the anchor is the captured state, not a source identity check.
A resumed or re-run branch updates only its own store; the parent's recorded
branch outcome is immutable history (compare, don't merge).
Correctness and determinism
- Parent determinism: the
branchop is recorded with the outcomes as its result; parent replay short-circuits the fan-out like any cached host call. - No record collisions: reserved per-branch sequence ranges guarantee disjointness, and every branch record is validated against its range before it joins the parent's durable log — a violation (e.g. a branch that outgrew its range width) fails the call rather than corrupting the log.
- Nesting integrity: branch records carry
parent_seq= thebranchcall's seq, matching the established nesting invariant. - Branch determinism: a persisted branch is replayable from its own stored checkpoint; resume is checkpoint replay plus live continuation, confined to the same reserved range.
- State-handover fidelity: branches inherit the VFS plus the explicit
input, not the parent's in-flight JS locals. The agent passes what a branch needs.
Cost, safety, concurrency
N branches make N sets of live host calls past the fork — real LLM/tool
spend. The controls: options.concurrency caps simultaneous live branches
(default 1), the fan-out is hard-capped at 16 variants, and each branch
context enforces the same policy layer as the parent. Branches use separate
RuntimeContexts and separate VMs on separate threads; no shared mutable
state.
Example
examples/branching/ is a runnable end-to-end
example: shared research once, a two-strategy fork, compare-and-pick, replay
via chidori resume, and the resume/edit-and-rerun workflows against the
branch store.