Перейти к основному содержимому

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) уже собирает warning DL3018 («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.