ADR 0045 — Перепланирование dev agent по событию и circuit breaker
Контекст
Находка P1 #10 из dogfooding, дословно из
docs/missions/dogfooding-mission.md:659:
Um dev agent processa UMA task e para.
:worksó é disparado na ativação e no aceite de paralelização; oreport_doneabre o gate e não se reagenda; nenhum worker do Oban redispara. Teto por módulo: 1 task, ou 2 com paralelização.
Практическое следствие — в :396-398 того же урожая: Фаза 10
шла партиями — человек повторно активировал выполнение (или перезапускал
engine) между каждой task, потому что ничто в системе не возвращало агента от открытой
PR обратно в «свободен для взятия». Находка #11 (:660, P2)
описывает симптом попытки обойти это снаружи: повторная активация не
перезапускает :work (супервизор возвращает :existing) и к тому же создаёт
осиротевшую сессию.
Два факта, проверенные в коде ДО проектирования, сформировали всё решение:
- Worktree принадлежит АГЕНТУ, а не task.
WorktreeManager.add_worktree/3удаляет предыдущий worktree агента перед созданием следующего, аQaLeadServer/SecOpsAgentServerнаходят его черезDevAgentState.find_by_task_id/2, пока работает gate. Взятие следующей task сразу после открытия PR физически уничтожило бы worktree, который gate ещё сканирует. Это решило, что открытая PR не может освобождать агента, — нужно состояние, которое УДЕРЖИВАЕТ worktree, пока gate не закончит. prod/patches.yamlзапускает 2 реплики engine, аEngine.Dev.Registryлокален для узла. Асинхронный job (Oban) может выполняться на любой реплике; определение «где процесс этого агента» через Registry ошибалось бы с доставкой в половине случаев в production.
Решение
Сигнал — это outbox, а не вызов внутри процесса
task.gate_resolved (только при ТЕРМИНАЛЬНЫХ исходах — done/blocked) и
task.became_claimable (история продвинута, task создана под уже готовой историей,
task разблокирована) — это строки outbox, которые выдаёт api,
читаемые уже существующим Engine.Outbox.Drain (aggregate_type получил
"task" рядом с "session") и маршрутизируемые в
новый Engine.Workers.DevAgentWakeWorker — всегдашний механизм,
новый обработчик.
Выбор outbox вместо, например, того, чтобы gate вызывал
DevAgentServer.correct/3, как он уже делает для changes_requested (этот
вызов внутри процесса ПРОДОЛЖАЕТ существовать, без изменений), — это вопрос
выживания: строка outbox переживает перезапуск engine
между вердиктом и пробуждением. Вызов внутри процесса — нет, и именно
это свойство нужно Фазе 12b-6 (регидратация), чтобы закрыть
регидратированный awaiting_gate, реагирующий на gate, разрешённый после
перезапуска.
awaiting_gate — это блокировка, а не метка
Открытая PR → агент входит в awaiting_gate и СОХРАНЯЕТ
task_id/worktree/branch — ничего не берётся, пока gate не
разрешится. Освобождает только терминальный исход (через task.gate_resolved),
через finish_task/2. Worktree из факта № 1 выше — вся причина
существования этого состояния.
Доставка через PubSub, а не Registry
Engine.Dev.Wake использует Phoenix.PubSub (уже под супервизией как
Engine.PubSub, до сих пор без использования в lib/) — бесплатно на весь кластер,
в отличие от локального для узла Registry (факт № 2). DevAgentServer
подписывается на собственный топик в init/1; DevAgentWakeWorker никогда не вызывает
GenServer напрямую, всегда через Wake.deliver/3.
Осознанно принятый предел: доставка AT-MOST-ONCE. Если
процесс агента на мгновение недоступен, когда происходит
трансляция, пробуждение теряется, и агент просыпается только на СЛЕДУЮЩЕМ
событии. Закрыть это окончательно потребовало бы регистрации :global для dev agents
— то же, что уже делает Engine.Sessions.SessionServer, по той же причине,
— с переписыванием AgentIo.via/2, DevAgentSupervisor.start_agent и
Monitor. Вне объёма этой фазы; см. «что остаётся на потом».
Circuit breaker живёт в engine, а не в api
actor.id у backlog.task_blocked — это ТОТ, КТО ЗАБЛОКИРОВАЛ (qa-agent,
secops-agent, qa-lead или сам dev), — api не может
отличить по нему «серию ЭТОГО агента».
consecutive_blocked хранится в dev_agent_states и увеличивается только в одном
месте (finish_task/2), потому что это единственная точка, через которую агент
узнаёт ЛЮБОЙ исход собственной task — локальный (ToolLoop) или удалённый
(task.gate_resolved с nextAction: "blocked", превышенный потолок исправлений
gate). RN-047 детализирует механизм и инварианты.
Потолок (max_consecutive_blocked) следует шаблону
task_budget_micros — колонка в projects, разрешаемая при активации
(параметр → настройка проекта → значение по умолчанию), — но получил то, чего у того
никогда не было: PATCH + поле в Configurações. Решение пользователя: даже
ценой большей поверхности потолок breaker относится к тому типу, который хочется
настраивать, не переактивируя выполнение вслепую.
Rearm — единственный выход из idle_tripped
Один клик (POST .../agents/:agentId/rearm) — без автоматического снятия,
тот же принцип, что у unblock для отдельных tasks. Api регистрирует
dev.rearmed с actor: user ДО того, как будет гарантировано, что в
engine что-то произошло (тот же порядок — сначала engine, потом событие, —
что уже использует AcceptParallelizationUseCase, по той же причине: event
log неизменяем, и запись до отказа оставила бы в нём ложь).
Engine НЕ выдаёт второй собственный dev.rearmed: это было бы то же
событие, засчитанное дважды разными акторами в одной сессии. То, что
происходит потом (взял или вернулся в idle), уже описывается через
dev.working/dev.idle, которые запускает try_claim/1 у rearm.
Последствия
- Приёмка центрального требования: проект с N готовыми tasks в одном
модуле обрабатывает их все последовательно с ОДНИМ агентом, без перезапуска
engine и без ручной повторной активации — цепочка claim → PR → одобренный gate
→ claim следующей → пустая очередь → idle → новая task → пробуждение
детерминирована и протестирована от начала до конца
(
dev_agent_chain_test.exs), с РЕАЛЬНОЙ инфраструктурой outbox/drain/worker/PubSub посередине, а не смоделированной. - Путь исправления dev↔gate (
changes_requested) остаётся ровно каким был — внутри процесса, без outbox. Это механическое доказательство того, что перепланирование не дублирует этот путь:task.gate_resolvedвыдаётся только для терминальных исходов, поэтому не существует второй строки, которая бы его запускала. - Регидратация перестала быть «возвратом с чистого листа»:
awaiting_gateиworkingудерживают или восстанавливают то, что имело смысл удержать; прерванныйworkingблокирует с честной диагностикой перезапуска вместо того, чтобы молча забирать или пытаться возобновить ToolLoop, которого больше не существует. - Атомарный claim не изменился (
FOR UPDATE OF t SKIP LOCKED,backlog.repository.ts) — перепланирование увеличивает число тех, КТО пытается claim (тот же агент, чаще; два агента модуля, просыпающиеся вместе), но не меняет уже существовавшую гарантию. Доказательство конкурентности api (8 реальных параллельных claim) остаётся нетронутым; новое — на стороне engine, о логике реакции. NoopDevAgentServerостаётся вне всего механизма: никогда не открывает gate, никогда не пробуждается,statusзафиксирован как"working". Это smoke-тест инфраструктуры, а не второй продукт, который нужно держать в соответствии с машиной состояний.
Состязательное ревью и что оно нашло
Реализация прошла независимый аудит ДО выхода из ветки — проведённый без того, чтобы кормить аудитора обоснованиями выше, чтобы не унаследовать слепые пятна автора. Он нашёл восемь дефектов, четыре из которых могли застопорить агента или загрузку. Все были исправлены; запись остаётся здесь, потому что урок полезнее списка.
Решение этого ADR не изменилось. Изменилась его реализация, которая была неверной в местах, до которых не дотягивались тесты.
Почему весь набор тестов прошёл зелёным с четырьмя зависаниями внутри:
фальшивки (FakeWorktreeManager, FakeEngineApiClient) возвращают
мгновенно и всегда успешно. Не было тестового пути для
падающего claim_task, для реальной стоимости ToolLoop внутри
init/1 и для gate, завершающего task вне
RecordGateVerdictUseCase. Тест, проверяющий только счастливый путь,
измеряет отсутствие опечаток, а не корректность замысла.
Фальшивка получила ветку ошибки и настраиваемую задержку вместе с исправлениями.
Четыре критических, по тому, чему они учат:
- Частичное состояние хуже ошибочного.
finish_task/2обнулялtask_id, но не трогалstatus; достаточно было временного 5xx в claim, чтобы агент осталсяawaiting_gateсtask_idnil — комбинация, которую НЕ принимает ни один из трёх guards. Агент становился живым мертвецом, а причиной был секундный сбой сети. init/1— путь загрузки, а не место для работы. Восстановление прерванногоworkingвыполнялось там, а посколькуstart_link/start_childждут:infinity, вся загрузка зависала на время task LLM. Оно переехало вhandle_continue.- Воронка, которая не единственная воронка, — не воронка. Замысел гласил: «агент
узнаёт КАЖДЫЙ исход собственной task через
finish_task/2», — ноQaLeadServerблокирует, вызываяMarkTaskBlockedUseCaseнапрямую, не проходя черезRecordGateVerdictUseCase, где была выдача. Выдача спустилась в настоящую воронку. - Я скопировал механизм outbox, не скопировав его причину. Четыре новые
выдачи не использовали
UnitOfWork, хотяAppendSessionEventUseCaseего использует, — нарушая инвариант, которыйarchitecture.mdописывает как причину существования outbox, в файле, который я редактировал в этой же фазе.
Что остаётся на потом
-
:globalдля dev agents — закрыл бы пробел at-most-once доставки через PubSub. Не срочно: следующее событие (другой gate, другая доступная task) всё равно доходит до агента, потерявшего пробуждение, просто не сразу. -
«3 реальных gate подряд, без перезапуска» как ручная приёмка, а не CI. QA Lead и SecOps — суждение LLM, недетерминированное по природе, по той же причине, которую ADR 0020 уже зафиксировал для критерия приёмки Фазы 4a («ЗАКРЫТ, но не детерминирован»). Набор тестов доказывает всё НИЖЕ ПО ТЕЧЕНИЮ от вердикта; dogfooding с настоящими gates остаётся человеческой демонстрацией, зафиксированной в CHANGELOG.
-
Kill посреди ToolLoop не имеет автоматического возобновления, по решению. Ход, сообщения и правки worktree существуют только в памяти — возобновлять нечего. Блокировать с диагностикой и оставлять решение человеку — та же доктрина, что уже управляет локальными блокировками; полное автоматическое возобновление (повторная отправка нового ToolLoop на ТУ ЖЕ уже взятую task) было бы новой автономией, а CLAUDE.md отмечает это как явную нецель Фазы 12.
-
Worktree может исчезнуть из-под ожидающего одобрения — находка D5 ревью, принятая осознанно, не исправленная. Блокировка
awaiting_gateзащищает окно GATE, но не окно конвейера одобрений: при автономии dev вmanual(переключатель панели позволяет)git_commit/git_pushостаютсяpending; gates сканируют WORKTREE, а не PR, поэтому могут одобрить, агент берёт следующую task, иadd_worktree/3удаляет каталог — а человеческое одобрение, когда придёт, выполнится против пути, которого больше не существует. До 12b агент останавливался, и worktree жил неограниченно.Не исправлено, потому что очевидное исправление — удерживать claim, пока есть ожидающий
proposed_actionэтого worktree, — заново вводит ровно ту связанность, которую эта фаза убрала: агент снова стал бы зависеть от внешнего состояния, решая, может ли он работать. Этот путь также требует отойти от значения по умолчанию:ActivateExecutionUseCaseзасеваетauto_approveдляgit_commit/git_push/pr_openкаждого dev agent, поэтому до него доходит только тот, кто изменил автономию вручную. Это зафиксировано как известный предел; если он проявится на практике, правильное исправление — worktree на TASK, а не на агента, а это структурное изменение и требует собственного ADR. -
12b не трогала находки #12–#16 того же урожая (свободный ручной handoff, продвижение story — это Фаза 12c, — возврат к PO, общая вкладка панели, счётчик одобрений). Вне объёма.
Ссылается на ADR 0005 (шаблон устойчивого состояния + регидратации, который этот ADR распространяет на dev agents) и ADR 0020 (источник доктрины «никогда не забирать молча» и прецедента ручной приёмки для недетерминированного критерия).