Delroy runs pipelines. A pipeline is a sequence of stages, each staffed by an agent, with structured results flowing from one stage into the next. For months every pipeline was a straight line: one agent per stage, one stage at a time.

Two changes broke that line open. A stage can now decide how wide it goes while the run is happening, and a wide stage puts its agents to work at the same time on the same project. Each took three attempts. The failed attempts taught me more than the working version, so that is most of what follows.

Part one: how wide should this stage be?

When you hand Delroy a goal, a planning pass turns it into a pipeline. That plan has to decide how many agents the implementation stage gets, and it decides before any agent has opened a file.

So it splits by category. “Authentication.” “Input handling.” “Rendering.” Those come out of your prompt, not out of your repository. A stage that first goes and counts the actual routers can split by the actual routers instead.

That is the whole feature. A stage can say “my width comes from whatever the planning stage finds.” An earlier stage, one that has read the code, publishes a work-list. The later stage fans into one lane per item on that list.

The publishing stage also gets to pick which agent staffs each lane. It has read the code, so it is the only party that knows a given slice is a rendering problem rather than a database one. The planner that wrote the stage never saw the repository, and could not have known.

Every rule is a refusal

The widening logic is built out of refusals, and I wrote it that way for a reason: a fan-out the model got wrong costs four times as much as no fan-out at all.

No work-list published? The stage runs as it was. Work-list holding something that is not a list, an entry with no description, fewer than two usable lanes, a stage that already has a plan-time split, a stage with an approval gate? Same answer every time. The stage runs exactly as it would have if the widening feature had never been built.

Refusing beats truncating

When the work-list comes back longer than the lane limit, Delroy declines to fan out at all rather than taking the first N items.

Truncating a research task costs you coverage, and you can live with that. Truncating a writing task means the dropped items get written by nobody while the stage still reports success. The run then fails at the build with no trace of what went missing. One agent doing all of it is slower and correct, so that is what happens.

The limit used to do two jobs at once. It was both the concurrency cap and the ceiling on how many lanes could exist, one number wearing two hats. An eleven-piece plan against eight parallel slots did not run eleven lanes eight at a time. It ran one agent over all eleven. I found this after a run published exactly eight items against a limit of exactly eight. One more piece in that plan and the whole fan-out would have vanished with no error anywhere. They are separate numbers now, because they answer separate questions.

Say what you refused

Every refusal explains itself now. For three rounds they said nothing at all, and a run whose implementation stage collapsed to a single agent looked identical to a run that never asked to fan out. I had to reconstruct the difference days later by inferring stage shape from how big the turns were.

Part two: the version I deleted

Now the harder half. Several agents writing to one codebase at the same time.

The obvious answer is to give each lane its own git worktree. I built it. Each lane got its own checkout, wrote its files, committed to its own branch, and a merge step reassembled the work at the end. It shipped behind a flag.

It failed in a way I should have predicted. A lane needed a class that a sibling lane was writing. It could not see that file, because the sibling’s checkout was somewhere else. So it wrote its own stub. The sibling wrote the real one. Two lanes created the same path with genuinely different APIs, git called it an add/add conflict, the merge rolled back, and the run’s work went in the bin.

In one measured run, 44% of the total spend happened after that first merge failed. It produced one file.

There was no repair path. Preferring either side of a conflict breaks the other lane’s code without telling you, and no marker reliably identifies a stub as a stub. Prevention was the only option left, so I retired the whole architecture.

Here is the part that took me longest to see. Worktrees did not only cost me failed merges. They made cross-lane reading impossible in the first place. Every lane saw a frozen copy of the project, so the only way to coordinate was to guess at an interface and reconcile at merge time. That guessing is what the 44% paid for.

Part three: ownership instead of isolation

The replacement is one shared working copy plus file ownership the runtime enforces. No branches, no merge, no conflicts, no rollback. Each lane declares the exact paths it may create or change, and every write outside that set gets refused before it touches disk.

Reads go the other way. A lane can open any file in the project, including one a sibling is writing at that moment. That is the entire point. The instruction each lane gets amounts to this: some of these files are being written right now by the lanes beside you, in this same working copy, so when you need something one of them is building, open the real file and code against what is there. Never stub, copy, or recreate a file you do not own. A stub compiles on its own and breaks the moment the real one lands beside it.

Alongside that, each lane gets a roster of which sibling owns what, and a summary of the interfaces the others are building for it.

Where the engineering lives

The idea is one sentence. The work is in the paths.

Fail closed, not open. If you derive the restriction from “a list of paths, or nothing,” then losing the plumbing anywhere along the chain gives you nothing, and nothing reads as unrestricted. The permission level and the path list have to be two separate signals. A lost list denies everything. An empty set and an absent set are different things.

Check the resolved path, never the string the model typed. By the time ownership gets consulted, the path has already been resolved and confirmed to sit inside the project. That makes a whole class of escapes unreachable rather than filtered: parent-directory traversal, absolute paths, symlinks pointing out of the tree. A symlink pointing at a sibling’s file resolves to that sibling’s real path, so it gets refused correctly instead of laundered through under a different name.

Normalise Unicode, and fold case only when the disk does. macOS preserves whatever Unicode normalisation you hand it, so one café.dart can exist as two different strings naming one file. Compare them naively and a lane gets refused its own file. Case is worse, because you cannot infer it from the operating system: macOS ships case-sensitive volumes, and Linux mounts case-insensitive ones. Delroy probes the disk and asks it directly.

That normalising step carries a comment about a bug I shipped. It strips a leading ./ in a loop rather than with the obvious string helper, because that helper works on character sets and turned ../x into a valid claim on x.

Refuse glob patterns rather than interpreting them. Concrete paths are what make “do these two lanes overlap?” a decidable question. Allow globs and it stops being decidable, and an undecidable overlap check is exactly how two lanes come to own one file.

Overlap has three shapes, not one. File against file is the obvious one. The other two are a file sitting inside another lane’s directory, and one directory sitting inside another’s. Miss those and you hand two lanes the same subtree, which brings back the last-writer-wins race that ownership exists to remove.

Reject half-owned splits. Parallel writing turns on only when there are at least two lanes, every lane declares at least one usable path, and none of them overlap. One unowned lane in the set is one lane writing wherever it likes, next to lanes that cannot defend their files.

The refusal has to teach

An error that says “permission denied” gets a model to try the same write under a different name. So the refusal carries four things: what was refused, the lane’s own list of paths, who owns the target, and what to do instead.

It names the sibling and says that sibling is building the file right now, in this same working copy. It says the file is not missing. It says do not stub it, do not copy it, do not recreate it under another name, open it and code against what is there. Then it says everything else you were asked to do is still in scope, continue with your own files.

That path list matters more than it looks. A lane twenty steps in has lost its original file list out of the context window, so the refusal has to hand it back. It reprints a capped number of paths before summarising, because a lane owning two hundred files would otherwise turn every refusal into a wall of text resent on every step after it.

After three refusals the message stops teaching and starts closing: finish your own files and report. It never ends the turn, which is where it differs from the other circuit breakers. A lane that has done real work still has to get to the end to tell anyone about it.

The shell is withheld, not policed

A writing lane gets no shell and no test runner. The editing tools resolve a path, so they can be checked against what the lane owns. A shell command cannot be. An in-place edit, a redirect, or a code generation step reaches any file in the tree, and reading shell for write intent has no bottom to it. So I removed the tools instead of trying to police them. Git stays available for reading, since two lanes staging files at once corrupt the index for the whole run.

The cost is real and I chose it: a writing lane cannot build or test itself. What replaces it was already there. Language-server diagnostics are read-only, and every successful write comes back with a diagnostics report attached. The build belongs to the single-agent stages that follow.

Part four: lanes that read forever

Ownership fixed the conflicts. Then I measured what the lanes were doing, and found a worse problem.

Across three runs, 76% of lane turns burned a full budget, changed no file, and reported success. The stage reported success too. Testing ran against a project missing most of what had been planned, and the run ended with “does not compile, 27 errors.”

Three of those lanes I read step by step. All three opened by reading files and never stopped. They spent 27 of 29 steps reading, made zero writes between them, and one of them finished holding 194 lines of complete, compiling Dart inside its final report rather than on disk. The code existed. Only the chance to write it was gone, because the wrap-up step had just taken the writing tool away.

The agent persona says “orient first, act second,” and that is the one instruction those lanes obeyed.

Three fixes went in.

Write first. Once a writing turn passes a fraction of its budget with nothing on disk, Delroy takes the reading tools away until something lands. It reapplies on every step, and not once, because a lane handed its tools back after a single nudge just resumes the loop. A turn that has written something gets its reading tools back. The goal is breaking an orientation loop, not stopping a lane from checking its work.

That rule started out bound to parallel lanes only, and then the ordinary single-agent stages burned the next run. One integration stage spent 100 model calls and 431 tool calls, 383 of them file reads, to make exactly one edit. It had seen 90% of everything it would ever read by step 21 and made that single edit at step 64. The rule now binds any stage whose job is to change the project.

Facts beat claims. A lane’s status is its own opinion. The count of files it changed is a fact the runtime recorded from real successful writes, and I refused to scrape it out of the model’s prose. Where the two disagree the fact wins: a lane that owns files, changed none, and claims success gets recorded as a failure. The stage sees a failure, only the failed lanes re-run, and an integration stage owns getting the project to build before anything judges it.

There is a narrower version of the same check that fires while the turn can still act. It tells the lane the content is the deliverable and not a description of the deliverable, and leaves an honest “needs changes” exit open.

Stop pasting the goal twice. Every stage template ended with its own goal section, and the task builder prepended the goal above it. On one real lane turn the goal ran 17,461 characters and the whole task ran 46,966. So 74% of what that lane read before its first tool call was the same text repeated, against a 120k conversation budget. That is why those turns sat pinned at the compaction ceiling.

Part five: the cheap win

One more piece of parallelism, much smaller and much easier. Inside a single agent turn, consecutive runs of two or more pure file reads now execute at the same time, four at a time.

The main loop still consumes those results at its normal spot in the original order, so event ordering, approval prompts and conversation shape stay identical to running them one by one. Only wall-clock time changes. Errors get captured and re-raised at the point the loop would have hit them.

Writes, shell commands, git and external tools never qualify. Neither does a read that one of your permission rules would gate: if you have a rule saying “ask before reading .env”, running that read early puts the file in memory before you get prompted, which defeats the rule. Those fall back to the sequential path. There is an environment variable that turns the whole thing off, and I have not needed it yet.

What I took away

Isolation felt like the safe choice, and it was the expensive one. Giving each lane a private copy of the project removed conflicts by removing the shared reality the lanes needed in order to agree with each other. Ownership on one working copy costs a guard and gives back the ability for a lane to read what its neighbour just wrote.

The other lesson is that measurement told me things I would never have guessed. I would have gone on describing this as a merge problem for as long as I cared to, because merges were the thing that visibly broke. The number that mattered was 76%, and I only found it by counting how often my own agents claimed success while producing nothing.