Worktrees¶
Two subagents writing to one directory is why the shipped build flow runs its
subtasks with concurrency: 2 and copies: true: past one writer, “independent
subtasks” stops being a promise a planner can keep. A working copy each turns
that into a question about the tasks rather than about the filesystem.
src/git/worktree.ts is the git side of it. It does four things and no more.
import { createWorktree, listWorktrees, removeWorktree, worktreePatch } from "@ai-for-dev/combo";
const made = await createWorktree(repo, { path: "/tmp/one", branch: "combo/one", base: "main" });
const patch = await worktreePatch("/tmp/one", "main");
await removeWorktree(repo, "/tmp/one", { patched: patch.ok });
Every call gives back a GitResult<T>: a value, or git’s own words about why
not. That is the same contract src/git/git.ts uses, for the same reason a
workflow turns a model failure into ok: false. A copy that could not be made
is an outcome the caller decides about.
A copy nobody captured is not removed¶
removeWorktree refuses a copy that still holds changes when patched is
false, and names the path so whoever reads the run can go and look.
Losing what a child wrote to make room is the one failure here a rerun cannot
undo. Everything else in this library fails into a Result and another attempt;
this does not, so it is the one place that stops rather than carries on.
patched: true is the caller saying the work is somewhere else now. It is also
what lets git’s own refusal be passed, since git knows nothing about the patch
you are holding. A caller who wants a copy gone whatever it holds deletes the
directory itself: that is not an act this module performs on anyone’s behalf.
Copies made and removed at once¶
git keeps the list of a repository’s copies under .git/worktrees/ and takes
no lock on it. A worktree add writes the new copy’s files there one at a
time, and a command that walks the list at that moment (another add, a
remove, list, branch -d) can read a half-written copy and die on it. So
this module runs those commands one at a time per repository, keyed by its
common git directory, whether they are called from the repository or from one
of its copies. The queue lives in the process; a second process working on the
same repository does not wait on it.
What the patch contains¶
worktreePatch runs git add --intent-to-add before it diffs, because a file
the child created is the ordinary case for a coder and git diff alone does not
show one. That writes to the index of the copy, which is acceptable: the copy
exists to be read and then dropped, and a patch missing every new file would be
worse than a touched index.
The patch is capped, like every other text that ends up in a prompt, and says so where it was cut.
The three calls that only ever happen together¶
Making a copy, taking its patch and removing it are never done apart, and the
order they go in is the whole safety of the thing. scratchWorktree is that
order, written once: a copy made for one piece of work, and released when the
work is done.
import { scratchWorktree } from "@ai-for-dev/combo";
const made = await scratchWorktree(repo, "add a slugify helper");
if (!made.ok) return; // no copy, no work - the caller decides
const scratch = made.value;
scratch.path; // what a subagent gets as its cwd
scratch.branch; // named after the work, so `git branch` reads
const patch = await scratch.release(); // the work out, the copy gone
It lives under the system’s temporary directory, outside the repository: a copy made inside the tree its own patch is taken against would show up in that patch. The base is a commit rather than a branch name, so the patch is against what the work really started from even if the branch has moved since.
release() is idempotent and safe in a finally. A second call gives back the
patch the first one took rather than an empty one, so releasing explicitly and
again in a finally cannot lose it. It commits the work on the branch before
the copy goes, deletes a branch nobody wrote on, and on a patch it could not
take leaves everything where it is: the caller gets the error and the work
stays on disk.
A copy per branch, not per subagent¶
In a flow, copies: true on a map or a parallel gives each branch one copy,
and every node of the branch works in it. In build that is the coder and the
reviewer of one subtask: a reviewer with a copy of its own would be reading the
code the coder did not touch, which is a review of nothing. Flows
says when a block needs copies, what lands, and what a stopped run keeps.
The copy is released whatever ended the branch, cancellation and failure included: whoever opens closes. The work is committed on the copy’s branch before the copy goes, so a caller that drops the patch has still lost nothing, and a branch nobody wrote on is deleted: a name for no work would only pile up, one per run.
Putting them back together¶
land applies the patches to one tree, one at a time.
const landed = await land(repo, [
{ label: "subtask 1", patch: first },
{ label: "subtask 2", patch: second },
]);
landed.applied; // what went in, in the order it did
landed.rejected; // the one that stopped it, when one did
One at a time because a conflict after three patches says only that one of
them caused it. A patch is checked before it is applied, so one that does not
fit touches nothing, and it stops the rest. A flow’s check written after the
block then judges the tree as a whole.
Nothing is rolled back. A failure stops the rest where it is, and what landed stays landed: undoing would discard work, and every patch is also on its branch.
The tree has to be clean to start with, or “which patch broke this” stops
having an answer. requireCleanTree: false is for the caller that put those
changes there itself and is therefore the only one able to tell them from
somebody else’s: a flow run lands each block onto what earlier blocks landed.
Landing adds no commit and moves no ref. What goes in stays in the working tree for a human to read.
examples/13-concurrent-writers.ts runs two coders at once on two subtasks, a
scratchWorktree each, prints the two patches, then lands them.
What has no function here¶
No push, no merge, no rebase, nothing that rewrites history. The rule is
src/git/git.ts’s own: adding one of those is a decision somebody takes in a
diff, not
an argument a model produces at runtime. See
Design decisions.