ADR 0052 — Dev agent ждёт одобрения посреди цикла
- Статус: Принят — реализован и доказан тестом в фазе A
- Дата: 2026-08-05
- Контекст: FASE 13b — первое реальное выполнение dev agent с моделью через API
Контекст
terminal dev agent — инструмент :pipeline: hook
Engine.Harness.Hooks.ActionPipeline создаёт proposed_action в api, которая
решает через permissions.json. Когда решение — auto_approve, api выполняет
сразу, и hook возвращает реальный вывод команды. Когда require_approval,
hook возвращает строку proposed_action <id> status pending как результат
инструмента — и ToolLoop переходит к следующей итерации.
Агент не может ждать. Он читает "pending", ничего не узнал о команде, пробует что-то другое, и каждая попытка расходует итерацию из предела.
В реальном выполнении (проект saudacao-local, task cd652d85) итог был таким:
toolloop.limit_reached {"iteration": 8, "max_iterations": 8}
backlog.task_blocked {"reason": "limite de iterações atingido"}
Восемь сожжённых итераций, ни одной написанной строки, а одобрения, которые пользователь дал, пришли уже после исчерпания цикла — оказались бесполезны.
Почему allowlist не решает
DEV_TERMINAL_ALLOW_PATTERNS существует именно чтобы избежать этого, и комментарий
в activate-execution.use-case.ts:139-144 точно описывает сбой. Но
это список предусмотренных команд, а модель изобретает команды. После
добавления команд чтения (ls, find, pwd, cat…) тот же агент продвинулся
довольно далеко — worktree создан, команда с exit 0, файлы прочитаны — и снова застрял
на git branch -a и git rev-parse.
Каждая новая модель, каждый новый стек — новая дыра. Allowlist — это смягчение, и он не может быть ответом.
Что уже существует и доказывает путь
:awaiting_approval уже является состоянием dev agent, созданным в Фазе 12e для
конца task: когда pr_open остаётся ожидающим, агент удерживает worktree и
освобождается через task.pr_settled, выдаваемый api и маршрутизируемый
DevAgentWakeWorker. Весь механизм — сохранённое состояние, удержание
worktree, событие outbox, worker, Wake.deliver — готов и задействован.
Нужно применить его к середине цикла, а не изобретать.
Решение
Когда инструмент :pipeline возвращает pending, ToolLoop останавливается, и
агент переходит в :awaiting_approval, удерживая всё. Решение пользователя выдаёт
событие, которое его будит, и цикл возобновляется с той точки, где остановился, с
реальным результатом команды на месте строки "pending".
Почему остановка, а не блокирующее ожидание
Очевидная альтернатива — hook ждёт решения опросом — проще и
неверна: ToolLoop работает внутри callback GenServer агента
(dev_agent_server.ex:365). Блокировка там застопорила бы сам почтовый ящик, который
принёс бы решение, а также корректное завершение и circuit breaker. Остановка и
возобновление по сообщению — это то, что уже делает pr_settled, и по той же причине.
Сообщения остаются в памяти
ctx цикла (с историей) остаётся в состоянии GenServer, пока агент
ждёт. Не сохраняется: агент продолжает жить, лишь простаивает. Это сохраняет
свойство, которое восстановление после перезапуска уже объявляет, — ход и сообщения
существуют только в памяти, а перезапуск во время task блокирует с диагнозом
(dev_agent_server.ex:153-159). Ожидающее одобрение, переживающее
перезапуск, попадает на этот же путь, который честен и уже существует.
Пять частей
| # | Где | Что |
|---|---|---|
| 1 | apps/api/.../approve-action.use-case.ts, deny-action.use-case.ts | выдавать task.action_settled, когда актор действия — dev agent, — с actionId, status и результатом выполнения |
| 2 | apps/engine/.../hooks/action_pipeline.ex | при получении pending сигнализировать остановку вместо возврата строки |
| 3 | apps/engine/.../harness/tool_loop.ex | поддерживать {:awaiting_approval, action_id, ctx} как исход, рядом с уже существующими halted |
| 4 | apps/engine/.../dev/dev_agent_server.ex | хранить ctx, сохранять :awaiting_approval + action_id и возобновлять ToolLoop.run/1 при получении {:action_settled, …} |
| 5 | apps/engine/.../workers/dev_agent_wake_worker.ex | маршрутизировать task.action_settled агенту, как уже делается с task.pr_settled |
Отказ — тоже ответ
Отклонённое действие возобновляет цикл с причиной отказа на месте результата —
агент узнаёт, что этот путь закрыт, и пробует другой. Отклонение не блокирует
task: это решение пользователя, и здесь действует тот же принцип, что у pr_settled с
opened: false (не учитывается circuit breaker).
Предел ожидания
Бесконечное ожидание — это бессмертная сессия под другим именем. Агент ждёт до предела
бездействия task; при превышении блокирует с origin: política — task остановилась,
потому что никто не решил, а это отличается от дефекта кода или модели.
Последствия
За
- Агент перестаёт сжигать итерации на то, что не может выполнить, а одобрения пользователя начинают действовать независимо от того, когда приходят.
- Allowlist перестаёт быть единственной защитой и возвращается к роли, для которой был задуман: избавлять от одобрения рутину, а не делать агента работоспособным.
- Пользователь видит
dev.awaiting_approvalв ленте и знает, что агент ждёт его, вместо того чтобы видеть task, заблокированную "limite de iterações".
Против
- Ожидающий агент занимает worktree и не берёт следующую task. Это та же
уже принятая цена, что в
awaiting_gate, и по той же причине: worktree — на АГЕНТА, а не на task. ctxв памяти означает, что перезапуск во время ожидания теряет ход. Это остаётся на уже существующем пути блокировки с диагнозом; сохранение истории сообщений — отдельная проблема, не этого ADR.
Рассмотренные альтернативы
Увеличить max_iterations. Лишь откладывает: при ожидающем одобрении каждая итерация
тратится впустую, поэтому любой предел достигается.
Автоодобрять terminal через agent_autonomy. Разрешило бы ЛЮБУЮ команду
внутри контейнера engine, и именно это
dev-terminal-patterns.ts:11-15 отвергает — с файлом deny продолжает
побеждать allow, а BUILTIN_DENY_PATTERNS остаются активными.
Опрос в hook. Застопоривает GenServer, как объяснено выше.