mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(team): harden long-running collaboration
This commit is contained in:
parent
03b6e9d2d8
commit
3424efc6a7
@ -501,7 +501,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
// triage LLM classifies the wake-up text. Mirrors the approval-replay
|
||||
// pattern: park in the DB, resume from the DB.
|
||||
if (teamPlanBridge != null) {
|
||||
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
|
||||
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId, persistGoal);
|
||||
if (parked instanceof TeamPlanBridge.Settled settled) {
|
||||
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
|
||||
settled.planId(), settled.completedResults().size());
|
||||
@ -590,6 +590,26 @@ public class PlanGenerationNode implements NodeAction {
|
||||
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
||||
}
|
||||
if (leadTeam != null) {
|
||||
List<String> missingNamedMembers = teamPlanBridge.namedAgentsOutsideRoster(
|
||||
leadTeam, persistGoal,
|
||||
listDelegatableAgents(chatOrigin.workspaceId(), agentId));
|
||||
if (!missingNamedMembers.isEmpty()) {
|
||||
String answer = "团队成员校验未通过:当前团队不包含「"
|
||||
+ String.join("、", missingNamedMembers)
|
||||
+ "」。请先将缺失的 Agent 加入团队后重试,或明确允许使用现有成员替代。";
|
||||
if (streamingHelper != null) {
|
||||
streamingHelper.broadcastContent(conversationId, answer);
|
||||
}
|
||||
log.info("[PlanGeneration] Team {} missing explicitly requested agents: {}",
|
||||
leadTeam.getId(), missingNamedMembers);
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer(answer)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
||||
.map(a -> "- " + a.getName()
|
||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||
@ -601,7 +621,9 @@ public class PlanGenerationNode implements NodeAction {
|
||||
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
|
||||
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。"
|
||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
||||
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。"));
|
||||
} else {
|
||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||
if (!delegatable.isEmpty()) {
|
||||
|
||||
@ -142,8 +142,16 @@ public class GlobalExceptionHandler {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (response.isCommitted() || isSseRequest(request)) {
|
||||
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
if (isExpectedClientDisconnect(e)) {
|
||||
// Browser reloads and tab closes routinely tear down the SSE
|
||||
// socket. This is transport lifecycle noise, not an
|
||||
// application warning, and should not page operators.
|
||||
log.debug("SSE client disconnected: {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
} else {
|
||||
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e);
|
||||
@ -167,6 +175,21 @@ public class GlobalExceptionHandler {
|
||||
return uri != null && uri.contains("/chat/stream");
|
||||
}
|
||||
|
||||
static boolean isExpectedClientDisconnect(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
String type = current.getClass().getName();
|
||||
String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase();
|
||||
if (type.endsWith("ClientAbortException")
|
||||
|| message.contains("broken pipe")
|
||||
|| message.contains("connection reset")
|
||||
|| message.contains("disconnected client")
|
||||
|| message.contains("connection aborted")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private HttpStatus httpStatusForCode(int code) {
|
||||
HttpStatus status = HttpStatus.resolve(code);
|
||||
return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
@ -55,19 +54,16 @@ import java.util.regex.Pattern;
|
||||
public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
|
||||
public OpenAiCompatibleChatModelBuilder(
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectMapper objectMapper,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
@ -490,17 +486,39 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
// ==================== logging ====================
|
||||
|
||||
private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) {
|
||||
try {
|
||||
log.info("OpenAI-compatible request: provider={}, body={}",
|
||||
provider.getProviderId(), objectMapper.writeValueAsString(chatRequest));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize OpenAI-compatible request for {}: {}",
|
||||
provider.getProviderId(), e.getMessage());
|
||||
}
|
||||
// Never log the request body: it contains system prompts, workspace
|
||||
// memory, user content and tool schemas. Besides leaking private
|
||||
// context, serializing it at INFO made long-running team jobs produce
|
||||
// multi-megabyte log lines. Cardinality-only diagnostics are enough to
|
||||
// correlate provider traffic without retaining payloads.
|
||||
log.debug("OpenAI-compatible request: provider={}, model={}, messages={}, tools={}, stream={}",
|
||||
provider.getProviderId(), chatRequest.model(),
|
||||
sizeOf(chatRequest.messages()), sizeOf(chatRequest.tools()), chatRequest.stream());
|
||||
}
|
||||
|
||||
private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) {
|
||||
log.error("OpenAI-compatible error: provider={}, status={}, body={}",
|
||||
provider.getProviderId(), e.getStatusCode(), e.getResponseBodyAsString());
|
||||
String body = e.getResponseBodyAsString();
|
||||
log.error("OpenAI-compatible error: provider={}, status={}, responseBytes={}",
|
||||
provider.getProviderId(), e.getStatusCode(), body == null ? 0 : body.length());
|
||||
if (log.isDebugEnabled() && body != null && !body.isBlank()) {
|
||||
log.debug("OpenAI-compatible error detail: provider={}, body={}",
|
||||
provider.getProviderId(), redactAndTruncate(body));
|
||||
}
|
||||
}
|
||||
|
||||
private static int sizeOf(java.util.Collection<?> values) {
|
||||
return values == null ? 0 : values.size();
|
||||
}
|
||||
|
||||
/** Defensive scrub for provider error bodies, which may echo request data. */
|
||||
static String redactAndTruncate(String body) {
|
||||
String redacted = body
|
||||
.replaceAll("(?i)(\\\"(?:api[_-]?key|authorization|token)\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")",
|
||||
"$1[REDACTED]$2")
|
||||
.replaceAll("(?i)((?:api[_-]?key|authorization|token)\\s*[=:]\\s*)[^,}\\s]+",
|
||||
"$1[REDACTED]")
|
||||
.replaceAll("(?i)(bearer\\s+)[A-Za-z0-9._~+\\-/=]+", "$1[REDACTED]");
|
||||
int max = 1024;
|
||||
return redacted.length() <= max ? redacted : redacted.substring(0, max) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,10 +142,11 @@ public class TeamController {
|
||||
public R<List<TaskVO>> listTasks(@PathVariable Long id,
|
||||
@RequestParam(required = false) List<String> status,
|
||||
@RequestParam(required = false) Integer limit,
|
||||
@RequestParam(required = false) Integer offset) {
|
||||
@RequestParam(required = false) Integer offset,
|
||||
@RequestParam(required = false) Long runId) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
List<TeamTaskEntity> tasks = taskService.listTasks(id, status, limit, offset);
|
||||
List<TeamTaskEntity> tasks = taskService.listTasks(id, status, limit, offset, runId);
|
||||
Set<Long> agentIds = tasks.stream()
|
||||
.flatMap(task -> java.util.stream.Stream.of(
|
||||
task.getAssigneeAgentId(), task.getOwnerAgentId()))
|
||||
@ -327,10 +328,11 @@ public class TeamController {
|
||||
@Operation(summary = "任务状态统计(看板列头)")
|
||||
@GetMapping("/{id}/tasks/stats")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<Map<String, Long>> taskStats(@PathVariable Long id) {
|
||||
public R<Map<String, Long>> taskStats(@PathVariable Long id,
|
||||
@RequestParam(required = false) Long runId) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
return R.ok(taskService.countByStatus(id));
|
||||
return R.ok(taskService.countByStatus(id, runId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,10 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Bridges the Plan-Execute graph onto the team task board. When a plan's
|
||||
@ -51,6 +54,7 @@ public class TeamPlanBridge {
|
||||
|
||||
/** Task subject cap; the full step text rides in the description. */
|
||||
static final int SUBJECT_MAX_CHARS = 120;
|
||||
private static final Pattern CHECKPOINT_TAG = Pattern.compile("(?i)(?:^|\\b)(R\\d{3})(?:\\b|/)");
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
@ -115,6 +119,29 @@ public class TeamPlanBridge {
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect enabled workspace agents explicitly named by the user but absent
|
||||
* from this team. Silently substituting another member violates the
|
||||
* requested roster and makes team runs look successful when a participant
|
||||
* never took part.
|
||||
*/
|
||||
public List<String> namedAgentsOutsideRoster(AgentTeamEntity team, String goal,
|
||||
List<AgentEntity> workspaceAgents) {
|
||||
if (goal == null || goal.isBlank() || workspaceAgents == null || workspaceAgents.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> memberIds = teamService.listMembers(team.getId()).stream()
|
||||
.map(AgentTeamMemberEntity::getAgentId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
return workspaceAgents.stream()
|
||||
.filter(agent -> agent.getId() != null && !memberIds.contains(agent.getId()))
|
||||
.filter(agent -> agent.getName() != null && !agent.getName().isBlank())
|
||||
.filter(agent -> goal.contains(agent.getName()))
|
||||
.map(AgentEntity::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ==================== hand-off ====================
|
||||
|
||||
/**
|
||||
@ -214,6 +241,14 @@ public class TeamPlanBridge {
|
||||
* progress snapshot.
|
||||
*/
|
||||
public ParkedPlanState checkParkedPlan(String conversationId) {
|
||||
return checkParkedPlan(conversationId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant that can honor a user's compact checkpoint response contract.
|
||||
* Normal status questions retain the detailed board snapshot.
|
||||
*/
|
||||
public ParkedPlanState checkParkedPlan(String conversationId, String currentMessage) {
|
||||
PlanEntity plan = planningService.findDelegatedPlan(conversationId);
|
||||
if (plan == null) {
|
||||
return new None();
|
||||
@ -236,7 +271,7 @@ public class TeamPlanBridge {
|
||||
.map(sub -> sub.getDescription())
|
||||
.toList();
|
||||
if (!allTerminal) {
|
||||
return new InFlight(buildProgressText(tasks));
|
||||
return new InFlight(buildProgressText(tasks, currentMessage));
|
||||
}
|
||||
List<String> results = settle(plan.getId(), tasks);
|
||||
finalizeRunWithFallback(teamOpt.get().getWorkspaceId(), tasks, results);
|
||||
@ -314,7 +349,28 @@ public class TeamPlanBridge {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildProgressText(List<TeamTaskEntity> tasks) {
|
||||
private String buildProgressText(List<TeamTaskEntity> tasks, String currentMessage) {
|
||||
String checkpointTag = checkpointTagOf(currentMessage);
|
||||
if (checkpointTag != null) {
|
||||
long completed = tasks.stream()
|
||||
.filter(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus()))
|
||||
.count();
|
||||
TeamTaskEntity active = tasks.stream()
|
||||
.filter(task -> !TeamTaskStatus.isTerminal(task.getStatus()))
|
||||
.findFirst()
|
||||
.orElse(tasks.get(tasks.size() - 1));
|
||||
StringBuilder compact = new StringBuilder(checkpointTag)
|
||||
.append("|执行中 ").append(completed).append('/').append(tasks.size())
|
||||
.append("|#").append(active.getTaskNumber()).append(' ')
|
||||
.append(active.getStatus());
|
||||
if (active.getProgressPercent() != null) {
|
||||
compact.append(' ').append(active.getProgressPercent()).append('%');
|
||||
}
|
||||
if ("R100".equalsIgnoreCase(checkpointTag)) {
|
||||
compact.append("(已完成第100轮检查点)");
|
||||
}
|
||||
return compact.toString();
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("计划仍在团队任务板上执行中:\n");
|
||||
for (TeamTaskEntity task : tasks) {
|
||||
sb.append("- #").append(task.getTaskNumber()).append(' ')
|
||||
@ -334,6 +390,18 @@ public class TeamPlanBridge {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static String checkpointTagOf(String message) {
|
||||
if (message == null || message.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String lower = message.toLowerCase();
|
||||
if (!message.contains("检查点") && !lower.contains("checkpoint")) {
|
||||
return null;
|
||||
}
|
||||
Matcher matcher = CHECKPOINT_TAG.matcher(message);
|
||||
return matcher.find() ? matcher.group(1).toUpperCase() : null;
|
||||
}
|
||||
|
||||
// ==================== helpers ====================
|
||||
|
||||
private static String subjectOf(String step) {
|
||||
|
||||
@ -654,8 +654,15 @@ public class TeamTaskService {
|
||||
*/
|
||||
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses,
|
||||
Integer limit, Integer offset) {
|
||||
return listTasks(teamId, statuses, limit, offset, null);
|
||||
}
|
||||
|
||||
/** Board query optionally scoped to one run to avoid mixing history. */
|
||||
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses,
|
||||
Integer limit, Integer offset, Long runId) {
|
||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
.eq(runId != null, TeamTaskEntity::getRunId, runId)
|
||||
.in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses)
|
||||
.orderByDesc(TeamTaskEntity::getPriority)
|
||||
.orderByDesc(TeamTaskEntity::getCreateTime)
|
||||
@ -666,10 +673,15 @@ public class TeamTaskService {
|
||||
|
||||
/** Per-status task counts for the board header, computed in the database. */
|
||||
public Map<String, Long> countByStatus(Long teamId) {
|
||||
return countByStatus(teamId, null);
|
||||
}
|
||||
|
||||
public Map<String, Long> countByStatus(Long teamId, Long runId) {
|
||||
Map<String, Long> counts = new HashMap<>();
|
||||
taskMapper.selectMaps(Wrappers.<TeamTaskEntity>query()
|
||||
.select("status", "count(*) as cnt")
|
||||
.eq("team_id", teamId)
|
||||
.eq(runId != null, "run_id", runId)
|
||||
.eq("deleted", 0)
|
||||
.groupBy("status"))
|
||||
.forEach(row -> counts.put(String.valueOf(row.get("status")),
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<configuration scan="true" scanPeriod="30 seconds">
|
||||
|
||||
<!-- ======================== 属性定义 ======================== -->
|
||||
<property name="LOG_HOME" value="./logs"/>
|
||||
<!-- Override with MATECLAW_LOG_HOME so IDE, CLI and packaged launches
|
||||
converge on one explicit directory instead of silently writing to
|
||||
different cwd-relative locations. -->
|
||||
<property name="LOG_HOME" value="${MATECLAW_LOG_HOME:-./logs}"/>
|
||||
<property name="APP_NAME" value="mateclaw"/>
|
||||
<!-- 保留天数 -->
|
||||
<property name="MAX_HISTORY" value="30"/>
|
||||
|
||||
@ -7,6 +7,8 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.i18n.I18nService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ -36,4 +38,16 @@ class GlobalExceptionHandlerTest {
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
assertEquals(500, response.getBody().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expectedSseDisconnectsAreRecognizedThroughCauseChain() {
|
||||
RuntimeException wrapped = new RuntimeException("wrapper",
|
||||
new java.io.IOException("Servlet container error notification for disconnected client"));
|
||||
|
||||
assertTrue(GlobalExceptionHandler.isExpectedClientDisconnect(wrapped));
|
||||
assertTrue(GlobalExceptionHandler.isExpectedClientDisconnect(
|
||||
new java.io.IOException("Broken pipe")));
|
||||
assertFalse(GlobalExceptionHandler.isExpectedClientDisconnect(
|
||||
new IllegalStateException("unexpected projector failure")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@ -58,7 +57,6 @@ class OpenAiCompatibleChatModelBuilderTest {
|
||||
// exercises the HTTP-client-construction path.
|
||||
builder = new OpenAiCompatibleChatModelBuilder(
|
||||
modelProviderService,
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
@ -66,6 +64,21 @@ class OpenAiCompatibleChatModelBuilderTest {
|
||||
provider.setProviderId("test-openai-compatible");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Provider error payloads redact credentials and stay bounded")
|
||||
void providerErrorPayloadIsRedactedAndTruncated() {
|
||||
String secret = "Bearer abc.def.ghi";
|
||||
String payload = "{\"authorization\":\"" + secret + "\",\"api_key\":\"sk-private\",\"detail\":\""
|
||||
+ "x".repeat(2000) + "\"}";
|
||||
|
||||
String safe = OpenAiCompatibleChatModelBuilder.redactAndTruncate(payload);
|
||||
|
||||
assertFalse(safe.contains("abc.def.ghi"));
|
||||
assertFalse(safe.contains("sk-private"));
|
||||
assertTrue(safe.contains("[REDACTED]"));
|
||||
assertTrue(safe.length() <= 1025);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearHolder() {
|
||||
ThinkingLevelHolder.clear();
|
||||
|
||||
@ -181,7 +181,7 @@ class TeamControllerTest {
|
||||
third.setId(103L);
|
||||
third.setAssigneeAgentId(13L);
|
||||
third.setOwnerAgentId(null);
|
||||
when(taskService.listTasks(TEAM_ID, null, null, null)).thenReturn(List.of(first, second, third));
|
||||
when(taskService.listTasks(TEAM_ID, null, null, null, null)).thenReturn(List.of(first, second, third));
|
||||
AgentEntity assignee = new AgentEntity();
|
||||
assignee.setId(11L);
|
||||
assignee.setName("Assignee");
|
||||
@ -193,7 +193,7 @@ class TeamControllerTest {
|
||||
shared.setName("Shared");
|
||||
when(agentMapper.selectBatchIds(any())).thenReturn(List.of(assignee, owner, shared));
|
||||
|
||||
R<List<TeamController.TaskVO>> response = controller.listTasks(TEAM_ID, null, null, null);
|
||||
R<List<TeamController.TaskVO>> response = controller.listTasks(TEAM_ID, null, null, null, null);
|
||||
|
||||
assertEquals(List.of("Assignee", "Assignee", "Shared"),
|
||||
response.getData().stream().map(TeamController.TaskVO::assigneeName).toList());
|
||||
@ -423,11 +423,11 @@ class TeamControllerTest {
|
||||
void crossWorkspaceTeamIsRejectedBeforeTaskBoardRead() {
|
||||
when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(null);
|
||||
|
||||
R<List<TeamController.TaskVO>> r = controller.listTasks(TEAM_ID, null, null, null);
|
||||
R<List<TeamController.TaskVO>> r = controller.listTasks(TEAM_ID, null, null, null, null);
|
||||
|
||||
assertEquals(500, r.getCode());
|
||||
assertEquals("team not found: 1", r.getMsg());
|
||||
verify(taskService, never()).listTasks(anyLong(), any(), any(), any());
|
||||
verify(taskService, never()).listTasks(anyLong(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -439,7 +439,7 @@ class TeamControllerTest {
|
||||
assertRole("delete", "admin", Long.class);
|
||||
assertRole("addMember", "admin", Long.class, TeamController.MemberRequest.class);
|
||||
assertRole("removeMember", "admin", Long.class, Long.class);
|
||||
assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class);
|
||||
assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class, Long.class);
|
||||
assertRole("getTask", "viewer", Long.class, Long.class);
|
||||
assertRole("createTask", "admin", Long.class, TeamController.CreateTaskRequest.class, java.security.Principal.class);
|
||||
assertRole("approve", "admin", Long.class, Long.class, java.security.Principal.class);
|
||||
@ -449,7 +449,7 @@ class TeamControllerTest {
|
||||
assertRole("taskEvents", "viewer", Long.class, Long.class);
|
||||
assertRole("events", "viewer", Long.class, Long.class);
|
||||
assertRole("comment", "admin", Long.class, Long.class, TeamController.CommentRequest.class, java.security.Principal.class);
|
||||
assertRole("taskStats", "viewer", Long.class);
|
||||
assertRole("taskStats", "viewer", Long.class, Long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -122,6 +122,18 @@ class TeamPlanBridgeTest {
|
||||
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicitly requested workspace agents missing from the team are reported")
|
||||
void reportsNamedAgentsOutsideRoster() {
|
||||
AgentEntity general = agent(4L, "通用助手");
|
||||
|
||||
assertEquals(List.of("通用助手"), bridge.namedAgentsOutsideRoster(
|
||||
team, "请让写手、分析师和通用助手共同完成", List.of(
|
||||
agent(WRITER_ID, "写手"), agent(ANALYST_ID, "分析师"), general)));
|
||||
assertTrue(bridge.namedAgentsOutsideRoster(
|
||||
team, "请让写手和分析师共同完成", List.of(general)).isEmpty());
|
||||
}
|
||||
|
||||
// ==================== hand-off ====================
|
||||
|
||||
@Test
|
||||
@ -274,6 +286,26 @@ class TeamPlanBridgeTest {
|
||||
verify(planningService, never()).updateSubPlanResult(any(), anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("checkpoint status requests get a single-line deterministic response")
|
||||
void compactCheckpointProgress() {
|
||||
parkedPlan();
|
||||
TeamTaskEntity completed = task(101L, 46, 0, TeamTaskStatus.COMPLETED);
|
||||
TeamTaskEntity active = task(102L, 47, 1, TeamTaskStatus.IN_PROGRESS);
|
||||
active.setProgressPercent(80);
|
||||
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(completed, active));
|
||||
|
||||
TeamPlanBridge.InFlight state = assertInstanceOf(TeamPlanBridge.InFlight.class,
|
||||
bridge.checkParkedPlan(CONV,
|
||||
"R100/100 最终检查点:仅用一行回复,并确认已连续完成100轮"));
|
||||
|
||||
assertEquals("R100|执行中 1/2|#47 in_progress 80%(已完成第100轮检查点)",
|
||||
state.progressText());
|
||||
assertFalse(state.progressText().contains("\n"));
|
||||
assertEquals("R002", TeamPlanBridge.checkpointTagOf("R002/100 checkpoint"));
|
||||
assertNull(TeamPlanBridge.checkpointTagOf("R002 ordinary status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a settled board syncs the sub-plan mirror and returns summary-ready results")
|
||||
void gateSettled() {
|
||||
|
||||
@ -966,15 +966,18 @@ export const teamApi = {
|
||||
addMember: (id: string, agentId: string, role: string) =>
|
||||
http.post(`/teams/${id}/members`, { agentId, role }),
|
||||
removeMember: (id: string, agentId: string) => http.delete(`/teams/${id}/members/${agentId}`),
|
||||
listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number }) =>
|
||||
listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number; runId?: string }) =>
|
||||
http.get(`/teams/${id}/tasks`, {
|
||||
params: {
|
||||
...(status?.length ? { status: status.join(',') } : {}),
|
||||
...(opts?.limit != null ? { limit: opts.limit } : {}),
|
||||
...(opts?.offset != null ? { offset: opts.offset } : {}),
|
||||
...(opts?.runId ? { runId: opts.runId } : {}),
|
||||
},
|
||||
}),
|
||||
taskStats: (id: string) => http.get(`/teams/${id}/tasks/stats`),
|
||||
taskStats: (id: string, runId?: string) => http.get(`/teams/${id}/tasks/stats`, {
|
||||
params: runId ? { runId } : {},
|
||||
}),
|
||||
getTask: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}`),
|
||||
createTask: (
|
||||
id: string,
|
||||
|
||||
@ -15,6 +15,8 @@ export default {
|
||||
back: 'Back',
|
||||
runs: 'Runs',
|
||||
board: 'Task Board',
|
||||
boardScope: 'Task board run scope',
|
||||
boardAllRuns: 'All historical tasks',
|
||||
members: 'Members',
|
||||
addMember: 'Add Member',
|
||||
memberName: 'Member',
|
||||
@ -73,6 +75,7 @@ export default {
|
||||
column: {
|
||||
todo: 'To Do',
|
||||
in_progress: 'In Progress',
|
||||
settling: 'Finalizing',
|
||||
in_review: 'In Review',
|
||||
completed: 'Completed',
|
||||
closed: 'Closed',
|
||||
@ -158,6 +161,7 @@ export default {
|
||||
loading: 'Loading...',
|
||||
processing: 'Processing...',
|
||||
success: 'Done',
|
||||
failed: 'Operation failed',
|
||||
revoked: 'Revoked',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
|
||||
@ -15,6 +15,8 @@ export default {
|
||||
back: '返回',
|
||||
runs: '运行记录',
|
||||
board: '任务板',
|
||||
boardScope: '任务板运行范围',
|
||||
boardAllRuns: '全部历史任务',
|
||||
members: '成员',
|
||||
addMember: '添加成员',
|
||||
memberName: '成员',
|
||||
@ -73,6 +75,7 @@ export default {
|
||||
column: {
|
||||
todo: '待处理',
|
||||
in_progress: '进行中',
|
||||
settling: '正在结算',
|
||||
in_review: '待审核',
|
||||
completed: '已完成',
|
||||
closed: '已终止',
|
||||
@ -158,6 +161,7 @@ export default {
|
||||
loading: '加载中...',
|
||||
processing: '处理中...',
|
||||
success: '操作成功',
|
||||
failed: '操作失败',
|
||||
revoked: '已撤销',
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
|
||||
@ -89,4 +89,21 @@ describe('useTeamStore request generation', () => {
|
||||
expect(store.tasks).toEqual([])
|
||||
expect(store.taskStats).toEqual({})
|
||||
})
|
||||
|
||||
it('scopes every board request to the selected run', async () => {
|
||||
api.get.mockResolvedValue(detail('A'))
|
||||
api.listTasks.mockResolvedValue({ data: [] })
|
||||
api.taskStats.mockResolvedValue({ data: {} })
|
||||
const store = useTeamStore()
|
||||
await store.openTeam('A')
|
||||
vi.clearAllMocks()
|
||||
|
||||
await store.setTaskRunId('A', '9007199254740993')
|
||||
|
||||
expect(api.listTasks).toHaveBeenCalledTimes(3)
|
||||
for (const call of api.listTasks.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ runId: '9007199254740993' })
|
||||
}
|
||||
expect(api.taskStats).toHaveBeenCalledWith('A', '9007199254740993')
|
||||
})
|
||||
})
|
||||
|
||||
@ -20,6 +20,8 @@ export const useTeamStore = defineStore('team', () => {
|
||||
const currentTeam = ref<TeamVO | null>(null)
|
||||
const members = ref<TeamMemberVO[]>([])
|
||||
const boardLoading = ref(false)
|
||||
/** Null = aggregate history; otherwise the board is scoped to one run. */
|
||||
const taskRunId = ref<string | null>(null)
|
||||
|
||||
/** Statuses that mean the board is still moving and worth polling. */
|
||||
const ACTIVE_STATUSES = ['pending', 'in_progress', 'in_review', 'blocked']
|
||||
@ -76,6 +78,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
completedTasks.value = []
|
||||
closedTasks.value = []
|
||||
taskStats.value = {}
|
||||
taskRunId.value = null
|
||||
await fetchTasks(teamId, generation)
|
||||
}
|
||||
|
||||
@ -88,6 +91,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
completedTasks.value = []
|
||||
closedTasks.value = []
|
||||
taskStats.value = {}
|
||||
taskRunId.value = null
|
||||
boardLoading.value = false
|
||||
}
|
||||
|
||||
@ -103,10 +107,10 @@ export const useTeamStore = defineStore('team', () => {
|
||||
const completedLimit = Math.max(TERMINAL_PAGE, completedTasks.value.length)
|
||||
const closedLimit = Math.max(TERMINAL_PAGE, closedTasks.value.length)
|
||||
const [active, completed, closed, stats] = (await Promise.all([
|
||||
teamApi.listTasks(teamId, ACTIVE_STATUSES),
|
||||
teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: completedLimit, offset: 0 }),
|
||||
teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: closedLimit, offset: 0 }),
|
||||
teamApi.taskStats(teamId),
|
||||
teamApi.listTasks(teamId, ACTIVE_STATUSES, { runId: taskRunId.value ?? undefined }),
|
||||
teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: completedLimit, offset: 0, runId: taskRunId.value ?? undefined }),
|
||||
teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: closedLimit, offset: 0, runId: taskRunId.value ?? undefined }),
|
||||
teamApi.taskStats(teamId, taskRunId.value ?? undefined),
|
||||
])) as any[]
|
||||
if (expectedGeneration !== teamGeneration
|
||||
|| requestSequence !== boardRequestSequence
|
||||
@ -131,6 +135,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
const res: any = await teamApi.listTasks(teamId, COMPLETED_STATUSES, {
|
||||
limit: TERMINAL_PAGE,
|
||||
offset: completedTasks.value.length,
|
||||
runId: taskRunId.value ?? undefined,
|
||||
})
|
||||
if (generation !== teamGeneration || String(currentTeam.value?.team.id ?? '') !== teamId) return
|
||||
completedTasks.value = [...completedTasks.value, ...(res.data || [])]
|
||||
@ -141,6 +146,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
const res: any = await teamApi.listTasks(teamId, CLOSED_STATUSES, {
|
||||
limit: TERMINAL_PAGE,
|
||||
offset: closedTasks.value.length,
|
||||
runId: taskRunId.value ?? undefined,
|
||||
})
|
||||
if (generation !== teamGeneration || String(currentTeam.value?.team.id ?? '') !== teamId) return
|
||||
closedTasks.value = [...closedTasks.value, ...(res.data || [])]
|
||||
@ -156,6 +162,16 @@ export const useTeamStore = defineStore('team', () => {
|
||||
await fetchTeams()
|
||||
}
|
||||
|
||||
async function setTaskRunId(teamId: string, runId: string | null) {
|
||||
if (taskRunId.value === runId) return
|
||||
taskRunId.value = runId
|
||||
activeTasks.value = []
|
||||
completedTasks.value = []
|
||||
closedTasks.value = []
|
||||
taskStats.value = {}
|
||||
await fetchTasks(teamId)
|
||||
}
|
||||
|
||||
async function deleteTeam(teamId: string) {
|
||||
await teamApi.delete(teamId)
|
||||
if (currentTeam.value?.team.id === teamId) {
|
||||
@ -172,6 +188,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
tasks,
|
||||
taskStats,
|
||||
boardLoading,
|
||||
taskRunId,
|
||||
hasActiveTasks,
|
||||
completedTotal,
|
||||
closedTotal,
|
||||
@ -183,6 +200,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
fetchTasks,
|
||||
loadMoreCompleted,
|
||||
loadMoreClosed,
|
||||
setTaskRunId,
|
||||
createTeam,
|
||||
deleteTeam,
|
||||
}
|
||||
|
||||
@ -97,6 +97,18 @@
|
||||
@click="setActiveTab('members')"
|
||||
>{{ t('teams.members') }}</button>
|
||||
</div>
|
||||
<select
|
||||
v-if="activeTab === 'board'"
|
||||
class="form-input board-run-filter"
|
||||
:aria-label="t('teams.boardScope')"
|
||||
:value="store.taskRunId || ''"
|
||||
@change="changeBoardRunFilter"
|
||||
>
|
||||
<option value="">{{ t('teams.boardAllRuns') }}</option>
|
||||
<option v-for="run in runHistory.runs.value" :key="run.id" :value="run.id">
|
||||
{{ run.title }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="activeTab === 'board'"
|
||||
class="btn-primary"
|
||||
@ -175,6 +187,10 @@
|
||||
>
|
||||
<div class="task-card__progress-bar" :style="{ width: vo.task.progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="vo.task.status === 'in_progress' && vo.task.progressPercent === 100"
|
||||
class="task-card__settling"
|
||||
>{{ t('teams.status.settling') }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="col.hasMore"
|
||||
@ -828,6 +844,8 @@ watch(
|
||||
if (!routeIsCurrent()) return
|
||||
await runHistory.open(state.teamId)
|
||||
if (!routeIsCurrent()) return
|
||||
await store.setTaskRunId(state.teamId, runHistory.runs.value[0]?.id ?? null)
|
||||
if (!routeIsCurrent()) return
|
||||
}
|
||||
activeTab.value = state.view ?? 'runs'
|
||||
runHistory.select(reconciliation.selectedRunId, reconciliation.selectedTaskId)
|
||||
@ -894,11 +912,12 @@ async function openTeam(teamId: string) {
|
||||
try {
|
||||
await store.openTeam(teamId)
|
||||
await runHistory.open(teamId)
|
||||
await store.setTaskRunId(teamId, runHistory.runs.value[0]?.id ?? null)
|
||||
activeTab.value = 'runs'
|
||||
runHistory.select(null)
|
||||
await router.push({ path: '/teams', query: buildTeamsRouteQuery(teamId) })
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || 'failed')
|
||||
ElMessage.error(e?.response?.data?.msg || e?.msg || e?.message || t('common.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -949,6 +968,12 @@ function refreshBoard() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function changeBoardRunFilter(event: Event) {
|
||||
if (!store.currentTeam) return
|
||||
const value = (event.target as HTMLSelectElement).value || null
|
||||
await store.setTaskRunId(String(store.currentTeam.team.id), value)
|
||||
}
|
||||
|
||||
function refreshCurrentView() {
|
||||
return activeTab.value === 'runs' ? runHistory.refresh() : refreshBoard()
|
||||
}
|
||||
@ -1610,6 +1635,11 @@ async function cancelTask() {
|
||||
color: var(--mc-text-primary);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.board-run-filter {
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ==================== kanban board ==================== */
|
||||
|
||||
@ -1776,6 +1806,11 @@ async function cancelTask() {
|
||||
background: var(--mc-primary);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.task-card__settling {
|
||||
margin-top: 5px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ==================== members panel ==================== */
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user