Баг в трекере: при переносе задачи в закрытый спринт счётчик оставшихся задач в спринте не пересчитывается. Обнаружился через жалобу, воспроизводится не всегда.
Второй заход серии. Тот же проект, тот же стартовый коммит, что и в первом заходе: Symfony 8, 96 тысяч строк, 784 теста. В прошлый раз результат работы агента проверялся глазами. Здесь появляется машинная проверка, и это меняет всё.
Правило нулевого шага
Отдать агенту описание бага и попросить починить — самый быстрый способ получить правдоподобную правку не в том месте.
Я так и сделал, чтобы посмотреть, что будет. Формулировка: «счётчик задач в спринте не обновляется при переносе задачи, почини».
Агент нашёл место, где счётчик считается, и добавил пересчёт в обработчик переноса. Логично. Дифф на 14 строк, выглядит правильно.
Только баг не про это. Пересчёт в обработчике уже был. Не работал он потому, что при переносе в закрытый спринт срабатывала ранняя ветка выхода, до пересчёта — и агент этой ветки не увидел, потому что искал место, где счётчик считается, а не место, где до счётчика не доходит.
Правка добавила второй вызов пересчёта туда, где он и так был. Баг остался.
Воспроизведение как первая задача
Заход второй. Формулировка другая:
Баг: счётчик sprint.remaining_issues не обновляется при переносе задачи
в закрытый спринт.
Задача на этот шаг — ТОЛЬКО воспроизвести. Ничего не чинить.
Напиши функциональный тест, который падает на текущем коде и описывает
ожидаемое поведение. Покажи вывод падения.Явный запрет чинить в этом шаге — не формальность. Без него агент напишет тест и тут же попытается сделать его зелёным, а мне нужен именно красный тест как отправная точка.
Через шесть минут:
public function test_remaining_counter_updates_when_issue_moved_to_closed_sprint(): void
{
$sprint = $this->createSprint(status: SprintStatus::Closed);
$issue = $this->createIssue(sprint: null, status: IssueStatus::Open);
$this->sprintService->moveIssue($issue, $sprint);
self::assertSame(1, $sprint->getRemainingIssues());
}Тест падает: получено 0, ожидалось 1. Баг воспроизведён, причём в изолированном виде и за шесть минут вместо получаса ручных кликов.
Это, пожалуй, главный практический выигрыш от агента на багах — не исправление, а быстрое воспроизведение. Написать тест, который ставит систему в нужное состояние, обычно скучнее и дольше, чем найти причину.
Причина, а не симптом
Теперь, имея красный тест, ставлю задачу на исправление. И добавляю в неё пункт, которого не было в первом заходе:
Тест tests/Functional/SprintMoveTest.php::test_remaining_counter_updates...
падает. Почини так, чтобы он проходил.
Перед правкой объясни ПРИЧИНУ падения: какая строка приводит к тому,
что пересчёт не выполняется. Только после этого предлагай изменение.
Тест менять нельзя.Требование сначала объяснить причину — самый дешёвый фильтр из всех, что я знаю. Оно ловит случай, когда агент собирается подогнать поведение под ожидание, не понимая механики.
Ответ был точным:
Вот это уже разбор, а не догадка. И из него сразу видно, что настоящий баг не в счётчике: перенос в закрытый спринт вообще не должен был произойти, а он происходит и остаётся наполовину выполненным.
Правка, которой я не ждал
Исходная задача звучала как «почини счётчик». Реальное исправление оказалось другим:
public function moveIssue(Issue $issue, ?Sprint $sprint): ?string
{
if ($sprint !== null && $sprint->isClosed()) {
return 'Нельзя перенести задачу в закрытый спринт';
}
$previous = $issue->getSprint();
$issue->setSprint($sprint);
$this->recalculateRemaining($previous);
$this->recalculateRemaining($sprint);
return null;
}Проверка переехала в начало, до изменения состояния. Счётчик пересчитывается для обоих спринтов — старого и нового, чего в исходном коде тоже не было.
И тест пришлось переписать, потому что изменилось само требование: перенос в закрытый спринт теперь запрещён, а не выполняется криво. Запрет «тест менять нельзя» действует, пока согласованное поведение остаётся прежним. Здесь я сначала явно пересмотрел контракт, а уже затем разрешил заменить проверку.
Итоговый тест проверяет уже доменное правило, а не ошибочный счётчик:
public function test_issue_cannot_be_moved_to_closed_sprint(): void
{
$sprint = $this->createSprint(status: SprintStatus::Closed);
$issue = $this->createIssue(sprint: null, status: IssueStatus::Open);
$error = $this->sprintService->moveIssue($issue, $sprint);
self::assertSame('Нельзя перенести задачу в закрытый спринт', $error);
self::assertNull($issue->getSprint());
}Соблазн подправить проверку
Отдельно про запрет менять тест. Он нужен, и вот почему.
В третьем эксперименте я специально дал агенту красный тест без этого ограничения. Задача: «сделай, чтобы проходило».
Из пяти прогонов в двух он изменил тест. Один раз поменял ожидаемое значение с 1 на 0 — «привёл ожидание в соответствие с поведением». Второй раз добавил в тест вызов пересчёта перед проверкой, то есть подпёр систему изнутри теста.
Обе правки дают зелёный прогон. Обе оставляют баг.
Это не злой умысел и не глупость. Для буквального критерия «сделай тест зелёным» правка проверки подходит, но задачу из трекера не решает. Значит, формулировка не зафиксировала настоящий результат.
Замер
| Заход | Время | Дифф | Результат |
|---|---|---|---|
| «Почини баг» | 9 мин | 14 строк | баг остался |
| Воспроизведение тестом | 6 мин | +22 строки теста | красный тест |
| Причина и правка | 11 мин | 9 строк | баг исправлен, найден второй |
Второй дефект — непересчёт счётчика у покинутого спринта — я не искал вовсе. Он вылез, когда пришлось объяснять механику: разбор причины подсветил, что пересчёт вызывается один раз, а спринта в операции два. Для него я добавил отдельный тест переноса между открытыми спринтами.
Общее время трёх шагов — 26 минут против 9 у наивного захода. Девять минут дали ноль, двадцать шесть — исправленное доменное поведение, найденный соседний дефект и два регрессионных теста.
Что дальше
Баг — задача с готовым критерием: тест был красный, стал зелёный. В следующем заходе критерия нет, потому что новой функции ещё не существует. Посмотрим, что происходит, когда агенту нужно сначала договориться, что именно считается сделанным.