mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
990 lines
48 KiB
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());
|
|
}
|
|
}
|