diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java new file mode 100644 index 00000000..cdd241a6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java @@ -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 agentIds) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java new file mode 100644 index 00000000..5ab849b1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java new file mode 100644 index 00000000..4bed621d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java new file mode 100644 index 00000000..611a385f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java @@ -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() { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java new file mode 100644 index 00000000..31ed429e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java new file mode 100644 index 00000000..7834abd0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java @@ -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 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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java new file mode 100644 index 00000000..bb79b0f1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java new file mode 100644 index 00000000..97dc5c0e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java @@ -0,0 +1,46 @@ +package vip.mate.team.model; + +import java.util.Set; + +/** + * Team task state machine constants. + * + *
+ * 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
+ * 
+ * + * @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 TERMINAL = Set.of(COMPLETED, FAILED, CANCELLED); + + /** Statuses that release dependent (blocked) tasks. Failed does NOT release. */ + public static final Set RELEASES_DEPENDENTS = Set.of(COMPLETED, CANCELLED); + + /** Statuses eligible for a manual retry back to pending. */ + public static final Set RETRYABLE = Set.of(FAILED, STALE); + + private TeamTaskStatus() { + } + + public static boolean isTerminal(String status) { + return status != null && TERMINAL.contains(status); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java new file mode 100644 index 00000000..0fa1354a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java new file mode 100644 index 00000000..73d05012 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java new file mode 100644 index 00000000..f16f3f68 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java new file mode 100644 index 00000000..c0bf9f33 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java new file mode 100644 index 00000000..37109cde --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java @@ -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 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.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 agentIds = listMembers(teamId).stream() + .map(AgentTeamMemberEntity::getAgentId).toList(); + memberMapper.delete(Wrappers.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 listTeams() { + return teamMapper.selectList(Wrappers.lambdaQuery() + .orderByDesc(AgentTeamEntity::getCreateTime)); + } + + public AgentTeamEntity getTeam(Long teamId) { + return teamMapper.selectById(teamId); + } + + public List listMembers(Long teamId) { + return memberMapper.selectList(Wrappers.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 getTeamForAgent(Long agentId) { + AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.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.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.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 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.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"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java new file mode 100644 index 00000000..950b3638 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -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 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.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.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.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 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.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 approveTask(Long taskId) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.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 rejectTask(Long taskId, String reason) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.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.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 cancelTask(Long taskId, String reason) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.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.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.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.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 listComments(Long taskId) { + return commentMapper.selectList(Wrappers.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.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 findDispatchable(Long teamId) { + return taskMapper.selectList(Wrappers.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.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 recoverStaleTasks() { + List expired = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .isNotNull(TeamTaskEntity::getLockExpiresAt) + .lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now())); + for (TeamTaskEntity task : expired) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, task.getId()) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE) + .set(TeamTaskEntity::getReason, "execution lease expired")); + } + 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 listTasks(Long teamId, List statuses) { + return taskMapper.selectList(Wrappers.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 releaseDependents(TeamTaskEntity finished) { + List blocked = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, finished.getTeamId()) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.BLOCKED)); + if (blocked.isEmpty()) { + return List.of(); + } + List released = new ArrayList<>(); + for (TeamTaskEntity candidate : blocked) { + List 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.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 ids) { + return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList()); + } + + static List 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(); + } + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql new file mode 100644 index 00000000..8eb1b376 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql new file mode 100644 index 00000000..b4c1f602 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql new file mode 100644 index 00000000..e4f75c09 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql @@ -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; diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java new file mode 100644 index 00000000..4c6bf4bc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -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 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 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\"]")); + } +} diff --git a/mateclaw-ui/src/types/components.d.ts b/mateclaw-ui/src/types/components.d.ts index 14909b9f..1dc64a8d 100644 --- a/mateclaw-ui/src/types/components.d.ts +++ b/mateclaw-ui/src/types/components.d.ts @@ -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']