ADR 0182 — handoff 提议获得生命周期:每个目标一个待处理,以及 superseded
状态
Accepted. 2026-09-30(AT-291 和 AT-292,故事 HS-075,第 29 轮;按负责人的决定,vault 中的 AT-273 并入 AT-292)。基于
ADR 0038(外部 handoff 只指向区域 lead 或没有区域的 agent,在 CreateHandoffUseCase 中验证)以及
ADR 0090(AppSec 把威胁模型交付给三个目标)。不编辑它们:为 handoffs 表增加一个状态和一个不变式。
Context
2026-09-30 在 dev 的代码上测量:
HandoffStatus声明了offered | accepted | completed | rejected,但只有offered和accepted被写入。没有过期、取代或去重。CreateHandoffUseCase每次调用都插入新行。任何重复都会变成新提议:agent 重复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 个待处理提议堆积。 - 给一个早已活跃的 agent 的提议会永远保持
offered,界面把它显示为可操作:接受会重新启用早已在运行的那个。
Decision
-
新状态
superseded(迁移0064,只有ALTER TYPE … ADD VALUE)。不再是当前提议的那个在行上改变状态,就像接受早已做的那样,并获得一个带handoffId、toAgent、motivo(agente_ativado|nova_oferta)和substitutaId的handoff.superseded事件。绝不 UPDATE 事件:宣告提议的handoff.offered保持原样。该事件落在旧提议的会话中,参与者为systemhandoff-lifecycle,即使在已结束的会话中也会写入(它不是对话,RN-581)。 -
每个(项目,目标)最多一个**
offered提议。** 决定是纯函数 (decidirOferta,domain/sessions/ciclo-de-vida-do-handoff.ts):- 没有待处理的,创建;
- 在同一会话中有待处理的,且新提议没有带来新工件(
null或相同)→ 返回现有的(desfecho: ja_oferecido),没有新行也没有事件。这就是双击、两个标签页、agent 重复调用工具; - 任何其他情况(新工件,或待处理的在另一会话中)→ 创建新的并取代待处理的(
desfecho: substituiu_oferta)。
为什么取代,而不是总是返回现有的。 卡片要求两个选项,并倾向于不丢失新工件。在工件变化时返回旧的,会让重写的 brief(或 Staff 的新 RFC)没有承载它的提议。返回另一个会话中的待处理提议,会让请求者在他所在的对话中没有任何可接受的东西——而如果另一个会话已经关闭,就在任何地方都没有可接受的东西(RN-581 拒绝在已结束会话中接受)。返回只在不丢失任何东西时才成立:同一会话、同一工件。
-
通过事务级咨询锁实现并发 (
pg_advisory_xact_lock(hashtextextended('handoff:<projeto>:<destino>'))),而不是部分唯一索引。索引需要在创建它之前、在同一迁移中清理已经存在的重复——而枚举的新值不能在创建它的事务中使用。通过标记rejected来清理是撒谎;不带事件地清理则违反规则本身。有了锁,之前的重复会在该目标的下一次提议或启用时收敛,带事件。锁按目标而不是按项目:给不同目标的提议彼此不等待。 -
给项目中已活跃** agent 的提议会被拒绝**,409
agente_ja_ativo,没有新行也没有事件。活跃 = 在项目的某个非终止会话中有针对它的agent.activated——即界面的读取方式(session-handoffs.ts)从会话扩展到项目;已结束的会话对其对话型 agent 来说已关闭。message是模型所读的文本(RN-163):工具offer_handoff原样转发它,FalhaDeTurno.origem/1把它归类为politica,而不是codigo。 -
启用 agent 会取代给它的待处理提议,在项目的任何会话中,经由三条启用路径:接受(经过
ActivateAgentUseCase;被接受的那个已经是accepted,不被触碰)、直接启用以及执行的启用(按dev-<modulo>)。 -
OfferInfraHandoffUseCase幂等。 每个已有待处理提议或已经活跃的目标(infra、dev-lead)不会被再次触发;两者都如此时,不写入任何东西,也不向 engine 请求任何东西(desfecho: ja_oferecido,jaAtendidos说明原因)。只触发缺少的部分:Infra 活跃而 Dev Lead 没有提议时,只请求 Dev Lead 的那个,不需要 Arquiteto 的轮次。 -
AppSec 只向尚未收到也未活跃的目标提供(RN-636)。 engine 以
seAusente模式询问 api(create_handoff_if_absent/5),而不是事先查询:从这里提出的询问会与另一个提议竞争,而 api 在目标的锁下作答。“已提供”和“已活跃”不会变成agent.error;真正的失败继续按目标叙述(RN-116)。
Consequences
- 界面多了一个状态。
GET .../handoffs返回superseded,创建路由返回desfecho。web 的类型在此重新生成;界面如何处理新状态(不再作为可操作提供、从项目读取“活跃”)属于handoff-weblane(AT-293..295)。 - 在“arquitetura pronta”上同一瞬间两次点击的竞争仍会把里程碑
architecture.readiness_confirmed记录两次。 没有提议重复(锁),Arquiteto 的第二个轮次被 engine 以 409turno_em_andamento拒绝(ADR 0163)。连事件也关闭需要在调用 engine 期间持有锁,那是事务中的网络——被拒绝。 - 给活跃 agent 的新提议不再存在,“通过提议重新启用”也随之消失。 在 PO 已活跃时重写 brief 的人会收到带名称的拒绝;工件留在日志中,对话与活跃的 agent 继续。需要新一轮的人要关闭它所活跃的那个会话。
- 当第一个故事的威胁模型提议仍待处理时,第二个故事的提议不会诞生(RN-636)。工件留在日志中;目标在被启用时会读取项目的威胁模型。待处理的提议不会指向最新的——这是不取代一个仍在等待接受的提议的代价。
- 执行的启用会取代给
dev-<modulo>的提议,而这些今天并不会诞生(子 agent 不接收外部 handoff,ADR 0038)。这个调用的存在是为了让规则在层级改变的那天生效,代价是每个模块一次读取。 - 之前的重复不由迁移清理。 它们在给同一目标的下一次提议或启用时收敛。一个再也不会收到提议或启用的目标会保留它的
offered,就像今天这样。