From dc3506f748c1bee9ad15b0a33fabc459ec847138 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 13 Aug 2026 07:00:24 -0400 Subject: [PATCH] feat(team): unify team run experience (#596) --- .../vip/mate/agent/context/ChatOrigin.java | 39 +- .../agent/controller/AgentController.java | 27 +- .../mate/channel/ChannelMessageRouter.java | 12 +- .../vip/mate/channel/web/ChatController.java | 24 +- .../mate/channel/web/ChatStreamTracker.java | 33 +- .../mate/channel/web/SseEventIdGenerator.java | 43 +++ .../channel/web/TalkModeWebSocketHandler.java | 6 +- .../channel/webchat/WebChatController.java | 10 +- .../vip/mate/cron/CronChatOriginFactory.java | 6 +- .../cron/service/CronJobLifecycleService.java | 15 +- .../vip/mate/cron/service/CronJobRunner.java | 8 +- .../mate/team/controller/TeamController.java | 16 +- .../team/controller/TeamRunController.java | 87 +++++ .../event/TeamRunCancelCommittedIntent.java | 19 + .../event/TeamRunDispatchCommittedIntent.java | 5 + .../mate/team/model/TeamRunCreateCommand.java | 26 ++ .../vip/mate/team/model/TeamRunEntity.java | 65 ++++ .../vip/mate/team/model/TeamRunStatus.java | 25 ++ .../java/vip/mate/team/model/TeamRunView.java | 64 ++++ .../team/model/TeamTaskCreateCommand.java | 2 + .../vip/mate/team/model/TeamTaskEntity.java | 2 + .../mate/team/repository/TeamRunMapper.java | 10 + .../service/SpringTeamRunEventPublisher.java | 20 + .../team/service/TeamAnnounceService.java | 224 +++++++---- .../mate/team/service/TeamContextBuilder.java | 6 +- .../team/service/TeamDispatchService.java | 5 +- .../mate/team/service/TeamEventChannel.java | 69 +++- .../team/service/TeamManualTaskService.java | 67 ++++ .../vip/mate/team/service/TeamPlanBridge.java | 39 +- .../service/TeamRunApplicationService.java | 47 +++ .../TeamRunCommittedIntentListener.java | 55 +++ .../team/service/TeamRunEventPublisher.java | 9 + .../service/TeamRunProjectionExecutor.java | 30 ++ .../service/TeamRunProjectionScheduler.java | 66 ++++ .../mate/team/service/TeamRunProjector.java | 112 ++++++ .../vip/mate/team/service/TeamRunService.java | 280 ++++++++++++++ .../team/service/TeamRunStateMachine.java | 78 ++++ .../mate/team/service/TeamTaskService.java | 105 ++++- .../vip/mate/team/tool/TeamTasksTool.java | 86 ++++- .../tool/builtin/ToolExecutionContext.java | 4 + .../h2/V181__team_run_foundation.sql | 36 ++ .../kingbase/V181__team_run_foundation.sql | 36 ++ .../mysql/V181__team_run_foundation.sql | 50 +++ .../mate/agent/context/ChatOriginTest.java | 15 + .../controller/AgentControllerOriginTest.java | 116 ++++++ .../web/ChatStreamTrackerEventIdTest.java | 122 ++++++ .../mate/cron/CronChatOriginFactoryTest.java | 28 ++ .../service/CronJobOriginPropagationTest.java | 92 +++++ .../vip/mate/team/MigrationSmokeTest.java | 181 +++++++++ .../team/controller/TeamControllerTest.java | 122 +++++- .../controller/TeamRunControllerTest.java | 147 +++++++ .../SpringTeamRunEventPublisherTest.java | 28 ++ .../team/service/TeamAnnounceServiceTest.java | 165 +++++++- .../team/service/TeamContextBuilderTest.java | 5 + .../service/TeamDispatchServiceEventTest.java | 91 +++++ .../team/service/TeamEventChannelTest.java | 107 ++++++ .../mate/team/service/TeamPlanBridgeTest.java | 91 ++++- .../TeamRunApplicationServiceTest.java | 98 +++++ .../TeamRunCommittedIntentEventTest.java | 321 ++++++++++++++++ .../TeamRunProjectionExecutorTest.java | 65 ++++ .../TeamRunProjectionSchedulerTest.java | 106 ++++++ .../team/service/TeamRunProjectorTest.java | 205 ++++++++++ .../mate/team/service/TeamRunServiceTest.java | 360 ++++++++++++++++++ .../team/service/TeamRunStateMachineTest.java | 118 ++++++ .../team/service/TeamTaskServiceTest.java | 214 ++++++++++- .../vip/mate/team/tool/TeamTasksToolTest.java | 225 ++++++++++- .../src/api/__tests__/teamRuns.test.ts | 92 +++++ mateclaw-ui/src/api/index.ts | 75 ++++ .../src/components/chat/MessageList.vue | 69 +++- .../src/components/chat/TeamWorkerBanner.vue | 77 ++++ .../chat/__tests__/TeamWorkerBanner.test.ts | 36 ++ .../teamRunTimelineRendering.test.ts | 92 +++++ .../src/components/live/AgentRunGroups.vue | 68 ++++ .../src/components/live/AgentRunWorkerRow.vue | 45 +++ mateclaw-ui/src/components/live/LivePanel.vue | 117 ++++-- .../live/__tests__/AgentRunGroups.test.ts | 49 +++ .../src/components/team-run/TeamRunCard.vue | 105 +++++ .../src/components/team-run/TeamRunDetail.vue | 144 +++++++ .../src/components/team-run/TeamRunDrawer.vue | 61 +++ .../components/team-run/TeamRunProgress.vue | 72 ++++ .../src/components/team-run/TeamRunStatus.vue | 77 ++++ .../components/team-run/TeamRunTaskList.vue | 114 ++++++ .../src/components/team-run/TeamRunsPanel.vue | 74 ++++ .../__tests__/teamRunComponents.test.ts | 115 ++++++ .../__tests__/teamRunPresentation.test.ts | 195 ++++++++++ .../__tests__/teamRunsWorkspace.test.ts | 73 ++++ mateclaw-ui/src/components/team-run/index.ts | 8 + .../team-run/teamRunPresentation.ts | 220 +++++++++++ .../__tests__/agentsLiveRouteState.test.ts | 94 +++++ .../composables/__tests__/sseEventIds.test.ts | 15 + .../__tests__/useAgentRunGroups.test.ts | 102 +++++ .../__tests__/useLiveSnapshot.test.ts | 69 ++++ .../__tests__/useTeamEvents.test.ts | 153 ++++++++ .../__tests__/useTeamRunHistory.test.ts | 218 +++++++++++ .../src/composables/agentsLiveRouteState.ts | 72 ++++ .../chat/__tests__/messageMetadata.test.ts | 147 +++++++ .../chat/__tests__/teamRunTimeline.test.ts | 77 ++++ .../chat/__tests__/useStreamEventId.test.ts | 30 ++ .../chat/__tests__/useTeamRuns.test.ts | 192 ++++++++++ .../src/composables/chat/messageMetadata.ts | 129 +++++++ .../src/composables/chat/teamRunTimeline.ts | 53 +++ .../src/composables/chat/useTeamRuns.ts | 160 ++++++++ mateclaw-ui/src/composables/sseEventIds.ts | 42 ++ .../src/composables/teamsRouteState.ts | 71 ++++ .../src/composables/useAgentRunGroups.ts | 145 +++++++ .../src/composables/useLiveSnapshot.ts | 37 ++ mateclaw-ui/src/composables/useTeamEvents.ts | 199 +++++++--- .../src/composables/useTeamRunHistory.ts | 168 ++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 64 ++++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 64 ++++ .../__tests__/teamStoreGeneration.test.ts | 92 +++++ mateclaw-ui/src/stores/useTeamStore.ts | 29 +- mateclaw-ui/src/types/index.ts | 6 + .../__tests__/generatedFileLinks.test.ts | 15 +- mateclaw-ui/src/utils/chatRouteHydration.ts | 39 ++ mateclaw-ui/src/utils/generatedFileLinks.ts | 13 + mateclaw-ui/src/views/Agents.vue | 10 +- mateclaw-ui/src/views/ChatConsole.vue | 57 ++- mateclaw-ui/src/views/Teams.vue | 270 +++++++++++-- .../chatConsoleRouteHydration.test.ts | 47 ++- mateclaw-ui/vitest.config.ts | 2 + 121 files changed, 9517 insertions(+), 332 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/web/SseEventIdGenerator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/event/TeamRunCancelCommittedIntent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/event/TeamRunDispatchCommittedIntent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamRunCreateCommand.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamRunEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamRunStatus.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/TeamRunMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/SpringTeamRunEventPublisher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamManualTaskService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunApplicationService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunCommittedIntentListener.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunEventPublisher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionExecutor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionScheduler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamRunStateMachine.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V181__team_run_foundation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V181__team_run_foundation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V181__team_run_foundation.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/controller/AgentControllerOriginTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/cron/CronChatOriginFactoryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/SpringTeamRunEventPublisherTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceEventTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamEventChannelTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunApplicationServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunCommittedIntentEventTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionExecutorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionSchedulerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamRunStateMachineTest.java create mode 100644 mateclaw-ui/src/api/__tests__/teamRuns.test.ts create mode 100644 mateclaw-ui/src/components/chat/TeamWorkerBanner.vue create mode 100644 mateclaw-ui/src/components/chat/__tests__/TeamWorkerBanner.test.ts create mode 100644 mateclaw-ui/src/components/chat/__tests__/teamRunTimelineRendering.test.ts create mode 100644 mateclaw-ui/src/components/live/AgentRunGroups.vue create mode 100644 mateclaw-ui/src/components/live/AgentRunWorkerRow.vue create mode 100644 mateclaw-ui/src/components/live/__tests__/AgentRunGroups.test.ts create mode 100644 mateclaw-ui/src/components/team-run/TeamRunCard.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunDetail.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunDrawer.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunProgress.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunStatus.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunTaskList.vue create mode 100644 mateclaw-ui/src/components/team-run/TeamRunsPanel.vue create mode 100644 mateclaw-ui/src/components/team-run/__tests__/teamRunComponents.test.ts create mode 100644 mateclaw-ui/src/components/team-run/__tests__/teamRunPresentation.test.ts create mode 100644 mateclaw-ui/src/components/team-run/__tests__/teamRunsWorkspace.test.ts create mode 100644 mateclaw-ui/src/components/team-run/index.ts create mode 100644 mateclaw-ui/src/components/team-run/teamRunPresentation.ts create mode 100644 mateclaw-ui/src/composables/__tests__/agentsLiveRouteState.test.ts create mode 100644 mateclaw-ui/src/composables/__tests__/sseEventIds.test.ts create mode 100644 mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts create mode 100644 mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts create mode 100644 mateclaw-ui/src/composables/__tests__/useTeamEvents.test.ts create mode 100644 mateclaw-ui/src/composables/__tests__/useTeamRunHistory.test.ts create mode 100644 mateclaw-ui/src/composables/agentsLiveRouteState.ts create mode 100644 mateclaw-ui/src/composables/chat/__tests__/messageMetadata.test.ts create mode 100644 mateclaw-ui/src/composables/chat/__tests__/teamRunTimeline.test.ts create mode 100644 mateclaw-ui/src/composables/chat/__tests__/useStreamEventId.test.ts create mode 100644 mateclaw-ui/src/composables/chat/__tests__/useTeamRuns.test.ts create mode 100644 mateclaw-ui/src/composables/chat/messageMetadata.ts create mode 100644 mateclaw-ui/src/composables/chat/teamRunTimeline.ts create mode 100644 mateclaw-ui/src/composables/chat/useTeamRuns.ts create mode 100644 mateclaw-ui/src/composables/sseEventIds.ts create mode 100644 mateclaw-ui/src/composables/teamsRouteState.ts create mode 100644 mateclaw-ui/src/composables/useAgentRunGroups.ts create mode 100644 mateclaw-ui/src/composables/useLiveSnapshot.ts create mode 100644 mateclaw-ui/src/composables/useTeamRunHistory.ts create mode 100644 mateclaw-ui/src/stores/__tests__/teamStoreGeneration.test.ts diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 46118d6a..0e4e2035 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -75,15 +75,29 @@ public record ChatOrigin( * forwarding uses this to tell "MateClaw authenticated this user" apart * from "this is an external/anonymous identifier" (RFC: identity typing). */ - @Nullable Long requesterUserId + @Nullable Long requesterUserId, + @Nullable Long originMessageId ) { + public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId, + @Nullable String requesterId, @Nullable Long workspaceId, + @Nullable String workspaceBasePath, @Nullable Long channelId, + @Nullable ChannelTarget channelTarget, boolean cronOrigin, + @Nullable String senderName, @Nullable String channelType, + @Nullable String chatId, @Nullable String baseUrl, + @Nullable Long requesterUserId) { + this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, + channelId, channelTarget, cronOrigin, senderName, channelType, + chatId, baseUrl, requesterUserId, null); + } + /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ public static final String CTX_KEY = "mateclaw.chatOrigin"; /** Sentinel used by AgentService default overloads where no origin is supplied. */ public static final ChatOrigin EMPTY = - new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null); + new ChatOrigin(null, null, "", null, null, null, null, false, + null, null, null, null, null, null); // ---------------- Factories per entry point ---------------- @@ -117,7 +131,7 @@ public record ChatOrigin( return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl, - requesterUserId); + requesterUserId, null); } public static ChatOrigin cron(@Nullable String conversationId, @@ -126,7 +140,8 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null); + workspaceId, workspaceBasePath, channelId, target, true, + null, null, null, null, null, null); } // ---------------- Wither-style updates ---------------- @@ -134,27 +149,27 @@ public record ChatOrigin( public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); } /** Carry a request-derived public base URL (see {@link #baseUrl()}). */ public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, newBaseUrl, requesterUserId); + senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId); } /** @@ -168,7 +183,13 @@ public record ChatOrigin( @Nullable String newChatId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - newSenderName, newChannelType, newChatId, baseUrl, requesterUserId); + newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId); + } + + public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) { + return new ChatOrigin(agentId, conversationId, requesterId, + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index e3b77655..bbe10d8c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -27,6 +27,9 @@ import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.service.WorkspaceService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; import java.io.IOException; import java.util.List; @@ -47,6 +50,7 @@ import java.util.concurrent.Executors; public class AgentController { private final AgentService agentService; + private final ConversationService conversationService; private final AuditEventService auditEventService; private final AuthService authService; private final WorkspaceService workspaceService; @@ -211,12 +215,13 @@ public class AgentController { AgentEntity agent = agentService.getAgent(id); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyAgentEnabled(agent); + ChatOrigin origin = persistOrigin(agent, id, message, conversationId, workspaceId); // RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码 SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L); sseExecutor.execute(() -> { try { - agentService.chatStream(id, message, conversationId) + agentService.chatStream(id, message, conversationId, origin) .doOnNext(chunk -> { try { emitter.send(SseEmitter.event().name("message").data(chunk)); @@ -251,7 +256,9 @@ public class AgentController { AgentEntity agent = agentService.getAgent(id); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyAgentEnabled(agent); - return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId())); + ChatOrigin origin = persistOrigin(agent, id, request.getMessage(), + request.getConversationId(), workspaceId); + return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId(), origin)); } @Operation(summary = "执行复杂任务(Plan-Execute)") @@ -264,7 +271,21 @@ public class AgentController { AgentEntity agent = agentService.getAgent(id); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyAgentEnabled(agent); - return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId())); + ChatOrigin origin = persistOrigin(agent, id, request.getMessage(), + request.getConversationId(), workspaceId); + return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId(), origin)); + } + + private ChatOrigin persistOrigin(AgentEntity agent, Long agentId, String message, + String conversationId, Long requestedWorkspaceId) { + Long resolvedWorkspaceId = agent != null && agent.getWorkspaceId() != null + ? agent.getWorkspaceId() + : requestedWorkspaceId != null ? requestedWorkspaceId : 1L; + MessageEntity savedUser = conversationService.saveMessage( + conversationId, "user", message); + return ChatOrigin.web(conversationId, "anonymous", resolvedWorkspaceId, null) + .withAgent(agentId) + .withOriginMessageId(savedUser == null ? null : savedUser.getId()); } @Operation(summary = "获取Agent运行状态") diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 3a32a716..184f7905 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -827,7 +827,8 @@ public class ChannelMessageRouter { // through unchanged (chatId is null). List parts = message.getContentParts(); String attributedContent = applyGroupTag(message, message.getContent()); - conversationService.saveMessage(conversationId, "user", attributedContent, parts); + MessageEntity savedUser = conversationService.saveMessage( + conversationId, "user", attributedContent, parts); // 构建 prompt(语音输入时注入场景提示词) String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); @@ -853,7 +854,8 @@ public class ChannelMessageRouter { // so cron jobs created during this conversation inherit the // channel binding (Issue #25 root path). ChatOrigin chatOrigin = chatOriginFactory.from( - channelEntity, message, conversationId, /* workspaceBasePath */ null); + channelEntity, message, conversationId, /* workspaceBasePath */ null) + .withOriginMessageId(savedUser == null ? null : savedUser.getId()); if (adapter instanceof StreamingChannelAdapter streamingAdapter) { savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin); @@ -1596,14 +1598,16 @@ public class ChannelMessageRouter { // Mirror processMessage's group attribution for the streaming path // (Web channel today; future streaming IM channels inherit it). String attributedContent = applyGroupTag(message, message.getContent()); - conversationService.saveMessage(conversationId, "user", attributedContent, parts); + MessageEntity savedUser = conversationService.saveMessage( + conversationId, "user", attributedContent, parts); String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); promptText = applyGroupTag(message, promptText); // RFC-063r §2.5: forward ChatOrigin so tools created during this // streaming conversation inherit channel binding. ChatOrigin origin = chatOriginFactory.from( - channelEntity, message, conversationId, /* workspaceBasePath */ null); + channelEntity, message, conversationId, /* workspaceBasePath */ null) + .withOriginMessageId(savedUser == null ? null : savedUser.getId()); return agentService.chatStream(agentId, promptText, conversationId, origin); } 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 c8d865fb..d9a6f0ba 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 @@ -581,10 +581,15 @@ public class ChatController { ? regenerateSeed.parts() : normalizeRequestParts(request); String promptText = buildPromptText(message, requestParts); + Long originMessageId; if (regenerateSeed == null) { // Regenerate reuses the already-persisted seed user row — // inserting again would duplicate it (issue #547). - conversationService.saveMessage(conversationId, "user", message, requestParts); + MessageEntity savedUser = conversationService + .saveMessage(conversationId, "user", message, requestParts); + originMessageId = savedUser == null ? null : savedUser.getId(); + } else { + originMessageId = regenerateSeed.seedMessageId(); } conversationService.updateStreamStatus(conversationId, "running"); @@ -602,7 +607,8 @@ public class ChatController { // is enriched with workspaceBasePath in StateGraph buildInitialState). vip.mate.agent.context.ChatOrigin webOrigin = memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()) - .withBaseUrl(requestBaseUrl); + .withBaseUrl(requestBaseUrl) + .withOriginMessageId(originMessageId); Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -1105,13 +1111,16 @@ public class ChatController { return R.fail(401, "未登录,请先登录"); } conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); - conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts()); + MessageEntity savedUser = conversationService.saveMessage( + request.getConversationId(), "user", request.getMessage(), request.getContentParts()); String promptText = buildPromptText(request.getMessage(), request.getContentParts()); // Carry the web origin so per-owner memory recall (read) and the // post-conversation memory write below agree on the same owner key. vip.mate.agent.context.ChatOrigin webOrigin = - memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()); + memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, + request.getEndUserId()).withOriginMessageId( + savedUser == null ? null : savedUser.getId()); AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); String response = result.content(); conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", @@ -1417,9 +1426,11 @@ public class ChatController { // 持久化排队的用户消息(含 contentParts;幂等:如果 /interrupt 已提前持久化则跳过)。 // 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库, // 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。 + Long queuedOriginMessageId = null; if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) { - conversationService.saveMessage(conversationId, "user", queuedMessage, + MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage, preConsumedInput.contentParts(), "queued"); + queuedOriginMessageId = savedUser == null ? null : savedUser.getId(); } // 广播 queued_input_started 事件 @@ -1444,7 +1455,8 @@ public class ChatController { // turn keeps a consistent (null-channel) binding. vip.mate.agent.context.ChatOrigin queuedOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null) - .withBaseUrl(baseUrl); + .withBaseUrl(baseUrl) + .withOriginMessageId(queuedOriginMessageId); Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 43a31b83..43a9bde7 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -58,6 +58,8 @@ public class ChatStreamTracker { /** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */ private static final int MAX_BUFFER_SIZE = 16000; + private static final SseEventIdGenerator EVENT_IDS = + new SseEventIdGenerator(System::currentTimeMillis); private final ObjectMapper objectMapper; @@ -130,10 +132,9 @@ public class ChatStreamTracker { } /** - * One buffered SSE event. The {@code id} is a per-conversation monotonic - * sequence — the SSE protocol's standard {@code id:} line carries this - * value so the client can echo it back via {@code lastEventId} when - * reconnecting, allowing us to skip already-delivered events on replay. + * One buffered SSE event. The {@code id} is process-global and monotonic, + * with a wall-clock floor so a normally restarted process starts above + * ids emitted by its predecessor. */ record SseEvent(long id, String name, String json) {} @@ -155,15 +156,6 @@ public class ChatStreamTracker { volatile boolean done; /** Guarded by lock; once true, cleanup owns this state. */ boolean evicting; - /** - * Monotonic sequence used as the SSE protocol {@code id:} field. - * Incremented inside {@code state.lock} as each event is buffered, - * so the buffer is always in (id-asc) order. On reconnect, the - * client echoes its last-seen id back via {@code lastEventId} and - * we skip events whose id is ≤ that value during replay — - * eliminating the duplicate-delivery class of bugs. - */ - long nextEventId = 0L; /** Flux 订阅的 Disposable,用于取消 LLM 流 */ volatile Disposable disposable; /** 停止标志:requestStop() 设为 true,各图节点和 LLM 调用检查此标志以提前退出 */ @@ -673,7 +665,7 @@ public class ChatStreamTracker { return; } if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) { - eventId = ++state.nextEventId; + eventId = EVENT_IDS.nextId(); state.buffer.add(new SseEvent(eventId, eventName, jsonData)); if (state.buffer.size() > MAX_BUFFER_SIZE) { trimBuffer(state.buffer); @@ -747,7 +739,7 @@ public class ChatStreamTracker { if (isDone || isAsyncTask) { if (state == null) return; synchronized (state.lock) { - long id = ++state.nextEventId; + long id = EVENT_IDS.nextId(); SseEvent ev = new SseEvent(id, eventName, jsonData); state.buffer.add(ev); if (state.buffer.size() > MAX_BUFFER_SIZE) { @@ -801,9 +793,10 @@ public class ChatStreamTracker { } synchronized (state.lock) { + long eventId = 0L; if (!skipBuffer) { - long id = ++state.nextEventId; - SseEvent event = new SseEvent(id, eventName, jsonData); + eventId = EVENT_IDS.nextId(); + SseEvent event = new SseEvent(eventId, eventName, jsonData); state.buffer.add(event); if (state.buffer.size() > MAX_BUFFER_SIZE) { trimBuffer(state.buffer); @@ -816,7 +809,7 @@ public class ChatStreamTracker { if (skipBuffer) { emitter.send(SseEmitter.event().name(eventName).data(jsonData)); } else { - emitter.send(SseEmitter.event().id(String.valueOf(state.nextEventId)).name(eventName).data(jsonData)); + emitter.send(SseEmitter.event().id(String.valueOf(eventId)).name(eventName).data(jsonData)); } } catch (IOException | IllegalStateException e) { log.debug("Removing dead subscriber for {}: {}", conversationId, e.getMessage()); @@ -1065,8 +1058,8 @@ public class ChatStreamTracker { * {@code lastEventId}. Pass 0 to replay everything (fresh attach * behavior — same as the no-arg overload). * - *

The id is the per-conversation monotonic sequence stamped on - * each {@link SseEvent} when it was first emitted. Frontend tracks + *

The id is the process-global monotonic value stamped on each + * {@link SseEvent} when it was first emitted. Frontend tracks * the last id it processed and echoes it back via the request * body's {@code lastEventId} field, eliminating the duplicate- * delivery class of bugs (the symptom: thinking segments rendered diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/SseEventIdGenerator.java b/mateclaw-server/src/main/java/vip/mate/channel/web/SseEventIdGenerator.java new file mode 100644 index 00000000..e59a971e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/SseEventIdGenerator.java @@ -0,0 +1,43 @@ +package vip.mate.channel.web; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +/** Generates positive event ids from a wall-clock floor and atomic sequence. */ +final class SseEventIdGenerator { + + static final long MAX_SAFE_INTEGER = 9_007_199_254_740_991L; + + private static final int COUNTER_BITS = 10; + private static final long IDS_PER_MILLISECOND = 1L << COUNTER_BITS; + private static final long MAX_EPOCH_MILLIS = MAX_SAFE_INTEGER / IDS_PER_MILLISECOND; + + private final LongSupplier clock; + private final AtomicLong lastId; + + SseEventIdGenerator(LongSupplier clock) { + this.clock = clock; + this.lastId = new AtomicLong(epochFloor(clock.getAsLong()) - 1); + } + + long nextId() { + long floor = epochFloor(clock.getAsLong()); + for (;;) { + long current = lastId.get(); + if (current >= MAX_SAFE_INTEGER) { + throw new IllegalStateException("SSE event id space exhausted"); + } + long next = Math.max(current + 1, floor); + if (lastId.compareAndSet(current, next)) { + return next; + } + } + } + + private long epochFloor(long epochMillis) { + if (epochMillis <= 0 || epochMillis > MAX_EPOCH_MILLIS) { + throw new IllegalStateException("clock is outside the SSE event id range"); + } + return epochMillis * IDS_PER_MILLISECOND; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java index d4eadf91..427e42ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java @@ -146,13 +146,15 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { Long talkWsId = talkAgent != null ? talkAgent.getWorkspaceId() : 1L; conversationService.getOrCreateConversation( talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId); - conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of()); + var savedUser = conversationService.saveMessage( + talkSession.conversationId, "user", transcript, List.of()); // 5. Agent 对话(同步)。Carry the voice user's identity so per-owner // memory recall (read) and the post-turn memory write (below) agree // on the same owner key. vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web( - talkSession.conversationId, talkSession.username, talkWsId, null); + talkSession.conversationId, talkSession.username, talkWsId, null) + .withOriginMessageId(savedUser == null ? null : savedUser.getId()); AgentService.ChatResult chatResult = agentService.chatWithUsage( talkSession.agentId, transcript, talkSession.conversationId, talkOrigin); String reply = chatResult.content(); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index f9a7921f..45ae4076 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -235,10 +235,13 @@ public class WebChatController { // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 List userParts = buildUserParts(conversationId, message, request.getAttachmentIds()); + Long originMessageId = request.getInternalOriginMessageId(); if (!request.isInternalSkipUserPersist()) { // Regenerate reuses the already-persisted seed user row — // inserting again would duplicate it. - conversationService.saveMessage(conversationId, "user", message, userParts); + var savedUser = conversationService + .saveMessage(conversationId, "user", message, userParts); + originMessageId = savedUser == null ? null : savedUser.getId(); } // 初始化 SSE 流跟踪 @@ -271,7 +274,8 @@ public class WebChatController { // (publish) paths below. vip.mate.agent.context.ChatOrigin webchatOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null) - .withSender(null, "api", null); + .withSender(null, "api", null) + .withOriginMessageId(originMessageId); String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin); reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin) @@ -1572,6 +1576,7 @@ public class WebChatController { req.setVisitorId(visitorId); req.setSessionId(sid); req.setInternalSkipUserPersist(true); + req.setInternalOriginMessageId(seed.seedMessageId()); return chatStream(apiKey, req); } @@ -2099,6 +2104,7 @@ public class WebChatController { */ @JsonIgnore private boolean internalSkipUserPersist; + private Long internalOriginMessageId; } /** Compact view of one of a visitor's conversation threads. */ diff --git a/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java index 68593e11..184b43db 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java @@ -29,6 +29,10 @@ public class CronChatOriginFactory { private final AgentMapper agentMapper; public ChatOrigin from(CronJobEntity job, String conversationId) { + return from(job, conversationId, null); + } + + public ChatOrigin from(CronJobEntity job, String conversationId, Long originMessageId) { AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null; Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L; @@ -36,6 +40,6 @@ public class CronChatOriginFactory { ChannelTarget target = dc != null ? dc.toChannelTarget() : null; return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null, - job.getChannelId(), target); + job.getChannelId(), target).withOriginMessageId(originMessageId); } } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index c12cbe69..5fa8d813 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -17,6 +17,7 @@ import vip.mate.dashboard.repository.CronJobRunMapper; import vip.mate.i18n.I18nService; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; import java.time.LocalDateTime; @@ -45,6 +46,9 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class CronJobLifecycleService { + public record StartResult(CronJobRunEntity run, Long originMessageId) { + } + private final CronJobRunMapper runMapper; private final ConversationService conversationService; private final ConversationCompletionPublisher completionPublisher; @@ -60,8 +64,8 @@ public class CronJobLifecycleService { * @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow) */ @Transactional(propagation = Propagation.REQUIRES_NEW) - public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType, - String conversationId) { + public StartResult startRun(CronJobEntity job, String userMessage, String triggerType, + String conversationId) { CronJobRunEntity run = new CronJobRunEntity(); run.setCronJobId(job.getId()); run.setConversationId(conversationId); @@ -90,10 +94,13 @@ public class CronJobLifecycleService { // Persist the user message before the LLM call so history reads // see a coherent (user → assistant) ordering even if the agent // throws mid-run. + Long originMessageId = null; if (userMessage != null && !userMessage.isBlank()) { - conversationService.saveMessage(conversationId, "user", userMessage); + MessageEntity savedUser = conversationService.saveMessage( + conversationId, "user", userMessage); + originMessageId = savedUser == null ? null : savedUser.getId(); } - return run; + return new StartResult(run, originMessageId); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index 133bce60..796eb306 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -102,13 +102,14 @@ public class CronJobRunner { String conversationId = conversationResolver.resolve(job); // T1 — short tx - CronJobRunEntity run; + CronJobLifecycleService.StartResult started; try { - run = lifecycle.startRun(job, userMessage, triggerType, conversationId); + started = lifecycle.startRun(job, userMessage, triggerType, conversationId); } catch (Exception e) { log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e); return; } + CronJobRunEntity run = started.run(); // task_type='reminder' — pure notification, no LLM call. The user // (or the create_reminder tool on their behalf) supplied the exact @@ -141,7 +142,8 @@ public class CronJobRunner { AgentService.ChatResult chatResult; AssistantMessage result; try { - ChatOrigin origin = originFactory.from(job, conversationId); + ChatOrigin origin = originFactory.from( + job, conversationId, started.originMessageId()); chatResult = runAgent(job, userMessage, origin, conversationId); result = new AssistantMessage(chatResult.content()); } catch (Exception e) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index ea80a8c1..b4ddae92 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -21,6 +21,7 @@ import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.service.TeamAnnounceService; import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; +import vip.mate.team.service.TeamManualTaskService; import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamTaskService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -48,6 +49,7 @@ public class TeamController { private final TeamService teamService; private final TeamTaskService taskService; + private final TeamManualTaskService manualTaskService; private final TeamDispatchService dispatchService; private final TeamAnnounceService announceService; private final TeamEventChannel eventChannel; @@ -162,9 +164,10 @@ public class TeamController { public R createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req, Principal principal) { return guarded(() -> { - requireTeam(id); - TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() + AgentTeamEntity team = requireTeam(id); + TeamTaskEntity task = manualTaskService.createTask(team, TeamTaskCreateCommand.builder() .teamId(id) + .runId(req.getRunId()) .subject(req.getSubject()) .description(req.getDescription()) .assigneeAgentId(req.getAssigneeAgentId()) @@ -175,9 +178,6 @@ public class TeamController { .channel("dashboard") .build()); eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); - if (TeamTaskStatus.PENDING.equals(task.getStatus())) { - dispatchService.requestDispatch(id); - } return R.ok(toTaskVO(task)); }); } @@ -398,7 +398,8 @@ public class TeamController { private TaskVO toTaskVO(TeamTaskEntity task) { return new TaskVO(task, agentName(task.getAssigneeAgentId()), - task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId())); + task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId()), + task.getRunId()); } private String agentName(Long agentId) { @@ -418,7 +419,7 @@ public class TeamController { public record MemberVO(Long agentId, String name, String role, String icon) { } - public record TaskVO(TeamTaskEntity task, String assigneeName, String ownerName) { + public record TaskVO(TeamTaskEntity task, String assigneeName, String ownerName, Long runId) { } public record TaskDetailVO(TaskVO task, List comments) { @@ -447,6 +448,7 @@ public class TeamController { @Data public static class CreateTaskRequest { + private Long runId; private String subject; private String description; private Long assigneeAgentId; diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java new file mode 100644 index 00000000..6ee6c124 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java @@ -0,0 +1,87 @@ +package vip.mate.team.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.service.TeamRunApplicationService; +import vip.mate.team.service.TeamRunService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.util.List; +import java.util.function.Supplier; + +/** Workspace-scoped REST API for team run reads and cancellation. */ +@Tag(name = "Team Runs") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class TeamRunController { + + private final TeamRunService runService; + private final TeamRunApplicationService applicationService; + + @Operation(summary = "Get team run") + @GetMapping("/team-runs/{runId}") + @RequireWorkspaceRole("viewer") + public R get(@PathVariable Long runId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(runService.getRun(runId, workspaceId(workspaceId)))); + } + + @Operation(summary = "List team runs") + @GetMapping("/teams/{teamId}/runs") + @RequireWorkspaceRole("viewer") + public R> listTeamRuns( + @PathVariable Long teamId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId)))); + } + + @Operation(summary = "List conversation team runs") + @GetMapping("/conversations/{conversationId}/team-runs") + @RequireWorkspaceRole("viewer") + public R> listConversationRuns( + @PathVariable String conversationId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(runService.listConversationRuns( + conversationId, workspaceId(workspaceId)))); + } + + @Operation(summary = "Cancel team run") + @PostMapping("/team-runs/{runId}/cancel") + @RequireWorkspaceRole("admin") + public R cancel( + @PathVariable Long runId, + @RequestBody(required = false) CancelRunRequest request, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(applicationService.cancelRun(runId, workspaceId(workspaceId), + request == null ? null : request.getReason()))); + } + + private long workspaceId(Long workspaceId) { + return workspaceId == null ? 1L : workspaceId; + } + + private R guarded(Supplier> action) { + try { + return action.get(); + } catch (IllegalArgumentException | IllegalStateException e) { + return R.fail(e.getMessage()); + } + } + + @Data + public static class CancelRunRequest { + private String reason; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunCancelCommittedIntent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunCancelCommittedIntent.java new file mode 100644 index 00000000..e69301a1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunCancelCommittedIntent.java @@ -0,0 +1,19 @@ +package vip.mate.team.event; + +import vip.mate.team.model.TeamRunView; + +import java.util.List; + +/** Carries detached cancellation side effects across the transaction boundary. */ +public record TeamRunCancelCommittedIntent( + TeamRunView run, + List workers +) { + + public TeamRunCancelCommittedIntent { + workers = List.copyOf(workers); + } + + public record WorkerTask(Long taskId, Integer taskNumber, String conversationId) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunDispatchCommittedIntent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunDispatchCommittedIntent.java new file mode 100644 index 00000000..689b17cb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamRunDispatchCommittedIntent.java @@ -0,0 +1,5 @@ +package vip.mate.team.event; + +/** Requests a team dispatch after the run transaction commits. */ +public record TeamRunDispatchCommittedIntent(Long teamId) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunCreateCommand.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunCreateCommand.java new file mode 100644 index 00000000..ac0231bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunCreateCommand.java @@ -0,0 +1,26 @@ +package vip.mate.team.model; + +import lombok.Builder; +import lombok.Data; + +/** Input required to create a persistent team run. */ +@Data +@Builder +public class TeamRunCreateCommand { + + private Long teamId; + + private Long workspaceId; + + private Long leadAgentId; + + private String leadConversationId; + + private Long originMessageId; + + private String title; + + private String objective; + + private String metadata; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunEntity.java new file mode 100644 index 00000000..98740e50 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunEntity.java @@ -0,0 +1,65 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Persistent identity and lifecycle state for one team execution. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_team_run") +public class TeamRunEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long teamId; + + private Long workspaceId; + + private Long leadAgentId; + + private String leadConversationId; + + private Long originMessageId; + + private String title; + + private String objective; + + private String status; + + @TableField(value = "final_summary", updateStrategy = FieldStrategy.ALWAYS) + private String finalSummary; + + @TableField(value = "stop_reason", updateStrategy = FieldStrategy.ALWAYS) + private String stopReason; + + @TableField(value = "metadata", updateStrategy = FieldStrategy.ALWAYS) + private String metadata; + + @TableField(value = "started_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime startedAt; + + @TableField(value = "completed_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime completedAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunStatus.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunStatus.java new file mode 100644 index 00000000..07766350 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunStatus.java @@ -0,0 +1,25 @@ +package vip.mate.team.model; + +import java.util.Set; + +/** Team run lifecycle status constants. */ +public final class TeamRunStatus { + + public static final String PLANNING = "planning"; + public static final String RUNNING = "running"; + public static final String AWAITING_REVIEW = "awaiting_review"; + public static final String FINALIZING = "finalizing"; + public static final String COMPLETED = "completed"; + public static final String PARTIAL = "partial"; + public static final String FAILED = "failed"; + public static final String CANCELLED = "cancelled"; + + public static final Set TERMINAL = Set.of(COMPLETED, PARTIAL, FAILED, CANCELLED); + + private TeamRunStatus() { + } + + public static boolean isTerminal(String status) { + return status != null && TERMINAL.contains(status); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java new file mode 100644 index 00000000..6633ffcf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java @@ -0,0 +1,64 @@ +package vip.mate.team.model; + +import java.time.LocalDateTime; +import java.util.List; + +/** Stable read projection for a team run and its tasks. */ +public record TeamRunView( + Long id, + Long teamId, + Long workspaceId, + Long leadAgentId, + String leadConversationId, + Long originMessageId, + String title, + String objective, + String status, + String finalSummary, + String stopReason, + String metadata, + LocalDateTime startedAt, + LocalDateTime completedAt, + LocalDateTime createTime, + LocalDateTime updateTime, + Progress progress, + List tasks +) { + + public record Progress(int total, int done, int failed, int inReview, int percent) { + } + + public record Task( + Long id, + Long teamId, + Long runId, + Integer taskNumber, + String subject, + String description, + String status, + Integer priority, + String taskType, + Long assigneeAgentId, + Long ownerAgentId, + String blockedBy, + Boolean requireApproval, + Integer progressPercent, + String progressStep, + String result, + String reason, + String conversationId, + String metadata, + LocalDateTime createTime, + LocalDateTime updateTime + ) { + + public static Task from(TeamTaskEntity task) { + return new Task(task.getId(), task.getTeamId(), task.getRunId(), task.getTaskNumber(), + task.getSubject(), task.getDescription(), task.getStatus(), task.getPriority(), + task.getTaskType(), task.getAssigneeAgentId(), task.getOwnerAgentId(), + task.getBlockedBy(), task.getRequireApproval(), task.getProgressPercent(), + task.getProgressStep(), task.getResult(), task.getReason(), task.getConversationId(), + task.getMetadata(), task.getCreateTime(), task.getUpdateTime()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java index 7834abd0..c25cc5db 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java @@ -18,6 +18,8 @@ public class TeamTaskCreateCommand { private Long teamId; + private Long runId; + private String subject; private String description; diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java index bb79b0f1..c6699c57 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java @@ -22,6 +22,8 @@ public class TeamTaskEntity { private Long teamId; + private Long runId; + /** Human-readable sequential number, unique within the team. */ private Integer taskNumber; diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamRunMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamRunMapper.java new file mode 100644 index 00000000..98e70153 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamRunMapper.java @@ -0,0 +1,10 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.team.model.TeamRunEntity; + +/** Persistent team run mapper. */ +@Mapper +public interface TeamRunMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/SpringTeamRunEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/team/service/SpringTeamRunEventPublisher.java new file mode 100644 index 00000000..2a999e86 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/SpringTeamRunEventPublisher.java @@ -0,0 +1,20 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import vip.mate.team.model.TeamRunView; + +import java.util.Map; + +/** Publishes run lifecycle events through the unified team event channel. */ +@Component +@RequiredArgsConstructor +public class SpringTeamRunEventPublisher implements TeamRunEventPublisher { + + private final TeamEventChannel eventChannel; + + @Override + public void publishCancelled(TeamRunView run) { + eventChannel.publishRunEvent(run, "team_run_cancelled", Map.of()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java index 64964cba..9ecba9fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java @@ -1,5 +1,6 @@ package vip.mate.team.service; +import cn.hutool.json.JSONObject; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,19 +15,23 @@ import vip.mate.team.model.TeamTaskStatus; import vip.mate.workspace.conversation.ConversationService; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * Delivers settled task results back to the team lead. Results arriving close - * together are debounced per lead conversation and merged into ONE combined - * announcement, so parallel members finishing near-simultaneously wake the - * lead once instead of once per task. + * together are debounced per lead conversation and run and merged into ONE + * combined announcement, so parallel members in the same run wake the lead + * once instead of once per task. Different runs never share a batch. * * Delivery is guaranteed, not opportunistic: when the lead is mid-turn the * announcement is NOT injected into the running turn (an in-turn notification @@ -73,13 +78,36 @@ public class TeamAnnounceService { private final ChatStreamTracker streamTracker; private final ConversationService conversationService; - /** Pending items per lead conversation; the first item arms the drain timer. */ - private final Map> pending = new ConcurrentHashMap<>(); + /** Pending items stay isolated by run while lead wake turns serialize by conversation. */ + private final Map pending = new ConcurrentHashMap<>(); + private final Set drainOwners = ConcurrentHashMap.newKeySet(); + private final AtomicLong batchSequence = new AtomicLong(); - record AnnounceItem(Long teamId, Integer taskNumber, String subject, String status, + record BatchKey(String conversationId, Long runId) { + } + + record AnnounceItem(Long taskId, Long teamId, Integer taskNumber, String subject, String status, String memberName, String detail) { } + private static final class PendingBatch { + private final long sequence; + private final List items; + private long readyAtMillis; + private int retries; + + private PendingBatch(long sequence) { + this(sequence, new ArrayList<>(), 0, 0); + } + + private PendingBatch(long sequence, List items, long readyAtMillis, int retries) { + this.sequence = sequence; + this.items = items; + this.readyAtMillis = readyAtMillis; + this.retries = retries; + } + } + /** * Queue a settled task for announcement to its lead. Safe to call from any * thread; no-op when the task has no originating lead conversation. @@ -99,76 +127,130 @@ public class TeamAnnounceService { detailWithFiles.append("\n- ").append(file.name()).append(" → ").append(file.url()); } } - AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(), + AnnounceItem item = new AnnounceItem(task.getId(), task.getTeamId(), task.getTaskNumber(), task.getSubject(), task.getStatus(), agentName(task.getAssigneeAgentId()), detailWithFiles.toString()); - String key = task.getLeadConversationId(); - List drainNow = null; + BatchKey key = new BatchKey(task.getLeadConversationId(), task.getRunId()); + boolean drainNow = false; synchronized (pending) { - List queue = pending.computeIfAbsent(key, k -> new ArrayList<>()); - queue.add(item); - if (queue.size() >= MAX_BATCH) { - drainNow = pending.remove(key); - } else if (queue.size() == 1) { + PendingBatch batch = pending.computeIfAbsent(key, + ignored -> new PendingBatch(batchSequence.incrementAndGet())); + batch.items.add(item); + if (batch.items.size() >= MAX_BATCH) { + drainNow = true; + } else if (batch.items.size() == 1) { DEBOUNCE_SCHEDULER.schedule(() -> drain(key), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); } } - if (drainNow != null) { - deliver(key, drainNow); + if (drainNow) { + drain(key); } } - /** Timer callback: take whatever accumulated and deliver it. */ - void drain(String leadConversationId) { - List items; + /** Acquire the conversation turn, then take and deliver one run-isolated batch. */ + void drain(BatchKey key) { + String conversationId = key.conversationId(); + if (!drainOwners.add(conversationId)) { + return; + } + PendingBatch batch; synchronized (pending) { - items = pending.remove(leadConversationId); - } - if (items != null && !items.isEmpty()) { - deliver(leadConversationId, items); - } - } - - void deliver(String leadConversationId, List items) { - deliver(leadConversationId, items, 0); - } - - private void deliver(String leadConversationId, List items, int busyRetries) { - Long teamId = items.get(0).teamId(); - AgentTeamEntity team = teamService.getTeam(teamId); - if (team == null) { - log.warn("Announce dropped: team {} vanished", teamId); - return; - } - if (runningConversations.isActive(leadConversationId) && busyRetries < MAX_BUSY_RETRIES) { - // Lead is mid-turn. Late tasks settling meanwhile join this batch - // via the pending map, so re-queue and re-arm instead of injecting - // into the running turn (which can drop the message on turn end). - List merged = items; - synchronized (pending) { - List late = pending.remove(leadConversationId); - if (late != null) { - merged = new ArrayList<>(items); - merged.addAll(late); - } + batch = pending.get(key); + if (batch != null && batch.readyAtMillis <= System.currentTimeMillis()) { + pending.remove(key); + } else { + batch = null; } - List retryItems = merged; - DEBOUNCE_SCHEDULER.schedule(() -> deliver(leadConversationId, retryItems, busyRetries + 1), - BUSY_RETRY_MILLIS, TimeUnit.MILLISECONDS); + } + if (batch == null) { + releaseAndScheduleNext(conversationId); return; } - String message = buildAnnouncement(items); - ANNOUNCE_EXECUTOR.submit(() -> wakeLead(team, leadConversationId, message, items.size())); + PendingBatch ownedBatch = batch; + try { + ANNOUNCE_EXECUTOR.submit(() -> deliverOwned(key, ownedBatch)); + } catch (RuntimeException e) { + requeue(key, ownedBatch); + releaseAndScheduleNext(conversationId); + throw e; + } + } + + private void deliverOwned(BatchKey key, PendingBatch batch) { + try { + List items = batch.items; + Long teamId = items.get(0).teamId(); + AgentTeamEntity team = teamService.getTeam(teamId); + if (team == null) { + log.warn("Announce dropped: team {} vanished", teamId); + return; + } + if (runningConversations.isActive(key.conversationId()) && batch.retries < MAX_BUSY_RETRIES) { + batch.retries++; + batch.readyAtMillis = System.currentTimeMillis() + BUSY_RETRY_MILLIS; + requeue(key, batch); + return; + } + wakeLead(team, key, buildAnnouncement(items), List.copyOf(items)); + } catch (Exception e) { + batch.retries++; + batch.readyAtMillis = System.currentTimeMillis() + BUSY_RETRY_MILLIS; + requeue(key, batch); + log.warn("Lead wake-up failed for conversation {} run {}: {}", + key.conversationId(), key.runId(), e.getMessage()); + } finally { + releaseAndScheduleNext(key.conversationId()); + } + } + + private void requeue(BatchKey key, PendingBatch batch) { + synchronized (pending) { + PendingBatch late = pending.remove(key); + if (late != null) { + batch.items.addAll(late.items); + } + pending.put(key, batch); + } + } + + private void releaseAndScheduleNext(String conversationId) { + drainOwners.remove(conversationId); + BatchKey nextKey; + long delay; + synchronized (pending) { + Map.Entry next = pending.entrySet().stream() + .filter(entry -> conversationId.equals(entry.getKey().conversationId())) + .min(Comparator.comparingLong(entry -> entry.getValue().sequence)) + .orElse(null); + if (next == null) { + return; + } + nextKey = next.getKey(); + delay = Math.max(0, next.getValue().readyAtMillis - System.currentTimeMillis()); + } + DEBOUNCE_SCHEDULER.schedule(() -> drain(nextKey), delay, TimeUnit.MILLISECONDS); } /** Start a fresh lead turn carrying the merged results; its reply reaches the user. */ - private void wakeLead(AgentTeamEntity team, String leadConversationId, - String message, int taskCount) { - try { + private void wakeLead(AgentTeamEntity team, BatchKey key, + String message, List items) { + String leadConversationId = key.conversationId(); + List taskIds = items.stream().map(item -> String.valueOf(item.taskId())).toList(); + Map startPayload = new HashMap<>(); + startPayload.put("teamId", String.valueOf(team.getId())); + startPayload.put("tasks", items.size()); + if (taskIds.size() == 1) { + startPayload.put("taskId", taskIds.get(0)); + } else { + startPayload.put("taskIds", taskIds); + } + if (key.runId() != null) { + startPayload.put("runId", String.valueOf(key.runId())); + } streamTracker.broadcastObject(leadConversationId, "team_announce_start", - Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount)); + startPayload); // Persist the announce turn: message persistence is the caller's // contract, and without it the lead's synthesized reply would // vanish from the conversation history on the next reload. @@ -178,22 +260,34 @@ public class TeamAnnounceService { // render a compact system strip instead of a user bubble. conversationService.saveMessage(leadConversationId, "user", message, null, "completed", 0, 0, null, null, - "{\"type\":\"team_announce\",\"taskCount\":" + taskCount + "}"); + announceMetadata("team_announce", key, taskIds)); AgentService.ChatResult result = agentService.chatWithUsage( team.getLeadAgentId(), message, leadConversationId); String reply = result == null ? null : result.content(); if (reply != null && !reply.isBlank()) { conversationService.saveMessage(leadConversationId, "assistant", reply, null, "completed", 0, 0, null, null, - "{\"type\":\"team_announce_reply\"}"); + announceMetadata("team_announce_reply", key, taskIds)); } - streamTracker.broadcastObject(leadConversationId, "team_announce_reply", - Map.of("teamId", String.valueOf(team.getId()), - "content", reply == null ? "" : reply)); - log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount); - } catch (Exception e) { - log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage()); + Map replyPayload = new HashMap<>(startPayload); + replyPayload.put("content", reply == null ? "" : reply); + streamTracker.broadcastObject(leadConversationId, "team_announce_reply", replyPayload); + log.info("Team {} lead woken with {} task result(s)", team.getId(), items.size()); + } + + private String announceMetadata(String type, BatchKey key, List taskIds) { + JSONObject metadata = new JSONObject() + .set("type", type) + .set("taskCount", taskIds.size()); + if (taskIds.size() == 1) { + metadata.set("taskId", taskIds.get(0)); + } else { + metadata.set("taskIds", taskIds); } + if (key.runId() != null) { + metadata.set("runId", String.valueOf(key.runId())); + } + return metadata.toString(); } /** Merged announcement text; single- and multi-result variants. */ diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java index b019d698..0e2d954f 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java @@ -156,10 +156,12 @@ public class TeamContextBuilder { return """ ### Delegation workflow (mandatory) - - Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it. + - Start each delegation batch with `team_tasks(action="start_run", title=..., objective=...)` and keep the returned runId. + - Create every task with that explicit run id: `team_tasks(action="create", runId=..., subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it. + - After ALL tasks are created, call `team_tasks(action="seal_run", runId=...)` exactly once. The mandatory sequence is start_run -> create* -> seal_run. - Check the board FIRST: a live board snapshot is injected into your context whenever tasks are in flight; consult it (or call `team_tasks(action="list")`) before creating tasks so you never create duplicates. - When a task's outcome needs a human decision before it counts as done (publishing something, destructive changes), create it with `requireApproval=true`; it will park in review for sign-off instead of completing automatically. - - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP — do not keep reasoning while members work. + - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Seal the run, then announce the assignments to the user and STOP — do not keep reasoning while members work. - Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow. - Never assign a task to yourself — the lead orchestrates, members execute. - Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps. diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index 2167c81a..3e1df3d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -3,9 +3,10 @@ package vip.mate.team.service; import cn.hutool.core.util.IdUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import vip.mate.team.event.TeamTasksDelegatedEvent; import vip.mate.agent.AgentService; import vip.mate.channel.web.ChatStreamTracker; @@ -84,7 +85,7 @@ public class TeamDispatchService { * plan's tasks land. Event-driven because the hand-off bridge cannot * depend on this service directly (bean cycle through the graph builder). */ - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) { requestDispatch(event.teamId()); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java index 9c726fde..46089e29 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java @@ -5,9 +5,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.model.TeamRunView; import vip.mate.team.model.TeamTaskEntity; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -38,12 +41,17 @@ public class TeamEventChannel { return; } try { - Map payload = new HashMap<>(extra == null ? Map.of() : extra); + Map payload = payload(extra); payload.put("taskId", String.valueOf(task.getId())); payload.put("taskNumber", task.getTaskNumber()); payload.put("subject", task.getSubject()); payload.put("teamId", String.valueOf(task.getTeamId())); payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId())); + if (task.getRunId() != null) { + payload.put("runId", String.valueOf(task.getRunId())); + } else { + payload.remove("runId"); + } String channelId = channelId(task.getTeamId()); streamTracker.register(channelId); @@ -52,9 +60,41 @@ public class TeamEventChannel { if (task.getLeadConversationId() != null) { streamTracker.broadcastObject(task.getLeadConversationId(), event, payload); } + log.debug("Team event published runId={} teamId={} conversationId={} taskId={} event={}", + task.getRunId(), task.getTeamId(), task.getLeadConversationId(), + task.getId(), event); } catch (Exception e) { // Events are a side channel — never let them affect the task flow. - log.debug("Team event '{}' broadcast skipped: {}", event, e.getMessage()); + log.debug("Team event skipped runId={} teamId={} conversationId={} taskId={} event={}: {}", + task.getRunId(), task.getTeamId(), task.getLeadConversationId(), + task.getId(), event, e.getMessage()); + } + } + + /** Publish a run lifecycle projection to the team channel and lead stream. */ + public void publishRunEvent(TeamRunView run, String event, Map extra) { + if (run == null) { + return; + } + try { + Map payload = payload(extra); + payload.put("runId", String.valueOf(run.id())); + payload.put("teamId", String.valueOf(run.teamId())); + payload.put("leadConversationId", run.leadConversationId()); + payload.put("status", run.status()); + payload.put("progress", run.progress()); + + String channelId = channelId(run.teamId()); + streamTracker.register(channelId); + streamTracker.broadcastObject(channelId, event, payload); + if (run.leadConversationId() != null) { + streamTracker.broadcastObject(run.leadConversationId(), event, payload); + } + log.debug("Team event published runId={} teamId={} conversationId={} taskId={} event={}", + run.id(), run.teamId(), run.leadConversationId(), null, event); + } catch (Exception e) { + log.debug("Team event skipped runId={} teamId={} conversationId={} taskId={} event={}: {}", + run.id(), run.teamId(), run.leadConversationId(), null, event, e.getMessage()); } } @@ -68,4 +108,29 @@ public class TeamEventChannel { static String channelId(Long teamId) { return CHANNEL_PREFIX + teamId; } + + private Map payload(Map extra) { + Map payload = new HashMap<>(); + if (extra != null) { + extra.forEach((key, value) -> payload.put(key, stringifyLongs(value))); + } + return payload; + } + + private Object stringifyLongs(Object value) { + if (value instanceof Long longValue) { + return String.valueOf(longValue); + } + if (value instanceof Map map) { + Map normalized = new HashMap<>(); + map.forEach((key, nested) -> normalized.put(String.valueOf(key), stringifyLongs(nested))); + return normalized; + } + if (value instanceof Iterable iterable) { + List normalized = new ArrayList<>(); + iterable.forEach(item -> normalized.add(stringifyLongs(item))); + return normalized; + } + return value; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamManualTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamManualTaskService.java new file mode 100644 index 00000000..7fc67db8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamManualTaskService.java @@ -0,0 +1,67 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.event.TeamRunDispatchCommittedIntent; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; + +/** Coordinates dashboard task creation with the team run lifecycle. */ +@Service +@RequiredArgsConstructor +public class TeamManualTaskService { + + private static final String DASHBOARD_CONVERSATION_PREFIX = "dashboard-team-"; + + private final TeamRunService runService; + private final TeamTaskService taskService; + private final ApplicationEventPublisher events; + + @Transactional + public TeamTaskEntity createTask(AgentTeamEntity team, TeamTaskCreateCommand command) { + boolean autoRun = command.getRunId() == null; + TeamRunEntity run = autoRun ? startRun(team, command) : requirePlanningRun(team, command.getRunId()); + command.setRunId(run.getId()); + command.setLeadConversationId(run.getLeadConversationId()); + TeamTaskEntity task = taskService.createTask(command); + if (autoRun) { + TeamRunService.SealResult sealed = runService.sealRunWithResult( + run.getId(), team.getWorkspaceId()); + if (sealed.transitioned()) { + events.publishEvent(new TeamRunDispatchCommittedIntent(team.getId())); + } + } + return task; + } + + private TeamRunEntity startRun(AgentTeamEntity team, TeamTaskCreateCommand command) { + String objective = command.getDescription() == null || command.getDescription().isBlank() + ? command.getSubject() : command.getDescription(); + return runService.startRun(TeamRunCreateCommand.builder() + .teamId(team.getId()) + .workspaceId(team.getWorkspaceId()) + .leadAgentId(team.getLeadAgentId()) + .leadConversationId(DASHBOARD_CONVERSATION_PREFIX + team.getId()) + .originMessageId(null) + .title(command.getSubject()) + .objective(objective) + .build()); + } + + private TeamRunEntity requirePlanningRun(AgentTeamEntity team, Long runId) { + TeamRunEntity run = runService.requireRun(runId, team.getWorkspaceId()); + if (!team.getId().equals(run.getTeamId())) { + throw new IllegalArgumentException("team task and run must belong to the same team"); + } + if (!TeamRunStatus.PLANNING.equals(run.getStatus())) { + throw new IllegalStateException("team run must be planning to accept tasks: " + runId); + } + return run; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java index 2b824ddf..08e03ad3 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.planning.model.PlanEntity; @@ -15,6 +16,9 @@ import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamMemberEntity; import vip.mate.team.model.TeamRole; import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; @@ -50,6 +54,7 @@ public class TeamPlanBridge { private final TeamService teamService; private final TeamTaskService taskService; + private final TeamRunService runService; private final PlanningService planningService; private final AgentMapper agentMapper; private final ApplicationEventPublisher eventPublisher; @@ -121,9 +126,30 @@ public class TeamPlanBridge { * referencing an earlier step); the caller guarantees * validity via its sequential-chain fallback */ + @Transactional public String delegatePlan(AgentTeamEntity team, Long planId, String goal, List steps, List> stepDeps, List memberIds, String leadConversationId) { + TeamRunEntity run = runService.startRun(TeamRunCreateCommand.builder() + .teamId(team.getId()) + .workspaceId(team.getWorkspaceId()) + .leadAgentId(team.getLeadAgentId()) + .leadConversationId(leadConversationId) + .originMessageId(-Math.abs(planId)) + .title(goal) + .objective(goal) + .metadata(new JSONObject().set("planId", String.valueOf(planId)).toString()) + .build()); + List existing = taskService.listTasksByRun(run.getId()); + if (!existing.isEmpty()) { + if (TeamRunStatus.PLANNING.equals(run.getStatus())) { + sealAndPublish(team, planId, run); + } + return buildAnnouncement(existing, stepDeps); + } + if (!TeamRunStatus.PLANNING.equals(run.getStatus())) { + throw new IllegalStateException("sealed team run has no tasks: " + run.getId()); + } List created = new ArrayList<>(); for (int i = 0; i < steps.size(); i++) { String step = steps.get(i); @@ -133,6 +159,7 @@ public class TeamPlanBridge { } TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() .teamId(team.getId()) + .runId(run.getId()) .subject(subjectOf(step)) .description(step + "\n\n[Plan context]\nOverall request: " + goal) .assigneeAgentId(memberIds.get(i)) @@ -147,13 +174,21 @@ public class TeamPlanBridge { .build()); created.add(task); } - planningService.markPlanDelegated(planId); - eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId())); + sealAndPublish(team, planId, run); log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(), created.size()); return buildAnnouncement(created, stepDeps); } + private void sealAndPublish(AgentTeamEntity team, Long planId, TeamRunEntity run) { + TeamRunService.SealResult seal = runService.sealRunWithResult( + run.getId(), team.getWorkspaceId()); + planningService.markPlanDelegated(planId); + if (seal.transitioned()) { + eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId())); + } + } + // ==================== resume gate ==================== /** Outcome of the parked-plan check on an inbound message. */ diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunApplicationService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunApplicationService.java new file mode 100644 index 00000000..d3267dc6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunApplicationService.java @@ -0,0 +1,47 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.event.TeamRunCancelCommittedIntent; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.ArrayList; +import java.util.List; + +/** Coordinates cancellation side effects around the run domain lifecycle. */ +@Service +@RequiredArgsConstructor +public class TeamRunApplicationService { + + private final TeamRunService runService; + private final TeamTaskService taskService; + private final ApplicationEventPublisher events; + + @Transactional + public TeamRunView cancelRun(Long runId, Long workspaceId, String reason) { + TeamRunService.CancelResult cancelled = runService.cancelRunWithResult( + runId, workspaceId, reason); + List workers = new ArrayList<>(); + if (cancelled.transitioned()) { + for (TeamTaskEntity task : taskService.listTasksByRun(runId)) { + if (TeamTaskStatus.isTerminal(task.getStatus())) { + continue; + } + if (TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + workers.add(new TeamRunCancelCommittedIntent.WorkerTask( + task.getId(), task.getTaskNumber(), task.getConversationId())); + } + taskService.cancelTask(task.getId(), reason); + } + } + TeamRunView view = runService.buildView(cancelled.run()); + if (cancelled.transitioned()) { + events.publishEvent(new TeamRunCancelCommittedIntent(view, workers)); + } + return view; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunCommittedIntentListener.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunCommittedIntentListener.java new file mode 100644 index 00000000..084eeef2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunCommittedIntentListener.java @@ -0,0 +1,55 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import vip.mate.team.event.TeamRunCancelCommittedIntent; +import vip.mate.team.event.TeamRunDispatchCommittedIntent; +import vip.mate.team.model.TeamTaskEntity; + +/** Executes run side effects only after their state transaction commits. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TeamRunCommittedIntentListener { + + private final TeamDispatchService dispatchService; + private final TeamRunEventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onDispatchCommitted(TeamRunDispatchCommittedIntent intent) { + try { + dispatchService.requestDispatch(intent.teamId()); + } catch (Exception e) { + log.warn("Team {} committed dispatch failed: {}", intent.teamId(), e.getMessage()); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onCancelCommitted(TeamRunCancelCommittedIntent intent) { + for (TeamRunCancelCommittedIntent.WorkerTask worker : intent.workers()) { + try { + dispatchService.interruptRun(snapshot(worker)); + } catch (Exception e) { + log.warn("Team task {} committed interrupt failed: {}", + worker.taskId(), e.getMessage()); + } + } + try { + eventPublisher.publishCancelled(intent.run()); + } catch (Exception e) { + log.warn("Team run {} committed cancellation event failed: {}", + intent.run().id(), e.getMessage()); + } + } + + private TeamTaskEntity snapshot(TeamRunCancelCommittedIntent.WorkerTask worker) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(worker.taskId()); + task.setTaskNumber(worker.taskNumber()); + task.setConversationId(worker.conversationId()); + return task; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunEventPublisher.java new file mode 100644 index 00000000..a2ddd2e9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunEventPublisher.java @@ -0,0 +1,9 @@ +package vip.mate.team.service; + +import vip.mate.team.model.TeamRunView; + +/** Stable application boundary for team run lifecycle events. */ +public interface TeamRunEventPublisher { + + void publishCancelled(TeamRunView run); +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionExecutor.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionExecutor.java new file mode 100644 index 00000000..e55b8513 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionExecutor.java @@ -0,0 +1,30 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamTaskMapper; + +/** Executes each run projection in an independent transaction. */ +@Service +@RequiredArgsConstructor +public class TeamRunProjectionExecutor { + + private final TeamRunProjector runProjector; + private final TeamTaskMapper taskMapper; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(Long runId) { + runProjector.project(runId); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void executeTask(Long taskId) { + TeamTaskEntity task = taskMapper.selectById(taskId); + if (task != null && task.getRunId() != null) { + runProjector.project(task.getRunId()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionScheduler.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionScheduler.java new file mode 100644 index 00000000..1ceade82 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjectionScheduler.java @@ -0,0 +1,66 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Schedules run projection outside the task mutation transaction. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamRunProjectionScheduler { + + private final TeamRunProjectionExecutor projectionExecutor; + + public void scheduleRun(Long runId) { + if (runId == null) { + return; + } + if (TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + projectRun(runId); + } + }); + return; + } + projectRun(runId); + } + + public void scheduleTask(Long taskId) { + if (taskId == null) { + return; + } + if (TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + projectTask(taskId); + } + }); + return; + } + projectTask(taskId); + } + + private void projectRun(Long runId) { + try { + projectionExecutor.execute(runId); + } catch (RuntimeException error) { + log.warn("Team run {} projection failed: {}", runId, error.getMessage()); + } + } + + private void projectTask(Long taskId) { + try { + projectionExecutor.executeTask(taskId); + } catch (RuntimeException error) { + log.warn("Team run projection for task {} failed: {}", taskId, error.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java new file mode 100644 index 00000000..dac9b43a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java @@ -0,0 +1,112 @@ +package vip.mate.team.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; + +import java.util.List; + +/** Projects task state into its owning run without exposing failures to task settlement. */ +@Slf4j +@Service +public class TeamRunProjector { + + private final TeamRunMapper runMapper; + private final TeamTaskMapper taskMapper; + private final TeamRunStateMachine stateMachine; + + public TeamRunProjector(TeamRunMapper runMapper, TeamTaskMapper taskMapper) { + this.runMapper = runMapper; + this.taskMapper = taskMapper; + this.stateMachine = new TeamRunStateMachine(); + } + + public TeamRunView project(Long runId) { + if (runId == null) { + return null; + } + try { + return projectOnce(runId, 1); + } catch (RuntimeException error) { + log.warn("Failed to project team run {}", runId, error); + return null; + } + } + + private TeamRunView projectOnce(Long runId, int retryRemaining) { + TeamRunEntity run = runMapper.selectById(runId); + if (run == null) { + return null; + } + List tasks = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getRunId, runId) + .orderByAsc(TeamTaskEntity::getTaskNumber)); + TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks); + if (TeamRunStatus.isTerminal(run.getStatus()) || TeamRunStatus.PLANNING.equals(run.getStatus())) { + return view(run, projection, tasks); + } + + JSONObject metadata = metadata(run.getMetadata()); + boolean metadataChanged; + if (projection.projectedOutcome() == null) { + metadataChanged = metadata.containsKey("projectedOutcome"); + metadata.remove("projectedOutcome"); + } else { + metadataChanged = !projection.projectedOutcome().equals(metadata.getStr("projectedOutcome")); + metadata.set("projectedOutcome", projection.projectedOutcome()); + } + boolean statusChanged = !projection.status().equals(run.getStatus()); + if (!statusChanged && !metadataChanged) { + return view(run, projection, tasks); + } + + String metadataJson = metadata.toString(); + LambdaUpdateWrapper update = Wrappers.lambdaUpdate() + .eq(TeamRunEntity::getId, run.getId()) + .eq(TeamRunEntity::getStatus, run.getStatus()); + if (run.getMetadata() == null) { + update.isNull(TeamRunEntity::getMetadata); + } else { + update.eq(TeamRunEntity::getMetadata, run.getMetadata()); + } + update + .set(TeamRunEntity::getStatus, projection.status()) + .set(TeamRunEntity::getMetadata, metadataJson); + int changed = runMapper.update(null, update); + if (changed == 1) { + run.setStatus(projection.status()); + run.setMetadata(metadataJson); + return view(run, projection, tasks); + } + return retryRemaining > 0 ? projectOnce(runId, retryRemaining - 1) : null; + } + + private TeamRunView view(TeamRunEntity run, TeamRunStateMachine.Projection projection, + List tasks) { + return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(), + run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(), + run.getStatus(), run.getFinalSummary(), run.getStopReason(), run.getMetadata(), + run.getStartedAt(), run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(), + projection.progress(), tasks.stream().map(TeamRunView.Task::from).toList()); + } + + private JSONObject metadata(String value) { + if (value == null || value.isBlank()) { + return new JSONObject(); + } + try { + return JSONUtil.parseObj(value); + } catch (RuntimeException invalidJson) { + return new JSONObject(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java new file mode 100644 index 00000000..d32f7ea1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java @@ -0,0 +1,280 @@ +package vip.mate.team.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** Owns team run creation, lifecycle transitions, authorization, and reads. */ +@Service +public class TeamRunService { + + public record SealResult(TeamRunEntity run, boolean transitioned) { + } + + public record CancelResult(TeamRunEntity run, boolean transitioned) { + } + + private static final int MAX_TITLE_LENGTH = 255; + private static final Set FINAL_OUTCOMES = Set.of( + TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED); + + private final TeamRunMapper runMapper; + private final TeamTaskMapper taskMapper; + private final TeamService teamService; + private final TeamRunStateMachine stateMachine; + + public TeamRunService(TeamRunMapper runMapper, TeamTaskMapper taskMapper, TeamService teamService) { + this.runMapper = runMapper; + this.taskMapper = taskMapper; + this.teamService = teamService; + this.stateMachine = new TeamRunStateMachine(); + } + + public TeamRunEntity startRun(TeamRunCreateCommand command) { + validateCreate(command); + TeamRunEntity existing = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(), + command.getOriginMessageId()); + if (existing != null) { + return existing; + } + + TeamRunEntity run = new TeamRunEntity(); + run.setTeamId(command.getTeamId()); + run.setWorkspaceId(command.getWorkspaceId()); + run.setLeadAgentId(command.getLeadAgentId()); + run.setLeadConversationId(command.getLeadConversationId()); + run.setOriginMessageId(command.getOriginMessageId()); + run.setTitle(deriveTitle(command)); + run.setObjective(command.getObjective().trim()); + run.setStatus(TeamRunStatus.PLANNING); + run.setMetadata(command.getMetadata()); + try { + runMapper.insert(run); + return run; + } catch (DuplicateKeyException duplicate) { + TeamRunEntity winner = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(), + command.getOriginMessageId()); + if (winner != null) { + return winner; + } + throw duplicate; + } + } + + public TeamRunEntity requireRun(Long runId, Long workspaceId) { + TeamRunEntity run = runMapper.selectById(runId); + if (run == null || workspaceId == null || !workspaceId.equals(run.getWorkspaceId())) { + throw new IllegalArgumentException("team run not found in workspace: " + runId); + } + return run; + } + + public Set findPlanningRunIds(Collection runIds) { + if (runIds == null || runIds.isEmpty()) { + return Set.of(); + } + return runMapper.selectBatchIds(runIds).stream() + .filter(run -> TeamRunStatus.PLANNING.equals(run.getStatus())) + .map(TeamRunEntity::getId) + .collect(Collectors.toSet()); + } + + public TeamRunView getRun(Long runId, Long workspaceId) { + return buildView(requireRun(runId, workspaceId)); + } + + public List listTeamRuns(Long teamId, Long workspaceId) { + return runMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamRunEntity::getTeamId, teamId) + .eq(TeamRunEntity::getWorkspaceId, workspaceId) + .orderByDesc(TeamRunEntity::getCreateTime)) + .stream().map(this::buildView).toList(); + } + + public List listConversationRuns(String conversationId, Long workspaceId) { + return runMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamRunEntity::getLeadConversationId, conversationId) + .eq(TeamRunEntity::getWorkspaceId, workspaceId) + .orderByDesc(TeamRunEntity::getCreateTime)) + .stream().map(this::buildView).toList(); + } + + @Transactional + public TeamRunEntity sealRun(Long runId, Long workspaceId) { + return sealRunWithResult(runId, workspaceId).run(); + } + + @Transactional + public SealResult sealRunWithResult(Long runId, Long workspaceId) { + TeamRunEntity run = requireRun(runId, workspaceId); + if (!TeamRunStatus.PLANNING.equals(run.getStatus())) { + return new SealResult(run, false); + } + long taskCount = taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getRunId, runId)); + if (taskCount == 0) { + throw new IllegalStateException("cannot seal a team run without tasks"); + } + + LocalDateTime startedAt = LocalDateTime.now(); + int changed = runMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamRunEntity::getId, runId) + .eq(TeamRunEntity::getStatus, TeamRunStatus.PLANNING) + .set(TeamRunEntity::getStatus, TeamRunStatus.RUNNING) + .set(TeamRunEntity::getStartedAt, startedAt)); + if (changed == 1) { + run.setStatus(TeamRunStatus.RUNNING); + run.setStartedAt(startedAt); + return new SealResult(run, true); + } + TeamRunEntity current = requireRun(runId, workspaceId); + if (!TeamRunStatus.PLANNING.equals(current.getStatus())) { + return new SealResult(current, false); + } + throw new IllegalStateException("failed to seal team run: " + runId); + } + + @Transactional + public TeamRunEntity markFinalized(Long runId, Long workspaceId, String finalSummary) { + TeamRunEntity run = requireRun(runId, workspaceId); + if (TeamRunStatus.isTerminal(run.getStatus())) { + return run; + } + if (!TeamRunStatus.FINALIZING.equals(run.getStatus())) { + throw new IllegalStateException("team run is not finalizing: " + runId); + } + String outcome = metadata(run.getMetadata()).getStr("projectedOutcome"); + if (!FINAL_OUTCOMES.contains(outcome)) { + throw new IllegalStateException("team run has no valid projected outcome: " + runId); + } + + LocalDateTime completedAt = LocalDateTime.now(); + int changed = runMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamRunEntity::getId, runId) + .eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING) + .set(TeamRunEntity::getStatus, outcome) + .set(TeamRunEntity::getFinalSummary, finalSummary) + .set(TeamRunEntity::getCompletedAt, completedAt)); + if (changed == 1) { + run.setStatus(outcome); + run.setFinalSummary(finalSummary); + run.setCompletedAt(completedAt); + return run; + } + TeamRunEntity current = requireRun(runId, workspaceId); + if (TeamRunStatus.isTerminal(current.getStatus())) { + return current; + } + throw new IllegalStateException("failed to finalize team run: " + runId); + } + + @Transactional + public TeamRunEntity cancelRun(Long runId, Long workspaceId, String reason) { + return cancelRunWithResult(runId, workspaceId, reason).run(); + } + + @Transactional + public CancelResult cancelRunWithResult(Long runId, Long workspaceId, String reason) { + TeamRunEntity run = requireRun(runId, workspaceId); + if (TeamRunStatus.isTerminal(run.getStatus())) { + return new CancelResult(run, false); + } + LocalDateTime completedAt = LocalDateTime.now(); + int changed = runMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamRunEntity::getId, runId) + .notIn(TeamRunEntity::getStatus, TeamRunStatus.TERMINAL) + .set(TeamRunEntity::getStatus, TeamRunStatus.CANCELLED) + .set(TeamRunEntity::getStopReason, reason) + .set(TeamRunEntity::getCompletedAt, completedAt)); + if (changed == 1) { + run.setStatus(TeamRunStatus.CANCELLED); + run.setStopReason(reason); + run.setCompletedAt(completedAt); + return new CancelResult(run, true); + } + return new CancelResult(requireRun(runId, workspaceId), false); + } + + public TeamRunView buildView(TeamRunEntity run) { + List tasks = tasksForRun(run.getId()); + TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks); + return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(), + run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(), + projection.status(), run.getFinalSummary(), run.getStopReason(), run.getMetadata(), + run.getStartedAt(), run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(), + projection.progress(), tasks.stream().map(TeamRunView.Task::from).toList()); + } + + private List tasksForRun(Long runId) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getRunId, runId) + .orderByAsc(TeamTaskEntity::getTaskNumber)); + } + + private void validateCreate(TeamRunCreateCommand command) { + if (command == null || command.getTeamId() == null || command.getWorkspaceId() == null + || command.getLeadAgentId() == null) { + throw new IllegalArgumentException("team, workspace, and lead are required"); + } + AgentTeamEntity team = teamService.getTeam(command.getTeamId()); + if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) { + throw new IllegalArgumentException("team not found or not active: " + command.getTeamId()); + } + if (!command.getWorkspaceId().equals(team.getWorkspaceId())) { + throw new IllegalArgumentException("team is not in workspace: " + command.getWorkspaceId()); + } + if (!command.getLeadAgentId().equals(team.getLeadAgentId())) { + throw new IllegalArgumentException("agent is not the team lead: " + command.getLeadAgentId()); + } + if (command.getLeadConversationId() == null || command.getLeadConversationId().isBlank()) { + throw new IllegalArgumentException("lead conversation is required"); + } + if (command.getObjective() == null || command.getObjective().isBlank()) { + throw new IllegalArgumentException("objective is required"); + } + } + + private TeamRunEntity findByOrigin(Long workspaceId, String conversationId, Long originMessageId) { + if (originMessageId == null) { + return null; + } + return runMapper.selectOne(Wrappers.lambdaQuery() + .eq(TeamRunEntity::getWorkspaceId, workspaceId) + .eq(TeamRunEntity::getLeadConversationId, conversationId) + .eq(TeamRunEntity::getOriginMessageId, originMessageId)); + } + + private String deriveTitle(TeamRunCreateCommand command) { + String title = command.getTitle() == null || command.getTitle().isBlank() + ? command.getObjective().trim() : command.getTitle().trim(); + return title.length() <= MAX_TITLE_LENGTH ? title : title.substring(0, MAX_TITLE_LENGTH); + } + + private JSONObject metadata(String value) { + if (value == null || value.isBlank()) { + return new JSONObject(); + } + try { + return JSONUtil.parseObj(value); + } catch (RuntimeException invalidJson) { + return new JSONObject(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunStateMachine.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunStateMachine.java new file mode 100644 index 00000000..23f9d920 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunStateMachine.java @@ -0,0 +1,78 @@ +package vip.mate.team.service; + +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.List; +import java.util.Set; + +/** Pure task-to-run lifecycle projection. */ +public final class TeamRunStateMachine { + + private static final Set ACTIVE_TASK_STATUSES = Set.of( + TeamTaskStatus.PENDING, + TeamTaskStatus.BLOCKED, + TeamTaskStatus.IN_PROGRESS, + TeamTaskStatus.STALE + ); + private static final Set KNOWN_TASK_STATUSES = Set.of( + TeamTaskStatus.PENDING, + TeamTaskStatus.BLOCKED, + TeamTaskStatus.IN_PROGRESS, + TeamTaskStatus.IN_REVIEW, + TeamTaskStatus.COMPLETED, + TeamTaskStatus.FAILED, + TeamTaskStatus.CANCELLED, + TeamTaskStatus.STALE + ); + + public Projection project(TeamRunEntity run, List tasks) { + List safeTasks = tasks == null ? List.of() : tasks; + TeamRunView.Progress progress = progress(safeTasks); + String currentStatus = run.getStatus(); + + if (TeamRunStatus.isTerminal(currentStatus) || TeamRunStatus.PLANNING.equals(currentStatus)) { + return new Projection(currentStatus, null, progress); + } + if (safeTasks.isEmpty() + || safeTasks.stream().anyMatch(task -> !KNOWN_TASK_STATUSES.contains(task.getStatus()))) { + return new Projection(currentStatus, null, progress); + } + if (safeTasks.stream().anyMatch(task -> ACTIVE_TASK_STATUSES.contains(task.getStatus()))) { + return new Projection(TeamRunStatus.RUNNING, null, progress); + } + if (safeTasks.stream().anyMatch(task -> TeamTaskStatus.IN_REVIEW.equals(task.getStatus()))) { + return new Projection(TeamRunStatus.AWAITING_REVIEW, null, progress); + } + + String outcome = progress.done() == progress.total() + ? TeamRunStatus.COMPLETED + : progress.done() > 0 ? TeamRunStatus.PARTIAL : TeamRunStatus.FAILED; + return new Projection(TeamRunStatus.FINALIZING, outcome, progress); + } + + private TeamRunView.Progress progress(List tasks) { + int done = 0; + int failed = 0; + int inReview = 0; + for (TeamTaskEntity task : tasks) { + if (TeamTaskStatus.COMPLETED.equals(task.getStatus())) { + done++; + } else if (TeamTaskStatus.FAILED.equals(task.getStatus()) + || TeamTaskStatus.CANCELLED.equals(task.getStatus())) { + failed++; + } else if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) { + inReview++; + } + } + int total = tasks.size(); + int percent = total == 0 ? 0 : done * 100 / total; + return new TeamRunView.Progress(total, done, failed, inReview, percent); + } + + public record Projection(String status, String projectedOutcome, TeamRunView.Progress progress) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index 5b64e7a8..134699fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -13,6 +13,8 @@ import vip.mate.team.model.TeamTaskCommentEntity; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskEventEntity; import vip.mate.team.repository.TeamTaskCommentMapper; import vip.mate.team.repository.TeamTaskEventMapper; @@ -26,6 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * Shared task board service. All status transitions are guarded conditional @@ -57,6 +60,8 @@ public class TeamTaskService { private final TeamTaskCommentMapper commentMapper; private final TeamTaskEventMapper eventMapper; private final TeamService teamService; + private final TeamRunProjectionScheduler projectionScheduler; + private final TeamRunService runService; // ==================== creation ==================== @@ -66,6 +71,15 @@ public class TeamTaskService { if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) { throw new IllegalArgumentException("team not found or not active: " + cmd.getTeamId()); } + if (cmd.getRunId() != null) { + TeamRunEntity run = runService.requireRun(cmd.getRunId(), team.getWorkspaceId()); + if (!cmd.getTeamId().equals(run.getTeamId())) { + throw new IllegalArgumentException("team task and run must belong to the same team"); + } + if (!TeamRunStatus.PLANNING.equals(run.getStatus())) { + throw new IllegalStateException("team run must be planning to accept tasks: " + cmd.getRunId()); + } + } if (cmd.getSubject() == null || cmd.getSubject().isBlank()) { throw new IllegalArgumentException("subject is required"); } @@ -92,6 +106,9 @@ public class TeamTaskService { if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) { throw new IllegalArgumentException("blocking task not found in this team: " + blockerId); } + if (!Objects.equals(blocker.getRunId(), cmd.getRunId())) { + throw new IllegalArgumentException("blocking task must belong to the same run: " + blockerId); + } if (TeamTaskStatus.isTerminal(blocker.getStatus())) { throw new IllegalArgumentException("blocking task " + blockerId + " is already " + blocker.getStatus() @@ -101,6 +118,7 @@ public class TeamTaskService { TeamTaskEntity task = new TeamTaskEntity(); task.setTeamId(cmd.getTeamId()); + task.setRunId(cmd.getRunId()); task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId())); task.setSubject(cmd.getSubject()); task.setDescription(cmd.getDescription()); @@ -125,6 +143,7 @@ public class TeamTaskService { "assignee: agent " + assignee); log.info("Team {} task #{} created ({}), assignee={} status={}", cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus()); + projectTask(task); return task; } @@ -135,13 +154,17 @@ public class TeamTaskService { * get false. The WHERE clause is the mutex. */ public boolean claimTask(Long taskId, Long agentId) { - return taskMapper.update(null, Wrappers.lambdaUpdate() + boolean claimed = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .isNull(TeamTaskEntity::getOwnerAgentId) .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getOwnerAgentId, agentId) .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; + if (claimed) { + projectTask(taskId); + } + return claimed; } /** @@ -149,12 +172,16 @@ public class TeamTaskService { * this overrides a previously set owner but still requires pending status. */ public boolean assignTask(Long taskId, Long agentId) { - return taskMapper.update(null, Wrappers.lambdaUpdate() + boolean assigned = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getOwnerAgentId, agentId) .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; + if (assigned) { + projectTask(taskId); + } + return assigned; } /** Record the member conversation executing the task. */ @@ -207,7 +234,9 @@ public class TeamTaskService { toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED, agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM, agentId != null ? String.valueOf(agentId) : null, null); - return toReview ? List.of() : releaseDependents(task); + List released = toReview ? List.of() : releaseDependents(task); + projectTask(task); + return released; } /** Human approval of an in_review task; releases dependents. */ @@ -221,7 +250,9 @@ public class TeamTaskService { if (rows != 1) { throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); } - return releaseDependents(task); + List released = releaseDependents(task); + projectTask(task); + return released; } /** Human rejection of an in_review task; cancels it and releases dependents. */ @@ -236,11 +267,14 @@ public class TeamTaskService { if (rows != 1) { throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); } - return releaseDependents(task); + List released = releaseDependents(task); + projectTask(task); + return released; } /** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */ public boolean failTask(Long taskId, String reason) { + TeamTaskEntity task = taskMapper.selectById(taskId); boolean failed = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .in(TeamTaskEntity::getStatus, @@ -249,9 +283,9 @@ public class TeamTaskService { .set(TeamTaskEntity::getReason, reason) .set(TeamTaskEntity::getLockExpiresAt, null)) == 1; if (failed) { - TeamTaskEntity task = taskMapper.selectById(taskId); recordEvent(task == null ? null : task.getTeamId(), taskId, TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason); + projectTask(taskId); } return failed; } @@ -270,12 +304,14 @@ public class TeamTaskService { if (rows != 1) { throw new IllegalStateException("task #" + task.getTaskNumber() + " is already terminal"); } - return releaseDependents(task); + List released = releaseDependents(task); + projectTask(task); + return released; } /** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */ public boolean retryTask(Long taskId) { - return taskMapper.update(null, Wrappers.lambdaUpdate() + boolean retried = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE) .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) @@ -283,12 +319,17 @@ public class TeamTaskService { .set(TeamTaskEntity::getLockExpiresAt, null) .set(TeamTaskEntity::getReason, null) .set(TeamTaskEntity::getDispatchCount, 0)) == 1; + if (retried) { + projectTask(taskId); + } + return retried; } // ==================== progress / comments ==================== /** Update progress and renew the execution lease in one shot. */ public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) { + TeamTaskEntity task = taskMapper.selectById(taskId); boolean updated = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) @@ -297,11 +338,11 @@ public class TeamTaskService { .set(step != null, TeamTaskEntity::getProgressStep, step) .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; if (updated) { - TeamTaskEntity task = taskMapper.selectById(taskId); recordEvent(task == null ? null : task.getTeamId(), taskId, TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, (percent != null ? percent + "%" : "") + (step != null ? " — " + step : "")); + projectTask(taskId); } return updated; } @@ -524,12 +565,20 @@ public class TeamTaskService { * picks at most one per assignee so a member never runs two tasks at once. */ public List findDispatchable(Long teamId) { - return taskMapper.selectList(Wrappers.lambdaQuery() + List candidates = taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .isNotNull(TeamTaskEntity::getAssigneeAgentId) .orderByDesc(TeamTaskEntity::getPriority) .orderByAsc(TeamTaskEntity::getCreateTime)); + Set runIds = candidates.stream() + .map(TeamTaskEntity::getRunId) + .filter(Objects::nonNull) + .collect(java.util.stream.Collectors.toSet()); + Set planningRunIds = runService.findPlanningRunIds(runIds); + return candidates.stream() + .filter(task -> task.getRunId() == null || !planningRunIds.contains(task.getRunId())) + .toList(); } /** Whether the agent is already executing a task in this team. */ @@ -551,13 +600,16 @@ public class TeamTaskService { .isNotNull(TeamTaskEntity::getLockExpiresAt) .lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now())); for (TeamTaskEntity task : expired) { - taskMapper.update(null, Wrappers.lambdaUpdate() + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, task.getId()) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE) .set(TeamTaskEntity::getReason, "execution lease expired")); - recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE, - AUTHOR_SYSTEM, null, "execution lease expired"); + if (rows == 1) { + recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE, + AUTHOR_SYSTEM, null, "execution lease expired"); + projectTask(task); + } } if (!expired.isEmpty()) { log.warn("Marked {} team task(s) stale after lease expiry", expired.size()); @@ -571,6 +623,12 @@ public class TeamTaskService { return taskMapper.selectById(taskId); } + public List listTasksByRun(Long runId) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getRunId, runId) + .orderByAsc(TeamTaskEntity::getTaskNumber)); + } + /** * Tasks created from a delegated plan's steps, ordered by creation. The * plan linkage lives in the task metadata JSON ({@code "planId"} written @@ -659,6 +717,7 @@ public class TeamTaskService { .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)); if (rows == 1) { released.add(candidate.getId()); + projectTask(candidate); } } if (!released.isEmpty()) { @@ -678,6 +737,26 @@ public class TeamTaskService { return task; } + private void projectTask(Long taskId) { + try { + projectionScheduler.scheduleTask(taskId); + } catch (RuntimeException error) { + log.warn("Team run projection failed after task {} changed: {}", taskId, error.getMessage()); + } + } + + private void projectTask(TeamTaskEntity task) { + if (task == null || task.getRunId() == null) { + return; + } + try { + projectionScheduler.scheduleRun(task.getRunId()); + } catch (RuntimeException error) { + log.warn("Team run {} projection failed after task {} changed: {}", + task.getRunId(), task.getId(), error.getMessage()); + } + } + private static LocalDateTime newLease() { return LocalDateTime.now().plusMinutes(LOCK_MINUTES); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 90e1fb29..2d08a920 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -11,6 +11,8 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.TeamTaskCommentEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEventEntity; @@ -18,6 +20,7 @@ import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamRunService; import vip.mate.team.service.TeamTaskService; import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.workspace.conversation.ConversationService; @@ -47,6 +50,7 @@ public class TeamTasksTool { private final TeamService teamService; private final TeamTaskService taskService; + private final TeamRunService runService; private final TeamDispatchService dispatchService; private final TeamEventChannel eventChannel; private final ConversationService conversationService; @@ -54,9 +58,11 @@ public class TeamTasksTool { @Tool(description = "Operate your team's shared task board. Actions: " + "'list' all tasks; 'get' one task with comments (taskId); " - + "'create' a task (lead only; subject, description, assigneeAgentId required, " + + "'start_run' (lead only; objective required, optional title) returns a runId; " + + "'create' stages a task (lead only; runId, subject, description, assigneeAgentId required, " + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " + "requireApproval=true to park the finished task for human sign-off); " + + "'seal_run' (lead only; runId) seals the batch and starts dispatch; " + "'complete' a task with its result summary (taskId, result); " + "'progress' to report execution progress (taskId, percent 0-100, step); " + "'comment' to leave a note, or type='blocker' when you are stuck and need the lead " @@ -66,10 +72,16 @@ public class TeamTasksTool { + "'retry' a failed/stale task back to pending (lead only; taskId). " + "Only usable when you belong to an agent team.") public String team_tasks( - @ToolParam(description = "One of: list, get, create, complete, progress, comment, attach, cancel, retry") + @ToolParam(description = "One of: start_run, create, seal_run, list, get, complete, progress, comment, attach, cancel, retry") String action, - @ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false) + @ToolParam(description = "Task id (string form is fine) — required by get/complete/progress/comment/attach/cancel/retry", required = false) String taskId, + @ToolParam(description = "create/seal_run: explicit team run id", required = false) + String runId, + @ToolParam(description = "start_run: concise run title", required = false) + String title, + @ToolParam(description = "start_run: objective for the delegated work", required = false) + String objective, @ToolParam(description = "create: short task title", required = false) String subject, @ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false) @@ -106,7 +118,11 @@ public class TeamTasksTool { if (conversation == null || conversation.getAgentId() == null) { return "Error: cannot resolve the calling agent for this conversation."; } + if (conversation.getWorkspaceId() == null) { + return "Error: workspaceId is missing from conversation context."; + } Long agentId = conversation.getAgentId(); + Long workspaceId = conversation.getWorkspaceId(); Optional teamOpt = teamService.getTeamForAgent(agentId); if (teamOpt.isEmpty()) { return "Error: you are not part of any agent team; team_tasks is unavailable."; @@ -116,10 +132,14 @@ public class TeamTasksTool { try { return switch (action == null ? "" : action) { + case "start_run" -> startRun(team, agentId, isLead, workspaceId, + conversationId, title, objective, ctx); case "list" -> renderBoard(team); case "get" -> renderDetail(team, parseId(taskId, "taskId")); - case "create" -> createTask(team, agentId, isLead, subject, description, - assigneeAgentId, blockedBy, priority, requireApproval, conversationId); + case "create" -> createTask(team, agentId, isLead, workspaceId, runId, + subject, description, assigneeAgentId, blockedBy, priority, + requireApproval, conversationId); + case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId); case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); @@ -127,7 +147,8 @@ public class TeamTasksTool { case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId")); default -> "Error: unknown action '" + action - + "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry."; + + "'. Use one of: start_run, create, seal_run, list, get, complete, progress, " + + "comment, attach, cancel, retry."; }; } catch (IllegalArgumentException | IllegalStateException e) { return "Error: " + e.getMessage(); @@ -140,7 +161,26 @@ public class TeamTasksTool { // ==================== actions ==================== + private String startRun(AgentTeamEntity team, Long agentId, boolean isLead, + Long workspaceId, String conversationId, String title, + String objective, @Nullable ToolContext ctx) { + if (!isLead) { + return "Error: only the team lead can start runs."; + } + TeamRunEntity run = runService.startRun(TeamRunCreateCommand.builder() + .teamId(team.getId()) + .workspaceId(workspaceId) + .leadAgentId(agentId) + .leadConversationId(conversationId) + .originMessageId(ToolExecutionContext.originMessageId(ctx)) + .title(title) + .objective(objective) + .build()); + return String.valueOf(run.getId()); + } + private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, + Long workspaceId, String runId, String subject, String description, String assigneeAgentId, String blockedBy, Integer priority, Boolean requireApproval, String conversationId) { @@ -148,8 +188,11 @@ public class TeamTasksTool { return "Error: only the team lead can create tasks. Report blockers or ask the " + "lead via a comment on your current task instead."; } + Long parsedRunId = parseId(runId, "runId"); + requireRun(team, workspaceId, conversationId, parsedRunId); TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() .teamId(team.getId()) + .runId(parsedRunId) .subject(subject) .description(description) .assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId")) @@ -160,15 +203,27 @@ public class TeamTasksTool { .leadConversationId(conversationId) .build()); eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); - if (TeamTaskStatus.PENDING.equals(task.getStatus())) { - dispatchService.requestDispatch(team.getId()); - } return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId() + ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId()) + ". Status: " + task.getStatus() + (TeamTaskStatus.BLOCKED.equals(task.getStatus()) ? " (starts automatically once its prerequisites finish)." : ".") - + " Members are dispatched automatically — do not wait in this turn."; + + " Seal the run after all tasks are staged."; + } + + private String sealRun(AgentTeamEntity team, boolean isLead, Long workspaceId, + String conversationId, String runId) { + if (!isLead) { + return "Error: only the team lead can seal runs."; + } + Long parsedRunId = parseId(runId, "runId"); + requireRun(team, workspaceId, conversationId, parsedRunId); + TeamRunService.SealResult result = runService.sealRunWithResult(parsedRunId, workspaceId); + if (result.transitioned()) { + dispatchService.requestDispatch(team.getId()); + return "✓ Team run " + parsedRunId + " sealed; dispatch started."; + } + return "Team run " + parsedRunId + " was already sealed; dispatch unchanged."; } private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) { @@ -333,6 +388,17 @@ public class TeamTasksTool { return task; } + private TeamRunEntity requireRun(AgentTeamEntity team, Long workspaceId, + String conversationId, Long runId) { + TeamRunEntity run = runService.requireRun(runId, workspaceId); + if (!team.getId().equals(run.getTeamId()) + || !conversationId.equals(run.getLeadConversationId())) { + throw new IllegalArgumentException( + "runId does not belong to this team and lead conversation: " + runId); + } + return run; + } + private String agentName(Long agentId) { if (agentId == null) { return "-"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java index 1f199b09..9b2309a9 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java @@ -87,4 +87,8 @@ public final class ToolExecutionContext { } return WORKSPACE_BASE_PATH.get(); } + + public static Long originMessageId(@Nullable ToolContext ctx) { + return ctx == null ? null : ChatOrigin.from(ctx).originMessageId(); + } } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V181__team_run_foundation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V181__team_run_foundation.sql new file mode 100644 index 00000000..250f4639 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V181__team_run_foundation.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS mate_team_run ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + lead_agent_id BIGINT NOT NULL, + lead_conversation_id VARCHAR(64) NOT NULL, + origin_message_id BIGINT NULL, + title VARCHAR(255) NOT NULL, + objective TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'planning', + final_summary TEXT, + stop_reason VARCHAR(1000), + metadata TEXT, + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_team_run_team_history + ON mate_team_run (team_id, create_time); +CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history + ON mate_team_run (lead_conversation_id, create_time); +CREATE INDEX IF NOT EXISTS idx_team_run_status + ON mate_team_run (status, update_time); +CREATE UNIQUE INDEX IF NOT EXISTS uk_team_run_origin_message + ON mate_team_run (workspace_id, lead_conversation_id, origin_message_id); + +ALTER TABLE mate_team_task + ADD COLUMN IF NOT EXISTS run_id BIGINT NULL; + +CREATE INDEX IF NOT EXISTS idx_team_task_run_number + ON mate_team_task (run_id, task_number); +CREATE INDEX IF NOT EXISTS idx_team_task_run_status + ON mate_team_task (run_id, status); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V181__team_run_foundation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V181__team_run_foundation.sql new file mode 100644 index 00000000..250f4639 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V181__team_run_foundation.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS mate_team_run ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + lead_agent_id BIGINT NOT NULL, + lead_conversation_id VARCHAR(64) NOT NULL, + origin_message_id BIGINT NULL, + title VARCHAR(255) NOT NULL, + objective TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'planning', + final_summary TEXT, + stop_reason VARCHAR(1000), + metadata TEXT, + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_team_run_team_history + ON mate_team_run (team_id, create_time); +CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history + ON mate_team_run (lead_conversation_id, create_time); +CREATE INDEX IF NOT EXISTS idx_team_run_status + ON mate_team_run (status, update_time); +CREATE UNIQUE INDEX IF NOT EXISTS uk_team_run_origin_message + ON mate_team_run (workspace_id, lead_conversation_id, origin_message_id); + +ALTER TABLE mate_team_task + ADD COLUMN IF NOT EXISTS run_id BIGINT NULL; + +CREATE INDEX IF NOT EXISTS idx_team_task_run_number + ON mate_team_task (run_id, task_number); +CREATE INDEX IF NOT EXISTS idx_team_task_run_status + ON mate_team_task (run_id, status); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V181__team_run_foundation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V181__team_run_foundation.sql new file mode 100644 index 00000000..54b7a4c7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V181__team_run_foundation.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS mate_team_run ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + lead_agent_id BIGINT NOT NULL, + lead_conversation_id VARCHAR(64) NOT NULL, + origin_message_id BIGINT NULL, + title VARCHAR(255) NOT NULL, + objective TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'planning', + final_summary TEXT, + stop_reason VARCHAR(1000), + metadata TEXT, + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_team_run_team_history (team_id, create_time), + KEY idx_team_run_conversation_history (lead_conversation_id, create_time), + KEY idx_team_run_status (status, update_time), + UNIQUE KEY uk_team_run_origin_message (workspace_id, lead_conversation_id, origin_message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_team_task' + AND COLUMN_NAME = 'run_id'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_team_task ADD COLUMN run_id BIGINT NULL AFTER team_id', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_team_task' + AND INDEX_NAME = 'idx_team_task_run_number'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_team_task_run_number ON mate_team_task (run_id, task_number)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_team_task' + AND INDEX_NAME = 'idx_team_task_run_status'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_team_task_run_status ON mate_team_task (run_id, status)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index 129722d2..b4fc7862 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -3,6 +3,7 @@ package vip.mate.agent.context; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.model.ToolContext; +import vip.mate.tool.builtin.ToolExecutionContext; import java.util.Map; @@ -49,6 +50,20 @@ class ChatOriginTest { "channelTarget must be preserved"); } + @Test + void originMessageId_isExplicitAndPreservedByWithersAndToolContext() { + ChatOrigin origin = ChatOrigin.web("conv-1", "user", 5L, null) + .withOriginMessageId(99L); + + assertNull(ChatOrigin.EMPTY.originMessageId()); + assertEquals(99L, origin.withAgent(7L).originMessageId()); + assertEquals(99L, origin.withWorkspace(6L, "/ws").originMessageId()); + assertEquals(99L, origin.withConversationId("conv-2").originMessageId()); + assertEquals(99L, origin.withBaseUrl("https://example.test").originMessageId()); + assertEquals(99L, origin.withSender("Alice", "web", null).originMessageId()); + assertEquals(99L, ToolExecutionContext.originMessageId(origin.toToolContext())); + } + @Test void cronFactory_setsRequesterToSystem() { ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 3L, null); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/controller/AgentControllerOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/controller/AgentControllerOriginTest.java new file mode 100644 index 00000000..d7ad2810 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/controller/AgentControllerOriginTest.java @@ -0,0 +1,116 @@ +package vip.mate.agent.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.service.AgentGenerationService; +import vip.mate.audit.service.AuditEventService; +import vip.mate.auth.service.AuthService; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.system.service.SystemSettingService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.core.service.WorkspaceService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentControllerOriginTest { + + private static final Long AGENT_ID = 10L; + private static final Long WORKSPACE_ID = 30L; + private static final Long MESSAGE_ID = 99L; + private static final String CONVERSATION_ID = "agent-entry"; + private static final String MESSAGE = "do work"; + + private AgentService agentService; + private ConversationService conversations; + private AgentController controller; + + @BeforeEach + void setUp() { + agentService = mock(AgentService.class); + conversations = mock(ConversationService.class); + controller = new AgentController(agentService, conversations, + mock(AuditEventService.class), mock(AuthService.class), mock(WorkspaceService.class), + mock(ModelConfigService.class), mock(ModelCapabilityService.class), + mock(SystemSettingService.class), mock(AgentGenerationService.class), + new ObjectMapper()); + AgentEntity agent = new AgentEntity(); + agent.setId(AGENT_ID); + agent.setWorkspaceId(WORKSPACE_ID); + agent.setEnabled(true); + when(agentService.getAgent(AGENT_ID)).thenReturn(agent); + MessageEntity saved = new MessageEntity(); + saved.setId(MESSAGE_ID); + when(conversations.saveMessage(CONVERSATION_ID, "user", MESSAGE)).thenReturn(saved); + } + + @Test + void sseEntryPersistsOnceAndUsesExplicitOrigin() { + when(agentService.chatStream(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any())) + .thenReturn(Flux.empty()); + + controller.chatStream(AGENT_ID, MESSAGE, CONVERSATION_ID, WORKSPACE_ID); + + ArgumentCaptor origin = ArgumentCaptor.forClass(ChatOrigin.class); + verify(agentService, org.mockito.Mockito.timeout(1000)) + .chatStream(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture()); + assertEquals(MESSAGE_ID, origin.getValue().originMessageId()); + verifySingleUserSave(); + verify(agentService, never()).chatStream(AGENT_ID, MESSAGE, CONVERSATION_ID); + } + + @Test + void syncChatEntryPersistsOnceAndUsesExplicitOrigin() { + AgentController.ChatRequest request = request(); + when(agentService.chat(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any())) + .thenReturn("done"); + + controller.chat(AGENT_ID, request, WORKSPACE_ID); + + ArgumentCaptor origin = ArgumentCaptor.forClass(ChatOrigin.class); + verify(agentService).chat(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture()); + assertEquals(MESSAGE_ID, origin.getValue().originMessageId()); + verifySingleUserSave(); + verify(agentService, never()).chat(AGENT_ID, MESSAGE, CONVERSATION_ID); + } + + @Test + void executeEntryPersistsOnceAndUsesExplicitOrigin() { + AgentController.ChatRequest request = request(); + when(agentService.execute(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any())) + .thenReturn("done"); + + controller.execute(AGENT_ID, request, WORKSPACE_ID); + + ArgumentCaptor origin = ArgumentCaptor.forClass(ChatOrigin.class); + verify(agentService).execute(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture()); + assertEquals(MESSAGE_ID, origin.getValue().originMessageId()); + verifySingleUserSave(); + verify(agentService, never()).execute(AGENT_ID, MESSAGE, CONVERSATION_ID); + } + + private void verifySingleUserSave() { + verify(conversations, times(1)).saveMessage(CONVERSATION_ID, "user", MESSAGE); + } + + private static AgentController.ChatRequest request() { + AgentController.ChatRequest request = new AgentController.ChatRequest(); + request.setMessage(MESSAGE); + request.setConversationId(CONVERSATION_ID); + return request; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java new file mode 100644 index 00000000..548530df --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java @@ -0,0 +1,122 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatStreamTrackerEventIdTest { + + @Test + void eventIdsIncreaseAcrossChannelsAndRecreatedRunState() { + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + CapturingEmitter firstChannel = new CapturingEmitter(); + CapturingEmitter secondChannel = new CapturingEmitter(); + CapturingEmitter recreatedChannel = new CapturingEmitter(); + + tracker.register("channel-a"); + tracker.attach("channel-a", firstChannel); + tracker.broadcast("channel-a", "progress", "{}"); + + tracker.register("channel-b"); + tracker.attach("channel-b", secondChannel); + tracker.broadcast("channel-b", "progress", "{}"); + + tracker.broadcast("channel-a", "done", "{}"); + tracker.register("channel-a"); + tracker.attach("channel-a", recreatedChannel, Long.MAX_VALUE); + tracker.broadcast("channel-a", "progress", "{}"); + + long first = firstChannel.ids.getFirst(); + long second = secondChannel.ids.getFirst(); + long recreated = recreatedChannel.ids.getFirst(); + assertTrue(first < second); + assertTrue(second < recreated); + } + + @Test + void laterClockFloorStartsAboveIdsFromAnEarlierGeneratorInstance() { + SseEventIdGenerator firstProcess = new SseEventIdGenerator(() -> 1_000L); + long first = firstProcess.nextId(); + long second = firstProcess.nextId(); + + SseEventIdGenerator restartedProcess = new SseEventIdGenerator(() -> 1_001L); + long afterRestart = restartedProcess.nextId(); + + assertEquals(first + 1, second); + assertTrue(afterRestart > second); + } + + @Test + void concurrentAllocationIsUnique() { + SseEventIdGenerator generator = new SseEventIdGenerator(() -> 1_000L); + Set ids = ConcurrentHashMap.newKeySet(); + + IntStream.range(0, 10_000).parallel().forEach(ignored -> ids.add(generator.nextId())); + + assertEquals(10_000, ids.size()); + } + + @Test + void currentEventIdsStayWithinTheJavaScriptSafeIntegerRange() { + SseEventIdGenerator generator = new SseEventIdGenerator(System::currentTimeMillis); + + assertTrue(generator.nextId() <= SseEventIdGenerator.MAX_SAFE_INTEGER); + } + + @Test + void fixedClockCanBorrowFutureSlotsBeyondOneMillisecondCapacity() { + SseEventIdGenerator generator = new SseEventIdGenerator(() -> 1_000L); + + long first = generator.nextId(); + long last = IntStream.range(0, 2_048) + .mapToLong(ignored -> generator.nextId()) + .reduce(first, (ignored, id) -> id); + + assertEquals(first + 2_048, last); + assertTrue(last <= SseEventIdGenerator.MAX_SAFE_INTEGER); + } + + @Test + void exhaustsAtTheJavaScriptSafeIntegerBoundary() { + long maxEpochMillis = SseEventIdGenerator.MAX_SAFE_INTEGER / 1_024L; + SseEventIdGenerator generator = new SseEventIdGenerator(() -> maxEpochMillis); + + long last = 0L; + for (int i = 0; i < 1_024; i++) { + last = generator.nextId(); + } + + assertEquals(SseEventIdGenerator.MAX_SAFE_INTEGER, last); + assertThrows(IllegalStateException.class, generator::nextId); + assertThrows(IllegalStateException.class, + () -> new SseEventIdGenerator(() -> maxEpochMillis + 1)); + } + + private static final class CapturingEmitter extends SseEmitter { + + private final List ids = new ArrayList<>(); + + @Override + public void send(SseEventBuilder builder) throws IOException { + Set entries = builder.build(); + for (ResponseBodyEmitter.DataWithMediaType entry : entries) { + if (entry.getData() instanceof String text && text.startsWith("id:")) { + int end = text.indexOf('\n'); + ids.add(Long.parseLong(text.substring(3, end).trim())); + } + } + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/CronChatOriginFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/cron/CronChatOriginFactoryTest.java new file mode 100644 index 00000000..6a78d947 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/CronChatOriginFactoryTest.java @@ -0,0 +1,28 @@ +package vip.mate.cron; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.cron.model.CronJobEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CronChatOriginFactoryTest { + + @Test + void explicitMessageIdIsCarriedByTheCronOrigin() { + AgentMapper agents = mock(AgentMapper.class); + AgentEntity agent = new AgentEntity(); + agent.setWorkspaceId(30L); + CronJobEntity job = new CronJobEntity(); + job.setAgentId(20L); + when(agents.selectById(20L)).thenReturn(agent); + + ChatOrigin origin = new CronChatOriginFactory(agents).from(job, "tasks_30", 99L); + + assertEquals(99L, origin.originMessageId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java new file mode 100644 index 00000000..1f5e7532 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java @@ -0,0 +1,92 @@ +package vip.mate.cron.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.cron.CronChatOriginFactory; +import vip.mate.cron.CronConversationResolver; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; +import vip.mate.i18n.I18nService; +import vip.mate.memory.event.ConversationCompletionPublisher; +import vip.mate.wiki.service.WikiProcessingService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CronJobOriginPropagationTest { + + private static final Long JOB_ID = 11L; + private static final Long AGENT_ID = 22L; + private static final Long WORKSPACE_ID = 33L; + private static final Long MESSAGE_ID = 44L; + private static final String CONVERSATION_ID = "tasks_33"; + + @Test + void lifecycleReturnsThePersistedUserMessageIdWithoutSavingTwice() { + ConversationService conversations = mock(ConversationService.class); + CronJobLifecycleService lifecycle = new CronJobLifecycleService( + mock(CronJobRunMapper.class), conversations, + mock(ConversationCompletionPublisher.class), + mock(ApplicationEventPublisher.class), mock(I18nService.class)); + CronJobEntity job = job(); + MessageEntity saved = new MessageEntity(); + saved.setId(MESSAGE_ID); + when(conversations.saveMessage(CONVERSATION_ID, "user", "do work")) + .thenReturn(saved); + + CronJobLifecycleService.StartResult result = lifecycle.startRun( + job, "do work", "scheduled", CONVERSATION_ID); + + assertEquals(MESSAGE_ID, result.originMessageId()); + verify(conversations, times(1)).saveMessage(CONVERSATION_ID, "user", "do work"); + } + + @Test + void runnerPassesTheLifecycleMessageIdIntoTheAgentOrigin() { + CronJobLifecycleService lifecycle = mock(CronJobLifecycleService.class); + AgentService agentService = mock(AgentService.class); + CronChatOriginFactory originFactory = mock(CronChatOriginFactory.class); + CronConversationResolver resolver = mock(CronConversationResolver.class); + CronJobEntity job = job(); + CronJobRunEntity run = new CronJobRunEntity(); + run.setId(55L); + ChatOrigin origin = ChatOrigin.cron(CONVERSATION_ID, WORKSPACE_ID, null, null, null) + .withOriginMessageId(MESSAGE_ID); + when(resolver.resolve(job)).thenReturn(CONVERSATION_ID); + when(lifecycle.startRun(job, "do work", "scheduled", CONVERSATION_ID)) + .thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID)); + when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin); + when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin))) + .thenReturn(AgentService.ChatResult.contentOnly("done")); + CronJobRunner runner = new CronJobRunner(lifecycle, agentService, originFactory, resolver, + mock(WikiProcessingService.class), new ObjectMapper()); + + runner.executeJob(job); + + verify(originFactory).from(job, CONVERSATION_ID, MESSAGE_ID); + verify(agentService).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin)); + verify(agentService, never()).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID)); + } + + private static CronJobEntity job() { + CronJobEntity job = new CronJobEntity(); + job.setId(JOB_ID); + job.setAgentId(AGENT_ID); + job.setWorkspaceId(WORKSPACE_ID); + job.setTaskType("text"); + job.setTriggerMessage("do work"); + return job; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java b/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java new file mode 100644 index 00000000..cdb3ff09 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java @@ -0,0 +1,181 @@ +package vip.mate.team; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.repository.TeamRunMapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:team_run_migration_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class MigrationSmokeTest { + + private static final Path MIGRATIONS = Path.of("src/main/resources/db/migration"); + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private TeamRunMapper runMapper; + + @Test + @DisplayName("team run migration creates the run table with a BIGINT workspace") + void teamRunTableExists() { + assertEquals(1L, countTables("mate_team_run")); + assertEquals("bigint", columnType("mate_team_run", "workspace_id").toLowerCase(Locale.ROOT)); + } + + @Test + @DisplayName("team run migration adds the nullable task binding") + void teamTaskRunBindingExists() { + assertEquals(1L, countColumns("mate_team_task", "run_id")); + assertEquals("YES", columnNullable("mate_team_task", "run_id")); + } + + @Test + @DisplayName("all database dialects contain the complete team run contract") + void allDialectsContainVersion181() throws Exception { + for (String dialect : List.of("h2", "mysql", "kingbase")) { + Path migration = MIGRATIONS.resolve(dialect).resolve("V181__team_run_foundation.sql"); + assertTrue(Files.exists(migration), dialect + " migration must contain version 181"); + String sql = Files.readString(migration).toLowerCase(Locale.ROOT); + assertTrue(sql.contains("mate_team_run"), dialect + " migration must create the run table"); + assertTrue(sql.matches("(?s).*workspace_id\\s+bigint.*"), + dialect + " migration must use BIGINT workspace ids"); + assertTrue(sql.matches("(?s).*run_id\\s+bigint\\s+null.*"), + dialect + " migration must add a nullable task run id"); + assertTrue(sql.matches("(?s).*unique\\s+(?:index(?:\\s+if\\s+not\\s+exists)?|key)" + + "\\s+uk_team_run_origin_message.*"), + dialect + " migration must enforce origin-message idempotency"); + assertTrue(sql.matches("(?s).*uk_team_run_origin_message.*?" + + "\\(workspace_id,\\s*lead_conversation_id,\\s*origin_message_id\\).*"), + dialect + " migration must scope origin-message idempotency by workspace"); + assertTrue(sql.contains("idx_team_task_run_number"), + dialect + " migration must index run task numbers"); + assertTrue(sql.contains("idx_team_task_run_status"), + dialect + " migration must index run task statuses"); + } + } + + @Test + @DisplayName("origin message identity is unique while manual runs allow null origins") + void originMessageUniquenessAllowsManualRuns() { + runMapper.insert(newRun(9_811_001L, "lead-unique", 7_001L)); + + assertThrows(DuplicateKeyException.class, + () -> runMapper.insert(newRun(9_811_002L, "lead-unique", 7_001L))); + + TeamRunEntity otherWorkspace = newRun(9_811_005L, "lead-unique", 7_001L); + otherWorkspace.setWorkspaceId(42L); + runMapper.insert(otherWorkspace); + assertNotNull(runMapper.selectById(9_811_005L)); + + runMapper.insert(newRun(9_811_003L, "lead-manual", null)); + runMapper.insert(newRun(9_811_004L, "lead-manual", null)); + assertNotNull(runMapper.selectById(9_811_003L)); + assertNotNull(runMapper.selectById(9_811_004L)); + } + + @Test + @DisplayName("team run mapper round-trips fields and clears nullable final state") + void teamRunMapperRoundTripAndClear() { + TeamRunEntity run = newRun(9_812_001L, "lead-round-trip", 7_002L); + LocalDateTime now = LocalDateTime.now().withNano(0); + run.setFinalSummary("delivered"); + run.setStopReason("finished"); + run.setMetadata("{\"outcome\":\"completed\"}"); + run.setStartedAt(now); + run.setCompletedAt(now.plusMinutes(1)); + + assertEquals(1, runMapper.insert(run)); + TeamRunEntity inserted = runMapper.selectById(run.getId()); + assertNotNull(inserted); + assertEquals(41L, inserted.getWorkspaceId()); + assertEquals("delivered", inserted.getFinalSummary()); + assertNotNull(inserted.getCreateTime()); + + inserted.setFinalSummary(null); + inserted.setStopReason(null); + inserted.setMetadata(null); + inserted.setStartedAt(null); + inserted.setCompletedAt(null); + assertEquals(1, runMapper.updateById(inserted)); + + TeamRunEntity cleared = runMapper.selectById(run.getId()); + assertNull(cleared.getFinalSummary()); + assertNull(cleared.getStopReason()); + assertNull(cleared.getMetadata()); + assertNull(cleared.getStartedAt()); + assertNull(cleared.getCompletedAt()); + } + + private TeamRunEntity newRun(long id, String leadConversationId, Long originMessageId) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(id); + run.setTeamId(31L); + run.setWorkspaceId(41L); + run.setLeadAgentId(51L); + run.setLeadConversationId(leadConversationId); + run.setOriginMessageId(originMessageId); + run.setTitle("Persistence contract"); + run.setObjective("Verify the team run persistence mapping"); + run.setStatus(TeamRunStatus.PLANNING); + return run; + } + + private Long countTables(String tableName) { + return jdbc.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?", + Long.class, + tableName); + } + + private Long countColumns(String tableName, String columnName) { + return jdbc.queryForObject( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?", + Long.class, + tableName, + columnName); + } + + private String columnType(String tableName, String columnName) { + return jdbc.queryForObject( + "SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?", + String.class, + tableName, + columnName); + } + + private String columnNullable(String tableName, String columnName) { + return jdbc.queryForObject( + "SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?", + String.class, + tableName, + columnName); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java index f4b6ec4e..dc77014f 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java @@ -5,7 +5,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import vip.mate.agent.repository.AgentMapper; import vip.mate.auth.model.UserEntity; import vip.mate.auth.service.AuthService; @@ -13,10 +15,17 @@ import vip.mate.common.result.R; import vip.mate.config.WorkspaceAccessInterceptor; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.event.TeamRunDispatchCommittedIntent; import vip.mate.team.service.TeamAnnounceService; import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; +import vip.mate.team.service.TeamManualTaskService; +import vip.mate.team.service.TeamRunService; import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamTaskService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -31,9 +40,11 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -47,9 +58,12 @@ class TeamControllerTest { private static final Long TEAM_ID = 1L; private static final Long TASK_ID = 100L; + private static final Long RUN_ID = 200L; @Mock private TeamService teamService; @Mock private TeamTaskService taskService; + @Mock private TeamRunService runService; + @Mock private ApplicationEventPublisher events; @Mock private TeamDispatchService dispatchService; @Mock private TeamAnnounceService announceService; @Mock private TeamEventChannel eventChannel; @@ -58,15 +72,18 @@ class TeamControllerTest { @Mock private AuthService authService; private TeamController controller; + private TeamManualTaskService manualTaskService; @BeforeEach void setUp() { - controller = new TeamController(teamService, taskService, dispatchService, + manualTaskService = new TeamManualTaskService(runService, taskService, events); + controller = new TeamController(teamService, taskService, manualTaskService, dispatchService, announceService, eventChannel, agentMapper); AgentTeamEntity team = new AgentTeamEntity(); team.setId(TEAM_ID); team.setWorkspaceId(1L); - org.mockito.Mockito.lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team); + team.setLeadAgentId(10L); + lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team); } @AfterEach @@ -84,6 +101,15 @@ class TeamControllerTest { return task; } + private TeamRunEntity run(Long teamId, Long workspaceId, String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(teamId); + run.setWorkspaceId(workspaceId); + run.setStatus(status); + return run; + } + // ==================== team / membership ==================== @Test @@ -141,9 +167,12 @@ class TeamControllerTest { @Test void createTaskSurfacesUnknownAssigneeAsReadableFailure() { + TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING); + when(runService.requireRun(RUN_ID, 1L)).thenReturn(planning); when(taskService.createTask(any(TeamTaskCreateCommand.class))) .thenThrow(new IllegalArgumentException("assignee 9 is not a member of this team")); TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); + req.setRunId(RUN_ID); req.setSubject("do the thing"); req.setAssigneeAgentId(9L); @@ -155,6 +184,95 @@ class TeamControllerTest { verify(dispatchService, never()).requestDispatch(anyLong()); } + @Test + void createTaskWithoutRunCreatesSealsAndPublishesOneDispatchIntent() { + TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING); + TeamRunEntity running = run(TEAM_ID, 1L, TeamRunStatus.RUNNING); + when(runService.startRun(any())).thenReturn(planning); + TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING); + created.setRunId(RUN_ID); + when(taskService.createTask(any())).thenReturn(created); + when(runService.sealRunWithResult(RUN_ID, 1L)) + .thenReturn(new TeamRunService.SealResult(running, true)); + TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); + req.setSubject("dashboard task"); + req.setDescription("details"); + req.setAssigneeAgentId(9L); + + R result = controller.createTask(TEAM_ID, req, null); + + assertEquals(RUN_ID, result.getData().runId()); + ArgumentCaptor runCommand = + ArgumentCaptor.forClass(TeamRunCreateCommand.class); + verify(runService).startRun(runCommand.capture()); + assertEquals("dashboard-team-1", runCommand.getValue().getLeadConversationId()); + assertEquals("dashboard task", runCommand.getValue().getTitle()); + assertEquals("details", runCommand.getValue().getObjective()); + assertEquals(null, runCommand.getValue().getOriginMessageId()); + ArgumentCaptor taskCommand = + ArgumentCaptor.forClass(TeamTaskCreateCommand.class); + verify(taskService).createTask(taskCommand.capture()); + assertEquals(RUN_ID, taskCommand.getValue().getRunId()); + verify(runService).sealRunWithResult(RUN_ID, 1L); + verify(events, times(1)).publishEvent(new TeamRunDispatchCommittedIntent(TEAM_ID)); + } + + @Test + void createTaskWithoutRunDoesNotPublishWhenSealAlreadyTransitioned() { + TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING); + TeamRunEntity running = run(TEAM_ID, 1L, TeamRunStatus.RUNNING); + when(runService.startRun(any())).thenReturn(planning); + TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING); + created.setRunId(RUN_ID); + when(taskService.createTask(any())).thenReturn(created); + when(runService.sealRunWithResult(RUN_ID, 1L)) + .thenReturn(new TeamRunService.SealResult(running, false)); + TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); + req.setSubject("dashboard task"); + req.setAssigneeAgentId(9L); + + controller.createTask(TEAM_ID, req, null); + + verify(events, never()).publishEvent(any()); + } + + @Test + void createTaskWithExplicitRunDoesNotSealOrDispatch() { + when(runService.requireRun(RUN_ID, 1L)) + .thenReturn(run(TEAM_ID, 1L, TeamRunStatus.PLANNING)); + TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING); + created.setRunId(RUN_ID); + when(taskService.createTask(any())).thenReturn(created); + TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); + req.setRunId(RUN_ID); + req.setSubject("another task"); + req.setAssigneeAgentId(9L); + + R result = controller.createTask(TEAM_ID, req, null); + + assertEquals(RUN_ID, result.getData().runId()); + verify(runService, never()).startRun(any()); + verify(runService, never()).sealRunWithResult(anyLong(), anyLong()); + verify(events, never()).publishEvent(any()); + verify(dispatchService, never()).requestDispatch(anyLong()); + } + + @Test + void createTaskRejectsExplicitRunFromAnotherTeam() { + when(runService.requireRun(RUN_ID, 1L)) + .thenReturn(run(99L, 1L, TeamRunStatus.PLANNING)); + TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); + req.setRunId(RUN_ID); + req.setSubject("another task"); + req.setAssigneeAgentId(9L); + + R result = controller.createTask(TEAM_ID, req, null); + + assertEquals(500, result.getCode()); + assertEquals("team task and run must belong to the same team", result.getMsg()); + verify(taskService, never()).createTask(any()); + } + @Test void approveRejectsTaskFromAnotherTeamsBoard() { when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "in_review")); diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java new file mode 100644 index 00000000..f64fafbb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java @@ -0,0 +1,147 @@ +package vip.mate.team.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import vip.mate.common.result.R; +import vip.mate.config.JacksonConfig; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.service.TeamRunApplicationService; +import vip.mate.team.service.TeamRunService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class TeamRunControllerTest { + + private static final Long RUN_ID = 9007199254740993L; + private static final Long TEAM_ID = 9007199254740995L; + private static final Long TASK_ID = 9007199254740997L; + private static final Long BLOCKER_ID = 9007199254740999L; + private static final Long WORKSPACE_ID = 30L; + private static final String CONVERSATION_ID = "lead-conversation"; + + private TeamRunService runService; + private TeamRunApplicationService applicationService; + private TeamRunController controller; + + @BeforeEach + void setUp() { + runService = mock(TeamRunService.class); + applicationService = mock(TeamRunApplicationService.class); + controller = new TeamRunController(runService, applicationService); + } + + @Test + void detailAndListsAreScopedToTheRequestedWorkspace() { + TeamRunView view = view(); + when(runService.getRun(RUN_ID, WORKSPACE_ID)).thenReturn(view); + when(runService.listTeamRuns(TEAM_ID, WORKSPACE_ID)).thenReturn(List.of(view)); + when(runService.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID)) + .thenReturn(List.of(view)); + + assertEquals(view, controller.get(RUN_ID, WORKSPACE_ID).getData()); + assertEquals(List.of(view), controller.listTeamRuns(TEAM_ID, WORKSPACE_ID).getData()); + assertEquals(List.of(view), + controller.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID).getData()); + } + + @Test + void crossWorkspaceReadReturnsAReadableFailure() { + when(runService.getRun(RUN_ID, WORKSPACE_ID)) + .thenThrow(new IllegalArgumentException("team run not found in workspace: " + RUN_ID)); + + R result = controller.get(RUN_ID, WORKSPACE_ID); + + assertEquals(500, result.getCode()); + assertEquals("team run not found in workspace: " + RUN_ID, result.getMsg()); + } + + @Test + void cancelDelegatesToTheApplicationServiceInTheCurrentWorkspace() { + TeamRunView view = view(); + when(applicationService.cancelRun(RUN_ID, WORKSPACE_ID, "stop")).thenReturn(view); + TeamRunController.CancelRunRequest request = new TeamRunController.CancelRunRequest(); + request.setReason("stop"); + + assertEquals(view, controller.cancel(RUN_ID, request, WORKSPACE_ID).getData()); + + verify(applicationService).cancelRun(RUN_ID, WORKSPACE_ID, "stop"); + verify(runService, never()).cancelRun(RUN_ID, WORKSPACE_ID, "stop"); + } + + @Test + void endpointsDeclareExactPathsAndRoles() throws Exception { + assertEndpoint("get", "viewer", "/team-runs/{runId}", Long.class, Long.class); + assertEndpoint("listTeamRuns", "viewer", "/teams/{teamId}/runs", Long.class, Long.class); + assertEndpoint("listConversationRuns", "viewer", "/conversations/{conversationId}/team-runs", + String.class, Long.class); + assertEndpoint("cancel", "admin", "/team-runs/{runId}/cancel", + Long.class, TeamRunController.CancelRunRequest.class, Long.class); + } + + @Test + void configuredJsonSerializesRunAndTeamLongIdsAsStrings() throws Exception { + TeamRunView view = view(); + when(runService.getRun(RUN_ID, WORKSPACE_ID)).thenReturn(view); + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + new JacksonConfig().longToStringCustomizer().customize(builder); + ObjectMapper mapper = builder.build(); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setMessageConverters(new MappingJackson2HttpMessageConverter(mapper)) + .build(); + + mvc.perform(get("/api/v1/team-runs/{runId}", RUN_ID) + .header("X-Workspace-Id", WORKSPACE_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(String.valueOf(RUN_ID))) + .andExpect(jsonPath("$.data.teamId").value(String.valueOf(TEAM_ID))) + .andExpect(jsonPath("$.data.tasks[0].id").value(String.valueOf(TASK_ID))) + .andExpect(jsonPath("$.data.tasks[0].blockedBy") + .value("[\"" + BLOCKER_ID + "\"]")) + .andExpect(jsonPath("$.data.tasks[0].metadata") + .value("{\"planId\":\"" + RUN_ID + "\"}")); + } + + private static TeamRunView view() { + return new TeamRunView(RUN_ID, TEAM_ID, WORKSPACE_ID, 1L, CONVERSATION_ID, + null, "Run", "Objective", "running", null, null, null, + null, null, null, null, + new TeamRunView.Progress(1, 0, 0, 0, 0), List.of(task())); + } + + private static TeamRunView.Task task() { + return new TeamRunView.Task(TASK_ID, TEAM_ID, RUN_ID, 1, "Task", null, + "blocked", 0, "general", 1L, null, + "[\"" + BLOCKER_ID + "\"]", false, null, null, + null, null, null, "{\"planId\":\"" + RUN_ID + "\"}", null, null); + } + + private static void assertEndpoint(String method, String role, String path, + Class... parameterTypes) throws Exception { + var reflected = TeamRunController.class.getDeclaredMethod(method, parameterTypes); + RequireWorkspaceRole permission = reflected.getAnnotation(RequireWorkspaceRole.class); + assertNotNull(permission); + assertEquals(role, permission.value()); + var get = reflected.getAnnotation(GetMapping.class); + var post = reflected.getAnnotation(PostMapping.class); + String actual = get != null ? get.value()[0] : post.value()[0]; + assertEquals(path, actual); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/SpringTeamRunEventPublisherTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/SpringTeamRunEventPublisherTest.java new file mode 100644 index 00000000..d087e8e7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/SpringTeamRunEventPublisherTest.java @@ -0,0 +1,28 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; + +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SpringTeamRunEventPublisherTest { + + @Test + void cancellationPublishesUnifiedRunProjection() { + TeamEventChannel channel = mock(TeamEventChannel.class); + SpringTeamRunEventPublisher publisher = new SpringTeamRunEventPublisher(channel); + TeamRunView run = new TeamRunView(20L, 10L, 30L, 1L, "lead-conversation", + null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null, + null, null, null, null, + new TeamRunView.Progress(1, 0, 0, 0, 0), List.of()); + + publisher.publishCancelled(run); + + verify(channel).publishRunEvent(run, "team_run_cancelled", Map.of()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java index 47ff1273..9d9e3438 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java @@ -1,5 +1,7 @@ package vip.mate.team.service; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,6 +17,12 @@ import vip.mate.team.model.TeamTaskStatus; import vip.mate.workspace.conversation.ConversationService; import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -25,8 +33,8 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; /** - * Pins the announce contract: settled results are batched per lead - * conversation and delivered as ONE merged wake-up message; a busy lead defers + * Pins the announce contract: settled results are batched per lead conversation + * and run and delivered as ONE merged wake-up message; a busy lead defers * delivery instead of risking an in-turn drop; the merged text carries the * synthesis / retry instructions the lead acts on. */ @@ -34,6 +42,7 @@ class TeamAnnounceServiceTest { private static final Long TEAM_ID = 10L; private static final Long LEAD_ID = 1L; + private static final Long RUN_ID = 90L; private static final String LEAD_CONV = "lead-conv"; private TeamService teamService; @@ -71,6 +80,7 @@ class TeamAnnounceServiceTest { TeamTaskEntity t = new TeamTaskEntity(); t.setId(id); t.setTeamId(TEAM_ID); + t.setRunId(RUN_ID); t.setTaskNumber(id.intValue()); t.setSubject("task " + id); t.setStatus(status); @@ -91,7 +101,7 @@ class TeamAnnounceServiceTest { service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "report done")); service.announceTaskSettled(settled(2L, TeamTaskStatus.FAILED, "blocked: no docs")); - service.drain(LEAD_CONV); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID)); ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); verify(agentService, timeout(3000)).chatWithUsage(eq(LEAD_ID), captor.capture(), eq(LEAD_CONV)); @@ -102,8 +112,17 @@ class TeamAnnounceServiceTest { assertTrue(message.contains("Task #2")); assertTrue(message.contains("blocked: no docs")); // Drained means a later timer fire must not wake the lead again. - service.drain(LEAD_CONV); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID)); verify(agentService, after(300).times(1)).chatWithUsage(any(), anyString(), anyString()); + + ArgumentCaptor metadata = ArgumentCaptor.forClass(String.class); + verify(conversationService, timeout(3000)) + .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), metadata.capture()); + JSONObject json = JSONUtil.parseObj(metadata.getValue()); + assertEquals(String.valueOf(RUN_ID), json.getStr("runId")); + assertFalse(json.containsKey("taskId")); + assertEquals(List.of("1", "2"), json.getJSONArray("taskIds").toList(String.class)); } @Test @@ -112,7 +131,7 @@ class TeamAnnounceServiceTest { when(runningConversations.isActive(LEAD_CONV)).thenReturn(true); service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done")); - service.drain(LEAD_CONV); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID)); verify(agentService, after(500).never()).chatWithUsage(any(), anyString(), anyString()); } @@ -124,7 +143,7 @@ class TeamAnnounceServiceTest { orphan.setLeadConversationId(null); service.announceTaskSettled(orphan); - service.drain(LEAD_CONV); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID)); verify(agentService, after(300).never()).chatWithUsage(any(), anyString(), anyString()); } @@ -137,7 +156,7 @@ class TeamAnnounceServiceTest { .thenReturn(AgentService.ChatResult.contentOnly("综合汇报")); service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done")); - service.drain(LEAD_CONV); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID)); verify(streamTracker, timeout(3000)) .broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any()); @@ -147,23 +166,149 @@ class TeamAnnounceServiceTest { // stays in the lead's conversation window for later turns. Both rows // carry an internal-note metadata type so the chat UI renders them as // a collapsed system strip instead of a user bubble. + ArgumentCaptor userMetadata = ArgumentCaptor.forClass(String.class); + ArgumentCaptor replyMetadata = ArgumentCaptor.forClass(String.class); verify(conversationService, timeout(3000)) .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), - eq(0), eq(0), isNull(), isNull(), contains("\"team_announce\"")); + eq(0), eq(0), isNull(), isNull(), userMetadata.capture()); verify(conversationService, timeout(3000)) .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报"), isNull(), eq("completed"), - eq(0), eq(0), isNull(), isNull(), contains("\"team_announce_reply\"")); + eq(0), eq(0), isNull(), isNull(), replyMetadata.capture()); + + assertAnnounceMetadata(userMetadata.getValue(), "team_announce", "1"); + assertAnnounceMetadata(replyMetadata.getValue(), "team_announce_reply", "1"); + } + + @Test + @DisplayName("legacy null-run announcements omit runId but retain taskId") + void legacyAnnouncementOmitsNullRunId() { + when(runningConversations.isActive(LEAD_CONV)).thenReturn(false); + TeamTaskEntity task = settled(7L, TeamTaskStatus.COMPLETED, "done"); + task.setRunId(null); + service.announceTaskSettled(task); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, null)); + + ArgumentCaptor metadata = ArgumentCaptor.forClass(String.class); + verify(conversationService, timeout(3000)) + .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), metadata.capture()); + JSONObject json = JSONUtil.parseObj(metadata.getValue()); + assertEquals("team_announce", json.getStr("type")); + assertEquals("7", json.getStr("taskId")); + assertFalse(json.containsKey("runId")); + } + + @Test + @DisplayName("concurrent run drains serialize lead wake turns and preserve metadata") + void concurrentRunDrainsSerializeLeadWakeTurns() throws Exception { + when(runningConversations.isActive(LEAD_CONV)).thenReturn(false); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicInteger wakeCount = new AtomicInteger(); + when(agentService.chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV))) + .thenAnswer(invocation -> { + if (wakeCount.incrementAndGet() == 1) { + firstStarted.countDown(); + assertTrue(releaseFirst.await(3, TimeUnit.SECONDS)); + } else { + secondStarted.countDown(); + } + return AgentService.ChatResult.contentOnly("reply"); + }); + TeamTaskEntity first = settled(11L, TeamTaskStatus.COMPLETED, "first"); + first.setRunId(101L); + TeamTaskEntity second = settled(22L, TeamTaskStatus.COMPLETED, "second"); + second.setRunId(202L); + service.announceTaskSettled(first); + service.announceTaskSettled(second); + + CompletableFuture.allOf( + CompletableFuture.runAsync(() -> service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 101L))), + CompletableFuture.runAsync(() -> service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 202L))) + ).join(); + + assertTrue(firstStarted.await(3, TimeUnit.SECONDS)); + assertFalse(secondStarted.await(300, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertTrue(secondStarted.await(3, TimeUnit.SECONDS)); + + ArgumentCaptor userMetadata = ArgumentCaptor.forClass(String.class); + ArgumentCaptor replyMetadata = ArgumentCaptor.forClass(String.class); + verify(conversationService, timeout(3000).times(2)) + .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), userMetadata.capture()); + verify(conversationService, timeout(3000).times(2)) + .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("reply"), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), replyMetadata.capture()); + + Map expected = Map.of("101", "11", "202", "22"); + assertRunTaskMetadata(userMetadata.getAllValues(), expected); + assertRunTaskMetadata(replyMetadata.getAllValues(), expected); + } + + @Test + @DisplayName("busy retry releases ownership and later serializes pending runs") + void busyRetrySerializesPendingRuns() throws Exception { + AtomicBoolean busy = new AtomicBoolean(true); + when(runningConversations.isActive(LEAD_CONV)).thenAnswer(invocation -> busy.get()); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicInteger wakeCount = new AtomicInteger(); + when(agentService.chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV))) + .thenAnswer(invocation -> { + if (wakeCount.incrementAndGet() == 1) { + firstStarted.countDown(); + assertTrue(releaseFirst.await(3, TimeUnit.SECONDS)); + } else { + secondStarted.countDown(); + } + return AgentService.ChatResult.contentOnly("reply"); + }); + TeamTaskEntity first = settled(31L, TeamTaskStatus.COMPLETED, "first"); + first.setRunId(301L); + TeamTaskEntity second = settled(32L, TeamTaskStatus.COMPLETED, "second"); + second.setRunId(302L); + service.announceTaskSettled(first); + service.announceTaskSettled(second); + + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 301L)); + service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 302L)); + verify(agentService, after(300).never()).chatWithUsage(any(), anyString(), anyString()); + busy.set(false); + + assertTrue(firstStarted.await(4, TimeUnit.SECONDS)); + assertFalse(secondStarted.await(300, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertTrue(secondStarted.await(3, TimeUnit.SECONDS)); + verify(agentService, timeout(3000).times(2)).chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV)); } @Test @DisplayName("announcement text: single result keeps the singular form and the playbook") void announcementText() { String single = TeamAnnounceService.buildAnnouncement(List.of( - new TeamAnnounceService.AnnounceItem(TEAM_ID, 1, "collect", TeamTaskStatus.COMPLETED, + new TeamAnnounceService.AnnounceItem(1L, TEAM_ID, 1, "collect", TeamTaskStatus.COMPLETED, "写手", "all collected"))); assertTrue(single.contains("A delegated team task has settled")); assertTrue(single.contains("member: 写手")); assertTrue(single.contains("ONE synthesized answer")); assertTrue(single.contains("action=\"retry\"")); } + + private void assertAnnounceMetadata(String metadata, String type, String taskId) { + JSONObject json = JSONUtil.parseObj(metadata); + assertEquals(type, json.getStr("type")); + assertEquals(String.valueOf(RUN_ID), json.getStr("runId")); + assertEquals(taskId, json.getStr("taskId")); + } + + private void assertRunTaskMetadata(List metadata, Map expected) { + assertEquals(expected, metadata.stream() + .map(JSONUtil::parseObj) + .collect(java.util.stream.Collectors.toMap( + json -> json.getStr("runId"), + json -> json.getStr("taskId")))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java index 2cddecea..4439a308 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java @@ -93,6 +93,11 @@ class TeamContextBuilderTest { assertTrue(ctx.contains("LEAD — you orchestrate")); assertTrue(ctx.contains("Delegation workflow (mandatory)")); assertTrue(ctx.contains("Delegation is NOT completion")); + int start = ctx.indexOf("team_tasks(action=\"start_run\""); + int create = ctx.indexOf("team_tasks(action=\"create\""); + int seal = ctx.indexOf("team_tasks(action=\"seal_run\""); + assertTrue(start >= 0 && start < create && create < seal, + "lead playbook must require start_run -> create* -> seal_run"); assertTrue(ctx.contains("写手")); assertTrue(ctx.contains("agentId: " + MEMBER_ID)); // The lead must not receive member execution instructions. diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceEventTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceEventTest.java new file mode 100644 index 00000000..88b2de7a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceEventTest.java @@ -0,0 +1,91 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import vip.mate.agent.AgentService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.event.TeamTasksDelegatedEvent; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; + +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamDispatchServiceEventTest { + + private static final Long TEAM_ID = 10L; + + @Test + void delegatedEventDispatchesOnlyAfterCommit() { + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(TestConfig.class)) { + TeamTaskService taskService = context.getBean(TeamTaskService.class); + when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of()); + ApplicationEventPublisher publisher = context; + TransactionTemplate transactions = new TransactionTemplate( + context.getBean(PlatformTransactionManager.class)); + + transactions.executeWithoutResult(status -> { + publisher.publishEvent(new TeamTasksDelegatedEvent(TEAM_ID)); + verify(taskService, after(200).never()).findDispatchable(TEAM_ID); + }); + + verify(taskService, after(1000)).findDispatchable(TEAM_ID); + } + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + static class TestConfig { + + @Bean + PlatformTransactionManager transactionManager() { + return new TestTransactionManager(); + } + + @Bean + TeamTaskService taskService() { + return mock(TeamTaskService.class); + } + + @Bean + TeamDispatchService dispatchService(TeamTaskService taskService) { + return new TeamDispatchService( + mock(TeamService.class), taskService, mock(AgentService.class), + mock(ConversationService.class), mock(ChatStreamTracker.class), + mock(TeamAnnounceService.class), mock(TeamEventChannel.class)); + } + } + + static class TestTransactionManager extends AbstractPlatformTransactionManager { + + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamEventChannelTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamEventChannelTest.java new file mode 100644 index 00000000..c988efd1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamEventChannelTest.java @@ -0,0 +1,107 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class TeamEventChannelTest { + + private static final Long RUN_ID = 9007199254740993L; + private static final Long TEAM_ID = 9007199254740995L; + private static final String LEAD_CONVERSATION_ID = "lead-conversation"; + + private ChatStreamTracker streamTracker; + private TeamEventChannel channel; + + @BeforeEach + void setUp() { + streamTracker = mock(ChatStreamTracker.class); + channel = new TeamEventChannel(streamTracker); + } + + @Test + void taskEventIncludesStringRunIdWhenPresent() { + TeamTaskEntity task = task(); + task.setRunId(RUN_ID); + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + + channel.publishTaskEvent(task, "team_task_created", Map.of("status", "pending")); + + verify(streamTracker).broadcastObject( + eq("team-events-" + TEAM_ID), eq("team_task_created"), payload.capture()); + Map data = (Map) payload.getValue(); + assertEquals(String.valueOf(RUN_ID), data.get("runId")); + assertEquals(String.valueOf(TEAM_ID), data.get("teamId")); + } + + @Test + void legacyTaskEventOmitsRunId() { + TeamTaskEntity task = task(); + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + + channel.publishTaskEvent(task, "team_task_created", Map.of()); + + verify(streamTracker).broadcastObject( + eq("team-events-" + TEAM_ID), eq("team_task_created"), payload.capture()); + assertFalse(((Map) payload.getValue()).containsKey("runId")); + } + + @Test + void runEventPublishesStringIdsAndProgressToTeamAndLeadConversation() { + TeamRunView run = run(); + ArgumentCaptor teamPayload = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor leadPayload = ArgumentCaptor.forClass(Object.class); + + channel.publishRunEvent(run, "team_run_cancelled", Map.of( + "reason", "stop", + "taskId", 9007199254740997L, + "blockedBy", List.of(9007199254740999L))); + + verify(streamTracker).register("team-events-" + TEAM_ID); + verify(streamTracker).broadcastObject( + eq("team-events-" + TEAM_ID), eq("team_run_cancelled"), teamPayload.capture()); + verify(streamTracker).broadcastObject( + eq(LEAD_CONVERSATION_ID), eq("team_run_cancelled"), leadPayload.capture()); + Map data = (Map) teamPayload.getValue(); + assertEquals(data, leadPayload.getValue()); + assertEquals(String.valueOf(RUN_ID), data.get("runId")); + assertEquals(String.valueOf(TEAM_ID), data.get("teamId")); + assertEquals(LEAD_CONVERSATION_ID, data.get("leadConversationId")); + assertEquals(TeamRunStatus.CANCELLED, data.get("status")); + assertEquals(run.progress(), data.get("progress")); + assertEquals("stop", data.get("reason")); + assertEquals("9007199254740997", data.get("taskId")); + assertEquals(List.of("9007199254740999"), data.get("blockedBy")); + } + + private static TeamTaskEntity task() { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(101L); + task.setTeamId(TEAM_ID); + task.setTaskNumber(1); + task.setSubject("Task"); + task.setAssigneeAgentId(2L); + task.setLeadConversationId(LEAD_CONVERSATION_ID); + return task; + } + + private static TeamRunView run() { + return new TeamRunView(RUN_ID, TEAM_ID, 30L, 1L, LEAD_CONVERSATION_ID, + null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null, + null, null, null, null, + new TeamRunView.Progress(2, 0, 0, 0, 0), List.of()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java index 3e71b4dd..bf7a1003 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java @@ -4,7 +4,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.planning.model.PlanEntity; @@ -13,6 +15,9 @@ import vip.mate.planning.service.PlanningService; import vip.mate.team.event.TeamTasksDelegatedEvent; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamRole; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskEntity; @@ -40,10 +45,13 @@ class TeamPlanBridgeTest { private static final Long ANALYST_ID = 3L; private static final Long PLAN_ID = 77L; private static final String CONV = "lead-conv"; + private static final Long WORKSPACE_ID = 30L; + private static final Long RUN_ID = 20L; private TeamService teamService; private TeamTaskService taskService; private PlanningService planningService; + private TeamRunService runService; private AgentMapper agentMapper; private ApplicationEventPublisher eventPublisher; private TeamPlanBridge bridge; @@ -54,15 +62,17 @@ class TeamPlanBridgeTest { teamService = mock(TeamService.class); taskService = mock(TeamTaskService.class); planningService = mock(PlanningService.class); + runService = mock(TeamRunService.class); agentMapper = mock(AgentMapper.class); eventPublisher = mock(ApplicationEventPublisher.class); - bridge = new TeamPlanBridge(teamService, taskService, planningService, + bridge = new TeamPlanBridge(teamService, taskService, runService, planningService, agentMapper, eventPublisher); team = new AgentTeamEntity(); team.setId(TEAM_ID); team.setName("编队"); team.setLeadAgentId(LEAD_ID); + team.setWorkspaceId(WORKSPACE_ID); when(teamService.listMembers(TEAM_ID)).thenReturn(List.of( member(LEAD_ID, TeamRole.LEAD), @@ -91,6 +101,7 @@ class TeamPlanBridgeTest { TeamTaskEntity t = new TeamTaskEntity(); t.setId(id); t.setTeamId(TEAM_ID); + t.setRunId(RUN_ID); t.setTaskNumber(number); t.setSubject("task " + number); t.setStatus(status); @@ -116,6 +127,13 @@ class TeamPlanBridgeTest { @Test @DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch") void delegatePlanCreatesLinkedTasks() { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setStatus(TeamRunStatus.PLANNING); + when(runService.startRun(any())).thenReturn(run); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of()); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(run, true)); when(taskService.createTask(any())).thenAnswer(inv -> { TeamTaskCreateCommand cmd = inv.getArgument(0); TeamTaskEntity created = new TeamTaskEntity(); @@ -143,14 +161,81 @@ class TeamPlanBridgeTest { assertTrue(second.getMetadata().contains("\"stepIndex\":1")); assertEquals(CONV, first.getLeadConversationId()); assertEquals(LEAD_ID, first.getCreatedByAgentId()); + assertEquals(RUN_ID, first.getRunId()); + assertEquals(RUN_ID, second.getRunId()); assertTrue(first.getDescription().contains("整体请求")); - verify(planningService).markPlanDelegated(PLAN_ID); - verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID)); + ArgumentCaptor runCaptor = ArgumentCaptor.forClass(TeamRunCreateCommand.class); + verify(runService).startRun(runCaptor.capture()); + assertEquals(WORKSPACE_ID, runCaptor.getValue().getWorkspaceId()); + assertEquals(-PLAN_ID, runCaptor.getValue().getOriginMessageId()); + assertTrue(runCaptor.getValue().getMetadata().contains("\"planId\":\"" + PLAN_ID + "\"")); + + InOrder order = inOrder(runService, taskService, planningService, eventPublisher); + order.verify(runService).startRun(any()); + order.verify(taskService, times(2)).createTask(any()); + order.verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID); + order.verify(planningService).markPlanDelegated(PLAN_ID); + order.verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID)); assertTrue(announcement.contains("并行")); assertTrue(announcement.contains("前置")); } + @Test + @DisplayName("a repeated delegation for a sealed run returns existing tasks without side effects") + void sealedRunDelegationIsIdempotent() { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setStatus(TeamRunStatus.RUNNING); + List existing = List.of( + task(101L, 1, 0, TeamTaskStatus.PENDING), + task(102L, 2, 1, TeamTaskStatus.PENDING)); + when(runService.startRun(any())).thenReturn(run); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(existing); + + String announcement = bridge.delegatePlan(team, PLAN_ID, "整体请求", + List.of("第一步", "第二步"), List.of(List.of(), List.of(0)), + List.of(WRITER_ID, ANALYST_ID), CONV); + + assertTrue(announcement.contains("task 1")); + verify(taskService, never()).createTask(any()); + verify(runService, never()).sealRunWithResult(any(), any()); + verify(planningService, never()).markPlanDelegated(any()); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("a retry with existing planning tasks seals once without recreating tasks") + void existingPlanningTasksAreSealedWithoutDuplication() { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setStatus(TeamRunStatus.PLANNING); + List existing = List.of( + task(101L, 1, 0, TeamTaskStatus.PENDING), + task(102L, 2, 1, TeamTaskStatus.PENDING)); + when(runService.startRun(any())).thenReturn(run); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(existing); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(run, true)); + + bridge.delegatePlan(team, PLAN_ID, "整体请求", + List.of("第一步", "第二步"), List.of(List.of(), List.of(0)), + List.of(WRITER_ID, ANALYST_ID), CONV); + + verify(taskService, never()).createTask(any()); + verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID); + verify(planningService).markPlanDelegated(PLAN_ID); + verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID)); + } + + @Test + @DisplayName("delegatePlan is transactional") + void delegatePlanIsTransactional() throws NoSuchMethodException { + assertNotNull(TeamPlanBridge.class.getMethod("delegatePlan", AgentTeamEntity.class, + Long.class, String.class, List.class, List.class, List.class, String.class) + .getAnnotation(Transactional.class)); + } + // ==================== resume gate ==================== private void parkedPlan() { diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunApplicationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunApplicationServiceTest.java new file mode 100644 index 00000000..479eda34 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunApplicationServiceTest.java @@ -0,0 +1,98 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.team.event.TeamRunCancelCommittedIntent; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class TeamRunApplicationServiceTest { + + private static final Long RUN_ID = 20L; + private static final Long WORKSPACE_ID = 30L; + + private TeamRunService runService; + private TeamTaskService taskService; + private ApplicationEventPublisher events; + private TeamRunApplicationService service; + + @BeforeEach + void setUp() { + runService = mock(TeamRunService.class); + taskService = mock(TeamTaskService.class); + events = mock(ApplicationEventPublisher.class); + service = new TeamRunApplicationService(runService, taskService, events); + } + + @Test + void firstCancellationCancelsActiveTasksAndPublishesDetachedIntentOnce() { + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + TeamTaskEntity pending = task(1L, TeamTaskStatus.PENDING, null); + TeamTaskEntity running = task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation"); + TeamTaskEntity completed = task(3L, TeamTaskStatus.COMPLETED, "old-conversation"); + TeamRunView view = mock(TeamRunView.class); + when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop")) + .thenReturn(new TeamRunService.CancelResult(cancelled, true)); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of(pending, running, completed)); + when(runService.buildView(cancelled)).thenReturn(view); + + TeamRunView result = service.cancelRun(RUN_ID, WORKSPACE_ID, "stop"); + + assertSame(view, result); + verify(taskService).cancelTask(1L, "stop"); + verify(taskService).cancelTask(2L, "stop"); + verify(taskService, never()).cancelTask(3L, "stop"); + ArgumentCaptor intent = + ArgumentCaptor.forClass(TeamRunCancelCommittedIntent.class); + verify(events).publishEvent(intent.capture()); + assertSame(view, intent.getValue().run()); + assertEquals(List.of(new TeamRunCancelCommittedIntent.WorkerTask( + 2L, null, "worker-conversation")), intent.getValue().workers()); + } + + @Test + void repeatedCancellationHasNoTaskOrEventSideEffects() { + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + TeamRunView view = mock(TeamRunView.class); + when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, null)) + .thenReturn(new TeamRunService.CancelResult(cancelled, false)); + when(runService.buildView(cancelled)).thenReturn(view); + + assertSame(view, service.cancelRun(RUN_ID, WORKSPACE_ID, null)); + + verify(taskService, never()).listTasksByRun(RUN_ID); + verifyNoInteractions(events); + } + + private static TeamRunEntity run(String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setWorkspaceId(WORKSPACE_ID); + run.setStatus(status); + return run; + } + + private static TeamTaskEntity task(Long id, String status, String conversationId) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(id); + task.setRunId(RUN_ID); + task.setStatus(status); + task.setConversationId(conversationId); + return task; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunCommittedIntentEventTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunCommittedIntentEventTest.java new file mode 100644 index 00000000..1c0d09b4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunCommittedIntentEventTest.java @@ -0,0 +1,321 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.event.TeamRunDispatchCommittedIntent; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamRunCommittedIntentEventTest { + + private static final Long TEAM_ID = 10L; + private static final Long RUN_ID = 20L; + private static final Long WORKSPACE_ID = 30L; + + @Test + void manualRunDispatchesOnlyAfterTheRealTransactionCommits() { + try (AnnotationConfigApplicationContext context = context()) { + TeamRunService runService = context.getBean(TeamRunService.class); + TeamTaskService taskService = context.getBean(TeamTaskService.class); + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + TeamManualTaskService service = context.getBean(TeamManualTaskService.class); + when(runService.startRun(any())).thenReturn(run(TeamRunStatus.PLANNING)); + when(taskService.createTask(any())).thenReturn(task(1L, TeamTaskStatus.PENDING, null)); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(run(TeamRunStatus.RUNNING), true)); + + transactions(context).executeWithoutResult(status -> { + service.createTask(team(), TeamTaskCreateCommand.builder() + .subject("dashboard task") + .assigneeAgentId(2L) + .build()); + verify(dispatchService, never()).requestDispatch(TEAM_ID); + }); + + verify(dispatchService).requestDispatch(TEAM_ID); + } + } + + @Test + void cancellationInterruptsAndPublishesOnlyAfterTheRealTransactionCommits() { + try (AnnotationConfigApplicationContext context = context()) { + TeamRunService runService = context.getBean(TeamRunService.class); + TeamTaskService taskService = context.getBean(TeamTaskService.class); + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class); + TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class); + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + TeamRunView view = view(); + when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop")) + .thenReturn(new TeamRunService.CancelResult(cancelled, true)); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of( + task(1L, TeamTaskStatus.PENDING, null), + task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation"))); + when(runService.buildView(cancelled)).thenReturn(view); + + transactions(context).executeWithoutResult(status -> { + service.cancelRun(RUN_ID, WORKSPACE_ID, "stop"); + verify(dispatchService, never()).interruptRun(any()); + verify(eventPublisher, never()).publishCancelled(any()); + }); + + TeamTaskEntity expectedSnapshot = new TeamTaskEntity(); + expectedSnapshot.setId(2L); + expectedSnapshot.setTaskNumber(2); + expectedSnapshot.setConversationId("worker-conversation"); + verify(dispatchService).interruptRun(expectedSnapshot); + verify(eventPublisher).publishCancelled(view); + } + } + + @Test + void listenerFailureDoesNotEscapeTheCommittedTransaction() { + try (AnnotationConfigApplicationContext context = context()) { + TeamRunService runService = context.getBean(TeamRunService.class); + TeamTaskService taskService = context.getBean(TeamTaskService.class); + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class); + TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class); + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + TeamRunView view = view(); + when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, null)) + .thenReturn(new TeamRunService.CancelResult(cancelled, true)); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of( + task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation"))); + when(runService.buildView(cancelled)).thenReturn(view); + doThrow(new IllegalStateException("interrupt failed")) + .when(dispatchService).interruptRun(any()); + + assertDoesNotThrow(() -> transactions(context).executeWithoutResult( + status -> service.cancelRun(RUN_ID, WORKSPACE_ID, null))); + + verify(eventPublisher).publishCancelled(view); + } + } + + @Test + void dispatchIntentFallsBackToImmediateExecutionWithoutATransaction() { + try (AnnotationConfigApplicationContext context = context()) { + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + + context.publishEvent(new TeamRunDispatchCommittedIntent(TEAM_ID)); + + verify(dispatchService).requestDispatch(TEAM_ID); + } + } + + @Test + void rolledBackManualRunNeverDispatches() { + try (AnnotationConfigApplicationContext context = context()) { + TeamRunService runService = context.getBean(TeamRunService.class); + TeamTaskService taskService = context.getBean(TeamTaskService.class); + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class); + TeamManualTaskService service = context.getBean(TeamManualTaskService.class); + when(runService.startRun(any())).thenReturn(run(TeamRunStatus.PLANNING)); + when(taskService.createTask(any())).thenReturn(task(1L, TeamTaskStatus.PENDING, null)); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(run(TeamRunStatus.RUNNING), true)); + + transactions(context).executeWithoutResult(status -> { + service.createTask(team(), TeamTaskCreateCommand.builder() + .subject("dashboard task") + .assigneeAgentId(2L) + .build()); + status.setRollbackOnly(); + }); + + verify(dispatchService, never()).requestDispatch(any()); + verify(dispatchService, never()).interruptRun(any()); + verify(eventPublisher, never()).publishCancelled(any()); + } + } + + @Test + void rolledBackCancellationNeverInterruptsOrPublishes() { + try (AnnotationConfigApplicationContext context = context()) { + TeamRunService runService = context.getBean(TeamRunService.class); + TeamTaskService taskService = context.getBean(TeamTaskService.class); + TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class); + TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class); + TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class); + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop")) + .thenReturn(new TeamRunService.CancelResult(cancelled, true)); + when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of( + task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation"))); + when(runService.buildView(cancelled)).thenReturn(view()); + + assertThrows(IllegalStateException.class, + () -> transactions(context).executeWithoutResult(status -> { + service.cancelRun(RUN_ID, WORKSPACE_ID, "stop"); + throw new IllegalStateException("roll back"); + })); + + verify(dispatchService, never()).requestDispatch(any()); + verify(dispatchService, never()).interruptRun(any()); + verify(eventPublisher, never()).publishCancelled(any()); + } + } + + private static AnnotationConfigApplicationContext context() { + return new AnnotationConfigApplicationContext(TestConfig.class); + } + + private static TransactionTemplate transactions(AnnotationConfigApplicationContext context) { + return new TransactionTemplate(context.getBean(PlatformTransactionManager.class)); + } + + private static AgentTeamEntity team() { + AgentTeamEntity team = new AgentTeamEntity(); + team.setId(TEAM_ID); + team.setWorkspaceId(WORKSPACE_ID); + team.setLeadAgentId(1L); + return team; + } + + private static TeamRunEntity run(String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(TEAM_ID); + run.setWorkspaceId(WORKSPACE_ID); + run.setLeadConversationId("dashboard-team-10"); + run.setStatus(status); + return run; + } + + private static TeamTaskEntity task(Long id, String status, String conversationId) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(id); + task.setTaskNumber(id.intValue()); + task.setRunId(RUN_ID); + task.setStatus(status); + task.setConversationId(conversationId); + return task; + } + + private static TeamRunView view() { + return new TeamRunView(RUN_ID, TEAM_ID, WORKSPACE_ID, 1L, "dashboard-team-10", + null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null, + null, null, null, null, + new TeamRunView.Progress(2, 0, 0, 0, 2), List.of()); + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + static class TestConfig { + + @Bean + PlatformTransactionManager transactionManager() { + return new TestTransactionManager(); + } + + @Bean + TeamRunService runService() { + return mock(TeamRunService.class); + } + + @Bean + TeamTaskService taskService() { + return mock(TeamTaskService.class); + } + + @Bean + TeamDispatchService dispatchService() { + return mock(TeamDispatchService.class); + } + + @Bean + TeamRunEventPublisher teamRunEventPublisher() { + return mock(TeamRunEventPublisher.class); + } + + @Bean + TeamManualTaskService manualTaskService(TeamRunService runService, + TeamTaskService taskService, + ApplicationEventPublisher events) { + return new TeamManualTaskService(runService, taskService, events); + } + + @Bean + TeamRunApplicationService teamRunApplicationService(TeamRunService runService, + TeamTaskService taskService, + ApplicationEventPublisher events) { + return new TeamRunApplicationService(runService, taskService, events); + } + + @Bean + TeamRunCommittedIntentListener teamRunCommittedIntentListener( + TeamDispatchService dispatchService, TeamRunEventPublisher eventPublisher) { + return new TeamRunCommittedIntentListener(dispatchService, eventPublisher); + } + } + + static class TestTransactionManager extends AbstractPlatformTransactionManager { + + private final ThreadLocal current = new ThreadLocal<>(); + + @Override + protected Object doGetTransaction() { + TestTransaction transaction = current.get(); + return transaction == null ? new TestTransaction() : transaction; + } + + @Override + protected boolean isExistingTransaction(Object transaction) { + return ((TestTransaction) transaction).active; + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + TestTransaction testTransaction = (TestTransaction) transaction; + testTransaction.active = true; + current.set(testTransaction); + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + ((TestTransaction) status.getTransaction()).active = false; + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + ((TestTransaction) status.getTransaction()).active = false; + } + + @Override + protected void doCleanupAfterCompletion(Object transaction) { + current.remove(); + } + + private static final class TestTransaction { + private boolean active; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionExecutorTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionExecutorTest.java new file mode 100644 index 00000000..95462243 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionExecutorTest.java @@ -0,0 +1,65 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamTaskMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamRunProjectionExecutorTest { + + @Test + void executeDelegatesInANewTransaction() throws Exception { + TeamRunProjector projector = mock(TeamRunProjector.class); + TeamTaskMapper taskMapper = mock(TeamTaskMapper.class); + TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper); + + executor.execute(20L); + + verify(projector).project(20L); + Transactional transactional = TeamRunProjectionExecutor.class + .getDeclaredMethod("execute", Long.class) + .getAnnotation(Transactional.class); + assertNotNull(transactional); + assertEquals(Propagation.REQUIRES_NEW, transactional.propagation()); + } + + @Test + void executeTaskLooksUpRunAndProjectsInANewTransaction() throws Exception { + TeamRunProjector projector = mock(TeamRunProjector.class); + TeamTaskMapper taskMapper = mock(TeamTaskMapper.class); + TeamTaskEntity task = new TeamTaskEntity(); + task.setRunId(20L); + when(taskMapper.selectById(5L)).thenReturn(task); + TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper); + + executor.executeTask(5L); + + verify(taskMapper).selectById(5L); + verify(projector).project(20L); + Transactional transactional = TeamRunProjectionExecutor.class + .getDeclaredMethod("executeTask", Long.class) + .getAnnotation(Transactional.class); + assertNotNull(transactional); + assertEquals(Propagation.REQUIRES_NEW, transactional.propagation()); + } + + @Test + void executeTaskSkipsTasksWithoutRuns() { + TeamRunProjector projector = mock(TeamRunProjector.class); + TeamTaskMapper taskMapper = mock(TeamTaskMapper.class); + when(taskMapper.selectById(5L)).thenReturn(new TeamTaskEntity()); + TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper); + + executor.executeTask(5L); + + verify(projector, never()).project(20L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionSchedulerTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionSchedulerTest.java new file mode 100644 index 00000000..7cc1c360 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectionSchedulerTest.java @@ -0,0 +1,106 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronizationUtils; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamTaskMapper; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamRunProjectionSchedulerTest { + + private static final Long RUN_ID = 20L; + private static final Long TASK_ID = 5L; + + private TeamRunProjectionExecutor executor; + private TeamRunProjectionScheduler scheduler; + + @BeforeEach + void setUp() { + clearTransactionState(); + executor = mock(TeamRunProjectionExecutor.class); + scheduler = new TeamRunProjectionScheduler(executor); + } + + @AfterEach + void tearDown() { + clearTransactionState(); + } + + @Test + void activeTransactionProjectsOnlyAfterCommit() { + beginTransactionSynchronization(); + + scheduler.scheduleRun(RUN_ID); + + verify(executor, never()).execute(RUN_ID); + TransactionSynchronizationUtils.triggerAfterCommit(); + verify(executor).execute(RUN_ID); + TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); + } + + @Test + void rolledBackTransactionDoesNotProject() { + beginTransactionSynchronization(); + + scheduler.scheduleRun(RUN_ID); + TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + + verify(executor, never()).execute(RUN_ID); + } + + @Test + void noTransactionProjectsImmediately() { + scheduler.scheduleRun(RUN_ID); + + verify(executor).execute(RUN_ID); + } + + @Test + void projectionFailureIsSwallowed() { + doThrow(new IllegalStateException("projection unavailable")).when(executor).execute(RUN_ID); + + assertDoesNotThrow(() -> scheduler.scheduleRun(RUN_ID)); + } + + @Test + void taskLookupIsDeferredUntilAfterCommit() { + TeamTaskMapper taskMapper = mock(TeamTaskMapper.class); + TeamRunProjector projector = mock(TeamRunProjector.class); + TeamTaskEntity task = new TeamTaskEntity(); + task.setRunId(RUN_ID); + when(taskMapper.selectById(TASK_ID)).thenReturn(task); + TeamRunProjectionScheduler taskScheduler = new TeamRunProjectionScheduler( + new TeamRunProjectionExecutor(projector, taskMapper)); + beginTransactionSynchronization(); + + taskScheduler.scheduleTask(TASK_ID); + + verify(taskMapper, never()).selectById(TASK_ID); + TransactionSynchronizationUtils.triggerAfterCommit(); + verify(taskMapper).selectById(TASK_ID); + verify(projector).project(RUN_ID); + TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); + } + + private void beginTransactionSynchronization() { + TransactionSynchronizationManager.setActualTransactionActive(true); + TransactionSynchronizationManager.initSynchronization(); + } + + private void clearTransactionState() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + TransactionSynchronizationManager.setActualTransactionActive(false); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java new file mode 100644 index 00000000..c2c29aab --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java @@ -0,0 +1,205 @@ +package vip.mate.team.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamRunProjectorTest { + + private static final Long RUN_ID = 20L; + + private TeamRunMapper runMapper; + private TeamTaskMapper taskMapper; + private TeamRunProjector projector; + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + TableInfoHelper.initTableInfo(assistant, TeamRunEntity.class); + TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class); + } + + @BeforeEach + void setUp() { + runMapper = mock(TeamRunMapper.class); + taskMapper = mock(TeamTaskMapper.class); + projector = new TeamRunProjector(runMapper, taskMapper); + } + + @Test + void projectsStatusAndReturnsComputedProgress() { + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.AWAITING_REVIEW, null)); + when(taskMapper.selectList(any())).thenReturn(List.of( + task(TeamTaskStatus.COMPLETED), task(TeamTaskStatus.PENDING))); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.RUNNING, view.status()); + assertEquals(new TeamRunView.Progress(2, 1, 0, 0, 50), view.progress()); + ArgumentCaptor> captor = updateCaptor(); + verify(runMapper).update(isNull(), captor.capture()); + assertTrue(captor.getValue().getSqlSegment().toUpperCase().contains("METADATA IS NULL")); + } + + @Test + void projectsTaskDependenciesAndMetadataWithoutReencodingIds() { + TeamTaskEntity task = task(TeamTaskStatus.BLOCKED); + task.setBlockedBy("[\"9007199254740993\"]"); + task.setMetadata("{\"deliverables\":[],\"planId\":\"9007199254740995\"}"); + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING, null)); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + TeamRunView.Task projected = projector.project(RUN_ID).tasks().getFirst(); + + assertEquals("[\"9007199254740993\"]", projected.blockedBy()); + assertEquals("{\"deliverables\":[],\"planId\":\"9007199254740995\"}", projected.metadata()); + } + + @Test + void terminalRunCannotBeMovedByLateTaskEvents() { + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.CANCELLED, "{\"traceId\":\"a\"}")); + when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.PENDING))); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.CANCELLED, view.status()); + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void concurrentCancellationWinsProjectionCompareAndSet() { + TeamRunEntity running = run(TeamRunStatus.RUNNING, null); + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED, null); + when(runMapper.selectById(RUN_ID)).thenReturn(running, cancelled); + when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.COMPLETED))); + when(runMapper.update(isNull(), any())).thenReturn(0); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.CANCELLED, view.status()); + } + + @Test + void failedCompareAndSetReloadsRunAndTasksBeforeRecomputing() { + TeamRunEntity firstRun = run(TeamRunStatus.RUNNING, "{\"revision\":1}"); + TeamRunEntity secondRun = run(TeamRunStatus.RUNNING, "{\"revision\":2}"); + TeamTaskEntity completed = task(TeamTaskStatus.COMPLETED); + TeamTaskEntity pending = task(TeamTaskStatus.PENDING); + when(runMapper.selectById(RUN_ID)).thenReturn(firstRun, secondRun); + when(taskMapper.selectList(any())).thenReturn(List.of(completed), List.of(pending)); + when(runMapper.update(isNull(), any())).thenReturn(0); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.RUNNING, view.status()); + assertEquals("{\"revision\":2}", view.metadata()); + assertEquals(TeamTaskStatus.PENDING, view.tasks().getFirst().status()); + assertEquals(new TeamRunView.Progress(1, 0, 0, 0, 0), view.progress()); + verify(taskMapper, times(2)).selectList(any()); + verify(runMapper, times(1)).update(isNull(), any()); + } + + @Test + void finalizingProjectionMergesOutcomeIntoMetadataObject() { + String originalMetadata = "{\"traceId\":\"a\",\"nested\":{\"kept\":true}}"; + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.RUNNING, originalMetadata)); + when(taskMapper.selectList(any())).thenReturn(List.of( + task(TeamTaskStatus.COMPLETED), task(TeamTaskStatus.FAILED))); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.FINALIZING, view.status()); + assertTrue(view.metadata().contains("\"traceId\":\"a\"")); + assertTrue(view.metadata().contains("\"nested\"")); + assertTrue(view.metadata().contains("\"projectedOutcome\":\"partial\"")); + + ArgumentCaptor> captor = updateCaptor(); + verify(runMapper).update(isNull(), captor.capture()); + captor.getValue().getSqlSegment(); + assertTrue(captor.getValue().getParamNameValuePairs().values().stream() + .map(String::valueOf).anyMatch(value -> value.contains("projectedOutcome"))); + assertTrue(captor.getValue().getParamNameValuePairs().containsValue(originalMetadata)); + } + + @Test + void planningRunKeepsPlanningWhenTaskIsCreated() { + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING, null)); + when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.PENDING))); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(TeamRunStatus.PLANNING, view.status()); + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void nullAndMissingRunsAreSafe() { + assertNull(projector.project(null)); + verify(runMapper, never()).selectById(any()); + + when(runMapper.selectById(RUN_ID)).thenReturn(null); + assertNull(projector.project(RUN_ID)); + verify(taskMapper, never()).selectList(any()); + } + + @Test + void projectionFailureIsLoggedAndSwallowed() { + when(runMapper.selectById(RUN_ID)).thenThrow(new IllegalStateException("database unavailable")); + + assertDoesNotThrow(() -> assertNull(projector.project(RUN_ID))); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private ArgumentCaptor> updateCaptor() { + return ArgumentCaptor.forClass((Class) LambdaUpdateWrapper.class); + } + + private TeamRunEntity run(String status, String metadata) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(10L); + run.setWorkspaceId(30L); + run.setLeadAgentId(40L); + run.setLeadConversationId("conversation"); + run.setTitle("Research"); + run.setObjective("Research the topic"); + run.setStatus(status); + run.setMetadata(metadata); + return run; + } + + private TeamTaskEntity task(String status) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setRunId(RUN_ID); + task.setStatus(status); + return task; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java new file mode 100644 index 00000000..c8f0d484 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java @@ -0,0 +1,360 @@ +package vip.mate.team.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TeamRunServiceTest { + + private static final Long RUN_ID = 20L; + private static final Long TEAM_ID = 10L; + private static final Long WORKSPACE_ID = 30L; + private static final Long LEAD_ID = 40L; + + private TeamRunMapper runMapper; + private TeamTaskMapper taskMapper; + private TeamService teamService; + private TeamRunService service; + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + TableInfoHelper.initTableInfo(assistant, TeamRunEntity.class); + TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class); + } + + @BeforeEach + void setUp() { + runMapper = mock(TeamRunMapper.class); + taskMapper = mock(TeamTaskMapper.class); + teamService = mock(TeamService.class); + service = new TeamRunService(runMapper, taskMapper, teamService); + when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, WORKSPACE_ID, LEAD_ID)); + } + + @Test + void startRunValidatesActiveTeamWorkspaceAndLead() { + when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_PAUSED, WORKSPACE_ID, LEAD_ID)); + assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build())); + + when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, 999L, LEAD_ID)); + assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build())); + + when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, WORKSPACE_ID, 999L)); + assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build())); + + verify(runMapper, never()).insert(any(TeamRunEntity.class)); + } + + @Test + void startRunValidatesConversationAndObjective() { + assertThrows(IllegalArgumentException.class, + () -> service.startRun(command().leadConversationId(" ").build())); + assertThrows(IllegalArgumentException.class, + () -> service.startRun(command().objective(null).build())); + } + + @Test + void startRunCreatesPlanningRunAndDerivesBoundedTitle() { + String objective = "x".repeat(300); + TeamRunCreateCommand command = command().title(" ").objective(objective).build(); + + TeamRunEntity created = service.startRun(command); + + assertEquals(TeamRunStatus.PLANNING, created.getStatus()); + assertEquals(255, created.getTitle().length()); + assertTrue(objective.startsWith(created.getTitle())); + assertEquals(WORKSPACE_ID, created.getWorkspaceId()); + assertEquals(LEAD_ID, created.getLeadAgentId()); + verify(runMapper).insert(created); + } + + @Test + void startRunReturnsExistingIdempotentRun() { + TeamRunEntity existing = run(TeamRunStatus.RUNNING); + when(runMapper.selectOne(any())).thenReturn(existing); + + assertSame(existing, service.startRun(command().build())); + + verify(runMapper, never()).insert(any(TeamRunEntity.class)); + } + + @Test + void startRunScopesIdempotencyByWorkspaceAndDoesNotOwnATransaction() throws Exception { + TeamRunEntity existing = run(TeamRunStatus.RUNNING); + when(runMapper.selectOne(any())).thenReturn(existing); + + service.startRun(command().build()); + + ArgumentCaptor> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(runMapper).selectOne(query.capture()); + query.getValue().getSqlSegment(); + assertTrue(query.getValue().getParamNameValuePairs().containsValue(WORKSPACE_ID)); + assertFalse(TeamRunService.class + .getDeclaredMethod("startRun", TeamRunCreateCommand.class) + .isAnnotationPresent(Transactional.class)); + } + + @Test + void startRunRecoversDuplicateKeyRaceByReadingWinner() { + TeamRunEntity winner = run(TeamRunStatus.PLANNING); + when(runMapper.selectOne(any())).thenReturn(null, winner); + when(runMapper.insert(any(TeamRunEntity.class))) + .thenThrow(new DuplicateKeyException("duplicate origin")); + + assertSame(winner, service.startRun(command().build())); + } + + @Test + void requireRunRejectsCrossWorkspaceAccess() { + TeamRunEntity foreign = run(TeamRunStatus.RUNNING); + foreign.setWorkspaceId(999L); + when(runMapper.selectById(RUN_ID)).thenReturn(foreign); + + assertThrows(IllegalArgumentException.class, () -> service.requireRun(RUN_ID, WORKSPACE_ID)); + } + + @Test + void sealRunRejectsEmptyPlanningRun() { + when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING)); + when(taskMapper.selectCount(any())).thenReturn(0L); + + assertThrows(IllegalStateException.class, () -> service.sealRun(RUN_ID, WORKSPACE_ID)); + + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void sealRunStartsPopulatedPlanningRun() { + TeamRunEntity planning = run(TeamRunStatus.PLANNING); + when(runMapper.selectById(RUN_ID)).thenReturn(planning); + when(taskMapper.selectCount(any())).thenReturn(2L); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunEntity sealed = service.sealRun(RUN_ID, WORKSPACE_ID); + + assertEquals(TeamRunStatus.RUNNING, sealed.getStatus()); + assertNotNull(sealed.getStartedAt()); + verify(runMapper).update(isNull(), any()); + } + + @Test + void sealRunWithResultReportsFirstTransition() { + TeamRunEntity planning = run(TeamRunStatus.PLANNING); + when(runMapper.selectById(RUN_ID)).thenReturn(planning); + when(taskMapper.selectCount(any())).thenReturn(2L); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID); + + assertSame(planning, result.run()); + assertTrue(result.transitioned()); + assertEquals(TeamRunStatus.RUNNING, result.run().getStatus()); + } + + @Test + void sealRunWithResultReportsRepeatedSealWithoutTransition() { + TeamRunEntity running = run(TeamRunStatus.RUNNING); + when(runMapper.selectById(RUN_ID)).thenReturn(running); + + TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID); + + assertSame(running, result.run()); + assertFalse(result.transitioned()); + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void sealRunWithResultReportsConcurrentWinnerWithoutTransition() { + TeamRunEntity planning = run(TeamRunStatus.PLANNING); + TeamRunEntity winner = run(TeamRunStatus.RUNNING); + when(runMapper.selectById(RUN_ID)).thenReturn(planning, winner); + when(taskMapper.selectCount(any())).thenReturn(2L); + when(runMapper.update(isNull(), any())).thenReturn(0); + + TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID); + + assertSame(winner, result.run()); + assertFalse(result.transitioned()); + } + + @Test + void sealRunReturnsRunsThatAlreadyLeftPlanning() { + for (String status : List.of(TeamRunStatus.RUNNING, TeamRunStatus.FINALIZING, + TeamRunStatus.COMPLETED, TeamRunStatus.CANCELLED)) { + TeamRunEntity current = run(status); + when(runMapper.selectById(RUN_ID)).thenReturn(current); + + assertSame(current, service.sealRun(RUN_ID, WORKSPACE_ID)); + } + + verify(taskMapper, never()).selectCount(any()); + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void cancelRunWithResultReportsOnlyTheFirstTransition() { + TeamRunEntity running = run(TeamRunStatus.RUNNING); + when(runMapper.selectById(RUN_ID)).thenReturn(running); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunService.CancelResult result = service.cancelRunWithResult( + RUN_ID, WORKSPACE_ID, "stop"); + + assertTrue(result.transitioned()); + assertEquals(TeamRunStatus.CANCELLED, result.run().getStatus()); + assertEquals("stop", result.run().getStopReason()); + } + + @Test + void cancelRunWithResultIsIdempotentAfterCancellation() { + TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED); + when(runMapper.selectById(RUN_ID)).thenReturn(cancelled); + + TeamRunService.CancelResult result = service.cancelRunWithResult( + RUN_ID, WORKSPACE_ID, null); + + assertFalse(result.transitioned()); + assertSame(cancelled, result.run()); + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void cancelRunWithResultReportsConcurrentWinnerWithoutTransition() { + TeamRunEntity running = run(TeamRunStatus.RUNNING); + TeamRunEntity winner = run(TeamRunStatus.CANCELLED); + when(runMapper.selectById(RUN_ID)).thenReturn(running, winner); + when(runMapper.update(isNull(), any())).thenReturn(0); + + TeamRunService.CancelResult result = service.cancelRunWithResult( + RUN_ID, WORKSPACE_ID, null); + + assertFalse(result.transitioned()); + assertSame(winner, result.run()); + } + + @Test + void markFinalizedUsesProjectedOutcomeAndWritesSummary() { + TeamRunEntity finalizing = run(TeamRunStatus.FINALIZING); + finalizing.setMetadata("{\"traceId\":\"abc\",\"projectedOutcome\":\"partial\"}"); + when(runMapper.selectById(RUN_ID)).thenReturn(finalizing); + when(runMapper.update(isNull(), any())).thenReturn(1); + + TeamRunEntity finalized = service.markFinalized(RUN_ID, WORKSPACE_ID, "usable result"); + + assertEquals(TeamRunStatus.PARTIAL, finalized.getStatus()); + assertEquals("usable result", finalized.getFinalSummary()); + assertNotNull(finalized.getCompletedAt()); + } + + @Test + void markFinalizedRejectsInvalidOutcome() { + TeamRunEntity finalizing = run(TeamRunStatus.FINALIZING); + finalizing.setMetadata("{\"projectedOutcome\":\"running\"}"); + when(runMapper.selectById(RUN_ID)).thenReturn(finalizing); + + assertThrows(IllegalStateException.class, + () -> service.markFinalized(RUN_ID, WORKSPACE_ID, "summary")); + } + + @Test + void markFinalizedReturnsAlreadyTerminalRuns() { + for (String status : Set.of(TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, + TeamRunStatus.FAILED, TeamRunStatus.CANCELLED)) { + TeamRunEntity current = run(status); + when(runMapper.selectById(RUN_ID)).thenReturn(current); + + assertSame(current, service.markFinalized(RUN_ID, WORKSPACE_ID, "summary")); + } + + verify(runMapper, never()).update(isNull(), any()); + } + + @Test + void getRunBuildsStableViewWithTasksAndProgress() { + TeamRunEntity running = run(TeamRunStatus.RUNNING); + TeamTaskEntity completed = task(1L, TeamTaskStatus.COMPLETED); + TeamTaskEntity pending = task(2L, TeamTaskStatus.PENDING); + when(runMapper.selectById(RUN_ID)).thenReturn(running); + when(taskMapper.selectList(any())).thenReturn(List.of(completed, pending)); + + var view = service.getRun(RUN_ID, WORKSPACE_ID); + + assertEquals(RUN_ID, view.id()); + assertEquals(2, view.tasks().size()); + assertEquals(50, view.progress().percent()); + } + + private TeamRunCreateCommand.TeamRunCreateCommandBuilder command() { + return TeamRunCreateCommand.builder() + .teamId(TEAM_ID) + .workspaceId(WORKSPACE_ID) + .leadAgentId(LEAD_ID) + .leadConversationId("conversation") + .originMessageId(50L) + .title("Research") + .objective("Research the topic"); + } + + private AgentTeamEntity team(String status, Long workspaceId, Long leadId) { + AgentTeamEntity team = new AgentTeamEntity(); + team.setId(TEAM_ID); + team.setStatus(status); + team.setWorkspaceId(workspaceId); + team.setLeadAgentId(leadId); + return team; + } + + private TeamRunEntity run(String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(TEAM_ID); + run.setWorkspaceId(WORKSPACE_ID); + run.setLeadAgentId(LEAD_ID); + run.setLeadConversationId("conversation"); + run.setTitle("Research"); + run.setObjective("Research the topic"); + run.setStatus(status); + return run; + } + + private TeamTaskEntity task(Long id, String status) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(id); + task.setTeamId(TEAM_ID); + task.setRunId(RUN_ID); + task.setStatus(status); + return task; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunStateMachineTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunStateMachineTest.java new file mode 100644 index 00000000..7f1d9fb4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunStateMachineTest.java @@ -0,0 +1,118 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class TeamRunStateMachineTest { + + private final TeamRunStateMachine stateMachine = new TeamRunStateMachine(); + + @ParameterizedTest(name = "{0}") + @MethodSource("projections") + void projectsTaskState(String name, String runStatus, List tasks, + String expectedStatus, String expectedOutcome, + int done, int failed, int inReview, int percent) { + TeamRunEntity run = run(runStatus); + + TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks); + + assertEquals(expectedStatus, projection.status()); + assertEquals(expectedOutcome, projection.projectedOutcome()); + assertEquals(new TeamRunView.Progress(tasks.size(), done, failed, inReview, percent), + projection.progress()); + } + + static Stream projections() { + return Stream.of( + Arguments.of("empty planning run", TeamRunStatus.PLANNING, tasks(), + TeamRunStatus.PLANNING, null, 0, 0, 0, 0), + Arguments.of("planning run with tasks", TeamRunStatus.PLANNING, + tasks(TeamTaskStatus.PENDING), TeamRunStatus.PLANNING, null, 0, 0, 0, 0), + Arguments.of("active tasks", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.IN_PROGRESS), + TeamRunStatus.RUNNING, null, 1, 0, 0, 50), + Arguments.of("blocked tasks are active", TeamRunStatus.AWAITING_REVIEW, + tasks(TeamTaskStatus.BLOCKED), TeamRunStatus.RUNNING, null, 0, 0, 0, 0), + Arguments.of("review only", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.IN_REVIEW), + TeamRunStatus.AWAITING_REVIEW, null, 1, 0, 1, 50), + Arguments.of("all completed", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.COMPLETED), + TeamRunStatus.FINALIZING, TeamRunStatus.COMPLETED, 2, 0, 0, 100), + Arguments.of("mixed completed and failed", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.FAILED), + TeamRunStatus.FINALIZING, TeamRunStatus.PARTIAL, 1, 1, 0, 50), + Arguments.of("mixed completed and cancelled", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.CANCELLED), + TeamRunStatus.FINALIZING, TeamRunStatus.PARTIAL, 1, 1, 0, 50), + Arguments.of("no successful tasks", TeamRunStatus.RUNNING, + tasks(TeamTaskStatus.FAILED, TeamTaskStatus.CANCELLED), + TeamRunStatus.FINALIZING, TeamRunStatus.FAILED, 0, 2, 0, 0) + ); + } + + @Test + void cancelledRunIsImmutable() { + TeamRunStateMachine.Projection projection = stateMachine.project( + run(TeamRunStatus.CANCELLED), tasks(TeamTaskStatus.PENDING)); + + assertEquals(TeamRunStatus.CANCELLED, projection.status()); + assertNull(projection.projectedOutcome()); + } + + @Test + void otherTerminalRunsAreImmutable() { + for (String status : List.of(TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED)) { + assertEquals(status, stateMachine.project(run(status), tasks(TeamTaskStatus.PENDING)).status()); + } + } + + @Test + void emptyNonPlanningRunKeepsItsCurrentNonTerminalStatus() { + for (String status : List.of( + TeamRunStatus.RUNNING, TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING)) { + TeamRunStateMachine.Projection projection = stateMachine.project(run(status), tasks()); + + assertEquals(status, projection.status()); + assertNull(projection.projectedOutcome()); + } + } + + @Test + void unknownTaskStatusKeepsCurrentNonTerminalStatus() { + TeamRunStateMachine.Projection projection = stateMachine.project( + run(TeamRunStatus.RUNNING), tasks("custom_status")); + + assertEquals(TeamRunStatus.RUNNING, projection.status()); + assertNull(projection.projectedOutcome()); + assertEquals(new TeamRunView.Progress(1, 0, 0, 0, 0), projection.progress()); + } + + private static TeamRunEntity run(String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setStatus(status); + return run; + } + + private static List tasks(String... statuses) { + return Arrays.stream(statuses).map(status -> { + TeamTaskEntity task = new TeamTaskEntity(); + task.setStatus(status); + return task; + }).toList(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index 97caa222..d2fcf84f 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -1,6 +1,7 @@ package vip.mate.team.service; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; @@ -8,8 +9,11 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskCommentEntity; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskEntity; @@ -20,6 +24,7 @@ import vip.mate.team.repository.TeamTaskEventMapper; import vip.mate.team.repository.TeamTaskMapper; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -36,11 +41,15 @@ class TeamTaskServiceTest { private static final Long TEAM_ID = 10L; private static final Long LEAD_ID = 1L; private static final Long MEMBER_ID = 2L; + private static final Long RUN_ID = 20L; + private static final Long WORKSPACE_ID = 30L; private TeamTaskMapper taskMapper; private TeamTaskCommentMapper commentMapper; private TeamTaskEventMapper eventMapper; private TeamService teamService; + private TeamRunProjectionScheduler projectionScheduler; + private TeamRunService runService; private TeamTaskService service; @BeforeAll @@ -60,12 +69,16 @@ class TeamTaskServiceTest { commentMapper = mock(TeamTaskCommentMapper.class); eventMapper = mock(TeamTaskEventMapper.class); teamService = mock(TeamService.class); - service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService); + projectionScheduler = mock(TeamRunProjectionScheduler.class); + runService = mock(TeamRunService.class); + service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService, + projectionScheduler, runService); AgentTeamEntity team = new AgentTeamEntity(); team.setId(TEAM_ID); team.setLeadAgentId(LEAD_ID); team.setStatus(TeamService.STATUS_ACTIVE); + team.setWorkspaceId(WORKSPACE_ID); when(teamService.getTeam(TEAM_ID)).thenReturn(team); when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true); when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1); @@ -87,6 +100,21 @@ class TeamTaskServiceTest { return t; } + private TeamTaskEntity runTask(Long id, String status) { + TeamTaskEntity task = task(id, status); + task.setRunId(RUN_ID); + return task; + } + + private TeamRunEntity planningRun(Long teamId) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(teamId); + run.setWorkspaceId(WORKSPACE_ID); + run.setStatus(TeamRunStatus.PLANNING); + return run; + } + // ==================== creation guards ==================== @Test @@ -137,6 +165,60 @@ class TeamTaskServiceTest { assertEquals(0, created.getDispatchCount()); } + @Test + @DisplayName("create copies an optional run id onto the persisted task") + void createCopiesRunId() { + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID)); + service.createTask(baseCreate().runId(RUN_ID).build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamTaskEntity.class); + verify(taskMapper).insert(captor.capture()); + assertEquals(RUN_ID, captor.getValue().getRunId()); + verify(projectionScheduler).scheduleRun(RUN_ID); + } + + @Test + @DisplayName("run-aware task creation requires a planning run in the same team") + void createRequiresPlanningRunInSameTeam() { + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(999L)); + IllegalArgumentException wrongTeam = assertThrows(IllegalArgumentException.class, + () -> service.createTask(baseCreate().runId(RUN_ID).build())); + assertTrue(wrongTeam.getMessage().contains("same team")); + + TeamRunEntity running = planningRun(TEAM_ID); + running.setStatus(TeamRunStatus.RUNNING); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running); + IllegalStateException wrongStatus = assertThrows(IllegalStateException.class, + () -> service.createTask(baseCreate().runId(RUN_ID).build())); + assertTrue(wrongStatus.getMessage().contains("planning")); + verify(taskMapper, never()).insert(any(TeamTaskEntity.class)); + } + + @Test + @DisplayName("blockedBy tasks must belong to the same run") + void createRequiresBlockersInSameRun() { + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID)); + when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.PENDING)); + + IllegalArgumentException runTaskWithLegacyBlocker = assertThrows(IllegalArgumentException.class, + () -> service.createTask(baseCreate().runId(RUN_ID).blockedBy(List.of(99L)).build())); + assertTrue(runTaskWithLegacyBlocker.getMessage().contains("same run")); + + when(taskMapper.selectById(99L)).thenReturn(runTask(99L, TeamTaskStatus.PENDING)); + IllegalArgumentException legacyTaskWithRunBlocker = assertThrows(IllegalArgumentException.class, + () -> service.createTask(baseCreate().blockedBy(List.of(99L)).build())); + assertTrue(legacyTaskWithRunBlocker.getMessage().contains("same run")); + } + + @Test + @DisplayName("legacy task creation does not trigger run projection") + void createLegacyTaskDoesNotProject() { + service.createTask(baseCreate().build()); + + verify(projectionScheduler, never()).scheduleRun(any()); + verify(projectionScheduler, never()).scheduleTask(any()); + } + // ==================== completion ==================== @Test @@ -207,6 +289,107 @@ class TeamTaskServiceTest { assertThrows(IllegalStateException.class, () -> service.completeTask(5L, MEMBER_ID, "late")); } + @Test + @DisplayName("successful completion triggers run projection") + void completeProjectsRun() { + TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(running); + when(taskMapper.update(isNull(), any())).thenReturn(1); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + service.completeTask(5L, MEMBER_ID, "done"); + + verify(projectionScheduler).scheduleRun(RUN_ID); + } + + @Test + @DisplayName("successful failure triggers run projection") + void failProjectsRun() { + when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskMapper.update(isNull(), any())).thenReturn(1); + InOrder mutationOrder = inOrder(taskMapper); + + assertTrue(service.failTask(5L, "error")); + + mutationOrder.verify(taskMapper).selectById(5L); + mutationOrder.verify(taskMapper).update(isNull(), any()); + mutationOrder.verifyNoMoreInteractions(); + verify(projectionScheduler).scheduleTask(5L); + } + + @Test + @DisplayName("successful cancellation triggers run projection") + void cancelProjectsRun() { + when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskMapper.update(isNull(), any())).thenReturn(1); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + service.cancelTask(5L, "stop"); + + verify(projectionScheduler).scheduleRun(RUN_ID); + } + + @Test + @DisplayName("successful retry triggers run projection") + void retryProjectsRun() { + when(taskMapper.update(isNull(), any())).thenReturn(1); + when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.PENDING)); + + assertTrue(service.retryTask(5L)); + + verify(projectionScheduler).scheduleTask(5L); + } + + @Test + @DisplayName("claim does not query the task after a successful mutation") + void claimDoesNotQueryTaskAfterMutation() { + when(taskMapper.update(isNull(), any())).thenReturn(1); + + assertTrue(service.claimTask(5L, MEMBER_ID)); + verify(taskMapper, never()).selectById(5L); + verify(projectionScheduler).scheduleTask(5L); + } + + @Test + @DisplayName("assign does not query the task after a successful mutation") + void assignDoesNotQueryTaskAfterMutation() { + when(taskMapper.update(isNull(), any())).thenReturn(1); + + assertTrue(service.assignTask(5L, MEMBER_ID)); + verify(taskMapper, never()).selectById(5L); + verify(projectionScheduler).scheduleTask(5L); + } + + @Test + @DisplayName("completion succeeds when projection scheduling fails") + void completeIgnoresProjectionSchedulingFailure() { + TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(running); + when(taskMapper.update(isNull(), any())).thenReturn(1); + when(taskMapper.selectList(any())).thenReturn(List.of()); + doThrow(new IllegalStateException("scheduler unavailable")) + .when(projectionScheduler).scheduleRun(RUN_ID); + + assertTrue(service.completeTask(5L, MEMBER_ID, "done").isEmpty()); + } + + @Test + @DisplayName("successful progress update triggers run projection") + void progressProjectsRun() { + when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskMapper.update(isNull(), any())).thenReturn(1); + InOrder mutationOrder = inOrder(taskMapper); + + assertTrue(service.updateProgress(5L, MEMBER_ID, 50, "halfway")); + + mutationOrder.verify(taskMapper).selectById(5L); + mutationOrder.verify(taskMapper).update(isNull(), any()); + mutationOrder.verifyNoMoreInteractions(); + verify(projectionScheduler).scheduleTask(5L); + } + // ==================== blocker comment ==================== @Test @@ -374,4 +557,33 @@ class TeamTaskServiceTest { assertTrue(service.listDeliverables(junk).isEmpty()); assertTrue(service.listDeliverables(null).isEmpty()); } + + @Test + @DisplayName("dispatch candidates exclude planning runs but keep running and legacy tasks") + void findDispatchableExcludesPlanningRuns() { + TeamTaskEntity planning = runTask(1L, TeamTaskStatus.PENDING); + TeamTaskEntity running = task(2L, TeamTaskStatus.PENDING); + running.setRunId(21L); + TeamTaskEntity legacy = task(3L, TeamTaskStatus.PENDING); + when(taskMapper.selectList(any())).thenReturn(List.of(planning, running, legacy)); + when(runService.findPlanningRunIds(Set.of(RUN_ID, 21L))).thenReturn(Set.of(RUN_ID)); + + assertEquals(List.of(running, legacy), service.findDispatchable(TEAM_ID)); + verify(runService).findPlanningRunIds(Set.of(RUN_ID, 21L)); + } + + @Test + @DisplayName("run task lookup is scoped by run id") + void listTasksByRunScopesQuery() { + TeamTaskEntity first = runTask(1L, TeamTaskStatus.PENDING); + when(taskMapper.selectList(any())).thenReturn(List.of(first)); + + assertEquals(List.of(first), service.listTasksByRun(RUN_ID)); + + ArgumentCaptor> query = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(taskMapper).selectList(query.capture()); + query.getValue().getSqlSegment(); + assertTrue(query.getValue().getParamNameValuePairs().containsValue(RUN_ID)); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index 6a1b9eb1..ff6395f4 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -5,15 +5,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamRunCreateCommand; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamRunService; import vip.mate.team.service.TeamTaskService; import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.workspace.conversation.ConversationService; @@ -39,9 +46,12 @@ class TeamTasksToolTest { private static final Long TEAM_ID = 10L; private static final Long LEAD_ID = 1L; private static final Long MEMBER_ID = 2L; + private static final Long WORKSPACE_ID = 30L; + private static final Long RUN_ID = 20L; private TeamService teamService; private TeamTaskService taskService; + private TeamRunService runService; private TeamDispatchService dispatchService; private TeamEventChannel eventChannel; private ConversationService conversationService; @@ -53,17 +63,19 @@ class TeamTasksToolTest { void setUp() { teamService = mock(TeamService.class); taskService = mock(TeamTaskService.class); + runService = mock(TeamRunService.class); dispatchService = mock(TeamDispatchService.class); eventChannel = mock(TeamEventChannel.class); conversationService = mock(ConversationService.class); agentMapper = mock(AgentMapper.class); - tool = new TeamTasksTool(teamService, taskService, dispatchService, + tool = new TeamTasksTool(teamService, taskService, runService, dispatchService, eventChannel, conversationService, agentMapper); team = new AgentTeamEntity(); team.setId(TEAM_ID); team.setName("研发组"); team.setLeadAgentId(LEAD_ID); + team.setWorkspaceId(WORKSPACE_ID); ToolExecutionContext.set(CONV, "admin"); } @@ -77,6 +89,7 @@ class TeamTasksToolTest { ConversationEntity conv = new ConversationEntity(); conv.setConversationId(CONV); conv.setAgentId(agentId); + conv.setWorkspaceId(WORKSPACE_ID); when(conversationService.findByConversationId(CONV)).thenReturn(conv); when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team)); when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID)); @@ -94,8 +107,18 @@ class TeamTasksToolTest { } private String invoke(String action, String taskId) { - return tool.team_tasks(action, taskId, null, null, null, null, null, - null, null, null, null, null, null, null, null, null); + return tool.team_tasks(action, taskId, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null); + } + + private TeamRunEntity run(Long teamId, String conversationId, String status) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(RUN_ID); + run.setTeamId(teamId); + run.setWorkspaceId(WORKSPACE_ID); + run.setLeadConversationId(conversationId); + run.setStatus(status); + return run; } // ==================== context & membership gating ==================== @@ -113,6 +136,7 @@ class TeamTasksToolTest { ConversationEntity conv = new ConversationEntity(); conv.setConversationId(CONV); conv.setAgentId(99L); + conv.setWorkspaceId(WORKSPACE_ID); when(conversationService.findByConversationId(CONV)).thenReturn(conv); when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty()); @@ -123,7 +147,21 @@ class TeamTasksToolTest { @DisplayName("unknown action lists the valid ones") void unknownAction() { callerIs(LEAD_ID); - assertTrue(invoke("destroy", null).contains("unknown action")); + String output = invoke("destroy", null); + assertTrue(output.contains("unknown action")); + assertTrue(output.contains("start_run")); + assertTrue(output.contains("seal_run")); + } + + @Test + @DisplayName("a conversation without workspace context yields a structured error") + void missingWorkspaceError() { + ConversationEntity conv = new ConversationEntity(); + conv.setConversationId(CONV); + conv.setAgentId(LEAD_ID); + when(conversationService.findByConversationId(CONV)).thenReturn(conv); + + assertTrue(invoke("list", null).contains("workspaceId")); } // ==================== role gating ==================== @@ -133,8 +171,8 @@ class TeamTasksToolTest { void memberCannotCreate() { callerIs(MEMBER_ID); String out = tool.team_tasks("create", null, "subj", "desc", - String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, - null, null, null); + null, null, null, String.valueOf(MEMBER_ID), null, null, null, null, null, null, + null, null, null, null, null); assertTrue(out.contains("only the team lead can create")); verify(taskService, never()).createTask(any()); } @@ -150,21 +188,64 @@ class TeamTasksToolTest { verify(taskService, never()).retryTask(any()); } + @Test + @DisplayName("members cannot start or seal runs") + void memberCannotStartOrSealRuns() { + callerIs(MEMBER_ID); + + String start = tool.team_tasks("start_run", null, null, "Run", "Objective", + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + String seal = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertTrue(start.contains("only the team lead")); + assertTrue(seal.contains("only the team lead")); + verifyNoInteractions(runService); + } + + @Test + @DisplayName("start_run keeps the explicit origin id after later conversation activity") + void startRunUsesExplicitOriginMessage() { + callerIs(LEAD_ID); + when(runService.startRun(any())).thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); + ToolContext originalTurn = ChatOrigin.web(CONV, "admin", WORKSPACE_ID, null) + .withOriginMessageId(99L) + .toToolContext(); + + String output = tool.team_tasks("start_run", null, null, "Research", "Find evidence", + null, null, null, null, null, null, null, null, null, null, null, null, null, + originalTurn); + + assertEquals(String.valueOf(RUN_ID), output); + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamRunCreateCommand.class); + verify(runService).startRun(captor.capture()); + TeamRunCreateCommand command = captor.getValue(); + assertEquals(TEAM_ID, command.getTeamId()); + assertEquals(WORKSPACE_ID, command.getWorkspaceId()); + assertEquals(LEAD_ID, command.getLeadAgentId()); + assertEquals(CONV, command.getLeadConversationId()); + assertEquals(99L, command.getOriginMessageId()); + assertEquals("Research", command.getTitle()); + assertEquals("Find evidence", command.getObjective()); + } + // ==================== create pass-through ==================== @Test @DisplayName("lead create parses ids, wires the lead conversation and reports the assignee") void leadCreatePassesThrough() { callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING); when(taskService.createTask(any())).thenReturn(created); AgentEntity member = new AgentEntity(); member.setName("写手"); when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); - String out = tool.team_tasks("create", null, "collect data", "step details", - String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null, - null, null, null); + String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, + "collect data", "step details", String.valueOf(MEMBER_ID), "11,12", 5, + null, null, null, null, null, null, null, null, null); assertTrue(out.startsWith("✓ Created task #3")); assertTrue(out.contains("写手")); @@ -176,17 +257,20 @@ class TeamTasksToolTest { assertEquals(List.of(11L, 12L), cmd.getBlockedBy()); assertEquals(LEAD_ID, cmd.getCreatedByAgentId()); assertEquals(CONV, cmd.getLeadConversationId()); - verify(dispatchService).requestDispatch(TEAM_ID); + assertEquals(RUN_ID, cmd.getRunId()); + verify(dispatchService, never()).requestDispatch(any()); } @Test @DisplayName("creating a blocked task does not trigger a dispatch sweep") void blockedCreateDoesNotDispatch() { callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED); when(taskService.createTask(any())).thenReturn(blocked); - tool.team_tasks("create", null, "later step", null, + tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "later step", null, String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null, null, null, null); @@ -197,9 +281,11 @@ class TeamTasksToolTest { @DisplayName("lead create passes requireApproval through to the command") void createPassesRequireApproval() { callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); when(taskService.createTask(any())).thenReturn(task(52L, TeamTaskStatus.PENDING)); - tool.team_tasks("create", null, "publish notes", null, + tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "publish notes", null, String.valueOf(MEMBER_ID), null, null, true, null, null, null, null, null, null, null, null); @@ -209,6 +295,101 @@ class TeamTasksToolTest { assertTrue(captor.getValue().isRequireApproval()); } + @Test + @DisplayName("create requires an explicit run id") + void createRequiresRunId() { + callerIs(LEAD_ID); + + String output = tool.team_tasks("create", null, null, null, null, "subject", "details", + String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, + null, null, null); + + assertTrue(output.contains("runId is required")); + verify(taskService, never()).createTask(any()); + } + + @Test + @DisplayName("create rejects a run owned by another team") + void createRejectsForeignRun() { + callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(999L, CONV, TeamRunStatus.PLANNING)); + + String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, + "subject", "details", String.valueOf(MEMBER_ID), null, null, null, null, + null, null, null, null, null, null, null); + + assertTrue(output.contains("runId")); + verify(taskService, never()).createTask(any()); + } + + @Test + @DisplayName("create rejects a run owned by another lead conversation") + void createRejectsForeignConversationRun() { + callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, "other-conversation", TeamRunStatus.PLANNING)); + + String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, + "subject", "details", String.valueOf(MEMBER_ID), null, null, null, null, + null, null, null, null, null, null, null); + + assertTrue(output.contains("lead conversation")); + verify(taskService, never()).createTask(any()); + } + + @Test + @DisplayName("seal_run dispatches once only after the run is sealed") + void sealRunDispatchesAfterSeal() { + callerIs(LEAD_ID); + TeamRunEntity planning = run(TEAM_ID, CONV, TeamRunStatus.PLANNING); + TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planning); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(running, true)); + + String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertTrue(output.contains("sealed")); + InOrder order = inOrder(runService, dispatchService); + order.verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID); + order.verify(dispatchService).requestDispatch(TEAM_ID); + verify(dispatchService, times(1)).requestDispatch(TEAM_ID); + } + + @Test + @DisplayName("repeated seal_run does not dispatch again") + void repeatedSealRunDoesNotDispatch() { + callerIs(LEAD_ID); + TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenReturn(new TeamRunService.SealResult(running, false)); + + String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertTrue(output.contains("already sealed")); + verify(dispatchService, never()).requestDispatch(any()); + } + + @Test + @DisplayName("seal_run does not dispatch when sealing fails") + void sealRunFailureDoesNotDispatch() { + callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); + when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID)) + .thenThrow(new IllegalStateException("cannot seal a team run without tasks")); + + String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertTrue(output.startsWith("Error:")); + verify(dispatchService, never()).requestDispatch(any()); + } + @Test @DisplayName("lead cancel interrupts the running member conversation") void cancelInterruptsRun() { @@ -231,9 +412,11 @@ class TeamTasksToolTest { callerIs(LEAD_ID); when(taskService.createTask(any())) .thenThrow(new IllegalArgumentException("assignee is required")); - String out = tool.team_tasks("create", null, "s", null, - String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, - null, null, null); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); + String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, + "s", null, String.valueOf(MEMBER_ID), null, null, null, null, null, null, + null, null, null, null, null); assertEquals("Error: assignee is required", out); } @@ -249,8 +432,8 @@ class TeamTasksToolTest { assertTrue(invoke("complete", "5").startsWith("Error: result is required")); - String ok = tool.team_tasks("complete", "5", null, null, null, null, null, - null, "done, see report", null, null, null, null, null, null, null); + String ok = tool.team_tasks("complete", "5", null, null, null, null, null, null, null, + null, null, "done, see report", null, null, null, null, null, null, null); assertTrue(ok.contains("Released 1 dependent task(s)")); } @@ -262,8 +445,8 @@ class TeamTasksToolTest { when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT), anyString(), eq("blocker"), anyString())).thenReturn(true); - String out = tool.team_tasks("comment", "5", null, null, null, null, null, - null, null, null, null, "missing credentials", "blocker", null, null, null); + String out = tool.team_tasks("comment", "5", null, null, null, null, null, null, null, + null, null, null, null, null, "missing credentials", "blocker", null, null, null); assertTrue(out.contains("stop working")); } @@ -273,8 +456,8 @@ class TeamTasksToolTest { callerIs(MEMBER_ID); when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); - String out = tool.team_tasks("attach", "5", null, null, null, null, null, - null, null, null, null, null, null, + String out = tool.team_tasks("attach", "5", null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "report.docx", "/api/v1/files/generated/abc", null); assertTrue(out.startsWith("✓ Deliverable attached: report.docx")); diff --git a/mateclaw-ui/src/api/__tests__/teamRuns.test.ts b/mateclaw-ui/src/api/__tests__/teamRuns.test.ts new file mode 100644 index 00000000..a40d63a0 --- /dev/null +++ b/mateclaw-ui/src/api/__tests__/teamRuns.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { http, teamApi, teamRunApi } from '@/api/index' +import type { TeamRun } from '@/api/index' + +describe('teamRunApi', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the run lifecycle endpoints without numeric id coercion', () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({} as never) + const post = vi.spyOn(http, 'post').mockResolvedValue({} as never) + const runId = '9007199254740993' + const teamId = '9007199254740995' + const conversationId = 'lead/conversation' + + teamRunApi.get(runId) + teamRunApi.listByTeam(teamId) + teamRunApi.listByConversation(conversationId) + teamRunApi.cancel(runId, 'stop') + teamApi.createTask(teamId, { + runId, + subject: 'Task', + assigneeAgentId: '2', + }) + + expect(get).toHaveBeenNthCalledWith(1, `/team-runs/${runId}`) + expect(get).toHaveBeenNthCalledWith(2, `/teams/${teamId}/runs`) + expect(get).toHaveBeenNthCalledWith( + 3, + `/conversations/${encodeURIComponent(conversationId)}/team-runs`, + ) + expect(post).toHaveBeenCalledWith(`/team-runs/${runId}/cancel`, { reason: 'stop' }) + expect(post).toHaveBeenCalledWith(`/teams/${teamId}/tasks`, { + runId, + subject: 'Task', + assigneeAgentId: '2', + }) + }) + + it('models every run projection id as a string', () => { + const run = { + id: '9007199254740993', + teamId: '9007199254740995', + workspaceId: '30', + leadAgentId: '1', + leadConversationId: 'lead-conversation', + originMessageId: '9007199254740997', + title: 'Run', + objective: 'Objective', + status: 'running', + finalSummary: null, + stopReason: null, + metadata: null, + startedAt: null, + completedAt: null, + createTime: null, + updateTime: null, + progress: { total: 1, done: 0, failed: 0, inReview: 0, percent: 0 }, + tasks: [{ + id: '9007199254740999', + teamId: '9007199254740995', + runId: '9007199254740993', + taskNumber: 1, + subject: 'Task', + description: null, + status: 'pending', + priority: 0, + taskType: 'general', + assigneeAgentId: '2', + ownerAgentId: null, + blockedBy: '["9007199254740997"]', + requireApproval: false, + progressPercent: null, + progressStep: null, + result: null, + reason: null, + conversationId: null, + metadata: '{"planId":"9007199254740993"}', + createTime: null, + updateTime: null, + }], + } satisfies TeamRun + + expect(typeof run.id).toBe('string') + expect(typeof run.teamId).toBe('string') + expect(typeof run.tasks[0].id).toBe('string') + expect(typeof run.tasks[0].runId).toBe('string') + expect(run.tasks[0].blockedBy).toBe('["9007199254740997"]') + expect(run.tasks[0].metadata).toBe('{"planId":"9007199254740993"}') + }) +}) diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 8288c55c..d3ed8fc6 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -893,6 +893,7 @@ export interface TeamMemberVO { export interface TeamTask { id: string teamId: string + runId?: string | null taskNumber: number subject: string description: string | null @@ -924,6 +925,7 @@ export interface TeamTaskVO { task: TeamTask assigneeName: string | null ownerName: string | null + runId?: string | null } export interface TeamTaskComment { @@ -977,6 +979,7 @@ export const teamApi = { data: { subject: string description?: string + runId?: string assigneeAgentId: string priority?: number blockedBy?: string[] @@ -994,6 +997,78 @@ export const teamApi = { http.post(`/teams/${id}/tasks/${taskId}/comments`, { content }), } +export type TeamRunStatus = + | 'planning' + | 'running' + | 'awaiting_review' + | 'finalizing' + | 'completed' + | 'partial' + | 'failed' + | 'cancelled' + +export interface TeamRunProgress { + total: number + done: number + failed: number + inReview: number + percent: number +} + +export interface TeamRunTask { + id: string + teamId: string + runId: string + taskNumber: number + subject: string + description: string | null + status: string + priority: number + taskType: string + assigneeAgentId: string + ownerAgentId: string | null + blockedBy: string | null + requireApproval: boolean | null + progressPercent: number | null + progressStep: string | null + result: string | null + reason: string | null + conversationId: string | null + metadata: string | null + createTime: string | null + updateTime: string | null +} + +export interface TeamRun { + id: string + teamId: string + workspaceId: string + leadAgentId: string + leadConversationId: string + originMessageId: string | null + title: string + objective: string + status: TeamRunStatus + finalSummary: string | null + stopReason: string | null + metadata: string | null + startedAt: string | null + completedAt: string | null + createTime: string | null + updateTime: string | null + progress: TeamRunProgress + tasks: TeamRunTask[] +} + +export const teamRunApi = { + get: (runId: string) => http.get(`/team-runs/${runId}`), + listByTeam: (teamId: string) => http.get(`/teams/${teamId}/runs`), + listByConversation: (conversationId: string) => + http.get(`/conversations/${encId(conversationId)}/team-runs`), + cancel: (runId: string, reason?: string) => + http.post(`/team-runs/${runId}/cancel`, { reason }), +} + // ==================== Wiki Knowledge Base ==================== // One row in the cross-KB failure center. ids are strings (global Long→String // Jackson config) to avoid Snowflake precision loss. diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index 77043a45..1cc2a428 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -49,35 +49,46 @@ -