fix(team): harden long-running collaboration

This commit is contained in:
mateaix 2026-08-16 13:14:16 +08:00
parent 03b6e9d2d8
commit 3424efc6a7
17 changed files with 327 additions and 39 deletions

View File

@ -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()) {

View File

@ -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;

View File

@ -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) + "";
}
}

View File

@ -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));
});
}

View File

@ -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) {

View File

@ -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")),

View File

@ -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"/>

View File

@ -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")));
}
}

View File

@ -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();

View File

@ -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

View File

@ -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() {

View File

@ -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,

View File

@ -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',

View File

@ -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: '停用',

View File

@ -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')
})
})

View File

@ -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,
}

View File

@ -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 ==================== */