mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(team): team registry, membership and shared task board with guarded state transitions
This commit is contained in:
parent
728ed53062
commit
626c3a2fae
@ -0,0 +1,14 @@
|
||||
package vip.mate.team.event;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Published when a team's composition or configuration changes. Listeners
|
||||
* evict the affected agents' cached runtime instances so the team context
|
||||
* baked into their system prompts is rebuilt on the next turn.
|
||||
*
|
||||
* @param agentIds every agent whose prompt may embed this team's context
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TeamChangedEvent(List<Long> agentIds) {
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Agent team: one lead agent plus member agents collaborating through a
|
||||
* shared task board. The lead orchestrates work by creating tasks assigned
|
||||
* to members; members execute in isolated conversations and report results.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_agent_team")
|
||||
public class AgentTeamEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
|
||||
/** Agent that orchestrates this team; exactly one per team. */
|
||||
private Long leadAgentId;
|
||||
|
||||
/** Team lifecycle status: active / paused. */
|
||||
private String status;
|
||||
|
||||
/** Monotonic per-team counter backing human-readable task numbers. */
|
||||
private Integer taskSeq;
|
||||
|
||||
/** Team-level settings as a JSON object (notification switches, escalation, ...). */
|
||||
private String settings;
|
||||
|
||||
/** Username of the admin who created the team. */
|
||||
private String createdBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Team membership row linking an agent to a team with a role.
|
||||
* An agent belongs to at most one active team (enforced in the service layer).
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_agent_team_member")
|
||||
public class AgentTeamMemberEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long teamId;
|
||||
|
||||
private Long agentId;
|
||||
|
||||
/** Member role within the team: lead / member / reviewer. */
|
||||
private String role;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
/**
|
||||
* Team membership role constants.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class TeamRole {
|
||||
|
||||
/** Orchestrates the team; receives the full task-board playbook. */
|
||||
public static final String LEAD = "lead";
|
||||
|
||||
/** Executes assigned tasks. */
|
||||
public static final String MEMBER = "member";
|
||||
|
||||
/** Reviews work submitted for approval. */
|
||||
public static final String REVIEWER = "reviewer";
|
||||
|
||||
private TeamRole() {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Comment on a team task, written by an agent, a human, or the system.
|
||||
* A comment of type "blocker" auto-fails the task and escalates to the lead.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_team_task_comment")
|
||||
public class TeamTaskCommentEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
/** Denormalized team id for board-level queries. */
|
||||
private Long teamId;
|
||||
|
||||
/** Author kind: agent / user / system. */
|
||||
private String authorType;
|
||||
|
||||
/** Agent id or username depending on authorType. */
|
||||
private String authorId;
|
||||
|
||||
/** note / blocker. */
|
||||
private String commentType;
|
||||
|
||||
private String content;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Input for creating a team task. The assignee is mandatory: every task must
|
||||
* name the member expected to execute it, so each delegation is trackable on
|
||||
* the board.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TeamTaskCreateCommand {
|
||||
|
||||
private Long teamId;
|
||||
|
||||
private String subject;
|
||||
|
||||
private String description;
|
||||
|
||||
/** Required: the member agent expected to execute this task. */
|
||||
private Long assigneeAgentId;
|
||||
|
||||
/** Creating agent id; NULL when a human creates the task from the board. */
|
||||
private Long createdByAgentId;
|
||||
|
||||
/** Higher dispatches first; defaults to 0. */
|
||||
private Integer priority;
|
||||
|
||||
/** general / request / note; defaults to general. */
|
||||
private String taskType;
|
||||
|
||||
/** Prerequisite task ids; non-empty list creates the task in blocked status. */
|
||||
private List<Long> blockedBy;
|
||||
|
||||
/** Park completion in in_review for human approval. */
|
||||
private boolean requireApproval;
|
||||
|
||||
/** Lead conversation that originated the task (result routing). */
|
||||
private String leadConversationId;
|
||||
|
||||
private String username;
|
||||
|
||||
private String channel;
|
||||
|
||||
/** Optional JSON metadata (attachments, origin routing, trace ids, ...). */
|
||||
private String metadata;
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A task on a team's shared board. Created by the lead (or an admin) with a
|
||||
* mandatory assignee, dispatched to that member for isolated execution, and
|
||||
* completed with a result summary. Supports dependency blocking, progress
|
||||
* reporting, an optional human-approval stage, and a dispatch circuit breaker.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_team_task")
|
||||
public class TeamTaskEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long teamId;
|
||||
|
||||
/** Human-readable sequential number, unique within the team. */
|
||||
private Integer taskNumber;
|
||||
|
||||
private String subject;
|
||||
|
||||
private String description;
|
||||
|
||||
/** See {@link TeamTaskStatus} for the full state machine. */
|
||||
private String status;
|
||||
|
||||
/** Higher value dispatches first among unblocked pending tasks. */
|
||||
private Integer priority;
|
||||
|
||||
/** Task category: general / request / note. */
|
||||
private String taskType;
|
||||
|
||||
/** Intended executor chosen at creation (required); never the team lead. */
|
||||
private Long assigneeAgentId;
|
||||
|
||||
/** Agent currently executing; NULL until the task is claimed or assigned. */
|
||||
private Long ownerAgentId;
|
||||
|
||||
/** Creating agent id when the task was created by an agent (NULL for humans). */
|
||||
private Long createdByAgentId;
|
||||
|
||||
/** JSON array of prerequisite task ids (as strings). */
|
||||
private String blockedBy;
|
||||
|
||||
/** When true, completion parks the task in in_review until a human approves. */
|
||||
private Boolean requireApproval;
|
||||
|
||||
private Integer progressPercent;
|
||||
|
||||
private String progressStep;
|
||||
|
||||
/** Result summary set on completion. */
|
||||
@TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String result;
|
||||
|
||||
/** Failure / cancellation / rejection reason. */
|
||||
private String reason;
|
||||
|
||||
/** Dispatch attempts; auto-fails past the circuit-breaker cap. */
|
||||
private Integer dispatchCount;
|
||||
|
||||
/** Execution lease expiry; an expired in_progress task is recoverable as stale. */
|
||||
@TableField(value = "lock_expires_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime lockExpiresAt;
|
||||
|
||||
/** Conversation in which the member executes this task. */
|
||||
private String conversationId;
|
||||
|
||||
/** Lead conversation that originated the task; used to route the result back. */
|
||||
private String leadConversationId;
|
||||
|
||||
/** User whose request triggered the task (scoping / board filtering). */
|
||||
private String username;
|
||||
|
||||
/** Origin channel of the triggering request (web / dingtalk / ...). */
|
||||
private String channel;
|
||||
|
||||
/** Custom JSON payload (attachments, origin routing, trace ids, ...). */
|
||||
private String metadata;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package vip.mate.team.model;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Team task state machine constants.
|
||||
*
|
||||
* <pre>
|
||||
* pending ──claim/assign──▶ in_progress ──complete──▶ completed
|
||||
* │ │ (require_approval) ▶ in_review ──approve──▶ completed
|
||||
* │ │ └──reject───▶ cancelled
|
||||
* │ ├──blocker/error──▶ failed ──retry──▶ pending
|
||||
* │ └──lease expired──▶ stale ──retry──▶ pending
|
||||
* ├──blocked_by set──▶ blocked ──all blockers released──▶ pending
|
||||
* └──cancel──▶ cancelled
|
||||
* </pre>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class TeamTaskStatus {
|
||||
|
||||
public static final String PENDING = "pending";
|
||||
public static final String IN_PROGRESS = "in_progress";
|
||||
public static final String IN_REVIEW = "in_review";
|
||||
public static final String COMPLETED = "completed";
|
||||
public static final String FAILED = "failed";
|
||||
public static final String CANCELLED = "cancelled";
|
||||
public static final String BLOCKED = "blocked";
|
||||
public static final String STALE = "stale";
|
||||
|
||||
/** No further transitions except hard delete. */
|
||||
public static final Set<String> TERMINAL = Set.of(COMPLETED, FAILED, CANCELLED);
|
||||
|
||||
/** Statuses that release dependent (blocked) tasks. Failed does NOT release. */
|
||||
public static final Set<String> RELEASES_DEPENDENTS = Set.of(COMPLETED, CANCELLED);
|
||||
|
||||
/** Statuses eligible for a manual retry back to pending. */
|
||||
public static final Set<String> RETRYABLE = Set.of(FAILED, STALE);
|
||||
|
||||
private TeamTaskStatus() {
|
||||
}
|
||||
|
||||
public static boolean isTerminal(String status) {
|
||||
return status != null && TERMINAL.contains(status);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package vip.mate.team.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
|
||||
/**
|
||||
* Agent team mapper.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Mapper
|
||||
public interface AgentTeamMapper extends BaseMapper<AgentTeamEntity> {
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package vip.mate.team.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
|
||||
/**
|
||||
* Team membership mapper.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Mapper
|
||||
public interface AgentTeamMemberMapper extends BaseMapper<AgentTeamMemberEntity> {
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package vip.mate.team.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.team.model.TeamTaskCommentEntity;
|
||||
|
||||
/**
|
||||
* Team task comment mapper.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Mapper
|
||||
public interface TeamTaskCommentMapper extends BaseMapper<TeamTaskCommentEntity> {
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package vip.mate.team.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
|
||||
/**
|
||||
* Team task board mapper.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Mapper
|
||||
public interface TeamTaskMapper extends BaseMapper<TeamTaskEntity> {
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.team.event.TeamChangedEvent;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
import vip.mate.team.model.TeamRole;
|
||||
import vip.mate.team.repository.AgentTeamMapper;
|
||||
import vip.mate.team.repository.AgentTeamMemberMapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Team registry: create/update/delete teams and manage membership.
|
||||
*
|
||||
* Invariants enforced here:
|
||||
* - every team has exactly one lead (the creating lead is auto-added with the lead role);
|
||||
* - an agent belongs to at most one active team (keeps system-prompt team context unambiguous);
|
||||
* - the lead cannot be removed or demoted while the team exists.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TeamService {
|
||||
|
||||
public static final String STATUS_ACTIVE = "active";
|
||||
public static final String STATUS_PAUSED = "paused";
|
||||
|
||||
private final AgentTeamMapper teamMapper;
|
||||
private final AgentTeamMemberMapper memberMapper;
|
||||
private final AgentMapper agentMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Transactional
|
||||
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
||||
List<Long> memberAgentIds, String createdBy) {
|
||||
requireAgentExists(leadAgentId, "lead");
|
||||
requireNotInAnyTeam(leadAgentId);
|
||||
if (memberAgentIds != null) {
|
||||
for (Long memberId : memberAgentIds) {
|
||||
if (memberId.equals(leadAgentId)) {
|
||||
throw new IllegalArgumentException("lead agent cannot also be listed as a member");
|
||||
}
|
||||
requireAgentExists(memberId, "member");
|
||||
requireNotInAnyTeam(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setName(name);
|
||||
team.setDescription(description);
|
||||
team.setLeadAgentId(leadAgentId);
|
||||
team.setStatus(STATUS_ACTIVE);
|
||||
team.setTaskSeq(0);
|
||||
team.setCreatedBy(createdBy);
|
||||
teamMapper.insert(team);
|
||||
|
||||
insertMember(team.getId(), leadAgentId, TeamRole.LEAD);
|
||||
if (memberAgentIds != null) {
|
||||
memberAgentIds.forEach(id -> insertMember(team.getId(), id, TeamRole.MEMBER));
|
||||
}
|
||||
log.info("Created agent team {} ({}) lead={} members={}", team.getId(), name,
|
||||
leadAgentId, memberAgentIds == null ? 0 : memberAgentIds.size());
|
||||
notifyTeamChanged(team.getId());
|
||||
return team;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void addMember(Long teamId, Long agentId, String role) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
if (TeamRole.LEAD.equals(role)) {
|
||||
throw new IllegalArgumentException("a team has exactly one lead; role must be member or reviewer");
|
||||
}
|
||||
if (agentId.equals(team.getLeadAgentId())) {
|
||||
throw new IllegalArgumentException("agent is already the team lead");
|
||||
}
|
||||
requireAgentExists(agentId, "member");
|
||||
requireNotInAnyTeam(agentId);
|
||||
insertMember(teamId, agentId, role == null ? TeamRole.MEMBER : role);
|
||||
notifyTeamChanged(teamId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeMember(Long teamId, Long agentId) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
if (agentId.equals(team.getLeadAgentId())) {
|
||||
throw new IllegalArgumentException("cannot remove the team lead; delete the team instead");
|
||||
}
|
||||
memberMapper.delete(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId)
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId));
|
||||
// The removed agent's prompt must drop the team block too.
|
||||
eventPublisher.publishEvent(new TeamChangedEvent(List.of(agentId)));
|
||||
notifyTeamChanged(teamId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTeam(Long teamId) {
|
||||
requireTeam(teamId);
|
||||
// Capture membership before it is wiped so every agent gets evicted.
|
||||
List<Long> agentIds = listMembers(teamId).stream()
|
||||
.map(AgentTeamMemberEntity::getAgentId).toList();
|
||||
memberMapper.delete(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId));
|
||||
teamMapper.deleteById(teamId);
|
||||
eventPublisher.publishEvent(new TeamChangedEvent(agentIds));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentTeamEntity updateTeam(Long teamId, String name, String description, String settings) {
|
||||
AgentTeamEntity team = requireTeam(teamId);
|
||||
if (name != null) {
|
||||
team.setName(name);
|
||||
}
|
||||
if (description != null) {
|
||||
team.setDescription(description);
|
||||
}
|
||||
if (settings != null) {
|
||||
team.setSettings(settings);
|
||||
}
|
||||
teamMapper.updateById(team);
|
||||
notifyTeamChanged(teamId);
|
||||
return team;
|
||||
}
|
||||
|
||||
public List<AgentTeamEntity> listTeams() {
|
||||
return teamMapper.selectList(Wrappers.<AgentTeamEntity>lambdaQuery()
|
||||
.orderByDesc(AgentTeamEntity::getCreateTime));
|
||||
}
|
||||
|
||||
public AgentTeamEntity getTeam(Long teamId) {
|
||||
return teamMapper.selectById(teamId);
|
||||
}
|
||||
|
||||
public List<AgentTeamMemberEntity> listMembers(Long teamId) {
|
||||
return memberMapper.selectList(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId)
|
||||
.orderByAsc(AgentTeamMemberEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the (single) active team an agent belongs to. Used by the prompt
|
||||
* builder to inject team context and by the task tool to scope board access.
|
||||
*/
|
||||
public Optional<AgentTeamEntity> getTeamForAgent(Long agentId) {
|
||||
AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId)
|
||||
.last("LIMIT 1"));
|
||||
if (member == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
AgentTeamEntity team = teamMapper.selectById(member.getTeamId());
|
||||
if (team == null || !STATUS_ACTIVE.equals(team.getStatus())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(team);
|
||||
}
|
||||
|
||||
public boolean isMember(Long teamId, Long agentId) {
|
||||
return memberMapper.selectCount(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getTeamId, teamId)
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId)) > 0;
|
||||
}
|
||||
|
||||
public boolean isLead(AgentTeamEntity team, Long agentId) {
|
||||
return team != null && agentId != null && agentId.equals(team.getLeadAgentId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically advance and return the team's task counter. The UPDATE takes a
|
||||
* row lock so concurrent creators serialize on the counter instead of racing.
|
||||
*/
|
||||
@Transactional
|
||||
public int nextTaskNumber(Long teamId) {
|
||||
int rows = teamMapper.update(null, Wrappers.<AgentTeamEntity>lambdaUpdate()
|
||||
.eq(AgentTeamEntity::getId, teamId)
|
||||
.setSql("task_seq = task_seq + 1"));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("team not found: " + teamId);
|
||||
}
|
||||
return teamMapper.selectById(teamId).getTaskSeq();
|
||||
}
|
||||
|
||||
/** Evict every current member's cached agent so team context rebuilds next turn. */
|
||||
private void notifyTeamChanged(Long teamId) {
|
||||
List<Long> agentIds = new ArrayList<>(listMembers(teamId).stream()
|
||||
.map(AgentTeamMemberEntity::getAgentId).toList());
|
||||
if (!agentIds.isEmpty()) {
|
||||
eventPublisher.publishEvent(new TeamChangedEvent(agentIds));
|
||||
}
|
||||
}
|
||||
|
||||
private void insertMember(Long teamId, Long agentId, String role) {
|
||||
AgentTeamMemberEntity member = new AgentTeamMemberEntity();
|
||||
member.setTeamId(teamId);
|
||||
member.setAgentId(agentId);
|
||||
member.setRole(role);
|
||||
memberMapper.insert(member);
|
||||
}
|
||||
|
||||
private AgentTeamEntity requireTeam(Long teamId) {
|
||||
AgentTeamEntity team = teamMapper.selectById(teamId);
|
||||
if (team == null) {
|
||||
throw new IllegalArgumentException("team not found: " + teamId);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
private void requireAgentExists(Long agentId, String roleLabel) {
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new IllegalArgumentException(roleLabel + " agent not found: " + agentId);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
|
||||
.eq(AgentTeamMemberEntity::getAgentId, agentId)
|
||||
.last("LIMIT 1"));
|
||||
if (member != null) {
|
||||
throw new IllegalStateException("agent " + agentId + " already belongs to team "
|
||||
+ member.getTeamId() + "; an agent can join only one team");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,474 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.TeamTaskStatus;
|
||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Shared task board service. All status transitions are guarded conditional
|
||||
* updates (state checked in the WHERE clause, success judged by affected-row
|
||||
* count), so concurrent agents cannot double-claim or double-complete a task —
|
||||
* the database is the arbiter, no in-process locking involved.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TeamTaskService {
|
||||
|
||||
/** Execution lease length; renewed by the runner while the member works. */
|
||||
static final int LOCK_MINUTES = 60;
|
||||
|
||||
/** Dispatch attempts before the circuit breaker auto-fails the task. */
|
||||
static final int MAX_DISPATCHES = 3;
|
||||
|
||||
public static final String AUTHOR_AGENT = "agent";
|
||||
public static final String AUTHOR_USER = "user";
|
||||
public static final String AUTHOR_SYSTEM = "system";
|
||||
|
||||
public static final String COMMENT_NOTE = "note";
|
||||
public static final String COMMENT_BLOCKER = "blocker";
|
||||
|
||||
private final TeamTaskMapper taskMapper;
|
||||
private final TeamTaskCommentMapper commentMapper;
|
||||
private final TeamService teamService;
|
||||
|
||||
// ==================== creation ====================
|
||||
|
||||
@Transactional
|
||||
public TeamTaskEntity createTask(TeamTaskCreateCommand cmd) {
|
||||
AgentTeamEntity team = teamService.getTeam(cmd.getTeamId());
|
||||
if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
|
||||
throw new IllegalArgumentException("team not found or not active: " + cmd.getTeamId());
|
||||
}
|
||||
if (cmd.getSubject() == null || cmd.getSubject().isBlank()) {
|
||||
throw new IllegalArgumentException("subject is required");
|
||||
}
|
||||
Long assignee = cmd.getAssigneeAgentId();
|
||||
if (assignee == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"assignee is required — specify which team member should handle this task");
|
||||
}
|
||||
if (assignee.equals(team.getLeadAgentId())) {
|
||||
throw new IllegalArgumentException(
|
||||
"cannot assign a task to the team lead; the lead orchestrates, members execute");
|
||||
}
|
||||
if (!teamService.isMember(cmd.getTeamId(), assignee)) {
|
||||
throw new IllegalArgumentException("assignee " + assignee + " is not a member of this team");
|
||||
}
|
||||
|
||||
List<Long> blockers = cmd.getBlockedBy() == null ? List.of() : cmd.getBlockedBy();
|
||||
for (Long blockerId : blockers) {
|
||||
TeamTaskEntity blocker = taskMapper.selectById(blockerId);
|
||||
if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) {
|
||||
throw new IllegalArgumentException("blocking task not found in this team: " + blockerId);
|
||||
}
|
||||
if (TeamTaskStatus.isTerminal(blocker.getStatus())) {
|
||||
throw new IllegalArgumentException("blocking task " + blockerId
|
||||
+ " is already " + blocker.getStatus()
|
||||
+ "; pass its result in the description instead of blocking on it");
|
||||
}
|
||||
}
|
||||
|
||||
TeamTaskEntity task = new TeamTaskEntity();
|
||||
task.setTeamId(cmd.getTeamId());
|
||||
task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId()));
|
||||
task.setSubject(cmd.getSubject());
|
||||
task.setDescription(cmd.getDescription());
|
||||
task.setStatus(blockers.isEmpty() ? TeamTaskStatus.PENDING : TeamTaskStatus.BLOCKED);
|
||||
task.setPriority(cmd.getPriority() == null ? 0 : cmd.getPriority());
|
||||
task.setTaskType(cmd.getTaskType() == null ? "general" : cmd.getTaskType());
|
||||
task.setAssigneeAgentId(assignee);
|
||||
task.setCreatedByAgentId(cmd.getCreatedByAgentId());
|
||||
task.setBlockedBy(blockers.isEmpty() ? null : toJsonIdArray(blockers));
|
||||
task.setRequireApproval(cmd.isRequireApproval());
|
||||
task.setDispatchCount(0);
|
||||
task.setLeadConversationId(cmd.getLeadConversationId());
|
||||
task.setUsername(cmd.getUsername());
|
||||
task.setChannel(cmd.getChannel());
|
||||
task.setMetadata(cmd.getMetadata());
|
||||
taskMapper.insert(task);
|
||||
log.info("Team {} task #{} created ({}), assignee={} status={}",
|
||||
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
|
||||
return task;
|
||||
}
|
||||
|
||||
// ==================== claim / assign ====================
|
||||
|
||||
/**
|
||||
* Atomically claim a pending, unowned task. Exactly one caller wins; losers
|
||||
* get false. The WHERE clause is the mutex.
|
||||
*/
|
||||
public boolean claimTask(Long taskId, Long agentId) {
|
||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
|
||||
.isNull(TeamTaskEntity::getOwnerAgentId)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a pending task to an agent (dispatch / admin path). Unlike claim,
|
||||
* this overrides a previously set owner but still requires pending status.
|
||||
*/
|
||||
public boolean assignTask(Long taskId, Long agentId) {
|
||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
||||
}
|
||||
|
||||
/** Record the member conversation executing the task. */
|
||||
public void attachConversation(Long taskId, String conversationId) {
|
||||
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.set(TeamTaskEntity::getConversationId, conversationId));
|
||||
}
|
||||
|
||||
// ==================== completion lifecycle ====================
|
||||
|
||||
/**
|
||||
* Complete a task with a result summary. A pending task is auto-claimed
|
||||
* first (single-call convenience; safe because the claim is atomic). When
|
||||
* the task requires approval it parks in in_review instead of completed.
|
||||
*
|
||||
* @return ids of dependent tasks released to pending by this completion
|
||||
*/
|
||||
@Transactional
|
||||
public List<Long> completeTask(Long taskId, Long agentId, String result) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) {
|
||||
claimTask(taskId, agentId);
|
||||
task = requireTask(taskId);
|
||||
}
|
||||
if (agentId != null && task.getOwnerAgentId() != null && !agentId.equals(task.getOwnerAgentId())) {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber()
|
||||
+ " is owned by another agent; only the owner can complete it");
|
||||
}
|
||||
boolean toReview = Boolean.TRUE.equals(task.getRequireApproval());
|
||||
String target = toReview ? TeamTaskStatus.IN_REVIEW : TeamTaskStatus.COMPLETED;
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getStatus, target)
|
||||
.set(TeamTaskEntity::getResult, result)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, null)
|
||||
.set(TeamTaskEntity::getProgressPercent, 100));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber()
|
||||
+ " is " + task.getStatus() + " and cannot be completed");
|
||||
}
|
||||
return toReview ? List.of() : releaseDependents(task);
|
||||
}
|
||||
|
||||
/** Human approval of an in_review task; releases dependents. */
|
||||
@Transactional
|
||||
public List<Long> approveTask(Long taskId) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_REVIEW)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.COMPLETED));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review");
|
||||
}
|
||||
return releaseDependents(task);
|
||||
}
|
||||
|
||||
/** Human rejection of an in_review task; cancels it and releases dependents. */
|
||||
@Transactional
|
||||
public List<Long> rejectTask(Long taskId, String reason) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_REVIEW)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.CANCELLED)
|
||||
.set(TeamTaskEntity::getReason, reason));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review");
|
||||
}
|
||||
return releaseDependents(task);
|
||||
}
|
||||
|
||||
/** 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()
|
||||
.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;
|
||||
}
|
||||
|
||||
/** Cancel a non-terminal task; releases dependents so siblings are not deadlocked. */
|
||||
@Transactional
|
||||
public List<Long> cancelTask(Long taskId, String reason) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.notIn(TeamTaskEntity::getStatus,
|
||||
TeamTaskStatus.COMPLETED, TeamTaskStatus.FAILED, TeamTaskStatus.CANCELLED)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.CANCELLED)
|
||||
.set(TeamTaskEntity::getReason, reason)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, null));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber() + " is already terminal");
|
||||
}
|
||||
return releaseDependents(task);
|
||||
}
|
||||
|
||||
/** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */
|
||||
public boolean retryTask(Long taskId) {
|
||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
|
||||
.set(TeamTaskEntity::getOwnerAgentId, null)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, null)
|
||||
.set(TeamTaskEntity::getReason, null)
|
||||
.set(TeamTaskEntity::getDispatchCount, 0)) == 1;
|
||||
}
|
||||
|
||||
// ==================== progress / comments ====================
|
||||
|
||||
/** 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()
|
||||
.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;
|
||||
}
|
||||
|
||||
/** Extend the execution lease (runner heartbeat). */
|
||||
public void renewLock(Long taskId) {
|
||||
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, newLease()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment. A blocker comment on an in_progress task auto-fails the
|
||||
* task; the caller (dispatch layer) is responsible for escalating to the
|
||||
* lead when this returns true.
|
||||
*
|
||||
* @return true when the comment was a blocker that failed the task
|
||||
*/
|
||||
@Transactional
|
||||
public boolean addComment(Long taskId, String authorType, String authorId,
|
||||
String commentType, String content) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
TeamTaskCommentEntity comment = new TeamTaskCommentEntity();
|
||||
comment.setTaskId(taskId);
|
||||
comment.setTeamId(task.getTeamId());
|
||||
comment.setAuthorType(authorType);
|
||||
comment.setAuthorId(authorId);
|
||||
comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType);
|
||||
comment.setContent(content);
|
||||
commentMapper.insert(comment);
|
||||
|
||||
if (COMMENT_BLOCKER.equals(comment.getCommentType())) {
|
||||
boolean failed = failTask(taskId, "blocked: " + content);
|
||||
if (failed) {
|
||||
log.info("Team task {} auto-failed by blocker comment from {}:{}",
|
||||
taskId, authorType, authorId);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<TeamTaskCommentEntity> listComments(Long taskId) {
|
||||
return commentMapper.selectList(Wrappers.<TeamTaskCommentEntity>lambdaQuery()
|
||||
.eq(TeamTaskCommentEntity::getTaskId, taskId)
|
||||
.orderByAsc(TeamTaskCommentEntity::getCreateTime));
|
||||
}
|
||||
|
||||
// ==================== dispatch support ====================
|
||||
|
||||
/**
|
||||
* Reserve one dispatch attempt. Returns false — and auto-fails the task —
|
||||
* once the circuit-breaker cap is exhausted, so a task that keeps bouncing
|
||||
* cannot loop forever.
|
||||
*/
|
||||
@Transactional
|
||||
public boolean tryAcquireDispatch(Long taskId) {
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.lt(TeamTaskEntity::getDispatchCount, MAX_DISPATCHES)
|
||||
.setSql("dispatch_count = dispatch_count + 1"));
|
||||
if (rows == 1) {
|
||||
return true;
|
||||
}
|
||||
boolean failed = failTask(taskId, "dispatch circuit breaker: exceeded "
|
||||
+ MAX_DISPATCHES + " attempts");
|
||||
if (failed) {
|
||||
log.warn("Team task {} auto-failed by dispatch circuit breaker", taskId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending tasks eligible for dispatch, priority first. The dispatch layer
|
||||
* picks at most one per assignee so a member never runs two tasks at once.
|
||||
*/
|
||||
public List<TeamTaskEntity> findDispatchable(Long teamId) {
|
||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
|
||||
.isNotNull(TeamTaskEntity::getAssigneeAgentId)
|
||||
.orderByDesc(TeamTaskEntity::getPriority)
|
||||
.orderByAsc(TeamTaskEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/** Whether the agent is already executing a task in this team. */
|
||||
public boolean hasActiveTask(Long teamId, Long agentId) {
|
||||
return taskMapper.selectCount(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
.eq(TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark in_progress tasks whose lease expired as stale. Returns the affected
|
||||
* tasks so a scheduler can escalate or retry them.
|
||||
*/
|
||||
@Transactional
|
||||
public List<TeamTaskEntity> recoverStaleTasks() {
|
||||
List<TeamTaskEntity> expired = taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.isNotNull(TeamTaskEntity::getLockExpiresAt)
|
||||
.lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now()));
|
||||
for (TeamTaskEntity task : expired) {
|
||||
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, task.getId())
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getReason, "execution lease expired"));
|
||||
}
|
||||
if (!expired.isEmpty()) {
|
||||
log.warn("Marked {} team task(s) stale after lease expiry", expired.size());
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
// ==================== queries ====================
|
||||
|
||||
public TeamTaskEntity getTask(Long taskId) {
|
||||
return taskMapper.selectById(taskId);
|
||||
}
|
||||
|
||||
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses) {
|
||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
.in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses)
|
||||
.orderByDesc(TeamTaskEntity::getPriority)
|
||||
.orderByDesc(TeamTaskEntity::getCreateTime));
|
||||
}
|
||||
|
||||
// ==================== dependency release ====================
|
||||
|
||||
/**
|
||||
* Release tasks blocked on the given task once ALL of their blockers have
|
||||
* reached a releasing status (completed / cancelled). Failed blockers keep
|
||||
* dependents blocked — a retry may still succeed.
|
||||
*
|
||||
* @return ids of tasks transitioned from blocked to pending
|
||||
*/
|
||||
List<Long> releaseDependents(TeamTaskEntity finished) {
|
||||
List<TeamTaskEntity> blocked = taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, finished.getTeamId())
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.BLOCKED));
|
||||
if (blocked.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> released = new ArrayList<>();
|
||||
for (TeamTaskEntity candidate : blocked) {
|
||||
List<Long> blockerIds = parseIdArray(candidate.getBlockedBy());
|
||||
if (!blockerIds.contains(finished.getId())) {
|
||||
continue;
|
||||
}
|
||||
boolean allReleased = blockerIds.stream().allMatch(id -> {
|
||||
if (Objects.equals(id, finished.getId())) {
|
||||
return true;
|
||||
}
|
||||
TeamTaskEntity blocker = taskMapper.selectById(id);
|
||||
// A vanished blocker must not deadlock its dependents forever.
|
||||
return blocker == null
|
||||
|| TeamTaskStatus.RELEASES_DEPENDENTS.contains(blocker.getStatus());
|
||||
});
|
||||
if (!allReleased) {
|
||||
continue;
|
||||
}
|
||||
int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, candidate.getId())
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.BLOCKED)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING));
|
||||
if (rows == 1) {
|
||||
released.add(candidate.getId());
|
||||
}
|
||||
}
|
||||
if (!released.isEmpty()) {
|
||||
log.info("Task {} released {} dependent task(s): {}",
|
||||
finished.getId(), released.size(), released);
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
// ==================== helpers ====================
|
||||
|
||||
private TeamTaskEntity requireTask(Long taskId) {
|
||||
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new IllegalArgumentException("team task not found: " + taskId);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private static LocalDateTime newLease() {
|
||||
return LocalDateTime.now().plusMinutes(LOCK_MINUTES);
|
||||
}
|
||||
|
||||
/** Ids are serialized as JSON strings to stay safe across the JS frontend. */
|
||||
private static String toJsonIdArray(List<Long> ids) {
|
||||
return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList());
|
||||
}
|
||||
|
||||
static List<Long> parseIdArray(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return JSONUtil.toList(json, String.class).stream()
|
||||
.map(Long::valueOf)
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
-- V172: Agent team foundation — team registry, membership, and the shared task board.
|
||||
-- A team groups one lead agent with member agents. The lead orchestrates work by
|
||||
-- creating tasks on the shared board; tasks are dispatched to the assigned member,
|
||||
-- executed in an isolated conversation, and completed with a result summary.
|
||||
-- (H2 dialect)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(1024),
|
||||
lead_agent_id BIGINT NOT NULL,
|
||||
status VARCHAR(16) DEFAULT 'active',
|
||||
-- Monotonic per-team counter backing human-readable task numbers (#1, #2, ...).
|
||||
task_seq INT DEFAULT 0,
|
||||
settings TEXT,
|
||||
created_by VARCHAR(64),
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_team_lead ON mate_agent_team(lead_agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team_member (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
agent_id BIGINT NOT NULL,
|
||||
role VARCHAR(16) DEFAULT 'member',
|
||||
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_member_team ON mate_agent_team_member(team_id, agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_member_agent ON mate_agent_team_member(agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
task_number INT,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(16) DEFAULT 'pending',
|
||||
priority INT DEFAULT 0,
|
||||
task_type VARCHAR(16) DEFAULT 'general',
|
||||
-- Intended executor chosen at creation time (required); dispatch turns it into owner.
|
||||
assignee_agent_id BIGINT NULL,
|
||||
-- Agent currently executing the task; NULL until claimed or assigned.
|
||||
owner_agent_id BIGINT NULL,
|
||||
created_by_agent_id BIGINT NULL,
|
||||
-- JSON array of prerequisite task ids (as strings); task stays 'blocked' until all
|
||||
-- prerequisites reach a releasing status (completed / cancelled).
|
||||
blocked_by TEXT,
|
||||
-- When TRUE, completion parks the task in 'in_review' until a human approves.
|
||||
require_approval BOOLEAN DEFAULT FALSE,
|
||||
progress_percent INT NULL,
|
||||
progress_step VARCHAR(512),
|
||||
result TEXT,
|
||||
reason VARCHAR(1024),
|
||||
-- Dispatch attempts; the dispatcher auto-fails the task past the circuit-breaker cap.
|
||||
dispatch_count INT DEFAULT 0,
|
||||
-- Execution lease; an in_progress task whose lease expired is recoverable as stale.
|
||||
lock_expires_at TIMESTAMP NULL,
|
||||
conversation_id VARCHAR(64),
|
||||
lead_conversation_id VARCHAR(64),
|
||||
username VARCHAR(64),
|
||||
channel VARCHAR(32),
|
||||
metadata TEXT,
|
||||
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_board ON mate_team_task(team_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_owner ON mate_team_task(team_id, owner_agent_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_number ON mate_team_task(team_id, task_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task_comment (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
team_id BIGINT NOT NULL,
|
||||
author_type VARCHAR(16),
|
||||
author_id VARCHAR(64),
|
||||
-- 'note' for regular comments; a 'blocker' comment auto-fails the task and
|
||||
-- escalates to the team lead.
|
||||
comment_type VARCHAR(16) DEFAULT 'note',
|
||||
content TEXT,
|
||||
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_comment ON mate_team_task_comment(task_id, create_time);
|
||||
@ -0,0 +1,76 @@
|
||||
-- V172: Agent team foundation — team registry, membership, and the shared task board.
|
||||
-- (KingbaseES / PostgreSQL dialect). See h2/V172 for design notes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(1024),
|
||||
lead_agent_id BIGINT NOT NULL,
|
||||
status VARCHAR(16) DEFAULT 'active',
|
||||
task_seq INT DEFAULT 0,
|
||||
settings TEXT,
|
||||
created_by VARCHAR(64),
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_team_lead ON mate_agent_team(lead_agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team_member (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
agent_id BIGINT NOT NULL,
|
||||
role VARCHAR(16) DEFAULT 'member',
|
||||
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_member_team ON mate_agent_team_member(team_id, agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_member_agent ON mate_agent_team_member(agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
task_number INT,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(16) DEFAULT 'pending',
|
||||
priority INT DEFAULT 0,
|
||||
task_type VARCHAR(16) DEFAULT 'general',
|
||||
assignee_agent_id BIGINT NULL,
|
||||
owner_agent_id BIGINT NULL,
|
||||
created_by_agent_id BIGINT NULL,
|
||||
blocked_by TEXT,
|
||||
require_approval BOOLEAN DEFAULT FALSE,
|
||||
progress_percent INT NULL,
|
||||
progress_step VARCHAR(512),
|
||||
result TEXT,
|
||||
reason VARCHAR(1024),
|
||||
dispatch_count INT DEFAULT 0,
|
||||
lock_expires_at TIMESTAMP NULL,
|
||||
conversation_id VARCHAR(64),
|
||||
lead_conversation_id VARCHAR(64),
|
||||
username VARCHAR(64),
|
||||
channel VARCHAR(32),
|
||||
metadata TEXT,
|
||||
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_board ON mate_team_task(team_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_owner ON mate_team_task(team_id, owner_agent_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_number ON mate_team_task(team_id, task_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task_comment (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
team_id BIGINT NOT NULL,
|
||||
author_type VARCHAR(16),
|
||||
author_id VARCHAR(64),
|
||||
comment_type VARCHAR(16) DEFAULT 'note',
|
||||
content TEXT,
|
||||
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_comment ON mate_team_task_comment(task_id, create_time);
|
||||
@ -0,0 +1,76 @@
|
||||
-- V172: Agent team foundation — team registry, membership, and the shared task board.
|
||||
-- (MySQL dialect). See h2/V172 for design notes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(1024),
|
||||
lead_agent_id BIGINT NOT NULL,
|
||||
status VARCHAR(16) DEFAULT 'active',
|
||||
task_seq INT DEFAULT 0,
|
||||
settings TEXT,
|
||||
created_by VARCHAR(64),
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0,
|
||||
KEY idx_agent_team_lead (lead_agent_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_team_member (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
agent_id BIGINT NOT NULL,
|
||||
role VARCHAR(16) DEFAULT 'member',
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0,
|
||||
KEY idx_team_member_team (team_id, agent_id),
|
||||
KEY idx_team_member_agent (agent_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL,
|
||||
task_number INT,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(16) DEFAULT 'pending',
|
||||
priority INT DEFAULT 0,
|
||||
task_type VARCHAR(16) DEFAULT 'general',
|
||||
assignee_agent_id BIGINT NULL,
|
||||
owner_agent_id BIGINT NULL,
|
||||
created_by_agent_id BIGINT NULL,
|
||||
blocked_by TEXT,
|
||||
require_approval BOOLEAN DEFAULT FALSE,
|
||||
progress_percent INT NULL,
|
||||
progress_step VARCHAR(512),
|
||||
result TEXT,
|
||||
reason VARCHAR(1024),
|
||||
dispatch_count INT DEFAULT 0,
|
||||
lock_expires_at TIMESTAMP NULL,
|
||||
conversation_id VARCHAR(64),
|
||||
lead_conversation_id VARCHAR(64),
|
||||
username VARCHAR(64),
|
||||
channel VARCHAR(32),
|
||||
metadata TEXT,
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0,
|
||||
KEY idx_team_task_board (team_id, status),
|
||||
KEY idx_team_task_owner (team_id, owner_agent_id, status),
|
||||
KEY idx_team_task_number (team_id, task_number)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_team_task_comment (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
team_id BIGINT NOT NULL,
|
||||
author_type VARCHAR(16),
|
||||
author_id VARCHAR(64),
|
||||
comment_type VARCHAR(16) DEFAULT 'note',
|
||||
content TEXT,
|
||||
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted INT DEFAULT 0,
|
||||
KEY idx_team_task_comment (task_id, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -0,0 +1,265 @@
|
||||
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.mockito.ArgumentCaptor;
|
||||
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.TeamTaskStatus;
|
||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins the task board's transition guards: mandatory assignee, lead
|
||||
* self-assignment rejection, approval parking, blocker-comment auto-fail,
|
||||
* the dispatch circuit breaker, and dependency release semantics.
|
||||
*/
|
||||
class TeamTaskServiceTest {
|
||||
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
private static final Long MEMBER_ID = 2L;
|
||||
|
||||
private TeamTaskMapper taskMapper;
|
||||
private TeamTaskCommentMapper commentMapper;
|
||||
private TeamService teamService;
|
||||
private TeamTaskService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// Lambda wrappers resolve column names from MyBatis-Plus's static
|
||||
// TableInfo cache; in a Spring context this happens during mapper
|
||||
// scan, in a plain Mockito test we trigger it manually.
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskMapper = mock(TeamTaskMapper.class);
|
||||
commentMapper = mock(TeamTaskCommentMapper.class);
|
||||
teamService = mock(TeamService.class);
|
||||
service = new TeamTaskService(taskMapper, commentMapper, teamService);
|
||||
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
team.setStatus(TeamService.STATUS_ACTIVE);
|
||||
when(teamService.getTeam(TEAM_ID)).thenReturn(team);
|
||||
when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true);
|
||||
when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1);
|
||||
}
|
||||
|
||||
private TeamTaskCreateCommand.TeamTaskCreateCommandBuilder baseCreate() {
|
||||
return TeamTaskCreateCommand.builder()
|
||||
.teamId(TEAM_ID)
|
||||
.subject("write report")
|
||||
.assigneeAgentId(MEMBER_ID);
|
||||
}
|
||||
|
||||
private TeamTaskEntity task(Long id, String status) {
|
||||
TeamTaskEntity t = new TeamTaskEntity();
|
||||
t.setId(id);
|
||||
t.setTeamId(TEAM_ID);
|
||||
t.setTaskNumber(7);
|
||||
t.setStatus(status);
|
||||
return t;
|
||||
}
|
||||
|
||||
// ==================== creation guards ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("create without assignee is rejected")
|
||||
void createRequiresAssignee() {
|
||||
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.createTask(baseCreate().assigneeAgentId(null).build()));
|
||||
assertTrue(e.getMessage().contains("assignee is required"));
|
||||
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("assigning a task to the lead is rejected (dual-session loop guard)")
|
||||
void createRejectsLeadAssignee() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.createTask(baseCreate().assigneeAgentId(LEAD_ID).build()));
|
||||
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocking on an already-terminal task is rejected")
|
||||
void createRejectsTerminalBlocker() {
|
||||
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.COMPLETED));
|
||||
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.createTask(baseCreate().blockedBy(List.of(99L)).build()));
|
||||
assertTrue(e.getMessage().contains("already completed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a task with live blockers is created in blocked status with string-id JSON")
|
||||
void createWithBlockersStartsBlocked() {
|
||||
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.PENDING));
|
||||
|
||||
TeamTaskEntity created = service.createTask(baseCreate().blockedBy(List.of(99L)).build());
|
||||
|
||||
assertEquals(TeamTaskStatus.BLOCKED, created.getStatus());
|
||||
// Ids must serialize as JSON strings to survive the JS frontend intact.
|
||||
assertEquals("[\"99\"]", created.getBlockedBy());
|
||||
verify(taskMapper).insert(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a plain task is created pending with the team-sequential number")
|
||||
void createPlainTaskPending() {
|
||||
TeamTaskEntity created = service.createTask(baseCreate().build());
|
||||
assertEquals(TeamTaskStatus.PENDING, created.getStatus());
|
||||
assertEquals(1, created.getTaskNumber());
|
||||
assertEquals(0, created.getDispatchCount());
|
||||
}
|
||||
|
||||
// ==================== completion ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("completing a require-approval task parks it and releases nothing")
|
||||
void completeWithApprovalParksInReview() {
|
||||
TeamTaskEntity t = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||||
t.setOwnerAgentId(MEMBER_ID);
|
||||
t.setRequireApproval(true);
|
||||
when(taskMapper.selectById(5L)).thenReturn(t);
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
List<Long> released = service.completeTask(5L, MEMBER_ID, "done");
|
||||
|
||||
assertTrue(released.isEmpty(), "in_review must not release dependents yet");
|
||||
// Only the completion update ran; no dependent scan happened.
|
||||
verify(taskMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completion by a non-owner is rejected")
|
||||
void completeByNonOwnerRejected() {
|
||||
TeamTaskEntity t = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||||
t.setOwnerAgentId(MEMBER_ID);
|
||||
when(taskMapper.selectById(5L)).thenReturn(t);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.completeTask(5L, 3L, "hijack"));
|
||||
verify(taskMapper, never()).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completing a terminal task fails with a state error")
|
||||
void completeTerminalRejected() {
|
||||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.CANCELLED));
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(0);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.completeTask(5L, MEMBER_ID, "late"));
|
||||
}
|
||||
|
||||
// ==================== blocker comment ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("a blocker comment auto-fails the task and reports escalation")
|
||||
void blockerCommentAutoFails() {
|
||||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
boolean escalate = service.addComment(5L, TeamTaskService.AUTHOR_AGENT, "2",
|
||||
TeamTaskService.COMMENT_BLOCKER, "missing API docs");
|
||||
|
||||
assertTrue(escalate, "caller must escalate to the lead");
|
||||
ArgumentCaptor<TeamTaskCommentEntity> captor =
|
||||
ArgumentCaptor.forClass(TeamTaskCommentEntity.class);
|
||||
verify(commentMapper).insert(captor.capture());
|
||||
assertEquals(TeamTaskService.COMMENT_BLOCKER, captor.getValue().getCommentType());
|
||||
verify(taskMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a note comment neither fails the task nor escalates")
|
||||
void noteCommentIsInert() {
|
||||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||
|
||||
boolean escalate = service.addComment(5L, TeamTaskService.AUTHOR_USER, "admin",
|
||||
null, "looking good");
|
||||
|
||||
assertFalse(escalate);
|
||||
verify(taskMapper, never()).update(isNull(), any());
|
||||
}
|
||||
|
||||
// ==================== circuit breaker ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("dispatch within the cap succeeds without failing the task")
|
||||
void dispatchWithinCap() {
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||||
assertTrue(service.tryAcquireDispatch(5L));
|
||||
verify(taskMapper, times(1)).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exhausted dispatch cap auto-fails the task and returns false")
|
||||
void dispatchCapExhaustedFailsTask() {
|
||||
// First update (increment guarded by cap) misses; second (failTask) lands.
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(0).thenReturn(1);
|
||||
|
||||
assertFalse(service.tryAcquireDispatch(5L));
|
||||
verify(taskMapper, times(2)).update(isNull(), any());
|
||||
}
|
||||
|
||||
// ==================== dependency release ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("a dependent is released only when ALL blockers reached a releasing status")
|
||||
void releaseWaitsForAllBlockers() {
|
||||
TeamTaskEntity finished = task(1L, TeamTaskStatus.COMPLETED);
|
||||
TeamTaskEntity dependent = task(3L, TeamTaskStatus.BLOCKED);
|
||||
dependent.setBlockedBy("[\"1\",\"2\"]");
|
||||
when(taskMapper.selectList(any())).thenReturn(List.of(dependent));
|
||||
// The sibling blocker is still failed (not a releasing status).
|
||||
when(taskMapper.selectById(2L)).thenReturn(task(2L, TeamTaskStatus.FAILED));
|
||||
|
||||
assertTrue(service.releaseDependents(finished).isEmpty(),
|
||||
"failed sibling blocker must keep the dependent blocked");
|
||||
|
||||
// Sibling now cancelled — cancellation releases dependents.
|
||||
when(taskMapper.selectById(2L)).thenReturn(task(2L, TeamTaskStatus.CANCELLED));
|
||||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(3L), service.releaseDependents(finished));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tasks not blocked on the finished task are ignored")
|
||||
void unrelatedBlockedTaskUntouched() {
|
||||
TeamTaskEntity finished = task(1L, TeamTaskStatus.COMPLETED);
|
||||
TeamTaskEntity unrelated = task(4L, TeamTaskStatus.BLOCKED);
|
||||
unrelated.setBlockedBy("[\"8\"]");
|
||||
when(taskMapper.selectList(any())).thenReturn(List.of(unrelated));
|
||||
|
||||
assertTrue(service.releaseDependents(finished).isEmpty());
|
||||
verify(taskMapper, never()).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed blocked_by JSON degrades to an empty blocker list")
|
||||
void parseIdArrayTolerant() {
|
||||
assertTrue(TeamTaskService.parseIdArray(null).isEmpty());
|
||||
assertTrue(TeamTaskService.parseIdArray(" ").isEmpty());
|
||||
assertTrue(TeamTaskService.parseIdArray("not-json").isEmpty());
|
||||
assertEquals(List.of(99L), TeamTaskService.parseIdArray("[\"99\"]"));
|
||||
}
|
||||
}
|
||||
7
mateclaw-ui/src/types/components.d.ts
vendored
7
mateclaw-ui/src/types/components.d.ts
vendored
@ -11,7 +11,9 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es/components/alert/index')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es/components/button/index')['ElButton']
|
||||
ElCard: typeof import('element-plus/es/components/card/index')['ElCard']
|
||||
ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog']
|
||||
@ -20,17 +22,22 @@ declare module 'vue' {
|
||||
ElDropdownItem: typeof import('element-plus/es/components/dropdown/index')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es/components/empty/index')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es/components/form/index')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es/components/form/index')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
|
||||
ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer']
|
||||
ElInput: typeof import('element-plus/es/components/input/index')['ElInput']
|
||||
ElOption: typeof import('element-plus/es/components/select/index')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es/components/pagination/index')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es/components/progress/index')['ElProgress']
|
||||
ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
|
||||
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es/components/tabs/index')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es/components/tag/index')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
Loading…
Reference in New Issue
Block a user