ADR 0021 — Закрытие Фазы 4a: gate infra, который проверяет, и панель, которая говорит правду
- Статус: принят — критерий приёмки НЕ закрыт (см. отдельный раздел)
- Дата: 2026-07-25
- Фаза: 4a (закрытие — аудит ADR 0014)
Контекст
ADR 0014 поставил InfraAgent, gates PR для infra и панель команды.
Как и в ADR 0019 и 0020, критерий приёмки ни разу не прогонялся: не существовало
демо infra (grep по InfraAgent|open_infra_pr|handoff-infra в четырёх
демо-скриптах возвращал 0 во всех).
Аудит чтением обнаружил, что ОБЕ стороны критерия приёмки были скомпрометированы — gate QA ничего не проверял, а панель показывала неверное состояние для большинства агентов.
Что было корректно и не трогалось: запрет terminal для InfraAgent
(структурный через tool registry + политику agent_autonomy deny, с
коротким замыканием, доказанным в decide.spec.ts, и полным путём в
propose-action.use-case.spec.ts), общая машина gates, цикл
исправления без новой PR и отказ от docker build --check (в контейнере
engine нет ни CLI docker, ни смонтированного сокета — подтверждено осмотром).
Решения
1. Gate QA для infra одобрял ЛЮБОЙ Dockerfile (центральная находка)
hadolint не был установлен в контейнере engine. А
InfraGateRunner.lint_dockerfiles/1 трактует отсутствие как изящную
деградацию: возвращает {[], ["hadolint indisponível, pulado"]} → findings == []
→ veredito = "approved".
Критерий приёмки («PR с валидным Dockerfile, проходящая gates») прошёл бы без какой-либо проверки. Тот же класс, что gitleaks в ADR 0020: gate казался зелёным именно потому, что ничего не проверял, а изящная деградация — существующая по веским причинам — это скрывала.
hadolint вошёл в Dockerfile в ТОМ ЖЕ блоке, что и gitleaks, чтобы воспользоваться
curl из виртуальной группы до её удаления.
2. Compose и CI не проходили никакой проверки
lint_dockerfiles/1 фильтрует по dockerfile?/1, и был даже тест, фиксирующий,
что «без Dockerfile среди файлов одобряет, не запуская hadolint». Но
InfraAgent предлагает ТАКЖЕ docker-compose.yml и .github/workflows/ci.yml,
а формулировка требует «синтаксической проверки» PR infra.
Engine.Actions.YamlLintDetector (новый) повторяет HadolintDetector —
содержимое вместо пути, available?/0 + lint/1, .Live/.Fake, та же
деградация. Использует yamllint через pip, пользуясь python3, который уже пришёл с
semgrep: в зависимостях Elixir нет YAML-парсера, а принятый в доме шаблон
проверки — внешний инструмент с необязательным обнаружением, а не новая зависимость.
3. Серьёзность: проваливает только error — иначе gate бесполезен
Обнаружено при тестировании бинарников до написания кода, и именно это решает, пригоден ли gate:
- Совершенно разумный Dockerfile (
FROM node:24-alpine+RUN apk add --no-cache git) уже собираетwarningDL3018 («pin versions in apk add»). Если быwarningпроваливал, ни один Dockerfile, сгенерированный LLM, не прошёл бы, и InfraAgent ходил бы по кругу, пока не превысил потолок исправлений, — ровно тот цикл, который заблокировал приёмку ADR 0020. - В yamllint фильтрации по уровню было недостаточно: в профиле
relaxedон классифицируетnew-line-at-end-of-fileкак[error], а YAML, сгенерированный LLM, редко заканчивается переводом строки. Выходом стал запуск с пустыми правилами (-d "{rules: {}}"): без единого активного правила yamllint сообщает только о сбое РАЗБОРА, а это и есть «синтаксическая проверка». Проверено: валидный YAML без финального перевода строки выходит чистым, незакрытыйports: [выдаёт[error] syntax error (syntax).
Неблокирующие находки остаются в заключении как информация — резюме различает «N achado(s)» и «N aviso(s) não bloqueante(s)», чтобы заключение не намекало, что стилистическая придирка заблокировала PR.
4. agent.status никогда не сохранялся — 4 агента застряли в «ocioso»
В engine agent.status существовал только как EngineWeb.Endpoint.broadcast. Но
deriveAgentRoster читает agent.status из event log, полученного по HTTP, — поэтому
Criativo, PO, Arquiteto и Infra отображались постоянно «ocioso»,
даже посреди хода. Хуже: обработчик onAgentStatus в web
инвалидировал query, которая по построению никогда не содержала бы данных, только что
принесённых push.
Engine.Sessions.LiveBroadcast.agent_status/4 (новый) транслирует И сохраняет.
Четыре сервера стали маршрутизировать через условие broadcast/3 вместо
изменения 20 мест вызова. Сначала трансляция (это живой путь, он не
должен ждать HTTP-обмена); сбой append не роняет ход — статус —
это повествование, а не решение.
5. Панель читала ПЕРВЫЕ 200 событий сессии
listPaginated упорядочивает asc(seq) с limit. Панель команды, раздел
выполнения и лента активности навсегда выводили состояние из НАЧАЛА сессии,
как только та переваливала за 200 событий, — что реальный прогон делает
с запасом. Одно это делало недействительным «корректные состояния во время реального
выполнения».
ListPaginatedOptions.latest (opt-in) берёт с конца через базу и разворачивает
в памяти, возвращая в возрастающем порядке, чтобы не менять чтение
потребителей. nextCursor равен null: нет страницы новее последней —
кто просматривает всю сессию, продолжает использовать afterSeq, который latest игнорирует.
6. Эвристики статуса, которые никогда не говорили правды
devStatusвозвращалtrabalhandoдля всего, что не было блокировкой, даже сdev.idleв логе и даже без единого события агента: остановившийся dev вечно выглядел работающим. Теперьdev.idleи полное молчание говорятocioso.- Ни один путь не давал
aguardando— счётчик в заголовке всегда был 0. Теперь агент с ожидающимproposed_actionвходит какaguardando, что является самым информативным состоянием, которое он может иметь на панели.falhouпо-прежнему побеждает: заблокированная task — то, что пользователь должен увидеть первым.
7. AgentCard не показывал автономию, task и токены
- Переключатель автономии никогда не рендерился:
AgentCardтребуетautonomy && onAutonomyChange, аProjectOverviewTabтак и не передал обработчик.setAgentAutonomyсуществовал вapi-client.tsс Фазы 4a как мёртвый код. Кроме того,autonomyActionTypeForотображал толькоinfraиdev-*— criativo/po/arquiteto/qa/secops даже не получали prop. - У task и токенов не было ни prop, ни слота. Task уже выводилась
deriveExecutionProgress, но питала толькоExecutionSection: один и тот жеdev-<modulo>появлялся на экране дважды, один раз с данными, другой без. - Токенов по агенту не было в backend. У
token_usageестьsession_id/actor_id/cost_micros, но порт выставлял толькоsumBySessionAndActorIds, потребляемый лишь стоимостью Psicólogo, и не было маршрута. Новые:sumBySessionGroupedByActor,GetSessionTokenUsageUseCaseиGET projects/:id/sessions/:id/token-usage. Никакого нового инструментирования —RecordLlmUsageUseCaseуже записывал для любого агента.
8. Восстановление tool call не действовало для разговорных агентов
Найдено при прогоне критерия приёмки. InfraAgent написал ПРАВИЛЬНЫЙ
propose_infra_pr, но как блок текста вместо нативного tool call —
и ход завершился пустым ответом, а агент ушёл в ocioso, ничего не
предложив. Engine.Harness.ToolCallRecovery существовал с ADR 0020 и решает
ровно это, но живёт внутри ToolLoop, а у четырёх разговорных
агентов (Criativo/PO/Arquiteto/Infra) собственный цикл — они остались
вне исправления, и никто не заметил, потому что ни один из них с тех пор не запускался
против локальной модели.
tool_calls/2 всех четырёх теперь обращается к восстановлению, когда toolCalls приходит
пустым, с привязкой к tool_specs самого агента (та же точность, что у ToolLoop:
восстанавливается только имя, являющееся реально зарегистрированным инструментом).
9. Gate QA одобрял PR infra БЕЗ ЕДИНОГО валидного файла
Вторая форма пустого одобрения, тоже проявившаяся только при прогоне: модель
выдала некорректные files — path были JSON-блобами, — поэтому ни
Dockerfile, ни YAML не был распознан, lint_dockerfiles/1 и lint_yamls/1
вернули пустой список, и вердикт вышел approved с резюме, буквально говорящим
«nenhum Dockerfile encontrado na PR nenhum YAML encontrado na PR».
PR дошла до awaiting_user, не содержа ни одного артефакта infra.
Утверждение демо это поймало (требуя Dockerfile); GATE — нет. Теперь PR
infra без единого распознаваемого файла — это changes_requested, с пунктом,
объясняющим ожидаемый формат path/content. Непроверяемое — не то же самое,
что валидное — а существовавший тест ("sem Dockerfile entre os arquivos, aprova sem rodar hadolint") закреплял ровно этот дефект.
10. Лента не описывала фазу выполнения
classifyEvent покрывал заключения, gates и infra, но события, которые формулировка
перечисляет поимённо, попадали в обобщённую ветвь:
backlog.task_claimed→ «atualizou o backlog», без указания КАКОЙ task- PR от dev → api записывает
action.pr_open, который попадал в ветвьaction.*и описывался как «executou um comando» вместе с любым terminal backlog.task_blocked/task_unblocked/task_status_changed→ обобщённо
11. Абсолютный путь ронял PR, а осиротевшая ветка блокировала сессию
Два связанных дефекта, оба наши, найденные при прогоне:
- Модель выбрала
path: "/api/Dockerfile".commitFilesлокального provider передаёт путь напрямую вgit update-index --cacheinfo, который отклоняет абсолютный путь — вся PR умирала с непрозрачнымCommand failed: git ... update-index.normalizeInfraPath(новая, чистая, протестированная) убирает слэш слева (однозначное намерение «корень репозитория») и отклоняет обход (..) с ясным сообщением: у содержимого от LLM никогда нет законной причины писать за пределами репозитория. - Поскольку первая попытка падала ПОСЛЕ
createBranchи ДО записи артефакта (он рождается только при успехе), каждая следующая попытка снова попадала вcreateBranchи умирала с «branch já existe» — сессия становилась навсегда неспособной открыть PR infra. Пять таких попыток подряд в логе.createBranchтеперь терпит существующую ветку.
У ExecuteInfraPrUseCase не было ни одного теста; он получил шесть.
Состояние критерия приёмки: НЕ ЗАКРЫТ
Четыре прогона, ни один не завершился. Первые три упали из-за наших дефектов (пункты 8, 9 и 11) — и поэтому стоило настаивать: каждый прогон оплатил реальный дефект, который не нашло ни одно чтение.
Четвёртый упал из-за ограничения модели: qwen2.5-coder:7b выдал
propose_infra_pr с некорректным JSON (несбалансированные кавычки) и бессмысленным
содержимым (node_modules/@express·require как содержимое Dockerfile).
Восстановление tool call правильно вернуло пустоту — оно отказывается угадывать
по сломанному JSON, и этот отказ — правильное поведение.
Задача InfraAgent тяжелее, чем у DevAgent: три типа артефактов (Dockerfile на модуль + compose + CI), с реальным содержимым, в одном вызове инструмента с вложенным payload. Локальная 7B не выдерживает этого надёжно.
Чего не хватает: направить DEMO_MODEL демо infra на модель через API.
Тот же вывод, что ADR 0020 сделал о семантическом gate QA, а binding по
агенту уже существует для этого.
Что, несмотря на это, ПРОВЕРЕНО реальным выполнением:
agent.statusсохраняется (working/idleв event log — пункт 4)- восстановление tool call в разговорных агентах работает (пункт 8):
в одном из прогонов InfraAgent предложил PR, и оба gate отработали до
awaiting_user - gates infra срабатывают по порядку и регистрируют заключение с
prActionId - gate отклоняет PR без валидного артефакта (пункт 9), а hadolint/yamllint установлены и отвечают в контейнере
Последствия
- Наборы тестов: engine 237, api 445, web 72 (базовая линия 227/433/47).
- Тесты:
hadolint_detector_testиyamllint_detector_testпротив реальных БИНАРНИКОВ (теги:hadolint/:yamllint, автоматически исключаемые при отсутствии — тот же механизм, что у:gitleaks), закрепляющие, что сломанный синтаксис проваливает, а стилистическая придирка — нет;session-event-latest.repository(первые × последние);token-usage.repository(агрегация по актору);agent-status.test.tsиactivity.test.ts— уderiveAgentRosterиclassifyEventне было НИ ОДНОГО теста, а это ровно пункты 3 и 4 формулировки;AgentCard.test.tsx(пять полей + переключатель). apps/api/scripts/demo-infra-agent.ts(pnpm --filter api demo:infra-agent) — исполняемый критерий приёмки. Он явно падает, если резюме заключения говорит «hadolint indisponível» или «yamllint indisponível» — gate, который одобряет без проверки, не считается одобрением.
Объём и допущения
docker build --check по-прежнему вне (нет ни CLI docker, ни сокета в контейнере
engine) — формулировка допускает это словами «quando disponíveis no container».
Канал Phoenix остаётся ТРИГГЕРОМ повторной загрузки, а не источником данных: payload
event.appended отбрасывается, а экран обновляет инвалидация
query. С теперь сохраняемым agent.status данные, о которых объявляет push, действительно существуют
в логе — а это и было сломанным звеном. Ретранслятор, использующий payload напрямую,
по-прежнему вне объёма.
Статус dev/qa/secops остаётся эвристическим по event log (best-effort, никогда не используется решением gate). Демо не детерминировано: InfraAgent генерирует файлы через LLM, и требуется PR с Dockerfile и двумя заключениями — а не то, чтобы модель написала конкретный Dockerfile.