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

ADR 0182 — Предложение handoff получает жизненный цикл: одно ожидающее на получателя и superseded

Статус​

Accepted. 2026-09-30 (AT-291 и AT-292, история HS-075, раунд 29; AT-273 из vault сливается с AT-292 по решению владельца). Поверх ADR 0038 (внешний handoff адресуется только lead области или агенту без области, проверяется в CreateHandoffUseCase) и ADR 0090 (AppSec поставляет threat model трём получателям). Не редактирует их: добавляет состояние и инвариант к таблице handoffs.

Контекст​

Измерено в коде dev 2026-09-30:

  • HandoffStatus объявлял offered | accepted | completed | rejected, но записывались только offered и accepted. Не было истечения, замещения и дедупликации.
  • CreateHandoffUseCase вставлял новую строку при каждом вызове. Любое повторение становилось новым предложением: агент, повторяющий offer_handoff, Criativo, заново подтверждающий готовность, Staff, возвращающий ещё один RFC.
  • OfferInfraHandoffUseCase не был идемпотентным. Двойной клик по «arquitetura pronta» или две вкладки записывали веху дважды и просили engine о двух завершающих ходах Arquiteto и двух предложениях Dev Lead — 2× Infra и 2× Dev Lead на экране.
  • AppSec (criar_handoffs_appsec/3) создаёт три предложения на историю с run_design, тем же трём получателям (arquiteto, dev-lead, infra). При N историях — 3N накопленных ожидающих предложений.
  • Предложение агенту, который уже был активен, оставалось offered навсегда, и экран показывал его как действенное: принятие повторно включило бы того, кто уже работал.

Решение​

  1. Новый статус superseded (migration 0064, только ALTER TYPE … ADD VALUE). Предложение, переставшее быть действующим, меняет статус в СТРОКЕ, как уже делало принятие, и получает событие handoff.superseded с handoffId, toAgent, motivo (agente_ativado | nova_oferta) и substitutaId. Никогда UPDATE события: handoff.offered, объявивший предложение, остаётся как был. Событие попадает в сессию СТАРОГО предложения, с актором system handoff-lifecycle, и входит даже в закрытую сессию (это не беседа, RN-581).

  2. Не более ОДНОГО предложения offered на (проект, получатель). Решение чистое (decidirOferta, domain/sessions/ciclo-de-vida-do-handoff.ts):

    • нет ожидающего — создаёт;
    • ожидающее в ТОЙ ЖЕ сессии, и новое предложение не несёт нового артефакта (null или равный) → возвращает существующее (desfecho: ja_oferecido), без строки и события. Это двойной клик, две вкладки, агент, повторяющий инструмент;
    • любой другой случай (НОВЫЙ артефакт или ожидающее в другой сессии) → создаёт новое и ЗАМЕЩАЕТ ожидающее (desfecho: substituiu_oferta).

    Почему замещать, а не всегда возвращать существующее. Карточка просила оба варианта и предпочитала не терять новый артефакт. Возврат старого, когда артефакт изменился, оставил бы переписанный brief (или новый RFC Staff) без предложения, которое его несёт. Возврат ожидающего из ДРУГОЙ сессии оставил бы того, кто просил, без чего-либо для принятия в беседе, в которой он находится, — а если другая сессия уже закрылась, без чего-либо принимаемого где бы то ни было (RN-581 отклоняет принятие в закрытой сессии). Возврат имеет смысл только там, где ничего не теряется: та же сессия, тот же артефакт.

  3. Конкурентность через консультативную блокировку ТРАНЗАКЦИИ (pg_advisory_xact_lock(hashtextextended('handoff:<projeto>:<destino>'))), а не через частичный уникальный индекс. Индекс потребовал бы очистить уже существующие дубликаты ДО его создания, в той же migration, — а новое значение enum не может использоваться в транзакции, которая его создаёт. Очистить, пометив rejected, значило бы солгать; очистить без события нарушило бы собственную линейку. С блокировкой прежние дубликаты сходятся при следующем предложении или включении этого получателя, С событием. Блокировка — по получателю, а не по проекту: предложения разным получателям не ждут друг друга.

  4. Предложение агенту, уже АКТИВНОМУ в проекте, отклоняется, 409 agente_ja_ativo, без строки и события. Активный = agent.activated для него в сессии проекта, которая НЕ терминальная, — прочтение экрана (session-handoffs.ts), расширенное с сессии до проекта; закрытая сессия — для её разговорных агентов. message — текст, который читает модель (RN-163): инструмент offer_handoff передаёт его буквально, а FalhaDeTurno.origem/1 классифицирует его как politica, а не codigo.

  5. Включение агента замещает ожидающие предложения ему, в ЛЮБОЙ сессии проекта, тремя путями, которые включают: принятие (которое проходит через ActivateAgentUseCase; принятое уже accepted и не затрагивается), прямое включение и включение выполнения (по dev-<modulo>).

  6. OfferInfraHandoffUseCase идемпотентен. Каждый получатель (infra, dev-lead) с ожидающим предложением или уже активный не задействуется снова; когда оба таковы, ничего не записывается и ничего не запрашивается у engine (desfecho: ja_oferecido, jaAtendidos говорит почему). Задействуется только недостающее: активный Infra и Dev Lead без предложения запрашивает только предложение Dev Lead, без хода Arquiteto.

  7. AppSec предлагает только тем, кто ещё не получил и не активен (RN-636). Engine спрашивает api в режиме seAusente (create_handoff_if_absent/5), вместо того чтобы проверять заранее: вопрос, заданный отсюда, был бы гонкой с другим предложением, а api отвечает под блокировкой получателя. «Уже предложено» и «уже активен» не становятся agent.error; настоящий сбой по-прежнему описывается по цели (RN-116).

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

  • Экран получает ещё один статус. GET .../handoffs возвращает superseded, а маршруты создания возвращают desfecho. Типы web здесь перегенерированы; то, что экран ДЕЛАЕТ с новым статусом (не предлагает как действенное, читает «активный» проекта), — дело lane handoff-web (AT-293..295).
  • Гонка двух кликов в ОДИН И ТОТ ЖЕ момент по «arquitetura pronta» всё ещё записывает веху architecture.readiness_confirmed дважды. Ни одно предложение не дублируется (блокировка), а второй ход Arquiteto отклоняется engine с 409 turno_em_andamento (ADR 0163). Закрыть и событие потребовало бы удерживать блокировку во время вызова engine, а это сеть внутри транзакции — отвергнуто.
  • Новое предложение активному агенту перестаёт существовать, а вместе с ним и «повторное включение через предложение». Тот, кто переписывает brief при уже активном PO, получает названный отказ; артефакт остаётся в log, и беседа продолжается с активным агентом. Кому нужен новый раунд, закрывает сессию, в которой он активен.
  • Предложение threat model второй истории не рождается, пока предложение первой ожидает (RN-636). Артефакт остаётся в log; получатель при включении читает threat models проекта. Ожидающее предложение не указывает на самый свежий — это цена того, что предложение, ещё ждущее принятия, не замещается.
  • Включение выполнения замещает предложения dev-<modulo>, которые сегодня не рождаются (субагент не получает внешний handoff, ADR 0038). Вызов существует, чтобы правило действовало в тот день, когда изменится иерархия, и стоит одного чтения на модуль.
  • Прежние дубликаты не очищаются migration. Они сходятся при следующем предложении или включении того же получателя. Получатель, который больше никогда не получит ни предложения, ни включения, сохраняет свои offered, как сегодня.