// post

vincent 0.8.0: resolving one issue as a DAG of parallel agent lanes

// Why a dependency graph of work units spends less wall-clock than one long agent session, what vincent 0.8.0 added to make that runnable, and what the real runs cost.

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.

store-migration10 min · no depstui-rows30 min · no depscli-flag5 min · no depsneedsapi-endpoint15 min · after store
Four units, one edge. Three of them can start immediately.

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.

0102030405060minone sessionstore, tui, api, cli, one after another60 minbarrierstore-migration 10mtui-rows 30mcli-flag 5mround 1 endsapi-endpoint 15m45 mineagerstore-migration 10mapi-endpoint 15mtui-rows 30mcli-flag 5m30 min
The same 60 minutes of agent work, scheduled three ways. Bar length is work; the difference is 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.

  • seed commits the failing regression test. A lane’s worktree is git worktree add from the parent task’s branch, so it sees what is committed there and nothing else. The sequential workflow’s diagnose deliberately leaves that test uncommitted, which is what lets confirm-red run it against unfixed code, and uncommitted it would reach no lane. seed refuses to commit anything that is not a test or testdata/, and refuses the .vincent-issue/ scratch directory outright.
  • plan-dag is 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 declares files, an extended regular expression matching every path it may create or modify, and no two units’ expressions may match the same path. And needs is happens-after, so a unit goes in another’s needs only 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-emit prints the graph. for_each: is a template and a template cannot read a file, and an agent step’s .Result is its final message rather than parseable JSON, so a command step is the channel. jq -c normalises a pretty-printed plan to one object per line, and (index .Steps "plan-emit").Result is the derived lane list.
  • plan-gate is 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 the files expressions overlap? Is needs minimal? Does each brief stand 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:

barriereager
Next round startswhen the whole round has mergedwhen a lane’s own needs have merged
A round costsits slowest lanenothing; there are no rounds
A lane’s starting treefixed by the round boundarywhatever had merged when it was cut
Re-running the same tasksame trees, same merge topologymay give a lane a different tree and a different result
Reproducing a failed lanere-run ityou 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:

StepSession bound
diagnose2 (guarded to the bug path)
digest2 (guarded to the enhancement path)
plan-dag2
build lanes12, six lanes times 1 + 1 in the unit workflow
build merge join6, one resolver attempt per round, up to six rounds
integrate3
docs-sync2 (guarded)
verify/repair3
merge-attempt5
total37

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.

vincent Workflow tab for task #196 showing the fan_out node marked eager, a derived fan-out group with wave one above wave two, and each lane's three steps
Task #196 on a v0.7.0-211-g26aa193 development build, not a tagged 0.8.0 binary. The w1 and w2 tags are the wave each lane was spawned in.

The commit timestamps off the merged branch, all on 2026-09-04:

  • 18:18:38: seed commits “test: add the failing regression test for #324”.
  • 18:41:00: wave 1, lane slot-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, lane projects-slot-column, task #203, commits “fix: count every slot holder in the projects view”.
  • 18:52:39: wave 2, lane board-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:

vincent Workflow tab for task #194 showing a command step, an approved manual gate, an eager fan_out, and two independent lanes side by side rejoining at the bottom
Task #194, issue #322: park-writes-running-row and timeline-lane-rollup, two units with no edge between them, merged as PR #333.

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.

← all posts