Як змусити Claude Code запускати Go-тести і не казати «готово», поки вони червоні
Агент пише: «Готово, всі тести проходять». Відкриваю термінал — три червоні пакети. Наступного разу тести зелені, але він їх не запускав. Ще наступного запускав тільки unit, а integration потребують Mongo, і локально їх не ганяв ніхто. В одному робочому репозиторії CI так простояв червоним три коміти поспіль. Це записано прямо в його Makefile, як попередження наступникам.
Я хотів одного: щоб тести йшли самі, поки агент редагує, а на момент «готово» вердикт уже був.
Моє рішення — cotest (continuous testing), набір хуків для Claude Code. Код: github.com/tggo/cotest. Ставиться однією командою, нижче — справжній вивід на лабораторному репозиторії з 4 пакетів.
$ 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.
Чому хуки Claude Code, а не власний AI-харнес?
Спершу хочеться звинуватити модель: вчора працювало, сьогодні ні. Але Claude Code під капотом змінюється без попередження, і на це я не впливаю. Свій харнес з нуля цього не вирішує: модель та сама.
Контролюю я хуки, кеш і перевірки. Тому cotest — це три хуки й два bash-скрипти, які кладуться в будь-який Go-репозиторій.
Як агент дізнається, що тести вже пройшли?
Спершу я думав, що проблема в подвійній роботі: воркер тестує у фоні, агент запускає те саме вручну. Насправді гірше: агент не має як дізнатися, що тести вже пройшли на цих самих файлах.
Через чексуму. Ключ кешу пакета — хеш його файлів плюс файлів усіх його залежностей усередині модуля. Змінив один домен — перезапускаються він і ті, хто його імпортує. Решта відповідає з кешу.
== 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
Звіт — по тестових файлах, бо «пакет червоний» не каже, де шукати. Логи лежать у .claude/battery/status/<рівень>/<пакет>.log, у .gitignore.
Хеш береться з git: тимчасовий індекс, add -A, write-tree. Тому нові, ще не закомічені файли теж рахуються. Результати зберігаються по хешу, тож відкат правки нічого не перезапускає: такий стан уже бачили.
Чому тести запускались двічі?
Агент дописує файл за два проходи. Перший запис — незавершений код, і тест, що стартував одразу, впаде на півфайлі. Тому воркер чекає 2 секунди тиші.
Але тоді Stop, що прийшов усередині цих 2 секунд, бачив «нічого не біжить» і запускав увесь набір сам. Через секунду прокидався воркер і запускав його вдруге.
- причина
- вікно debounce
- докази
- два однакові прогони в
runs.jsonlз різницею в секунду - рішення
- маркер
pending: воркер ставить його до сну, Stop бачить і чекає
Як не лишати сиріт go test після скасування прогону?
Агент редагує далі — попередній прогін застарів, його треба вбити. Я вбивав процес воркера, а go test під ним разом із тест-бінарниками жили далі й доїдали CPU. Рішення — kill_tree: обійти дерево процесів і вбити все знизу вгору. На macOS немає setsid, тож інакше ніяк.
Ще один наслідок виліз пізніше. Убиті пакети записувались у журнал як ok=NO 0s, наче впали, і детектор нестабільних тестів збирав хибні сліди. Тепер прогін перед записом перевіряє, чи його не витіснили, і якщо так — нічого не пише.
Чому правки через Bash не запускали тести?
Тест на живому агенті: він змінює код, Stop зелений. Тільки тести не запускались.
Агент редагував файли через Bash — cat <<EOF > file.go. Хук слухав Write|Edit|MultiEdit, Bash до списку не входив.
було
"matcher": "Write|Edit|MultiEdit"
агент: cat <<EOF > store.go
хук: тиша
Stop: зелений зі старого кешу
стало
"matcher": "Write|Edit|MultiEdit|Bash"
агент: cat <<EOF > store.go
хук: kick → worker
Stop: червоний, якщо зламав
Заодно знайшлася ще одна: хук, який запускає фоновий процес і не закриває всі дескриптори, вішає Claude Code, поки фон не закінчиться. Тому stdin/stdout/stderr воркера йдуть у /dev/null і лог.
Як кеш тестів враховує testdata/?
Змінив фікстуру в testdata/ — тест повертає старий зелений результат з кешу. testdata не Go-пакет, go list про нього не знає.
Рішення: кожен файл належить найближчому каталогу-пакету вгору по дереву. internal/link/testdata/big.json — частина internal/link. Для того, що лежить поза пакетами (міграції, compose-файли), є BATTERY_GLOBAL у конфігу: зміна там інвалідує все.
Як перевірити, що тести справді покривають новий код?
Тут я поставив собі незручне питання: працює — добре, а чи корисно?
Маленький проєкт, паралельна сесія агента робить рефакторинг. Кожен Stop зелений. Потім я рахую, які з нових рядків виконує хоч один тест. Нові файли — control.go, dial.go, tunnel.go, черга браузерів — не виконує жоден. cotest весь день чесно казав «все ок», бо тестів на новий код просто не було.
Тому у версії 1.1 Stop після зелених тестів робить ще одну перевірку: кожен змінений рядок (порівняно з HEAD, разом із новими файлами) має виконуватися хоч одним unit- або integration-тестом. Ось як це виглядає на лабораторному репо: додаю метод Count() без тесту.
editagentstore.go: + func (s *Store) Count() int
kickhookPostToolUse → worker
done?agent«готово»
stoptestsgreen
stopcoverinternal/link/store/store.go: 82-85 не виконується жодним тестом
editagentstore_test.go: + TestCount
stoptestsgreen, lines covered → done · 5.4 s
Повідомлення, з яким Stop повертає агента, дослівно:
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.Перший аудит, до речі, збрехав: сказав «все покрито». Результати першої версії лежали в кеші без зрізів покриття, і про непокриті блоки просто не було звідки знати. Тепер результат без файлу покриття вважається застарілим.
Як не дати агенту вимкнути перевірки?
Якщо в конфігу є BATTERY_COVER=off, агент, якого тричі повернули, рано чи пізно його знайде і поставить. Він не злий, він хоче завершити задачу.
Тому охоронець блокує запис у файли самого cotest: battery.conf, battery.sh, guard.sh, settings.json, test.sh. Їх змінює лише людина.
Перша версія перестаралась: бачила в команді test.sh і > — і блокувала. Під роздачу пішов звичайний ./test.sh 2>&1. Скаргу написала сама агентська сесія з сусіднього проєкту. Тепер блокується тільки запис у захищений файл. Справжні рішення охоронця:
./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
Перший фікс пропускав echo x > test.sh: регекс чекав / перед іменем файлу. Тепер перевірено на 8 випадках.
Як cotest працює на репозиторії з 223 пакетами?
Лабораторія — 4 пакети. Справжня перевірка — робочий репозиторій на 223 пакети з Mongo в integration-тестах і -race.
Перша версія запускала окремий go test на кожен пакет — на 223 пакетах це 223 процеси одночасно. Тепер один go test -json на рівень і потоковий розбирач на perl, який закриває кожен пакет, щойно прийшла його остання подія.
Друга знахідка: пакет з ідентифікаторами імпортують ~200 інших. Будь-яка правка в ньому перезапускала ~410 пакет-рівнів, і Stop чекав 2,5 хвилини integration-тестів усіх залежних. Тепер Stop чекає integration лише пакетів, чиї файли змінились. Unit і lint і далі перевіряють усіх залежних, а решту integration воркер дотестовує у фоні.
І ще: battery.conf експортує адресу Mongo і змінну, яка вимагає integration-тестів. Без Mongo вони тепер падають, а не тихо пропускаються. Це той самий сценарій, через який CI стояв червоним три коміти.
У якому порядку з’являлись рішення?
- фоновий воркер + кеш по хешу дерева
- подвійний прогін у debounce → маркер
pending - сироти
go test→kill_tree - Bash-правки невидимі →
Bashу matcher testdata/поза кешем → файл належить найближчому пакету- інсталятор, перший проєкт, self-check зелений
- 223 пакети: один
go testна рівень, Stop чекає лише змінені - гейт покриття змінених рядків
- охоронець власних файлів, потім його виправлення
- v1.1 у трьох репозиторіях, код на GitHub
- база покриття на сесію, перезапуск упалих тестів,
BATTERY_RUN_ID, бенчмарки
Усе це — один день, 25 жовтня 2026.
Наскільки це швидко?
Які обмеження cotest вдалося закрити?
Після першої версії лишився список із шести обмежень. Чотири з них закрив того ж вечора, ще одне частково. Кожне перевірив на копії лабораторного репозиторію, вивід нижче звідти.
- було
- покриття рахувалось від HEAD: рядки, які агент закомітив посеред сесії, вже не перевірялись
- стало
- перша правка після зеленого Stop записує HEAD як базу сесії в
status/session.base. Зелений Stop її скидає. Якщо історію переписали і база вже не предок HEAD — назад до HEAD
Агент дописав непротестовану функцію і закомітив її. Раніше Stop пропустив би:
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
- було
- нестабільний тест ловився лише як «ті самі файли, інший результат, ніж колись». Для unit цього майже не траплялось: результат на тих самих файлах віддає кеш
- стало
- упалі тести одразу перезапускаються один раз з
-count=1. Пройшли — у звіті рядокFLAKY. Пакет лишається червоним, але агент знає, що зламав не він
== 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
- було
- охоронець блокував і
go test -bench - стало
- бенчмарки з вимкненими тестами (
-run '^$') дозволені. Лише один-runі одинgo testу команді, інакше через;можна було б протягти звичайний прогін
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
- було
- одна спільна база для всіх integration-прогонів, а воркер,
./test.shі Stop можуть бігти одночасно - стало
- кожен прогін
go testотримуєBATTERY_RUN_ID. Тести, які створюють базу, додають його в назву. Це половина рішення: тести проєкту мусять цю змінну використати
- було
- правка пакета, який імпортують усі, — хвилини фонового CPU
- стало частково
- CPU стільки ж, але воркер спершу тестує пакети, чиї файли змінились, а залежні — другим прогоном. Stop чекає саме перших, тож відповідь приходить раніше
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
Що лишилось:
- Тільки Go: кеш тримається на
go list -depsі git-деревах. - Нестабільний тест лишає Stop червоним. Пропустити його мовчки — означало б дати агенту ще один вихід.
Як встановити cotest у свій Go-репозиторій?
git clone https://github.com/tggo/cotest
cotest/install.sh ~/code/your-go-repo
Потрібні git, go, jq, perl і python3. Після встановлення перезапустити сесії Claude Code в репозиторії. Налаштування — у .claude/battery/battery.conf: прапорці тестів (-race), змінні середовища, режим покриття block|warn|off.