fix(goal): preserve criteria initialized while bootstrap is in flight

This commit is contained in:
mateaix 2026-09-13 21:21:45 +08:00
parent bb3d477aa2
commit b04b0f673b
3 changed files with 72 additions and 1 deletions

View File

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

View File

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

View File

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