fix(team): make checkpoint evidence idempotent

This commit is contained in:
mateaix 2026-08-16 15:59:26 +08:00
parent fb7cb6c04a
commit 6e8e7db31e
4 changed files with 88 additions and 10 deletions

View File

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

View File

@ -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. */

View File

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

View File

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