November 4

Полгода с LLM-агентами в монорепо из 95 PHP-пакетов. Полный разбор: что сработало, что взорвалось, что пришлось строить

У меня в монорепо ~95 библиотек rasuvaeff/* — спецификации, resilience-примитивы, тестирующие фреймворки, пачка интеграций для Yii3. PHP на хосте нет вообще: всё исполняется в Docker через образ composer:2. Пишут, чинят и релизят это хозяйство в основном LLM-агенты (Claude Code, OpenCode) — я занимаюсь тем, что проектирую правила и ревьюлю. Полгода назад это выглядело как эксперимент. Сейчас это конвейер, и про него есть что рассказать — включая места, где он ломался неожиданным образом.

1. Память: главный дефицит не интеллект, а преемственность

Первая проблема проявилась не в качестве кода, а в повторяемости. Одна и та же ловушка находится агентом заново — каждый раз за полную цену, иногда с новым красивым решением, которое тоже не работает. Человек в проекте через полгода помнит, что «вот тут MySQL ругается». Агент — нет.


Родился docs/evolved-rules.md — внешняя память репозитория. Сейчас там 50 записей, и каждая — это не абстрактный совет, а разбор конкретного инцидента с датой, пакетом и рецептом. Примеры, чтобы было понятно, какого уровня это вещи:


• ER-001: якорь \z вместо $ в regex-валидации идентификаторов — PCRE $ матчится перед trailing newline, и невалидная строка с \n на конце проходила whitelist. Классика, но пока не наступишь — не узнаешь.


• ER-049: литеральный DEFAULT на TEXT-колонке. MySQL и MariaDB отвергают его ошибкой 1101, migrate:up тихо возвращает ноль созданных таблиц — а SQLite и PostgreSQL тот же DDL съедают. В CI один SQLite, поэтому всё зелёное, прод — без схемы. Правило теперь: пакет с миграцией обязан нести кросс-БД тест и матричную джобу с MySQL и PostgreSQL service containers.


• ER-044: setSourceNamespaces() для миграций молча не находил ничего в девяти *-db пакетах — PSR-4-матч по строковому префиксу уводил в пакет-ядро. migrate:up возвращал 0, не создав ни одной таблицы, без единой ошибки. Лечилось upstream-PR в yiisoft/db-migration (наш #350), до этого — только знание ловушки спасало.


Формат записи важен: не «проверяйте якоря», а «вот пакет, вот дата, вот симптом, вот почему CI это не поймал, вот что делать». Агент перед задачей читает корневой AGENTS.md, который тянет эти правила по теме. Сработало: повторные наступания на известные грабли практически исчезли.

2. Верификация: «должно работать» — не результат

В каждом пакетном AGENTS.md золотое правило номер один: never claim "done" without a fresh green composer build, paste the output. Это не бюрократия. Агент, который «уверен» в исправлении и не прогнал гейт, — самый дорогой тип ошибки в конвейере, потому что он выглядит как успех.


composer build — полный конвейер: validate → normalize → require-checker → cs → psalm → тесты. Релиз добавляет rector, BC-check и мутации. И это работает только потому, что гейт дешёвый: одна команда в Docker, десятки секунд на типичный пакет. Правило, которое агенты выполняют плохо, но выполняют быстро, лучше правила идеального, которое тяжело исполнить.


Отдельная категория — правила против самообмана агента. Например: @psalm-suppress и baseline запрещены на уровне конвенции, потому что suppress — это способ сказать «я знаю лучше» без доказательств. И это осознанное решение.

3. Мутационное тестирование как детектор лжи в метриках

Самая поучительная история этих месяцев. Infection показывал MSI 97% — красиво. Потом заметили расхождение в числах мутантов на одном пакете: 1169 против 2726. Причина: без флага --with-uncovered Infection мутирует только код, «покрытый» с точки зрения Testo — а Testo маппит мутант→тест через атрибут #[Covers], а не через реальное исполнение (ER-003). Из 2726 мутантов 1557 вообще не создавались. Реальный MSI был 41%, и «97%» были артефактом инструмента, а не качеством тестов.


Что построили на этом уроке:


• флаг --with-uncovered обязателен, MSI считается по общему числу мутантов;


• мутанты на дефолтах параметров в сигнатурах игнорируются через глобальный regex-фильтр — их нельзя убить тестами, они просто шумят в знаменателе (проверено на трёх пакетах: убираются ровно строки вида private int $x = 100;);


• локальный прогон мутаций обязан совпадать с CI: в CI выключен zend.assertions, \assert() в src не компилируется, его мутанты там Not Covered — а локально исполняются и убиваются. Один пакет дал 95% локально и 93% в CI, прежде чем это отследили;


• порог minMsi агент не имеет права снижать. Только владелец, только после исчерпания всех рычагов, не ниже 85, с записью причины в infection.json5. Это правило появилось потому, что иначе агент «чинит» красный мутационный гейт самым дешёвым способом — опуская планку.


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

4. Property-based тесты и корпус регрессий

Часть пакетов — алгебраические штуки (round-trip сериализаций, монотонность backoff, детерминизм hash-бакетинга), и для них example-тесты не покрывают пространство входов. Под это написано собственное семейство rasuvaeff/property-testing-*: генераторы, shrink, а для stateful-сценариев — Gen::commands() + StateMachine::check().


Интересна не библиотека, а инфраструктура вокруг неё. Когда property фальсифицируется, контрпример сохраняется в корпус на диске (PROPERTY_DB) — и должен переживать между CI-прогонами, чтобы регрессия ловилась навсегда. И тут выяснилось:


• combined actions/cache объявляет post-if: "success()" — post-шаг сохранения не выполняется на красном job'е. А писать в корпус — это ровно падающий прогон. Итого: баг найден, контрпример не сохранён, «навсегда» не работает. Лечится раздельными cache/restore и cache/save с if: !cancelled();


• при re-run прогона run_id тот же, ключ кэша занят, save молча возвращает warning «Cache already exists». Нужен run_attempt в ключе;


• GitHub-hosted раннер — каждый раз новая VM; без restore-шага корпус просто не существует в начале следующего job'а.


Три ловушки в одном механизме, каждая найдена отдельным ревью. Это, кстати, отдельный вывод: полезные фичи CI ломаются не в логике, а в склейке — deprecated-флаги, повторные прогоны, эфемерность раннеров.

5. Ошибки на границах: то, что не видит ни один локальный гейт

Самый ценный класс находок. Примеры:


• DI-конфликт между пакетами. yiisoft/config запрещает двум vendor-пакетам определять один ключ в одной группе. Ошибка существует только между пакетами — каждый пакетный тест зелёный, потому что каждый тест видит ровно один di.php. Сначала пытались имитировать merge вручную (require чужого конфига + array_intersect_key) — выяснили, что такая проверка не знает про metadata, слои и модификаторы реального merge. Написали bin/config-merge-harness: собирает одноразовое приложение с path-репозиториями, даёт config-плагину написать настоящий merge-plan и мержит группы теми же модификаторами, что и раннер.


• Циклическая ссылка из README-рецепта. yii3-filestorage-db документировал включение дедупликации через StorageInterface::class => fn (Factory $f) => ..., а фабрика сама запрашивала StorageInterface — то есть себя. Любой контейнер отвечал CircularReferenceException, при этом зелёными были и харнесс, и оба пакетных теста: каждый грузил ровно один di.php. Нашло только внешнее ревью (CodeRabbit). Мораль: если README описывает переопределение чужого DI-ключа — нужен тест, исполняющий рецепт целиком.


• Матричный CI-job и гейт по changes. Пропущенный своим if: матричный job не разворачивает матрицу: GitHub отчитывается одним check'ом с сырым именем PHP ${{ matrix.php }} вместо PHP 8.3/PHP 8.4. Если такие имена зарегистрированы в required status checks — PR виснет в «Expected — Waiting for status» навсегда.


• Пустая директория в git. benchmarks/ без файлов не хранится git'ом; локально всё зелёное (директория-то есть), свежий клон в CI падает «File or directory not found» во всех job'ах сразу.


Общая закономерность: ошибка живёт на границе — между пакетами, между машинами, между «как у меня» и «как на чистом клоне». Изнутри своей задачи агент границу не видит, и никакой локальный прогон её не покажет. Лечится только специализированными харнессами и дисциплиной «проверять рецепт из README исполнением, а не чтением».

6. Процесс: ленивая раскатка вместо больших волн

Соблазн, когда нашёл системную проблему в 76 пакетах, — починить все сразу. Мы пробовали: большая волна PR'ов — это максимальная поверхность для конфликтов, вечные ревью и выгорание на середине. Работает другое — ленивая раскатка: правило фиксируется в шаблонах (templates/), чтобы новые пакеты наследовали правильное сразу, а старые подтягивают его в первом же PR, который их трогает по другому поводу. Правка CI — issue-first не требует, поэтому довесок не раздувает PR.


Плюс жёсткое разделение: PR строго про доки (README, examples/) не должен нести в себе миграцию CI — unrelated changes смешиваются. Каждое правило в AGENTS.md имеет явное «не применять, если...».


Такие мелочи — «ровно один ## Unreleased в CHANGELOG, и выше всех версий», «тег ставится на post-merge HEAD и никогда не двигается, потому что Packagist блокирует re-tag навсегда» — по отдельности выглядят паранойей. Вместе они дают то, что конвейер из агентов за полгода не запубликовал ни одного сломанного релиза.

7. Ревью и аудит: люди всё ещё нужны, но иначе

bin/package-audit детерминированно проверяет 15+ инвариантов: обязательный набор файлов, отсутствие suppress'ов, @api на каждом публичном классе, #[Covers] на тестах, упоминание всех публичных типов в README, исполняемость примеров, структуру CHANGELOG. Он не устаёт и не пропускает. Но за эти месяцы дважды вещи, которые не видела вся автоматика, находил CodeRabbit. Вывод не «автоматизируйте всё», а не стройте систему, претендующую на полноту — стройте систему, в которой известно, что она не видит, и есть внешний слой поверх.

Итог

Агенты не заменили инженера. Они сместили работу с «писать код» на «проектировать гейты, которые плохой код не пропустят, и память, которая переживает контекст сессии». Конкретно у меня это:

  1. AGENTS.md как конституция с датированными кейсами, а не как welcome-README.
  2. Один дешёвый обязательный гейт, лучше десяти необязательных.
  3. Метрики, которые агент может только достигать, но не «настраивать».
  4. Харнессы для ошибок на границах — то, что не ловится локально в принципе.
  5. Ленивая раскатка вместо волн рефакторинга.
  6. Явная карта слепых зон + внешний ревью поверх.


И честно: это самая интересная инженерия, которой я занимался за последнее время. Не потому что код стал сложнее — потому что «как сделать, чтобы система с LLM внутри была надёжной» — это задача про архитектуру стимулов и памяти, а не про код.