ADR 0020 — Разблокировать gates QA и SecOps: gitleaks по рабочему дереву, валидируемые заключения и tool call в тексте
- Статус: принят — критерий приёмки ЗАКРЫТ, но не детерминирован с локальной моделью (см. отдельный раздел)
- Дата: 2026-07-25
- Фаза: 4a (закрытие отклонений gates PR)
Контекст
Gates PR (ADR 0013) были полны на бумаге — машина состояний с
неизменным порядком и потолком исправлений, QAAgent с принудительным emit_qa_verdict,
детерминированный SecOpsAgent, DevAgentServer.correct/3 в той же ветке, UI с
временной шкалой, — но ни разу не прогоняли критерий приёмки. Не существовало
демо gates: demo-dev-agent-real останавливается на открытой PR.
То же движение, что в ADR 0019 для DevAgent: написать демо приёмки, прогнать с реальными LLM и сканерами и исправить то, что сломается. Аудит чтением нашёл четыре дефекта до первого прогона, один из них фатальный; десять последующих прогонов нашли ещё шесть, плюс пять проблем среды инференса.
Критерий приёмки: в task с (a) правилом без теста и (b) захардкоженным
секретом QA возвращает первое, dev исправляет, SecOps блокирует второе,
dev исправляет, и PR доходит до awaiting_user с 4 заключениями на временной шкале.
Закрыт — реальная последовательность приведена ниже.
Что больше всего бросается в глаза в этой сессии: из десяти прогонов НИ ОДИН не упал из-за дефекта в машине состояний, в принудительном применении gates или в потоке возврата. Они падали из-за сканера, просматривавшего не то, из-за невалидированного артефакта, из-за диагностики, угадывавшей причину, из-за молча обрезанного контекста, из-за простаивающего GPU и из-за инструкции подброса, которая не истекала.
Решения
1. gitleaks сканировал ИСТОРИЮ, а не рабочее дерево (фатальная находка)
Engine.Actions.GitleaksDetector.Live запускал gitleaks detect --source <worktree>. В gitleaks 8.x detect сканирует журнал коммитов.
Последствие для потока gate, подтверждённое в контейнере до какого-либо
исправления: dev коммитит секрет → SecOps отклоняет → dev удаляет секрет в
НОВОМ коммите → секрет остаётся в предыдущем коммите ветки → SecOps отклоняет
снова, на каждом круге, пока не будет превышен потолок и task не станет blocked. Никакое
исправление секрета было невозможно: критерий приёмки был недостижим.
árvore de trabalho já limpa, segredo só no commit anterior:
gitleaks detect --source -> 2 commits scanned, leaks found: 1 (reprova pra sempre)
gitleaks dir -> no leaks found (aprova, correto)
Исправлено на gitleaks dir <worktree>. Зафиксированы два последствия:
- Пин
GITLEAKS_VERSIONвdocker/engine/Dockerfileстановится несущим:dirсуществует только начиная с 8.19. Более старый бинарник возвращает код выхода вне[0, 1], что уже попадает в{:error, :scan_failed}, и gate регистрирует «пропущено» — деградирует, а не ломается. - Теперь сканируется ВСЁ дерево worktree (надмножество diff). Секрет, уже существующий в базовой ветке, проваливает каждую PR. Это правильное поведение для gate, но оно шире, чем «по diff», обещанное ADR 0013, — построчная корреляция diff↔находка по-прежнему вне объёма.
gitleaks dirсообщает АБСОЛЮТНЫЙ путь (detectсообщал относительный); детектор теперь делает его относительным, потому что путь идёт в заключение, которое читает пользователь, и в prompt исправления для dev.
2. Заключения не были валидируемыми артефактами
Формулировка требует «регистрирует заключение как артефакт», но оба gate
записывали session_event artifact.qa_verdict/artifact.secops_verdict СЫРЫМ,
не пропуская через Engine.Harness.ArtifactSchemas, — то же отклонение, которое ADR 0019
исправил для task_blocked.
- Оба типа входят в
@schemas, вне@tool_emittable: они server-emitted (у SecOps даже нет LLM; заключение QA рождается из toolemit_qa_verdict, принудительно применяемой отдельно). - Субъект заключения — не фиксированный обязательный ключ: gate dev использует
taskId, аInfraGateRunnerиспользуетprActionId, в ОДНОМ и том же типе артефакта.check_extra/2требует ровно одного из двух — никогда обоих, никогда ни одного. UI уже обрабатывал оба структурно (GateSubject). vereditoвалидируется по значениям машины состояний api: вердикт внеapproved/changes_requestedобрушил быRecordGateVerdictUseCase, поэтому лучше отклонить артефакт.Engine.Harness.ArtifactEmitter(новый) сосредотачивает «валидирует и только затем записывает + транслирует».AgentIo.emit_artifact/3теперь делегирует; три серверных эмиттера (dev, gates dev, gate infra) используют один и тот же путь.
3. Сканер мог подвесить gate
run_scanner/3 — дублированный байт в байт между SecOpsAgentServer и
InfraGateRunner — вызывал System.cmd синхронно внутри handle_cast,
без таймаута. Зависший в сети semgrep --config auto заморозил бы gate
всего проекта, без диагностики.
Engine.Gates.Scanner (новый) объединяет оба и применяет идиому
Engine.Actions.TerminalExecutor.execute/3: Task.async + Task.yield +
Task.shutdown(:brutal_kill), потолок в SECOPS_SCAN_TIMEOUT_MS (по умолчанию 180 с,
намного свободнее, чем у terminal, потому что semgrep сканирует дерево и загружает правила
из сети). Таймаут переиспользует уже существовавший путь «пропущено».
Тест таймаута нашёл регрессию, которую внесло само исправление:
Task.async СВЯЗЫВАЕТ task с вызывающим, поэтому детектор, бросивший исключение,
обрушил бы GenServer gate — хуже, чем исходный синхронный вызов. Вызов
детектора заключён в try/rescue/catch ВНУТРИ task и превращается в
возвращаемое значение.
semgrep также получил --metrics=off и исключения node_modules/.git.
--config auto по-прежнему зависит от сети; без неё gate регистрирует «пропущено» —
приёмка зависит только от gitleaks.
4. QA без потолка токенов и сжигающий круг из потолка исправлений
QaAgentServerне передавалtoken_budget_microsв ToolLoop (DevAgent передаёт). Gate запускается заново на КАЖДОМ исправлении, поэтому стоимость умножается без какого-либо потолка. Теперь используется тот жеtask_budget_micros, что и у dev, который уже читался из базы тем же путём, что иmax_gate_corrections.- QA, не дошедший до заключения (лимит итераций, бюджет, остановившаяся модель),
превращался в
changes_requested. Это возвращало dev — которому нечего было исправлять — и сжигало одно из K исправлений; при повторном невезении сломанный QA блокировал идеальную task, а зарегистрированное заключение обвиняло dev. Теперь task блокируется напрямую, с истинной причиной и без траты исправления, с различением трёх исходов (и с использованиемctx.last_error, чтобы отделить «модель остановилась» от «provider упал», как ADR 0019 сделал для dev).
5. Локальная модель выдаёт tool call как ТЕКСТ (находка прогона)
В первом прогоне приёмки qwen2.5-coder:7b выполнил ровно ту
работу, что нужно, — оба write_file и правильный terminal, с подброшенным секретом
и отсутствующим тестом, — но выдал всё как блок ```json в
content вместо использования нативного протокола tool calling. ToolLoop видел
пустой toolCalls, завершался с {:ok, ctx}, и task умирала в «parou sem
concluir nem reportar bloqueio». Gate так и не открывался.
Engine.Harness.ToolCallRecovery (новый) вызывается только когда toolCalls
пришёл пустым — модель, делающая настоящий tool call, туда никогда не попадает. Он извлекает
JSON-объекты верхнего уровня из текста (сканирование с подсчётом скобок, с учётом
строк и экранирования: модель выдаёт несколько объектов, склеенных в ОДНОМ блоке, что
не является валидным JSON-документом).
Фильтр того, что считается tool call, был ужесточён во время самого прогона:
первая версия принимала любой объект с name + arguments, и реальный QA
выдал {"name": "enviar(payload)", "parameters": {...}} — галлюцинируя
вызов БИЗНЕС-функции, которую он проверял. Теперь name должен быть
в реестре инструментов цикла (ctx.tool_specs). С привязанным именем
parameters удалось принять как синоним arguments, не открывая дверь для
произвольного JSON. Это не парсер естественного языка: если модель просто разговаривала,
результат — [], и цикл завершается, как раньше.
6. Ошибка provider В ТЕЛЕ ответа превращалась в «модель остановилась»
ADR 0019 заставил ToolLoop сохранять ctx.last_error, когда llm_turn возвращает
{:error, _}. Но api отвечает 200 с error в теле, когда provider
падает, — только сломанный транспорт становится {:error, _}. На этом пути last_error
оставался nil, и потребитель {:ok, ctx} диагностировал «o modelo parou sem
sinalizar» для сбоя инфраструктуры.
Найдено худшим из возможных способов: gate QA умер с fetch failed в Ollama, и
диагностика, записанная в event log, сказала «o modelo parou sem chamar
emit_qa_verdict» — система обвинила модель в падении provider. loop/1
теперь регистрирует и error из тела.
7. Prompt QA как явный протокол
initial_message QA был разрозненным абзацем, тогда как у DevAgent есть
AGENTS.md репозитория, направляющий каждый шаг. Он стал нумерованным сценарием:
правила story перечислены по одному, какой инструмент использовать на каждом шаге, одна
строка coverageMatrix на правило, явно сформулированный критерий approved и
указание никогда не вызывать функции проверяемого кода.
8. fetch без настраиваемого таймаута в provider Ollama
ollama-provider.ts использовал fetch, у которого headersTimeout в undici — 300 с
ФИКСИРОВАННО — настраивается только передачей собственного dispatcher, что потребовало бы
зависимости undici. На практике LLM_TURN_TIMEOUT_MS в engine ничего
не значил: api сдавалась раньше, с непрозрачным fetch failed, а агент регистрировал
«o modelo parou» для запроса, на который так и не ответили.
Заменено на node:http (без новой зависимости), с OLLAMA_REQUEST_TIMEOUT_MS
(по умолчанию 300000, без изменения поведения). Семантика тоже улучшилась: это
потолок НЕАКТИВНОСТИ сокета, а не общей длительности — законный ход может
минутами обрабатывать prompt, но никогда долго не молчит. У
provider не было ни одного теста; он получил тест с настоящим фальшивым Ollama
(node:http), покрывающий поток NDJSON со строкой, разорванной между чанками, немой
сервер, статус 500 и отказ в соединении.
Он должен быть >= LLM_TURN_TIMEOUT_MS в engine, иначе первой сдаётся
api, и потолок engine остаётся декоративным.
9. Заключение gate преобладает над формулировкой task
Исходная task остаётся в контексте во время исправления (именно она определяет, что
реализовывать), и correction_message давал заключению тот же вес. Когда
gate противоречит формулировке — классический случай: SecOps велит убрать
секрет, который task запросила, — агент слушался task и возвращал проблему.
Prompt теперь явно говорит, что заключение ПРЕОБЛАДАЕТ.
Честно говоря: не это закрыло приёмку (см. раздел о критерии). Это правильные слова, и они остаются в силе, но решило дело удаление оттуда противоречивой инструкции.
10. --metrics=off полностью сломал semgrep
Регрессия, внесённая решением 3: --config auto ТРЕБУЕТ включённой телеметрии
(«Cannot create auto config when metrics are off»), поэтому каждое сканирование стало
завершаться с ошибкой, а gate регистрировал «semgrep falhou, pulado» — изящно
деградируя, как и задумано, и именно поэтому оставаясь незамеченным в четырёх
подряд прогонах приёмки.
Заменено на ИМЕНОВАННЫЙ ruleset (p/security-audit), который работает с выключенными
метриками. Отправлять профиль кода пользователя на semgrep.dev ради
запуска gate безопасности — неприемлемый обмен. Правила по-прежнему загружаются из
registry по сети при первом запуске; без сети gate продолжает регистрировать
«пропущено».
Состояние критерия приёмки: ЗАКРЫТ, но НЕ ДЕТЕРМИНИРОВАН
Закрылся на 10-м прогоне, с локальным qwen2.5-coder:7b, ровно в последовательности
формулировки (pnpm --filter api demo:pr-gates, exit 0):
dev → PR aberta
qa → changes_requested: regras da story sem teste
dev → corrige (MESMA branch, sem PR nova)
qa → approved → awaiting_secops
secops → changes_requested: [gitleaks] src/credenciais.js:2 — GitHub PAT
dev → corrige (MESMA branch)
secops → approved → awaiting_user
11-й прогон, сразу после этого, НЕ закрылся — QA правильно указал на правило без
теста, dev исправил, а на втором проходе QA завершил цикл,
не вызвав emit_qa_verdict. Исправления тут ни при чём: это вариативность
модели. С локальным qwen2.5-coder:7b СЕМАНТИЧЕСКИЙ шаг приёмки (сопоставить бизнес-правило
с тестом, дважды подряд в одной PR) ненадёжен.
Что это значит и чего это не значит:
- Машина gates проверена. Полная последовательность была произведена
реальными агентами против реальных сканеров: неизменный порядок, возврат в той же
ветке, потолок исправлений, заключения как артефакт, терминальный
awaiting_user. Это не регрессирует между прогонами — меняется суждение модели. - Демо не годится как автоматизированный регрессионный тест, пока зависит
от локальной 7B. Оно служит исполняемым критерием приёмки, запускаемым
осознанно. Чтобы сделать его надёжным, направьте
DEMO_QA_MODELна модель через API: семантический gate — это роль, которая хуже всего ложится на маленькую модель, а binding по агенту (областьagent, побеждаетproject) существует ровно для этого.
Девять прогонов до закрывшегося ни разу не упали из-за дефекта gate — они падали из-за среды инференса и из-за ошибки проектирования самого демо. Стоит зафиксировать, потому что ни одна из них не была в доменном коде:
Среда инференса (всё теперь выставлено в docker-compose.yml)
| переменная | что было не так |
|---|---|
| GPU | у сервиса ollama не было зарезервированного устройства: RTX 4060 ПРОСТАИВАЛА, пока модель на 5,9GB работала на 100% CPU. Prompt QA в ~7.000 токенов занимал ~50 с только на приём. Это стало opt-in override docker-compose.gpu.yml (pnpm dev:gpu), вне основного compose, потому что без nvidia-container-toolkit на хосте резервирование ЗАСТАВЛЯЕТ сервис падать при старте. На GPU: 100% GPU, 5,5GB в VRAM |
OLLAMA_CONTEXT_LENGTH | значение по умолчанию 4096 МОЛЧА обрезало prompt, собранный под 128k, — агент терял собственные инструкции и начинал имитировать схему инструментов, которая оставалась в конце контекста |
OLLAMA_MAX_LOADED_MODELS | при высоком OLLAMA_KEEP_ALIVE модели НАКАПЛИВАЮТСЯ: 15,2GB резидентных весов на машине с 15GB, и агент отвечает пусто из-за нехватки памяти |
OLLAMA_REQUEST_TIMEOUT_MS | см. решение 8 |
START_OUTBOX_DRAIN / START_ANAMNESE | Psicólogo и Anamnese потребляют ходы LLM параллельно с агентами выполнения и обрывали соединение dev посреди цикла. Внимание: guards препятствуют только НОВЫМ постановкам в очередь — однажды накопилось 20 AnamneseWorker в executing от предыдущих прогонов, которые запускаются при следующей загрузке независимо от guard. Очередь нужно очищать, а не только выключать guard |
Ошибка проектирования демо: инструкция подброса, которая не истекает
Секрет подбрасывался указанием dev написать const TOKEN = "ghp_..." в
ОПИСАНИИ TASK. Но описание закреплено в контексте на КАЖДОМ круге
исправления: после того как SecOps отклонял, dev перегенерировал файл, копируя
литерал из самой формулировки, — четыре раза подряд, пока не исчерпал потолок.
write_file в event log показывают, что токен появляется нетронутым на каждом круге.
Никакой текст prompt не побеждает литеральный фрагмент кода в task (решение 9
пыталось, и этого не хватило). Подброс перенесён в КАРКАС репозитория
(src/credenciais.js, закоммиченный в базовую ветку): dev никогда не получает приказа
писать секрет, а исправление на process.env ничему не противоречит. Это
работает только потому, что SecOps сканирует рабочее дерево, а не только diff, —
принятое последствие решения 1, которое здесь стало требованием.
Последствия
- Тесты:
gitleaks_detector_testзапускает реальный БИНАРНИК (тег:gitleaks, автоматически исключается, когда его нет) и фиксирует регрессию пункта 1;scanner_testпокрывает чисто/находка/отсутствует/завис/взорвался;artifact_schemas_testпокрывает оба субъекта и невалидный вердикт;tool_call_recovery_testиспользует РЕАЛЬНЫЙ текст, на котором застряло демо, и имя, которое НЕ является инструментом;tool_loop_testпокрывает ошибку в теле ответа;qa_agent_server_testпокрывает блокировку без сжигания исправления. Engine 227. apps/api/scripts/demo-pr-gates.ts(pnpm --filter api demo:pr-gates) — это исполняемый критерий приёмки: завершается с кодом != 0, когда не закрывается, и печатает временную шкалу заключений для диагностики.
Объём и допущения
Подброшенные дефекты происходят из описания task, а не из удачи модели: оно
велит реализовать оба правила story, но протестировать только первое, и объявить
литеральный токен в коде. Это искусственно намеренно — под тестом
gate, а не усердие dev. Секрет — синтетический PAT GitHub, потому что
правила gitleaks по умолчанию ловят ghp_ по формату + энтропии, без allowlist
значений-примеров, которая мешает ключам AWS (AKIA...EXAMPLE).
Семантический шаг приёмки (QA сопоставляет RF с тестом) зависит от суждения
модели; DEMO_MODEL и DEMO_QA_MODEL позволяют менять её по агенту.
awaiting_user остаётся терминальным: merge всегда выполняется пользователем вручную.
Два урока, которые выходят за рамки этих gates:
- Среда инференса — часть системы, а не фон. Три из проблем этой сессии не были ни в одной строке Elixir или TypeScript: они были в размере контекста, в резидентности модели и в конкуренции между агентами. Все проявлялись как «самостоятельная остановка» агента — симптом, который код приписывал модели.
- Угадывающая диагностика хуже отсутствующей. Дважды в этой сессии система обвинила модель в сбое инфраструктуры (пункт 6), а исправление gate QA (пункт 4) существует потому, что gate обвинял DEV в сбое самого QA. Каждый исход агента должен нести, ОТКУДА пришёл сбой, а не только то, что он был.