ADR 0182 — A oferta de handoff ganha ciclo de vida: uma pendente por destino, e superseded
Status
Accepted. 2026-09-30 (AT-291 e AT-292, história HS-075, rodada 29; a AT-273
do vault se funde na AT-292 por decisão do dono). Sobre o
ADR 0038 (handoff externo endereça só lead de área
ou agente sem área, validado em CreateHandoffUseCase) e o
ADR 0090 (o AppSec entrega o
threat model a três destinos). Não os edita: acrescenta um estado e uma
invariante à tabela handoffs.
Context
Medido no código de dev em 2026-09-30:
HandoffStatusdeclaravaoffered | accepted | completed | rejected, mas sóofferedeacceptederam gravados. Não havia expiração, supersessão nem dedupe.CreateHandoffUseCaseinseria uma linha nova a cada chamada. Qualquer repetição virava oferta nova: o agente repetindooffer_handoff, o Criativo reconfirmando a prontidão, o Staff devolvendo outro RFC.OfferInfraHandoffUseCasenão era idempotente. Duplo clique em "arquitetura pronta", ou duas abas, gravava o marco duas vezes e pedia ao engine dois turnos de fechamento do Arquiteto e duas ofertas ao Dev Lead — 2× Infra e 2× Dev Lead na tela.- O AppSec (
criar_handoffs_appsec/3) cria três ofertas por história comrun_design, aos mesmos três destinos (arquiteto,dev-lead,infra). Com N histórias, 3N ofertas pendentes empilhadas. - Uma oferta a um agente que já estava ativo ficava
offeredpara sempre, e a tela a mostrava acionável: aceitar ativaria de novo quem já estava de pé.
Decision
-
Status novo
superseded(migration0064, sóALTER TYPE … ADD VALUE). A oferta que deixou de ser a vigente muda de status na LINHA, como o aceite já fazia, e ganha um eventohandoff.supersededcomhandoffId,toAgent,motivo(agente_ativado|nova_oferta) esubstitutaId. Nunca UPDATE em evento: ohandoff.offeredque anunciou a oferta fica como estava. O evento cai na sessão da oferta VELHA, com o atorsystemhandoff-lifecycle, e entra mesmo em sessão encerrada (não é conversa, RN-581). -
No máximo UMA oferta
offeredpor (projeto, destino). A decisão é pura (decidirOferta,domain/sessions/ciclo-de-vida-do-handoff.ts):- sem pendente, cria;
- pendente na MESMA sessão e a oferta nova não traz artefato novo (
nullou igual) → devolve a existente (desfecho: ja_oferecido), sem linha nem evento. É o duplo clique, as duas abas, o agente repetindo a ferramenta; - qualquer outro caso (artefato NOVO, ou pendente noutra sessão) → cria a
nova e SUBSTITUI a pendente (
desfecho: substituiu_oferta).
Por que substituir, e não sempre devolver a existente. O card pedia as duas opções e preferia não perder o artefato novo. Devolver a antiga quando o artefato mudou deixaria o brief reescrito (ou o RFC novo do Staff) sem oferta que o carregue. Devolver uma pendente de OUTRA sessão deixaria quem pediu sem nada para aceitar na conversa em que está — e, se a outra sessão já fechou, sem nada aceitável em lugar nenhum (RN-581 recusa aceite em sessão encerrada). Devolver só vale onde nada se perde: mesma sessão, mesmo artefato.
-
Concorrência por lock consultivo de TRANSAÇÃO (
pg_advisory_xact_lock(hashtextextended('handoff:<projeto>:<destino>'))), não por índice único parcial. O índice exigiria limpar as duplicatas que já existem ANTES de criá-lo, na mesma migration — e o valor novo do enum não pode ser usado na transação que o cria. Limpar marcandorejectedmentiria; limpar sem evento violaria a própria régua. Com o lock, as duplicatas de antes convergem na próxima oferta ou ativação daquele destino, COM evento. O lock é por destino, não por projeto: ofertas a destinos diferentes não se esperam. -
Oferta a agente já ATIVO no projeto é recusada, 409
agente_ja_ativo, sem linha nem evento. Ativo =agent.activatedpara ele numa sessão do projeto que NÃO é terminal — a leitura da tela (session-handoffs.ts) estendida da sessão ao projeto; sessão encerrada para os conversacionais dela. Amessageé o texto que o modelo lê (RN-163): a ferramentaoffer_handoffa repassa literal, eFalhaDeTurno.origem/1a classifica comopolitica, nãocodigo. -
Ativar agente substitui as ofertas pendentes a ele, em QUALQUER sessão do projeto, pelos três caminhos que ativam: o aceite (que passa por
ActivateAgentUseCase; a aceita já éacceptede não é tocada), a ativação direta e a ativação da execução (pordev-<modulo>). -
OfferInfraHandoffUseCaseidempotente. Cada destino (infra,dev-lead) com oferta pendente ou já ativo não é acionado de novo; com os dois assim, nada é gravado nem pedido ao engine (desfecho: ja_oferecido,jaAtendidosdizendo por quê). Só o que falta é acionado: Infra ativo e Dev Lead sem oferta pede só a do Dev Lead, sem turno do Arquiteto. -
AppSec só oferece a quem ainda não recebeu nem está ativo (RN-636). O engine pergunta à api no modo
seAusente(create_handoff_if_absent/5), em vez de consultar antes: a pergunta feita daqui seria corrida com outra oferta, e a api responde sob o lock do destino. "Já oferecido" e "já ativo" não viramagent.error; falha de verdade continua narrada por alvo (RN-116).
Consequences
- A tela ganha um status a mais.
GET .../handoffsdevolvesuperseded, e as rotas de criação devolvemdesfecho. Os tipos do web são regenerados aqui; o que a tela FAZ com o status novo (não oferecer como acionável, ler "ativo" do projeto) é da lanehandoff-web(AT-293..295). - A corrida de dois cliques no MESMO instante em "arquitetura pronta" ainda
grava o marco
architecture.readiness_confirmedduas vezes. Nenhuma oferta duplica (o lock), e o segundo turno do Arquiteto é recusado pelo engine com 409turno_em_andamento(ADR 0163). Fechar também o evento exigiria segurar o lock durante a chamada ao engine, que é rede dentro de transação — recusado. - Oferta nova a agente ativo deixa de existir, e com ela o "reativar pela oferta". Quem reescreve o brief com o PO já ativo recebe a recusa nomeada; o artefato fica no log e a conversa segue com o agente ativo. Quem precisa de uma nova rodada fecha a sessão em que ele está ativo.
- A oferta do threat model de uma segunda história não nasce enquanto a da primeira estiver pendente (RN-636). O artefato fica no log; o destino, ao ser ativado, lê os threat models do projeto. A oferta pendente não aponta para o mais recente — é o preço de não substituir a oferta que ainda espera aceite.
- Ativação da execução substitui ofertas a
dev-<modulo>, que hoje não nascem (subagente não recebe handoff externo, ADR 0038). A chamada existe para a regra valer no dia em que a hierarquia mudar, e custa uma leitura por módulo. - As duplicatas de antes não são limpas pela migration. Convergem na
próxima oferta ou ativação ao mesmo destino. Um destino que nunca mais
receber nem oferta nem ativação mantém as suas
offered, como hoje.