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

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 явно отказывается их сливать.

Ссылки​

  • ADR 0081 — таблица project_containers и машина состояний
  • ADR 0134 — команда терминала внутри контейнера; этот ADR — то, что позволяет ей приземлиться
  • ADR 0135 — шлюз образа стал действовать во всех трёх режимах
  • ADR 0137 — mounted/runner, поднимающие контейнер на машине пользователя
  • RN-502