diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java
new file mode 100644
index 00000000..7678f9d5
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java
@@ -0,0 +1,41 @@
+package vip.mate.goal.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Configuration knobs for the persistent-goal subsystem.
+ *
+ *
{@link #enabled} is the master gate: when {@code false} (PR1-4 default)
+ * the StateGraph wiring stays inactive and {@code findActiveByConversation}
+ * still works for tests, but no graph node touches the table. PR5 flips it
+ * to {@code true}.
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "mateclaw.goal")
+public class GoalProperties {
+
+ /** Master switch — when off, the graph never invokes GoalEvaluationNode. */
+ private boolean enabled = false;
+
+ /** Default turn budget when the user doesn't override. */
+ private int defaultTurnBudget = 20;
+
+ /** Default combined (agent + eval) LLM call budget. */
+ private int defaultLlmCallBudget = 200;
+
+ /** Default cooldown between auto-followups in seconds. */
+ private int autoFollowupCooldownSeconds = 0;
+
+ /**
+ * Provider/model id for the evaluator. Empty string means "use the
+ * same model as the chat agent" — convenient for dev, expensive in
+ * production. Operators should point this at a cheap model.
+ */
+ private String evaluatorModel = "";
+
+ /** Max messages from parent conversation included in evaluator prompt. */
+ private int evaluatorContextMessages = 8;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java
new file mode 100644
index 00000000..aa87642e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java
@@ -0,0 +1,159 @@
+package vip.mate.goal.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.common.result.R;
+import vip.mate.exception.MateClawException;
+import vip.mate.goal.model.GoalCreateRequest;
+import vip.mate.goal.model.GoalEntity;
+import vip.mate.goal.model.GoalEventEntity;
+import vip.mate.goal.model.GoalUpdateRequest;
+import vip.mate.goal.service.GoalService;
+import vip.mate.workspace.conversation.ConversationService;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * REST surface for persistent goals.
+ *
+ *
Authorization model: every write authorizes the caller against the
+ * goal's bound conversation via
+ * {@link ConversationService#isConversationOwner}, mirroring the
+ * {@code SubagentController} pattern. Read endpoints follow the same rule
+ * so non-owners cannot enumerate someone else's goals by guessing IDs.
+ *
+ *
Snowflake IDs are serialized as strings by the project-wide Jackson
+ * config (see CLAUDE.md "ID Handling"); request DTOs accept either number
+ * or string forms.
+ */
+@Slf4j
+@Tag(name = "Goals")
+@RestController
+@RequestMapping("/api/v1/goals")
+@RequiredArgsConstructor
+public class GoalController {
+
+ private final GoalService goalService;
+ private final ConversationService conversationService;
+
+ @Operation(summary = "Create a persistent goal for a conversation")
+ @PostMapping
+ public R create(@RequestBody GoalCreateRequest req, Authentication auth) {
+ String username = currentUsername(auth);
+ requireOwner(req.getConversationId(), username);
+ GoalEntity g = goalService.create(req, username);
+ return R.ok(g);
+ }
+
+ @Operation(summary = "Get the active goal bound to a conversation (or null)")
+ @GetMapping("/by-conversation/{conversationId}")
+ public R findActive(@PathVariable String conversationId, Authentication auth) {
+ requireOwner(conversationId, currentUsername(auth));
+ return R.ok(goalService.findActiveByConversation(conversationId));
+ }
+
+ @Operation(summary = "Get goal detail by id")
+ @GetMapping("/{id}")
+ public R get(@PathVariable Long id, Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ requireOwner(g.getConversationId(), currentUsername(auth));
+ return R.ok(g);
+ }
+
+ @Operation(summary = "Get the event timeline for a goal")
+ @GetMapping("/{id}/events")
+ public R> events(@PathVariable Long id,
+ @RequestParam(defaultValue = "100") int limit,
+ Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ requireOwner(g.getConversationId(), currentUsername(auth));
+ return R.ok(goalService.listEvents(id, limit));
+ }
+
+ @Operation(summary = "List goals (optionally filtered by status)")
+ @GetMapping
+ public R> list(@RequestParam(required = false) String status,
+ @RequestParam(defaultValue = "50") int limit,
+ Authentication auth) {
+ // List is owner-scoped — only your own goals are visible.
+ return R.ok(goalService.list(status, currentUsername(auth), limit));
+ }
+
+ @Operation(summary = "Sparse update of a non-terminal goal")
+ @PatchMapping("/{id}")
+ public R update(@PathVariable Long id,
+ @RequestBody GoalUpdateRequest req,
+ Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ String username = currentUsername(auth);
+ requireOwner(g.getConversationId(), username);
+ return R.ok(goalService.update(id, req, username));
+ }
+
+ @Operation(summary = "Pause an active goal")
+ @PostMapping("/{id}/pause")
+ public R pause(@PathVariable Long id, Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ String username = currentUsername(auth);
+ requireOwner(g.getConversationId(), username);
+ return R.ok(goalService.pause(id, username));
+ }
+
+ @Operation(summary = "Resume a paused goal")
+ @PostMapping("/{id}/resume")
+ public R resume(@PathVariable Long id, Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ String username = currentUsername(auth);
+ requireOwner(g.getConversationId(), username);
+ return R.ok(goalService.resume(id, username));
+ }
+
+ @Operation(summary = "Abandon a goal (terminal)")
+ @PostMapping("/{id}/abandon")
+ public R abandon(@PathVariable Long id, Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ String username = currentUsername(auth);
+ requireOwner(g.getConversationId(), username);
+ return R.ok(goalService.abandon(id, username));
+ }
+
+ @Operation(summary = "Append a sub-criterion to an active goal")
+ @PostMapping("/{id}/criteria")
+ public R addCriterion(@PathVariable Long id,
+ @RequestBody Map body,
+ Authentication auth) {
+ GoalEntity g = goalService.getById(id);
+ String username = currentUsername(auth);
+ requireOwner(g.getConversationId(), username);
+ String criterion = body != null ? body.get("criterion") : null;
+ return R.ok(goalService.appendCriterion(id, criterion, username));
+ }
+
+ // ==================== Helpers ====================
+
+ private String currentUsername(Authentication auth) {
+ return auth != null ? auth.getName() : "anonymous";
+ }
+
+ private void requireOwner(String conversationId, String username) {
+ if (conversationId == null || conversationId.isBlank()) {
+ throw new MateClawException("err.goal.bad_request", 400, "conversationId required");
+ }
+ if (!conversationService.isConversationOwner(conversationId, username)) {
+ throw new MateClawException("err.goal.forbidden", 403,
+ "Not the owner of conversation " + conversationId);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java
new file mode 100644
index 00000000..3b2bcae7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java
@@ -0,0 +1,33 @@
+package vip.mate.goal.model;
+
+import lombok.Data;
+
+/**
+ * Request body for {@code POST /api/v1/goals}.
+ *
+ * Only {@code conversationId}, {@code agentId}, {@code workspaceId} and
+ * {@code title} are mandatory. Budgets default to the values in
+ * {@link vip.mate.goal.config.GoalProperties}.
+ *
+ *
ID fields stay as {@code Long} on the wire (Jackson accepts both
+ * numeric and string forms via the project's default coercion), but the
+ * frontend must send them as strings to preserve snowflake precision —
+ * see CLAUDE.md "ID Handling" section.
+ */
+@Data
+public class GoalCreateRequest {
+
+ private String conversationId;
+ private Long agentId;
+ private Long workspaceId;
+
+ private String title;
+ private String description;
+ private String exitCriteria;
+ private String successCheckPrompt;
+
+ private Integer turnBudget;
+ private Integer llmCallBudget;
+ private Boolean autoFollowupEnabled;
+ private Integer followupCooldownSeconds;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java
new file mode 100644
index 00000000..96159277
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java
@@ -0,0 +1,120 @@
+package vip.mate.goal.model;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Persistent goal bound to one conversation.
+ *
+ *
Lifecycle and DB uniqueness are documented in the V120 migration and
+ * the {@link GoalStatus} enum. Concurrency safety relies on:
+ *
+ * - The DB-level unique index on the active-state subset (H2 predicate
+ * index / MySQL generated column).
+ * - The {@link #version} column updated via explicit
+ * {@code WHERE version = ?} in the service layer's update wrappers.
+ * No global OptimisticLockerInnerInterceptor is wired, so the
+ * service must opt in per-write.
+ *
+ *
+ * The {@code active_conv_key} generated column (MySQL) is intentionally
+ * NOT mapped to a Java field — MyBatis Plus would otherwise try to write
+ * to it on insert. H2 has no such column.
+ */
+@Data
+@TableName("mate_agent_goal")
+public class GoalEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** FK to mate_conversation.conversation_id. */
+ private String conversationId;
+
+ /** FK to mate_agent.id. */
+ private Long agentId;
+
+ /** FK to mate_workspace.id. */
+ private Long workspaceId;
+
+ /** Username of the goal creator; matches mate_user.username. */
+ private String createdBy;
+
+ /** Short title displayed on the avatar tooltip and timeline. */
+ private String title;
+
+ /** Long-form objective. Always non-null but may be short. */
+ private String description;
+
+ /** LLM-readable exit criteria; evaluator scores against this. Nullable. */
+ @TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS)
+ private String exitCriteria;
+
+ /** Optional per-goal evaluator prompt override; nullable -> default. */
+ @TableField(value = "success_check_prompt", updateStrategy = FieldStrategy.ALWAYS)
+ private String successCheckPrompt;
+
+ /**
+ * One of the {@link GoalStatus} lowercase values. Persistence handled
+ * by MyBatis Plus + @EnumValue annotation on the enum.
+ */
+ private GoalStatus status;
+
+ /** Maximum evaluation turns before exhaustion. */
+ private Integer turnBudget;
+
+ /** Cumulative turns evaluated; bumped by GoalEvaluationNode. */
+ private Integer turnsUsed;
+
+ /** Single cap covering both agent-side and evaluator-side LLM calls. */
+ private Integer llmCallBudget;
+
+ /** Main-graph LLM calls observed via state.LLM_CALL_COUNT delta. */
+ private Integer agentLlmCallsUsed;
+
+ /** Evaluator LLM calls consumed by GoalEvaluationService. */
+ private Integer evalLlmCallsUsed;
+
+ /** Last evaluator gap text — shown in the hover tooltip. Nullable. */
+ @TableField(value = "progress_summary", updateStrategy = FieldStrategy.ALWAYS)
+ private String progressSummary;
+
+ /** Last completion score 0.0..1.0. Nullable (no eval yet). */
+ @TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS)
+ private Double completionScore;
+
+ @TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS)
+ private LocalDateTime lastEvaluationAt;
+
+ /** Whether the evaluator may inject a follow-up user prompt. */
+ private Boolean autoFollowupEnabled;
+
+ /** Minimum interval between two auto-followups for the same goal. */
+ private Integer followupCooldownSeconds;
+
+ @TableField(value = "last_followup_at", updateStrategy = FieldStrategy.ALWAYS)
+ private LocalDateTime lastFollowupAt;
+
+ /** Optimistic lock version; service updates must pass {@code WHERE version=?}. */
+ private Integer version;
+
+ @TableLogic
+ private Integer deleted;
+
+ private LocalDateTime createTime;
+ private LocalDateTime updateTime;
+
+ /** Convenience derived counter used by SSE event payloads. */
+ public int totalLlmCallsUsed() {
+ int a = agentLlmCallsUsed != null ? agentLlmCallsUsed : 0;
+ int e = evalLlmCallsUsed != null ? evalLlmCallsUsed : 0;
+ return a + e;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java
new file mode 100644
index 00000000..d1d87a4d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java
@@ -0,0 +1,54 @@
+package vip.mate.goal.model;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Value object carrying one evaluation pass result from
+ * {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to
+ * {@code GoalService.recordEvaluation}.
+ *
+ *
Defined in PR1 so the service-layer signature is stable; the actual
+ * evaluator implementation lands in PR2.
+ *
+ *
{@link #completed} means "evaluator judged this turn satisfies all
+ * exit criteria". It does not mean "graph FINISH_REASON should change" —
+ * goal status and graph FinishReason are independent (RFC 48 §3.1 v2).
+ *
+ *
{@link #llmCallsConsumed} is the evaluator-side delta only; the
+ * agent-side delta is read from graph state by the node itself.
+ */
+public record GoalEvaluationResult(
+ double score,
+ String gap,
+ String decision,
+ boolean completed,
+ String evaluatorModel,
+ int llmCallsConsumed,
+ long latencyMs) {
+
+ public static final String DECISION_COMPLETED = "completed";
+ public static final String DECISION_CONTINUE = "continue";
+ public static final String DECISION_FALLBACK = "fallback";
+
+ /** Failure fallback used when the evaluator LLM call errors out.
+ * Does NOT charge eval_llm_calls_used. */
+ public static GoalEvaluationResult fallback(String reason) {
+ return new GoalEvaluationResult(
+ 0.0, "evaluator unavailable: " + reason,
+ DECISION_FALLBACK, false,
+ "", 0, 0L);
+ }
+
+ public Map toMap() {
+ Map m = new LinkedHashMap<>();
+ m.put("completionScore", score);
+ m.put("gap", gap == null ? "" : gap);
+ m.put("decision", decision);
+ m.put("completed", completed);
+ m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel);
+ m.put("llmCallsConsumed", llmCallsConsumed);
+ m.put("latencyMs", latencyMs);
+ return m;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java
new file mode 100644
index 00000000..9f5daf1a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java
@@ -0,0 +1,35 @@
+package vip.mate.goal.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Append-only event row in the goal's audit trail.
+ *
+ * {@link #eventType} values are defined in {@link GoalEventType}.
+ * The drawer timeline reads these rows in reverse chronological order.
+ */
+@Data
+@TableName("mate_agent_goal_event")
+public class GoalEventEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ private Long goalId;
+
+ /** One of the {@link GoalEventType} string values. */
+ private String eventType;
+
+ /** Optional FK to mate_message.id for the assistant turn this event ties to. */
+ private Long messageId;
+
+ /** JSON detail payload — schema varies per event_type. */
+ private String detailJson;
+
+ private LocalDateTime createTime;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java
new file mode 100644
index 00000000..e41256af
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java
@@ -0,0 +1,41 @@
+package vip.mate.goal.model;
+
+/**
+ * String constants for {@link GoalEventEntity#getEventType()}.
+ *
+ *
Kept as constants (not an enum) because the column is queried by
+ * MyBatis Plus as a plain string, and SSE event payloads serialize the
+ * value verbatim — having the same literal in code, DB, and wire format
+ * eliminates one source of accidental drift.
+ */
+public final class GoalEventType {
+
+ /** Goal created by the user or by the agent via setGoal tool. */
+ public static final String CREATED = "created";
+
+ /** One evaluation pass completed; carries score/gap/decision. */
+ public static final String EVALUATED = "evaluated";
+
+ /** Auto-followup prompt was injected for the next reasoning loop. */
+ public static final String FOLLOWUP_INJECTED = "followup_injected";
+
+ /** Evaluator judged completion; goal status flipped to completed. */
+ public static final String COMPLETED = "completed";
+
+ /** turn_budget or llm_call_budget consumed; status flipped to exhausted. */
+ public static final String EXHAUSTED = "exhausted";
+
+ /** User paused active goal. */
+ public static final String PAUSED = "paused";
+
+ /** User resumed a paused goal. */
+ public static final String RESUMED = "resumed";
+
+ /** User abandoned the goal; final state. */
+ public static final String ABANDONED = "abandoned";
+
+ /** Sub-criterion appended without restarting the goal. */
+ public static final String CRITERION_ADDED = "criterion_added";
+
+ private GoalEventType() {}
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java
new file mode 100644
index 00000000..112512c9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java
@@ -0,0 +1,47 @@
+package vip.mate.goal.model;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * Persistent goal lifecycle states.
+ *
+ *
State machine:
+ *
+ * create -> active
+ * active <-> paused
+ * active -> { completed | abandoned | exhausted } (terminal states)
+ *
+ *
+ * The persisted DB value is the lowercase string carried by
+ * {@link #value} via the MyBatis Plus {@link EnumValue} annotation. This
+ * is load-bearing — the V120 migration's predicate unique index (H2) and
+ * generated-column unique index (MySQL) both compare {@code status =
+ * 'active'} as a literal string. Writing the enum's {@link #name()} (all
+ * uppercase) would silently bypass the uniqueness guarantee and let
+ * concurrent creates land two ACTIVE rows on the same conversation.
+ */
+public enum GoalStatus {
+
+ ACTIVE("active"),
+ PAUSED("paused"),
+ COMPLETED("completed"),
+ ABANDONED("abandoned"),
+ EXHAUSTED("exhausted");
+
+ @EnumValue
+ private final String value;
+
+ GoalStatus(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ /** Terminal states do not transition; they free the conversation
+ * uniqueness slot for a fresh active goal. */
+ public boolean isTerminal() {
+ return this == COMPLETED || this == ABANDONED || this == EXHAUSTED;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java
new file mode 100644
index 00000000..1f6b1f5f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java
@@ -0,0 +1,25 @@
+package vip.mate.goal.model;
+
+import lombok.Data;
+
+/**
+ * Request body for {@code PATCH /api/v1/goals/{id}}. All fields nullable;
+ * only present fields are applied (sparse update).
+ *
+ *
Fields not editable post-create: {@code conversationId},
+ * {@code agentId}, {@code workspaceId}, {@code createdBy}, {@code status}
+ * (use the dedicated pause/resume/abandon endpoints).
+ */
+@Data
+public class GoalUpdateRequest {
+
+ private String title;
+ private String description;
+ private String exitCriteria;
+ private String successCheckPrompt;
+
+ private Integer turnBudget;
+ private Integer llmCallBudget;
+ private Boolean autoFollowupEnabled;
+ private Integer followupCooldownSeconds;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java
new file mode 100644
index 00000000..236872b5
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java
@@ -0,0 +1,12 @@
+package vip.mate.goal.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.goal.model.GoalEventEntity;
+
+/**
+ * MyBatis Plus mapper for {@link GoalEventEntity}.
+ */
+@Mapper
+public interface GoalEventMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java
new file mode 100644
index 00000000..03ae8859
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java
@@ -0,0 +1,16 @@
+package vip.mate.goal.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.goal.model.GoalEntity;
+
+/**
+ * MyBatis Plus mapper for {@link GoalEntity}.
+ *
+ * Lives under the {@code repository} sub-package per project convention —
+ * {@code MateClawApplication} uses {@code @MapperScan("vip.mate.**.repository")}.
+ * Putting the mapper elsewhere makes startup fail with a missing-bean error.
+ */
+@Mapper
+public interface GoalMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java
new file mode 100644
index 00000000..e8537e7e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java
@@ -0,0 +1,81 @@
+package vip.mate.goal.service;
+
+import vip.mate.goal.model.GoalCreateRequest;
+import vip.mate.goal.model.GoalEntity;
+import vip.mate.goal.model.GoalEvaluationResult;
+import vip.mate.goal.model.GoalEventEntity;
+import vip.mate.goal.model.GoalUpdateRequest;
+
+import java.util.List;
+
+/**
+ * Persistent goal service — CRUD, status transitions, and bookkeeping
+ * called from {@code GoalEvaluationNode} (PR2).
+ *
+ * Concurrency model: writes use a per-row {@code WHERE version=?}
+ * compare-and-set. On conflict the service retries up to 3 times before
+ * surfacing {@code MateClawException("err.goal.optimistic_lock_conflict",
+ * 409)}. Create additionally uses the DB-level
+ * {@code uk_agent_goal_active_conv} unique index as the last line of
+ * defence; service-layer pre-check is only a UX nicety.
+ */
+public interface GoalService {
+
+ // ==================== CRUD ====================
+
+ /**
+ * Create a new goal. Fails 409 when the conversation already has an
+ * active goal (DB unique index hit).
+ */
+ GoalEntity create(GoalCreateRequest req, String username);
+
+ GoalEntity getById(Long id);
+
+ /** Active goal for the conversation, or null. Used by buildInitialState. */
+ GoalEntity findActiveByConversation(String conversationId);
+
+ /** Paged list filtered by status / owner. */
+ List list(String status, String username, int limit);
+
+ /** Sparse update. Throws if any terminal-state goal is targeted. */
+ GoalEntity update(Long id, GoalUpdateRequest req, String username);
+
+ /** Events for the timeline drawer, newest first. */
+ List listEvents(Long goalId, int limit);
+
+ // ==================== State machine ====================
+
+ GoalEntity pause(Long id, String username);
+ GoalEntity resume(Long id, String username);
+ GoalEntity abandon(Long id, String username);
+
+ /** Flip active->completed. Writes a 'completed' event. */
+ GoalEntity markCompleted(Long id, GoalEvaluationResult result);
+
+ /** Flip active->exhausted with the reason that triggered it. */
+ GoalEntity markExhausted(Long id, String reason);
+
+ // ==================== Evaluation bookkeeping ====================
+
+ /**
+ * Atomic bookkeeping for one evaluation pass. Bumps turns_used (+1),
+ * agent_llm_calls_used (+agentDelta), eval_llm_calls_used (+evalDelta),
+ * persists progress_summary / completion_score / last_evaluation_at,
+ * writes one 'evaluated' GoalEventEntity. Optimistic-lock retry x3.
+ */
+ void recordEvaluation(Long id,
+ GoalEvaluationResult result,
+ int agentLlmCallsDelta,
+ int evalLlmCallsDelta);
+
+ /** True when turns_used >= turn_budget OR (agent + eval) >= llm_call_budget. */
+ boolean isBudgetExhausted(GoalEntity goal);
+
+ /** "turn_budget" or "llm_call_budget" — the dimension that hit the cap. */
+ String exhaustionReason(GoalEntity goal);
+
+ void recordFollowupInjected(Long id, String prompt);
+
+ /** Append a sub-criterion without restarting the goal. */
+ GoalEntity appendCriterion(Long id, String criterion, String username);
+}
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
new file mode 100644
index 00000000..d22161af
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java
@@ -0,0 +1,484 @@
+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.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+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.GoalEntity;
+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.function.IntSupplier;
+
+/**
+ * Default implementation. Concurrency safety relies on:
+ *
+ * - Service-level pre-check + DB-level unique index for "at most one
+ * active goal per conversation" — see V120 migration.
+ * - Per-write optimistic lock via {@code WHERE version=?} on
+ * state-mutating updates; retried up to 3 times on contention.
+ * - {@code @Transactional} on every write so the goal row update and
+ * the matching event-log insert succeed or fail together.
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+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;
+
+ // ==================== 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);
+ entity.setTurnBudget(req.getTurnBudget() != null
+ ? req.getTurnBudget() : properties.getDefaultTurnBudget());
+ entity.setTurnsUsed(0);
+ entity.setLlmCallBudget(req.getLlmCallBudget() != null
+ ? req.getLlmCallBudget() : properties.getDefaultLlmCallBudget());
+ entity.setAgentLlmCallsUsed(0);
+ entity.setEvalLlmCallsUsed(0);
+ entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled()));
+ entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
+ ? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds());
+ 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(),
+ "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()
+ .eq(GoalEntity::getConversationId, conversationId)
+ .eq(GoalEntity::getStatus, GoalStatus.ACTIVE)
+ .last("LIMIT 1"));
+ }
+
+ @Override
+ public List list(String status, String username, int limit) {
+ LambdaQueryWrapper w = new LambdaQueryWrapper()
+ .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) {
+ GoalEntity g = getById(id);
+ ensureNotTerminal(g, "update");
+
+ LambdaUpdateWrapper w = baseLockedUpdate(g);
+ boolean changed = false;
+ 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) {
+ validateBudget(req.getTurnBudget(), "turnBudget");
+ w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
+ }
+ if (req.getLlmCallBudget() != null) {
+ validateBudget(req.getLlmCallBudget(), "llmCallBudget");
+ 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 g;
+ }
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "update");
+ recordAudit("goal.updated", g, Map.of("by", username));
+ return goalMapper.selectById(id);
+ }
+
+ @Override
+ public List listEvents(Long goalId, int limit) {
+ return eventMapper.selectList(new LambdaQueryWrapper()
+ .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 resume(Long id, String username) {
+ return flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE,
+ GoalEventType.RESUMED, "goal.resumed", username);
+ }
+
+ @Override
+ @Transactional
+ public GoalEntity abandon(Long id, String username) {
+ GoalEntity g = getById(id);
+ ensureNotTerminal(g, "abandon");
+ // Allows abandon from both ACTIVE and PAUSED.
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getStatus, GoalStatus.ABANDONED);
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "abandon");
+ writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username));
+ recordAudit("goal.abandoned", g, Map.of("by", username));
+ return goalMapper.selectById(id);
+ }
+
+ @Override
+ @Transactional
+ public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
+ GoalEntity g = getById(id);
+ if (g.getStatus().isTerminal()) return g; // idempotent
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getStatus, GoalStatus.COMPLETED);
+ if (result != null) {
+ w.set(GoalEntity::getCompletionScore, result.score())
+ .set(GoalEntity::getProgressSummary, result.gap());
+ }
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "markCompleted");
+ Map detail = new LinkedHashMap<>();
+ detail.put("finalScore", result != null ? result.score() : null);
+ detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
+ detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
+ writeEvent(id, GoalEventType.COMPLETED, null, detail);
+ recordAudit("goal.completed", g, detail);
+ return goalMapper.selectById(id);
+ }
+
+ @Override
+ @Transactional
+ public GoalEntity markExhausted(Long id, String reason) {
+ GoalEntity g = getById(id);
+ if (g.getStatus().isTerminal()) return g;
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "markExhausted");
+ Map 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());
+ writeEvent(id, GoalEventType.EXHAUSTED, null, detail);
+ recordAudit("goal.exhausted", g, detail);
+ return goalMapper.selectById(id);
+ }
+
+ // ==================== Evaluation bookkeeping ====================
+
+ @Override
+ @Transactional
+ public void recordEvaluation(Long id, GoalEvaluationResult result,
+ int agentLlmCallsDelta, int evalLlmCallsDelta) {
+ GoalEntity g = getById(id);
+ if (g.getStatus().isTerminal()) return; // ignore late evaluations
+
+ int agentDelta = Math.max(0, agentLlmCallsDelta);
+ int evalDelta = Math.max(0, evalLlmCallsDelta);
+
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .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());
+ if (result != null) {
+ w.set(GoalEntity::getCompletionScore, result.score())
+ .set(GoalEntity::getProgressSummary, result.gap());
+ }
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "recordEvaluation");
+
+ Map detail = new LinkedHashMap<>();
+ if (result != null) {
+ detail.put("completionScore", result.score());
+ detail.put("gap", result.gap());
+ detail.put("decision", result.decision());
+ detail.put("evaluatorModel", result.evaluatorModel());
+ detail.put("latencyMs", result.latencyMs());
+ }
+ detail.put("agentLlmCallsDelta", agentDelta);
+ detail.put("evalLlmCallsDelta", evalDelta);
+ writeEvent(id, GoalEventType.EVALUATED, null, detail);
+ }
+
+ @Override
+ public boolean isBudgetExhausted(GoalEntity goal) {
+ int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
+ int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE;
+ if (turns >= turnBudget) return true;
+ int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE;
+ return 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 (turns >= turnBudget) return "turn_budget";
+ return "llm_call_budget";
+ }
+
+ @Override
+ @Transactional
+ public void recordFollowupInjected(Long id, String prompt) {
+ GoalEntity g = getById(id);
+ if (g.getStatus().isTerminal()) return;
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "recordFollowupInjected");
+ 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");
+ }
+ GoalEntity g = getById(id);
+ ensureNotTerminal(g, "appendCriterion");
+ String existing = g.getExitCriteria() != null ? g.getExitCriteria() : "";
+ String merged = existing.isEmpty() ? criterion.trim()
+ : existing + "\n+ " + criterion.trim();
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getExitCriteria, merged);
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), "appendCriterion");
+ writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
+ "criterion", criterion.trim(),
+ "by", username));
+ return goalMapper.selectById(id);
+ }
+
+ // ==================== Internals ====================
+
+ 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)");
+ }
+ if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget");
+ if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget");
+ }
+
+ private static void validateBudget(int v, String name) {
+ if (v <= 0) {
+ throw new MateClawException("err.goal.invalid_budget", 400,
+ name + " 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 baseLockedUpdate(GoalEntity g) {
+ return new LambdaUpdateWrapper()
+ .eq(GoalEntity::getId, g.getId())
+ .eq(GoalEntity::getVersion, g.getVersion())
+ .eq(GoalEntity::getDeleted, 0);
+ }
+
+ private static void bumpVersionAndTime(LambdaUpdateWrapper w) {
+ w.setSql("version = version + 1")
+ .set(GoalEntity::getUpdateTime, LocalDateTime.now());
+ }
+
+ private void retryOptimistic(IntSupplier update, String op) {
+ for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) {
+ int rows = update.getAsInt();
+ if (rows > 0) return;
+ 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 = getById(id);
+ if (g.getStatus() != from) {
+ throw new MateClawException("err.goal.bad_transition", 409,
+ "Cannot transition " + g.getStatus().getValue() + " -> " + to.getValue());
+ }
+ LambdaUpdateWrapper w = baseLockedUpdate(g)
+ .set(GoalEntity::getStatus, to);
+ bumpVersionAndTime(w);
+ retryOptimistic(() -> goalMapper.update(null, w), to.getValue());
+ writeEvent(id, eventType, null, Map.of("by", username,
+ "from", from.getValue(), "to", to.getValue()));
+ recordAudit(auditAction, g, Map.of("by", username));
+ return goalMapper.selectById(id);
+ }
+
+ private void writeEvent(Long goalId, String type, Long messageId, Map 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 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 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());
+ }
+}
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql b/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql
new file mode 100644
index 00000000..4b69d284
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql
@@ -0,0 +1,88 @@
+-- Persistent goal: cross-turn objective lock-in with self-evaluation loop.
+--
+-- A goal binds to a single conversation and stays active across many user
+-- turns. After each agent reply the GoalEvaluationNode rates progress and
+-- can optionally inject a follow-up prompt. The status state machine is:
+-- active -> { paused | completed | abandoned | exhausted }
+-- with paused -> active as the only reverse edge.
+--
+-- DB-level uniqueness for "at most one active goal per conversation":
+-- We use a virtual generated column that yields conversation_id only when
+-- the row is in active state (and not soft-deleted) — NULL otherwise — and
+-- a plain UNIQUE constraint over it. NULLs do not participate in
+-- uniqueness checks (SQL standard), so terminal-state rows coexist with
+-- the next active goal on the same conversation. H2 2.x supports the
+-- exact same syntax as MySQL for virtual generated columns; we use it
+-- here rather than a predicate index (which H2 lacks).
+
+CREATE TABLE IF NOT EXISTS mate_agent_goal (
+ id BIGINT NOT NULL,
+ conversation_id VARCHAR(64) NOT NULL,
+ agent_id BIGINT NOT NULL,
+ workspace_id BIGINT NOT NULL,
+ created_by VARCHAR(64) NOT NULL,
+
+ title VARCHAR(255) NOT NULL,
+ description CLOB NOT NULL,
+ exit_criteria CLOB NULL,
+ success_check_prompt CLOB NULL,
+
+ -- DB values are always lowercase (active|paused|completed|abandoned|
+ -- exhausted) — enforced by the GoalStatus enum's @EnumValue
+ -- annotation. The generated column below depends on this convention.
+ status VARCHAR(16) NOT NULL DEFAULT 'active',
+
+ turn_budget INT NOT NULL DEFAULT 20,
+ turns_used INT NOT NULL DEFAULT 0,
+ llm_call_budget INT NOT NULL DEFAULT 200,
+ agent_llm_calls_used INT NOT NULL DEFAULT 0,
+ eval_llm_calls_used INT NOT NULL DEFAULT 0,
+
+ progress_summary CLOB NULL,
+ completion_score DOUBLE NULL,
+ last_evaluation_at TIMESTAMP NULL,
+
+ auto_followup_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ followup_cooldown_seconds INT NOT NULL DEFAULT 0,
+ last_followup_at TIMESTAMP NULL,
+
+ version INT NOT NULL DEFAULT 0,
+ deleted INT NOT NULL DEFAULT 0,
+ create_time TIMESTAMP NOT NULL,
+ update_time TIMESTAMP NOT NULL,
+
+ -- Virtual generated column: yields the conversation id only for the
+ -- "live active" subset, NULL otherwise. A plain UNIQUE constraint
+ -- over it gives "at most one active row per conversation" while
+ -- letting any number of completed/abandoned/exhausted/deleted rows
+ -- coexist (NULLs are non-comparable under UNIQUE).
+ active_conv_key VARCHAR(80) GENERATED ALWAYS AS (
+ CASE WHEN status = 'active' AND deleted = 0
+ THEN conversation_id ELSE NULL END
+ ),
+
+ PRIMARY KEY (id),
+ CONSTRAINT uk_agent_goal_active_conv UNIQUE (active_conv_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_goal_conv
+ ON mate_agent_goal(conversation_id, status);
+CREATE INDEX IF NOT EXISTS idx_agent_goal_status
+ ON mate_agent_goal(status, last_evaluation_at);
+CREATE INDEX IF NOT EXISTS idx_agent_goal_owner
+ ON mate_agent_goal(created_by, status);
+
+-- Append-only event log: timeline view in the drawer reads from here.
+-- event_type values are documented in vip.mate.goal.model.GoalEventType.
+CREATE TABLE IF NOT EXISTS mate_agent_goal_event (
+ id BIGINT NOT NULL,
+ goal_id BIGINT NOT NULL,
+ event_type VARCHAR(32) NOT NULL,
+ message_id BIGINT NULL,
+ detail_json CLOB NULL,
+ create_time TIMESTAMP NOT NULL,
+ PRIMARY KEY (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_goal_event_goal
+ ON mate_agent_goal_event(goal_id, id);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql
new file mode 100644
index 00000000..643a9671
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql
@@ -0,0 +1,75 @@
+-- Persistent goal — see h2/V120__agent_goal.sql for full design notes.
+--
+-- MySQL-specific differences vs H2:
+-- 1. CLOB -> LONGTEXT
+-- 2. TIMESTAMP -> DATETIME(3) for millisecond precision matching V117
+-- 3. BOOLEAN -> TINYINT(1)
+-- 4. H2 uses a PREDICATE unique index for "one active goal per
+-- conversation"; MySQL InnoDB does not support filtered indexes,
+-- so we emulate it with a virtual generated column that is NULL for
+-- non-active rows + a plain unique index. NULLs are excluded from
+-- uniqueness enforcement by MySQL's default index semantics.
+
+CREATE TABLE IF NOT EXISTS mate_agent_goal (
+ id BIGINT NOT NULL,
+ conversation_id VARCHAR(64) NOT NULL,
+ agent_id BIGINT NOT NULL,
+ workspace_id BIGINT NOT NULL,
+ created_by VARCHAR(64) NOT NULL,
+
+ title VARCHAR(255) NOT NULL,
+ description LONGTEXT NOT NULL,
+ exit_criteria LONGTEXT NULL,
+ success_check_prompt LONGTEXT NULL,
+
+ -- DB values are always lowercase (active|paused|completed|abandoned|
+ -- exhausted) — enforced by the GoalStatus enum's @EnumValue
+ -- annotation. The active_conv_key generated column below depends on
+ -- this convention; any uppercase write would defeat uniqueness.
+ status VARCHAR(16) NOT NULL DEFAULT 'active',
+
+ turn_budget INT NOT NULL DEFAULT 20,
+ turns_used INT NOT NULL DEFAULT 0,
+ llm_call_budget INT NOT NULL DEFAULT 200,
+ agent_llm_calls_used INT NOT NULL DEFAULT 0,
+ eval_llm_calls_used INT NOT NULL DEFAULT 0,
+
+ progress_summary LONGTEXT NULL,
+ completion_score DOUBLE NULL,
+ last_evaluation_at DATETIME(3) NULL,
+
+ auto_followup_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ followup_cooldown_seconds INT NOT NULL DEFAULT 0,
+ last_followup_at DATETIME(3) NULL,
+
+ -- Virtual generated column: NULL for non-active or deleted rows so
+ -- they fall out of the unique-index check. InnoDB ignores NULL keys
+ -- for uniqueness, giving us "at most one active row per conversation".
+ active_conv_key VARCHAR(80)
+ GENERATED ALWAYS AS (
+ CASE WHEN status = 'active' AND deleted = 0
+ THEN conversation_id ELSE NULL END
+ ) VIRTUAL,
+
+ version INT NOT NULL DEFAULT 0,
+ deleted TINYINT(1) NOT NULL DEFAULT 0,
+ create_time DATETIME(3) NOT NULL,
+ update_time DATETIME(3) NOT NULL,
+
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_agent_goal_active_conv (active_conv_key),
+ KEY idx_agent_goal_conv (conversation_id, status),
+ KEY idx_agent_goal_status (status, last_evaluation_at),
+ KEY idx_agent_goal_owner (created_by, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS mate_agent_goal_event (
+ id BIGINT NOT NULL,
+ goal_id BIGINT NOT NULL,
+ event_type VARCHAR(32) NOT NULL,
+ message_id BIGINT NULL,
+ detail_json LONGTEXT NULL,
+ create_time DATETIME(3) NOT NULL,
+ PRIMARY KEY (id),
+ KEY idx_agent_goal_event_goal (goal_id, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java
new file mode 100644
index 00000000..21f96eb5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java
@@ -0,0 +1,140 @@
+package vip.mate.goal;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+import vip.mate.MateClawApplication;
+import vip.mate.exception.MateClawException;
+import vip.mate.goal.model.GoalCreateRequest;
+import vip.mate.goal.model.GoalEntity;
+import vip.mate.goal.model.GoalStatus;
+import vip.mate.goal.service.GoalService;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Integration test that pins two load-bearing DB invariants:
+ *
+ *
+ * - {@code GoalStatus} persists as lowercase strings ({@code "active"}
+ * etc., NOT {@code "ACTIVE"}). The V120 predicate unique index
+ * compares {@code status = 'active'} as a literal — any uppercase
+ * write would silently defeat the uniqueness guarantee.
+ * - The {@code uk_agent_goal_active_conv} unique index rejects a
+ * second active-row insert for the same conversation. Service-layer
+ * pre-check is a UX nicety; this is the source of truth.
+ *
+ *
+ * Uses an in-memory H2 MySQL-compat database so Flyway runs V120
+ * exactly as it would in dev. The {@code DATABASE_TO_LOWER=TRUE} flag is
+ * standard across mateclaw's other Spring tests.
+ */
+@SpringBootTest(
+ classes = MateClawApplication.class,
+ webEnvironment = SpringBootTest.WebEnvironment.NONE
+)
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:goal_persistence_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
+ "spring.ai.dashscope.api-key=test-key",
+ "spring.main.web-application-type=none",
+ "mateclaw.goal.enabled=false"
+})
+class GoalPersistenceIntegrationTest {
+
+ @Autowired private GoalService goalService;
+ @Autowired private JdbcTemplate jdbc;
+
+ private GoalCreateRequest req(String convId, String title) {
+ GoalCreateRequest r = new GoalCreateRequest();
+ r.setConversationId(convId);
+ r.setAgentId(1L);
+ r.setWorkspaceId(1L);
+ r.setTitle(title);
+ r.setDescription("desc");
+ return r;
+ }
+
+ @Test
+ @DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv")
+ void status_persistsAsLowercaseString() {
+ GoalEntity created = goalService.create(req("conv-status-1", "lower-case check"), "alice");
+ String raw = jdbc.queryForObject(
+ "SELECT status FROM mate_agent_goal WHERE id = ?",
+ String.class, created.getId());
+ assertEquals("active", raw,
+ "GoalStatus must persist as lowercase 'active' — uppercase 'ACTIVE' would " +
+ "silently bypass the V120 predicate unique index uk_agent_goal_active_conv.");
+ }
+
+ @Test
+ @DisplayName("Each terminal status also persists lowercase")
+ void terminalStatuses_alsoPersistLowercase() {
+ GoalEntity g = goalService.create(req("conv-status-terminal", "terminal check"), "alice");
+
+ goalService.abandon(g.getId(), "alice");
+ String s = jdbc.queryForObject(
+ "SELECT status FROM mate_agent_goal WHERE id = ?",
+ String.class, g.getId());
+ assertEquals("abandoned", s);
+ }
+
+ @Test
+ @DisplayName("Service rejects a second active goal on the same conversation (UX pre-check 409)")
+ void servicePreCheck_blocksDuplicateActiveCreation() {
+ goalService.create(req("conv-dup-1", "first"), "alice");
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> goalService.create(req("conv-dup-1", "second"), "alice"));
+ assertEquals(409, ex.getCode());
+ }
+
+ @Test
+ @DisplayName("DB unique index rejects a second active row even when service pre-check is bypassed")
+ void uniqueIndex_isUltimateSourceOfTruth() {
+ // First goal — via service so it gets a real ID + workspace + timestamps.
+ goalService.create(req("conv-uq-1", "first"), "alice");
+
+ // Second insertion — bypass the service entirely and write through
+ // JdbcTemplate. Must hit DuplicateKeyException at the DB level.
+ LocalDateTime now = LocalDateTime.now();
+ try {
+ jdbc.update(
+ "INSERT INTO mate_agent_goal " +
+ "(id, conversation_id, agent_id, workspace_id, created_by, " +
+ " title, description, status, turn_budget, turns_used, " +
+ " llm_call_budget, agent_llm_calls_used, eval_llm_calls_used, " +
+ " auto_followup_enabled, followup_cooldown_seconds, " +
+ " version, deleted, create_time, update_time) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ 99999L, "conv-uq-1", 1L, 1L, "alice",
+ "second", "desc", "active",
+ 20, 0, 200, 0, 0,
+ false, 0,
+ 0, 0, Timestamp.valueOf(now), Timestamp.valueOf(now));
+ fail("Expected DuplicateKeyException from uk_agent_goal_active_conv");
+ } catch (DuplicateKeyException expected) {
+ // good
+ }
+ }
+
+ @Test
+ @DisplayName("A new active goal is allowed after the previous one entered a terminal state")
+ void terminalGoal_releasesUniquenessSlot() {
+ GoalEntity first = goalService.create(req("conv-recycle-1", "first"), "alice");
+ goalService.abandon(first.getId(), "alice");
+
+ // After abandon, the conversation should be free to host a new active goal.
+ GoalEntity second = goalService.create(req("conv-recycle-1", "second"), "alice");
+ assertNotNull(second);
+ assertEquals(GoalStatus.ACTIVE, second.getStatus());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java
new file mode 100644
index 00000000..6ad92928
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java
@@ -0,0 +1,168 @@
+package vip.mate.goal.controller;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.Authentication;
+import vip.mate.common.result.R;
+import vip.mate.exception.MateClawException;
+import vip.mate.goal.model.GoalCreateRequest;
+import vip.mate.goal.model.GoalEntity;
+import vip.mate.goal.model.GoalStatus;
+import vip.mate.goal.model.GoalUpdateRequest;
+import vip.mate.goal.service.GoalService;
+import vip.mate.workspace.conversation.ConversationService;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Authorization + happy-path coverage for {@link GoalController}.
+ *
+ *
Every write must:
+ * 1. Resolve the goal's conversation via service.getById (where applicable).
+ * 2. Reject non-owners with 403 before delegating to the service.
+ * 3. Delegate to the service when authorized.
+ */
+@ExtendWith(MockitoExtension.class)
+class GoalControllerTest {
+
+ @Mock private GoalService goalService;
+ @Mock private ConversationService conversationService;
+ @Mock private Authentication auth;
+
+ private GoalController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new GoalController(goalService, conversationService);
+ when(auth.getName()).thenReturn("alice");
+ }
+
+ private GoalEntity goal(Long id, String convId, GoalStatus status) {
+ GoalEntity g = new GoalEntity();
+ g.setId(id);
+ g.setConversationId(convId);
+ g.setAgentId(10L);
+ g.setWorkspaceId(1L);
+ g.setCreatedBy("alice");
+ g.setTitle("ship");
+ g.setStatus(status);
+ return g;
+ }
+
+ private GoalCreateRequest req(String convId) {
+ GoalCreateRequest r = new GoalCreateRequest();
+ r.setConversationId(convId);
+ r.setAgentId(10L);
+ r.setWorkspaceId(1L);
+ r.setTitle("ship");
+ return r;
+ }
+
+ // ==================== create ====================
+
+ @Test
+ void create_succeeds_whenOwner() {
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
+ when(goalService.create(any(), eq("alice")))
+ .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ R result = controller.create(req("conv-1"), auth);
+ assertNotNull(result.getData());
+ assertEquals(1L, result.getData().getId());
+ }
+
+ @Test
+ void create_returns403_whenNotOwner() {
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> controller.create(req("conv-1"), auth));
+ assertEquals(403, ex.getCode());
+ verify(goalService, never()).create(any(), anyString());
+ }
+
+ @Test
+ void create_returns400_whenConversationIdBlank() {
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> controller.create(req(""), auth));
+ assertEquals(400, ex.getCode());
+ }
+
+ // ==================== find / get ====================
+
+ @Test
+ void findActive_returnsNull_whenNoActiveGoal() {
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
+ when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
+ assertNull(controller.findActive("conv-1", auth).getData());
+ }
+
+ @Test
+ void get_returns403_whenCallerIsNotOwner() {
+ when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> controller.get(1L, auth));
+ assertEquals(403, ex.getCode());
+ }
+
+ // ==================== state machine ====================
+
+ @Test
+ void pause_delegatesToService_whenOwner() {
+ GoalEntity g = goal(1L, "conv-1", GoalStatus.ACTIVE);
+ when(goalService.getById(1L)).thenReturn(g);
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
+ when(goalService.pause(1L, "alice")).thenReturn(goal(1L, "conv-1", GoalStatus.PAUSED));
+
+ R result = controller.pause(1L, auth);
+ assertEquals(GoalStatus.PAUSED, result.getData().getStatus());
+ }
+
+ @Test
+ void abandon_returns403_whenCallerIsNotOwner() {
+ when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> controller.abandon(1L, auth));
+ assertEquals(403, ex.getCode());
+ verify(goalService, never()).abandon(any(), anyString());
+ }
+
+ // ==================== update / criteria ====================
+
+ @Test
+ void update_delegatesToService_whenOwner() {
+ when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
+ when(goalService.update(eq(1L), any(), eq("alice")))
+ .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ GoalUpdateRequest req = new GoalUpdateRequest();
+ req.setTitle("new title");
+ controller.update(1L, req, auth);
+ verify(goalService).update(eq(1L), any(), eq("alice"));
+ }
+
+ @Test
+ void addCriterion_passesCriterionStringFromBody() {
+ when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+ when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
+ when(goalService.appendCriterion(eq(1L), eq("tests pass"), eq("alice")))
+ .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
+
+ controller.addCriterion(1L, Map.of("criterion", "tests pass"), auth);
+ verify(goalService).appendCriterion(1L, "tests pass", "alice");
+ }
+}
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
new file mode 100644
index 00000000..03e77485
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java
@@ -0,0 +1,370 @@
+package vip.mate.goal.service;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
+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.GoalEntity;
+import vip.mate.goal.model.GoalEvaluationResult;
+import vip.mate.goal.model.GoalEventEntity;
+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.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link GoalServiceImpl} — covers CRUD, state machine,
+ * evaluation bookkeeping, budget exhaustion, optimistic-lock retry, and
+ * the DB unique-index 409 mapping.
+ */
+@ExtendWith(MockitoExtension.class)
+class GoalServiceTest {
+
+ @Mock private GoalMapper goalMapper;
+ @Mock private GoalEventMapper eventMapper;
+ @Mock private AuditEventService auditEventService;
+
+ private GoalServiceImpl service;
+
+ @BeforeAll
+ static void initTableInfo() {
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new Configuration(), ""),
+ GoalEntity.class);
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new Configuration(), ""),
+ GoalEventEntity.class);
+ }
+
+ @BeforeEach
+ void setUp() {
+ GoalProperties properties = new GoalProperties();
+ service = new GoalServiceImpl(goalMapper, eventMapper, properties,
+ auditEventService, new ObjectMapper());
+ }
+
+ // ==================== Helpers ====================
+
+ private GoalCreateRequest validReq() {
+ GoalCreateRequest r = new GoalCreateRequest();
+ r.setConversationId("conv-1");
+ r.setAgentId(10L);
+ r.setWorkspaceId(1L);
+ r.setTitle("ship the blog");
+ r.setDescription("deploy and verify");
+ r.setExitCriteria("hello world accessible");
+ return r;
+ }
+
+ private GoalEntity persisted(Long id, GoalStatus status) {
+ GoalEntity g = new GoalEntity();
+ g.setId(id);
+ g.setConversationId("conv-1");
+ g.setAgentId(10L);
+ g.setWorkspaceId(1L);
+ g.setCreatedBy("alice");
+ g.setTitle("ship the blog");
+ g.setDescription("desc");
+ g.setStatus(status);
+ g.setTurnBudget(20);
+ g.setTurnsUsed(0);
+ g.setLlmCallBudget(200);
+ g.setAgentLlmCallsUsed(0);
+ g.setEvalLlmCallsUsed(0);
+ g.setAutoFollowupEnabled(false);
+ g.setFollowupCooldownSeconds(0);
+ g.setVersion(0);
+ g.setDeleted(0);
+ g.setCreateTime(LocalDateTime.now());
+ g.setUpdateTime(LocalDateTime.now());
+ return g;
+ }
+
+ // ==================== create ====================
+
+ @Test
+ void create_succeeds_whenNoActiveGoalExists() {
+ when(goalMapper.selectOne(any())).thenReturn(null);
+ when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1);
+
+ GoalEntity created = service.create(validReq(), "alice");
+
+ assertNotNull(created);
+ assertEquals("alice", created.getCreatedBy());
+ assertEquals(GoalStatus.ACTIVE, created.getStatus());
+ assertEquals(20, created.getTurnBudget());
+ assertEquals(200, created.getLlmCallBudget());
+ verify(eventMapper, times(1)).insert(any(GoalEventEntity.class));
+ verify(auditEventService).record(eq("goal.created"), eq("goal"),
+ anyString(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void create_returns409_whenActiveGoalAlreadyExists() {
+ when(goalMapper.selectOne(any())).thenReturn(persisted(99L, GoalStatus.ACTIVE));
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.create(validReq(), "alice"));
+ assertEquals(409, ex.getCode());
+ verify(goalMapper, never()).insert(any(GoalEntity.class));
+ }
+
+ @Test
+ void create_returns409_whenDbUniqueIndexHits() {
+ // Concurrent race: pre-check sees nothing, but the DB does.
+ when(goalMapper.selectOne(any())).thenReturn(null);
+ when(goalMapper.insert(any(GoalEntity.class)))
+ .thenThrow(new DuplicateKeyException("uk_agent_goal_active_conv"));
+
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.create(validReq(), "alice"));
+ assertEquals(409, ex.getCode());
+ }
+
+ @Test
+ void create_rejectsBlankTitle() {
+ GoalCreateRequest r = validReq();
+ r.setTitle("");
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.create(r, "alice"));
+ assertEquals(400, ex.getCode());
+ }
+
+ @Test
+ void create_rejectsNonPositiveBudget() {
+ GoalCreateRequest r = validReq();
+ r.setTurnBudget(0);
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.create(r, "alice"));
+ assertEquals(400, ex.getCode());
+ }
+
+ // ==================== state transitions ====================
+
+ @Test
+ void pause_flipsActiveToPaused_andWritesEvent() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.PAUSED));
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ GoalEntity result = service.pause(1L, "alice");
+
+ assertEquals(GoalStatus.PAUSED, result.getStatus());
+ ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
+ verify(eventMapper).insert(evCaptor.capture());
+ assertEquals("paused", evCaptor.getValue().getEventType());
+ }
+
+ @Test
+ void pause_failsWhenGoalIsTerminal() {
+ when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED));
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.pause(1L, "alice"));
+ assertEquals(409, ex.getCode());
+ }
+
+ @Test
+ void resume_flipsPausedToActive() {
+ GoalEntity g = persisted(1L, GoalStatus.PAUSED);
+ when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ACTIVE));
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+ assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus());
+ }
+
+ @Test
+ void abandon_flipsAnyNonTerminalToAbandoned() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ABANDONED));
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+ assertEquals(GoalStatus.ABANDONED, service.abandon(1L, "alice").getStatus());
+ }
+
+ @Test
+ void markCompleted_isIdempotent_onTerminal() {
+ GoalEntity g = persisted(1L, GoalStatus.COMPLETED);
+ when(goalMapper.selectById(1L)).thenReturn(g);
+ GoalEntity result = service.markCompleted(1L, null);
+ assertEquals(GoalStatus.COMPLETED, result.getStatus());
+ verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
+ }
+
+ @Test
+ void markExhausted_carriesReasonInDetail() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.EXHAUSTED));
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ service.markExhausted(1L, "turn_budget");
+
+ ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
+ verify(eventMapper).insert(evCaptor.capture());
+ assertEquals("exhausted", evCaptor.getValue().getEventType());
+ assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget"));
+ }
+
+ // ==================== evaluation bookkeeping ====================
+
+ @Test
+ void recordEvaluation_bumpsCountersAndWritesEvent() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ when(goalMapper.selectById(1L)).thenReturn(g);
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ GoalEvaluationResult r = new GoalEvaluationResult(
+ 0.62, "DNS still missing", "continue", false,
+ "qwen-turbo", 1, 800L);
+ service.recordEvaluation(1L, r, 3, 1);
+
+ ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
+ verify(eventMapper).insert(evCaptor.capture());
+ assertEquals("evaluated", evCaptor.getValue().getEventType());
+ String detail = evCaptor.getValue().getDetailJson();
+ assertTrue(detail.contains("agentLlmCallsDelta"));
+ assertTrue(detail.contains("evalLlmCallsDelta"));
+ assertTrue(detail.contains("qwen-turbo"));
+ }
+
+ @Test
+ void recordEvaluation_isNoop_onTerminalGoal() {
+ when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED));
+ service.recordEvaluation(1L, null, 5, 1);
+ verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
+ verify(eventMapper, never()).insert(any(GoalEventEntity.class));
+ }
+
+ @Test
+ void isBudgetExhausted_detectsTurnBudgetHit() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ g.setTurnsUsed(20);
+ g.setTurnBudget(20);
+ assertTrue(service.isBudgetExhausted(g));
+ assertEquals("turn_budget", service.exhaustionReason(g));
+ }
+
+ @Test
+ void isBudgetExhausted_detectsLlmBudgetHit() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ g.setAgentLlmCallsUsed(180);
+ g.setEvalLlmCallsUsed(25);
+ g.setLlmCallBudget(200);
+ assertTrue(service.isBudgetExhausted(g));
+ assertEquals("llm_call_budget", service.exhaustionReason(g));
+ }
+
+ @Test
+ void isBudgetExhausted_returnsFalse_whenHeadroomRemains() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ g.setTurnsUsed(5);
+ g.setAgentLlmCallsUsed(30);
+ g.setEvalLlmCallsUsed(4);
+ assertFalse(service.isBudgetExhausted(g));
+ }
+
+ @Test
+ void appendCriterion_concatenatesWithMarker() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ g.setExitCriteria("DNS works");
+ when(goalMapper.selectById(1L)).thenReturn(g, g);
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ service.appendCriterion(1L, "tests pass", "alice");
+
+ ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
+ verify(eventMapper).insert(evCaptor.capture());
+ assertEquals("criterion_added", evCaptor.getValue().getEventType());
+ assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass"));
+ }
+
+ @Test
+ void appendCriterion_rejectsBlankInput() {
+ // Validation happens before selectById, so we do NOT stub the mapper.
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.appendCriterion(1L, " ", "alice"));
+ assertEquals(400, ex.getCode());
+ verify(goalMapper, never()).selectById(any());
+ }
+
+ // ==================== optimistic lock retry ====================
+
+ @Test
+ void update_failsAfterRetriesExhausted_whenVersionAlwaysStale() {
+ GoalEntity g = persisted(1L, GoalStatus.ACTIVE);
+ when(goalMapper.selectById(1L)).thenReturn(g);
+ // Always return 0 rows affected — simulates persistent version conflict.
+ when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0);
+
+ GoalUpdateRequest upd = new GoalUpdateRequest();
+ upd.setTitle("new title");
+
+ MateClawException ex = assertThrows(MateClawException.class,
+ () -> service.update(1L, upd, "alice"));
+ assertEquals(409, ex.getCode());
+ verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class));
+ }
+
+ @Test
+ void findActiveByConversation_returnsNull_forBlankInput() {
+ assertNull(service.findActiveByConversation(""));
+ assertNull(service.findActiveByConversation(null));
+ verify(goalMapper, never()).selectOne(any());
+ }
+
+ @Test
+ void getById_throws404_whenMissing() {
+ when(goalMapper.selectById(1L)).thenReturn(null);
+ MateClawException ex = assertThrows(MateClawException.class, () -> service.getById(1L));
+ assertEquals(404, ex.getCode());
+ }
+
+ /** Helper: mutate a copy of {@code g} with a new status, simulating
+ * what the post-update selectById would return. */
+ private GoalEntity statusFlipped(GoalEntity g, GoalStatus newStatus) {
+ GoalEntity copy = new GoalEntity();
+ copy.setId(g.getId());
+ copy.setConversationId(g.getConversationId());
+ copy.setAgentId(g.getAgentId());
+ copy.setWorkspaceId(g.getWorkspaceId());
+ copy.setCreatedBy(g.getCreatedBy());
+ copy.setTitle(g.getTitle());
+ copy.setStatus(newStatus);
+ copy.setTurnBudget(g.getTurnBudget());
+ copy.setTurnsUsed(g.getTurnsUsed());
+ copy.setLlmCallBudget(g.getLlmCallBudget());
+ copy.setAgentLlmCallsUsed(g.getAgentLlmCallsUsed());
+ copy.setEvalLlmCallsUsed(g.getEvalLlmCallsUsed());
+ copy.setVersion(g.getVersion() + 1);
+ copy.setDeleted(0);
+ copy.setCreateTime(g.getCreateTime());
+ copy.setUpdateTime(LocalDateTime.now());
+ return copy;
+ }
+}