I got tired of the same loop: copy a stack trace, paste it into a chat window(chat gpt), paste the reply into my editor, find out it doesn't compile, repeat.

So I built heal-cli. It wraps your build or test command. When that command fails, it asks an LLM for candidate patches, runs each one in a throwaway sandbox, and writes a fix back to your tree only if it compiles and the original failure is gone. The compiler and runtime decide what counts as fixed, not the model and not me.

It's MIT licensed, Go-only (it leans on gopls), and Linux/WSL-only for now. This post covers the three problems that took the most time.

The shape of it


your command fails → capture the error + a file skeleton from gopls → fan out prompts to the LLM for N candidate patches → for each candidate: copy file → sandbox → build → run → keep only candidates where the original failure is gone → write the winner back to your tree 

The search over candidates is a concurrent Monte Carlo Tree Search: one goroutine per branch, channels to collect results. The rest of this post is about what happens inside that "sandbox" step and around it.

Problem 1: Docker was the bottleneck

My first version ran each candidate in a container. With a tree search, you run a lot of candidates, and container startup dominated the wall-clock time. I measured about [Y ms] per candidate with Docker.

The replacement is the re-exec pattern with Linux user namespaces. The Go runtime is multithreaded, so you can't do the classic fork-then-set-up-the-child dance. Instead the binary starts a copy of itself through /proc/self/exe with a marker argument, and that child process is created inside new namespaces:

cmd := exec.CommandContext(ctx, "/proc/self/exe", "sandbox-child", workDir) cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUSER, // [add NEWNS / NEWPID / NEWNET if you use them] UidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getuid(), Size: 1}, }, GidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getgid(), Size: 1}, }, Setpgid: true, } 

The child sees an ephemeral temp directory holding a copy of the target file, builds and runs the candidate there, and exits. This costs about [X ms] per candidate, against [Y ms] with Docker.

What this is and isn't. It contains accidents: a patch that loops forever, deletes files, or writes somewhere it shouldn't. It is not a security boundary against deliberately hostile code, and I don't claim it is. If you need that, look at gVisor or a microVM. The threat model here is a confused model, not an attacker.

A gotcha: some distros restrict unprivileged user namespaces. Recent Ubuntu releases do this through AppArmor. [Describe what heal-cli does when namespace creation fails: error message, fallback, or doc link.]

Problem 2: The model loops

Left alone, an LLM will happily go Fix A → Error B → Fix B → Error A forever, and every lap costs API tokens.

After each sandbox run I normalize the failure output and hash it:

var ( hexAddr = regexp.MustCompile(`0x[0-9a-f]+`) goroutine = regexp.MustCompile(`goroutine \d+`) waitTime = regexp.MustCompile(`\d+ (minutes|seconds)`) // [your other normalizers: tmp dir paths, +0x offsets, etc.] ) func fingerprint(trace string) string { t := hexAddr.ReplaceAllString(trace, "0x?") t = goroutine.ReplaceAllString(t, "goroutine N") t = waitTime.ReplaceAllString(t, "N units") sum := sha256.Sum256([]byte(t)) return hex.EncodeToString(sum[:]) } 

If a fingerprint repeats along the same search path, that branch is pruned. SHA-256 is just a convenient fingerprint here, nothing cryptographic. The limitation is that it only catches exact-state cycles. Two failures with different text but the same underlying cause look different to it. Even so, it cut most of the wasted spend in my test runs [number or anecdote].

Problem 3: A killed process leaves nothing to learn from

Candidates that hang get killed after a timeout:

ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // whole process group } cmd.WaitDelay = 2 * time.Second 

The problem is that SIGKILL leaves no stack trace. The search had no failure text to feed back to the model, so it stalled. My fix is a synthetic trace for timed-out runs. It contains [describe: the timeout, the tail of the output, the command, whatever you include]. A hang is now just another failure the search can learn from.

Context: why gopls

Raw error text is a thin signal. I run gopls as a headless JSON-RPC daemon and extract the file skeleton (structs, interfaces, signatures) into the prompt. The goal is to nudge the model toward the root cause instead of wrapping the panic in a recover(). It doesn't always work.

What I'm least sure about

The MCTS reward function. Right now a candidate scores [compiles +A, original error gone +B, tests pass +C, diff-size penalty], and I hand-tuned the weights on [N] cases. The failure mode I keep hitting is [e.g. it prefers patches that make the error disappear by deleting the code that raised it].

If you've designed reward signals over LLM output, how would you make this harder to game?

Try it

# 1. Install heal-cli go install github.com/sainathmanda7/heal-cli@latest # 2. Install the gopls language server go install golang.org/x/tools/gopls@latest 

You bring your own OpenRouter/OpenAI key, so the API cost is yours.

I'd like feedback on three things: the reward function, what escapes the sandbox, and how you'd map this idea to macOS, which has no namespaces.

Repo: https://github.com/sainathmanda7/heal-cli