mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(team): make checkpoint evidence idempotent
This commit is contained in:
parent
fb7cb6c04a
commit
6e8e7db31e
@ -271,7 +271,8 @@ public class TeamDispatchService {
|
||||
if (terminalCheckpoint != null) {
|
||||
String terminalEvidence = "[checkpoint:" + terminalCheckpoint + "] acknowledged";
|
||||
boolean terminalAlreadyAcknowledged = taskService.listComments(current.getId()).stream()
|
||||
.anyMatch(comment -> terminalEvidence.equals(comment.getContent()));
|
||||
.anyMatch(comment -> comment.getContent() != null
|
||||
&& comment.getContent().contains(terminalEvidence));
|
||||
if (terminalAlreadyAcknowledged) {
|
||||
List<Long> released = taskService.completeTask(task.getId(), null,
|
||||
truncate(reply, MAX_RESULT_CHARS));
|
||||
@ -392,6 +393,11 @@ public class TeamDispatchService {
|
||||
for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) {
|
||||
section.append(" File: ").append(file.name()).append(" → ")
|
||||
.append(file.url()).append('\n');
|
||||
String inspectionPath = generatedFileInspectionPath(file.url());
|
||||
if (inspectionPath != null) {
|
||||
section.append(" Inspect locally: ").append(inspectionPath)
|
||||
.append(" (do not guess an HTTP port)\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (section.isEmpty()) {
|
||||
@ -402,6 +408,18 @@ public class TeamDispatchService {
|
||||
.append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n");
|
||||
}
|
||||
|
||||
static String generatedFileInspectionPath(String url) {
|
||||
String prefix = "/api/v1/files/generated/";
|
||||
if (url == null || !url.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
String fileId = url.substring(prefix.length());
|
||||
if (!fileId.matches("[A-Za-z0-9-]+")) {
|
||||
return null;
|
||||
}
|
||||
return "../generated-files/" + fileId;
|
||||
}
|
||||
|
||||
/** Push a task event onto the team channel and the lead conversation's stream. */
|
||||
private void broadcast(TeamTaskEntity task, String event, Map<String, Object> extra) {
|
||||
eventChannel.publishTaskEvent(task, event, extra);
|
||||
|
||||
@ -391,15 +391,25 @@ public class TeamTaskService {
|
||||
* @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) {
|
||||
public synchronized boolean addComment(Long taskId, String authorType, String authorId,
|
||||
String commentType, String content) {
|
||||
TeamTaskEntity task = requireTask(taskId);
|
||||
String normalizedType = commentType == null ? COMMENT_NOTE : commentType;
|
||||
String checkpointKey = checkpointEvidenceKey(content);
|
||||
if (COMMENT_NOTE.equals(normalizedType)
|
||||
&& checkpointKey != null
|
||||
&& checkpointTerminalTag(task) != null
|
||||
&& hasCheckpointEvidence(taskId, checkpointKey)) {
|
||||
log.debug("Skipped duplicate checkpoint evidence {} on team task {}",
|
||||
checkpointKey, taskId);
|
||||
return false;
|
||||
}
|
||||
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.setCommentType(normalizedType);
|
||||
comment.setContent(content);
|
||||
commentMapper.insert(comment);
|
||||
recordEvent(task.getTeamId(), taskId,
|
||||
@ -424,20 +434,46 @@ public class TeamTaskService {
|
||||
.orderByAsc(TeamTaskCommentEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/** Persist a note once, keyed by an exact stable content value. */
|
||||
/** Persist a note once, using a semantic checkpoint key when present. */
|
||||
@Transactional
|
||||
public synchronized boolean addCommentOnce(Long taskId, String authorType, String authorId,
|
||||
String commentType, String content) {
|
||||
Long existing = commentMapper.selectCount(Wrappers.<TeamTaskCommentEntity>lambdaQuery()
|
||||
.eq(TeamTaskCommentEntity::getTaskId, taskId)
|
||||
.eq(TeamTaskCommentEntity::getContent, content));
|
||||
if (existing != null && existing > 0) {
|
||||
String checkpointKey = checkpointEvidenceKey(content);
|
||||
boolean exists = checkpointKey == null
|
||||
? hasExactComment(taskId, content)
|
||||
: hasCheckpointEvidence(taskId, checkpointKey);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
addComment(taskId, authorType, authorId, commentType, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean hasExactComment(Long taskId, String content) {
|
||||
Long existing = commentMapper.selectCount(Wrappers.<TeamTaskCommentEntity>lambdaQuery()
|
||||
.eq(TeamTaskCommentEntity::getTaskId, taskId)
|
||||
.eq(TeamTaskCommentEntity::getContent, content));
|
||||
return existing != null && existing > 0;
|
||||
}
|
||||
|
||||
private boolean hasCheckpointEvidence(Long taskId, String checkpointKey) {
|
||||
Long existing = commentMapper.selectCount(Wrappers.<TeamTaskCommentEntity>lambdaQuery()
|
||||
.eq(TeamTaskCommentEntity::getTaskId, taskId)
|
||||
.like(TeamTaskCommentEntity::getContent, checkpointKey));
|
||||
return existing != null && existing > 0;
|
||||
}
|
||||
|
||||
static String checkpointEvidenceKey(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Matcher matcher = Pattern.compile("(?i)\\[checkpoint:(R\\d{3,})]\\s*acknowledged")
|
||||
.matcher(content);
|
||||
return matcher.find()
|
||||
? "[checkpoint:" + matcher.group(1).toUpperCase() + "] acknowledged"
|
||||
: null;
|
||||
}
|
||||
|
||||
// ==================== timeline events ====================
|
||||
|
||||
/** Timeline detail cap, matching the column width. */
|
||||
|
||||
@ -236,7 +236,7 @@ class TeamDispatchServiceTest {
|
||||
TeamTaskEntity completed = task(1L, MEMBER_A);
|
||||
completed.setStatus(TeamTaskStatus.COMPLETED);
|
||||
TeamTaskCommentEntity evidence = new TeamTaskCommentEntity();
|
||||
evidence.setContent("[checkpoint:R300] acknowledged");
|
||||
evidence.setContent("运行台账终点: [checkpoint:R300] acknowledged");
|
||||
when(taskService.getTask(1L)).thenReturn(running, completed);
|
||||
when(taskService.checkpointTerminalTag(running)).thenReturn("R300");
|
||||
when(taskService.listComments(1L)).thenReturn(List.of(evidence));
|
||||
@ -390,8 +390,14 @@ class TeamDispatchServiceTest {
|
||||
assertTrue(section.contains("[Prerequisite results]"));
|
||||
assertTrue(section.contains("pricing collected: 3 competitors"));
|
||||
assertTrue(section.contains("prices.xlsx → /api/v1/files/generated/x"));
|
||||
assertTrue(section.contains("Inspect locally: ../generated-files/x"));
|
||||
assertTrue(section.contains("do not guess an HTTP port"));
|
||||
assertFalse(section.contains("#2"), "vanished blockers leave no trace");
|
||||
|
||||
assertNull(TeamDispatchService.generatedFileInspectionPath("https://example.com/file"));
|
||||
assertNull(TeamDispatchService.generatedFileInspectionPath(
|
||||
"/api/v1/files/generated/../../secret"));
|
||||
|
||||
// No blockers → no section at all.
|
||||
StringBuilder plain = new StringBuilder();
|
||||
service.appendPrerequisiteResults(plain, task(4L, MEMBER_A));
|
||||
|
||||
@ -435,6 +435,24 @@ class TeamTaskServiceTest {
|
||||
verify(commentMapper, times(1)).insert(any(TeamTaskCommentEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("checkpoint notes are deduplicated by their embedded stable key")
|
||||
void checkpointCommentIsSemanticallyIdempotent() {
|
||||
TeamTaskEntity tracker = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||||
tracker.setSubject("R001-R010 共享跟踪检查点");
|
||||
when(taskMapper.selectById(5L)).thenReturn(tracker);
|
||||
when(commentMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
assertFalse(service.addComment(5L, TeamTaskService.AUTHOR_AGENT, "2",
|
||||
TeamTaskService.COMMENT_NOTE,
|
||||
"[运行台账] R001: [checkpoint:R001] acknowledged"));
|
||||
|
||||
verify(commentMapper, never()).insert(any(TeamTaskCommentEntity.class));
|
||||
assertEquals("[checkpoint:R001] acknowledged",
|
||||
TeamTaskService.checkpointEvidenceKey(
|
||||
"[运行台账] [CHECKPOINT:r001] acknowledged"));
|
||||
}
|
||||
|
||||
// ==================== circuit breaker ====================
|
||||
|
||||
@Test
|
||||
|
||||
Loading…
Reference in New Issue
Block a user