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:
parent
df063cbacd
commit
9115d4f95f
@ -59,6 +59,11 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
// 跨空间 handoff(market→agent):来源类型标识 + precheck 落库的来源 owner 标记(bind 凭此识别 market 放宽路径)
|
||||
private static final String SOURCE_TYPE_MARKET_AGENT = "market_agent";
|
||||
private static final String SOURCE_OWNER_MARKET = "market";
|
||||
// 首次绑定写入 binding_source:同空间记 user、market 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 行=用系统默认),首次绑定也由
|
||||
// precheck→bind 流程创建("替换"=首次写入,无独立创建端点是设计有意)。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=active、tenant_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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user