← Writing

Case study

DaVinci

A personal multi-agent orchestrator that takes a task and a repo and drives it through research, planning, execution, and integration testing, with human review at the gates that matter.

RoleDesign and build, solo
WhenLate 2025 to early 2026
Stack
Next.jsECS/FargateStep FunctionsSQS FIFOLambdaPostgrescoding-agent CLI

Most of the work in shipping a feature isn't typing the code. It's holding the whole thing in your head: what the change touches, what to build first, what breaks downstream, whether the tests actually prove anything or just pass. That coordination is the expensive part. DaVinci was my attempt to hand the coordination to a system and keep myself in the seat only where judgment earns its keep.

It's a personal project. It takes a task and a repository and drives it the way a small team would. Research the codebase, plan the work into dependent pieces, write and test each piece, then run the whole thing end to end. You can let it go start to finish, or you can make it stop and check with you at the points that matter. I built it solo over a few months to answer a question I kept arguing about: how much of the coding-agent problem is a model problem, and how much is plumbing. The answer turned out to be mostly plumbing.

The problem

A single coding agent on a single task is a solved demo. Point it at a repo, give it a prompt, watch it churn. The trouble starts the moment the task is big enough to need a plan, or you want more than one of them running, or you want to trust the result without reading every line. Then you're not building a smarter agent, you're building the thing around the agent: the queue that holds work, the state that survives a crash, the gate where a human says yes, the logic that stops two agents from stomping each other's commits. None of that is prompt engineering. It's the boring distributed-systems stuff that decides whether the clever part ever ships. DaVinci is less a code generator than a small operations layer for a fleet of processes that don't fully trust each other. The generating is the easy tenth of it.

The shape

A dashboard and an orchestration API sit in front. They drive a task through four phases, each backed by its own container-style task: Research, Planning, Execution, and an Integration test. The phases don't call each other directly. Each one reports back to the API over HTTP callbacks as it makes progress, the API writes that progress to Postgres, and completing one phase is what triggers the next. Artifacts and diffs land in object storage. Everything durable lives in Postgres so a phase can die and get re-triggered without losing the thread.

System architecture: a dashboard and orchestration API drive four phase tasks (Research, Planning, Execution, Integration test). Execution fans out through a FIFO queue, a router, a capacity state machine, and an executor container. The executor reports progress to the API over HTTP callbacks. The API persists to Postgres and object storage.
The breadth shot. Four phases in front, a queue-router-state-machine-executor path behind Execution, everything durable in Postgres.

The phases talk through callbacks instead of a direct chain because the work is long and lossy. A research container might run for many minutes, and if the API held a connection open and waited, a single blip would take the whole task down. So each phase is fire-and-report: the API kicks it off, forgets about it, and reacts to callbacks. State is the only thing that connects one phase to the next, and state lives in the database, not in memory.

Four phases, two modes

Research breaks a task into three to five directions and explores them at once. A container clones the repo and runs a coding-agent CLI on each direction in parallel, and the findings stream back through the same callbacks. When the last direction reports in, that completion auto-triggers Planning. Planning aggregates the findings and produces a subtask hierarchy with explicit dependencies, so the executor later knows what can run now and what has to wait. On approval it cuts a dedicated working branch and queues the root subtasks, the ones with no dependencies.

A four-phase pipeline: Research, Planning, Execution, Integration test. Research auto-triggers Planning; a plan-review gate sits before Execution and an exec-review gate before Integration test. In handoff mode it runs end to end; in review mode it stops at each gate for approval or chat feedback.
Four phases, two modes. Hand it off, or make it stop and check with you.

Two modes. In handoff mode it runs end to end on its own, which is fine for well-scoped work. In review mode it pauses at two gates, after planning and after execution, and waits for me to approve or push back in a chat. It revises and resubmits until I'm happy. I ended up writing a whole post on why those gates are an architecture and not a checkbox. DaVinci is where that conviction came from.

The hard part: making the executor honest

The interesting part is how a single subtask actually gets done. The lazy version hands the whole thing to one model and hopes. Instead the executor runs a small loop with three separate roles, because "write code" and "check the code" are different jobs and one model wearing both hats grades its own homework.

The executor feedback loop: a code-gen agent, then a test-gen agent, then a test-runner agent. The runner diagnoses a root cause with one of five outcomes and routes the next round: code_issue to code-gen, test_issue to test-gen, both_issue to both, env_issue to fix the environment, test_passed to commit and push. Up to five rounds per subtask.
Code-gen writes it, test-gen tests it, test-runner decides who was wrong. Five outcomes, each routed somewhere.

A code-gen agent writes the change. A test-gen agent writes tests against it. A test-runner runs them and, when they fail, does the part everyone skips: it diagnoses why. Every failure gets a root cause out of five: is the code wrong (code_issue), is the test wrong (test_issue), are both wrong (both_issue), is it the environment (env_issue), or did it actually pass (test_passed)? That label decides where the feedback goes next round. code_issue routes back to code-gen, test_issue to test-gen, both_issue to both, env_issue into fixing the setup rather than the diff. Blaming the code when the test was garbage only burns rounds. The loop runs up to five times, and if it can't get to green honestly, it says so and flags the subtask instead of committing a passing lie.

The hard part: running a lot of these without lighting money on fire

One agent is easy. The problem is fifty. Each executor is a heavy container, and if every queued subtask spun one up the instant it was ready, both the bill and the account limits would start screaming. So execution doesn't run tasks directly. It puts them on an SQS FIFO queue, a Lambda router picks them up, and a per-subtask Step Function checks for capacity before it starts anything.

A capacity state machine: check capacity, then a decision. If there is capacity, run the task and wait for it to finish. If not, wait 60 seconds (which costs nothing) and loop back to check again.
If there's room, run. If not, sleep for free and check again.

If there's room, it runs. If not, it waits sixty seconds and checks again, and the wait costs nothing, which is the entire trick. A queue that burns compute just to hold its place is a queue that taught you an expensive lesson. This one sleeps for free and wakes up when a slot opens. Concurrency stays capped, the account stays happy, and nothing gets dropped. FIFO ordering keeps a single subtask from being picked up twice on a retry.

The hard part: the subtasks fight over the same branch

Subtasks that share the same dependencies run in parallel, which means they race to push to the same working branch. The first one wins. The rest get rejected. So each executor, when its push bounces, fetches, rebases, and if there's a real conflict, hands both versions to a model to resolve, merging dependency additions and overlapping imports the way you would by hand. Then it tries the push again, up to five times, before it gives up and flags the subtask for me.

Parallel-push conflict resolution: push to the branch, get rejected, fetch and rebase on the new tip, then a conflict check. No conflict retries the push. A conflict hands both versions to an agent that merges dependency lists and imports without rewriting history, then loops back to rebase. After five failed attempts the subtask is flagged.
Lose the race, rebase, and if it really conflicts, let an agent merge it. Five tries, then it's my problem.

Nothing rewrites history. Every attempt, including the ones that failed and got retried, stays in the log where I can read it. The file changes for each subtask get pulled from the version-control API and stored, so the record of what landed doesn't depend on the branch staying pristine. When you're letting agents commit, the audit trail isn't a nice-to-have. It's the only reason you can trust the thing at all, a lesson that turned into another post.

When a subtask can't be saved, there are two ways out. Rerun starts a fresh attempt with an incremented number, so the failed one stays on the record. Force-bypass marks it done and unblocks its dependents, for when I've decided the dependency doesn't really matter. Both are explicit and logged.

The hard part: letting a human in without stopping the machine

In review mode the plan doesn't go straight to execution. It gets submitted to a chat gate, and nothing branches or queues until I approve. I can approve it, or I can send feedback in the chat, in which case the planner folds the feedback in, revises, and resubmits. Only on approval does DaVinci cut the working branch and queue the root subtasks.

The human-in-the-loop review loop: a plan is submitted with subtasks and dependencies, a human reviews it in a chat, and on approve the system creates a branch and queues root subtasks. Chat feedback instead sends the plan to a revise step that folds the feedback in and resubmits. Execution review mirrors the same shape.
Plan submitted, human reviews, approve branches and queues, feedback revises and resubmits. Execution review works the same way.

The execution review gate mirrors this one phase later: once the subtasks are committed, the same submit-review-revise loop runs before the integration test. The point of doing it this way, rather than a blocking prompt, is that the review is just another durable state. The task sits in a pending-review status in Postgres until I act. I can walk away, come back an hour later, and the chat is still there. The human is a step in the state machine, not a thread someone has to keep alive.

That durability is also what makes pause, resume, and stop work. Pausing writes the current phase into the task's status and halts the active work. Resume reads that stored phase back and re-triggers whatever got killed, which only works because no phase depended on anything held in memory. Stop is terminal. All three are just status transitions, which is the whole reason they're reliable.

The data and state model

Everything runs off a task row in Postgres with a status that walks a fixed lifecycle: pending, researching, research_complete, planning, pending_plan_review, plan_complete, executing, pending_execution_review, integration_testing, and finally completed or failed. The pending-review statuses are where the human gates live. Any phase can drop the task into failed.

The full task status lifecycle stored in Postgres: pending to researching to research_complete to planning to pending_plan_review, then on approve to plan_complete to executing to pending_execution_review, then on approve to integration_testing, ending in completed or failed. A paused state stores the current phase and resume re-triggers the killed work. Any phase can transition to failed.
The status lifecycle. Review gates are just statuses, so pausing at one is free.

Hanging off the task are the tables that make it replayable. Research directions record what each parallel explorer found. Subtasks carry their dependencies, status, commit sha, the files they changed, and their test results. Test rounds store each pass through the executor loop with its root-cause label, so I can read back exactly why round three routed to test-gen. Integration-test runs are tracked separately because there can be more than one, and plan-review and execution-review messages are the chat transcripts of the gates. There's also a catalog of MCP tool servers a task can use and a record of which skills got invoked. It's a lot of tables for a personal project, and that's the point: the schema is the audit trail, and the audit trail is what lets me trust an agent's commit without reading it.

A few things ride on top. Model selection is per phase, so research can run a cheaper model than execution. The MCP tool servers are configured per task, so a run only gets the tools it should. And task creation can be driven from a Linear issue, which is how I used it day to day: file the issue, let DaVinci pick it up.

The integration test phase

The last phase catches the lies the unit tests missed. A container sets up a real environment, node or python plus whatever services the repo needs, including a Postgres instance, and runs the regression checks: lint, type-check, the existing suite. It's allowed up to three auto-fix commits to clear those, because a broken import isn't worth flagging a human over. Once the baseline is green, an agent does feature verification, actually exercising the thing the task was supposed to build rather than trusting that green tests mean a working feature. Multiple runs get tracked, because you almost never get it in one.

What I'd keep

DaVinci is shelved now. It was an experiment, and experiments earn their keep by teaching you something you carry into the next thing. Three things stuck.

Durable, resumable execution beats clever prompting. The moment a run can survive a crash and pick up where it left off, half the reliability problems stop being problems. The human belongs at specific gates, not sprinkled through every step, or you've just built a slower human. And the instant you run more than a couple of agents at once, the hard problems stop being about the model and start being about queues, capacity, conflict resolution, and who gets to push.

It stopped being a project about generating code and became a project about running a lot of processes that don't fully trust each other, safely. That second project is the one I'd build again.

Comments