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

ADR 0067 — Gate переживает перезапуск

  • Статус: принят
  • Дата: 2026-08-12
  • Контекст: повторяющаяся находка на ревью — «PR на ревью Dev никогда не завершается»
  • Расширяет: ADR 0057, в том же отношении, в каком тот находится к ADR 0052

Контекст​

ADR 0057 решил для агентов gate проблему, которую 0052 уже решил для dev agent: приостанавливаться посреди цикла, а не умирать с лживым origin: infra. Он сам уже объявлял в разделе «Чего это НЕ решает»: «перезапуск посреди ожидания теряет цикл» — pendente/em_voo у QaLeadServer живут только в памяти процесса, который является restart: :temporary (как и SecOpsAgentServer), а создание долговременной таблицы, эквивалентной dev_agent_states (Фаза 12b), было объявлено отдельными рамками.

Эти отдельные рамки — этот ADR, — но он покрывает БОЛЬШЕ, чем подсказывает исходная фраза. Повторное исследование кода (qa_lead_server.ex, secops_agent_server.ex, dispatcher.ex) нашло ВТОРОЕ окно потери, отличное от приостановки в {:awaiting, ...}: промежуточные переходы gate — DevAgentServer.correct/3 (возврат dev) и Dispatcher.run_secops/2 (QA одобрил, вызывает SecOps) — это ПРЯМОЙ вызов в памяти, делаемый ПОСЛЕ того, как record_gate_verdict уже ДОЛГОВРЕМЕННО продвинул gate_status задачи в api. Если процесс падает ровно между ними (вердикт уже записан, вызов в процессе ещё не сделан), api считает, что следующий gate выполняется, а engine никогда никого не вызывает — PR навсегда застревает в awaiting_secops (или в awaiting_qa, ожидая исправления, которое никогда не придёт), и никакой перезапуск engine этого не чинит, потому что ничто никогда не знало, что этот шаг ожидал.

Итак, два окна, а не одно:

  1. in_progress — процесс упал ДО записи какого-либо вердикта (в начале цикла или посреди приостановленного субагента, ожидающего одобрения, — случай, который 0057 уже покрывал).
  2. dispatch_pending — вердикт УЖЕ записан (долговременно, в api), а потерялся лишь вызов в процессе, применяющий следующий шаг (correct или run_secops).

Первое 0057 задокументировал; второе не было замечено — и на практике оно случается легче всего: это ровно промежуток между возвратом HTTP-ответа и выполнением следующей строки кода.

Решение​

Gate получает долговременное состояние, в той же идиоме, что dev_agent_states (ADR 0045), и спасатель, возобновляющий оставшееся ожидающим, — без ручного вмешательства.

Таблица: gate_states (схема engine)​

Составной ключ {project_id, task_id, gate}. Каждая строка представляет цикл gate В ПОЛЁТЕ, с step:

  • "in_progress" — в этой попытке ни один вердикт не записан. subagent (необязательно) — лишь диагностика, какая подспециализация была приостановлена, — ctx ToolLoop НЕ сохраняется (тот же выбор, что laço_pendente у dev agent: он не переживает перезапуск, и притворяться, что переживает, было бы хуже, чем не пытаться).
  • "dispatch_pending" — вердикт уже записан; next_action ("correct" | "run_secops") и, когда это "correct", correction_reason/correction_diagnosis хранят то, что нужно DevAgentServer.correct/3, чтобы быть вызванным снова без повторного чтения api.

Пишется и удаляется в ТЕХ ЖЕ точках, где qa_lead_server.ex/ secops_agent_server.ex уже делают переходы, — параллельного пути не существует. run_area/run_secops пишут in_progress до запуска любого субагента/сканера; apply_gate_result/apply_verdict пишут dispatch_pending СРАЗУ после того, как record_gate_verdict вернул correct/run_secops, и ДО вызова в процессе; строка удаляется сразу после вызова (или при любом другом терминальном исходе — done, блокировке или ошибке).

Спасатель: Engine.Gates.GateRescuer​

Обходит gate_states в поисках строк, простаивающих дольше gate_rescue_stale_after_seconds (по умолчанию 900 с — намеренно щедро: ToolLoop субагента QA может законно выполнять до TOOL_LOOP_MAX_ITERATIONS_GATE (60) итераций, и короткий порог спасал бы — и дублировал бы — цикл, который просто медленный). Для каждой строки:

  • in_progress → перезапускает ОБЛАСТЬ целиком (Dispatcher.run_qa/ run_secops снова). ХИРУРГИЧЕСКОЕ возобновление невозможно (ctx не выжил), но это БЕЗОПАСНО: api принимает record_gate_verdict только для gate, который всё ещё ВЛАДЕЕТ текущим gate_status (nextGateStatus, pr-gate-state-machine.ts:67-69), — если этот цикл уже завершился другим путём, вторая попытка получает ошибку (сегодня неотображённый 500 — см. «Последствия») и ничего не повреждает.
  • dispatch_pending → повторно отправляет ровно недостающий вызов (Dispatcher.run_secops/DevAgentServer.correct).

Вызывается из двух мест, та же пара, что использует Engine.Dev.DevRehydrator: один раз при загрузке (Engine.Application, после двух супервизоров gate) и периодически через Engine.Workers.GateRescueSchedulerWorker — самоперепланирующийся тик Oban (по умолчанию 5 мин), та же идиома, что ModelSyncSchedulerWorker/AnamneseSchedulerWorker. Переиспользование Oban вместо изобретения другого механизма планирования — намеренное решение: engine уже использует для этого очереди Postgres, и второй механизм только для gates дублировал бы существующее по неправильной причине.

Две охраны против дублирования работы​

Повторный запуск цикла gate дорог (LLM) и в пределе может породить два конкурирующих вердикта для одного шага. Две охраны, ни одна не совершенна сама по себе, вместе достаточны:

  1. Registry.lookup перед любым спасением. Живой процесс НА ЭТОМ узле никогда не тревожится — если он ещё работает (пусть медленно), спасение не действует. Это НЕ покрывает другую реплику: Engine.Gates.Registry локален для узла, та же оговорка, которую Engine.Dev.Wake уже принимает начиная с ADR 0045 (доставка PubSub at-most-once по этой причине).
  2. Порог устаревания — вторая линия защиты именно для случая, который не покрывает охрана 1, — достаточно щедрый, чтобы дать удалённой реплике время завершить реальный цикл, прежде чем другая попробует снова.

Худший исход остаточной гонки (обе охраны отказывают одновременно) — ДУБЛИРОВАННАЯ и ДЕШЁВАЯ работа: SecOps пересканирует (детерминированно, без LLM); QA перезапускается, и второй record_gate_verdict отклоняется api, ничего не записывая, — никогда несогласованные данные. DevAgentServer.correct/3 уже был идемпотентен-по-охране-состояния начиная с ADR 0052 (handle_cast({:correct, _}, state) действует, только если status == :awaiting_gate); второй correct, пришедший после того, как первый уже продвинул агента, — NO-OP, бесплатно.

Последствия​

Находка ADR 0057 закрывается, и идёт дальше того, что он объявлял. Оба окна (in_progress и dispatch_pending) покрыты; второе даже не было названо до этого исследования.

У InvalidGateActionError по-прежнему нет отображённого фильтра. Исследование api для этого ADR нашло, что второй вызов record_gate_verdict для gate, который уже не владеет gate_status, просачивается как общий 500 Nest, а не семантический 4xx, — DomainTransitionErrorFilter ловит только InvalidSessionTransitionError/InvalidActionTransitionError. Спасение in_progress зависит от этого отказа, чтобы ничего не повредить при остаточной гонке, и он РАБОТАЕТ (исключение прерывает до любого UPDATE/INSERT — record-gate-verdict.use-case.ts:82-88, до транзакции), но лог выглядит хуже, чем нужно. Отображение фильтра — улучшение наблюдаемости, а не исправление бага, и остаётся вне этого ADR — зафиксировано в backlog.

Остаточное окно между возвратом record_gate_verdict и КОММИТОМ локального upsert! dispatch_pending не закрыто — оно принято, по тому же рассуждению, по которому ADR 0045 уже принял его для Engine.Dev.Wake. Две записи (UPDATE в api, удалённый; локальный INSERT в engine) не могут быть транзакционными между собой без 2PC — вне рамок. Вызов в процессе между ними локальный и мгновенный (без сетевого I/O), так что окно порядка микросекунд, а не секунд; щедрый порог устаревания делает реалистичное столкновение sweeper с ним невозможным.

Мульти-реплика остаётся уже известным пределом. Registry локален для узла, Engine.Dev.Wake at-most-once — этот ADR не меняет ни того, ни другого, лишь использует тот же уже существовавший образец (охрана 1 выше). Окончательное закрытие этого (регистр :global) остаётся продолжением ADR 0045, а не этого.

Находка X/Z/AD (allowlist глаголов не сходится) остаётся вне рамок — никаких изменений в политике ToolLoop, никаких изменений в allowlist. Этот ADR только о том, чтобы gate ПЕРЕЖИЛ перезапуск посреди цикла, а не о том, что он делает внутри него.

Рассмотренные альтернативы​

ХИРУРГИЧЕСКИ возобновлять приостановленный ctx, сохраняя его. Отвергнуто по той же причине, по которой dev agent уже не делает этого для регидрированного working: история сообщений ToolLoop велика, меняет формат вместе с harness, а сохранение формата, читаемого лишь один раз при спасении, — долг, рождающийся готовым гнить. Перезапуск области с нуля дешевле в поддержке и, по доводу проверки состояния api, столь же безопасен.

Второй механизм планирования только для gates вместо Oban. Отвергнуто: engine уже использует очереди Postgres для всего (осушение outbox, wake dev agent, Anamnese, синхронизация каталога) — изобретение другого механизма лишь для этого дублировало бы инфраструктуру по неправильной причине.

Снизить порог устаревания до секунд ради более быстрого спасения. Отвергнуто: субагент QA, законно выполняющий 40 из 60 разрешённых итераций, был бы спасён ПОСЕРЕДИНЕ, дублируя работу, которая вот-вот завершилась бы сама. Цена ожидания до 15 минут настоящего спасения меньше цены слишком раннего спасения цикла, который просто был занят.