From 6dc5fa201ed3028d89ab7778fb91d336e1ca3283 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Tue, 15 Sep 2026 09:38:12 +0800 Subject: [PATCH] fix(goal): preserve unselected queue snapshots --- .../vip/mate/channel/web/ChatController.java | 2 +- .../goal/service/GoalApprovalRunService.java | 17 ++++++-- .../docs/en/managed-json-acceptance.md | 2 +- .../docs/zh/managed-json-acceptance.md | 2 +- .../web/ChatControllerDurableQueueTest.java | 40 +++++++++++++++++++ ...alJsonExternalApprovalIntegrationTest.java | 2 +- .../GoalJsonHttpRuntimeIntegrationTest.java | 8 ++++ 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index d2149373..26670263 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1544,7 +1544,7 @@ public class ChatController { "排队消息缺少Goal选择快照,内容已保存,请重新发送"); return; } - if (preConsumedInput.selectedGoalId() != null && preConsumedInput.selectedGoalId() > 0) { + if (preConsumedInput.selectedGoalId() != null) { var selectedOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId, preConsumedInput.createdBy(), queuedConversation.getWorkspaceId(), null, baseUrl, preConsumedInput.requesterUserId()) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java index 28815ab9..fe32c8b3 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java @@ -38,7 +38,7 @@ public class GoalApprovalRunService { public ChatOrigin captureSelectedGoal(ChatOrigin origin) { if (origin == null || origin.cronOrigin() || origin.requesterUserId() == null || origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null - || (origin.selectedGoalId() != null && origin.selectedGoalId() > 0) + || origin.selectedGoalId() != null || (origin.executionAttribution() != null && origin.executionAttribution().goalAttemptId() != null)) return origin; var selected = jdbc.queryForList(""" @@ -53,13 +53,22 @@ public class GoalApprovalRunService { return origin.withSelectedGoalId(selected.getFirst()); } - /** A queued selected turn must still have its original Goal and account before execution starts. */ + /** A queued selection snapshot, including explicit zero, must still match before execution starts. */ public boolean queuedSelectionStillCurrent(ChatOrigin origin) { - if (origin == null || origin.selectedGoalId() == null || origin.selectedGoalId() <= 0 - || origin.requesterUserId() == null || origin.requesterId() == null) + if (origin == null || origin.selectedGoalId() == null || origin.selectedGoalId() < 0 + || origin.requesterUserId() == null || origin.requesterId() == null + || origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null) return false; try { return acceptance.withAuthenticatedUser(origin.requesterUserId(), origin.requesterId(), current -> { + if (origin.selectedGoalId() == 0) { + Integer selected = jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_agent_goal + WHERE conversation_id=? AND agent_id=? AND workspace_id=? + AND json_acceptance_required=TRUE AND status IN ('active','paused') AND deleted=0 + """, Integer.class, origin.conversationId(), origin.agentId(), origin.workspaceId()); + return selected != null && selected == 0; + } var scope = acceptance.authorizedGoal(origin.selectedGoalId(), current, true); return scope.required() && java.util.List.of("active", "paused").contains(scope.status()) && Objects.equals(scope.conversationId(), origin.conversationId()) diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index 8a55aa97..249b3bd8 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -59,7 +59,7 @@ Built-in shell/code execution is not OS-isolated from the service host. Selectin Recovery attempts receive guidance to inspect existing evidence before repeating work. If the first recovered segment is deferred before execution, its recovery context is retained for the next claim. Ordinary continuation after an executed segment does not become a new recovery. -From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. Before a selected queued turn starts, the server rechecks its account and Goal. If either is no longer current, it saves the user text and asks for a fresh request without starting the agent. An old queue row without a selection snapshot is handled the same way when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. A persistent Goal worker also checks the queued selection and original account before running it. It saves a mismatched, revoked or legacy-unknown input from a conversation with managed Goal history as conversation text with a durable assistant notice, then continues to later queued items. An explicitly unselected queue item still follows the unselected path for an active unmanaged Goal. A terminal Goal cannot run any queued input; its text and notice are saved instead. A paused Goal retains its claimed input for processing after resume. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers also retain their attempt-owner validation; queue validation does not replace the lease check. +From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. Before a selected queued turn starts, the server rechecks its account and Goal. If either is no longer current, it saves the user text and asks for a fresh request without starting the agent. An old queue row without a selection snapshot is handled the same way when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. A persistent Goal worker also checks the queued selection and original account before running it. It saves a mismatched, revoked or legacy-unknown input from a conversation with managed Goal history as conversation text with a durable assistant notice, then continues to later queued items. An explicitly unselected queue item still follows the unselected path for an active unmanaged Goal. Its zero selection snapshot is not recaptured into a managed Goal that appears while the item waits; the Web consumer saves and skips that text and asks the user to resend instead. A terminal Goal cannot run any queued input; its text and notice are saved instead. A paused Goal retains its claimed input for processing after resume. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers also retain their attempt-owner validation; queue validation does not replace the lease check. Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index fb6c97ef..6a61a220 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -57,7 +57,7 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效, 内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。 -Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;V201还在入队时保存选定的受管Goal ID。选定Goal的排队消息在开始执行前复查账户与Goal;若已失效,只保存用户文字并提示重新发送,不启动Agent。升级前没有选择快照的队列行,若会话有受管Goal历史,也按此方式处理。聊天页面会移除这条排队状态并提示重新发送,后续排队消息继续处理。持久Goal工作器也在执行排队消息前校验原选定Goal与账户;不匹配、已撤权或有受管历史的旧未知选择时,保存用户正文和持久的助手告知,再继续后续队列。明确未选定的队列项仍可按ACTIVE未受管Goal旧路径运行;终态Goal不执行任何排队消息,而是保存正文和告知。Goal暂停时保留已领取的队列行,恢复后再处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器仍校验原有attempt owner及租约,队列校验不能代替它。 +Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;V201还在入队时保存选定的受管Goal ID。选定Goal的排队消息在开始执行前复查账户与Goal;若已失效,只保存用户文字并提示重新发送,不启动Agent。升级前没有选择快照的队列行,若会话有受管Goal历史,也按此方式处理。聊天页面会移除这条排队状态并提示重新发送,后续排队消息继续处理。持久Goal工作器也在执行排队消息前校验原选定Goal与账户;不匹配、已撤权或有受管历史的旧未知选择时,保存用户正文和持久的助手告知,再继续后续队列。明确未选定的队列项仍可按ACTIVE未受管Goal旧路径运行;它的零值选择快照不会在等待期间被新出现的受管Goal重新捕获,Web消费者会保存并跳过正文,提示用户重新发送。终态Goal不执行任何排队消息,而是保存正文和告知。Goal暂停时保留已领取的队列行,恢复后再处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器仍校验原有attempt owner及租约,队列校验不能代替它。 恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。 diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java index d7122a27..bb51dadd 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java @@ -137,6 +137,45 @@ class ChatControllerDurableQueueTest { org.mockito.Mockito.verifyNoInteractions(agents); } + @Test + void explicitlyUnselectedQueuedInputDoesNotJoinGoalThatAppearedWhileWaiting() { + AgentService agents = mock(AgentService.class); + ConversationService conversations = mock(ConversationService.class); + ConversationInputQueueStore queue = mock(ConversationInputQueueStore.class); + ChatStreamTracker streams = mock(ChatStreamTracker.class); + var input = new QueuedInput(94L, "conv", 2L, "alice", "unselected queued text", + List.of(), "claimed", "claim", null, null, + LocalDateTime.now(), LocalDateTime.now(), 42L, 0L); + when(queue.claimNext(eq("conv"), any(), any())).thenReturn(java.util.Optional.of(input)); + var saved = new vip.mate.workspace.conversation.model.MessageEntity(); saved.setId(102L); + when(conversations.saveMessage("conv", "user", "unselected queued text", List.of(), "queued")) + .thenReturn(saved); + when(queue.bindMessage(eq(94L), any(), eq(102L), any())).thenReturn(true); + when(queue.consume(eq(94L), any(), any())).thenReturn(true); + when(agents.chatStructuredStream(eq(2L), eq("unselected queued text"), eq("conv"), + eq("alice"), any(), any())).thenReturn(reactor.core.publisher.Flux.never()); + var conversation = new vip.mate.workspace.conversation.model.ConversationEntity(); + conversation.setConversationId("conv"); conversation.setAgentId(2L); conversation.setWorkspaceId(3L); + when(conversations.findByConversationId("conv")).thenReturn(conversation); + var runs = mock(vip.mate.goal.service.GoalApprovalRunService.class); + when(runs.queuedSelectionStillCurrent(any())).thenReturn(false); + ChatController controller = new ChatController(agents, conversations, mock(ApprovalWorkflowService.class), + streams, new ObjectMapper(), mock(ConversationCompletionPublisher.class), + mock(MemoryOwnerResolver.class), mock(ChatUploadLocationResolver.class), + mock(OfficePreviewService.class), queue); + org.springframework.test.util.ReflectionTestUtils.setField(controller, "goalApprovalRuns", runs); + + var emitter = new RecordingEmitter(); + org.springframework.test.util.ReflectionTestUtils.invokeMethod(controller, "startQueuedMessage", "conv", + emitter, new java.util.concurrent.atomic.AtomicBoolean(false), "alice", "http://localhost"); + + org.mockito.Mockito.verify(runs).queuedSelectionStillCurrent(any()); + org.mockito.Mockito.verify(conversations).saveMessage("conv", "user", "unselected queued text", List.of(), "queued"); + org.mockito.Mockito.verify(queue).consume(eq(94L), any(), any()); + assertThat(emitter.events.toString()).contains("queued_input_skipped", "unselected queued text"); + org.mockito.Mockito.verifyNoInteractions(agents); + } + @Test void skippingAnOldRowStillRunsTheNextQueuedMessage() { AgentService agents = mock(AgentService.class); @@ -158,6 +197,7 @@ class ChatControllerDurableQueueTest { when(conversations.findByConversationId("conv")).thenReturn(conversation); var runs = mock(vip.mate.goal.service.GoalApprovalRunService.class); when(runs.hasManagedGoalHistory("conv", "2")).thenReturn(true); + when(runs.queuedSelectionStillCurrent(any())).thenReturn(true); when(runs.captureSelectedGoal(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(agents.chatStructuredStream(eq(2L), eq("next"), eq("conv"), eq("alice"), any(), any())) .thenReturn(reactor.core.publisher.Flux.never()); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonExternalApprovalIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonExternalApprovalIntegrationTest.java index 812770b4..42be0809 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonExternalApprovalIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonExternalApprovalIntegrationTest.java @@ -14,7 +14,7 @@ class GoalJsonExternalApprovalIntegrationTest extends GoalJsonHttpRuntimeIntegra @Override @ParameterizedTest - @CsvSource({"false,approval,true", "true,approval,true", + @CsvSource({"false,sync,true", "false,approval,true", "true,approval,true", "false,scheduled-queued,true", "true,scheduled-queued,true", "false,scheduled-queued-foreign,true", "true,scheduled-queued-foreign,true", "false,scheduled-queued-legacy,true", "true,scheduled-queued-legacy,true", diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java index 42183a3b..e8956490 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java @@ -149,6 +149,14 @@ class GoalJsonHttpRuntimeIntegrationTest { JsonNode configured = request("PUT", "/api/v1/goals/" + goal.getId() + "/json-acceptance/requirements/r", token, Map.of("expectedRevision", "0", "artifactSlot", "report", "requiredFields", List.of("summary"))); assertEquals(200, configured.path("code").asInt(), configured.toString()); + if (!plan && entry.equals("sync") && accepted) { + var explicitlyUnselected = ChatOrigin.web(conversation, username, 1L, null, null, userId) + .withAgent(agentId).withSelectedGoalId(0L); + assertEquals(0L, approvalRuns.captureSelectedGoal(explicitlyUnselected).selectedGoalId(), + "An explicit queue snapshot must not be recaptured into a later Goal"); + assertFalse(approvalRuns.queuedSelectionStillCurrent(explicitlyUnselected), + "An unselected queue snapshot must become stale when a managed Goal appears"); + } if (reuse) { for (long generation = 0; generation < 32; generation++) { artifacts.publish(goal.getId(), "report", new ManagedGoalJsonService.PublishRequest(generation, "{\"summary\":false}"), username);