feat(team): collaboration observability — live event channel, task timeline, prerequisite hand-off and readable validation errors

This commit is contained in:
mateaix 2026-07-25 19:57:57 +08:00
parent 251a3288dd
commit c15e51b34b
21 changed files with 1201 additions and 95 deletions

View File

@ -10,18 +10,22 @@ import vip.mate.agent.repository.AgentMapper;
import vip.mate.common.result.R;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskEventEntity;
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.TeamService;
import vip.mate.team.service.TeamTaskService;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
@ -44,6 +48,7 @@ public class TeamController {
private final TeamTaskService taskService;
private final TeamDispatchService dispatchService;
private final TeamAnnounceService announceService;
private final TeamEventChannel eventChannel;
private final AgentMapper agentMapper;
// ==================== team CRUD ====================
@ -77,24 +82,28 @@ public class TeamController {
@Operation(summary = "创建团队")
@PostMapping
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
req.getLeadAgentId(), req.getMemberAgentIds(),
principal != null ? principal.getName() : "admin");
return R.ok(toVO(team));
return guarded(() -> {
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
req.getLeadAgentId(), req.getMemberAgentIds(),
principal != null ? principal.getName() : "admin");
return R.ok(toVO(team));
});
}
@Operation(summary = "更新团队")
@PutMapping("/{id}")
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
return R.ok(toVO(teamService.updateTeam(id, req.getName(), req.getDescription(),
req.getSettings())));
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(),
req.getDescription(), req.getSettings()))));
}
@Operation(summary = "删除团队")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
teamService.deleteTeam(id);
return R.ok(null);
return guarded(() -> {
teamService.deleteTeam(id);
return R.ok(null);
});
}
// ==================== membership ====================
@ -102,15 +111,19 @@ public class TeamController {
@Operation(summary = "添加成员")
@PostMapping("/{id}/members")
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
teamService.addMember(id, req.getAgentId(), req.getRole());
return R.ok(null);
return guarded(() -> {
teamService.addMember(id, req.getAgentId(), req.getRole());
return R.ok(null);
});
}
@Operation(summary = "移除成员")
@DeleteMapping("/{id}/members/{agentId}")
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
teamService.removeMember(id, agentId);
return R.ok(null);
return guarded(() -> {
teamService.removeMember(id, agentId);
return R.ok(null);
});
}
// ==================== task board ====================
@ -125,89 +138,146 @@ public class TeamController {
@Operation(summary = "任务详情(含评论)")
@GetMapping("/{id}/tasks/{taskId}")
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
TeamTaskEntity task = requireTask(id, taskId);
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
return guarded(() -> {
TeamTaskEntity task = requireTask(id, taskId);
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
});
}
@Operation(summary = "手动创建任务")
@PostMapping("/{id}/tasks")
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
Principal principal) {
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(id)
.subject(req.getSubject())
.description(req.getDescription())
.assigneeAgentId(req.getAssigneeAgentId())
.priority(req.getPriority())
.blockedBy(req.getBlockedBy())
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
.username(principal != null ? principal.getName() : null)
.channel("dashboard")
.build());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(task));
return guarded(() -> {
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(id)
.subject(req.getSubject())
.description(req.getDescription())
.assigneeAgentId(req.getAssigneeAgentId())
.priority(req.getPriority())
.blockedBy(req.getBlockedBy())
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
.username(principal != null ? principal.getName() : null)
.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));
});
}
@Operation(summary = "批准 in_review 任务")
@PostMapping("/{id}/tasks/{taskId}/approve")
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId) {
requireTask(id, taskId);
List<Long> released = taskService.approveTask(taskId);
if (!released.isEmpty()) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(taskService.getTask(taskId)));
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId,
Principal principal) {
return guarded(() -> {
requireTask(id, taskId);
List<Long> released = taskService.approveTask(taskId);
recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null);
publishBoardEvent(taskId, "team_task_approved");
if (!released.isEmpty()) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(taskService.getTask(taskId)));
});
}
@Operation(summary = "驳回 in_review 任务")
@PostMapping("/{id}/tasks/{taskId}/reject")
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req) {
requireTask(id, taskId);
taskService.rejectTask(taskId, req == null ? null : req.getReason());
TeamTaskEntity task = taskService.getTask(taskId);
// The lead must hear about the rejection to re-plan or retry.
announceService.announceTaskSettled(task);
dispatchService.requestDispatch(id);
return R.ok(toTaskVO(task));
@RequestBody(required = false) ReasonRequest req,
Principal principal) {
return guarded(() -> {
requireTask(id, taskId);
taskService.rejectTask(taskId, req == null ? null : req.getReason());
recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal,
req == null ? null : req.getReason());
publishBoardEvent(taskId, "team_task_rejected");
TeamTaskEntity task = taskService.getTask(taskId);
// The lead must hear about the rejection to re-plan or retry.
announceService.announceTaskSettled(task);
dispatchService.requestDispatch(id);
return R.ok(toTaskVO(task));
});
}
@Operation(summary = "重试 failed/stale 任务")
@PostMapping("/{id}/tasks/{taskId}/retry")
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId) {
requireTask(id, taskId);
if (!taskService.retryTask(taskId)) {
return R.fail("only failed or stale tasks can be retried");
}
dispatchService.requestDispatch(id);
return R.ok(toTaskVO(taskService.getTask(taskId)));
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId,
Principal principal) {
return guarded(() -> {
requireTask(id, taskId);
if (!taskService.retryTask(taskId)) {
return R.fail("only failed or stale tasks can be retried");
}
recordUserEvent(id, taskId, TeamTaskEventEntity.RETRIED, principal, null);
publishBoardEvent(taskId, "team_task_retried");
dispatchService.requestDispatch(id);
return R.ok(toTaskVO(taskService.getTask(taskId)));
});
}
@Operation(summary = "取消任务")
@PostMapping("/{id}/tasks/{taskId}/cancel")
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req) {
TeamTaskEntity task = requireTask(id, taskId);
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
// Stop the member run mid-flight instead of letting it burn to the end.
dispatchService.interruptRun(task);
if (!released.isEmpty()) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(taskService.getTask(taskId)));
@RequestBody(required = false) ReasonRequest req,
Principal principal) {
return guarded(() -> {
TeamTaskEntity task = requireTask(id, taskId);
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal,
req == null ? null : req.getReason());
publishBoardEvent(taskId, "team_task_cancelled");
// Stop the member run mid-flight instead of letting it burn to the end.
dispatchService.interruptRun(task);
if (!released.isEmpty()) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(taskService.getTask(taskId)));
});
}
@Operation(summary = "任务时间线")
@GetMapping("/{id}/tasks/{taskId}/events")
public R<List<TeamTaskEventEntity>> taskEvents(@PathVariable Long id, @PathVariable Long taskId) {
return guarded(() -> {
requireTask(id, taskId);
return R.ok(taskService.listEvents(taskId));
});
}
@Operation(summary = "团队事件流SSE")
@GetMapping("/{id}/events")
public SseEmitter events(@PathVariable Long id,
@RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) {
SseEmitter emitter = new SseEmitter(0L);
eventChannel.attach(id, emitter, lastEventId == null ? 0L : lastEventId);
return emitter;
}
private void recordUserEvent(Long teamId, Long taskId, String eventType,
Principal principal, String detail) {
taskService.recordEvent(teamId, taskId, eventType, TeamTaskService.AUTHOR_USER,
principal != null ? principal.getName() : null, detail);
}
private void publishBoardEvent(Long taskId, String event) {
eventChannel.publishTaskEvent(taskService.getTask(taskId), event, Map.of());
}
@Operation(summary = "添加评论")
@PostMapping("/{id}/tasks/{taskId}/comments")
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody CommentRequest req, Principal principal) {
requireTask(id, taskId);
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
principal != null ? principal.getName() : "admin",
TeamTaskService.COMMENT_NOTE, req.getContent());
return R.ok(null);
return guarded(() -> {
requireTask(id, taskId);
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
principal != null ? principal.getName() : "admin",
TeamTaskService.COMMENT_NOTE, req.getContent());
return R.ok(null);
});
}
@Operation(summary = "任务状态统计(看板列头)")
@ -219,6 +289,20 @@ public class TeamController {
// ==================== helpers / DTOs ====================
/**
* Runs an endpoint body whose service layer reports validation verdicts
* (unknown assignee, wrong task status, cross-team task id) via
* IllegalArgumentException / IllegalStateException. Those must reach the
* client as readable text in the R envelope, not the catch-all 500 handler.
*/
private <T> R<T> guarded(Supplier<R<T>> action) {
try {
return action.get();
} catch (IllegalArgumentException | IllegalStateException e) {
return R.fail(e.getMessage());
}
}
private TeamTaskEntity requireTask(Long teamId, Long taskId) {
TeamTaskEntity task = taskService.getTask(taskId);
if (task == null || !task.getTeamId().equals(teamId)) {

View File

@ -0,0 +1,64 @@
package vip.mate.team.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* One moment in a team task's lifecycle (created, dispatched, progress,
* comment, deliverable, settlement, approval actions). Append-only side
* channel rendered as the task's collaboration timeline; recording failures
* never affect the task itself.
*
* @author MateClaw Team
*/
@Data
@TableName("mate_team_task_event")
public class TeamTaskEventEntity {
// event_type values; kept as plain constants (no enum) so new moments can
// be recorded without a schema or code migration.
public static final String CREATED = "created";
public static final String DISPATCHED = "dispatched";
public static final String PROGRESS = "progress";
public static final String COMMENT = "comment";
public static final String BLOCKER = "blocker";
public static final String DELIVERABLE = "deliverable";
public static final String COMPLETED = "completed";
public static final String IN_REVIEW = "in_review";
public static final String FAILED = "failed";
public static final String CANCELLED = "cancelled";
public static final String APPROVED = "approved";
public static final String REJECTED = "rejected";
public static final String RETRIED = "retried";
public static final String STALE = "stale";
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Denormalized team id for team-level activity queries. */
private Long teamId;
private Long taskId;
private String eventType;
/** Actor kind: agent / user / system. */
private String actorType;
/** Agent id or username depending on actorType; null for system moments. */
private String actorId;
/** Human-readable one-liner: progress step, failure reason, file name… */
private String detail;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,12 @@
package vip.mate.team.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import vip.mate.team.model.TeamTaskEventEntity;
/**
* Mapper for the team task event timeline.
*
* @author MateClaw Team
*/
public interface TeamTaskEventMapper extends BaseMapper<TeamTaskEventEntity> {
}

View File

@ -9,6 +9,7 @@ import vip.mate.agent.AgentService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskEventEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService;
@ -71,6 +72,7 @@ public class TeamDispatchService {
private final ConversationService conversationService;
private final ChatStreamTracker streamTracker;
private final TeamAnnounceService announceService;
private final TeamEventChannel eventChannel;
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
@ -125,6 +127,8 @@ public class TeamDispatchService {
if (!taskService.assignTask(task.getId(), assignee)) {
continue; // another sweep won the race, or status moved on
}
taskService.recordEvent(teamId, task.getId(), TeamTaskEventEntity.DISPATCHED,
TeamTaskService.AUTHOR_SYSTEM, null, "agent " + assignee);
if (!taskService.tryAcquireDispatch(task.getId())) {
// Circuit breaker tripped; the task was auto-failed the lead
// must hear about it or the work silently disappears.
@ -252,6 +256,10 @@ public class TeamDispatchService {
announceService.announceTaskSettled(current);
}
/** Per-prerequisite and whole-section caps keeping the envelope bounded. */
static final int MAX_PREREQ_RESULT_CHARS = 1500;
static final int MAX_PREREQ_SECTION_CHARS = 6000;
/** The full instruction envelope the member receives; it cannot see the lead's conversation. */
private String buildDispatchContent(TeamTaskEntity task) {
StringBuilder sb = new StringBuilder(1024);
@ -261,6 +269,7 @@ public class TeamDispatchService {
if (task.getDescription() != null && !task.getDescription().isBlank()) {
sb.append("\n").append(task.getDescription()).append('\n');
}
appendPrerequisiteResults(sb, task);
sb.append("""
[Instructions]
@ -272,22 +281,47 @@ public class TeamDispatchService {
return sb.toString();
}
/** Push a task event onto the lead conversation's SSE stream (UI + observability). */
private void broadcast(TeamTaskEntity task, String event, Map<String, Object> extra) {
if (task.getLeadConversationId() == null) {
/**
* Hand the member everything its prerequisites produced: result summaries
* and deliverable links, so upstream output flows downstream without the
* lead re-typing it. Bounded by per-item and whole-section caps the
* member can fetch the full record with team_tasks(action="get").
*/
void appendPrerequisiteResults(StringBuilder sb, TeamTaskEntity task) {
List<Long> blockerIds = TeamTaskService.parseIdArray(task.getBlockedBy());
if (blockerIds.isEmpty()) {
return;
}
try {
Map<String, Object> payload = new HashMap<>(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()));
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
} catch (Exception e) {
log.debug("Team task event broadcast skipped: {}", e.getMessage());
StringBuilder section = new StringBuilder();
for (Long blockerId : blockerIds) {
TeamTaskEntity blocker = taskService.getTask(blockerId);
if (blocker == null) {
continue;
}
section.append("- #").append(blocker.getTaskNumber())
.append(" \"").append(blocker.getSubject()).append("\" (")
.append(blocker.getStatus()).append(')');
if (blocker.getResult() != null && !blocker.getResult().isBlank()) {
section.append(": ").append(truncate(blocker.getResult().strip(),
MAX_PREREQ_RESULT_CHARS));
}
section.append('\n');
for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) {
section.append(" File: ").append(file.name()).append("")
.append(file.url()).append('\n');
}
}
if (section.isEmpty()) {
return;
}
sb.append("\n[Prerequisite results]\n")
.append(truncate(section.toString(), MAX_PREREQ_SECTION_CHARS))
.append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n");
}
/** Push a task event onto the team channel and the lead conversation's stream. */
private void broadcast(TeamTaskEntity task, String event, Map<String, Object> extra) {
eventChannel.publishTaskEvent(task, event, extra);
}
private static String truncate(String s, int max) {

View File

@ -0,0 +1,71 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
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.TeamTaskEntity;
import java.util.HashMap;
import java.util.Map;
/**
* Team-scoped SSE event channel, backed by a synthetic conversation id on the
* existing stream tracker so registration, ring buffering, replay-by-last-id
* and heartbeats are all inherited instead of re-invented. One channel per
* team, alive for the application's lifetime; publishing lazily (re)registers,
* so a recycled channel heals on the next event and subscribers simply
* reconnect.
*
* Task events are additionally mirrored onto the originating lead
* conversation's stream (when the task has one) for in-chat observability.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TeamEventChannel {
static final String CHANNEL_PREFIX = "team-events-";
private final ChatStreamTracker streamTracker;
/** Publish a task lifecycle event to the team channel (+ lead stream if any). */
public void publishTaskEvent(TeamTaskEntity task, String event, Map<String, Object> extra) {
if (task == null) {
return;
}
try {
Map<String, Object> payload = new HashMap<>(extra == null ? Map.of() : 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()));
String channelId = channelId(task.getTeamId());
streamTracker.register(channelId);
streamTracker.broadcastObject(channelId, event, payload);
if (task.getLeadConversationId() != null) {
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
}
} catch (Exception e) {
// Events are a side channel never let them affect the task flow.
log.debug("Team event '{}' broadcast skipped: {}", event, e.getMessage());
}
}
/** Attach a subscriber, replaying buffered events newer than lastEventId. */
public boolean attach(Long teamId, SseEmitter emitter, long lastEventId) {
String channelId = channelId(teamId);
streamTracker.register(channelId);
return streamTracker.attach(channelId, emitter, lastEventId);
}
static String channelId(Long teamId) {
return CHANNEL_PREFIX + teamId;
}
}

View File

@ -46,6 +46,7 @@ public class TeamService {
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
List<Long> memberAgentIds, String createdBy) {
requireAgentExists(leadAgentId, "lead");
requireReactLead(leadAgentId);
requireNotInAnyTeam(leadAgentId);
if (memberAgentIds != null) {
for (Long memberId : memberAgentIds) {
@ -224,6 +225,22 @@ public class TeamService {
}
}
/**
* The lead must run the ReAct graph. A plan-execute lead orchestrates
* through its own serial per-step delegation pipeline, which bypasses the
* team board entirely tasks are never created, members never run in
* parallel, and the collaboration silently degrades to solo delegation.
*/
private void requireReactLead(Long agentId) {
AgentEntity agent = agentMapper.selectById(agentId);
if (agent != null && "plan_execute".equals(agent.getAgentType())) {
throw new IllegalArgumentException(
"lead agent must be a ReAct agent: a plan-execute lead plans serial steps "
+ "through its own delegation pipeline and never uses the team board. "
+ "Switch the agent's type to ReAct, or pick a different lead.");
}
}
private void requireNotInAnyTeam(Long agentId) {
// Membership check ignores team status on purpose: an agent parked in a
// paused team must not silently join a second one.

View File

@ -13,7 +13,9 @@ 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.TeamTaskEventEntity;
import vip.mate.team.repository.TeamTaskCommentMapper;
import vip.mate.team.repository.TeamTaskEventMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.net.URI;
@ -51,6 +53,7 @@ public class TeamTaskService {
private final TeamTaskMapper taskMapper;
private final TeamTaskCommentMapper commentMapper;
private final TeamTaskEventMapper eventMapper;
private final TeamService teamService;
// ==================== creation ====================
@ -112,6 +115,12 @@ public class TeamTaskService {
task.setChannel(cmd.getChannel());
task.setMetadata(cmd.getMetadata());
taskMapper.insert(task);
recordEvent(cmd.getTeamId(), task.getId(), TeamTaskEventEntity.CREATED,
cmd.getCreatedByAgentId() != null ? AUTHOR_AGENT
: cmd.getUsername() != null ? AUTHOR_USER : AUTHOR_SYSTEM,
cmd.getCreatedByAgentId() != null ? String.valueOf(cmd.getCreatedByAgentId())
: cmd.getUsername(),
"assignee: agent " + assignee);
log.info("Team {} task #{} created ({}), assignee={} status={}",
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
return task;
@ -186,6 +195,10 @@ public class TeamTaskService {
throw new IllegalStateException("task #" + task.getTaskNumber()
+ " is " + task.getStatus() + " and cannot be completed");
}
recordEvent(task.getTeamId(), taskId,
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);
}
@ -220,13 +233,19 @@ public class TeamTaskService {
/** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */
public boolean failTask(Long taskId, String reason) {
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
boolean failed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId)
.in(TeamTaskEntity::getStatus,
TeamTaskStatus.PENDING, TeamTaskStatus.IN_PROGRESS, TeamTaskStatus.STALE)
.set(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED)
.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);
}
return failed;
}
/** Cancel a non-terminal task; releases dependents so siblings are not deadlocked. */
@ -262,13 +281,21 @@ public class TeamTaskService {
/** Update progress and renew the execution lease in one shot. */
public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) {
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
boolean updated = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId)
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
.eq(agentId != null, TeamTaskEntity::getOwnerAgentId, agentId)
.set(percent != null, TeamTaskEntity::getProgressPercent, percent)
.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 : ""));
}
return updated;
}
/** Extend the execution lease (runner heartbeat). */
@ -298,6 +325,10 @@ public class TeamTaskService {
comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType);
comment.setContent(content);
commentMapper.insert(comment);
recordEvent(task.getTeamId(), taskId,
COMMENT_BLOCKER.equals(comment.getCommentType())
? TeamTaskEventEntity.BLOCKER : TeamTaskEventEntity.COMMENT,
authorType, authorId, content);
if (COMMENT_BLOCKER.equals(comment.getCommentType())) {
boolean failed = failTask(taskId, "blocked: " + content);
@ -316,6 +347,42 @@ public class TeamTaskService {
.orderByAsc(TeamTaskCommentEntity::getCreateTime));
}
// ==================== timeline events ====================
/** Timeline detail cap, matching the column width. */
static final int MAX_EVENT_DETAIL_CHARS = 1000;
/**
* Record a lifecycle moment on the task's timeline. Best-effort side
* channel: any failure is logged and swallowed a missing timeline row
* is acceptable, a task transition broken by the audit trail is not.
*/
public void recordEvent(Long teamId, Long taskId, String eventType,
String actorType, String actorId, String detail) {
try {
TeamTaskEventEntity event = new TeamTaskEventEntity();
event.setTeamId(teamId);
event.setTaskId(taskId);
event.setEventType(eventType);
event.setActorType(actorType);
event.setActorId(actorId);
event.setDetail(detail == null || detail.length() <= MAX_EVENT_DETAIL_CHARS
? detail : detail.substring(0, MAX_EVENT_DETAIL_CHARS));
eventMapper.insert(event);
} catch (Exception e) {
log.warn("Team task {} timeline event '{}' not recorded: {}",
taskId, eventType, e.getMessage());
}
}
/** The task's timeline, oldest first. */
public List<TeamTaskEventEntity> listEvents(Long taskId) {
return eventMapper.selectList(Wrappers.<TeamTaskEventEntity>lambdaQuery()
.eq(TeamTaskEventEntity::getTaskId, taskId)
.orderByAsc(TeamTaskEventEntity::getCreateTime)
.orderByAsc(TeamTaskEventEntity::getId));
}
// ==================== deliverables ====================
/** Maximum deliverables per task; the board is a summary surface, not a file store. */
@ -377,6 +444,8 @@ public class TeamTaskService {
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId)
.set(TeamTaskEntity::getMetadata, metadata.toString()));
recordEvent(task.getTeamId(), taskId, TeamTaskEventEntity.DELIVERABLE,
AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, name.trim());
log.info("Team task {} deliverable attached: {}", taskId, name.trim());
}
@ -479,6 +548,8 @@ public class TeamTaskService {
.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 (!expired.isEmpty()) {
log.warn("Marked {} team task(s) stale after lease expiry", expired.size());

View File

@ -13,8 +13,10 @@ import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskEventEntity;
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.TeamTaskService;
import vip.mate.tool.builtin.ToolExecutionContext;
@ -22,7 +24,9 @@ import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@ -44,6 +48,7 @@ public class TeamTasksTool {
private final TeamService teamService;
private final TeamTaskService taskService;
private final TeamDispatchService dispatchService;
private final TeamEventChannel eventChannel;
private final ConversationService conversationService;
private final AgentMapper agentMapper;
@ -119,8 +124,8 @@ public class TeamTasksTool {
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
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.";
};
@ -154,6 +159,7 @@ public class TeamTasksTool {
.requireApproval(Boolean.TRUE.equals(requireApproval))
.leadConversationId(conversationId)
.build());
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
dispatchService.requestDispatch(team.getId());
}
@ -193,6 +199,16 @@ public class TeamTasksTool {
return "Error: percent must be between 0 and 100.";
}
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
if (ok) {
Map<String, Object> extra = new HashMap<>();
if (percent != null) {
extra.put("progressPercent", percent);
}
if (step != null) {
extra.put("progressStep", step);
}
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra);
}
return ok ? "✓ Progress recorded."
: "Error: task is not in progress under your ownership; progress not recorded.";
}
@ -217,12 +233,16 @@ public class TeamTasksTool {
+ ". It now shows on the task card; keep your result a summary instead of pasting file contents.";
}
private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) {
private String cancel(AgentTeamEntity team, Long agentId, boolean isLead,
Long taskId, String reason) {
if (!isLead) {
return "Error: only the team lead can cancel tasks.";
}
TeamTaskEntity task = requireTaskInTeam(team, taskId);
List<Long> released = taskService.cancelTask(taskId, reason);
taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.CANCELLED,
TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), reason);
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_cancelled", Map.of());
// Stop the member run mid-flight instead of letting it burn to the end.
dispatchService.interruptRun(task);
if (!released.isEmpty()) {
@ -231,7 +251,7 @@ public class TeamTasksTool {
return "✓ Task cancelled.";
}
private String retry(AgentTeamEntity team, boolean isLead, Long taskId) {
private String retry(AgentTeamEntity team, Long agentId, boolean isLead, Long taskId) {
if (!isLead) {
return "Error: only the team lead can retry tasks.";
}
@ -239,6 +259,9 @@ public class TeamTasksTool {
if (!taskService.retryTask(taskId)) {
return "Error: only failed or stale tasks can be retried.";
}
taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.RETRIED,
TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), null);
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_retried", Map.of());
dispatchService.requestDispatch(team.getId());
return "✓ Task reset to pending; it will be re-dispatched.";
}

View File

@ -0,0 +1,20 @@
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
-- moments (created, dispatched, progress, comments, deliverables, settlement,
-- approval actions), rendered as the task's collaboration timeline in the UI.
-- Written as a side channel: failures to record never affect the task itself.
-- (H2 dialect)
CREATE TABLE IF NOT EXISTS mate_team_task_event (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
task_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
actor_type VARCHAR(16),
actor_id VARCHAR(64),
detail VARCHAR(1000),
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_task_event_task ON mate_team_task_event(task_id);
CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time);

View File

@ -0,0 +1,17 @@
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
-- moments. (KingbaseES / PostgreSQL dialect). See h2/V174 for design notes.
CREATE TABLE IF NOT EXISTS mate_team_task_event (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
task_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
actor_type VARCHAR(16),
actor_id VARCHAR(64),
detail VARCHAR(1000),
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_task_event_task ON mate_team_task_event(task_id);
CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time);

View File

@ -0,0 +1,17 @@
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
-- moments. (MySQL dialect). See h2/V174 for design notes.
CREATE TABLE IF NOT EXISTS mate_team_task_event (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
task_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
actor_type VARCHAR(16),
actor_id VARCHAR(64),
detail VARCHAR(1000),
create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT DEFAULT 0,
KEY idx_team_task_event_task (task_id),
KEY idx_team_task_event_team (team_id, create_time)
);

View File

@ -0,0 +1,243 @@
package vip.mate.team.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.common.result.R;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.service.TeamAnnounceService;
import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamTaskService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Verifies that service-layer validation verdicts (IllegalArgumentException /
* IllegalStateException) surface through the R envelope as readable messages
* instead of escaping to the global catch-all 500 handler.
*/
@ExtendWith(MockitoExtension.class)
class TeamControllerTest {
private static final Long TEAM_ID = 1L;
private static final Long TASK_ID = 100L;
@Mock private TeamService teamService;
@Mock private TeamTaskService taskService;
@Mock private TeamDispatchService dispatchService;
@Mock private TeamAnnounceService announceService;
@Mock private TeamEventChannel eventChannel;
@Mock private AgentMapper agentMapper;
private TeamController controller;
@BeforeEach
void setUp() {
controller = new TeamController(teamService, taskService, dispatchService,
announceService, eventChannel, agentMapper);
}
private TeamTaskEntity task(Long teamId, String status) {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(TASK_ID);
task.setTeamId(teamId);
task.setTaskNumber(7);
task.setStatus(status);
task.setSubject("subject");
return task;
}
// ==================== team / membership ====================
@Test
void createTeamSurfacesMembershipConflictAsReadableFailure() {
when(teamService.createTeam(any(), any(), any(), any(), any()))
.thenThrow(new IllegalStateException(
"agent 5 already belongs to team 2; an agent can join only one team"));
TeamController.CreateTeamRequest req = new TeamController.CreateTeamRequest();
req.setName("t");
req.setLeadAgentId(5L);
R<TeamController.TeamVO> r = controller.create(req, null);
assertEquals(500, r.getCode());
assertEquals("agent 5 already belongs to team 2; an agent can join only one team", r.getMsg());
}
@Test
void updateTeamSurfacesUnknownTeamAsReadableFailure() {
when(teamService.updateTeam(eq(TEAM_ID), any(), any(), any()))
.thenThrow(new IllegalArgumentException("team not found: " + TEAM_ID));
R<TeamController.TeamVO> r = controller.update(TEAM_ID, new TeamController.UpdateTeamRequest());
assertEquals(500, r.getCode());
assertEquals("team not found: 1", r.getMsg());
}
@Test
void addMemberSurfacesValidationAsReadableFailure() {
doThrow(new IllegalArgumentException("agent is already the team lead"))
.when(teamService).addMember(TEAM_ID, 5L, "member");
TeamController.MemberRequest req = new TeamController.MemberRequest();
req.setAgentId(5L);
req.setRole("member");
R<Void> r = controller.addMember(TEAM_ID, req);
assertEquals(500, r.getCode());
assertEquals("agent is already the team lead", r.getMsg());
}
@Test
void removeMemberSurfacesLeadProtectionAsReadableFailure() {
doThrow(new IllegalArgumentException("cannot remove the team lead; delete the team instead"))
.when(teamService).removeMember(TEAM_ID, 5L);
R<Void> r = controller.removeMember(TEAM_ID, 5L);
assertEquals(500, r.getCode());
assertEquals("cannot remove the team lead; delete the team instead", r.getMsg());
}
// ==================== task board ====================
@Test
void createTaskSurfacesUnknownAssigneeAsReadableFailure() {
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.setSubject("do the thing");
req.setAssigneeAgentId(9L);
R<TeamController.TaskVO> r = controller.createTask(TEAM_ID, req, null);
assertEquals(500, r.getCode());
assertEquals("assignee 9 is not a member of this team", r.getMsg());
verify(eventChannel, never()).publishTaskEvent(any(), anyString(), any());
verify(dispatchService, never()).requestDispatch(anyLong());
}
@Test
void approveRejectsTaskFromAnotherTeamsBoard() {
when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "in_review"));
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
assertEquals(500, r.getCode());
assertEquals("task not found on this team's board", r.getMsg());
verify(taskService, never()).approveTask(anyLong());
}
@Test
void approveSurfacesWrongStatusAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "pending"));
when(taskService.approveTask(TASK_ID))
.thenThrow(new IllegalStateException("task #7 is not awaiting review"));
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
assertEquals(500, r.getCode());
assertEquals("task #7 is not awaiting review", r.getMsg());
}
@Test
void approveHappyPathStillReturnsTask() {
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "in_review"));
when(taskService.approveTask(TASK_ID)).thenReturn(List.of(200L));
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
assertEquals(200, r.getCode());
assertNotNull(r.getData());
verify(dispatchService).requestDispatch(TEAM_ID);
}
@Test
void rejectSurfacesWrongStatusAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "pending"));
doThrow(new IllegalStateException("task #7 is not awaiting review"))
.when(taskService).rejectTask(TASK_ID, null);
R<TeamController.TaskVO> r = controller.reject(TEAM_ID, TASK_ID, null, null);
assertEquals(500, r.getCode());
assertEquals("task #7 is not awaiting review", r.getMsg());
verify(announceService, never()).announceTaskSettled(any());
}
@Test
void retrySurfacesTaskFromAnotherTeamAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "failed"));
R<TeamController.TaskVO> r = controller.retry(TEAM_ID, TASK_ID, null);
assertEquals(500, r.getCode());
assertEquals("task not found on this team's board", r.getMsg());
verify(taskService, never()).retryTask(anyLong());
}
@Test
void retryOnNonRetryableStatusKeepsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "done"));
when(taskService.retryTask(TASK_ID)).thenReturn(false);
R<TeamController.TaskVO> r = controller.retry(TEAM_ID, TASK_ID, null);
assertEquals(500, r.getCode());
assertEquals("only failed or stale tasks can be retried", r.getMsg());
}
@Test
void cancelSurfacesTerminalTaskAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "done"));
when(taskService.cancelTask(TASK_ID, null))
.thenThrow(new IllegalStateException("task #7 is already terminal"));
R<TeamController.TaskVO> r = controller.cancel(TEAM_ID, TASK_ID, null, null);
assertEquals(500, r.getCode());
assertEquals("task #7 is already terminal", r.getMsg());
verify(dispatchService, never()).interruptRun(any());
}
@Test
void commentSurfacesUnknownTaskAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(null);
TeamController.CommentRequest req = new TeamController.CommentRequest();
req.setContent("hi");
R<Void> r = controller.comment(TEAM_ID, TASK_ID, req, null);
assertEquals(500, r.getCode());
assertEquals("task not found on this team's board", r.getMsg());
verify(taskService, never()).addComment(anyLong(), anyString(), any(), anyString(), any());
}
@Test
void getTaskSurfacesUnknownTaskAsReadableFailure() {
when(taskService.getTask(TASK_ID)).thenReturn(null);
R<TeamController.TaskDetailVO> r = controller.getTask(TEAM_ID, TASK_ID);
assertEquals(500, r.getCode());
assertEquals("task not found on this team's board", r.getMsg());
}
}

View File

@ -37,6 +37,7 @@ class TeamDispatchServiceTest {
private ConversationService conversationService;
private ChatStreamTracker streamTracker;
private TeamAnnounceService announceService;
private TeamEventChannel eventChannel;
private TeamDispatchService service;
@BeforeEach
@ -47,8 +48,9 @@ class TeamDispatchServiceTest {
conversationService = mock(ConversationService.class);
streamTracker = mock(ChatStreamTracker.class);
announceService = mock(TeamAnnounceService.class);
eventChannel = mock(TeamEventChannel.class);
service = new TeamDispatchService(teamService, taskService, agentService,
conversationService, streamTracker, announceService);
conversationService, streamTracker, announceService, eventChannel);
}
private TeamTaskEntity task(Long id, Long assignee) {
@ -145,7 +147,7 @@ class TeamDispatchServiceTest {
service.settleOutcome(running, "analysis finished");
verify(taskService).completeTask(1L, null, "analysis finished");
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_completed"), any());
verify(eventChannel).publishTaskEvent(any(), eq("team_task_completed"), any());
verify(announceService).announceTaskSettled(done);
}
@ -160,7 +162,7 @@ class TeamDispatchServiceTest {
service.settleOutcome(failed, "irrelevant reply");
verify(taskService, never()).completeTask(any(), any(), anyString());
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any());
verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
}
// ==================== run tracking & interrupt ====================
@ -200,8 +202,8 @@ class TeamDispatchServiceTest {
service.runTask(TEAM_ID, assigned);
verify(streamTracker, never())
.broadcastObject(anyString(), eq("team_task_failed"), any());
verify(eventChannel, never())
.publishTaskEvent(any(), eq("team_task_failed"), any());
verify(announceService, never()).announceTaskSettled(any());
// Tracking still ends cleanly.
verify(streamTracker).complete(startsWith("team-task-"));
@ -221,7 +223,7 @@ class TeamDispatchServiceTest {
service.runTask(TEAM_ID, assigned);
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any());
verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
verify(announceService).announceTaskSettled(failed);
}
@ -241,6 +243,36 @@ class TeamDispatchServiceTest {
verifyNoMoreInteractions(streamTracker);
}
// ==================== prerequisite hand-off ====================
@Test
@DisplayName("the dispatch envelope carries prerequisite results and deliverables")
void envelopeCarriesPrerequisiteResults() {
TeamTaskEntity dependent = task(3L, MEMBER_B);
dependent.setBlockedBy("[\"1\",\"2\"]");
TeamTaskEntity done = task(1L, MEMBER_A);
done.setStatus(TeamTaskStatus.COMPLETED);
done.setResult("pricing collected: 3 competitors");
when(taskService.getTask(1L)).thenReturn(done);
when(taskService.getTask(2L)).thenReturn(null); // vanished blocker is skipped
when(taskService.listDeliverables(done)).thenReturn(List.of(
new TeamTaskService.Deliverable("prices.xlsx", "/api/v1/files/generated/x", null)));
StringBuilder sb = new StringBuilder();
service.appendPrerequisiteResults(sb, dependent);
String section = sb.toString();
assertTrue(section.contains("[Prerequisite results]"));
assertTrue(section.contains("pricing collected: 3 competitors"));
assertTrue(section.contains("prices.xlsx → /api/v1/files/generated/x"));
assertFalse(section.contains("#2"), "vanished blockers leave no trace");
// No blockers no section at all.
StringBuilder plain = new StringBuilder();
service.appendPrerequisiteResults(plain, task(4L, MEMBER_A));
assertEquals(0, plain.length());
}
@Test
@DisplayName("an oversized member reply is truncated before persisting")
void settleTruncatesLongReply() {

View File

@ -0,0 +1,87 @@
package vip.mate.team.service;
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.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.repository.AgentTeamMapper;
import vip.mate.team.repository.AgentTeamMemberMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Pins team creation guards most importantly that a plan-execute agent can
* never become a lead: its own serial delegation pipeline bypasses the team
* board, so the collaboration would silently degrade to solo delegation.
*/
class TeamServiceTest {
private static final Long LEAD_ID = 1L;
private static final Long MEMBER_ID = 2L;
@BeforeAll
static void initTableInfo() {
// Lambda wrappers resolve columns from the static TableInfo cache;
// plain Mockito tests must seed it manually.
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, AgentTeamEntity.class);
TableInfoHelper.initTableInfo(assistant, AgentTeamMemberEntity.class);
}
private AgentTeamMapper teamMapper;
private AgentTeamMemberMapper memberMapper;
private AgentMapper agentMapper;
private TeamService service;
@BeforeEach
void setUp() {
teamMapper = mock(AgentTeamMapper.class);
memberMapper = mock(AgentTeamMemberMapper.class);
agentMapper = mock(AgentMapper.class);
service = new TeamService(teamMapper, memberMapper, agentMapper,
mock(ApplicationEventPublisher.class));
}
private AgentEntity agent(Long id, String agentType) {
AgentEntity a = new AgentEntity();
a.setId(id);
a.setName("agent-" + id);
a.setAgentType(agentType);
return a;
}
@Test
@DisplayName("a plan-execute agent is rejected as lead with an actionable message")
void planExecuteLeadRejected() {
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> service.createTeam("", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
assertTrue(e.getMessage().contains("ReAct"));
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
}
@Test
@DisplayName("members may be plan-execute — only the lead role is restricted")
void planExecuteMemberAllowedByTypeGuard() {
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "react"));
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "plan_execute"));
when(memberMapper.selectCount(any())).thenReturn(0L);
assertDoesNotThrow(() ->
service.createTeam("", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
verify(teamMapper).insert(any(AgentTeamEntity.class));
}
}

View File

@ -13,8 +13,10 @@ import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskEventEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.repository.TeamTaskCommentMapper;
import vip.mate.team.repository.TeamTaskEventMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.util.List;
@ -37,6 +39,7 @@ class TeamTaskServiceTest {
private TeamTaskMapper taskMapper;
private TeamTaskCommentMapper commentMapper;
private TeamTaskEventMapper eventMapper;
private TeamService teamService;
private TeamTaskService service;
@ -48,14 +51,16 @@ class TeamTaskServiceTest {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
TableInfoHelper.initTableInfo(assistant, TeamTaskEventEntity.class);
}
@BeforeEach
void setUp() {
taskMapper = mock(TeamTaskMapper.class);
commentMapper = mock(TeamTaskCommentMapper.class);
eventMapper = mock(TeamTaskEventMapper.class);
teamService = mock(TeamService.class);
service = new TeamTaskService(taskMapper, commentMapper, teamService);
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService);
AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID);

View File

@ -12,6 +12,7 @@ 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.TeamTaskService;
import vip.mate.tool.builtin.ToolExecutionContext;
@ -42,6 +43,7 @@ class TeamTasksToolTest {
private TeamService teamService;
private TeamTaskService taskService;
private TeamDispatchService dispatchService;
private TeamEventChannel eventChannel;
private ConversationService conversationService;
private AgentMapper agentMapper;
private TeamTasksTool tool;
@ -52,10 +54,11 @@ class TeamTasksToolTest {
teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
dispatchService = mock(TeamDispatchService.class);
eventChannel = mock(TeamEventChannel.class);
conversationService = mock(ConversationService.class);
agentMapper = mock(AgentMapper.class);
tool = new TeamTasksTool(teamService, taskService, dispatchService,
conversationService, agentMapper);
eventChannel, conversationService, agentMapper);
team = new AgentTeamEntity();
team.setId(TEAM_ID);

View File

@ -880,6 +880,17 @@ export interface TeamTaskComment {
createTime?: string
}
export interface TeamTaskEvent {
id: string
teamId: string
taskId: string
eventType: string
actorType: string | null
actorId: string | null
detail: string | null
createTime?: string
}
export const teamApi = {
list: () => http.get('/teams'),
get: (id: string) => http.get(`/teams/${id}`),
@ -909,6 +920,7 @@ export const teamApi = {
requireApproval?: boolean
},
) => http.post(`/teams/${id}/tasks`, data),
listTaskEvents: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}/events`),
approveTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/approve`),
rejectTask: (id: string, taskId: string, reason?: string) =>
http.post(`/teams/${id}/tasks/${taskId}/reject`, { reason }),

View File

@ -0,0 +1,64 @@
/**
* Team board event subscription over SSE.
*
* EventSource cannot carry the Authorization header, so this reads the SSE
* body through fetch + ReadableStream (same approach as the chat stream).
* No auto-reconnect: the board keeps its polling fallback, so a dropped
* subscription degrades gracefully instead of stacking retry loops.
*/
export interface TeamBoardEvent {
event: string
data: Record<string, unknown>
}
export function subscribeTeamEvents(
teamId: string,
onEvent: (e: TeamBoardEvent) => void,
): () => void {
const controller = new AbortController()
const run = async () => {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
const token = localStorage.getItem('token')
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`/api/v1/teams/${teamId}/events`, {
headers,
signal: controller.signal,
})
if (!res.ok || !res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE frames are separated by a blank line.
let sep: number
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, sep)
buffer = buffer.slice(sep + 2)
let event = 'message'
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (event === 'heartbeat' || !data) continue
try {
onEvent({ event, data: JSON.parse(data) })
} catch {
// Non-JSON payloads are not board events; ignore.
}
}
}
}
run().catch(() => {
// Aborted or dropped — the board's polling fallback takes over.
})
return () => controller.abort()
}

View File

@ -51,6 +51,25 @@ export default {
taskCreateIncomplete: 'Subject and assignee are required',
deliverables: 'Deliverables',
viewRun: 'View execution',
timeline: 'Timeline',
leadReactHint: 'The lead must be a ReAct agent: plan-execute agents orchestrate through their own serial delegation pipeline and never use the team board',
leadTypeWarning: 'Lead is plan-execute — team collaboration will not engage',
eventType: {
created: 'Created',
dispatched: 'Dispatched',
progress: 'Progress',
comment: 'Comment',
blocker: 'Blocker',
deliverable: 'Deliverable',
completed: 'Completed',
in_review: 'In review',
failed: 'Failed',
cancelled: 'Cancelled',
approved: 'Approved',
rejected: 'Rejected',
retried: 'Retried',
stale: 'Stale',
},
column: {
todo: 'To Do',
in_progress: 'In Progress',

View File

@ -51,6 +51,25 @@ export default {
taskCreateIncomplete: '请填写任务标题并选择执行成员',
deliverables: '交付物',
viewRun: '查看执行过程',
timeline: '时间线',
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
eventType: {
created: '创建',
dispatched: '派发',
progress: '进度',
comment: '评论',
blocker: '阻塞上报',
deliverable: '交付物',
completed: '完成',
in_review: '待审核',
failed: '失败',
cancelled: '已取消',
approved: '批准',
rejected: '驳回',
retried: '重试',
stale: '失联',
},
column: {
todo: '待处理',
in_progress: '进行中',

View File

@ -78,6 +78,9 @@
</span>
{{ store.currentTeam.leadName }}
</span>
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
{{ t('teams.leadTypeWarning') }}
</span>
</div>
<div class="detail-header__right">
<div class="view-switch">
@ -102,6 +105,18 @@
</div>
</div>
<!-- Live activity feed -->
<transition-group
v-if="activeTab === 'board' && activityFeed.length > 0"
name="activity"
tag="div"
class="activity-feed"
>
<div v-for="item in activityFeed" :key="item.key" class="activity-line">
{{ item.text }}
</div>
</transition-group>
<!-- Kanban board -->
<div v-if="activeTab === 'board'" class="board-grid">
<div v-for="col in boardColumns" :key="col.key" class="board-col">
@ -190,6 +205,8 @@
:key="String(agent.id)"
class="agent-pill"
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
:disabled="agent.agentType === 'plan_execute'"
:title="agent.agentType === 'plan_execute' ? t('teams.leadReactHint') : undefined"
@click="selectLead(String(agent.id))"
>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
@ -198,6 +215,7 @@
{{ agent.name }}
</button>
</div>
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
</div>
<div class="form-group">
<label>{{ t('teams.membersField') }} <i>*</i></label>
@ -408,6 +426,21 @@
</a>
</div>
</div>
<div v-if="taskEvents.length > 0" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.timeline') }}</div>
<div class="timeline">
<div v-for="ev in taskEvents" :key="ev.id" class="timeline-row">
<span class="timeline-row__dot" :class="`dot-ev--${ev.eventType}`"></span>
<span class="timeline-row__time">{{ (ev.createTime || '').slice(5, 16) }}</span>
<span class="timeline-row__type">{{ t(`teams.eventType.${ev.eventType}`, ev.eventType) }}</span>
<span v-if="ev.actorType === 'agent'" class="timeline-row__actor">
{{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }}
</span>
<span v-else-if="ev.actorId" class="timeline-row__actor">{{ ev.actorId }}</span>
<span v-if="ev.detail" class="timeline-row__detail">{{ ev.detail }}</span>
</div>
</div>
</div>
<div class="task-detail__block">
<div class="task-detail__label">{{ t('teams.comments') }}</div>
<div v-if="comments.length === 0" class="task-detail__muted"></div>
@ -470,7 +503,8 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { teamApi } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskVO } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskEvent, TeamTaskVO } from '@/api/index'
import { subscribeTeamEvents } from '@/composables/useTeamEvents'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { agentIconColor } from '@/utils/agentIconColor'
import { useAgentStore } from '@/stores/useAgentStore'
@ -518,6 +552,50 @@ function agentIcon(agentId?: string | null, apiIcon?: string | null): string {
return agent?.icon || 'pi:user'
}
// ==================== live board events ====================
/** Recent activity lines shown above the board; each expires after a few seconds. */
const activityFeed = ref<{ key: number; text: string }[]>([])
let activityKey = 0
let unsubscribeEvents: (() => void) | null = null
let refreshDebounce: ReturnType<typeof setTimeout> | null = null
function onBoardEvent(e: { event: string; data: Record<string, unknown> }) {
if (!e.event.startsWith('team_task_')) return
// Event-driven refresh, debounced so bursts collapse into one fetch.
if (refreshDebounce) clearTimeout(refreshDebounce)
refreshDebounce = setTimeout(() => {
refreshDebounce = null
refreshBoard()
}, 300)
const type = e.event.slice('team_task_'.length)
const subject = String(e.data.subject ?? '')
const taskNumber = e.data.taskNumber != null ? `#${e.data.taskNumber} ` : ''
const key = ++activityKey
activityFeed.value.push({
key,
text: `${taskNumber}${subject} · ${t(`teams.eventType.${type}`, type)}`,
})
if (activityFeed.value.length > 3) activityFeed.value.shift()
setTimeout(() => {
activityFeed.value = activityFeed.value.filter((item) => item.key !== key)
}, 8000)
}
function startEventSubscription(teamId: string) {
stopEventSubscription()
unsubscribeEvents = subscribeTeamEvents(teamId, onBoardEvent)
}
function stopEventSubscription() {
if (unsubscribeEvents) {
unsubscribeEvents()
unsubscribeEvents = null
}
activityFeed.value = []
}
// ==================== polling ====================
let pollTimer: ReturnType<typeof setInterval> | null = null
@ -541,7 +619,15 @@ function stopPolling() {
watch(
() => store.currentTeam,
(team) => (team ? startPolling() : stopPolling()),
(team) => {
if (team) {
startPolling()
startEventSubscription(String(team.team.id))
} else {
stopPolling()
stopEventSubscription()
}
},
)
onMounted(() => {
@ -551,7 +637,10 @@ onMounted(() => {
}
})
onBeforeUnmount(() => stopPolling())
onBeforeUnmount(() => {
stopPolling()
stopEventSubscription()
})
// ==================== team actions ====================
@ -596,6 +685,18 @@ const memberCandidates = computed(() =>
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
)
/**
* A plan-execute lead orchestrates through its own serial delegation pipeline
* and never touches the team board surface a standing warning so a lead
* whose type was switched after team creation doesn't fail silently.
*/
const leadIsPlanExecute = computed(() => {
const leadId = store.currentTeam?.team.leadAgentId
if (!leadId) return false
const agent = agentStore.agents.find((a) => String(a.id) === String(leadId))
return agent?.agentType === 'plan_execute'
})
function openCreateDialog() {
createForm.name = ''
createForm.description = ''
@ -764,6 +865,8 @@ function openTaskRun() {
})
}
const taskEvents = ref<TeamTaskEvent[]>([])
async function openTask(vo: TeamTaskVO) {
if (!store.currentTeam) return
try {
@ -771,6 +874,13 @@ async function openTask(vo: TeamTaskVO) {
currentTask.value = res.data?.task || vo
comments.value = res.data?.comments || []
taskDialogVisible.value = true
// Timeline loads after the dialog opens; a failure just leaves it empty.
taskEvents.value = []
teamApi.listTaskEvents(store.currentTeam.team.id, vo.task.id)
.then((eventsRes: any) => {
taskEvents.value = eventsRes.data || []
})
.catch(() => {})
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
}
@ -1507,6 +1617,88 @@ async function cancelTask() {
font-size: 12px;
color: var(--mc-text-tertiary);
}
.agent-pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.lead-warning {
font-size: 12px;
color: var(--mc-danger, #d9534f);
background: rgba(217, 83, 79, 0.08);
border: 1px solid rgba(217, 83, 79, 0.25);
border-radius: 8px;
padding: 2px 8px;
}
.activity-feed {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.activity-line {
font-size: 12px;
color: var(--mc-text-secondary);
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border);
border-radius: 8px;
padding: 5px 10px;
}
.activity-enter-active,
.activity-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.activity-enter-from,
.activity-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: var(--mc-text-secondary);
}
.timeline-row__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mc-border);
flex-shrink: 0;
align-self: center;
}
.dot-ev--completed,
.dot-ev--approved { background: var(--mc-success, #67c23a); }
.dot-ev--failed,
.dot-ev--blocker,
.dot-ev--cancelled,
.dot-ev--rejected { background: var(--mc-danger, #d9534f); }
.dot-ev--dispatched,
.dot-ev--progress { background: var(--mc-primary); }
.timeline-row__time {
font-variant-numeric: tabular-nums;
color: var(--mc-text-tertiary);
flex-shrink: 0;
}
.timeline-row__type {
font-weight: 600;
color: var(--mc-text-primary);
flex-shrink: 0;
}
.timeline-row__actor {
color: var(--mc-text-secondary);
flex-shrink: 0;
}
.timeline-row__detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deliverable-list {
display: flex;
flex-direction: column;