almdev Технический блог

Агент чинит баг, который я сам не воспроизвёл
Падающий тест как контракт и три попытки исправить симптом вместо причины.

Второй заход лаборатории: почему воспроизведение до правки обязательно, как падающий тест меняет поведение агента и что делать с его склонностью подправить проверку вместо кода.

Подкатегория: Лаборатория

Чтение
5 мин
Технологии / версии
Symfony · Coding agent · PHP · Лаборатория
Серия
Лаборатория · часть 2 из 9
11 июн 2026 · 5 мин · 4 просмотра · AI
Ремонтный аппарат меняет механизм до подтверждения неисправности
Лаборатория 06/2026

Баг в трекере: при переносе задачи в закрытый спринт счётчик оставшихся задач в спринте не пересчитывается. Обнаружился через жалобу, воспроизводится не всегда.

Второй заход серии. Тот же проект, тот же стартовый коммит, что и в первом заходе: 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 у наивного захода. Девять минут дали ноль, двадцать шесть — исправленное доменное поведение, найденный соседний дефект и два регрессионных теста.

Что дальше

Баг — задача с готовым критерием: тест был красный, стал зелёный. В следующем заходе критерия нет, потому что новой функции ещё не существует. Посмотрим, что происходит, когда агенту нужно сначала договориться, что именно считается сделанным.

Серия

Лаборатория

#symfony #coding-agent #php #lab #testy