mateclaw/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java

990 lines
48 KiB
Java

package vip.mate.goal.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalCriteriaCodec;
import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalEventType;
import vip.mate.goal.model.GoalStatus;
import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.repository.GoalEventMapper;
import vip.mate.goal.repository.GoalMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Default implementation. Concurrency safety relies on:
* <ol>
* <li>Service-level pre-check + DB-level unique index for "at most one
* active goal per conversation" — see V120 migration.</li>
* <li>Per-write optimistic lock via {@code WHERE version=?} on
* state-mutating updates; retried up to 3 times on contention.</li>
* <li>{@code @Transactional} on every write so the goal row update and
* the matching event-log insert succeed or fail together.</li>
* </ol>
*/
@Slf4j
@Service
public class GoalServiceImpl implements GoalService {
private static final int OPTIMISTIC_LOCK_MAX_RETRIES = 3;
private final GoalMapper goalMapper;
private final GoalEventMapper eventMapper;
private final GoalProperties properties;
private final AuditEventService auditEventService;
private final ObjectMapper objectMapper;
private ApplicationEventPublisher applicationEventPublisher;
private PlatformTransactionManager transactionManager;
/**
* Optional — only set when the memory subsystem is wired. On goal
* completion we forward a synthetic "[goal completed] ..." turn so
* the memory dreaming pass can fold the outcome into long-term
* memory and the user can ask about it later. Failures are best
* effort: memory should never block the state-machine write.
*/
private vip.mate.memory.spi.MemoryManager memoryManager;
private GoalJsonBindingService jsonBindings;
private ManagedGoalJsonService managedArtifacts;
@Autowired
public void setManagedArtifacts(ManagedGoalJsonService managedArtifacts) { this.managedArtifacts = managedArtifacts; }
@Autowired
public void setJsonBindings(GoalJsonBindingService jsonBindings) {
this.jsonBindings = jsonBindings;
}
public GoalServiceImpl(GoalMapper goalMapper,
GoalEventMapper eventMapper,
GoalProperties properties,
AuditEventService auditEventService,
ObjectMapper objectMapper) {
this.goalMapper = goalMapper;
this.eventMapper = eventMapper;
this.properties = properties;
this.auditEventService = auditEventService;
this.objectMapper = objectMapper;
}
@Autowired(required = false)
public void setMemoryManager(vip.mate.memory.spi.MemoryManager memoryManager) {
this.memoryManager = memoryManager;
}
@Autowired(required = false)
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.applicationEventPublisher = publisher;
}
@Autowired(required = false)
public void setTransactionManager(PlatformTransactionManager manager) {
this.transactionManager = manager;
}
// ==================== CRUD ====================
@Override
@Transactional
public GoalEntity create(GoalCreateRequest req, String username) {
validateCreate(req);
// Service-level pre-check is a UX nicety only — the DB unique
// index is the source of truth. Two concurrent creates can both
// pass this check; the second insert will surface a
// DuplicateKeyException and we map it to 409.
GoalEntity active = findActiveByConversation(req.getConversationId());
if (active != null) {
throw new MateClawException("err.goal.conversation_has_active", 409,
"Conversation already has an active goal: " + active.getId());
}
GoalEntity entity = new GoalEntity();
entity.setConversationId(req.getConversationId());
entity.setAgentId(req.getAgentId());
entity.setWorkspaceId(req.getWorkspaceId());
entity.setCreatedBy(username);
entity.setTitle(req.getTitle().trim());
entity.setDescription(req.getDescription() != null ? req.getDescription() : "");
entity.setExitCriteria(req.getExitCriteria());
entity.setSuccessCheckPrompt(req.getSuccessCheckPrompt());
entity.setStatus(GoalStatus.ACTIVE);
boolean persistent = persistentOnCreate(req);
entity.setPersistentExecution(persistent);
entity.setTurnBudget(req.getTurnBudget() != null
? req.getTurnBudget() : persistent ? 0 : properties.getDefaultTurnBudget());
entity.setTurnsUsed(0);
entity.setLlmCallBudget(req.getLlmCallBudget() != null
? req.getLlmCallBudget() : persistent ? 0 : properties.getDefaultLlmCallBudget());
entity.setAgentLlmCallsUsed(0);
entity.setEvalLlmCallsUsed(0);
// Three-state default: explicit true/false is honored; null falls
// back to the configured create-time default.
entity.setAutoFollowupEnabled(req.getAutoFollowupEnabled() != null
? req.getAutoFollowupEnabled()
: properties.isDefaultAutoFollowup());
entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds());
// Normalize any caller-supplied checklist: assign C1..Cn, force
// passed=false, clear evidence. Empty/omitted -> null column so the
// first evaluation bootstraps the list.
entity.setCriteria(serializeCriteria(normalizeInitialCriteria(req.getCriteria())));
entity.setVersion(0);
entity.setDeleted(0);
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
try {
goalMapper.insert(entity);
} catch (DuplicateKeyException dup) {
// Lost the race against another create. The unique index hit
// is the authoritative "conversation already has active goal".
throw new MateClawException("err.goal.conversation_has_active", 409,
"Conversation already has an active goal (DB unique index)");
}
writeEvent(entity.getId(), GoalEventType.CREATED, null, Map.of(
"title", entity.getTitle(),
"persistentExecution", persistent,
"turnBudget", entity.getTurnBudget(),
"llmCallBudget", entity.getLlmCallBudget(),
"by", username));
recordAudit("goal.created", entity, Map.of("by", username, "title", entity.getTitle()));
return entity;
}
@Override
public GoalEntity getById(Long id) {
GoalEntity g = goalMapper.selectById(id);
if (g == null) {
throw new MateClawException("err.goal.not_found", 404, "Goal not found: " + id);
}
return g;
}
@Override
public GoalEntity findActiveByConversation(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return goalMapper.selectOne(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.eq(GoalEntity::getStatus, GoalStatus.ACTIVE)
.last("LIMIT 1"));
}
@Override
public GoalEntity findLatestByConversation(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return goalMapper.selectOne(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.orderByDesc(GoalEntity::getCreateTime)
.orderByDesc(GoalEntity::getId)
.last("LIMIT 1"));
}
@Override
public List<GoalEntity> listByConversation(String conversationId, Long beforeId, int limit) {
if (conversationId == null || conversationId.isBlank()) return List.of();
return goalMapper.selectList(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.lt(beforeId != null, GoalEntity::getId, beforeId)
.orderByDesc(GoalEntity::getId)
.last("LIMIT " + Math.max(1, Math.min(50, limit))));
}
@Override
public List<GoalEntity> list(String status, String username, int limit) {
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
.orderByDesc(GoalEntity::getCreateTime);
if (username != null && !username.isBlank()) {
w.eq(GoalEntity::getCreatedBy, username);
}
if (status != null && !status.isBlank()) {
GoalStatus s;
try {
s = GoalStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException ex) {
throw new MateClawException("err.goal.bad_status", 400, "Unknown status: " + status);
}
w.eq(GoalEntity::getStatus, s);
}
w.last("LIMIT " + Math.max(1, Math.min(200, limit)));
return goalMapper.selectList(w);
}
@Override
@Transactional
public GoalEntity update(Long id, GoalUpdateRequest req, String username) {
// Validate mode and budgets together against each freshly read CAS row.
GoalEntity updated = retryOptimistic(id, "update", fresh -> {
ensureNotTerminal(fresh, "update");
boolean persistent = req.getPersistentExecution() != null
? req.getPersistentExecution() : Boolean.TRUE.equals(fresh.getPersistentExecution());
Integer turns = req.getTurnBudget() != null ? req.getTurnBudget() : fresh.getTurnBudget();
Integer calls = req.getLlmCallBudget() != null ? req.getLlmCallBudget() : fresh.getLlmCallBudget();
if (turns != null && (req.getTurnBudget() != null || req.getPersistentExecution() != null))
validateBudget(turns, "turnBudget", persistent);
if (calls != null && (req.getLlmCallBudget() != null || req.getPersistentExecution() != null))
validateBudget(calls, "llmCallBudget", persistent);
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh);
boolean changed = false;
if (req.getPersistentExecution() != null) {
w.set(GoalEntity::getPersistentExecution, req.getPersistentExecution()); changed = true;
}
if (req.getTitle() != null && !req.getTitle().isBlank()) {
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
}
if (req.getDescription() != null) {
w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
}
if (req.getExitCriteria() != null) {
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
}
if (req.getSuccessCheckPrompt() != null) {
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
}
if (req.getTurnBudget() != null) {
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
}
if (req.getLlmCallBudget() != null) {
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
}
if (req.getAutoFollowupEnabled() != null) {
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
}
if (req.getFollowupCooldownSeconds() != null) {
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
changed = true;
}
if (!changed) {
return null; // idempotent no-op
}
boolean exitsChanged = req.getExitCriteria() != null
&& !Objects.equals(req.getExitCriteria(), fresh.getExitCriteria());
boolean definitionChanged = exitsChanged
|| req.getPersistentExecution() != null
&& !Objects.equals(req.getPersistentExecution(), Boolean.TRUE.equals(fresh.getPersistentExecution()))
|| req.getTitle() != null && !req.getTitle().isBlank()
&& !Objects.equals(req.getTitle().trim(), fresh.getTitle())
|| req.getDescription() != null && !Objects.equals(req.getDescription(), fresh.getDescription())
|| req.getSuccessCheckPrompt() != null
&& !Objects.equals(req.getSuccessCheckPrompt(), fresh.getSuccessCheckPrompt());
if (definitionChanged) {
// Replacing the free-text exit definition requires a new draft.
// Other context edits preserve user criterion text but revoke its old verdicts.
String criteria = exitsChanged ? null : GoalCriteriaCodec.serialize(
GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper).stream()
.map(c -> new GoalCriterion(c.id(), c.text(), false, "")).toList(), objectMapper);
w.set(GoalEntity::getEvaluationRevision, Math.addExact(fresh.getEvaluationRevision(), 1L))
.set(GoalEntity::getCriteria, criteria)
.set(GoalEntity::getCompletionScore, 0.0)
.set(GoalEntity::getProgressSummary, "Goal definition changed; reevaluation required");
}
bumpVersionAndTime(w);
return w;
});
recordAudit("goal.updated", updated, Map.of("by", username,
"evaluationRevision", updated.getEvaluationRevision()));
return updated;
}
@Override
public List<GoalEventEntity> listEvents(Long goalId, int limit) {
return eventMapper.selectList(new LambdaQueryWrapper<GoalEventEntity>()
.eq(GoalEventEntity::getGoalId, goalId)
.orderByDesc(GoalEventEntity::getId)
.last("LIMIT " + Math.max(1, Math.min(500, limit))));
}
// ==================== State machine ====================
@Override
@Transactional
public GoalEntity pause(Long id, String username) {
return flipStatus(id, GoalStatus.ACTIVE, GoalStatus.PAUSED,
GoalEventType.PAUSED, "goal.paused", username);
}
@Override
@Transactional
public GoalEntity waitForInput(Long id, String reason, String username) {
if (reason == null || reason.isBlank()) {
throw new MateClawException("err.goal.wait_reason_required", 400,
"A precise reason describing the missing input or permission is required");
}
String trimmed = reason.trim();
String boundedReason = trimmed.length() <= 1000 ? trimmed : trimmed.substring(0, 997) + "...";
GoalEntity paused = retryOptimistic(id, "waitForInput", fresh -> {
if (fresh.getStatus() != GoalStatus.ACTIVE || !Boolean.TRUE.equals(fresh.getPersistentExecution())) {
throw new MateClawException("err.goal.wait_requires_active_persistent", 409,
"Waiting for input requires an active persistent goal");
}
LambdaUpdateWrapper<GoalEntity> update = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.PAUSED)
.set(GoalEntity::getProgressSummary, "Waiting for input: " + boundedReason);
bumpVersionAndTime(update);
return update;
});
Map<String, Object> detail = Map.of("by", username, "reason", boundedReason,
"state", "waiting_input", "from", "active", "to", "paused");
writeEvent(id, GoalEventType.PAUSED, null, detail);
recordAudit("goal.waiting_input", paused, detail);
return paused;
}
@Override
@Transactional
public GoalEntity resume(Long id, String username) {
GoalEntity resumed = flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE,
GoalEventType.RESUMED, "goal.resumed", username);
if (Boolean.TRUE.equals(resumed.getPersistentExecution()) && applicationEventPublisher != null) {
applicationEventPublisher.publishEvent(new GoalExecutionSignal.Resume(id));
}
return resumed;
}
@Override
@Transactional
public GoalEntity abandon(Long id, String username) {
// Allows abandon from both ACTIVE and PAUSED.
GoalEntity updated = retryOptimistic(id, "abandon", fresh -> {
ensureNotTerminal(fresh, "abandon");
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.ABANDONED);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username));
recordAudit("goal.abandoned", updated, Map.of("by", username));
return updated;
}
@Override
@Transactional
public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
return completeGoal(id, result, false, null, false);
}
@Override
@Transactional
public GoalEntity markEvaluatedCompleted(Long id, GoalEvaluationResult result) {
if (result == null || !result.completed()
|| !GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires a completed evaluation");
}
return completeGoal(id, result, true, null, false);
}
@Override
@Transactional
public GoalEntity markRuntimeCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) {
return completeGoal(id, result, false, origin, true);
}
@Override
@Transactional
public GoalEntity markRuntimeEvaluatedCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) {
if (result == null || !result.completed()
|| !GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires a completed evaluation");
}
return completeGoal(id, result, true, origin, true);
}
private GoalEntity completeGoal(Long id, GoalEvaluationResult result, boolean evaluated,
vip.mate.agent.context.ChatOrigin origin, boolean runtimeCaller) {
boolean[] transitioned = {false};
var jsonProof = new java.util.concurrent.atomic.AtomicReference<List<GoalJsonBindingService.State>>(List.of());
GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
// A failed CAS may retry against another worker's completed row.
transitioned[0] = false;
jsonProof.set(List.of());
if (fresh.getStatus().isTerminal()) {
if (evaluated && fresh.getStatus() != GoalStatus.COMPLETED) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion cannot replace another terminal state");
}
return null; // idempotent
}
ManagedGoalJsonService.RuntimeScope runtime = null;
if (runtimeCaller && fresh.isJsonAcceptanceRequired()) {
if (managedArtifacts == null) throw new MateClawException(409, "Managed JSON runtime verification is unavailable");
runtime = managedArtifacts.runtimeGoal(origin);
if (runtime.goal().id() != fresh.getId()) throw new MateClawException(403, "Completion runtime goal mismatch");
}
if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires the current evaluation definition revision");
}
boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution());
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
// Rechecked against the fresh row on every CAS retry. A stale
// evaluator result must never force-pass newly added criteria.
if ((persistent || evaluated) && (fresh.getStatus() != GoalStatus.ACTIVE
|| !GoalCriteriaCodec.allPassed(existing))) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Completion requires an active goal and evidence for every current criterion");
}
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
// Preserve automatically evaluated and persistent evidence verbatim. Legacy manual
// completion retains its historical force-passed checklist snapshot.
if (!persistent && !evaluated && !existing.isEmpty()) {
List<GoalCriterion> allPassed = existing.stream()
.map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true,
c.evidence() == null || c.evidence().isBlank()
? "marked complete" : c.evidence()))
.toList();
w.set(GoalEntity::getCriteria, GoalCriteriaCodec.serialize(allPassed, objectMapper));
}
if (fresh.isJsonAcceptanceRequired()) {
if (jsonBindings == null) throw new MateClawException("err.goal.json_acceptance_required", 409,
"Managed JSON verification service is unavailable");
jsonProof.set(jsonBindings.requireForCompletion(fresh));
if (runtime != null) ManagedGoalJsonService.verifyLease(runtime);
}
bumpVersionAndTime(w);
transitioned[0] = true;
return w;
});
if (!transitioned[0]) return g;
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
if (g.isJsonAcceptanceRequired()) {
detail.put("jsonAcceptanceRequired", true);
detail.put("jsonBindings", jsonProof.get());
}
writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, detail);
syncCompletionMemoryAfterCommit(g, result);
return g;
}
private void syncCompletionMemoryAfterCommit(GoalEntity goal, GoalEvaluationResult result) {
var target = memoryManager;
if (target == null) return;
// Snapshot values before returning the mutable entity to the caller.
Long agentId = goal.getAgentId();
String conversationId = goal.getConversationId();
String subject = "[goal completed] " + goal.getTitle();
String summary = goal.getProgressSummary() != null && !goal.getProgressSummary().isBlank()
? goal.getProgressSummary() : "Final score: " + (result != null ? result.score() : "");
Runnable sync = () -> {
try {
if (transactionManager != null) {
// afterCommit still has the old transaction's resources bound.
// Adapter DB writes need their own transaction to commit reliably.
TransactionTemplate independent = new TransactionTemplate(transactionManager);
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
independent.executeWithoutResult(status -> target.syncAll(agentId, conversationId, subject, summary));
} else {
target.syncAll(agentId, conversationId, subject, summary);
}
} catch (Exception e) {
log.debug("[GoalService] memory syncAll on goal completion failed: {}", e.getMessage());
}
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { sync.run(); }
});
} else {
log.debug("[GoalService] skipped completion memory: no commit synchronization available");
}
} else {
sync.run(); // Direct/non-transactional callers retain best-effort behavior.
}
}
@Override
@Transactional
public GoalEntity markExhausted(Long id, String reason) {
GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> {
if (fresh.getStatus().isTerminal()) return null;
boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution());
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, persistent ? GoalStatus.PAUSED : GoalStatus.EXHAUSTED);
if (persistent) {
w.set(GoalEntity::getProgressSummary, "Paused: "
+ (reason != null ? reason : "budget limit")
+ ". Increase the budget and resume to continue.");
}
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason != null ? reason : "unknown");
detail.put("turnsUsed", g.getTurnsUsed());
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
boolean persistent = Boolean.TRUE.equals(g.getPersistentExecution());
writeEvent(id, persistent ? GoalEventType.PAUSED : GoalEventType.EXHAUSTED, null, detail);
recordAudit(persistent ? "goal.paused" : "goal.exhausted", g, detail);
return g;
}
// ==================== Evaluation bookkeeping ====================
@Override
@Transactional
public void recordEvaluation(Long id, GoalEvaluationResult result,
int agentLlmCallsDelta, int evalLlmCallsDelta) {
// Cheap pre-check so terminal goals also skip the event write — the
// in-loop guard below still protects against a status flip during a
// contended retry, but it would also issue the event log entry that
// a no-op skip should not produce.
GoalEntity initial = goalMapper.selectById(id);
if (initial == null || initial.getStatus().isTerminal()) return;
int agentDelta = Math.max(0, agentLlmCallsDelta);
int evalDelta = Math.max(0, evalLlmCallsDelta);
GoalEntity g = retryOptimistic(id, "recordEvaluation", fresh -> {
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.setSql("turns_used = turns_used + 1")
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
// Late model results still consume usage, but cannot overwrite a
// persistent pause/input boundary established while the call ran.
if (result != null && result.evaluationRevision() == fresh.getEvaluationRevision()
&& (!Boolean.TRUE.equals(fresh.getPersistentExecution()) || fresh.getStatus() == GoalStatus.ACTIVE)) {
// Persist the checklist by carrier: bootstrap writes the fresh
// draft; verdict merges the per-criterion delta into the
// current list (re-read on the locked `fresh` to avoid races).
String criteriaJson = nextCriteriaJson(fresh, result);
if (criteriaJson != null) {
w.set(GoalEntity::getCriteria, criteriaJson);
}
List<GoalCriterion> current = GoalCriteriaCodec.parse(
criteriaJson != null ? criteriaJson : fresh.getCriteria(), objectMapper);
if (!current.isEmpty() && !GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision())) {
// The model may have seen fewer criteria. Derive the public
// projection from the same fresh checklist this CAS writes.
setChecklistProgress(w, current);
} else {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
}
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>();
if (result != null) {
detail.put("completionScore", g.getCompletionScore());
detail.put("gap", g.getProgressSummary());
detail.put("decision", result.decision());
detail.put("evaluatorScore", result.score());
detail.put("evaluatorGap", result.gap());
detail.put("evaluatedRevision", result.evaluationRevision());
detail.put("currentEvaluationRevision", g.getEvaluationRevision());
detail.put("staleEvaluation", result.evaluationRevision() != g.getEvaluationRevision());
detail.put("evaluatorModel", result.evaluatorModel());
detail.put("latencyMs", result.latencyMs());
}
detail.put("agentLlmCallsDelta", agentDelta);
detail.put("evalLlmCallsDelta", evalDelta);
// Full checklist (array) so the timeline / SSE consumer never sees the
// raw String column or has to reconstruct from the per-round delta.
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
writeEvent(id, GoalEventType.EVALUATED, null, detail);
}
/**
* Compute the next criteria JSON for a record-evaluation write, or
* {@code null} when the result carries no checklist change. Bootstrap
* 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()) {
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
if (existing.isEmpty()) {
return null;
}
return GoalCriteriaCodec.serialize(
GoalCriteriaCodec.merge(existing, result.criterionVerdicts()), objectMapper);
}
return null;
}
@Override
public boolean isBudgetExhausted(GoalEntity goal) {
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE;
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
if ((!persistent || turnBudget != 0) && turns >= turnBudget) return true;
int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE;
return (!persistent || callBudget != 0) && goal.totalLlmCallsUsed() >= callBudget;
}
@Override
public String exhaustionReason(GoalEntity goal) {
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE;
if ((!Boolean.TRUE.equals(goal.getPersistentExecution()) || turnBudget != 0)
&& turns >= turnBudget) return "turn_budget";
return "llm_call_budget";
}
@Override
@Transactional
public void recordFollowupInjected(Long id, String prompt) {
// Mirror recordEvaluation's pre-check so a late followup on a
// terminal goal does not produce an event-log entry.
GoalEntity initial = goalMapper.selectById(id);
if (initial == null || initial.getStatus().isTerminal()) return;
GoalEntity g = retryOptimistic(id, "recordFollowupInjected", fresh -> {
if (fresh.getStatus().isTerminal()) return null;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.FOLLOWUP_INJECTED, null, Map.of(
"prompt", prompt != null ? prompt : "",
"turnsUsed", g.getTurnsUsed()));
}
@Override
@Transactional
public GoalEntity appendCriterion(Long id, String criterion, String username) {
if (criterion == null || criterion.isBlank()) {
throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty");
}
String trimmed = criterion.trim();
// Double-write: append a structured criterion (authoritative) and
// mirror the text into exit_criteria for backward compatibility /
// human readability. New id is the current max ordinal + 1. Merge
// against the freshly refetched row so concurrent appends don't clobber.
GoalEntity g = retryOptimistic(id, "appendCriterion", fresh -> {
ensureNotTerminal(fresh, "appendCriterion");
List<GoalCriterion> list = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
list.add(new GoalCriterion("C" + (list.size() + 1), trimmed, false, ""));
String criteriaJson = GoalCriteriaCodec.serialize(GoalCriteriaCodec.reindex(list), objectMapper);
String existing = fresh.getExitCriteria() != null ? fresh.getExitCriteria() : "";
String mergedText = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getCriteria, criteriaJson)
.set(GoalEntity::getExitCriteria, mergedText);
setChecklistProgress(w, list);
bumpVersionAndTime(w);
return w;
});
List<GoalCriterion> full = GoalCriteriaCodec.parse(g.getCriteria(), objectMapper);
String criterionId = full.isEmpty() ? "" : full.get(full.size() - 1).id();
writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
"criterion", trimmed,
"criterionId", criterionId,
"criteria", full,
"by", username));
return g;
}
/** Both evaluation and user edits project the checklist written by this CAS. */
private static void setChecklistProgress(LambdaUpdateWrapper<GoalEntity> update, List<GoalCriterion> criteria) {
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(criteria);
double score = criteria.isEmpty() ? 0.0 : (double) (criteria.size() - remaining.size()) / criteria.size();
String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream()
.map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; "));
update.set(GoalEntity::getCompletionScore, score)
.set(GoalEntity::getProgressSummary, gap);
}
// ==================== Internals ====================
/**
* Normalize a caller-supplied initial checklist: keep only non-blank
* {@code text}, assign stable ids {@code C1..Cn} (ignore any caller ids),
* force {@code passed=false} and clear evidence. Returns {@code null} for
* an empty/null result so the column stays NULL and the first evaluation
* bootstraps the list.
*/
private List<GoalCriterion> normalizeInitialCriteria(List<GoalCriterion> raw) {
if (raw == null || raw.isEmpty()) {
return null;
}
List<GoalCriterion> kept = new java.util.ArrayList<>(raw.size());
for (GoalCriterion c : raw) {
if (c != null && c.text() != null && !c.text().isBlank()) {
kept.add(new GoalCriterion("", c.text().trim(), false, ""));
}
}
return kept.isEmpty() ? null : GoalCriteriaCodec.reindex(kept);
}
/** Serialize a checklist to JSON text, or {@code null} for a null list. */
private String serializeCriteria(List<GoalCriterion> criteria) {
return GoalCriteriaCodec.serialize(criteria, objectMapper);
}
@Override
public GoalResponse toResponse(GoalEntity e) {
if (e == null) {
return null;
}
GoalResponse r = new GoalResponse();
r.setId(e.getId());
r.setConversationId(e.getConversationId());
r.setAgentId(e.getAgentId());
r.setWorkspaceId(e.getWorkspaceId());
r.setCreatedBy(e.getCreatedBy());
r.setTitle(e.getTitle());
r.setDescription(e.getDescription());
r.setExitCriteria(e.getExitCriteria());
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
r.setStatus(e.getStatus());
r.setJsonAcceptanceRequired(e.isJsonAcceptanceRequired());
r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution()));
r.setTurnBudget(e.getTurnBudget());
r.setTurnsUsed(e.getTurnsUsed());
r.setLlmCallBudget(e.getLlmCallBudget());
r.setAgentLlmCallsUsed(e.getAgentLlmCallsUsed());
r.setEvalLlmCallsUsed(e.getEvalLlmCallsUsed());
r.setTotalLlmCallsUsed(e.totalLlmCallsUsed());
r.setProgressSummary(e.getProgressSummary());
r.setCompletionScore(e.getCompletionScore());
r.setLastEvaluationAt(e.getLastEvaluationAt());
r.setAutoFollowupEnabled(e.getAutoFollowupEnabled());
r.setFollowupCooldownSeconds(e.getFollowupCooldownSeconds());
r.setLastFollowupAt(e.getLastFollowupAt());
r.setVersion(e.getVersion());
r.setCreateTime(e.getCreateTime());
r.setUpdateTime(e.getUpdateTime());
// Always an array; empty when the column is null/unparseable.
r.setCriteria(GoalCriteriaCodec.parse(e.getCriteria(), objectMapper));
return r;
}
@Override
public List<GoalResponse> toResponseList(List<GoalEntity> entities) {
if (entities == null) {
return List.of();
}
return entities.stream().map(this::toResponse).toList();
}
private void validateCreate(GoalCreateRequest req) {
if (req == null) {
throw new MateClawException("err.goal.bad_request", 400, "Request body required");
}
if (req.getConversationId() == null || req.getConversationId().isBlank()) {
throw new MateClawException("err.goal.bad_request", 400, "conversationId required");
}
if (req.getAgentId() == null) {
throw new MateClawException("err.goal.bad_request", 400, "agentId required");
}
if (req.getWorkspaceId() == null) {
throw new MateClawException("err.goal.bad_request", 400, "workspaceId required");
}
if (req.getTitle() == null || req.getTitle().isBlank()) {
throw new MateClawException("err.goal.bad_request", 400, "title required");
}
if (req.getTitle().length() > 255) {
throw new MateClawException("err.goal.bad_request", 400, "title too long (>255)");
}
boolean persistent = persistentOnCreate(req);
if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget", persistent);
if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget", persistent);
}
private boolean persistentOnCreate(GoalCreateRequest req) {
return req.getPersistentExecution() != null
? req.getPersistentExecution() : properties.isDefaultPersistentExecution();
}
private static void validateBudget(int v, String name, boolean persistent) {
if (v < 0 || (!persistent && v == 0)) {
throw new MateClawException("err.goal.invalid_budget", 400,
name + (persistent ? " must be >= 0, got " : " must be > 0, got ") + v);
}
}
private void ensureNotTerminal(GoalEntity g, String op) {
if (g.getStatus().isTerminal()) {
throw new MateClawException("err.goal.terminal_state", 409,
"Cannot " + op + " a goal in terminal state " + g.getStatus().getValue());
}
}
/** Build an update wrapper that enforces version match + soft-delete guard. */
private LambdaUpdateWrapper<GoalEntity> baseLockedUpdate(GoalEntity g) {
return new LambdaUpdateWrapper<GoalEntity>()
.eq(GoalEntity::getId, g.getId())
.eq(GoalEntity::getVersion, g.getVersion())
.eq(GoalEntity::getDeleted, 0);
}
private static void bumpVersionAndTime(LambdaUpdateWrapper<GoalEntity> w) {
w.setSql("version = version + 1")
.set(GoalEntity::getUpdateTime, LocalDateTime.now());
}
/**
* Builder that takes the just-refetched goal entity and either returns a
* fully prepared {@link LambdaUpdateWrapper} (with version pinned to the
* fresh row) or returns {@code null} to signal "no-op, treat as success"
* for idempotent skips (e.g. terminal-state guards).
*/
@FunctionalInterface
private interface UpdateBuilder {
LambdaUpdateWrapper<GoalEntity> build(GoalEntity fresh);
}
/**
* Refetch + rebuild + CAS loop. The builder is invoked on each attempt
* against a freshly loaded entity so the {@code WHERE version=?} clause
* always matches the current row version. Returns the post-update entity
* (or the unchanged fresh entity when the builder signals a no-op).
*
* <p>This replaces the earlier single-shot wrapper capture which could
* not recover from the very first CAS miss — once {@code oldVersion}
* went stale, all subsequent retries with the same wrapper were
* doomed. Refetching per attempt is the correct shape for optimistic
* locking: read, build delta against the read, CAS on the read's
* version.
*/
private GoalEntity retryOptimistic(Long id, String op, UpdateBuilder builder) {
for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) {
GoalEntity fresh = goalMapper.selectById(id);
if (fresh == null) {
throw new MateClawException("err.goal.not_found", 404,
"Goal not found: " + id);
}
LambdaUpdateWrapper<GoalEntity> w = builder.build(fresh);
if (w == null) {
// Builder declined to issue a write (idempotent skip). Treat
// as success — callers like markCompleted hit this when the
// goal already moved to a terminal state via another path.
return fresh;
}
int rows = goalMapper.update(null, w);
if (rows > 0) {
return goalMapper.selectById(id);
}
log.debug("[GoalService] Optimistic lock miss on {} (attempt {}/{})",
op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES);
}
throw new MateClawException("err.goal.optimistic_lock_conflict", 409,
"Concurrent modification on " + op + " after "
+ OPTIMISTIC_LOCK_MAX_RETRIES + " retries");
}
private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to,
String eventType, String auditAction, String username) {
GoalEntity g = retryOptimistic(id, to.getValue(), fresh -> {
if (fresh.getStatus() != from) {
throw new MateClawException("err.goal.bad_transition", 409,
"Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue());
}
if (to == GoalStatus.ACTIVE && Boolean.TRUE.equals(fresh.getPersistentExecution())
&& isBudgetExhausted(fresh)) {
throw new MateClawException("err.goal.budget_exhausted", 409,
"Increase the exhausted budget before resuming: " + exhaustionReason(fresh));
}
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, to);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, eventType, null, Map.of("by", username,
"from", from.getValue(), "to", to.getValue()));
recordAudit(auditAction, g, Map.of("by", username));
return g;
}
private void writeEvent(Long goalId, String type, Long messageId, Map<String, Object> detail) {
GoalEventEntity ev = new GoalEventEntity();
ev.setGoalId(goalId);
ev.setEventType(type);
ev.setMessageId(messageId);
ev.setDetailJson(safeJson(detail));
ev.setCreateTime(LocalDateTime.now());
eventMapper.insert(ev);
}
private void recordAudit(String action, GoalEntity g, Map<String, Object> detail) {
try {
auditEventService.record(action, "goal",
String.valueOf(g.getId()), g.getTitle(),
safeJson(detail), g.getWorkspaceId());
} catch (Exception ex) {
log.warn("[GoalService] audit record failed: {}", ex.getMessage());
}
}
private String safeJson(Map<String, Object> m) {
try {
return objectMapper.writeValueAsString(m);
} catch (JsonProcessingException e) {
return "{}";
}
}
/** Returns the time elapsed since the last followup, or null if never. */
public Duration timeSinceLastFollowup(GoalEntity g) {
if (g.getLastFollowupAt() == null) return null;
return Duration.between(g.getLastFollowupAt(), LocalDateTime.now());
}
}