How to make Claude Code run Go tests and not say "done" while they're red
Translated from Ukrainian.
The agent writes: “Done, all tests pass.” I open a terminal and find three red packages. Next time the tests are green, but it never ran them. The time after that it ran only unit tests. The integration tests need Mongo, and nobody ran those locally. In one work repository CI stayed red for three commits in a row. That’s written right into its Makefile as a warning to whoever comes next.
I wanted one thing: the tests run on their own while the agent edits, so by the time it says “done” the verdict is already in.
My solution is cotest (continuous testing), a set of hooks for Claude Code. Code: github.com/tggo/cotest. It installs with one command. Below is real output on a lab repository with 4 packages.
$ cotest/install.sh ~/code/lab write .claude/battery/battery.sh, guard.sh create .claude/battery/battery.conf write test.sh merge .claude/settings.json update CLAUDE.md (cotest block) check ./test.sh unit (first run, fills the cache) PASS cmd/shorty (ran, hash 7af16c6f) PASS internal/audit/log (ran, hash bb287914) PASS internal/link/httpapi (ran, hash 247a08dc) PASS internal/link/store (ran, hash 430edab4) check guard blocks direct go test done. Restart Claude sessions in ~/code/lab so they load the hooks.
Why Claude Code hooks and not my own AI harness?
The first urge is to blame the model: it worked yesterday, it doesn’t today. But Claude Code changes under the hood without warning, and I have no say in that. Writing my own harness from scratch doesn’t fix this either, because the model is the same.
What I do control is hooks, cache and checks. So cotest is three hooks and two bash scripts that drop into any Go repository.
How does the agent know the tests already passed?
At first I thought the problem was duplicated work: the worker tests in the background, and the agent runs the same thing by hand. It’s actually worse. The agent has no way to know the tests already passed on these exact files.
The answer is a checksum. A package’s cache key is the hash of its files plus the files of all its dependencies inside the module. Change one domain, and that domain reruns along with whatever imports it. Everything else answers from cache.
== unit PASS internal/link/httpapi (cached, hash 247a08dc) server_test.go PASS 2 tests 0.00s PASS internal/link/store (cached, hash 430edab4) store_test.go PASS 4 tests 0.00s == lint PASS internal/link/httpapi (ran, hash 58872f2f) go vet: no issues == integration PASS internal/link/store (ran, hash 4a1d4fe1) integration_test.go PASS 2 tests 3.00s store_test.go PASS 4 tests 0.00s
The report is per test file, because “package is red” doesn’t tell you where to look. Logs live in .claude/battery/status/<level>/<package>.log, which is in .gitignore.
The hash comes from git: a temporary index, add -A, write-tree. That way new, not-yet-committed files count too. Results are stored by hash, so reverting an edit doesn’t rerun anything, because that state has already been seen.
Why did tests run twice?
The agent writes a file in two passes. The first write is unfinished code, and a test that starts right away will fail on half a file. So the worker waits for 2 seconds of quiet.
But then a Stop that arrived inside those 2 seconds saw “nothing running” and ran the whole suite itself. A second later the worker woke up and ran it again.
- cause
- the debounce window
- evidence
- two identical runs in
runs.jsonla second apart - fix
- a
pendingmarker: the worker sets it before sleeping, Stop sees it and waits
How do you avoid orphaned go test processes after cancelling a run?
The agent keeps editing, so the previous run is stale and has to be killed. I was killing the worker process, but the go test under it and the test binaries kept running and eating CPU. The fix is kill_tree: walk the process tree and kill everything bottom-up. macOS has no setsid, so there’s no other way.
Another consequence showed up later. Killed packages were logged as ok=NO 0s, as if they had failed, and the flaky test detector collected false traces. Now a run checks whether it was superseded before writing, and if it was, it writes nothing.
Why didn’t edits through Bash trigger tests?
A test on a live agent: it changes code, Stop is green. Except the tests never ran.
The agent was editing files through Bash, with cat <<EOF > file.go. The hook listened for Write|Edit|MultiEdit, and Bash wasn’t on the list.
before
"matcher": "Write|Edit|MultiEdit"
agent: cat <<EOF > store.go
hook: silence
Stop: green from stale cache
after
"matcher": "Write|Edit|MultiEdit|Bash"
agent: cat <<EOF > store.go
hook: kick → worker
Stop: red if it broke something
Along the way I found one more: a hook that starts a background process without closing all file descriptors hangs Claude Code until the background process finishes. So the worker’s stdin/stdout/stderr go to /dev/null and the log.
How does the test cache account for testdata/?
I change a fixture in testdata/ and the test returns the old green result from cache. testdata isn’t a Go package, and go list doesn’t know about it.
The fix: every file belongs to the nearest package directory up the tree. internal/link/testdata/big.json is part of internal/link. For things that live outside packages (migrations, compose files) there’s BATTERY_GLOBAL in the config: a change there invalidates everything.
How do you check that tests actually cover the new code?
Here I asked myself an uncomfortable question: fine, it works, but is it useful?
A small project, a parallel agent session doing a refactor. Every Stop is green. Then I count which of the new lines are executed by at least one test. The new files, control.go, dial.go, tunnel.go and the browser queue, aren’t executed by any. cotest honestly said “all ok” all day, because there were simply no tests for the new code.
So in version 1.1, after green tests, Stop does one more check: every changed line (relative to HEAD, new files included) must be executed by at least one unit or integration test. Here’s how it looks on the lab repo when I add a Count() method with no test.
editagentstore.go: + func (s *Store) Count() int
kickhookPostToolUse → worker
done?agent"done"
stoptestsgreen
stopcoverinternal/link/store/store.go: 82-85 not executed by any test
editagentstore_test.go: + TestCount
stoptestsgreen, lines covered → done · 5.4 s
The message Stop sends the agent back with, verbatim:
Tests are green, but these lines you changed are not executed by any test:
internal/link/store/store.go: 82-85
Add tests that run them (./test.sh cover shows what is still uncovered). If a line
truly cannot be tested, stop and tell the human why; do not edit the battery settings.The first audit lied, by the way: it said “everything is covered”. Results from the first version sat in the cache without coverage profiles, so there was nothing to learn about uncovered blocks from. Now a result without a coverage file counts as stale.
How do you stop the agent from turning the checks off?
If the config has BATTERY_COVER=off, an agent that’s been sent back three times will sooner or later find it and set it. It isn’t malicious. It just wants to finish the task.
So the guard blocks writes to cotest’s own files: battery.conf, battery.sh, guard.sh, settings.json, test.sh. Only a human changes them.
The first version overdid it: it saw test.sh and > in a command and blocked it. A plain ./test.sh 2>&1 got caught in the crossfire. The complaint came from an agent session in a neighbouring project. Now only writes into a protected file are blocked. The guard’s real decisions:
./test.sh ./internal/link/store 2>&1 allow ./test.sh cover > /tmp/out 2>&1 allow cat test.sh allow echo x > test.sh block battery's own files sed -i 's/block/off/' .claude/battery/battery.conf block battery's own files go test ./... block → use ./test.sh
The first fix let echo x > test.sh through: the regex expected a / before the file name. It’s now checked against 8 cases.
How does cotest work on a repository with 223 packages?
The lab is 4 packages. The real test is a work repository with 223 packages, Mongo in the integration tests, and -race.
The first version ran a separate go test per package, which on 223 packages means 223 processes at once. Now it’s one go test -json per level and a streaming parser in perl that closes each package as soon as its last event arrives.
The second finding: the identifiers package is imported by ~200 others. Any edit to it reran ~410 package-levels, and Stop waited 2.5 minutes for the integration tests of every dependent. Now Stop waits for integration only on packages whose files changed. Unit and lint still check all dependents, and the worker finishes the remaining integration tests in the background.
One more thing: battery.conf exports the Mongo address and a variable that requires integration tests. Without Mongo they now fail instead of being silently skipped. That’s the same scenario that kept CI red for three commits.
In what order did the fixes come?
- background worker + cache by tree hash
- double run in debounce →
pendingmarker - orphaned
go test→kill_tree - Bash edits invisible →
Bashin the matcher testdata/outside the cache → a file belongs to the nearest package- installer, first project, self-check green
- 223 packages: one
go testper level, Stop waits only for changed ones - coverage gate on changed lines
- guard for its own files, then the fix to the guard
- v1.1 in three repositories, code on GitHub
- per-session coverage base, rerun of failed tests,
BATTERY_RUN_ID, benchmarks
All of this was one day, October 25, 2026.
How fast is it?
Which cotest limitations did I manage to close?
After the first version there was a list of six limitations. I closed four of them the same evening, and one more partly. I checked each on a copy of the lab repository, and the output below comes from there.
- before
- coverage was counted from HEAD: lines the agent committed mid-session were no longer checked
- after
- the first edit after a green Stop records HEAD as the session base in
status/session.base. A green Stop resets it. If history was rewritten and the base is no longer an ancestor of HEAD, it falls back to HEAD
The agent added an untested function and committed it. Before, Stop would have let it through:
Tests are green, but these lines you changed are not executed by any test: internal/link/store/store.go: 82-83 Add tests that run them (./test.sh cover shows what is still uncovered). If a line truly cannot be tested, stop and tell the human why; do not edit the battery settings. stop1 rc=2
- before
- a flaky test was caught only as "same files, different result than before". For unit tests this almost never happened, because the cache returns the result for the same files
- after
- failed tests are rerun once right away with
-count=1. If they pass, the report gets aFLAKYline. The package stays red, but the agent knows it wasn't what broke it
== unit FAIL internal/link/store (cached, hash 76f69faa) store_test.go PASS 4 tests 0.00s twice_test.go FAIL 3 tests 0.00s -> TestFlaky --- TestFlaky === RUN TestFlaky twice_test.go:25: first run fails --- FAIL: TestFlaky (0.00s) FLAKY: TestFlaky failed, then passed on an immediate rerun of the same files
- before
- the guard also blocked
go test -bench - after
- benchmarks with tests disabled (
-run '^$') are allowed. Only one-runand onego testper command, otherwise a regular run could be smuggled in after a;
go test -run '^$' -bench . ./x rc=0 go test -run '^$' -bench . ./x; go test ./... rc=2 go test -run '^$' -run TestA -bench . ./x rc=2 go test -bench . ./x rc=2
- before
- one shared database for all integration runs, while the worker,
./test.shand Stop can run at the same time - after
- every
go testrun gets aBATTERY_RUN_ID. Tests that create a database add it to the name. This is half the fix: the project's tests have to actually use the variable
- before
- an edit to a package everything imports meant minutes of background CPU
- partly after
- the CPU cost is the same, but the worker tests packages whose files changed first and the dependents in a second run. Stop waits for the first ones, so the answer comes sooner
18:43:35.659 [unit] internal/link/store ok=NO by=worker 0s 18:43:36.005 [unit] internal/link/httpapi ok=yes by=worker 0s 18:43:36.064 [unit] cmd/shorty ok=yes by=worker 0s
What’s left:
- Go only: the cache relies on
go list -depsand git trees. - A flaky test keeps Stop red. Letting it through silently would give the agent one more way out.
How do I install cotest in my Go repository?
git clone https://github.com/tggo/cotest
cotest/install.sh ~/code/your-go-repo
You need git, go, jq, perl and python3. After installing, restart the Claude Code sessions in the repository. Settings live in .claude/battery/battery.conf: test flags (-race), environment variables, and the coverage mode block|warn|off.