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навсегда, и экран показывал его как действенное: принятие повторно включило бы того, кто уже работал.
Решение
-
Новый статус
superseded(migration0064, толькоALTER TYPE … ADD VALUE). Предложение, переставшее быть действующим, меняет статус в СТРОКЕ, как уже делало принятие, и получает событиеhandoff.supersededсhandoffId,toAgent,motivo(agente_ativado|nova_oferta) иsubstitutaId. Никогда UPDATE события:handoff.offered, объявивший предложение, остаётся как был. Событие попадает в сессию СТАРОГО предложения, с акторомsystemhandoff-lifecycle, и входит даже в закрытую сессию (это не беседа, RN-581). -
Не более ОДНОГО предложения
offeredна (проект, получатель). Решение чистое (decidirOferta,domain/sessions/ciclo-de-vida-do-handoff.ts):- нет ожидающего — создаёт;
- ожидающее в ТОЙ ЖЕ сессии, и новое предложение не несёт нового артефакта (
nullили равный) → возвращает существующее (desfecho: ja_oferecido), без строки и события. Это двойной клик, две вкладки, агент, повторяющий инструмент; - любой другой случай (НОВЫЙ артефакт или ожидающее в другой сессии) → создаёт
новое и ЗАМЕЩАЕТ ожидающее (
desfecho: substituiu_oferta).
Почему замещать, а не всегда возвращать существующее. Карточка просила оба варианта и предпочитала не терять новый артефакт. Возврат старого, когда артефакт изменился, оставил бы переписанный brief (или новый RFC Staff) без предложения, которое его несёт. Возврат ожидающего из ДРУГОЙ сессии оставил бы того, кто просил, без чего-либо для принятия в беседе, в которой он находится, — а если другая сессия уже закрылась, без чего-либо принимаемого где бы то ни было (RN-581 отклоняет принятие в закрытой сессии). Возврат имеет смысл только там, где ничего не теряется: та же сессия, тот же артефакт.
-
Конкурентность через консультативную блокировку ТРАНЗАКЦИИ (
pg_advisory_xact_lock(hashtextextended('handoff:<projeto>:<destino>'))), а не через частичный уникальный индекс. Индекс потребовал бы очистить уже существующие дубликаты ДО его создания, в той же migration, — а новое значение enum не может использоваться в транзакции, которая его создаёт. Очистить, пометивrejected, значило бы солгать; очистить без события нарушило бы собственную линейку. С блокировкой прежние дубликаты сходятся при следующем предложении или включении этого получателя, С событием. Блокировка — по получателю, а не по проекту: предложения разным получателям не ждут друг друга. -
Предложение агенту, уже АКТИВНОМУ в проекте, отклоняется, 409
agente_ja_ativo, без строки и события. Активный =agent.activatedдля него в сессии проекта, которая НЕ терминальная, — прочтение экрана (session-handoffs.ts), расширенное с сессии до проекта; закрытая сессия — для её разговорных агентов.message— текст, который читает модель (RN-163): инструментoffer_handoffпередаёт его буквально, аFalhaDeTurno.origem/1классифицирует его какpolitica, а неcodigo. -
Включение агента замещает ожидающие предложения ему, в ЛЮБОЙ сессии проекта, тремя путями, которые включают: принятие (которое проходит через
ActivateAgentUseCase; принятое ужеacceptedи не затрагивается), прямое включение и включение выполнения (поdev-<modulo>). -
OfferInfraHandoffUseCaseидемпотентен. Каждый получатель (infra,dev-lead) с ожидающим предложением или уже активный не задействуется снова; когда оба таковы, ничего не записывается и ничего не запрашивается у engine (desfecho: ja_oferecido,jaAtendidosговорит почему). Задействуется только недостающее: активный Infra и Dev Lead без предложения запрашивает только предложение Dev Lead, без хода Arquiteto. -
AppSec предлагает только тем, кто ещё не получил и не активен (RN-636). Engine спрашивает api в режиме
seAusente(create_handoff_if_absent/5), вместо того чтобы проверять заранее: вопрос, заданный отсюда, был бы гонкой с другим предложением, а api отвечает под блокировкой получателя. «Уже предложено» и «уже активен» не становятсяagent.error; настоящий сбой по-прежнему описывается по цели (RN-116).
Последствия
- Экран получает ещё один статус.
GET .../handoffsвозвращаетsuperseded, а маршруты создания возвращаютdesfecho. Типы web здесь перегенерированы; то, что экран ДЕЛАЕТ с новым статусом (не предлагает как действенное, читает «активный» проекта), — дело lanehandoff-web(AT-293..295). - Гонка двух кликов в ОДИН И ТОТ ЖЕ момент по «arquitetura pronta» всё ещё
записывает веху
architecture.readiness_confirmedдважды. Ни одно предложение не дублируется (блокировка), а второй ход Arquiteto отклоняется engine с 409turno_em_andamento(ADR 0163). Закрыть и событие потребовало бы удерживать блокировку во время вызова engine, а это сеть внутри транзакции — отвергнуто. - Новое предложение активному агенту перестаёт существовать, а вместе с ним и «повторное включение через предложение». Тот, кто переписывает brief при уже активном PO, получает названный отказ; артефакт остаётся в log, и беседа продолжается с активным агентом. Кому нужен новый раунд, закрывает сессию, в которой он активен.
- Предложение threat model второй истории не рождается, пока предложение первой ожидает (RN-636). Артефакт остаётся в log; получатель при включении читает threat models проекта. Ожидающее предложение не указывает на самый свежий — это цена того, что предложение, ещё ждущее принятия, не замещается.
- Включение выполнения замещает предложения
dev-<modulo>, которые сегодня не рождаются (субагент не получает внешний handoff, ADR 0038). Вызов существует, чтобы правило действовало в тот день, когда изменится иерархия, и стоит одного чтения на модуль. - Прежние дубликаты не очищаются migration. Они сходятся при следующем
предложении или включении того же получателя. Получатель, который больше никогда не
получит ни предложения, ни включения, сохраняет свои
offered, как сегодня.