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