ADR 0012 — Настоящий DevAgent: ToolLoop, бюджет на task и дисциплина блокировки
- Статус: принят
- Дата: 2026-07-24
- Фаза: 4a (сессия 2 — заменяет NoopDevAgent)
Контекст
Предыдущая сессия построила инфраструктуру выполнения (worktrees, идентичность commit, git pipeline, блокировка merge, атомарный claim) и проверила её с помощью NoopDevAgent (без LLM). Эта сессия заменяет глупое тело настоящим DevAgent: он забирает task, собирает богатый контекст через уже существующий Harness Фазы 3, реализует через ToolLoop внутри worktree, открывает PR только с зелёным набором тестов (запущенным им самим через терминал), соблюдает бюджет токенов на task и возвращает заблокированную task с диагностикой, когда не может справиться.
Решения
1. ToolLoop получает halt-через-hook и потолок токенов (аддитивно)
У Engine.Harness.ToolLoop было только два исхода ({:ok, ctx} /
{:limit_reached, ctx}); ни один агент в production не использовал .Default
(Criativo/PO/Arquiteto заново реализуют собственный цикл) — низкий риск его
расширения. Два новых исхода:
{:halted, reason, ctx}: hook:post_tool_useуже поддерживал{:halt, reason}(Engine.Harness.Hooks), но результат ОТБРАСЫВАЛСЯ (_ = Hooks.run(...)). Теперьdispatch/2используетEnum.reduce_whileи распространяет halt — это механизм, которым DevAgent завершает цикл ровно на том ходе, в котором сигнализирует о завершении или блокировке (пункт 2).{:budget_exceeded, ctx}:ctx.tokens_spent_microsнакапливаетusage.costMicros, который каждыйllm_turnуже возвращал (и который игнорировался);ctx.token_budget_micros(опциональный, по умолчаниюnil= без потолка) проверяет ДО следующего хода — никогда не тратит сверх настроенного, в том же духе, чтоlimit_reached(никогда бесконечный цикл, теперь также никогда бесконечный расход).ctx.tools(реестр инструментов) иctx.hooksстановятся явными переопределениями —Engine.Harness.Tools.specs/1/find/2принимают альтернативный реестр (по умолчанию фиксированный, сохраняет EchoAgent/тесты). DevAgent используетEngine.Dev.Tools.registry/0(безEmitArtifact; сReportDone/ReportBlocked).ctx.workspace_rootпереопределяет корень файлов/AGENTS.md (по умолчанию — общий workspace проекта) —WorkspaceFiles,InstructionFilesиContextBuilder.build_layers/3начинают принимать этот явный корень. DevAgent передаёт worktree — ReadFile/WriteFile/SearchWorkspace и прочитанный AGENTS.md работают в правильной ветке/commit, а не в общем клоне.- Терминал в worktree (исправление реального пробела):
TerminalExecutor.run/3ВСЕГДА выполнялся в общем workspace проекта, никогда в worktree агента — без исправления тесты DevAgent выполнялись бы вне кода, который он изменил. Опциональныйcwdпроходит:ActionPipeline(payload инструментаterminal) →ExecuteTerminalActionUseCase/ApiToEngineClient(api) →POST /internal/actions/execute→TerminalExecutor(engine).
2. Дисциплина завершения — ReportDone/ReportBlocked ПРИНУДИТЕЛЬНЫ
Engine.Dev.Tools.ReportDone не доверяет LLM: ищет ПОСЛЕДНЕЕ сообщение
tool «terminal» в истории и ТРЕБУЕТ, чтобы оно начиналось с "exit 0" (точный
формат ActionPipeline.terminal_result/1) — без этого возвращает ошибку (модель
пытается снова). ReportBlocked всегда принимает {reason, diagnosis}.
Новый hook :post_tool_use (Engine.Dev.Hooks.Termination) останавливает цикл,
когда один из двух успешен, — использует механизм пункта 1. DevAgentServer
обрабатывает 5 возможных исходов ToolLoop.run/1: report_done (открывает PR),
report_blocked, limit_reached, budget_exceeded и {:ok, ctx} (модель
остановилась без сигнала — рассматривается как защитная блокировка). Ни один путь
не открывает PR без доказательства зелёного набора тестов; ни один не оставляет task застрявшей без исхода.
3. PR со ссылкой на story + статус in_review
taskStatusEnum получает 'in_review' (todo → in_progress → in_review → done; переход в done откладывается до появления QA — вне охвата).
Тело PR — Markdown-чек-лист DoD story (- [ ] item);
заголовок ссылается на story+task. Контекст берётся из GetDevTaskContextUseCase
(api, новый): полная story (RF/RNF/DoD/DoR), разрешённые бизнес-правила
(session_events artifact.business_rule, тот же шаблон, что у
CreateStoryUseCase) и ADR проекта (без фильтра по модулю — то же
упрощение, что у GetArchitectureUseCase, задокументированное).
4. Бюджет на task — решается в engine, без новой таблицы
Budgets (таблица budgets) ограничены только проектом ИЛИ сессией; у task нет
собственной сессии (все tasks модуля выполняются в одной сессии
выполнения). Вместо инвазивной миграции в budgets/token_usage
потолок на task решается ЦЕЛИКОМ в engine: каждый run_task вызывает
ToolLoop.run/1 ОДИН раз, поэтому накопленный tokens_spent_micros ЯВЛЯЕТСЯ стоимостью
task, изолированной по выполнению. taskBudgetMicros (настраивается при включении,
POST execution/activate, по умолчанию US$0,50, если опущен) проходит
ActivateExecutionUseCase → startExecution → dev_agent_states (новый
столбец, миграция) → ctx.token_budget_micros в ToolLoop.
5. Блокировка task — принудительный контроль повторного claim
tasks получает blocked boolean + blocked_reason text (миграция). Блокировка —
НЕ новое значение статуса (task возвращается в todo) — это ортогональный флаг.
ClaimNextTaskUseCase/claimNext исключает blocked = true из атомарного claim —
без этого хронически невыполнимая task повторно забиралась бы и снова блокировалась
по кругу. Человек освобождает через UnblockTaskUseCase
(POST sessions/:id/tasks/:id/unblock), прочитав диагностику.
6. Реальная ошибка, найденная тестом конкурентности: FOR UPDATE OF t
Тест N одновременных claim (пункт 6) выявил, что
FOR UPDATE SKIP LOCKED в claimNext БЕЗ OF t также пытался заблокировать строку
stories из join — поскольку несколько tasks разделяют ОДНУ И ТУ ЖЕ story, это
сериализовало конкурентные claim через блокировку story (SKIP LOCKED отбрасывал
их вместо того, чтобы попробовать другую task), теряя claim даже при доступных
tasks. Исправлено FOR UPDATE OF t (блокировка только строки tasks).
Без настоящего теста конкурентности (только последовательного, как было в
предыдущей сессии) эта ошибка прошла бы незамеченной.
Последствия
- UI: текущая task/итерация/потраченные токены вживую (через обогащённый
agent.response, уже существующий опрос); заблокированные tasks выделены (стиль, уже используемый «Pendências de validação cruzada»). - Тесты:
tool_loop_test(halt через hook, потолок токенов, пользовательский реестр);dev_agent_server_test(счастливый поток, невыполнимая task → blocked, превышенный бюджет → blocked, report_done без зелёного набора тестов → отклонено);claim-next-task(настоящая конкурентность — нашёл ошибку пункта 6);mark-task-blocked/get-dev-task-context(новые).
Охват и допущения
Только DevAgent (заменяющий Noop) — QA/SecOps/Infra и полная панель
команды через каналы Phoenix остаются вне этой сессии. in_review → done
откладывается до появления QA. ADR по модулю по-прежнему без настоящего фильтра
(все ADR проекта). Окно контекста DevAgent зафиксировано на
128.000 токенов (TODO: использовать реальное окно разрешённой модели, когда ход
LLM будет возвращать его в engine, — сегодня engine никогда не знал этого числа).