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

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 рождается из tool emit_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_ANAMNESEPsicó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. Каждый исход агента должен нести, ОТКУДА пришёл сбой, а не только то, что он был.