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

ADR 0045 — Перепланирование dev agent по событию и circuit breaker

Контекст​

Находка P1 #10 из dogfooding, дословно из docs/missions/dogfooding-mission.md:659:

Um dev agent processa UMA task e para. :work só é disparado na ativação e no aceite de paralelização; o report_done abre 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) и к тому же создаёт осиротевшую сессию.

Два факта, проверенные в коде ДО проектирования, сформировали всё решение:

  1. Worktree принадлежит АГЕНТУ, а не task. WorktreeManager.add_worktree/3 удаляет предыдущий worktree агента перед созданием следующего, а QaLeadServer/SecOpsAgentServer находят его через DevAgentState.find_by_task_id/2, пока работает gate. Взятие следующей task сразу после открытия PR физически уничтожило бы worktree, который gate ещё сканирует. Это решило, что открытая PR не может освобождать агента, — нужно состояние, которое УДЕРЖИВАЕТ worktree, пока gate не закончит.
  2. 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_id nil — комбинация, которую НЕ принимает ни один из трёх 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 (источник доктрины «никогда не забирать молча» и прецедента ручной приёмки для недетерминированного критерия).