PRODUCTS

KEYWORDS

Vibe-Coded Agents for Vibe-Coded Issues

Dolt is a SQL database with Git-style version control. It speaks the MySQL wire protocol, so any MySQL client connects to it, and it adds version control primitives on top: branch, merge, diff, clone, and push, all over SQL. Under the hood, go-mysql-server handles the SQL execution layer between clients and Dolt’s storage engine.

Gas Town builds on those primitives. It is a multi-agent coding orchestrator built on write-only code, and as it scaled to hundreds of concurrent workers, those agents were constantly branching, merging, and committing against Dolt. That load surfaced a new class of issues. Vibe-coded issues.

To be clear, I’m not throwing shade. After all, I’m familiar with running agents in parallel to work through MySQL correctness work, but we’re also entering a new ballpark here. It’s hard to expect clear reproductions when hundreds of agents unpredictably use a tool at the same time. I would know, since I recently spent hours trying to get a reproduction on a couple of Gas Town Dolt issues, some leading to nowhere.

So, in the spirit of Vibe Code vs Trad Code, sometimes you fight fire with fire. I’ve “vibe-coded” grunt: a Go CLI that provisions isolated Docker agent environments in parallel, but with options this time around.

The Problem: Pre-Baked Containers Don’t Scale#

The obvious answer to “Gas Town introduced these issues, why not use Gas Town to reproduce them?” is that issue reproduction isn’t really a Gas Town job. Gas Town is built for long-running, write-only work where persistent agent memory via Beads accumulates across sessions. Reproducing a GitHub issue is the opposite: a short-lived, discrete task where you spin up, get a failing test, and tear down.

My previous setup relied on a single container image with all dependencies pre-baked. That worked when I was targeting a specific repository and task. The moment I needed to handle two repos with different requirements, it broke down and I had to manually update things.

What I actually wanted was to decide at launch time what a specific agent container needs: which post-install scripts to run, how much memory to give it, what prompt to start it with. Per-container, per-repo, configurable from just CLI flags.

So I pointed an agent at the problem. I had it follow Go best practices from the official docs and address IDE warnings as it went, rather than letting it freewheel. The result is grunt. I can read the code, which puts it firmly in Trad Code territory even if it got there via an agent.

How grunt Works#

One command does everything:

grunt agent create -name gms -repo dolthub/go-mysql-server

A grunt agent session running against go-mysql-server

create resolves the repo’s configuration, builds the Docker image with the right post-install scripts applied, clones the repo, starts the container, and drops you straight into a zellij session with Claude already running. There is no separate start step.

The nice part is that grunt agent create -repo dolthub/dolt already knows what that repo needs without me specifying anything. dolthub/dolt needs CGo build dependencies plus bats for its test suite. dolthub/go-mysql-server needs something lighter. That comes from the configuration layer, covered in the next section. Even if you don’t have a config, it’ll figure out the GitHub URL automatically for any new repositories.

If I want to override the post-install scripts or provider (only claude so far) for a specific run, I pass them directly:

grunt agent create -name dolt -repo dolthub/dolt -repo dolthub/go-mysql-server -issue 1234 \
  -post-install go,bats,dolt-cgo-deps \
  -provider claude

When I have several issues to chase at once, -d starts the container in the background and hands control back immediately. Then I attach to whichever is idle:

grunt agent create -d -name dolt -repo dolthub/dolt -issue 10782
grunt agent create -d -name gms -repo dolthub/go-mysql-server
grunt agent ls
ID                 STATE    ACTIVITY  PROVIDER  REPOS                         ISSUES              CREATED
gms-41b27c1c       running  -         claude    dolthub/go-mysql-server (+2)  10190               2026-03-27T18:20:49Z
gms-c35d524e       stopped  -         claude    dolthub/go-mysql-server (+1)  dolthub/dolt#10757  2026-03-30T20:12:06Z
dolt-d32698b0      running  working   claude    dolthub/dolt                  10782               2026-03-31T17:16:23Z
gms-a573c42c       running  idle      claude    dolthub/go-mysql-server (+1)  -                   2026-04-01T17:39:20Z
grunt agent attach gms-a573c42c

For changes I want to stick across all runs of a repo, I use grunt config set. I’ll usually do this when working on any new repo to set their post-install scripts:

grunt config set branch.prefix -value elian -repo dolthub/dolt
branch.prefix[dolthub/dolt]=elian

grunt config ls -repo dolthub/dolt
$ grunt config ls -repo dolthub/dolt
agent.provider=claude
agent.memory-limit=8g
branch.prefix=elian
issue.prompt=Create a failing reproduction for the following issue {{....
repo.post-install=go,bats,expect,dolt-cgo-deps
repo.services=

That config ls output is the resolved view of a three-layer system. The UX borrows directly from git config.

Per-Repo Config#

Rather than making users configure everything from scratch, grunt ships with per-repo defaults embedded directly in the binary using //go:embed. Each supported repo gets one JSON file covering the two things that vary most: which post-install scripts to run, and a default prompt to seed the agent with:

{
  "scripts": ["go", "bats", "expect", "dolt-cgo-deps"],
  "prompt": "Create a failing reproduction for the following issue {{.IssueRef}} under the relevant available repositories..."
}

Adding a new repository means adding one JSON file. On top of those sit two user-owned layers: a global config that applies across all repos, and a per-repo override. Every lookup walks the same three layers:

func (s ProfileService) AgentProvider(repo string) (string, error) {
    profile, err := s.Store.Load()
    if err != nil {
        return "", fmt.Errorf("load profile: %w", err)
    }
    if provider, ok := profile.RepoAgentProviders[repo]; ok {
        return provider, nil
    }
    return profile.AgentProvider, nil
}

This works fine for the number of config values grunt has today. It’s worth noting that the agent duplicated this walk across every accessor instead of merging the layers once at load time. A cleaner approach would resolve everything in Load() and let accessors just read fields. With only a handful of values that is not a real problem yet, but it’s worth revisiting.

Adding a New Agent Type#

grunt only ships with Claude today, but it was designed so that swapping in a different agent is one interface implementation away. Each agent type implements AgentProvider, which covers everything that varies between providers: how to set up the agent in the Docker container, where to find the agent’s config files, how to read its activity status, and what command to launch it with:

type AgentProvider interface {
    Name() string
    Spec() Spec
    PrepareRuntime(input RuntimeInput) (RuntimeOutput, error)
    ActivityStatusPath(workspaceRoot string) string
    ReadActivityStatus(workspaceRoot string) (string, error)
    EnsureGlobalConfig(paths config.Paths) error
    SaveAPIKey(key string, paths config.Paths) error
    ConfigPaths(paths config.Paths) ConfigPaths
    Validate() error
}

PrepareRuntime handles the container setup: it returns the Docker mounts, environment files, and build commands specific to that provider. Spec returns the startup command and shell. Those get passed into a zellij layout file that grunt generates at runtime using Go’s text/template (has also been useful in other templates i.e. the Dockerfile).

layout {
    tab {
        pane name="Claude" command={{ printf "%q" .StartupCommand }}
    }
    new_tab_template {
        pane name="Shell" command={{ printf "%q" .PaneShell }}
    }
}

Zellij is a terminal multiplexer, similar to tmux, that lets grunt give each agent its own named panes. Because the layout is generated from a template, a different provider produces a different layout with no special casing anywhere in the terminal code. Provider selection is a single switch:

func LookupAgentProvider(name string) (AgentProvider, error) {
    switch strings.ToLower(strings.TrimSpace(name)) {
    case "", domain.DefaultProvider:
        return ClaudeProvider{}, nil
    default:
        return nil, fmt.Errorf("%q: %w", name, errUnsupportedProvider)
    }
}

Conclusion#

grunt is a vibe-coded tool for reproducing issues caused by a vibe-coded tool. That is about as recursive as it gets. We could go on about each aspect of implementation, but the above represents the main goal of these ephemeral configurable agent instances. It’s readable-enough, and it works.

It’s already helped me spin up multiple reproductions in parallel and close issues faster. I’ve also gotten new ideas on putting this against CI flaky tests in the background too. If you have questions about the setup or want to dig into Dolt, come find us on Discord. We are always happy to talk Go.