From b04b0f673ba6801847191d9509d3e47f9a85a451 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 13 Sep 2026 21:21:45 +0800 Subject: [PATCH] fix(goal): preserve criteria initialized while bootstrap is in flight --- .../mate/goal/service/GoalServiceImpl.java | 8 +++- .../goal/GoalPersistenceIntegrationTest.java | 18 +++++++ .../mate/goal/service/GoalServiceTest.java | 47 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 85a3882e..66b5f80e 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -506,11 +506,17 @@ public class GoalServiceImpl implements GoalService { /** * Compute the next criteria JSON for a record-evaluation write, or * {@code null} when the result carries no checklist change. Bootstrap - * results replace the list with the freshly derived draft; verdict + * results initialize a still-empty list with the derived draft; verdict * results merge their per-criterion delta into the locked-row list. */ private String nextCriteriaJson(GoalEntity fresh, GoalEvaluationResult result) { if (result.bootstrapCriteria() != null && !result.bootstrapCriteria().isEmpty()) { + // A user append or another evaluator may have initialized the + // checklist while this model call ran. The fresh canonical list + // wins, including after an optimistic-lock retry. + if (!GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper).isEmpty()) { + return null; + } return GoalCriteriaCodec.serialize(result.bootstrapCriteria(), objectMapper); } if (result.criterionVerdicts() != null && !result.criterionVerdicts().isEmpty()) { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index 3d4e722c..5e6dea56 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -77,6 +77,24 @@ class GoalPersistenceIntegrationTest { return r; } + @Test + void lateBootstrapCannotReplaceUserCriterionCommittedWhileModelWasRunning() { + GoalEntity created = goalService.create(req("bootstrap-append-boundary", "prepare a report"), "alice"); + // The evaluator started with an empty checklist. A user append commits + // before its delayed bootstrap result reaches recordEvaluation. + goalService.appendCriterion(created.getId(), "include the user requested appendix", "alice"); + var delayed = new vip.mate.goal.model.GoalEvaluationResult(0.0, "checklist created", "continue", false, + "fixture", 1, 0, java.util.List.of(), java.util.List.of( + new vip.mate.goal.model.GoalCriterion("C1", "model draft", false, ""))); + goalService.recordEvaluation(created.getId(), delayed, 2, 1); + GoalEntity saved = goalService.getById(created.getId()); + var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(saved.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper()); + assertEquals(1, criteria.size()); + assertEquals("include the user requested appendix", criteria.getFirst().text()); + assertEquals(1, saved.getEvalLlmCallsUsed()); + assertEquals(2, saved.getAgentLlmCallsUsed()); + } + @Test @DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv") void status_persistsAsLowercaseString() { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index a17e3092..476117ad 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -618,6 +618,53 @@ class GoalServiceTest { verify(goalMapper, never()).selectById(any()); } + private GoalEvaluationResult bootstrapEvaluation() { + return new GoalEvaluationResult(0.0, "checklist created", "continue", false, "fixture", 1, 0, + java.util.List.of(), java.util.List.of( + new vip.mate.goal.model.GoalCriterion("C1", "model draft", false, ""))); + } + + @Test + void bootstrapInitializesStillEmptyChecklist() { + GoalEntity empty = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(empty); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); + ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), writes.capture()); + assertTrue(setsProperty(writes.getValue(), "criteria")); + assertTrue(writes.getValue().getParamNameValuePairs().values().stream() + .anyMatch(value -> String.valueOf(value).contains("model draft"))); + } + + @Test + void lateBootstrapPreservesChecklistEstablishedByUser() { + GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); + fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"user requirement\",\"passed\":false,\"evidence\":\"\"}]"); + when(goalMapper.selectById(1L)).thenReturn(fresh); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); + ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), writes.capture()); + assertFalse(setsProperty(writes.getValue(), "criteria"), "late bootstrap must preserve current user criteria"); + assertTrue(writes.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); + } + + @Test + void bootstrapRechecksEmptyChecklistAfterCasConflict() { + GoalEntity empty = persisted(1L, GoalStatus.ACTIVE); + GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); + fresh.setVersion(1); + fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"concurrent user requirement\",\"passed\":false,\"evidence\":\"\"}]"); + when(goalMapper.selectById(1L)).thenReturn(empty, empty, fresh, fresh); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1); + service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); + ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper, times(2)).update(any(), writes.capture()); + assertTrue(setsProperty(writes.getAllValues().get(0), "criteria")); + assertFalse(setsProperty(writes.getAllValues().get(1), "criteria"), "retry must not replace newly established criteria"); + } + // ==================== criteria checklist ==================== @Test