An agent session is a sequential program. It reads a file, edits it, runs the tests, reads the failure, and does each of those after the last one, because there is exactly one of it. Spending more tokens does not change that shape.
vincent’s github-resolve-issue.yaml hands the implementation to one 45-minute agent session on one branch. When an issue is genuinely three separate pieces of work with an order between only two of them, that session still does all three end to end, because the workflow gives it no way to say otherwise. The fix isn’t a longer timeout. It’s a graph.
0.7.0 was the release where vincent started carrying all of its own development. 0.8.0, out on 2026-09-04, is where it stopped doing that in a straight line.
Nodes are units, edges are order
The idea is small and old. Nodes are pieces of work. Edges mean “cannot start until this exists”. Two nodes with no path between them can be delivered at the same time, by separate agents, on separate branches, and merged back as each one finishes. A node that genuinely cannot be written until another’s code exists declares that, and gets its worktree cut from a branch that already contains it.
What that buys is the difference between the sum of the pieces and the longest chain through them. What it costs is that every piece is a real agent session. This is a wall-clock trade, not a cost saving. Four lanes do not cost a quarter of a session each. They cost four sessions, plus a planner to draw the graph, plus an integrator to make the joined branch cohere, plus whatever the merges need. The workflow file says that in its own header, because a file that trades one resource for another should name both.
The arithmetic, on a worked example
Take an issue that is honestly four pieces of work. A store migration, a set of TUI rows that render the new data, an API endpoint that serves it, and a CLI flag that exposes it. Only one edge is real: the endpoint calls what the migration adds, so it cannot be written until that code exists. The TUI rows and the CLI flag touch neither.
One agent session does all four end to end, because there is one of it: 60 minutes. The graph does not remove any of that work. It removes the waiting.
Read it row by row. The single session is 10 + 30 + 15 + 5, because it is one program. barrier spawns the three root units together and finishes the round when its slowest lane lands, so 30 minutes buys the two that took 10 and 5 as well, and only then does api-endpoint get a branch with the migration on it: 30 + 15 = 45. eager does not make api-endpoint wait for tui-rows, which it has nothing to do with. The migration merges at minute 10, the endpoint is cut from that branch and runs 10 to 25, and the whole thing finishes when the longest single lane does: 30.
The number that matters is not 60 against 30. It is that the floor is the longest chain through the graph, not the sum of the work, and that a needs edge is the only thing that raises that floor. That is why the planner’s prompt says an edge costs wall-clock and that a chain of four is a sequential workflow with extra steps. Two units that could have been independent, ordered anyway out of caution, hand back exactly what the fan-out was for.
This is a model, not a measurement. Real unit durations are not known in advance, the planner and the integrator are two more sessions on top, and a real graph rarely has this shape. The measured run is further down.
What 0.8.0 added
The fan_out step already existed. Since 0.4.0 it has created real child tasks with their own branches and worktrees, then merged the lane branches back in declared order. What it could not do was order the lanes against each other, or decide how many there should be while the task was running. 0.8.0 closed both, and the way it closed them is the interesting part.
Ordering became an edge on the lane. A lane may name the sibling lanes it needs:, and the step runs the resulting graph in rounds: it merges what is finished before spawning the lanes those merges made eligible. So a dependent lane’s worktree is not cut from the branch as it stood when the fan-out started, it is cut from a branch that already contains its dependencies’ commits. That is the whole mechanism. There is no message passing between lanes and no shared state. A lane inherits its dependencies as commits, which is the only channel git was ever going to give it.
Width became a run-time value. for_each: over a step’s stdout, plus a single lane: template, renders one lane per item. Before that, every lane was spelled out in the YAML, so the shape of the fan-out had to be known when the workflow was authored rather than when the issue was read. Deriving it is what lets an agent decide how many pieces of work an issue contains, which is a judgement no static file can make.
Scheduling became a choice. barrier walks the graph a round at a time. schedule: eager merges each lane and spawns its dependents the moment that lane’s own needs are done, without waiting for unrelated siblings. That one has a price and gets its own section below.
Two smaller changes make the result usable rather than merely correct. Lanes are real child tasks on the board now, so L hangs them under the parent, l opens the lane under the cursor and U goes back, the output pane grows a lane selector, a parent blocked on a failed lane says which lane, and GET /v1/tasks/{id}/diff?by=lane splits the parent’s diff by the lane that produced it. And the Workflow tab draws the running workflow as a control-flow graph with run state on it, which is what both screenshots below show.
One bug fix belongs in the list even though it reads like a footnote. A command step’s .Result used to be its stdout and its stderr, cut at the wrong bound, so a for_each: list longer than 4 KiB lost its last items and a derived lane list gained a lane for every incidental git or curl progress line. Derived fan-out does not work without that fix.
The same workflow, split in the middle
github-resolve-issue.yaml is 2,484 lines and 18 steps: ten command, four agent, two loop, two manual. The DAG variant, github-resolve-issue-dag.yaml, is 2,867 lines and 23 steps: twelve command, five agent, three manual, one fan_out, two loop. Everything before the implementation and everything after it is copied across unchanged on purpose, so the two workflows fail the same way and a fix to one is a legible diff against the other.
The entire difference is that implement becomes six steps.
seedcommits the failing regression test. A lane’s worktree isgit worktree addfrom the parent task’s branch, so it sees what is committed there and nothing else. The sequential workflow’sdiagnosedeliberately leaves that test uncommitted, which is what letsconfirm-redrun it against unfixed code, and uncommitted it would reach no lane.seedrefuses to commit anything that is not a test ortestdata/, and refuses the.vincent-issue/scratch directory outright.plan-dagis an agent, it touches no code, and it is the only step that has to exist for the two files to differ. It writes.vincent-issue/units.jsonl, one JSON object per unit:{"id","title","needs","files","check","brief"}. Its prompt carries two rules. Every unit declaresfiles, an extended regular expression matching every path it may create or modify, and no two units’ expressions may match the same path. Andneedsis happens-after, so a unit goes in another’sneedsonly when it genuinely cannot be written until that one’s code exists, because “a chain of four is a sequential workflow with extra steps”. The planner is told to split only where it buys wall-clock, told that emitting exactly one unit is a correct answer, and capped at six.plan-emitprints the graph.for_each:is a template and a template cannot read a file, and an agent step’s.Resultis its final message rather than parseable JSON, so a command step is the channel.jq -cnormalises a pretty-printed plan to one object per line, and(index .Steps "plan-emit").Resultis the derived lane list.plan-gateis a manual gate that renders the graph inline. It is the cheapest decision in the workflow, placed immediately before the thing it authorises: approving spawns up to six child tasks, each with a worktree and an agent session, and rejecting costs one planner run. It asks four things. Is it split at all, and should it be? Do thefilesexpressions overlap? Isneedsminimal? Does eachbriefstand alone?
The graph is validated by a jq program inside plan-dag’s own check, which rejects a plan with a missing key, a duplicate or non-slug id, a needs naming a unit that does not exist, a self-edge, a seventh unit, or a cycle, the last of these by peeling nodes whose dependencies are already peeled and asserting that all of them came off. That check sits on the agent step rather than on a command step deliberately: a check failure is appended to the next attempt’s prompt, so a malformed graph is something the planner fixes. vincent also refuses a cycle at spawn with fan_out_invalid, and catching it here is the difference between a retry and a blocked task.
Then the fan-out, which is short:
- id: build
name: Deliver the units in parallel
type: fan_out
max_lanes: 6
schedule: eager
for_each: '{{ (index .Steps "plan-emit").Result }}'
lane:
id: '{{ .Item.id }}'
needs: '{{ .Item.needs }}'
workflow: github-resolve-issue-unit
fields:
unit_id: '{{ .Item.id }}'
unit_title: '{{ .Item.title }}'
unit_brief: '{{ .Item.brief }}'
unit_files: '{{ .Item.files }}'
unit_check: '{{ .Item.check }}'
merge:
on_conflict: agent
.Item is a JSON object rather than plain text, the one place a template value here is not a string, because a node of a graph carries an identity and its edges. The lane body is its own 371-line file, github-resolve-issue-unit.yaml, three steps long: unit-context, unit-implement, unit-clean.
The sixth step is integrate, one agent session on the joined branch. Six lanes that each passed their own check can still leave a branch that does not cohere: two units that agreed on a helper’s name in prose and not in code, a CHANGELOG.md entry written three times, a suite that is red only in the combination. It is also the first step that has seen the whole change, which is why it writes the PR body. A pull request describes one change, and until the join there was no such thing. It is told in as many words not to redo a unit’s work, because an integrator that starts rewriting lanes is a 45-minute session spent undoing the parallelism the file exists for.
What keeps lanes from colliding
needs is happens-after, not isolation. There is one branch, and two lanes with no edge between them can still edit the same file and collide at the join. Nothing in vincent prevents that, so the workflow does, twice.
The first time is the design instruction above, the disjoint files expressions, which plan-gate puts in front of a human. The second time is enforcement: unit-context writes the expression to .vincent-issue/unit-files.re, and the lane’s check greps the lane’s own diff against it with grep -vEf, failing the lane if it committed anything the plan did not give it, with the offending paths on stderr so the retry can see them.
Both the expression and the unit’s check command reach the shell through a quoted heredoc rather than through the script, because they are written by an agent and one quote in either would otherwise break the script that was meant to run them. Conflicts that survive all of that go to merge.on_conflict: agent, whose prompt says both sides are wanted and whose check asserts only what is true mid-join: nothing left unmerged, no conflict marker staged, and go build plus go vet over the packages that resolution touched.
What a lane is allowed to assert
On the bug path the branch carries a committed, failing test from seed until the unit that fixes it merges. So go run mage.go test is red by construction inside the fan-out, and a lane check that ran it would fail every lane for the same reason. Each lane’s bar is its own check, written by the planner and scoped to what that unit changed. The suite is integrate’s bar, after the join, and verify is still the cross-platform leg after that: the same two assertions in the same order as the sequential file, with the units’ own checks added in front.
go build ./... is not a lane bar either, for a structurally identical reason. A unit that changes a signature and the unit that updates its call sites are two units, the second needs the first and lands after it, so between the two merges the repository legitimately does not compile. The first lane cannot repair that, because the call site is inside another unit’s files expression.
That is not hypothetical. It happened on issue #328 on 2026-09-05, where the engine lane’s Runner.Retry signature change was consumed by a later internal/api lane, and a whole-repo build as the bar left no exit but blocked. The fix was to the workflow, in PR #336: two commits, fix(workflow): stop asserting a whole-repo build inside a fan-out lane and fix(workflow): scope the conflict resolver's build to what it staged. integrate runs go build ./... once, after every unit has merged.
barrier or eager
The timeline above makes eager look like a free 15 minutes. It is not free, and on the runs I have actually done it has been worth nothing at all. This is the one setting in the file I would change.
barrier is the default. The step computes the rounds of the graph, spawns every lane in a round at once, and starts the next round only when every lane in the current one has finished and merged. A round therefore costs its slowest lane, and a round boundary is a fixed point: every lane in round two is cut from exactly the same branch, whatever order round one happened to finish in.
eager throws away the round boundary. A lane is merged and its dependents spawned as soon as that lane’s own needs are satisfied, so a fast root unit’s dependent can be running while a slow unrelated sibling is still going.
What that buys, and what it costs:
barrier | eager | |
|---|---|---|
| Next round starts | when the whole round has merged | when a lane’s own needs have merged |
| A round costs | its slowest lane | nothing; there are no rounds |
| A lane’s starting tree | fixed by the round boundary | whatever had merged when it was cut |
| Re-running the same task | same trees, same merge topology | may give a lane a different tree and a different result |
| Reproducing a failed lane | re-run it | you cannot; the tree it saw was a stopwatch outcome |
The reproducibility line is the one that matters, and the workflow file states it plainly rather than burying it, which is the right call for a file trading one resource for another. But there is a second cost that only shows up once something goes wrong: when a lane fails, the first question is what was on the branch when it was cut, and under eager that question has no stable answer. You cannot re-run the lane and get the same conditions. On a workflow whose whole point is that agents write code you did not watch them write, giving up “run it again and see” is a real loss.
Against that, look at what eager actually bought on the runs below. It bought nothing, on any of them. eager differs from barrier only when a round contains both a lane whose dependents are ready and a slower unrelated sibling. Every DAG run vincent has done on itself so far had its multi-lane round as the last round: issue #324 was one root then two dependents, #323 the same, #328 a chain of two, #322 two independents in a single round. In all four the two schedules produce identical timing, because there was never a dependent waiting behind an unrelated sibling. The reproducibility was spent and no wall-clock came back.
That is the shape an issue planner tends to produce, too. A planner told to keep needs minimal writes small graphs with a wide last round, and a wide last round is exactly where the two schedules agree. So my recommendation is to start with barrier, which also means writing nothing, since it is the default. Turn eager on when you have a specific plan with a measured round where a fast lane’s dependents are stuck behind a slow sibling, and you are willing to accept that re-running that task is not the same experiment twice.
More generally: needs, derived lanes and eager are all one release old. The first two are the valuable part and they are structural, so a bug in them shows up as a refused plan or a blocked task. eager is a tuning knob on top, and its failure mode is a run you cannot reproduce, which is the worst kind of thing to discover late. Get the graph right first. The scheduler is the last thing to tighten, not the first.
What it costs
The other price is sessions. Counting 1 + max_retries per agent, loops multiplied by their iteration ceiling, and every lane, this is the upper bound on agent sessions vincent may start automatically before any human presses retry:
| Step | Session bound |
|---|---|
| diagnose | 2 (guarded to the bug path) |
| digest | 2 (guarded to the enhancement path) |
| plan-dag | 2 |
| build lanes | 12, six lanes times 1 + 1 in the unit workflow |
| build merge join | 6, one resolver attempt per round, up to six rounds |
| integrate | 3 |
| docs-sync | 2 (guarded) |
| verify/repair | 3 |
| merge-attempt | 5 |
| total | 37 |
The sequential file’s same bound is 17. Twelve of the extra twenty are the lanes, six are their joins, and two are the planner. Six lanes also leave six worktrees on disk until the tree is archived, which is what vincent gc and vincent doctor are for, and the first thing you meet after a run.
Two waves on a real issue
The DAG workflow landed in vincent’s own repo on 2026-09-02, in a commit called “feat(workflows): resolve an issue as a DAG of parallel work units”. vincent has been building itself with it since. The clean worked example is issue #324, parent task #196, merged as PR #329: three units, two waves.

The commit timestamps off the merged branch, all on 2026-09-04:
18:18:38:seedcommits “test: add the failing regression test for #324”.18:41:00: wave 1, laneslot-counts-daemon, child task #198, commits “feat: serve the §11 slot counts from the daemon”.18:42:17: that lane is merged into the parent branch.18:46:35: wave 2, laneprojects-slot-column, task #203, commits “fix: count every slot holder in the projects view”.18:52:39: wave 2, laneboard-header-slots, task #202, commits “fix: count every slot holder in the board header”.
Both wave-2 branches carry the commit Merge lane 'slot-counts-daemon' of task 198 at 18:42:17. That is the changelog claim on disk: a dependent lane’s worktree was cut from a branch that already contained its dependency’s commits, not from the branch as it stood when the fan-out started.
The two wave-2 lanes ran concurrently and finished 4m18s and 10m22s after the wave-1 merge. Run one after the other that is about 14m40s of coding; the wave cost about 10m22s. I am not going to inflate that. It is a four-minute saving on a small three-unit issue, the shape is what scales rather than this instance, and from the red test to the last unit landing was 34 minutes. integrate and the documentation audit ran the next morning, and the PR merged at 07:58 on 2026-09-05.
Note what schedule: eager contributed to that run: nothing. Wave one held a single lane, so it merged and released wave two at the same moment a barrier would have. The four minutes came from the two wave-2 lanes overlapping, which barrier does as well.
The other runs so far
Not every plan has an edge in it. Issue #322 came back as two units with nothing between them, so both lanes were spawned in the same wave and their branches converge straight at the join:

The rest of that window: issue #323 became parent task #195 with lane #197 step-input-record first, then #204 and #205, which both carry Merge lane 'step-input-record' of task 197, merged as PR #331. Issue #328 became parent task #209 with engine-cascade (#210) and api-retry-response (#211) needing it, plus retry lanes #212 and #213, merged as PR #337. That is the run that hit the whole-repo build problem.
One caveat worth stating plainly, since the timestamps invite the wrong reading: calendar time on these tasks is dominated by the two human gates and by overnight parks, not by compute. What the DAG shortens is the coding half, and that is also the half that can be measured.
When it is worth reaching for
Not for a two-line fix. The planner is told to emit a single unit in that case, and the fan-out then degenerates into the sequential shape with extra steps and an extra gate. The workflow is worth choosing when an issue is honestly several pieces of work, when those pieces have few edges between them, and when you would rather spend roughly twice the agent sessions than wait for them in series. If that trade does not sound good on a given issue, github-resolve-issue.yaml is still sitting right next to it and still does the same thing at both ends.
And take the graph without the scheduler. needs plus derived lanes is the change that turns an issue into something with a critical path instead of a length; schedule: eager is a tuning knob that has so far cost me reproducibility and returned nothing. The file as it stands sets eager, which on the plans it has actually seen is redundant rather than wrong. barrier is what I would leave it on until a run shows me a round where it is the thing in the way.
vincent is still pre-1.0, so its workflow surface can move under both files. The changelog tracks what moves, the repository has the rest, and the other things I build are collected at lezli01.is-a.dev.