ADR 0143 — Агенты dev только после контейнера
Статус
Принят — 2026-09-04
Контекст
При реальном запуске проекта exp001 (2026-09-04) лента продвинулась далеко:
bootstrap Gitflow, product_brief, module_map и в 18:00 Arquiteto,
поставляющий project_image, module_routing и диаграмму C4. Два ADR
стали PR, прошло три merge. А затем десять задач dev застряли
разом, все с одним исходом: рабочему дереву dev agent негде было
родиться.
Важная здесь находка — не причина той конкретной папки, а то, что
ничто не упорядочивало контейнер перед dev agent. Ни одного container_start не было
предложено, а десять dev agents всё равно начали — чтобы застрять десять раз.
Был и второй дефект, более тихий, в той же строке потока.
ADR 0134 (RN-492) заставил
команду терминала dev agent проходить engine → api → broker и выполняться
ВНУТРИ настоящего контейнера — когда он был. Когда его не было,
Engine.Actions.TerminalExecutor.decisao_de_execucao/1 падал в
:caminho_de_sempre, то есть в System.cmd внутри процесса engine — того же
процесса, который говорит с базой, с api и со всеми остальными проектами
установки. Изоляция, ради создания которой существует ADR 0134, действовала лишь на
счастливом пути, а её отсутствие не приводило к отказу: оно деградировало, и деградировало молча.
Этот ADR закрывает обе половины.
Решение
1. Защита живёт в try_claim, а не только на HTTP-границе
Engine.Dev.AgentIo.try_claim/2 — ЕДИНСТВЕННАЯ точка claim — обращается к
Engine.Containers.ProjectContainerLifecycle.running?/1 до вызова
claim_task/1. Без ЗАРЕГИСТРИРОВАННОЙ строки running в project_containers
(ADR 0081) агент падает в
:idle, сохраняется и испускает dev.blocked_by_container — не вызывая api.
Предикат уже существовал: это тот же, к которому TerminalExecutor обращается со времён
ADR 0134. Ничего нового для ответа на вопрос не построено; изменилось то,
кто его задаёт и когда.
Эквивалентный gate в api (ActivateExecutionUseCase, отказывающий с 400 без
ciclo?.status === 'running') — решение родственного PR, и именно он даёт
предупреждение человеку в момент клика. Он не заменяет эту защиту, потому что
у claim есть путь, который не покрывает ни один маршрут: регидратация.
Engine.Dev.DevRehydrator не делает cast :work; claim после
перезапуска engine делает DevAgentServer.init/1 → finish_restart_recovery/1 →
try_claim/2, не проходя ни через какой endpoint. Gate только на HTTP-границе
позволил бы каждому регидрированному агенту снова работать без контейнера — а
перезапуск — это ровно та ситуация, когда никто не смотрит.
Защита идёт до claim_task/1, а не после: захватить, чтобы тут же
вернуть, оставило бы задачу помеченной и без живого владельца, а это состояние, которое
AgentIo.block_task/4 существует, чтобы никогда не производить.
2. :idle, а не новый статус
Docblock клаузы {:error, reason} самого try_claim/2 уже
фиксировал причину, написанную, когда в Фазе 12b было исправлено постоянное зависание агента:
«:idle é o único estado do qual um wake ainda
resgata». Все guards handle_info/2 сопоставлены с ним —
{:wake, :became_claimable} требует :idle, :rearm требует :idle_tripped,
{:gate_resolved, …} требует совпадения task_id.
Выдуманный здесь :blocked_by_container был бы состоянием, из которого ничто не
выручает: агент застыл бы навсегда, воссоздав симптом, ради устранения которого
существовала Фаза 12b. Различает «я остановился, потому что очередь опустела» и
«я остановился, потому что нет контейнера» СОБЫТИЕ (dev.idle ×
dev.blocked_by_container), а не статус — и событие устойчиво, по шаблону
RN-059.
3. Пробуждение приходит через outbox, в собственном агрегате
Чтение никого не оповещает. Агент, уже остановленный в :idle, продолжал бы стоять,
пока мимо не пройдёт несвязанное событие, — а это на практике означает «пока
кто-нибудь вручную не включит выполнение снова».
Поэтому RegistrarTransicaoDeContainerUseCase публикует: записывая приход в
running, он добавляет, в той же транзакции, строку outbox
container.running. Engine.Outbox.Drain начинает сливать третий
aggregate_type ("container", рядом с "session" и "task"), а
Engine.Workers.DevAgentWakeWorker доставляет сообщение агентам проекта.
Три выбора внутри этого:
aggregateType: 'container', а не'task'. Агрегатtaskуже сливался, и его повторное использование стоило бы ноль строк в drain, — но событие не о какой-либо задаче.graph_projectionиз ADR 0099 уже установил, чтоaggregate_type— это то, как разные потребители не конкурируют друг с другом; лгать о нём ради экономии строки запроса — плохо стареет.aggregateId— это ПРОЕКТ, а не строкаproject_containers: потребитель будит агентов по проекту, а строка могла быть пересоздана.{:wake, :became_claimable}, уже существовавшее сообщение. Его семантика уже «сейчас может быть работа» — комментарийEngineWeb.ExecutionCommandController.acordar/4использует его ровно так для повторного включения выполнения, которое тоже не о конкретной задаче. Новое сообщение потребовало бы новой клаузыhandle_info/2в ОБОИХ серверах (реальном и Noop) с guard, идентичным уже существующему, без какого-либо другого решения.
Fan-out идёт по ПРОЕКТУ (DevAgentState.list_by_project/1), а не по модулю:
контейнер принадлежит проекту, и api не может знать, какие модули существуют на
стороне engine.
4. Терминал перестаёт молча деградировать
decisao_de_execucao/1 получает :recusar_container_ausente, повторяя
:recusar_nao_verificado/:recusar_runner_desconectado, которые у режима runner
уже были со времён ADR 0104. Это та же
дисциплина — «не хватило предусловия, значит, не выполняется нигде», —
применённая к режиму, у которого её ещё не было.
mounted входит в ту же ветку: при running контейнере проходит в
broker так же, как container; без него — отказ. Обращаться с ним иначе было бы
произвольно после того, как mounted начал по-настоящему поднимать контейнер.
А catch-all _ -> :caminho_de_sempre сжимается до того, чем всегда должен был
быть сам по себе: несуществующий проект или некорректный project_id. Ни один режим
выполнения в него не падает.
Отказ — обычный failed_result, никогда не crash, — тот же контракт сбоя, что у
двух других отказов модуля.
Последствия
Проект без поднятого контейнера перестаёт работать и говорит почему. Это то, ради чего
существует решение, а не побочный эффект. Это также причина, по которой оно может
войти только ПОСЛЕ изменений, дающих контейнер режиму mounted: без них
проект mounted остаётся без единого dev agent, а единственный выход — режим
container.
Каждой прежней spec dev agent теперь нужен running
контейнер. Это девять тестовых файлов в engine, и изменение — одна строка
setup в каждом (Engine.DataCase.container_running!/1), а не модуль,
заменяемый через config. Это был выбор: производственный шов, существующий лишь для того, чтобы
набор тестов не проходил, сделал бы защиту инертной ровно там, где она проверяется.
Один вопрос, два места, которые его задают. AgentIo.try_claim/2 и
TerminalExecutor.decisao_de_execucao/1 оба вызывают один и тот же
ProjectContainerLifecycle.running?/1. Это не два вывода: это одна
функция с двумя вызывающими, отвечающая на разные вопросы («могу ли я
начать?» и «где это выполняется?»).
Доставка пробуждения остаётся at-most-once, как и весь Engine.Dev.Wake
(его moduledoc объявляет это со времён Фазы 12b). Если агент
на мгновение недоступен в момент broadcast, он остаётся :idle до
следующего события — включая ручное повторное включение выполнения, которое доставляет
то же {:wake, :became_claimable}.
ЗАРЕГИСТРИРОВАННЫЙ running по-прежнему не является НАБЛЮДАЕМЫМ running
(RN-486). Контейнер, умерший извне,
всё ещё проходит эту защиту; агент делает claim, и сбой проявляется при первом
вызове broker, как обычный сбой команды. Слияние двух потребовало бы
сетевого вызова на каждый claim, а RN-486 явно отказывается их сливать.