feat(team): team registry, membership and shared task board with guarded state transitions

This commit is contained in:
matevip 2026-07-24 17:37:04 +08:00
parent 728ed53062
commit 626c3a2fae
19 changed files with 1641 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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