package vip.mate.team.service; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; 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.model.TeamRunEntity; import vip.mate.team.model.TeamRunStatus; import vip.mate.team.model.TeamTaskEventEntity; import vip.mate.team.repository.TeamTaskCommentMapper; import vip.mate.team.repository.TeamTaskEventMapper; import vip.mate.team.repository.TeamTaskMapper; import java.net.URI; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 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 { private static final Pattern CHECKPOINT_RANGE = Pattern.compile( "(?i)R(\\d{3})\\s*[-–—]\\s*R(\\d{3})"); /** 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 TeamTaskEventMapper eventMapper; private final TeamService teamService; private final TeamRunProjectionScheduler projectionScheduler; private final TeamRunService runService; // ==================== 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.getRunId() != null) { TeamRunEntity run = runService.requireRun(cmd.getRunId(), team.getWorkspaceId()); if (!cmd.getTeamId().equals(run.getTeamId())) { throw new IllegalArgumentException("team task and run must belong to the same team"); } if (!TeamRunStatus.PLANNING.equals(run.getStatus())) { throw new IllegalStateException("team run must be planning to accept tasks: " + cmd.getRunId()); } } 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"); } // Dependency edges can only reference pre-existing tasks and blockedBy is // immutable after creation, so the dependency graph is acyclic by // construction — adding an edit path for blockedBy would break this // invariant and require real cycle detection. 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 (!Objects.equals(blocker.getRunId(), cmd.getRunId())) { throw new IllegalArgumentException("blocking task must belong to the same run: " + 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.setRunId(cmd.getRunId()); 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); recordEvent(cmd.getTeamId(), task.getId(), TeamTaskEventEntity.CREATED, cmd.getCreatedByAgentId() != null ? AUTHOR_AGENT : cmd.getUsername() != null ? AUTHOR_USER : AUTHOR_SYSTEM, cmd.getCreatedByAgentId() != null ? String.valueOf(cmd.getCreatedByAgentId()) : cmd.getUsername(), "assignee: agent " + assignee); log.info("Team {} task #{} created ({}), assignee={} status={}", cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus()); projectTask(task); 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) { boolean claimed = 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; if (claimed) { projectTask(taskId); } return claimed; } /** * 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) { boolean assigned = 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::getReason, null) .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; if (assigned) { projectTask(taskId); } return assigned; } /** 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), but * only by its assignee — otherwise any team member could complete another * member's not-yet-dispatched task. 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) { if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) { throw new IllegalStateException("task #" + task.getTaskNumber() + " is assigned to another agent; only the assignee can claim and complete it"); } 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"); } recordEvent(task.getTeamId(), taskId, toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED, agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM, agentId != null ? String.valueOf(agentId) : null, null); List released = toReview ? List.of() : releaseDependents(task); projectTask(task); return released; } /** 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"); } List released = releaseDependents(task); projectTask(task); return released; } /** 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"); } List released = releaseDependents(task); projectTask(task); return released; } /** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */ public boolean failTask(Long taskId, String reason) { TeamTaskEntity task = taskMapper.selectById(taskId); boolean failed = 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; if (failed) { recordEvent(task == null ? null : task.getTeamId(), taskId, TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason); projectTask(taskId); } return failed; } /** 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"); } List released = releaseDependents(task); projectTask(task); return released; } /** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */ public boolean retryTask(Long taskId) { boolean retried = 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; if (retried) { projectTask(taskId); } return retried; } /** * Requeue an automatically dispatched task whose member result is unusable. * Unlike a manual retry this deliberately preserves {@code dispatchCount}, * so the existing dispatch circuit breaker remains the hard upper bound. */ public boolean requeueUnusableResult(Long taskId, String reason) { TeamTaskEntity task = taskMapper.selectById(taskId); boolean requeued = taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .set(TeamTaskEntity::getOwnerAgentId, null) .set(TeamTaskEntity::getLockExpiresAt, null) .set(TeamTaskEntity::getReason, reason)) == 1; if (requeued) { recordEvent(task == null ? null : task.getTeamId(), taskId, TeamTaskEventEntity.RETRIED, AUTHOR_SYSTEM, null, reason); projectTask(taskId); } return requeued; } // ==================== progress / comments ==================== /** Update progress and renew the execution lease in one shot. */ public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) { TeamTaskEntity task = taskMapper.selectById(taskId); boolean updated = 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; if (updated) { recordEvent(task == null ? null : task.getTeamId(), taskId, TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, (percent != null ? percent + "%" : "") + (step != null ? " — " + step : "")); projectTask(taskId); } return updated; } /** 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); recordEvent(task.getTeamId(), taskId, COMMENT_BLOCKER.equals(comment.getCommentType()) ? TeamTaskEventEntity.BLOCKER : TeamTaskEventEntity.COMMENT, authorType, authorId, content); if (COMMENT_BLOCKER.equals(comment.getCommentType())) { boolean failed = failTask(taskId, "blocked: " + content); 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)); } /** Persist a note once, keyed by an exact stable content value. */ @Transactional public synchronized boolean addCommentOnce(Long taskId, String authorType, String authorId, String commentType, String content) { Long existing = commentMapper.selectCount(Wrappers.lambdaQuery() .eq(TeamTaskCommentEntity::getTaskId, taskId) .eq(TeamTaskCommentEntity::getContent, content)); if (existing != null && existing > 0) { return false; } addComment(taskId, authorType, authorId, commentType, content); return true; } // ==================== timeline events ==================== /** Timeline detail cap, matching the column width. */ static final int MAX_EVENT_DETAIL_CHARS = 1000; /** * Record a lifecycle moment on the task's timeline. Best-effort side * channel: any failure is logged and swallowed — a missing timeline row * is acceptable, a task transition broken by the audit trail is not. */ public void recordEvent(Long teamId, Long taskId, String eventType, String actorType, String actorId, String detail) { try { TeamTaskEventEntity event = new TeamTaskEventEntity(); event.setTeamId(teamId); event.setTaskId(taskId); event.setEventType(eventType); event.setActorType(actorType); event.setActorId(actorId); event.setDetail(detail == null || detail.length() <= MAX_EVENT_DETAIL_CHARS ? detail : detail.substring(0, MAX_EVENT_DETAIL_CHARS)); eventMapper.insert(event); } catch (Exception e) { log.warn("Team task {} timeline event '{}' not recorded: {}", taskId, eventType, e.getMessage()); } } /** The task's timeline, oldest first. */ public List listEvents(Long taskId) { return eventMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEventEntity::getTaskId, taskId) .orderByAsc(TeamTaskEventEntity::getCreateTime) .orderByAsc(TeamTaskEventEntity::getId)); } // ==================== deliverables ==================== /** Maximum deliverables per task; the board is a summary surface, not a file store. */ static final int MAX_DELIVERABLES = 10; /** Download-path prefix of the generated-file cache — the only accepted deliverable URL form. */ static final String GENERATED_FILE_PATH = "/api/v1/files/generated/"; /** A produced-file reference surfaced on the task card. */ public record Deliverable(String name, String url, String time) { } /** * Attach a produced-file reference to the task, stored under the * "deliverables" key of the task's metadata JSON. * * Single-writer assumption: only the task owner's run thread calls this * (tool-side gating) and no other code path writes metadata, so a plain * read-modify-write is safe. If a second metadata writer ever appears, * switch to a SQL-level JSON merge or optimistic locking. */ @Transactional public void addDeliverable(Long taskId, Long agentId, String name, String url) { TeamTaskEntity task = requireTask(taskId); if (TeamTaskStatus.isTerminal(task.getStatus())) { throw new IllegalStateException("task #" + task.getTaskNumber() + " is " + task.getStatus() + "; deliverables can only be attached while it is active"); } 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 attach deliverables"); } if (name == null || name.isBlank() || url == null || url.isBlank()) { throw new IllegalArgumentException("both name and url are required for a deliverable"); } String trimmedUrl = url.trim(); if (!isGeneratedFileUrl(trimmedUrl)) { throw new IllegalArgumentException("url must be a " + GENERATED_FILE_PATH + " download link produced by a render tool; external links are not accepted"); } JSONObject metadata = task.getMetadata() == null || task.getMetadata().isBlank() ? new JSONObject() : JSONUtil.parseObj(task.getMetadata()); JSONArray deliverables = metadata.getJSONArray("deliverables"); if (deliverables == null) { deliverables = new JSONArray(); } if (deliverables.size() >= MAX_DELIVERABLES) { throw new IllegalStateException("task #" + task.getTaskNumber() + " already has " + MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more"); } deliverables.add(new JSONObject() .set("name", name.trim()) .set("url", trimmedUrl) .set("time", LocalDateTime.now().toString())); metadata.set("deliverables", deliverables); taskMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamTaskEntity::getId, taskId) .set(TeamTaskEntity::getMetadata, metadata.toString())); recordEvent(task.getTeamId(), taskId, TeamTaskEventEntity.DELIVERABLE, AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, name.trim()); log.info("Team task {} deliverable attached: {}", taskId, name.trim()); } /** Parse the task's deliverable list; empty on missing/malformed metadata. */ public List listDeliverables(TeamTaskEntity task) { if (task == null || task.getMetadata() == null || task.getMetadata().isBlank()) { return List.of(); } try { JSONArray arr = JSONUtil.parseObj(task.getMetadata()) .getJSONArray("deliverables"); if (arr == null) { return List.of(); } List result = new ArrayList<>(); for (Object entry : arr) { JSONObject obj = (JSONObject) entry; result.add(new Deliverable(obj.getStr("name"), obj.getStr("url"), obj.getStr("time"))); } return result; } catch (Exception e) { return List.of(); } } /** Accept the cache's relative download path, or an absolute URL whose path is one. */ private static boolean isGeneratedFileUrl(String url) { if (url.startsWith(GENERATED_FILE_PATH)) { return true; } if (url.startsWith("http://") || url.startsWith("https://")) { try { String path = URI.create(url).getPath(); return path != null && path.startsWith(GENERATED_FILE_PATH); } catch (Exception e) { return false; } } return false; } // ==================== 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) { List candidates = taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .isNotNull(TeamTaskEntity::getAssigneeAgentId) .orderByDesc(TeamTaskEntity::getPriority) .orderByAsc(TeamTaskEntity::getCreateTime)); Set runIds = candidates.stream() .map(TeamTaskEntity::getRunId) .filter(Objects::nonNull) .collect(java.util.stream.Collectors.toSet()); Set planningRunIds = runService.findPlanningRunIds(runIds); return candidates.stream() .filter(task -> task.getRunId() == null || !planningRunIds.contains(task.getRunId())) .toList(); } /** 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) { int rows = 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 (rows == 1) { recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE, AUTHOR_SYSTEM, null, "execution lease expired"); projectTask(task); } } 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 listTasksByRun(Long runId) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getRunId, runId) .orderByAsc(TeamTaskEntity::getTaskNumber)); } /** * Tasks created from a delegated plan's steps, ordered by creation. The * plan linkage lives in the task metadata JSON ({@code "planId"} written * as a string), matched with a LIKE — team boards are small and the * pattern includes the quoted key, so false positives are not a concern. */ public List listTasksByPlan(Long teamId, Long planId) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .like(TeamTaskEntity::getMetadata, "\"planId\":\"" + planId + "\"") .orderByAsc(TeamTaskEntity::getCreateTime)); } /** * Locate the team's dedicated long-running checkpoint tracker, even when * the current checkpoint belongs to a later run. Highest priority and * newest creation win when historical tests left more than one candidate. */ public java.util.Optional findCheckpointTracker(Long teamId) { return java.util.Optional.ofNullable(taskMapper.selectOne( Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .and(candidate -> candidate .like(TeamTaskEntity::getSubject, "共享跟踪") .or().like(TeamTaskEntity::getSubject, "检查点") .or().like(TeamTaskEntity::getSubject, "checkpoint")) .orderByDesc(TeamTaskEntity::getPriority) .orderByDesc(TeamTaskEntity::getCreateTime) .last("LIMIT 1"))); } /** Terminal checkpoint tag declared by a long-running tracker, e.g. R300. */ public String checkpointTerminalTag(TeamTaskEntity task) { if (task == null) { return null; } String description = task.getDescription() == null ? "" : task.getDescription(); int contextStart = description.indexOf("[Plan context]"); if (contextStart >= 0) { description = description.substring(0, contextStart); } String text = (task.getSubject() == null ? "" : task.getSubject()) + " " + description; String lower = text.toLowerCase(); if (!text.contains("共享跟踪") && !text.contains("检查点") && !lower.contains("checkpoint")) { return null; } Matcher matcher = CHECKPOINT_RANGE.matcher(text); return matcher.find() ? "R" + matcher.group(2) : null; } public List listTasks(Long teamId, List statuses) { return listTasks(teamId, statuses, null, null); } /** * Board query with optional windowing. Terminal columns grow without * bound on long-lived teams, so the UI pages them (newest first) while * active columns stay unwindowed. LIMIT/OFFSET is valid across all three * supported dialects. */ public List listTasks(Long teamId, List statuses, Integer limit, Integer offset) { return listTasks(teamId, statuses, limit, offset, null); } /** Board query optionally scoped to one run to avoid mixing history. */ public List listTasks(Long teamId, List statuses, Integer limit, Integer offset, Long runId) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .eq(runId != null, TeamTaskEntity::getRunId, runId) .in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses) .orderByDesc(TeamTaskEntity::getPriority) .orderByDesc(TeamTaskEntity::getCreateTime) .last(limit != null, "LIMIT " + (limit == null ? 0 : Math.max(1, limit)) + " OFFSET " + (offset == null ? 0 : Math.max(0, offset)))); } /** Per-status task counts for the board header, computed in the database. */ public Map countByStatus(Long teamId) { return countByStatus(teamId, null); } public Map countByStatus(Long teamId, Long runId) { Map counts = new HashMap<>(); taskMapper.selectMaps(Wrappers.query() .select("status", "count(*) as cnt") .eq("team_id", teamId) .eq(runId != null, "run_id", runId) .eq("deleted", 0) .groupBy("status")) .forEach(row -> counts.put(String.valueOf(row.get("status")), ((Number) row.get("cnt")).longValue())); return counts; } // ==================== 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()); projectTask(candidate); } } 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 void projectTask(Long taskId) { try { projectionScheduler.scheduleTask(taskId); } catch (RuntimeException error) { log.warn("Team run projection failed after task {} changed: {}", taskId, error.getMessage()); } } private void projectTask(TeamTaskEntity task) { if (task == null || task.getRunId() == null) { return; } try { projectionScheduler.scheduleRun(task.getRunId()); } catch (RuntimeException error) { log.warn("Team run {} projection failed after task {} changed: {}", task.getRunId(), task.getId(), error.getMessage()); } } 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(); } } }