fix(ai): 修 02D 槽位绑定首次创建死锁,precheck/bind 改 upsert + 补 active 唯一约束

根因:precheck(line102)/bind(line182)都 requireSlotBinding(binding!=null)缺行抛 AI_RESOURCE_FORBIDDEN;但"替换"是设计中唯一创建入口(替换=首次写入,无独立创建端点是设计有意,见后端-04 line748/产品-02D line507),要求被替换行先存在→死锁,首次绑定永不可达。work4 seed binding 是 E2E 绕此 bug 手插非设计常态。修:precheck 缺行放行;bind 抽 persistBinding upsert(有行 updateById 沿用乐观锁 revision+1,无行 insert revision=1 避免首次 expectedSlotRevision null 拆箱 NPE);V29 补 active partial unique index(tenant+work+slot WHERE status=active,并发首次写兜底);前端 mock protected 槽位对齐后端 startsWith(protected:)口径。验证:MuseAgentSlotServiceTest 26/26 + AppMuseAgentSlotControllerAnnotationTest 4/4 + 前端 useAgents/AIPanel.contract 4/4;handoff/幂等/乐观锁不破坏。范围:不建 Override Slot Contract 主数据(B)/不碰默认 agent 来源/不动 runtime 授权。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-24 10:09:53 -07:00
parent df063cbacd
commit 9115d4f95f
4 changed files with 132 additions and 28 deletions

View File

@ -59,6 +59,11 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
// 跨空间 handoff(marketagent):来源类型标识 + precheck 落库的来源 owner 标记(bind 凭此识别 market 放宽路径)
private static final String SOURCE_TYPE_MARKET_AGENT = "market_agent";
private static final String SOURCE_OWNER_MARKET = "market";
// 首次绑定写入 binding_source:同空间记 usermarket handoff market( source_owner 同口径,供后续溯源)
private static final String BINDING_SOURCE_USER = "user";
private static final String BINDING_SOURCE_MARKET = "market";
// 首次绑定的起始 revision:无历史版本, 1 起算(对齐建表 revision 默认 1)
private static final int FIRST_BINDING_REVISION = 1;
@Resource
private MuseAgentSlotBindingMapper slotBindingMapper;
@ -99,7 +104,9 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
requireWorkOwner(workId, ownerUserId);
MuseAgentSlotBindingDO binding = slotBindingMapper.selectActiveByWorkAndSlot(workId, slotKey);
rejectProtectedSlot(slotKey);
requireSlotBinding(binding);
// WHY 不再要求 binding 必须存在:槽位绑定是稀疏 override( active =用系统默认),首次绑定也由
// precheckbind 流程创建("替换"=首次写入,无独立创建端点是设计有意)binding==null requireRevision
// 自动跳过(见该方法守卫),首次 expectedSlotRevision 可为 null;此处放行让首次 precheck 能落库,破除死锁
requireRevision(binding, request.getExpectedSlotRevision());
rejectBlockedSource(request.getAuthorizationSnapshotId());
@ -179,21 +186,16 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
MuseAgentSlotBindingDO binding = slotBindingMapper.selectActiveByWorkAndSlotForUpdate(
TenantContextHolder.getRequiredTenantId(), workId, slotKey);
requireSlotBinding(binding);
// WHY 缺行不再抛错:首次绑定无 active ,bind 是唯一创建入口("替换"=首次写入)requireRevision
// binding==null 自动跳过乐观锁(无历史版本可比),有行时仍按 precheck.expectedSlotRevision 校验
requireRevision(binding, precheck.getExpectedSlotRevision());
precheckMapper.markConsumed(TenantContextHolder.getRequiredTenantId(), request.getAgentSlotPrecheckId(),
LocalDateTime.now());
binding.setAgentId(precheck.getSourceAgentId());
binding.setAgentVersion(String.valueOf(precheck.getSourceAgentVersion()));
binding.setAuthorizationSnapshotId(parseLong(precheck.getAuthorizationSnapshotId()));
binding.setRevision(precheck.getExpectedSlotRevision() + 1);
binding.setStatus(STATUS_ACTIVE);
binding.setCommandId(request.getCommandId());
slotBindingMapper.updateById(binding);
Integer slotRevision = persistBinding(binding, precheck, workId, slotKey, request.getCommandId(), marketHandoff);
AgentSlotBindRespVO response = new AgentSlotBindRespVO();
response.setSlotRevision(binding.getRevision());
response.setSlotRevision(slotRevision);
response.setSourceStatus(STATUS_ACTIVE);
commandService.recordSucceeded(envelope, JsonUtils.toJsonString(response));
audit(OPERATION_BIND, ownerUserId, workId, slotKey, request.getCommandId(), requestHash,
@ -244,10 +246,44 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
}
}
private void requireSlotBinding(MuseAgentSlotBindingDO binding) {
if (binding == null) {
throw new ServiceException(AI_RESOURCE_FORBIDDEN);
/**
* 绑定持久化:稀疏 override upsert
* <p>WHY 分两支:槽位绑定无独立创建端点,首次绑定与替换共用 bind 流程已有 active =替换(updateById,
* revision 在原版本上 +1,沿用乐观锁); active =首次创建(insert,revision {@link #FIRST_BINDING_REVISION}
* 起算)首次 precheck.expectedSlotRevision 可能为 null,故起始 revision 用常量而非 +1,避免拆箱 NPE
* <p>insert agent_id/agent_version(建表 NOT NULL,来源取自 precheck 必填字段)status=activetenant_id;
* binding_source market handoff/同空间分别记 market/user,供后续溯源命中 V29 active partial unique
* index ( work+slot 已有 active 行的并发首次写)会抛唯一约束冲突,"同 work+slot 仅一条 active"一致
*
* @return 写入后的槽位 revision(供响应回传)
*/
private Integer persistBinding(MuseAgentSlotBindingDO binding, MuseAgentSlotPrecheckDO precheck, Long workId,
String slotKey, String commandId, boolean marketHandoff) {
if (binding != null) {
// 替换:已有 active ,沿用既有乐观锁语义(revision = 原版本 + 1)
binding.setAgentId(precheck.getSourceAgentId());
binding.setAgentVersion(String.valueOf(precheck.getSourceAgentVersion()));
binding.setAuthorizationSnapshotId(parseLong(precheck.getAuthorizationSnapshotId()));
binding.setRevision(precheck.getExpectedSlotRevision() + 1);
binding.setStatus(STATUS_ACTIVE);
binding.setCommandId(commandId);
slotBindingMapper.updateById(binding);
return binding.getRevision();
}
// 首次创建: active ,bind 是唯一入口;起始 revision 用常量(首次 expectedSlotRevision 可为 null)
MuseAgentSlotBindingDO created = new MuseAgentSlotBindingDO();
created.setWorkId(workId);
created.setSlotKey(slotKey);
created.setAgentId(precheck.getSourceAgentId());
created.setAgentVersion(String.valueOf(precheck.getSourceAgentVersion()));
created.setAuthorizationSnapshotId(parseLong(precheck.getAuthorizationSnapshotId()));
created.setRevision(FIRST_BINDING_REVISION);
created.setStatus(STATUS_ACTIVE);
created.setBindingSource(marketHandoff ? BINDING_SOURCE_MARKET : BINDING_SOURCE_USER);
created.setCommandId(commandId);
created.setTenantId(TenantContextHolder.getRequiredTenantId());
slotBindingMapper.insert(created);
return created.getRevision();
}
private void requireVisibleActiveSourceAgent(Long ownerUserId, Long sourceAgentId, Integer sourceAgentVersion,

View File

@ -159,18 +159,25 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectPrecheckWhenSlotBindingMissing() {
AgentSlotPrecheckReqVO request = precheckRequest("cmd-precheck-1", 3);
void should_allowFirstPrecheckWhenSlotBindingMissing() {
// 修复死锁:槽位绑定是稀疏 override, active =首次绑定,precheck 不再 forbidden须能落库;
// 首次 expectedSlotRevision 可为 null(无历史版本),requireRevision binding==null 自动跳过
TenantContextHolder.setTenantId(100L);
AgentSlotPrecheckReqVO request = precheckRequest("cmd-precheck-1", null);
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer")).thenReturn(null);
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "写作助手", "system", null, "active"));
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
when(commandService.buildRequestHash(request)).thenReturn("hash-precheck");
when(commandService.reserveCommand(any())).thenReturn(null);
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer")).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, "1", 9001L, "writer", request);
assertEquals(AI_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
verify(commandService).reserveCommand(any());
verify(precheckMapper, never()).insert(any(MuseAgentSlotPrecheckDO.class));
verify(precheckMapper).insert(argThat((MuseAgentSlotPrecheckDO precheck) ->
precheck.getPrecheckId().equals(result.getAgentSlotPrecheckId())
&& "pending".equals(precheck.getStatus())
&& precheck.getExpectedSlotRevision() == null));
verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("agentSlotPrecheckId")));
}
@Test
@ -400,10 +407,13 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectBindWhenLockedSlotBindingMissing() {
void should_createFirstBindingWhenLockedSlotBindingMissing() {
// 修复死锁: active =首次绑定,bind 是唯一创建入口,缺行不再 forbidden,而是 INSERT active ;
// 首次 revision 1 起算(不依赖 precheck.expectedSlotRevision,后者首次为 null);precheck 仍被原子核销
TenantContextHolder.setTenantId(100L);
AgentSlotBindReqVO request = bindRequest("cmd-bind-1", "precheck-1", 3);
AgentSlotBindReqVO request = bindRequest("cmd-bind-1", "precheck-1", null);
MuseAgentSlotPrecheckDO precheck = precheck("precheck-1", "pending", LocalDateTime.now().plusMinutes(5));
precheck.setExpectedSlotRevision(null); // 首次绑定无历史版本
when(commandService.buildRequestHash(any())).thenReturn("hash-bind");
when(commandService.reserveCommand(any())).thenReturn(null);
when(precheckMapper.selectPendingForUpdate(100L, "precheck-1")).thenReturn(precheck);
@ -412,11 +422,48 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer")).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> slotService.bindAgentSlot(10001L, 9001L, "writer", request));
AgentSlotBindRespVO result = slotService.bindAgentSlot(10001L, 9001L, "writer", request);
assertEquals(AI_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
verify(precheckMapper, never()).markConsumed(any(), any(), any());
assertEquals(1, result.getSlotRevision());
assertEquals("active", result.getSourceStatus());
verify(precheckMapper).markConsumed(eq(100L), eq("precheck-1"), any(LocalDateTime.class));
// 首次走 insert( updateById):新行带 work/slot/agent/version/revision=1/status=active/bindingSource=user/commandId/tenant
verify(slotBindingMapper).insert(argThat((MuseAgentSlotBindingDO created) ->
Long.valueOf(9001L).equals(created.getWorkId())
&& "writer".equals(created.getSlotKey())
&& Long.valueOf(1001L).equals(created.getAgentId())
&& "1".equals(created.getAgentVersion())
&& Integer.valueOf(1).equals(created.getRevision())
&& "active".equals(created.getStatus())
&& "user".equals(created.getBindingSource())
&& "cmd-bind-1".equals(created.getCommandId())
&& Long.valueOf(100L).equals(created.getTenantId())));
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
}
@Test
void should_createFirstMarketBindingWithMarketBindingSource() {
// 首次绑定 + market handoff 来源(precheck.source_owner=market):insert 新行的 bindingSource market,
// source_owner 同口径供后续溯源;bind 不重复核验 token(已在 precheck 核销)
TenantContextHolder.setTenantId(100L);
AgentSlotBindReqVO request = bindRequest("cmd-bind-mkt-first", "precheck-mkt", null);
MuseAgentSlotPrecheckDO precheck = precheck("precheck-mkt", "pending", LocalDateTime.now().plusMinutes(5));
precheck.setExpectedSlotRevision(null);
precheck.setSourceOwner("market");
when(commandService.buildRequestHash(any())).thenReturn("hash-bind");
when(commandService.reserveCommand(any())).thenReturn(null);
when(precheckMapper.selectPendingForUpdate(100L, "precheck-mkt")).thenReturn(precheck);
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer")).thenReturn(null);
AgentSlotBindRespVO result = slotService.bindAgentSlot(10001L, 9001L, "writer", request);
assertEquals(1, result.getSlotRevision());
verify(marketHandoffTokenApi, never()).verify(any()); // bind 不重复核验
verify(slotBindingMapper).insert(argThat((MuseAgentSlotBindingDO created) ->
"market".equals(created.getBindingSource()) && Integer.valueOf(1).equals(created.getRevision())));
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
}

View File

@ -0,0 +1,19 @@
-- 为 agent 槽位绑定补"同 work+slot 仅一条 active"唯一约束(隐患修复)。
--
-- 背景:muse_agent_slot_binding 是稀疏 override(无 active 行=用系统默认),首次绑定由 precheck→bind 流程创建。
-- 修复"首次创建死锁"后,bind 在缺 active 行时会 INSERT 新行。但建表(V4)只有普通索引
-- idx_muse_agent_slot_binding_work(tenant_id, work_id, slot_key, status) + command 唯一索引,
-- 无 active 唯一约束;若两个并发首次 bind 同时越过 selectActiveByWorkAndSlotForUpdate 的空结果(各自悲观锁
-- 锁不到不存在的行),会写出同 work+slot 两条 active,违反设计("同 work+slot 仅一条 active",见后端-04)。
--
-- 修法:建 partial unique index(WHERE status='active' AND deleted=false),只对 active 行去重,
-- 软删行/历史非 active 行不占名额。纳入 tenant_id 对齐既有 idx_..._work 与 uk_..._command 的租户维度。
-- 对齐同库 V26 content order_no partial unique 惯例。
-- 安全性:此约束不被任何 ON CONFLICT 引用(bind 走 selectForUpdate + insert/update,不用 ON CONFLICT),
-- 仅作并发兜底;命中即抛唯一约束冲突,与"唯一 active"语义一致。
-- 幂等:DROP INDEX IF EXISTS 后重建,对已应用 V4 的库与全新库均安全。
DROP INDEX IF EXISTS uk_muse_agent_slot_binding_active;
CREATE UNIQUE INDEX uk_muse_agent_slot_binding_active
ON muse_agent_slot_binding (tenant_id, work_id, slot_key)
WHERE status = 'active' AND deleted = FALSE;

View File

@ -101,7 +101,9 @@ const INITIAL_SLOTS: AgentSlotSummary[] = [
sourceStatus: 'healthy',
},
{
slotKey: 'safety.output_guard',
// 对齐后端 protected 口径:isProtectedSlot=slotKey.startsWith("protected:") 是 SSOT,
// 受保护槽位的 slotKey 必带 protected: 前缀(否则前端标 protected=true 但后端按非保护处理,口径漂移)。
slotKey: 'protected:safety.output_guard',
slotName: '输出安全门控',
slotType: 'guardrail',
protected: true,