Як змусити Claude Code запускати Go-тести і не казати «готово», поки вони червоні

10 хв читання

Агент пише: «Готово, всі тести проходять». Відкриваю термінал — три червоні пакети. Наступного разу тести зелені, але він їх не запускав. Ще наступного запускав тільки unit, а integration потребують Mongo, і локально їх не ганяв ніхто. В одному робочому репозиторії CI так простояв червоним три коміти поспіль. Це записано прямо в його Makefile, як попередження наступникам.

Я хотів одного: щоб тести йшли самі, поки агент редагує, а на момент «готово» вердикт уже був.

Моє рішення — cotest (continuous testing), набір хуків для Claude Code. Код: github.com/tggo/cotest. Ставиться однією командою, нижче — справжній вивід на лабораторному репозиторії з 4 пакетів.

install.sh
$ 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-репозиторій.

cotest · як ходять події
агентредагує код PostToolUsekick → повертається за мс worker2 с тиші · хеш · go test status/результат під хешем пакета PreToolUse · guardgo test → ./test.sh Stopзелене і покрите? готовоагент завершує з кешу «готово» червоне або непокрите → назад, до 3 разів
Агент не бачить фону. Він бачить тільки ./test.sh і відповідь Stop.

Як агент дізнається, що тести вже пройшли?

Спершу я думав, що проблема в подвійній роботі: воркер тестує у фоні, агент запускає те саме вручну. Насправді гірше: агент не має як дізнатися, що тести вже пройшли на цих самих файлах.

Через чексуму. Ключ кешу пакета — хеш його файлів плюс файлів усіх його залежностей усередині модуля. Змінив один домен — перезапускаються він і ті, хто його імпортує. Решта відповідає з кешу.

./test.sh ./internal/link/...
== 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 повертає агента, дослівно:

Stop hook feedback
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. Скаргу написала сама агентська сесія з сусіднього проєкту. Тепер блокується тільки запис у захищений файл. Справжні рішення охоронця:

guard.sh
./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.

223пакети, усі зелені на холодну
3:15холодний unit з -race
3:54integration + lint
~410пакет-рівнів на одну правку пакета з ідентифікаторами

Перша версія запускала окремий 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 стояв червоним три коміти.

У якому порядку з’являлись рішення?

  1. фоновий воркер + кеш по хешу дерева
  2. подвійний прогін у debounce → маркер pending
  3. сироти go test → kill_tree
  4. Bash-правки невидимі → Bash у matcher
  5. testdata/ поза кешем → файл належить найближчому пакету
  6. інсталятор, перший проєкт, self-check зелений
  7. 223 пакети: один go test на рівень, Stop чекає лише змінені
  8. гейт покриття змінених рядків
  9. охоронець власних файлів, потім його виправлення
  10. v1.1 у трьох репозиторіях, код на GitHub
  11. база покриття на сесію, перезапуск упалих тестів, BATTERY_RUN_ID, бенчмарки

Усе це — один день, 25 жовтня 2026.

Наскільки це швидко?

3.0 → 0.56 степлий ./test.sh по всьому, 4 пакети
0.45 схеш усіх пакетів на всіх рівнях
2 свід «готово» до зеленого Stop у реальному прогоні агента

Які обмеження cotest вдалося закрити?

Після першої версії лишився список із шести обмежень. Чотири з них закрив того ж вечора, ще одне частково. Кожне перевірив на копії лабораторного репозиторію, вивід нижче звідти.

було
покриття рахувалось від HEAD: рядки, які агент закомітив посеред сесії, вже не перевірялись
стало
перша правка після зеленого Stop записує HEAD як базу сесії в status/session.base. Зелений Stop її скидає. Якщо історію переписали і база вже не предок HEAD — назад до HEAD

Агент дописав непротестовану функцію і закомітив її. Раніше Stop пропустив би:

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. Пакет лишається червоним, але агент знає, що зламав не він
./test.sh unit ./internal/link/store
== 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 у команді, інакше через ; можна було б протягти звичайний прогін
guard.sh, код виходу
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 чекає саме перших, тож відповідь приходить раніше
battery.log: змінений пакет першим
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.

GoAI-харнеси й агентиСкрипти й автоматизація