feat(decision): add guarded decision layer with shadow mode

This commit is contained in:
matevip 2026-09-28 04:18:05 -04:00
parent 69b8c36be0
commit 1470d07ddd
61 changed files with 3443 additions and 35 deletions

View File

@ -1,5 +1,8 @@
package vip.mate.agent;
import vip.mate.goal.service.GoalDecisionAdapter;
import vip.mate.planning.service.AgentRoutingDecisionAdapter;
// PR-0b: DashScope imports moved with the construction code into DashScopeChatModelBuilder.
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.CompileConfig;
@ -159,6 +162,15 @@ public class AgentGraphBuilder {
private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService;
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
private final vip.mate.goal.config.GoalProperties goalProperties;
private GoalDecisionAdapter goalDecisionAdapter;
private AgentRoutingDecisionAdapter routingDecisionAdapter;
@Autowired(required = false)
public void setRoutingDecisionAdapter(AgentRoutingDecisionAdapter adapter) { this.routingDecisionAdapter = adapter; }
@Autowired(required = false)
public void setGoalDecisionAdapter(GoalDecisionAdapter adapter) { this.goalDecisionAdapter = adapter; }
/** C4: per-conversation environment notification registry, injected into ReasoningNode. */
private final vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
@ -702,6 +714,7 @@ public class AgentGraphBuilder {
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
planGenerationNode.setRoutingAdapter(routingDecisionAdapter);
List<ToolCallback> advertisedCallbacks = toolDisclosureService
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
@ -840,6 +853,7 @@ public class AgentGraphBuilder {
goalEvaluationService, goalFollowupService, goalService, goalProperties,
conversationWindowManager, conversationService,
vip.mate.goal.service.GraphFlavor.PLAN_EXECUTE);
goalEvalNode.setDecisionAdapter(goalDecisionAdapter);
StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory)
.addNode(PlanStateKeys.PLAN_GENERATION_NODE,
@ -1182,6 +1196,7 @@ public class AgentGraphBuilder {
goalEvaluationService, goalFollowupService, goalService, goalProperties,
conversationWindowManager, conversationService,
vip.mate.goal.service.GraphFlavor.REACT);
goalEvalNode.setDecisionAdapter(goalDecisionAdapter);
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
.addNode(MateClawStateKeys.REASONING_NODE,

View File

@ -14,6 +14,11 @@ import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalDecisionAdapter;
import vip.mate.goal.model.GoalContinuationDecision.Action;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionRecordingException;
import java.time.LocalDateTime;
import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor;
@ -73,6 +78,10 @@ public class GoalEvaluationNode implements NodeAction {
this.flavor = flavor;
}
private GoalDecisionAdapter decisionAdapter;
public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; }
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// Master kill switch — when disabled the node stays inert.
@ -230,6 +239,8 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
} catch (Throwable t) {
DecisionRecordingException recording = DecisionRecordingException.find(t);
if (recording != null) throw recording;
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
Map<String, Object> outward = result.toMap();
@ -266,12 +277,14 @@ public class GoalEvaluationNode implements NodeAction {
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
GoalProperties.MAX_HARD_CONTINUATIONS_CEILING);
Optional<String> followup;
boolean planningSucceeded = true;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
refreshed.getId(), t.toString());
followup = Optional.empty();
planningSucceeded = false;
}
// Per-run safety net: cap the autonomous self-continuation loop so a
// single user message can't drive an unbounded number of steps or
@ -286,6 +299,16 @@ public class GoalEvaluationNode implements NodeAction {
// recursion ceiling. hardCap==0 keeps the legacy behaviour (a
// max-iterations turn simply ends the run).
boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap;
// Caps are deterministic guards and must be resolved before consulting a provider.
GoalDecisionAdapter.Selection selection = decisionAdapter == null || !planningSucceeded ? null
: decisionAdapter.selectGraph(refreshed, followup, LocalDateTime.now(), perRunCapReached || hardCapReached);
boolean followupSuppressed = selection != null && selection.ticket().mode() == DecisionMode.ACTIVE
&& followup.isPresent() && !perRunCapReached && !hardCapReached
&& (selection.decision().action() == Action.DEFER || selection.decision().action() == Action.RETRY);
if (selection != null && selection.ticket().mode() == DecisionMode.ACTIVE) {
followup = selection.decision().action() == Action.CONTINUE
? Optional.of(selection.decision().prompt()) : Optional.empty();
}
if (followup.isPresent() && (perRunCapReached || hardCapReached)) {
log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " +
"(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run",
@ -363,12 +386,15 @@ public class GoalEvaluationNode implements NodeAction {
.clearFinalSummaryThinking()
.clearCurrentStepThinking();
}
return out.build();
Map<String, Object> output = out.build();
// This records submission to the graph, not durable execution of the next pass.
// A database commit and delivery of in-memory graph output are not atomic.
if (selection != null) decisionAdapter.outcome(selection.ticket(), true, "FOLLOWUP_SUBMITTED");
return output;
}
// Continue but no follow-up — just record the evaluation event.
// (helper below avoids needing a custom() factory on GraphEventPublisher.)
return MateClawStateAccessor.output()
Map<String, Object> output = MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_evaluated", Map.of(
@ -377,6 +403,11 @@ public class GoalEvaluationNode implements NodeAction {
"gap", result.gap() == null ? "" : result.gap(),
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
.build();
// Graph DEFER/RETRY applies suppression of this pass only; it creates no durable schedule.
if (selection != null) decisionAdapter.outcome(selection.ticket(), followupSuppressed,
followupSuppressed ? "FOLLOWUP_SUPPRESSED"
: perRunCapReached || hardCapReached ? "GRAPH_CAP" : "NO_FOLLOWUP");
return output;
}
/**

View File

@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.planning.service.PlanningService;
import vip.mate.planning.service.AgentRoutingDecisionAdapter;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.service.TeamPlanBridge;
@ -78,6 +80,10 @@ public class PlanGenerationNode implements NodeAction {
* pipeline (non-team deployments / tests).
*/
private TeamPlanBridge teamPlanBridge;
private AgentRoutingDecisionAdapter routingAdapter;
public void setRoutingAdapter(AgentRoutingDecisionAdapter adapter) { this.routingAdapter = adapter; }
public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) {
this.teamPlanBridge = teamPlanBridge;
@ -807,10 +813,16 @@ public class PlanGenerationNode implements NodeAction {
List<Long> stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
// A Team whose roster resolution failed must still bypass non-Team routing.
var routing = leadTeam == null && routingAdapter != null && routingAdapter.enabled()
? routingAdapter.select(chatOrigin.workspaceId(), agentId, conversationId,
displayGoal(accessor.goal()), steps.size(), stepAgentIds) : null;
var plan = routing == null
? planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds)
: planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds, routing);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
stepAgentIds != null ? ", planner delegation=" + stepAgentIds : "");
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
@ -847,6 +859,8 @@ public class PlanGenerationNode implements NodeAction {
return planOut.build();
} catch (Exception e) {
DecisionRecordingException recording = DecisionRecordingException.find(e);
if (recording != null) throw recording;
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
// When the triage LLM fails or returns unparseable output we now fall back to
// a single-step plan (the user's goal verbatim) instead of a direct text

View File

@ -0,0 +1,13 @@
package vip.mate.decision.api;
public final class DecisionCodes {
private DecisionCodes() {}
public static String requireCode(String value) {
if (value == null || !value.matches("[A-Za-z0-9_.:-]{1,80}")) throw new IllegalArgumentException("Invalid decision code");
return value;
}
public static String description(String value) {
if (value == null || value.isBlank() || value.length() > 2048) throw new IllegalArgumentException("Invalid description size");
return value;
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.decision.api;
import java.util.List;
import java.util.Map;
public record DecisionFacts(Map<String, Boolean> flags, Map<String, Double> numbers, List<String> evidence) {
public DecisionFacts(Map<String, Boolean> flags, Map<String, Double> numbers) { this(flags, numbers, List.of()); }
public DecisionFacts {
flags = Map.copyOf(flags); numbers = Map.copyOf(numbers); evidence = List.copyOf(evidence);
if (flags.size() + numbers.size() > 64 || evidence.size() > 8 || evidence.stream().mapToInt(String::length).sum() > 8192)
throw new IllegalArgumentException("Facts exceed limits");
flags.keySet().forEach(DecisionCodes::requireCode); numbers.keySet().forEach(DecisionCodes::requireCode);
if (numbers.values().stream().anyMatch(n -> !Double.isFinite(n))) throw new IllegalArgumentException("Nonfinite fact");
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.decision.api;
public enum DecisionMode { OFF, SHADOW, ACTIVE }

View File

@ -0,0 +1,3 @@
package vip.mate.decision.api;
public enum DecisionOutcome { PENDING, OBSERVED, APPLIED, NOT_APPLIED }

View File

@ -0,0 +1,32 @@
package vip.mate.decision.api;
import java.util.List;
import java.util.HashSet;
public sealed interface DecisionQuestion {
String version();
String description();
boolean accepts(DecisionValue value);
record Option(String code, String description) {
public Option { DecisionCodes.requireCode(code); DecisionCodes.description(description); }
}
record Choice(String version, String description, List<Option> options) implements DecisionQuestion {
public Choice {
DecisionCodes.requireCode(version); DecisionCodes.description(description); options = List.copyOf(options);
if (options.isEmpty() || options.size() > 64 || new HashSet<>(options.stream().map(Option::code).toList()).size() != options.size())
throw new IllegalArgumentException("Invalid options");
}
public boolean accepts(DecisionValue value) { return value instanceof DecisionValue.Choice c && options.stream().anyMatch(o -> o.code().equals(c.code())); }
}
record BooleanQuestion(String version, String description) implements DecisionQuestion {
public BooleanQuestion { DecisionCodes.requireCode(version); DecisionCodes.description(description); }
public boolean accepts(DecisionValue value) { return value instanceof DecisionValue.BooleanValue; }
}
record Score(String version, String description, double min, double max) implements DecisionQuestion {
public Score {
DecisionCodes.requireCode(version); DecisionCodes.description(description);
if (!Double.isFinite(min) || !Double.isFinite(max) || min > max) throw new IllegalArgumentException("Invalid score range");
}
public boolean accepts(DecisionValue value) { return value instanceof DecisionValue.Score s && s.value() >= min && s.value() <= max; }
}
}

View File

@ -0,0 +1,13 @@
package vip.mate.decision.api;
public class DecisionRecordingException extends RuntimeException {
public DecisionRecordingException() { super("Decision audit unavailable; preserve current business state"); }
/** Locate audit failures through transaction, graph, and asynchronous execution wrappers. */
public static DecisionRecordingException find(Throwable failure) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof DecisionRecordingException recording) return recording;
}
return null;
}
}

View File

@ -0,0 +1,18 @@
package vip.mate.decision.api;
import java.util.Objects;
/**
* An immutable snapshot assembled by trusted domain code. Options define the legal domain;
* guard and policy values must originate from server policy, never provider or client input.
*/
public record DecisionRequest(DecisionType type, DecisionScope scope, String phase, DecisionQuestion question,
DecisionFacts facts, DecisionValue baseline, DecisionValue guardValue, DecisionValue policyOverride) {
public DecisionRequest {
Objects.requireNonNull(type); Objects.requireNonNull(scope); DecisionCodes.requireCode(phase);
Objects.requireNonNull(question); Objects.requireNonNull(facts); Objects.requireNonNull(baseline);
if (!question.accepts(baseline) || guardValue != null && !question.accepts(guardValue)
|| policyOverride != null && !question.accepts(policyOverride)) throw new IllegalArgumentException("Value outside question domain");
if (guardValue != null && policyOverride != null && !guardValue.equals(policyOverride)) throw new IllegalArgumentException("Policy cannot override guard");
}
}

View File

@ -0,0 +1,10 @@
package vip.mate.decision.api;
public record DecisionScope(Long workspaceId, Long subjectId, Long runId, String conversationId, String attemptId) {
public DecisionScope(Long workspaceId, Long subjectId, Long runId) { this(workspaceId, subjectId, runId, null, null); }
public DecisionScope {
if (conversationId != null && (conversationId.isBlank() || conversationId.length() > 128)
|| attemptId != null && (attemptId.isBlank() || attemptId.length() > 128))
throw new IllegalArgumentException("Invalid scope identifier size");
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.decision.api;
public record DecisionTicket(String id, DecisionMode mode, DecisionValue effectiveValue) {}

View File

@ -0,0 +1,3 @@
package vip.mate.decision.api;
public enum DecisionType { GOAL_CONTINUATION, WORKER_RESULT, AGENT_ROUTING }

View File

@ -0,0 +1,18 @@
package vip.mate.decision.api;
public sealed interface DecisionValue {
record Choice(String code) implements DecisionValue {
public Choice { DecisionCodes.requireCode(code); }
}
record BooleanValue(boolean value) implements DecisionValue {}
record Score(double value) implements DecisionValue {
public Score { if (!Double.isFinite(value)) throw new IllegalArgumentException("Nonfinite score"); }
}
default String encoded() {
return switch (this) {
case Choice c -> c.code();
case BooleanValue b -> Boolean.toString(b.value());
case Score s -> Double.toString(s.value());
};
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.decision.config;
import lombok.Data;
import org.springframework.stereotype.Component;
import org.springframework.boot.context.properties.ConfigurationProperties;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionType;
import java.util.EnumMap;
import java.util.Map;
@Data
@Component
@ConfigurationProperties(prefix = "mate.decision")
public class DecisionProperties {
private DecisionMode mode = DecisionMode.SHADOW;
private Map<DecisionType, DecisionMode> scenarios = new EnumMap<>(DecisionType.class);
private String provider = "rule";
private double confidenceThreshold = 0.8;
private long timeoutMs = 250;
private int providerThreads = 4;
private int shadowThreads = 2;
private int queueCapacity = 64;
private int retentionDays = 30;
private int retentionBatchSize = 500;
public DecisionMode modeFor(DecisionType type) { return scenarios.getOrDefault(type, mode); }
public void validate() {
if (mode == null || scenarios == null || provider == null || !provider.matches("[A-Za-z0-9_.:-]{1,80}")
|| !Double.isFinite(confidenceThreshold) || confidenceThreshold < 0 || confidenceThreshold > 1
|| timeoutMs < 1 || timeoutMs > 60000 || providerThreads < 1 || providerThreads > 32
|| shadowThreads < 1 || shadowThreads > 16 || queueCapacity < 1 || queueCapacity > 10000
|| retentionDays < 1 || retentionBatchSize < 1 || retentionBatchSize > 10000)
throw new IllegalArgumentException("Invalid decision configuration");
}
}

View File

@ -0,0 +1,149 @@
package vip.mate.decision.core;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class DecisionService implements AutoCloseable {
private final DecisionProperties properties;
private final Map<String, DecisionProvider> providers;
private final DecisionRecordStore store;
private final MeterRegistry metrics;
private final ThreadPoolExecutor providerExecutor;
private final ThreadPoolExecutor shadowExecutor;
public DecisionService(DecisionProperties properties, List<DecisionProvider> providers, DecisionRecordStore store, MeterRegistry metrics) {
properties.validate();
this.properties = properties;
this.providers = providers.stream().collect(Collectors.toUnmodifiableMap(p -> DecisionCodes.requireCode(p.id()), Function.identity()));
this.store = store; this.metrics = metrics;
providerExecutor = executor(properties.getProviderThreads(), properties.getQueueCapacity(), "decision-provider-");
shadowExecutor = executor(properties.getShadowThreads(), properties.getQueueCapacity(), "decision-shadow-");
}
private static ThreadPoolExecutor executor(int threads, int capacity, String prefix) {
return new ThreadPoolExecutor(threads, threads, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(capacity),
Thread.ofPlatform().daemon(true).name(prefix, 0).factory(), new ThreadPoolExecutor.AbortPolicy());
}
public DecisionTicket decide(DecisionRequest request) {
var mode = properties.modeFor(request.type());
metrics.counter("mate.decision.requests", "type", request.type().name(), "mode", mode.name()).increment();
if (mode == DecisionMode.OFF) return new DecisionTicket(null, mode, request.baseline());
String id = UUID.randomUUID().toString();
if (mode == DecisionMode.SHADOW) {
submitShadow(() -> evaluateAndRecord(request, mode, id), request.type());
return new DecisionTicket(id, mode, request.baseline());
}
return new DecisionTicket(id, mode, evaluateAndRecord(request, mode, id));
}
private DecisionValue evaluateAndRecord(DecisionRequest request, DecisionMode mode, String id) {
long start = System.nanoTime();
var evaluated = evaluate(request);
DecisionValue effective = evaluated.value;
String reason = evaluated.reason;
String overrideReason = "NONE";
if (request.guardValue() == null && request.policyOverride() != null) {
effective = request.policyOverride(); overrideReason = "POLICY_OVERRIDE";
}
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
String providerId = request.guardValue() != null ? "none" : providers.containsKey(properties.getProvider()) ? properties.getProvider() : "unavailable";
var record = new DecisionRecord(id, request.type(), request.scope(), request.phase(), request.question().version(), mode,
providerId, evaluated.version, kind(request.baseline()), request.baseline().encoded(),
evaluated.proposed == null ? null : evaluated.proposed.encoded(), effective.encoded(), evaluated.confidence, reason, overrideReason, elapsed);
metrics.counter("mate.decision.total", "type", request.type().name(), "mode", mode.name(), "provider", providerId, "reason", reason, "override", overrideReason).increment();
metrics.timer("mate.decision.duration", "type", request.type().name(), "mode", mode.name()).record(elapsed, TimeUnit.MILLISECONDS);
if (evaluated.proposed != null || "BASELINE".equals(evaluated.reason)) {
var proposed = evaluated.proposed == null ? request.baseline() : evaluated.proposed;
metrics.counter("mate.decision.comparison", "type", request.type().name(), "mode", mode.name(), "stage", "proposed",
"agreement", Boolean.toString(proposed.equals(request.baseline()))).increment();
metrics.counter("mate.decision.comparison", "type", request.type().name(), "mode", mode.name(), "stage", "effective",
"agreement", Boolean.toString(effective.equals(request.baseline()))).increment();
}
try { store.insert(record); }
catch (RuntimeException ex) {
failure("record", request.type());
if (mode == DecisionMode.ACTIVE) throw new DecisionRecordingException();
}
return effective;
}
private Evaluation evaluate(DecisionRequest request) {
if (request.guardValue() != null) return new Evaluation(request.guardValue(), null, null, "none", "GUARD");
DecisionProvider provider = providers.get(properties.getProvider());
if (provider == null) return fallback(request, "UNAVAILABLE");
Future<DecisionResult> future = null;
try {
future = providerExecutor.submit(() -> {
if (!provider.capability().supports(request)) return new DecisionResult(DecisionResult.Status.UNAVAILABLE, null, null, "unsupported");
return provider.decide(request);
});
DecisionResult result = future.get(properties.getTimeoutMs(), TimeUnit.MILLISECONDS);
if (result == null || result.status() == null) return fallback(request, "INVALID_RESULT");
String version = DecisionCodes.requireCode(result.version());
if (result.status() == DecisionResult.Status.BASELINE)
return new Evaluation(request.baseline(), null, null, version, "BASELINE");
if (result.status() != DecisionResult.Status.PROPOSED)
return new Evaluation(request.baseline(), null, null, version, result.status().name());
if (!request.question().accepts(result.value()) || result.confidence() == null || !Double.isFinite(result.confidence())
|| result.confidence() < 0 || result.confidence() > 1) return fallback(request, "INVALID_RESULT");
if (result.confidence() < properties.getConfidenceThreshold())
return new Evaluation(request.baseline(), result.value(), result.confidence(), version, "LOW_CONFIDENCE");
return new Evaluation(result.value(), result.value(), result.confidence(), version, "PROPOSED");
} catch (TimeoutException ex) { return fallback(request, "TIMEOUT"); }
catch (RejectedExecutionException ex) { return fallback(request, "SATURATED"); }
catch (InterruptedException ex) { Thread.currentThread().interrupt(); return fallback(request, "INTERRUPTED"); }
catch (RuntimeException | ExecutionException ex) { return fallback(request, "PROVIDER_ERROR"); }
finally { if (future != null && !future.isDone()) future.cancel(true); }
}
private Evaluation fallback(DecisionRequest request, String reason) { return new Evaluation(request.baseline(), null, null, "unknown", reason); }
private static String kind(DecisionValue value) {
return switch (value) {
case DecisionValue.Choice ignored -> "CHOICE";
case DecisionValue.BooleanValue ignored -> "BOOLEAN";
case DecisionValue.Score ignored -> "SCORE";
};
}
/**
* ACTIVE writes join the caller's transaction: call at the mutation boundary, before commit.
* SHADOW writes run only after a successful commit and are best effort. An absent outcome
* remains pending; returning a proposal never claims that a business mutation happened.
*/
public void recordOutcome(DecisionTicket ticket, DecisionOutcome outcome, DecisionValue actualValue) {
if (ticket == null || ticket.mode() == DecisionMode.OFF || ticket.id() == null) return;
if (outcome == null || outcome == DecisionOutcome.PENDING || actualValue == null) throw new IllegalArgumentException("Terminal outcome required");
if (ticket.mode() == DecisionMode.ACTIVE) {
try { store.outcome(ticket.id(), outcome, actualValue); }
catch (RuntimeException ex) { throw new DecisionRecordingException(); }
} else {
Runnable write = () -> submitShadow(() -> {
try { store.outcome(ticket.id(), outcome, actualValue); }
catch (RuntimeException ex) { failure("outcome", null); }
}, null);
if (TransactionSynchronizationManager.isActualTransactionActive() && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { write.run(); }
});
} else write.run();
}
}
private void submitShadow(Runnable task, DecisionType type) {
try { shadowExecutor.execute(() -> { try { task.run(); } catch (RuntimeException ex) { failure("shadow", type); } }); }
catch (RejectedExecutionException ex) { failure("dropped", type); }
}
private void failure(String stage, DecisionType type) {
metrics.counter("mate.decision.failure", "stage", stage, "type", type == null ? "OUTCOME" : type.name()).increment();
}
@Override @PreDestroy public void close() { shadowExecutor.shutdownNow(); providerExecutor.shutdownNow(); }
private record Evaluation(DecisionValue value, DecisionValue proposed, Double confidence, String version, String reason) {}
}

View File

@ -0,0 +1,22 @@
package vip.mate.decision.provider;
import java.util.Set;
import vip.mate.decision.api.*;
public record DecisionCapability(Set<Kind> kinds, Set<String> languages, int maxOptions, int maxEvidenceCharacters) {
public enum Kind { CHOICE, BOOLEAN, SCORE }
public DecisionCapability {
kinds = Set.copyOf(kinds); languages = Set.copyOf(languages);
if (maxOptions < 1 || maxEvidenceCharacters < 0) throw new IllegalArgumentException("Invalid capability limits");
}
public static DecisionCapability standard() { return new DecisionCapability(Set.of(Kind.values()), Set.of("und"), 64, 8192); }
public boolean supports(DecisionRequest request) {
var kind = switch (request.question()) {
case DecisionQuestion.Choice ignored -> Kind.CHOICE;
case DecisionQuestion.BooleanQuestion ignored -> Kind.BOOLEAN;
case DecisionQuestion.Score ignored -> Kind.SCORE;
};
return kinds.contains(kind) && (!(request.question() instanceof DecisionQuestion.Choice c) || c.options().size() <= maxOptions)
&& request.facts().evidence().stream().mapToInt(String::length).sum() <= maxEvidenceCharacters;
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.decision.provider;
import vip.mate.decision.api.DecisionRequest;
public interface DecisionProvider {
String id();
DecisionCapability capability();
DecisionResult decide(DecisionRequest request);
}

View File

@ -0,0 +1,9 @@
package vip.mate.decision.provider;
import vip.mate.decision.api.DecisionValue;
public record DecisionResult(Status status, DecisionValue value, Double confidence, String version) {
public enum Status { PROPOSED, BASELINE, UNAVAILABLE, ABSTAIN }
public static DecisionResult proposed(DecisionValue value, double confidence, String version) { return new DecisionResult(Status.PROPOSED, value, confidence, version); }
public static DecisionResult unavailable() { return new DecisionResult(Status.UNAVAILABLE, null, null, "stub-v1"); }
}

View File

@ -0,0 +1,11 @@
package vip.mate.decision.provider;
import org.springframework.stereotype.Component;
import vip.mate.decision.api.DecisionRequest;
@Component
public class LlmDecisionProvider implements DecisionProvider {
public String id() { return "llm"; }
public DecisionCapability capability() { return DecisionCapability.standard(); }
public DecisionResult decide(DecisionRequest request) { return DecisionResult.unavailable(); }
}

View File

@ -0,0 +1,13 @@
package vip.mate.decision.provider;
import org.springframework.stereotype.Component;
import vip.mate.decision.api.DecisionRequest;
@Component
public class RuleDecisionProvider implements DecisionProvider {
public String id() { return "rule"; }
public DecisionCapability capability() { return DecisionCapability.standard(); }
public DecisionResult decide(DecisionRequest request) {
return new DecisionResult(DecisionResult.Status.BASELINE, request.baseline(), null, "rule-v1");
}
}

View File

@ -0,0 +1,10 @@
package vip.mate.decision.record;
import vip.mate.decision.api.*;
public record DecisionRecord(String id, DecisionType type, DecisionScope scope, String phase, String questionVersion,
DecisionMode mode, String provider, String providerVersion, String valueKind,
String baseline, String proposed, String effective, Double confidence, String reason, String overrideReason,
long elapsedMs) {
public String policyVersion() { return "v1"; }
}

View File

@ -0,0 +1,9 @@
package vip.mate.decision.record;
import vip.mate.decision.api.DecisionOutcome;
import vip.mate.decision.api.DecisionValue;
public interface DecisionRecordStore {
void insert(DecisionRecord record);
void outcome(String id, DecisionOutcome outcome, DecisionValue actualValue);
}

View File

@ -0,0 +1,92 @@
package vip.mate.decision.record;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Repository
public class JdbcDecisionRecordStore implements DecisionRecordStore {
private final JdbcTemplate jdbc;
private final TransactionTemplate independent;
private final TransactionTemplate joined;
private final DecisionProperties properties;
public JdbcDecisionRecordStore(JdbcTemplate jdbc, PlatformTransactionManager manager, DecisionProperties properties) {
this.jdbc = jdbc; this.properties = properties;
independent = new TransactionTemplate(manager);
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
joined = new TransactionTemplate(manager);
}
@Override public void insert(DecisionRecord r) {
independent.executeWithoutResult(status -> jdbc.update("""
INSERT INTO mate_decision_record
(id, decision_type, workspace_id, subject_id, run_id, conversation_id, attempt_id, phase,
question_version, policy_version, mode, provider, provider_version, value_kind,
baseline_value, proposed_value, effective_value, confidence, reason, override_reason, elapsed_ms, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", r.id(), r.type().name(), r.scope().workspaceId(), r.scope().subjectId(), r.scope().runId(),
r.scope().conversationId(), r.scope().attemptId(), r.phase(), r.questionVersion(), r.policyVersion(), r.mode().name(),
r.provider(), r.providerVersion(), r.valueKind(), r.baseline(), r.proposed(), r.effective(),
r.confidence(), r.reason(), r.overrideReason(), r.elapsedMs(), Timestamp.from(Instant.now())));
}
@Override public void outcome(String id, DecisionOutcome outcome, DecisionValue value) {
joined.executeWithoutResult(status -> {
// Serialize with retention when the proposal already exists. Missing proposals
// are expected for asynchronous outcomes, so duplicate inserts still use a savepoint.
jdbc.queryForList("SELECT id FROM mate_decision_record WHERE id = ? FOR UPDATE", String.class, id);
Object savepoint = status.createSavepoint();
try {
jdbc.update("INSERT INTO mate_decision_outcome (decision_id, outcome, actual_value, create_time) VALUES (?, ?, ?, ?)",
id, outcome.name(), value.encoded(), Timestamp.from(Instant.now()));
} catch (DuplicateKeyException ex) {
status.rollbackToSavepoint(savepoint);
// A locking read observes the committed winner even under repeatable-read isolation.
var existing = jdbc.query("SELECT outcome, actual_value FROM mate_decision_outcome WHERE decision_id = ? FOR UPDATE",
(rs, row) -> new StoredOutcome(rs.getString(1), rs.getString(2)), id);
if (existing.isEmpty() || !existing.getFirst().equals(new StoredOutcome(outcome.name(), value.encoded())))
throw new IllegalStateException("Decision outcome is immutable");
} finally {
status.releaseSavepoint(savepoint);
}
});
}
@Scheduled(fixedDelayString = "${mate.decision.retention-interval-ms:3600000}")
public void purgeExpired() {
if (properties.getMode() == DecisionMode.OFF && properties.getScenarios().values().stream().noneMatch(m -> m != DecisionMode.OFF)) return;
var cutoff = Timestamp.from(Instant.now().minus(properties.getRetentionDays(), ChronoUnit.DAYS));
independent.executeWithoutResult(status -> {
var ids = jdbc.queryForList("""
SELECT r.id FROM mate_decision_record r
LEFT JOIN mate_decision_outcome o ON o.decision_id = r.id
WHERE r.create_time < ? AND (o.decision_id IS NULL OR o.create_time < ?)
ORDER BY r.create_time LIMIT ?
""", String.class, cutoff, cutoff, properties.getRetentionBatchSize());
for (String id : ids) {
var proposalTimes = jdbc.queryForList("SELECT create_time FROM mate_decision_record WHERE id = ? FOR UPDATE", Timestamp.class, id);
if (proposalTimes.isEmpty() || !proposalTimes.getFirst().before(cutoff)) continue;
var outcomeTimes = jdbc.queryForList("SELECT create_time FROM mate_decision_outcome WHERE decision_id = ? FOR UPDATE", Timestamp.class, id);
if (!outcomeTimes.isEmpty() && !outcomeTimes.getFirst().before(cutoff)) continue;
jdbc.update("DELETE FROM mate_decision_outcome WHERE decision_id = ? AND create_time < ?", id, cutoff);
jdbc.update("DELETE FROM mate_decision_record WHERE id = ?", id);
}
int remaining = properties.getRetentionBatchSize() - ids.size();
if (remaining > 0) {
var orphans = jdbc.queryForList("""
SELECT o.decision_id FROM mate_decision_outcome o
WHERE o.create_time < ? AND NOT EXISTS (SELECT 1 FROM mate_decision_record r WHERE r.id = o.decision_id)
ORDER BY o.create_time LIMIT ?
""", String.class, cutoff, remaining);
for (String id : orphans) jdbc.update("DELETE FROM mate_decision_outcome WHERE decision_id = ? AND create_time < ?", id, cutoff);
}
});
}
private record StoredOutcome(String outcome, String value) {}
}

View File

@ -11,6 +11,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import vip.mate.agent.runtime.RunningConversationRegistry;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
@ -44,6 +46,10 @@ public class GoalContinuationSupervisor {
private final ConcurrentHashMap<Long, GoalRunCoordinator.ClaimedRun> active = new ConcurrentHashMap<>();
private final AtomicReference<LocalDateTime> providerBackoffUntil = new AtomicReference<>();
private volatile boolean closing;
private GoalDecisionAdapter decisionAdapter;
@Autowired(required = false)
public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; }
@Autowired
public GoalContinuationSupervisor(GoalContinuationStore store, GoalService goals, GoalProperties properties,
@ -108,22 +114,28 @@ public class GoalContinuationSupervisor {
if (!eligible(goal)) {
settle(claimed,new SegmentOutcome.Cancelled("goal_not_runnable"),now); return;
}
var decision = followups.decide(goal,new GoalEvaluationResult(0,goal.getProgressSummary(),
GoalEvaluationResult.DECISION_CONTINUE,false,"",0,0,List.of(),null),now);
var evaluation = new GoalEvaluationResult(0,goal.getProgressSummary(),
GoalEvaluationResult.DECISION_CONTINUE,false,"",0,0,List.of(),null);
var selection = decisionAdapter == null ? null
: decisionAdapter.select(goal, evaluation, now, claimed.attempt().id(), "DURABLE");
var decision = selection == null ? followups.decide(goal, evaluation, now) : selection.decision();
DecisionTicket ticket = selection == null ? null : selection.ticket();
switch (decision.action()) {
case DEFER, RETRY -> {
settle(claimed,new SegmentOutcome.Defer(decision.reason(),decision.nextRunAt()),now); return;
settle(claimed,new SegmentOutcome.Defer(decision.reason(),decision.nextRunAt()),now,ticket); return;
}
case BUDGET_LIMITED -> {
goals.markExhausted(goal.getId(),decision.reason());
settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now); return;
settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now,ticket); return;
}
case COMPLETE, DISABLED -> {
settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now); return;
settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now,ticket); return;
}
case CONTINUE -> { }
}
if(!coordinator.markRunning(claimed,now)) return;
boolean started = ticket == null ? coordinator.markRunning(claimed,now)
: coordinator.markRunning(claimed,now,ticket);
if (!started) return;
// Recovery requeues the projection as retry and gives the new attempt
// a durable parent; the old running-state check alone loses its guidance.
boolean recovered = claimed.attempt().parentAttemptId()!=null
@ -136,10 +148,17 @@ public class GoalContinuationSupervisor {
// Shutdown cancellation is not user Stop: retain the lease for recovery.
if (closing) return;
settle(claimed,outcome,LocalDateTime.now(clock));
} catch (DecisionRecordingException error) {
// Retain the lease for recovery; audit availability is not a model or user-input failure.
log.warn("Goal {} decision recording failed; retaining recovery lease", initial.getId());
} catch (RuntimeException error) {
// A shutdown/lost-lease cancellation is not a task failure. Leave the
// running lease for restart recovery; the runner saves partial evidence.
if (closing || Thread.currentThread().isInterrupted()) return;
if (DecisionRecordingException.find(error) != null) {
log.warn("Goal {} decision recording failed; retaining recovery lease", initial.getId());
return;
}
boolean transientError = retryable(error);
if (transientError) activateProviderBackoff(now);
if (!transientError) {
@ -170,6 +189,15 @@ public class GoalContinuationSupervisor {
}
}
private void settle(GoalRunCoordinator.ClaimedRun claimed, SegmentOutcome outcome,
LocalDateTime now, DecisionTicket ticket) {
if (ticket == null) {
settle(claimed, outcome, now);
} else if (coordinator.settle(claimed, outcome, now, ticket)) {
streams.broadcastObject(claimed.goal().getConversationId(), "goal_continuation", store.get(claimed.goal().getId()));
}
}
private static boolean eligible(GoalEntity goal) {
return goal != null && goal.getStatus()==GoalStatus.ACTIVE
&& Boolean.TRUE.equals(goal.getPersistentExecution()) && Boolean.TRUE.equals(goal.getAutoFollowupEnabled());

View File

@ -0,0 +1,98 @@
package vip.mate.goal.service;
import org.springframework.stereotype.Service;
import vip.mate.decision.api.DecisionFacts;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionOutcome;
import vip.mate.decision.api.DecisionQuestion;
import vip.mate.decision.api.DecisionRequest;
import vip.mate.decision.api.DecisionScope;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionType;
import vip.mate.decision.api.DecisionValue;
import vip.mate.decision.core.DecisionService;
import vip.mate.goal.model.GoalContinuationDecision;
import vip.mate.goal.model.GoalContinuationDecision.Action;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/** Restricts continuation proposals to scheduling; completion remains a domain transition. */
@Service
public class GoalDecisionAdapter {
private final GoalFollowupService followups;
private final DecisionService decisions;
public GoalDecisionAdapter(GoalFollowupService followups, DecisionService decisions) {
this.followups = followups;
this.decisions = decisions;
}
public record Selection(DecisionTicket ticket, GoalContinuationDecision decision) {}
public Selection select(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now,
String attemptId, String phase) {
GoalContinuationDecision baseline = followups.decide(goal, result, now);
DecisionTicket ticket = request(goal, baseline.action(), attemptId, phase,
baseline.action() != Action.CONTINUE);
return effective(ticket, baseline, now);
}
private Selection effective(DecisionTicket ticket, GoalContinuationDecision baseline, LocalDateTime now) {
if (ticket.mode() != DecisionMode.ACTIVE || baseline.action() != Action.CONTINUE) {
return new Selection(ticket, baseline);
}
String code = ((DecisionValue.Choice) ticket.effectiveValue()).code();
if ("DEFER".equals(code) || "RETRY".equals(code)) {
return new Selection(ticket, new GoalContinuationDecision(Action.valueOf(code), baseline.prompt(),
now.plusSeconds(30), "decision_" + code.toLowerCase(Locale.ROOT)));
}
return new Selection(ticket, baseline);
}
public Selection selectGraph(GoalEntity goal, Optional<String> followup, LocalDateTime now, boolean capped) {
GoalContinuationDecision baseline = followup.isPresent()
? new GoalContinuationDecision(Action.CONTINUE, followup.get(), now, "remaining_criteria")
: new GoalContinuationDecision(Action.DISABLED, null, null, "no_followup");
if (capped) return new Selection(request(goal, Action.DISABLED, null, "GRAPH_CAP", true), baseline);
return effective(request(goal, baseline.action(), null, "GRAPH", baseline.action() != Action.CONTINUE), baseline, now);
}
private DecisionTicket request(GoalEntity goal, Action action, String attemptId, String phase, boolean guarded) {
DecisionValue.Choice baseline = new DecisionValue.Choice(action.name());
List<DecisionQuestion.Option> options = guarded
? List.of(new DecisionQuestion.Option(action.name(), "Existing domain guard"))
: List.of(new DecisionQuestion.Option("CONTINUE", "Start the next bounded continuation"),
new DecisionQuestion.Option("DEFER", "Defer for thirty seconds"),
new DecisionQuestion.Option("RETRY", "Retry scheduling after thirty seconds"));
DecisionScope scope = new DecisionScope(goal == null ? null : goal.getWorkspaceId(),
goal == null ? null : goal.getId(), null,
identifier(goal == null ? null : goal.getConversationId()), identifier(attemptId));
DecisionFacts facts = new DecisionFacts(Map.of("PERSISTENT", goal != null && Boolean.TRUE.equals(goal.getPersistentExecution())),
Map.of("TURNS_USED", goal == null || goal.getTurnsUsed() == null ? 0d : goal.getTurnsUsed().doubleValue()));
return decisions.decide(new DecisionRequest(DecisionType.GOAL_CONTINUATION, scope, phase,
new DecisionQuestion.Choice("goal-continuation-v1", "Schedule bounded goal continuation", options),
facts, baseline, guarded ? baseline : null, null));
}
private static String identifier(String value) {
return value == null || value.isBlank() || value.length() > 128 ? null : value;
}
public void outcome(DecisionTicket ticket, boolean applied, String actualCode) {
DecisionOutcome outcome = !applied ? DecisionOutcome.NOT_APPLIED
: ticket != null && ticket.mode() == DecisionMode.SHADOW ? DecisionOutcome.OBSERVED : DecisionOutcome.APPLIED;
decisions.recordOutcome(ticket, outcome, new DecisionValue.Choice(actualCode));
}
/** Called only after the existing domain transition succeeds, within its transaction. */
public void observeTransition(GoalEntity goal, Action action) {
DecisionTicket ticket = request(goal, action, null, "TERMINAL", true);
decisions.recordOutcome(ticket, DecisionOutcome.OBSERVED, new DecisionValue.Choice(goal.getStatus().name()));
}
}

View File

@ -1,6 +1,10 @@
package vip.mate.goal.service;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionValue;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalAttempt;
@ -33,6 +37,11 @@ public class GoalRunCoordinator {
this.clock=clock;
}
private GoalDecisionAdapter decisionAdapter;
@Autowired(required = false)
public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; }
public record ClaimedRun(GoalContinuationStore.Continuation candidate,GoalEntity goal,
GoalAttempt attempt,long revision) {}
@ -77,6 +86,29 @@ public class GoalRunCoordinator {
return attempts.markRunning(run.attempt().id(),run.attempt().leaseToken(),now);
}
@Transactional
public boolean markRunning(ClaimedRun run, LocalDateTime now, DecisionTicket ticket) {
if (ticket != null && ticket.mode() == DecisionMode.ACTIVE) {
if (run == null || !continuations.lockGoal(run.goal().getId())) {
if (decisionAdapter != null) decisionAdapter.outcome(ticket, false, "FENCE_REJECTED");
return false;
}
// Provider work may have overlapped a user stop. Recheck under the same goal lock.
if (!eligible(goals.getById(run.goal().getId()))) {
if (decisionAdapter != null) decisionAdapter.outcome(ticket, false, "GOAL_NOT_RUNNABLE");
return false;
}
}
boolean applied = markRunning(run, now);
if (decisionAdapter != null) decisionAdapter.outcome(ticket, applied, applied ? "RUN_STARTED" : "FENCE_REJECTED");
return applied;
}
@Transactional
public boolean settle(ClaimedRun run, SegmentOutcome outcome, LocalDateTime now, DecisionTicket ticket) {
return settleWithDecision(run, outcome, now, ticket);
}
@Transactional
public boolean renew(ClaimedRun run,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
@ -108,26 +140,61 @@ public class GoalRunCoordinator {
@Transactional
public boolean settle(ClaimedRun run,SegmentOutcome outcome,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
return settleWithDecision(run, outcome, now, null);
}
private boolean settleWithDecision(ClaimedRun run, SegmentOutcome outcome, LocalDateTime now, DecisionTicket ticket) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return rejectDecision(ticket, "FENCE_REJECTED");
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
if(!current(run,nowEpoch)) return rejectDecision(ticket, "FENCE_REJECTED");
GoalEntity fresh=goals.getById(run.goal().getId());
Settlement settlement=classify(run,outcome,fresh,now);
// A delayed proposal cannot revive scheduling after a concurrent stop or disable.
if (ticket != null && ticket.mode() == DecisionMode.ACTIVE
&& "queued".equals(settlement.projectionState) && !eligible(fresh)) {
return rejectDecision(ticket, "GOAL_NOT_RUNNABLE");
}
if((outcome instanceof SegmentOutcome.Continue || outcome instanceof SegmentOutcome.Complete)
&& !attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),"resolved",
"evaluation_saved",null,now)) return false;
"evaluation_saved",null,now)) return rejectDecision(ticket, "FENCE_REJECTED");
if(!attempts.finish(run.attempt().id(),run.attempt().leaseToken(),settlement.attemptState,
outcome.reason(),settlement.errorCategory,now)) return false;
outcome.reason(),settlement.errorCategory,now)) return rejectDecision(ticket, "FENCE_REJECTED");
if(!continuations.settleFenced(run.goal().getId(),run.attempt().leaseToken(),run.attempt().id(),
run.revision(),settlement.projectionState,settlement.nextRunAt,settlement.failures,
settlement.reason,now)) {
throw new IllegalStateException("Goal projection fence changed during settlement");
}
if (ticket != null && decisionAdapter != null) {
boolean delayOverridden = ticket.mode() == DecisionMode.ACTIVE
&& ticket.effectiveValue() instanceof DecisionValue.Choice choice
&& ("DEFER".equals(choice.code()) || "RETRY".equals(choice.code()))
&& !"queued".equals(settlement.projectionState);
decisionAdapter.outcome(ticket, !delayOverridden, settledCode(settlement.projectionState));
}
return true;
}
private boolean rejectDecision(DecisionTicket ticket, String actualCode) {
if (ticket != null && decisionAdapter != null) decisionAdapter.outcome(ticket, false, actualCode);
return false;
}
private static String settledCode(String state) {
return switch (state) {
case "completed" -> "COMPLETED";
case "budget_limited" -> "BUDGET_LIMITED";
case "waiting_approval" -> "WAITING_APPROVAL";
case "waiting_input" -> "WAITING_INPUT";
case "retry" -> "RETRY";
case "blocked" -> "BLOCKED";
case "paused" -> "PAUSED";
case "queued" -> "QUEUED";
default -> throw new IllegalStateException("Unknown goal settlement state");
};
}
private java.time.Instant currentInstant(LocalDateTime requested) {
// Preserve absolute time across DST overlap and time spent waiting for the goal lock.
// Keep sub-second precision for next_run_at comparisons; only persisted leases use seconds.

View File

@ -19,6 +19,7 @@ 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.GoalContinuationDecision.Action;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalCriteriaCodec;
import vip.mate.goal.model.GoalCriterion;
@ -74,6 +75,10 @@ public class GoalServiceImpl implements GoalService {
private vip.mate.memory.spi.MemoryManager memoryManager;
private GoalJsonBindingService jsonBindings;
private ManagedGoalJsonService managedArtifacts;
private GoalDecisionAdapter decisionAdapter;
@Autowired(required = false)
public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; }
@Autowired
public void setManagedArtifacts(ManagedGoalJsonService managedArtifacts) { this.managedArtifacts = managedArtifacts; }
@ -486,6 +491,7 @@ public class GoalServiceImpl implements GoalService {
return w;
});
if (!transitioned[0]) return g;
if (decisionAdapter != null) decisionAdapter.observeTransition(g, Action.COMPLETE);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
@ -542,7 +548,9 @@ public class GoalServiceImpl implements GoalService {
@Override
@Transactional
public GoalEntity markExhausted(Long id, String reason) {
boolean[] transitioned = {false};
GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> {
transitioned[0] = false;
if (fresh.getStatus().isTerminal()) return null;
boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution());
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
@ -552,9 +560,11 @@ public class GoalServiceImpl implements GoalService {
+ (reason != null ? reason : "budget limit")
+ ". Increase the budget and resume to continue.");
}
transitioned[0] = true;
bumpVersionAndTime(w);
return w;
});
if (transitioned[0] && decisionAdapter != null) decisionAdapter.observeTransition(g, Action.BUDGET_LIMITED);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason != null ? reason : "unknown");
detail.put("turnsUsed", g.getTurnsUsed());

View File

@ -0,0 +1,144 @@
package vip.mate.planning.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springframework.stereotype.Service;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import java.util.*;
/** Optional per-step routing; planner assignments remain the compatibility baseline. */
@Service
public class AgentRoutingDecisionAdapter {
private final DecisionService decisions;
private final DecisionProperties properties;
private final AgentMapper agents;
public AgentRoutingDecisionAdapter(DecisionService decisions, DecisionProperties properties, AgentMapper agents) {
this.decisions = decisions; this.properties = properties; this.agents = agents;
}
public record Step(Long baseline, DecisionTicket ticket) {}
public record Selection(Long workspaceId, String parentId, List<Step> steps) {
public Selection { steps = List.copyOf(steps); }
public boolean active() { return steps.stream().anyMatch(step -> step.ticket().mode() == DecisionMode.ACTIVE); }
}
private record Candidate(Long id, String name) {}
public boolean enabled() { return properties.modeFor(DecisionType.AGENT_ROUTING) != DecisionMode.OFF; }
/** Provider work happens here, before the plan persistence transaction. */
public Selection select(Long workspaceId, String parentId, String conversationId, String originalGoal,
int stepCount, List<Long> baseline) {
if (!enabled()) return null;
try {
List<AgentEntity> catalog = workspaceId == null ? List.of() : agents.selectList(
Wrappers.<AgentEntity>lambdaQuery().eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getDeleted, 0).orderByAsc(AgentEntity::getId));
List<Candidate> candidates = eligible(catalog, workspaceId, parentId);
Set<Long> eligible = new HashSet<>();
candidates.forEach(candidate -> eligible.add(candidate.id()));
String goal = originalGoal == null ? "" : originalGoal.toLowerCase(Locale.ROOT);
boolean explicit = catalog.stream().filter(agent -> agent != null && Objects.equals(workspaceId, agent.getWorkspaceId()))
.anyMatch(agent -> agent.getName() != null && !agent.getName().isBlank()
&& goal.contains(agent.getName().trim().toLowerCase(Locale.ROOT)));
boolean oversized = candidates.size() > 63;
List<Step> selections = new ArrayList<>();
for (int index = 0; index < stepCount; index++) {
Long old = baseline != null && index < baseline.size() ? baseline.get(index) : null;
boolean stale = old != null && !eligible.contains(old);
List<DecisionQuestion.Option> options = new ArrayList<>();
options.add(option(null));
// An excluded old ID is representable only in a guarded request: SHADOW must
// describe the unchanged legacy assignment, while ACTIVE must use LOCAL.
if (oversized || stale) {
if (old != null) options.add(option(old));
} else {
candidates.forEach(candidate -> options.add(option(candidate.id())));
}
DecisionValue oldValue = value(old);
DecisionValue guard = stale ? value(null) : explicit || oversized ? oldValue : null;
var facts = new DecisionFacts(Map.of("EXPLICIT_AGENT", explicit, "CANDIDATE_LIMIT", oversized,
"BASELINE_INELIGIBLE", stale), Map.of("STEP_INDEX", (double) index));
var scope = new DecisionScope(workspaceId, numeric(parentId), null, identifier(conversationId), null);
DecisionTicket ticket = decisions.decide(new DecisionRequest(DecisionType.AGENT_ROUTING, scope,
"PLAN_STEP", new DecisionQuestion.Choice("agent-routing-v1", "Assign a planner step", options),
facts, oldValue, guard, null));
selections.add(new Step(old, ticket));
}
return new Selection(workspaceId, parentId, selections);
} catch (RuntimeException failure) {
if (properties.modeFor(DecisionType.AGENT_ROUTING) == DecisionMode.ACTIVE) throw new DecisionRecordingException();
return null;
}
}
/** ACTIVE uses locking reads so eligibility is current even under repeatable-read isolation. */
public List<Long> assignments(Selection selection) {
Set<Long> eligible = new HashSet<>();
if (selection.active()) {
Set<Long> ids = new TreeSet<>();
for (Step step : selection.steps()) {
if (step.baseline() != null) ids.add(step.baseline());
Long proposed = agentId(step.ticket().effectiveValue());
if (proposed != null) ids.add(proposed);
}
if (selection.workspaceId() != null && !ids.isEmpty()) {
List<AgentEntity> current = agents.selectList(Wrappers.<AgentEntity>lambdaQuery()
.eq(AgentEntity::getWorkspaceId, selection.workspaceId())
.in(AgentEntity::getId, ids).orderByAsc(AgentEntity::getId).last("FOR UPDATE"));
eligible(current, selection.workspaceId(), selection.parentId()).forEach(candidate -> eligible.add(candidate.id()));
}
}
List<Long> result = new ArrayList<>();
for (Step step : selection.steps()) {
Long assigned = step.baseline();
if (step.ticket().mode() == DecisionMode.ACTIVE) {
Long proposed = agentId(step.ticket().effectiveValue());
assigned = proposed == null || eligible.contains(proposed) ? proposed
: step.baseline() != null && eligible.contains(step.baseline()) ? step.baseline() : null;
}
result.add(assigned);
}
return result.stream().allMatch(Objects::isNull) ? null : Collections.unmodifiableList(result);
}
public void record(Selection selection, List<Long> actual) {
for (int index = 0; index < selection.steps().size(); index++) {
Step step = selection.steps().get(index);
DecisionValue value = value(actual == null ? null : actual.get(index));
DecisionOutcome outcome = step.ticket().mode() == DecisionMode.SHADOW ? DecisionOutcome.OBSERVED
: value.equals(step.ticket().effectiveValue()) ? DecisionOutcome.APPLIED : DecisionOutcome.NOT_APPLIED;
decisions.recordOutcome(step.ticket(), outcome, value);
}
}
private List<Candidate> eligible(List<AgentEntity> catalog, Long workspaceId, String parentId) {
if (workspaceId == null) return List.of();
var byId = new TreeMap<Long, Candidate>();
for (AgentEntity agent : catalog) {
if (agent != null && agent.getId() != null && agent.getId() > 0
&& (agent.getDeleted() == null || agent.getDeleted() == 0)
&& Boolean.TRUE.equals(agent.getEnabled()) && workspaceId.equals(agent.getWorkspaceId())
&& !Objects.equals(agent.getId(), numeric(parentId))) {
byId.putIfAbsent(agent.getId(), new Candidate(agent.getId(), agent.getName()));
}
}
return List.copyOf(byId.values());
}
private static DecisionQuestion.Option option(Long id) {
return new DecisionQuestion.Option(value(id).code(), id == null ? "Execute with parent" : "Delegate to eligible peer");
}
private static DecisionValue.Choice value(Long id) { return new DecisionValue.Choice(id == null ? "LOCAL" : "AGENT:" + id); }
private static Long agentId(DecisionValue value) {
String code = ((DecisionValue.Choice) value).code();
return "LOCAL".equals(code) ? null : Long.valueOf(code.substring("AGENT:".length()));
}
private static Long numeric(String value) {
try { return Long.valueOf(value); } catch (RuntimeException ignored) { return null; }
}
private static String identifier(String value) { return value == null || value.isBlank() || value.length() > 128 ? null : value; }
}

View File

@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.decision.api.DecisionRecordingException;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.planning.model.PlanEntity;
import vip.mate.planning.model.SubPlanEntity;
@ -12,6 +14,7 @@ import vip.mate.planning.repository.SubPlanMapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
@ -28,6 +31,32 @@ public class PlanningService {
private final PlanMapper planMapper;
private final SubPlanMapper subPlanMapper;
private AgentRoutingDecisionAdapter routingAdapter;
@Autowired(required = false)
public void setRoutingAdapter(AgentRoutingDecisionAdapter adapter) { this.routingAdapter = adapter; }
/** Persist selected assignments and their actual outcomes in one transaction. */
@Transactional
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> steps,
List<Long> baseline, AgentRoutingDecisionAdapter.Selection selection) {
if (selection == null) return createPlan(agentId, conversationId, goal, steps, baseline);
try {
if (routingAdapter == null || selection.steps().size() != steps.size()
|| !Objects.equals(agentId, selection.parentId())) {
throw new IllegalArgumentException("Routing selection does not match plan");
}
List<Long> actual = routingAdapter.assignments(selection);
PlanEntity plan = insertPlan(agentId, conversationId, goal, steps, actual, selection.active());
routingAdapter.record(selection, actual);
return plan;
} catch (RuntimeException failure) {
// An ACTIVE plan must not be replaced by an unrecorded fallback after rollback.
if (selection.active()) throw new DecisionRecordingException();
throw failure;
}
}
/**
* 创建执行计划(由 StateGraphPlanExecuteAgent 调用)
@ -54,6 +83,11 @@ public class PlanningService {
@Transactional
public PlanEntity createPlan(String agentId, String conversationId, String goal,
List<String> steps, List<Long> stepAgentIds) {
return insertPlan(agentId, conversationId, goal, steps, stepAgentIds, false);
}
private PlanEntity insertPlan(String agentId, String conversationId, String goal,
List<String> steps, List<Long> stepAgentIds, boolean requireInserted) {
PlanEntity plan = new PlanEntity();
plan.setAgentId(agentId);
plan.setConversationId(conversationId);
@ -66,7 +100,8 @@ public class PlanningService {
plan.setStatus("pending");
plan.setTotalSteps(steps.size());
plan.setCompletedSteps(0);
planMapper.insert(plan);
int inserted = planMapper.insert(plan);
if (requireInserted && inserted != 1) throw new DecisionRecordingException();
IntStream.range(0, steps.size()).forEach(i -> {
SubPlanEntity sub = new SubPlanEntity();
@ -79,7 +114,8 @@ public class PlanningService {
if (stepAgentIds != null && i < stepAgentIds.size()) {
sub.setAssignedAgentId(stepAgentIds.get(i));
}
subPlanMapper.insert(sub);
int insertedSub = subPlanMapper.insert(sub);
if (requireInserted && insertedSub != 1) throw new DecisionRecordingException();
});
log.info("Created plan {} with {} steps for agent {}", plan.getId(), steps.size(), agentId);

View File

@ -6,6 +6,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.decision.api.DecisionTicket;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import vip.mate.team.event.TeamTasksDelegatedEvent;
@ -54,6 +57,11 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class TeamDispatchService {
private WorkerDecisionAdapter decisionAdapter;
@Autowired(required = false)
public void setDecisionAdapter(WorkerDecisionAdapter adapter) { this.decisionAdapter = adapter; }
/** Result summaries are capped before persisting to keep the board readable. */
static final int MAX_RESULT_CHARS = 8000;
@ -204,6 +212,7 @@ public class TeamDispatchService {
return;
}
String childConvId = "team-task-" + IdUtil.fastSimpleUUID();
WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId);
ScheduledFuture<?> heartbeat = null;
try {
AgentTeamEntity team = teamService.getTeam(teamId);
@ -250,8 +259,12 @@ public class TeamDispatchService {
return;
}
settleOutcome(task, reply);
settleOutcome(task, reply, snapshot);
} catch (Exception e) {
if (DecisionRecordingException.find(e) != null) {
log.warn("Team task #{} decision recording failed; retaining current state", task.getTaskNumber());
return;
}
log.warn("Team {} task #{} member run ended exceptionally: {}", teamId,
task.getTaskNumber(), e.getMessage());
// Only report a failure the guarded transition actually applied — an
@ -302,18 +315,49 @@ public class TeamDispatchService {
* otherwise the final reply becomes the task result (auto-completion).
*/
void settleOutcome(TeamTaskEntity task, String reply) {
settleOutcome(task, reply, WorkerDecisionAdapter.Snapshot.capture(task));
}
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) {
TeamTaskEntity current = taskService.getTask(task.getId());
if (current == null) {
return;
}
if (decisionAdapter != null && decisionAdapter.enabled() && decisionAdapter.active()
&& TeamTaskStatus.IN_PROGRESS.equals(current.getStatus()) && !snapshot.matches(current)) {
var rejected = decisionAdapter.judge(task, "STALE_ATTEMPT", null);
decisionAdapter.outcome(rejected.ticket(), false, "STALE_ATTEMPT");
return;
}
if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) {
boolean attachedGeneratedFile = attachGeneratedFileDeliverable(current, reply);
boolean attachedGeneratedFile = attachGeneratedFileDeliverable(current, reply, snapshot);
String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile);
if (invalidReason != null) {
int attempts = current.getDispatchCount() == null ? 0 : current.getDispatchCount();
int maxAttempts = isResponseGenerationFailure(invalidReason)
? MAX_RESPONSE_FAILURE_DISPATCHES
: TeamTaskService.MAX_DISPATCHES;
if (decisionAdapter != null && decisionAdapter.enabled()) {
var judgment = decisionAdapter.judge(current, invalidCode(invalidReason), reply);
String actual;
if (decisionAdapter.active()) {
actual = taskService.settleUnusableResult(task.getId(), invalidReason,
attempts < maxAttempts, judgment.ticket(), snapshot);
} else {
actual = attempts < maxAttempts && taskService.requeueUnusableResult(task.getId(), invalidReason)
? TeamTaskStatus.PENDING : taskService.failTask(task.getId(), invalidReason)
? TeamTaskStatus.FAILED : null;
decisionAdapter.outcome(judgment.ticket(), actual != null,
actual == null ? "TRANSITION_REJECTED" : actual);
}
if (TeamTaskStatus.PENDING.equals(actual)) {
broadcast(task, "team_task_retrying", Map.of("reason", invalidReason));
} else if (TeamTaskStatus.FAILED.equals(actual)) {
broadcast(task, "team_task_failed", Map.of("reason", invalidReason));
announceService.announceTaskSettled(taskService.getTask(task.getId()));
}
return;
}
if (attempts < maxAttempts
&& taskService.requeueUnusableResult(task.getId(), invalidReason)) {
log.warn("Team task #{} produced an unusable result on attempt {}/{}; requeued: {}",
@ -337,8 +381,10 @@ public class TeamDispatchService {
.anyMatch(comment -> comment.getContent() != null
&& comment.getContent().contains(terminalEvidence));
if (terminalAlreadyAcknowledged) {
List<Long> released = taskService.completeTask(task.getId(), null,
truncate(reply, MAX_RESULT_CHARS));
DecisionTicket ticket = (decisionAdapter == null || !decisionAdapter.enabled()) ? null
: decisionAdapter.judge(current, "CHECKPOINT_ACK", reply).ticket();
List<Long> released = completeWorker(task.getId(), truncate(reply, MAX_RESULT_CHARS), ticket, snapshot);
if (released == null) return;
TeamTaskEntity completed = taskService.getTask(task.getId());
log.info("Team task #{} completed after deferred {} acknowledgement "
+ "({} dependents released)",
@ -349,15 +395,39 @@ public class TeamDispatchService {
}
int percent = current.getProgressPercent() == null
? 1 : Math.max(1, current.getProgressPercent());
taskService.updateProgress(task.getId(), null, percent,
"waiting for " + terminalCheckpoint + " checkpoint");
String step = "waiting for " + terminalCheckpoint + " checkpoint";
if (decisionAdapter != null && decisionAdapter.enabled()) {
var judgment = decisionAdapter.judge(current, "CHECKPOINT_WAIT", reply);
if (decisionAdapter.active()) {
taskService.parkWorkerCheckpoint(task.getId(), percent, step, judgment.ticket(), snapshot);
} else {
boolean applied = taskService.updateProgress(task.getId(), null, percent, step);
decisionAdapter.outcome(judgment.ticket(), applied,
applied ? TeamTaskStatus.IN_PROGRESS : "TRANSITION_REJECTED");
}
} else {
taskService.updateProgress(task.getId(), null, percent, step);
}
log.info("Team task #{} parked as long-running checkpoint tracker until {}",
task.getTaskNumber(), terminalCheckpoint);
return;
}
List<Long> released = taskService.completeTask(task.getId(), null,
truncate(reply == null || reply.isBlank() ? "(no output)" : reply,
MAX_RESULT_CHARS));
DecisionTicket ticket = null;
if (decisionAdapter != null && decisionAdapter.enabled()) {
var judgment = decisionAdapter.judge(current, "VALID", reply);
ticket = judgment.ticket();
if (!judgment.accepted()) {
String reason = "worker result did not satisfy task requirements";
if (taskService.failTask(task.getId(), reason, ticket, snapshot)) {
broadcast(task, "team_task_failed", Map.of("reason", reason));
announceService.announceTaskSettled(taskService.getTask(task.getId()));
}
return;
}
}
List<Long> released = completeWorker(task.getId(),
truncate(reply == null || reply.isBlank() ? "(no output)" : reply, MAX_RESULT_CHARS), ticket, snapshot);
if (released == null) return;
current = taskService.getTask(task.getId());
log.info("Team task #{} auto-completed ({} dependents released)",
task.getTaskNumber(), released.size());
@ -379,6 +449,22 @@ public class TeamDispatchService {
announceService.announceTaskSettled(current);
}
private List<Long> completeWorker(Long taskId, String result, DecisionTicket ticket,
WorkerDecisionAdapter.Snapshot snapshot) {
if (ticket == null) return taskService.completeTask(taskId, null, result);
var completed = taskService.completeTask(taskId, null, result, ticket, snapshot);
return completed.applied() ? completed.released() : null;
}
private static String invalidCode(String reason) {
return switch (reason) {
case "member produced no result" -> "BLANK";
case "member response generation failed" -> "PLACEHOLDER";
case "required deliverable was not attached" -> "ARTIFACT_MISSING";
default -> "CLARIFICATION";
};
}
private String invalidResultReason(TeamTaskEntity task, String reply,
boolean attachedGeneratedFile) {
if (reply == null || reply.isBlank()) {
@ -429,7 +515,7 @@ public class TeamDispatchService {
|| normalized.contains("请提供");
}
private boolean attachGeneratedFileDeliverable(TeamTaskEntity task, String reply) {
private boolean attachGeneratedFileDeliverable(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) {
// A render link is a useful task artifact regardless of how the task
// was created. The metadata flag controls validation/retry semantics,
// not whether an otherwise valid generated file is discoverable in UI.
@ -444,7 +530,11 @@ public class TeamDispatchService {
continue;
}
try {
taskService.addDeliverable(task.getId(), task.getAssigneeAgentId(), name, url);
if (decisionAdapter != null && decisionAdapter.enabled() && decisionAdapter.active()) {
if (!taskService.addWorkerDeliverable(snapshot, task.getAssigneeAgentId(), name, url)) return false;
} else {
taskService.addDeliverable(task.getId(), task.getAssigneeAgentId(), name, url);
}
log.info("Team task #{} auto-attached generated deliverable from member reply: {}",
task.getTaskNumber(), name);
return true;

View File

@ -7,6 +7,9 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionMode;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskCommentEntity;
@ -70,6 +73,90 @@ public class TeamTaskService {
private final TeamRunProjectionScheduler projectionScheduler;
private final TeamRunService runService;
private WorkerDecisionAdapter decisionAdapter;
@Autowired(required = false)
public void setDecisionAdapter(WorkerDecisionAdapter adapter) { this.decisionAdapter = adapter; }
public record Completion(boolean applied, List<Long> released, String status) {}
/** Lock the execution row before applying an ACTIVE result or attaching its artifact. */
private TeamTaskEntity lockWorkerAttempt(Long taskId, WorkerDecisionAdapter.Snapshot snapshot) {
if (snapshot == null || !Objects.equals(taskId, snapshot.taskId())) return null;
TeamTaskEntity locked = taskMapper.selectOne(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getId, snapshot.taskId()).last("FOR UPDATE"));
return snapshot.matches(locked) ? locked : null;
}
private boolean allowWorkerMutation(Long taskId, DecisionTicket ticket, WorkerDecisionAdapter.Snapshot snapshot) {
if (ticket == null || ticket.mode() != DecisionMode.ACTIVE || lockWorkerAttempt(taskId, snapshot) != null) return true;
decisionAdapter.outcome(ticket, false, "STALE_ATTEMPT");
return false;
}
@Transactional
public Completion completeTask(Long taskId, Long agentId, String result, DecisionTicket ticket,
WorkerDecisionAdapter.Snapshot snapshot) {
TeamTaskEntity locked = null;
if (ticket != null && ticket.mode() == DecisionMode.ACTIVE) {
locked = lockWorkerAttempt(taskId, snapshot);
if (locked == null) {
decisionAdapter.outcome(ticket, false, "STALE_ATTEMPT");
return new Completion(false, List.of(), null);
}
}
Completion completed = completeTaskInternal(taskId, agentId, result, false, locked);
if (decisionAdapter != null) decisionAdapter.outcome(ticket, completed.applied(), completed.status());
return completed;
}
@Transactional
public boolean failTask(Long taskId, String reason, DecisionTicket ticket, WorkerDecisionAdapter.Snapshot snapshot) {
if (!allowWorkerMutation(taskId, ticket, snapshot)) return false;
boolean applied = failTask(taskId, reason);
if (decisionAdapter != null) decisionAdapter.outcome(ticket, applied, applied ? TeamTaskStatus.FAILED : "TRANSITION_REJECTED");
return applied;
}
@Transactional
public boolean requeueUnusableResult(Long taskId, String reason, DecisionTicket ticket,
WorkerDecisionAdapter.Snapshot snapshot) {
if (!allowWorkerMutation(taskId, ticket, snapshot)) return false;
boolean applied = requeueUnusableResult(taskId, reason);
if (decisionAdapter != null) decisionAdapter.outcome(ticket, applied, applied ? TeamTaskStatus.PENDING : "TRANSITION_REJECTED");
return applied;
}
@Transactional
public String settleUnusableResult(Long taskId, String reason, boolean retryAllowed, DecisionTicket ticket,
WorkerDecisionAdapter.Snapshot snapshot) {
if (!allowWorkerMutation(taskId, ticket, snapshot)) return null;
String actual = retryAllowed && requeueUnusableResult(taskId, reason) ? TeamTaskStatus.PENDING
: failTask(taskId, reason) ? TeamTaskStatus.FAILED : null;
if (decisionAdapter != null) decisionAdapter.outcome(ticket, actual != null,
actual == null ? "TRANSITION_REJECTED" : actual);
return actual;
}
@Transactional
public boolean parkWorkerCheckpoint(Long taskId, int percent, String step, DecisionTicket ticket,
WorkerDecisionAdapter.Snapshot snapshot) {
if (!allowWorkerMutation(taskId, ticket, snapshot)) return false;
boolean applied = updateProgress(taskId, null, percent, step);
if (decisionAdapter != null) decisionAdapter.outcome(ticket, applied,
applied ? TeamTaskStatus.IN_PROGRESS : "TRANSITION_REJECTED");
return applied;
}
@Transactional
public boolean addWorkerDeliverable(WorkerDecisionAdapter.Snapshot snapshot, Long agentId, String name, String url) {
if (snapshot == null) return false;
TeamTaskEntity locked = lockWorkerAttempt(snapshot.taskId(), snapshot);
if (locked == null) return false;
addDeliverableInternal(locked, agentId, name, url);
return true;
}
// ==================== creation ====================
@Transactional
@ -212,7 +299,11 @@ public class TeamTaskService {
*/
@Transactional
public List<Long> completeTask(Long taskId, Long agentId, String result) {
TeamTaskEntity task = requireTask(taskId);
return completeTaskInternal(taskId, agentId, result, true, null).released();
}
private Completion completeTaskInternal(Long taskId, Long agentId, String result, boolean observe, TeamTaskEntity locked) {
TeamTaskEntity task = locked == null ? requireTask(taskId) : locked;
if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) {
if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) {
throw new IllegalStateException("task #" + task.getTaskNumber()
@ -235,6 +326,7 @@ public class TeamTaskService {
.set(TeamTaskEntity::getLockExpiresAt, null)
.set(TeamTaskEntity::getProgressPercent, 100));
if (rows != 1) {
if (locked != null) return new Completion(false, List.of(), "TRANSITION_REJECTED");
throw new IllegalStateException("task #" + task.getTaskNumber()
+ " is " + task.getStatus() + " and cannot be completed");
}
@ -244,7 +336,8 @@ public class TeamTaskService {
agentId != null ? String.valueOf(agentId) : null, null);
List<Long> released = toReview ? List.of() : releaseDependents(task);
projectTask(task);
return released;
if (observe && decisionAdapter != null && decisionAdapter.enabled()) decisionAdapter.observeCompletion(task, target);
return new Completion(true, released, target);
}
/** Human approval of an in_review task; releases dependents. */
@ -804,7 +897,11 @@ public class TeamTaskService {
*/
@Transactional
public void addDeliverable(Long taskId, Long agentId, String name, String url) {
TeamTaskEntity task = requireTask(taskId);
addDeliverableInternal(requireTask(taskId), agentId, name, url);
}
private void addDeliverableInternal(TeamTaskEntity task, Long agentId, String name, String url) {
Long taskId = task.getId();
if (TeamTaskStatus.isTerminal(task.getStatus())) {
throw new IllegalStateException("task #" + task.getTaskNumber() + " is "
+ task.getStatus() + "; deliverables can only be attached while it is active");

View File

@ -1,5 +1,8 @@
package vip.mate.team.service;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.team.model.TeamTaskStatus;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -68,6 +71,8 @@ public class TeamWorkerInterventionService {
intervention.agentId(), REPLAY_PROMPT, intervention.conversationId(),
claimedPending.getToolCallPayload(), origin));
} catch (RuntimeException error) {
DecisionRecordingException recording = DecisionRecordingException.find(error);
if (recording != null) throw recording;
taskService.parkToolReplayUncertain(taskId, pendingId,
"Approved tool replay failed and its outcome is uncertain: "
+ safeMessage(error));
@ -168,6 +173,8 @@ public class TeamWorkerInterventionService {
result = turnGate.withPermit(permit, () -> agentService.chatWithUsage(
intervention.agentId(), feedback, intervention.conversationId(), origin));
} catch (RuntimeException error) {
DecisionRecordingException recording = DecisionRecordingException.find(error);
if (recording != null) throw recording;
taskService.failTask(taskId, "worker feedback failed: " + safeMessage(error));
throw error;
}
@ -275,7 +282,10 @@ public class TeamWorkerInterventionService {
"team_task_awaiting_approval", Map.of("pendingId", next.getPendingId()));
return;
}
dispatchService.settleOutcome(intervention.task(), reply);
var original = intervention.task();
var snapshot = new WorkerDecisionAdapter.Snapshot(original.getId(), intervention.agentId(),
original.getDispatchCount(), intervention.conversationId(), TeamTaskStatus.IN_PROGRESS);
dispatchService.settleOutcome(original, reply, snapshot);
dispatchService.requestDispatch(intervention.task().getTeamId());
}

View File

@ -0,0 +1,101 @@
package vip.mate.team.service;
import org.springframework.stereotype.Service;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskStatus;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Locale;
/** Judges result quality without granting new attempts or bypassing task approval. */
@Service
public class WorkerDecisionAdapter {
private final DecisionService decisions;
private final DecisionProperties properties;
private final TeamService teams;
public WorkerDecisionAdapter(DecisionService decisions, DecisionProperties properties, TeamService teams) {
this.decisions = decisions; this.properties = properties; this.teams = teams;
}
public record Snapshot(Long taskId, Long owner, Integer dispatchCount, String conversationId, String status) {
public static Snapshot capture(TeamTaskEntity task) {
return new Snapshot(task.getId(), task.getOwnerAgentId(), task.getDispatchCount(),
task.getConversationId(), task.getStatus());
}
public Snapshot withConversation(String conversation) {
return new Snapshot(taskId, owner, dispatchCount, conversation, status);
}
public boolean matches(TeamTaskEntity task) {
return task != null && taskId != null && owner != null && dispatchCount != null
&& conversationId != null && !conversationId.isBlank()
&& TeamTaskStatus.IN_PROGRESS.equals(status)
&& Objects.equals(taskId, task.getId()) && Objects.equals(owner, task.getOwnerAgentId())
&& Objects.equals(dispatchCount, task.getDispatchCount())
&& Objects.equals(conversationId, task.getConversationId()) && Objects.equals(status, task.getStatus());
}
}
public record Judgment(DecisionTicket ticket, boolean accepted) {}
public boolean enabled() { return properties.modeFor(DecisionType.WORKER_RESULT) != DecisionMode.OFF; }
public boolean active() { return properties.modeFor(DecisionType.WORKER_RESULT) == DecisionMode.ACTIVE; }
public Judgment judge(TeamTaskEntity task, String guard, String reply) {
boolean valid = "VALID".equals(guard) || "EXPLICIT_COMPLETION".equals(guard) || "CHECKPOINT_ACK".equals(guard);
DecisionValue value = new DecisionValue.BooleanValue(valid);
if (properties.modeFor(DecisionType.WORKER_RESULT) == DecisionMode.OFF) {
return new Judgment(new DecisionTicket(null, DecisionMode.OFF, value), valid);
}
boolean guarded = !"VALID".equals(guard);
String subject = task.getSubject();
String description = task.getDescription();
if (!guarded && (subject == null || subject.isBlank() || reply == null)) {
guard = "EVIDENCE_UNAVAILABLE"; guarded = true;
}
int evidenceLength = (subject == null ? 0 : subject.length())
+ (description == null ? 0 : description.length()) + (reply == null ? 0 : reply.length());
if (!guarded && evidenceLength > 8192) { guard = "EVIDENCE_OVERSIZE"; guarded = true; }
List<String> evidence = guarded ? List.of()
: List.of(subject, description == null ? "" : description, reply);
AgentTeamEntity team;
try { team = teams.getTeam(task.getTeamId()); }
catch (RuntimeException failure) {
if (active()) throw new DecisionRecordingException();
team = null;
}
var facts = new DecisionFacts(Map.of(guard, true, "REQUIRES_APPROVAL", Boolean.TRUE.equals(task.getRequireApproval())),
Map.of("DISPATCH_COUNT", task.getDispatchCount() == null ? 0d : task.getDispatchCount().doubleValue()), evidence);
DecisionTicket ticket = decisions.decide(new DecisionRequest(DecisionType.WORKER_RESULT,
new DecisionScope(team == null ? null : team.getWorkspaceId(), task.getId(), task.getRunId(),
identifier(task.getConversationId()), null), "EXPLICIT_COMPLETION".equals(guard) ? guard : "SETTLEMENT",
new DecisionQuestion.BooleanQuestion("worker-result-v1", "Does this worker result satisfy the assigned task?"),
facts, value, guarded ? value : null, null));
return new Judgment(ticket, ((DecisionValue.BooleanValue) ticket.effectiveValue()).value());
}
public void outcome(DecisionTicket ticket, boolean applied, String status) {
if (ticket == null) return;
decisions.recordOutcome(ticket, applied
? ticket.mode() == DecisionMode.SHADOW ? DecisionOutcome.OBSERVED : DecisionOutcome.APPLIED
: DecisionOutcome.NOT_APPLIED,
new DecisionValue.Choice(status.toUpperCase(Locale.ROOT)));
}
public void observeCompletion(TeamTaskEntity task, String status) {
var judgment = judge(task, "EXPLICIT_COMPLETION", null);
decisions.recordOutcome(judgment.ticket(), DecisionOutcome.OBSERVED,
new DecisionValue.Choice(status.toUpperCase(Locale.ROOT)));
}
private static String identifier(String value) {
return value == null || value.isBlank() || value.length() > 128 ? null : value;
}
}

View File

@ -1,5 +1,7 @@
package vip.mate.team.tool;
import vip.mate.decision.api.DecisionRecordingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
@ -156,8 +158,12 @@ public class TeamTasksTool {
+ "comment, attach, cancel, retry.";
};
} catch (IllegalArgumentException | IllegalStateException e) {
DecisionRecordingException recording = DecisionRecordingException.find(e);
if (recording != null) throw recording;
return "Error: " + e.getMessage();
} catch (Exception e) {
DecisionRecordingException recording = DecisionRecordingException.find(e);
if (recording != null) throw recording;
log.warn("team_tasks {} failed for team={} agent={}: {}",
action, team.getId(), agentId, e.getMessage());
return "Error: team_tasks failed — " + e.getMessage();

View File

@ -421,6 +421,18 @@ mateclaw:
# MateClaw Agent 配置
mate:
decision:
mode: SHADOW
scenarios: {}
provider: rule
confidence-threshold: 0.8
timeout-ms: 250
provider-threads: 4
shadow-threads: 2
queue-capacity: 64
retention-days: 30
retention-batch-size: 500
retention-interval-ms: 3600000
channel:
# 入站消息去重:IM 平台在 ack 迟到 / 丢失 / 非 200 时会重投同一条消息,
# 不去重则每次重投都会跑一轮完整 Agent 回合,用户看到重复答复。

View File

@ -0,0 +1,35 @@
-- Decision payloads deliberately exclude facts, evidence, descriptions and error messages.
CREATE TABLE mate_decision_record (
id VARCHAR(36) PRIMARY KEY,
decision_type VARCHAR(40) NOT NULL,
workspace_id BIGINT,
subject_id BIGINT,
run_id BIGINT,
conversation_id VARCHAR(128),
attempt_id VARCHAR(128),
phase VARCHAR(80) NOT NULL,
question_version VARCHAR(80) NOT NULL,
policy_version VARCHAR(80) NOT NULL,
mode VARCHAR(16) NOT NULL,
provider VARCHAR(80) NOT NULL,
provider_version VARCHAR(80) NOT NULL,
value_kind VARCHAR(16) NOT NULL,
baseline_value VARCHAR(80) NOT NULL,
proposed_value VARCHAR(80),
effective_value VARCHAR(80) NOT NULL,
confidence DOUBLE PRECISION,
reason VARCHAR(40) NOT NULL,
override_reason VARCHAR(40) NOT NULL,
elapsed_ms BIGINT NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_retention ON mate_decision_record (create_time);
CREATE INDEX idx_decision_scope ON mate_decision_record (workspace_id, decision_type, subject_id, create_time);
-- Separate rows allow asynchronous proposals and committed outcomes to arrive in either order.
CREATE TABLE mate_decision_outcome (
decision_id VARCHAR(36) PRIMARY KEY,
outcome VARCHAR(16) NOT NULL,
actual_value VARCHAR(80) NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_outcome_retention ON mate_decision_outcome (create_time);

View File

@ -0,0 +1,35 @@
-- Decision payloads deliberately exclude facts, evidence, descriptions and error messages.
CREATE TABLE mate_decision_record (
id VARCHAR(36) PRIMARY KEY,
decision_type VARCHAR(40) NOT NULL,
workspace_id BIGINT,
subject_id BIGINT,
run_id BIGINT,
conversation_id VARCHAR(128),
attempt_id VARCHAR(128),
phase VARCHAR(80) NOT NULL,
question_version VARCHAR(80) NOT NULL,
policy_version VARCHAR(80) NOT NULL,
mode VARCHAR(16) NOT NULL,
provider VARCHAR(80) NOT NULL,
provider_version VARCHAR(80) NOT NULL,
value_kind VARCHAR(16) NOT NULL,
baseline_value VARCHAR(80) NOT NULL,
proposed_value VARCHAR(80),
effective_value VARCHAR(80) NOT NULL,
confidence DOUBLE PRECISION,
reason VARCHAR(40) NOT NULL,
override_reason VARCHAR(40) NOT NULL,
elapsed_ms BIGINT NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_retention ON mate_decision_record (create_time);
CREATE INDEX idx_decision_scope ON mate_decision_record (workspace_id, decision_type, subject_id, create_time);
-- Separate rows allow asynchronous proposals and committed outcomes to arrive in either order.
CREATE TABLE mate_decision_outcome (
decision_id VARCHAR(36) PRIMARY KEY,
outcome VARCHAR(16) NOT NULL,
actual_value VARCHAR(80) NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_outcome_retention ON mate_decision_outcome (create_time);

View File

@ -0,0 +1,35 @@
-- Decision payloads deliberately exclude facts, evidence, descriptions and error messages.
CREATE TABLE mate_decision_record (
id VARCHAR(36) PRIMARY KEY,
decision_type VARCHAR(40) NOT NULL,
workspace_id BIGINT,
subject_id BIGINT,
run_id BIGINT,
conversation_id VARCHAR(128),
attempt_id VARCHAR(128),
phase VARCHAR(80) NOT NULL,
question_version VARCHAR(80) NOT NULL,
policy_version VARCHAR(80) NOT NULL,
mode VARCHAR(16) NOT NULL,
provider VARCHAR(80) NOT NULL,
provider_version VARCHAR(80) NOT NULL,
value_kind VARCHAR(16) NOT NULL,
baseline_value VARCHAR(80) NOT NULL,
proposed_value VARCHAR(80),
effective_value VARCHAR(80) NOT NULL,
confidence DOUBLE PRECISION,
reason VARCHAR(40) NOT NULL,
override_reason VARCHAR(40) NOT NULL,
elapsed_ms BIGINT NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_retention ON mate_decision_record (create_time);
CREATE INDEX idx_decision_scope ON mate_decision_record (workspace_id, decision_type, subject_id, create_time);
-- Separate rows allow asynchronous proposals and committed outcomes to arrive in either order.
CREATE TABLE mate_decision_outcome (
decision_id VARCHAR(36) PRIMARY KEY,
outcome VARCHAR(16) NOT NULL,
actual_value VARCHAR(80) NOT NULL,
create_time TIMESTAMP NOT NULL
);
CREATE INDEX idx_decision_outcome_retention ON mate_decision_outcome (create_time);

View File

@ -0,0 +1,77 @@
# Structured decision layer
The decision layer provides replaceable structured judgments. It does not execute tools or replace permissions, completion verification, or existing state machines. Requests use Choice, Boolean, or Score questions. Domain constraints remain authoritative after a Provider returns a suggestion.
## Modes and configuration
`mate.decision.mode` defaults to `SHADOW`:
| Mode | Behavior |
|---|---|
| OFF | Returns the legacy judgment without calling the new Provider or decision record store |
| SHADOW | Keeps the legacy judgment; a bounded background queue evaluates and records suggestions |
| ACTIVE | Uses suggestions that pass hard constraints, type checks, and confidence checks; domain services still commit state |
The following staged-rollout example observes only Goal decisions. Without scenario overrides, all three scenarios inherit the global SHADOW mode.
```yaml
mate:
decision:
mode: SHADOW
provider: rule
confidence-threshold: 0.8
timeout-ms: 250
provider-threads: 4
shadow-threads: 2
queue-capacity: 64
retention-days: 30
retention-batch-size: 500
retention-interval-ms: 3600000
scenarios:
GOAL_CONTINUATION: SHADOW
WORKER_RESULT: OFF
AGENT_ROUTING: OFF
```
Scenario settings override the global mode. To disable the layer completely, set the global mode to OFF and remove or disable scenario overrides. The built-in `rule` Provider explicitly returns the legacy judgment without inventing a confidence value. The `llm` Provider is an unavailable stub; this phase does not call a new model service.
## Processing
1. A deterministic guard checks hard constraints and can bypass the Provider.
2. The Provider produces a typed suggestion within a bounded executor and deadline.
3. Invalid types or candidates, timeout, failure, abstention, or low confidence fall back to the legacy judgment.
4. A policy override applies domain policy without bypassing the guard.
5. The legacy value, Provider suggestion, and effective suggestion are stored separately from the actual committed outcome.
SHADOW always returns the legacy judgment. Queue saturation or background recording failure reduces observation coverage. ACTIVE persists a proposal before returning it; actual outcomes participate in the caller's business transaction. A persisted proposal does not prove that business state committed.
## Integration boundaries
- Goal continuation preserves completion evidence, budgets and leases. An ACTIVE suggestion can conservatively defer or retry an otherwise eligible continuation.
- Worker judging preserves deterministic invalid-result checks, checkpoint rules and approval. An ACTIVE semantic rejection fails the task through the existing state machine; it does not grant another execution attempt. Settlement rechecks the original owner, dispatch count and conversation under lock.
- Agent routing is limited to non-Team plan steps; Team Lead decisions and explicit channel or tool routing remain outside this layer.
## Observability and privacy
Records use `mate_decision_record` and `mate_decision_outcome`. Metadata includes scenario, mode, Provider/version, policy/question versions, workspace and execution identifiers, pipeline evaluation duration (excluding later business commits), confidence, fallback cause, and override cause. Stored values are structured; raw prompts, answers, SQL, tool arguments, evidence text, and exception messages are excluded.
| Outcome | Meaning |
|---|---|
| PENDING | A proposal exists without a confirmed outcome; inferred from a missing outcome row |
| OBSERVED | The actual legacy-path result was observed |
| APPLIED | The caller recorded an actual result at an explicit application boundary |
| NOT_APPLIED | Concurrency checks, limits, or other constraints prevented application |
Graph-local `FOLLOWUP_SUBMITTED` means a followup output was constructed and submitted. `FOLLOWUP_SUPPRESSED` means an ACTIVE DEFER/RETRY suggestion prevented the current graph followup; it does not schedule a durable retry. These outcomes do not prove subsequent nodes executed or provide an atomic database-and-graph commit guarantee.
Micrometer metrics include `mate.decision.total`, `mate.decision.duration`, `mate.decision.comparison`, and `mate.decision.failure`. Business identifiers are excluded from metric tags. Comparison metrics distinguish raw Provider suggestions from effective suggestions; guards and unavailable Providers are not valid model comparisons.
Routing v1 records correlate by parent Agent, conversation and PLAN_STEP phase. Step index is transient and plan/subplan IDs are not persisted in the audit, so exact audit-to-subplan reconstruction is not available. Actual assignment outcomes still commit with plan insertion.
Queries must be scoped to workspace and time range. No new public query API is provided. Agreement rates must be considered alongside dropped work, failures, and missing outcomes. Agreement from the Rule stub does not establish improved model quality.
## Validation and future Providers
Tests use controlled Providers and do not require real models. Database tests use H2 with MySQL/PostgreSQL compatibility modes; these do not replace native MySQL/Kingbase validation.
Future Providers can implement the Java SPI for rules, remote models, or local inference. Rollout requires fixed protocol/model versions, minimized input, domain evaluation including Chinese, confidence calibration, timeout/circuit-breaking policies, and gradual enablement by scenario. Language capability metadata does not currently implement language detection or calibration. Industrial writes, uncertain side effects, and retry idempotency remain governed by existing execution and approval mechanisms.

View File

@ -0,0 +1,77 @@
# 结构化决策层
决策层提供可替换的结构化判断接口,不执行工具,也不替代现有权限、完成验收或状态机。请求使用 Choice、Boolean 或 Score,Provider 输出建议后仍须通过原有业务约束。
## 模式与配置
`mate.decision.mode` 默认 `SHADOW`:
| 模式 | 行为 |
|---|---|
| OFF | 直接使用旧判断,不调用新的 Provider 或决策记录存储 |
| SHADOW | 原判断继续生效;有界后台队列计算新建议并记录比较结果 |
| ACTIVE | 使用通过硬约束、类型和置信度检查的建议;实际状态提交仍由领域服务控制 |
下面是仅启用 Goal 观察的分阶段配置示例;未配置场景覆盖时,三个场景均继承全局 SHADOW。
```yaml
mate:
decision:
mode: SHADOW
provider: rule
confidence-threshold: 0.8
timeout-ms: 250
provider-threads: 4
shadow-threads: 2
queue-capacity: 64
retention-days: 30
retention-batch-size: 500
retention-interval-ms: 3600000
scenarios:
GOAL_CONTINUATION: SHADOW
WORKER_RESULT: OFF
AGENT_ROUTING: OFF
```
场景配置优先于全局模式。完全关闭时,将全局模式设为 OFF,并清除或关闭各场景覆盖项。当前内置 `rule` 返回原判断且不伪造置信度,`llm` 为明确返回不可用的占位 Provider;本阶段不调用新的模型服务。
## 处理流程
1. Deterministic Guard 先检查硬限制,可直接跳过 Provider。
2. Provider 在有界线程池和超时约束下产生类型化建议。
3. 类型非法、候选不存在、超时、异常、弃权或置信度不足时回到原判断。
4. Policy Override 应用领域策略,不能突破 Guard。
5. 保存原判断、Provider 建议、策略后的有效建议;实际提交结果另外保存。
SHADOW 永远返回原判断;队列满或后台记录失败只影响观察覆盖率。ACTIVE 在返回建议前持久化记录,实际效果参与调用方业务事务。建议持久化成功不代表业务已提交。
## 场景边界
- Goal 续写保留完成证据、预算和租约约束;ACTIVE 建议只能对原本允许的继续动作进行保守的延后或重试选择。
- Worker 判断保留确定性的无效结果检查、checkpoint 与审批要求;ACTIVE 语义拒绝通过既有状态机令任务失败,不增加执行重试次数。提交时在锁内重验原 owner、派发次数和会话。
- Agent Routing 只覆盖非 Team 的计划步骤;Team Lead、渠道绑定和显式工具委派仍保持原实现。
## 可观测与隐私
记录使用 `mate_decision_record` 和 `mate_decision_outcome`。字段包括场景、模式、Provider/版本、策略/问题版本、工作区和执行标识、管线计算耗时(不含后续业务提交)、置信度、回退原因和 Override 原因。结果只保存结构化值,不保存原始 Prompt、回答、SQL、工具参数、证据正文或异常消息。
| 结果状态 | 含义 |
|---|---|
| PENDING | 已记录建议,但没有可确认的提交结果;由缺少 outcome 行推导 |
| OBSERVED | 观察到原业务路径的实际结果 |
| APPLIED | 调用方在明确应用边界记录了实际结果 |
| NOT_APPLIED | 建议因并发状态、次数限制等原因未应用 |
图内 `FOLLOWUP_SUBMITTED` 仅表示已构建并提交续写输出;`FOLLOWUP_SUPPRESSED` 表示 ACTIVE DEFER/RETRY 建议阻止了当前续写,不代表已经安排持久重试。这些结果不表示后续节点已经执行,也不提供数据库与图执行之间的原子提交保证。
Micrometer 指标包括 `mate.decision.total`、`mate.decision.duration`、`mate.decision.comparison`、`mate.decision.failure`。业务 ID 不进入指标标签。比较指标区分 Provider 原始建议与策略后的建议;Guard 和不可用 Provider 不应被算作有效模型比较。
Routing v1 的持久记录通过父 Agent、会话和 PLAN_STEP 阶段关联;步骤序号仅是瞬时事实,尚未保存 plan/subplan ID,因此不能从审计记录精确重建每个子计划的对应关系。实际指派结果仍与计划插入同事务保存。
查询记录必须限定工作区和时间范围;当前不新增公开查询接口。分析一致率时还应查看丢弃、失败和缺少 outcome 的数量。Rule 的一致率不能说明新模型质量提高。
## 验证与后续接入
测试通过受控 Provider 验证新行为,不需要配置真实模型。数据库测试使用 H2,并执行 MySQL/PostgreSQL 兼容模式;这不能代替真实 MySQL/Kingbase 验证。
未来 Provider 可通过 Java SPI 实现规则、远程模型或本地推理。上线前需要协议和版本固定、输入最小化、中文领域数据评测、置信度校准、超时/熔断和逐场景灰度。语言能力元数据目前不等于语言识别或校准能力。工业写操作、未知副作用和重试幂等性仍由原执行与审批机制控制。

View File

@ -1,5 +1,15 @@
package vip.mate.agent.graph.node;
import vip.mate.goal.service.GoalDecisionAdapter;
import vip.mate.goal.model.GoalContinuationDecision;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionValue;
import vip.mate.decision.api.DecisionRecordingException;
import java.time.LocalDateTime;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
@ -24,6 +34,9 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@ -36,6 +49,8 @@ import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
/**
@ -246,6 +261,93 @@ class GoalEvaluationNodeContinuationTest {
verify(f.goalService, never()).markCompleted(any(), any());
}
@Test void graphSubmissionRecordsAfterConstructionDespiteLegacyBookkeepingFailure() throws Exception {
Fixture f = new Fixture();
var adapter = mock(GoalDecisionAdapter.class);
var ticket = new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice("CONTINUE"));
when(adapter.selectGraph(any(), any(), any(), eq(false))).thenReturn(new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(GoalContinuationDecision.Action.CONTINUE, "selected prompt", LocalDateTime.now(), "remaining_criteria")));
doThrow(new IllegalStateException("legacy audit unavailable")).when(f.goalService).recordFollowupInjected(any(), any());
var node = f.node(); node.setDecisionAdapter(adapter);
var output = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0));
assertEquals("selected prompt", output.get(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT));
verify(adapter).outcome(ticket, true, "FOLLOWUP_SUBMITTED");
}
@Test void graphCapsArePassedAsGuardsAndNeverRecordedAsSubmission() throws Exception {
Fixture f = new Fixture(); var adapter = mock(GoalDecisionAdapter.class);
var ticket = new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice("DISABLED"));
when(adapter.selectGraph(any(), any(), any(), eq(true))).thenReturn(new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(GoalContinuationDecision.Action.CONTINUE, "selected prompt", LocalDateTime.now(), "remaining_criteria")));
var node = f.node(); node.setDecisionAdapter(adapter);
var output = node.apply(f.state(FinishReason.NORMAL.getValue(), f.properties.getMaxFollowupsPerRun(), 0));
assertFalse(Boolean.TRUE.equals(output.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)));
verify(adapter).outcome(ticket, false, "GRAPH_CAP");
}
@Test void activeRecordingFailureEscapesGraphPlanningCatch() {
Fixture f = new Fixture(); var adapter = mock(GoalDecisionAdapter.class);
when(adapter.selectGraph(any(), any(), any(), anyBoolean())).thenThrow(new DecisionRecordingException());
var node = f.node(); node.setDecisionAdapter(adapter);
assertThrows(DecisionRecordingException.class, () -> node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)));
}
@Test void failedLegacyPlanningIsNotRetriedByDecisionAdapter() throws Exception {
Fixture f = new Fixture(); var adapter = mock(GoalDecisionAdapter.class);
when(f.followupService.maybeBuildFollowup(any(), any())).thenThrow(new IllegalStateException("planning failed"));
var node = f.node(); node.setDecisionAdapter(adapter);
var output = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0));
assertEquals(true, output.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
verify(f.followupService, times(1)).maybeBuildFollowup(any(), any());
verifyNoInteractions(adapter);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void wrappedTerminalAuditFailureEscapesLegacyDegradation(boolean completed) {
Fixture f = new Fixture();
var failure = new IllegalStateException("transaction wrapper", new DecisionRecordingException());
if (completed) {
var result = new GoalEvaluationResult(1, "verified", "completed", true, "", 0, 0, List.of(), null);
when(f.evaluationService.evaluate(any(), anyList(), anyString())).thenReturn(result);
when(f.goalService.markRuntimeEvaluatedCompleted(any(), any(), any())).thenThrow(failure);
} else {
when(f.goalService.isBudgetExhausted(any())).thenReturn(true);
when(f.goalService.markExhausted(any(), any())).thenThrow(failure);
}
assertThrows(DecisionRecordingException.class,
() -> f.node().apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)));
}
@ParameterizedTest
@ValueSource(strings = {"DEFER", "RETRY"})
void activeDelayRecordsAppliedGraphSuppressionWithoutClaimingDurableScheduling(String action) throws Exception {
Fixture f = new Fixture(); var adapter = mock(GoalDecisionAdapter.class);
var ticket = new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice(action));
when(adapter.selectGraph(any(), any(), any(), eq(false))).thenReturn(new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(GoalContinuationDecision.Action.valueOf(action), "original prompt",
LocalDateTime.now().plusSeconds(30), "decision_delay")));
var node = f.node(); node.setDecisionAdapter(adapter);
var output = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0));
assertEquals(true, output.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
assertFalse(Boolean.TRUE.equals(output.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)));
assertFalse(output.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT));
verify(f.goalService, never()).recordFollowupInjected(any(), any());
verify(adapter).outcome(ticket, true, "FOLLOWUP_SUPPRESSED");
}
@Test void absentBaselineFollowupRemainsNotApplied() throws Exception {
Fixture f = new Fixture(); var adapter = mock(GoalDecisionAdapter.class);
when(f.followupService.maybeBuildFollowup(any(), any())).thenReturn(Optional.empty());
var ticket = new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice("DISABLED"));
when(adapter.selectGraph(any(), any(), any(), eq(false))).thenReturn(new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(GoalContinuationDecision.Action.DISABLED, null, null, "no_followup")));
var node = f.node(); node.setDecisionAdapter(adapter);
var output = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0));
assertEquals(true, output.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
verify(adapter).outcome(ticket, false, "NO_FOLLOWUP");
}
// ===== Test fixture =====
private static final class Fixture {

View File

@ -0,0 +1,90 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.model.AgentEntity;
import vip.mate.decision.api.*;
import vip.mate.planning.service.*;
import vip.mate.planning.model.PlanEntity;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.service.TeamPlanBridge;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlanGenerationRoutingTest {
PlanningService planning=mock(PlanningService.class);
NodeStreamingChatHelper streaming=mock(NodeStreamingChatHelper.class);
AgentService agents=mock(AgentService.class);
AgentRoutingDecisionAdapter adapter=mock(AgentRoutingDecisionAdapter.class);
PlanGenerationNode node;
AgentRoutingDecisionAdapter.Selection selection;
@BeforeEach void setup() {
node=new PlanGenerationNode(null,planning,streaming,null,null,null,null,agents);
node.setRoutingAdapter(adapter);when(adapter.enabled()).thenReturn(true);
selection=new AgentRoutingDecisionAdapter.Selection(10L,"1",List.of(
new AgentRoutingDecisionAdapter.Step(2L,new DecisionTicket("ticket",DecisionMode.SHADOW,new DecisionValue.Choice("AGENT:2"))),
new AgentRoutingDecisionAdapter.Step(null,new DecisionTicket("ticket2",DecisionMode.SHADOW,new DecisionValue.Choice("LOCAL")))));
when(adapter.select(any(),any(),any(),any(),anyInt(),any())).thenReturn(selection);
var peer=new AgentEntity();peer.setId(2L);peer.setName("Research");peer.setEnabled(true);peer.setWorkspaceId(10L);
when(agents.listAgentsByWorkspace(10L,true)).thenReturn(List.of(peer));
var plan=new PlanEntity();plan.setId(50L);
when(planning.createPlan(anyString(),anyString(),anyString(),anyList(),any())).thenReturn(plan);
when(planning.createPlan(anyString(),anyString(),anyString(),anyList(),any(),any())).thenReturn(plan);
reply("[\"Research\"]");
}
void reply(String names) {
when(streaming.streamCallSilent(any(),any(),any(),any())).thenReturn(new NodeStreamingChatHelper.StreamResult(
"{\"needs_planning\":true,\"steps\":[\"read\",\"write\"],\"step_agents\":"+names+"}","",null,List.of(),false,0,0));
}
OverAllState state() {
var values=new HashMap<String,Object>();values.put(PlanStateKeys.GOAL,"Ask Research to summarize");
values.put(MateClawStateKeys.AGENT_ID,"1");values.put(MateClawStateKeys.CONVERSATION_ID,"conversation");
values.put(MateClawStateKeys.CHAT_ORIGIN,ChatOrigin.web("conversation","admin",10L,null).withAgent(1L));
return new OverAllState(values);
}
@Test void enabledNonTeamUsesTicketOverloadAndOriginalBaseline() throws Exception {
node.apply(state());verify(adapter).select(10L,"1","conversation","Ask Research to summarize",2,Arrays.asList(2L,null));
verify(planning).createPlan("1","conversation","Ask Research to summarize",List.of("read","write"),Arrays.asList(2L,null),selection);
verify(streaming,times(1)).streamCallSilent(any(),any(),any(),any());
}
@ParameterizedTest @ValueSource(booleans={true,false})
void teamSuccessAndSerialFallbackBypassAdapter(boolean resolved) throws Exception {
var bridge=mock(TeamPlanBridge.class);node.setTeamPlanBridge(bridge);
var team=new AgentTeamEntity();team.setId(20L);team.setName("Team");
when(bridge.leadTeam(1L)).thenReturn(Optional.of(team));
when(bridge.resolveMembers(any(),any(),any())).thenReturn(resolved?List.of(2L,2L):null);
when(bridge.delegatePlan(any(),any(),any(),any(),any(),any(),any())).thenReturn("delegated");
node.apply(state());verifyNoInteractions(adapter);
verify(planning).createPlan(eq("1"),eq("conversation"),anyString(),eq(List.of("read","write")),eq(resolved?List.of(2L,2L):Arrays.asList(2L,null)));
}
@ParameterizedTest @ValueSource(booleans={true,false})
void absentAndOffUseLegacyOverload(boolean absent) throws Exception {
if(absent)node.setRoutingAdapter(null);else when(adapter.enabled()).thenReturn(false);
node.apply(state());verify(planning).createPlan(anyString(),anyString(),anyString(),anyList(),eq(Arrays.asList(2L,null)));
verify(planning,never()).createPlan(anyString(),anyString(),anyString(),anyList(),any(),any());
}
@Test void missingNamesRetainNullList() throws Exception {
reply("[]");node.apply(state());verify(adapter).select(any(),any(),any(),any(),eq(2),isNull());
}
@Test void duplicateNamesKeepLegacyLastWinnerAndExtraNamesAreIgnored() throws Exception {
var first=new AgentEntity();first.setId(2L);first.setName("Research");
var last=new AgentEntity();last.setId(3L);last.setName("Research");
when(agents.listAgentsByWorkspace(10L,true)).thenReturn(List.of(first,last));reply("[\"Research\",\"unknown\",\"Research\"]");
node.apply(state());verify(adapter).select(any(),any(),any(),any(),eq(2),eq(Arrays.asList(3L,null)));
}
@ParameterizedTest @ValueSource(booleans={true,false})
void auditFailureNeverFallsBackToUnrecordedPlan(boolean wrapped) {
var failure=new DecisionRecordingException();
when(planning.createPlan(anyString(),anyString(),anyString(),anyList(),any(),any())).thenThrow(wrapped?new IllegalStateException(failure):failure);
assertThrows(DecisionRecordingException.class,()->node.apply(state()));
verify(planning,never()).createPlan(anyString(),anyString(),anyString(),anyList());
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.decision;
import org.junit.jupiter.api.Test;
import vip.mate.decision.api.*;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import static org.junit.jupiter.api.Assertions.*;
class DecisionContractTest {
@Test void typedQuestionsRejectWrongKindsAndOutOfRangeValues() {
var choice = new DecisionQuestion.Choice("v1", "Choose action", List.of(new DecisionQuestion.Option("CONTINUE", "Continue")));
assertTrue(choice.accepts(new DecisionValue.Choice("CONTINUE")));
assertFalse(choice.accepts(new DecisionValue.BooleanValue(true)));
assertFalse(choice.accepts(new DecisionValue.Choice("COMPLETE")));
assertFalse(new DecisionQuestion.Score("v1", "Score", 0, 1).accepts(new DecisionValue.Score(2)));
assertThrows(IllegalArgumentException.class, () -> new DecisionValue.Score(Double.NaN));
}
@Test void factsAreImmutableAndRejectUnboundedOrNonFiniteData() {
var flags = new HashMap<String, Boolean>();
flags.put("eligible", true);
var facts = new DecisionFacts(flags, Map.of("attempts", 2d));
flags.clear();
assertTrue(facts.flags().get("eligible"));
assertThrows(UnsupportedOperationException.class, () -> facts.flags().clear());
assertThrows(IllegalArgumentException.class, () -> new DecisionFacts(Map.of(), Map.of("bad", Double.NaN)));
}
@Test void limitsRejectInsteadOfSilentlyTruncatingAndGuardsCannotBeOverridden() {
assertThrows(IllegalArgumentException.class, () -> new DecisionFacts(Map.of(), Map.of(), List.of("x".repeat(8193))));
assertThrows(IllegalArgumentException.class, () -> new DecisionQuestion.Choice("v1", "Choose", List.of(
new DecisionQuestion.Option("A", "first"), new DecisionQuestion.Option("A", "second"))));
var r = DecisionServiceTest.request(null);
assertThrows(IllegalArgumentException.class, () -> new DecisionRequest(r.type(), r.scope(), r.phase(), r.question(), r.facts(),
r.baseline(), r.baseline(), new DecisionValue.Choice("DEFER")));
assertThrows(IllegalArgumentException.class, () -> new DecisionRequest(r.type(), r.scope(), r.phase(), r.question(), r.facts(),
r.baseline(), null, new DecisionValue.Choice("DELETE")));
}
}

View File

@ -0,0 +1,204 @@
package vip.mate.decision;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.*;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DecisionPipelineTest {
static Stream<DecisionResult> invalidResults() {
return Stream.of(
new DecisionResult(DecisionResult.Status.PROPOSED, new DecisionValue.Choice("DEFER"), null, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), Double.NaN, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), Double.POSITIVE_INFINITY, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), 1.1, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), -0.1, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), .79, "v1"),
DecisionResult.proposed(new DecisionValue.Choice("INVALID"), 1, "v1"),
DecisionResult.proposed(new DecisionValue.BooleanValue(true), 1, "v1"),
new DecisionResult(DecisionResult.Status.ABSTAIN, null, null, "v1"),
DecisionResult.unavailable(),
DecisionResult.proposed(new DecisionValue.Choice("DEFER"), 1, "private provider free text"));
}
@ParameterizedTest @MethodSource("invalidResults") void invalidOrUnavailableResultsPreserveBaseline(DecisionResult result) {
var p = active();
var provider = provider(r -> result);
try (var service = new DecisionService(p, List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry())) {
assertEquals(DecisionServiceTest.request(null).baseline(), service.decide(DecisionServiceTest.request(null)).effectiveValue());
}
}
@Test void ruleIsExplicitBaselineAndLlmIsUnavailable() {
var request = DecisionServiceTest.request(null);
var rule = new RuleDecisionProvider().decide(request);
assertEquals(DecisionResult.Status.BASELINE, rule.status()); assertNull(rule.confidence());
assertEquals(DecisionResult.Status.UNAVAILABLE, new LlmDecisionProvider().decide(request).status());
}
@Test void unsupportedCapabilitySkipsProvider() {
var provider = mock(DecisionProvider.class); when(provider.id()).thenReturn("test");
when(provider.capability()).thenReturn(new DecisionCapability(Set.of(DecisionCapability.Kind.BOOLEAN), Set.of("und"), 1, 0));
try (var service = new DecisionService(active(), List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry())) {
assertEquals(DecisionServiceTest.request(null).baseline(), service.decide(DecisionServiceTest.request(null)).effectiveValue());
verify(provider, never()).decide(any());
}
}
@Test void timeoutAndSaturationRemainBoundedWhenProviderIgnoresInterrupt() throws Exception {
var release = new CountDownLatch(1); var entered = new CountDownLatch(1); var calls = new AtomicInteger();
var p = active(); p.setTimeoutMs(30); p.setProviderThreads(1); p.setQueueCapacity(1);
var provider = provider(r -> {
calls.incrementAndGet(); entered.countDown();
while (release.getCount() > 0) { try { release.await(); } catch (InterruptedException ignored) {} }
return DecisionResult.unavailable();
});
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(p, List.of(provider), store, new SimpleMeterRegistry())) {
for (int i = 0; i < 6; i++) assertEquals(DecisionServiceTest.request(null).baseline(), service.decide(DecisionServiceTest.request(null)).effectiveValue());
assertTrue(entered.await(1, TimeUnit.SECONDS)); assertEquals(1, calls.get());
var records = ArgumentCaptor.forClass(DecisionRecord.class); verify(store, times(6)).insert(records.capture());
assertTrue(records.getAllValues().stream().anyMatch(r -> r.reason().equals("SATURATED")));
} finally { release.countDown(); }
}
@Test void shadowReturnsImmediatelyAndFailuresCannotEscape() throws Exception {
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var p = active(); p.setMode(DecisionMode.SHADOW); p.setTimeoutMs(1000);
var provider = provider(r -> { entered.countDown(); try { release.await(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } return DecisionResult.unavailable(); });
var store = mock(DecisionRecordStore.class); doThrow(new IllegalStateException("private")).when(store).insert(any());
try (var service = new DecisionService(p, List.of(provider), store, new SimpleMeterRegistry())) {
var ticket = service.decide(DecisionServiceTest.request(null));
assertTrue(entered.await(1, TimeUnit.SECONDS)); assertEquals(DecisionMode.SHADOW, ticket.mode());
assertEquals(DecisionServiceTest.request(null).baseline(), ticket.effectiveValue());
} finally { release.countDown(); }
}
@Test void auditContainsNoEvidenceDescriptionsOrExceptionMessages() {
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(active(), List.of(provider(r -> { throw new IllegalStateException("secret-provider-failure"); })), store, new SimpleMeterRegistry())) {
service.decide(DecisionServiceTest.request(null));
var record = ArgumentCaptor.forClass(DecisionRecord.class); verify(store).insert(record.capture());
String serialized = record.getValue().toString();
assertFalse(serialized.contains("private evidence")); assertFalse(serialized.contains("Private description"));
assertFalse(serialized.contains("secret-provider-failure")); assertTrue(serialized.contains("PROVIDER_ERROR"));
}
}
@Test void activeOutcomeParticipatesInDomainRollback() {
var jdbc = DecisionRecordStoreTest.database("h2"); var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = new JdbcDecisionRecordStore(jdbc, manager, active());
try (var service = new DecisionService(active(), List.of(provider(r -> DecisionResult.proposed(new DecisionValue.Choice("DEFER"), 1, "v1"))), store, new SimpleMeterRegistry())) {
new TransactionTemplate(manager).executeWithoutResult(status -> {
var ticket = service.decide(DecisionServiceTest.request(null));
service.recordOutcome(ticket, DecisionOutcome.APPLIED, ticket.effectiveValue()); status.setRollbackOnly();
});
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
}
@Test void shadowOutcomeIsNeverEnqueuedForRolledBackTransaction() {
var jdbc = DecisionRecordStoreTest.database("h2"); var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = mock(DecisionRecordStore.class); var p = active(); p.setMode(DecisionMode.SHADOW);
try (var service = new DecisionService(p, List.of(), store, new SimpleMeterRegistry())) {
new TransactionTemplate(manager).executeWithoutResult(status -> {
service.recordOutcome(new DecisionTicket("id", DecisionMode.SHADOW, new DecisionValue.Choice("CONTINUE")), DecisionOutcome.APPLIED, new DecisionValue.Choice("CONTINUE"));
status.setRollbackOnly();
});
verifyNoInteractions(store);
}
}
@Test void shadowRecordsEvaluatedValueSeparatelyFromActualBaseline() {
var p = active(); p.setMode(DecisionMode.SHADOW); var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(p, List.of(provider(r -> DecisionResult.proposed(new DecisionValue.Choice("DEFER"), 1, "v1"))), store, new SimpleMeterRegistry())) {
var ticket = service.decide(DecisionServiceTest.request(null));
assertEquals(new DecisionValue.Choice("CONTINUE"), ticket.effectiveValue());
var records = ArgumentCaptor.forClass(DecisionRecord.class); verify(store, timeout(1500)).insert(records.capture());
assertEquals("DEFER", records.getValue().effective());
}
}
@Test void policyOverrideRetainsFallbackReason() {
var original = DecisionServiceTest.request(null);
var request = new DecisionRequest(original.type(), original.scope(), original.phase(), original.question(), original.facts(),
original.baseline(), null, new DecisionValue.Choice("DEFER"));
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(active(), List.of(provider(r -> DecisionResult.unavailable())), store, new SimpleMeterRegistry())) {
assertEquals(new DecisionValue.Choice("DEFER"), service.decide(request).effectiveValue());
var records = ArgumentCaptor.forClass(DecisionRecord.class); verify(store).insert(records.capture());
assertEquals("UNAVAILABLE", records.getValue().reason());
assertEquals("POLICY_OVERRIDE", records.getValue().overrideReason());
}
}
@Test void booleanAndScoreQuestionsFlowThroughTypedValidation() {
var store = mock(DecisionRecordStore.class);
for (DecisionQuestion question : List.of(new DecisionQuestion.BooleanQuestion("v1", "Allow?"), new DecisionQuestion.Score("v1", "Score?", 0, 10))) {
DecisionValue baseline = question instanceof DecisionQuestion.Score ? new DecisionValue.Score(2) : new DecisionValue.BooleanValue(false);
DecisionValue proposed = question instanceof DecisionQuestion.Score ? new DecisionValue.Score(8) : new DecisionValue.BooleanValue(true);
var request = new DecisionRequest(DecisionType.WORKER_RESULT, new DecisionScope(1L, 2L, 3L), "JUDGE", question,
new DecisionFacts(Map.of(), Map.of()), baseline, null, null);
try (var service = new DecisionService(active(), List.of(provider(r -> DecisionResult.proposed(proposed, .8, "v1"))), store, new SimpleMeterRegistry())) {
assertEquals(proposed, service.decide(request).effectiveValue());
}
}
}
@Test void scenarioOffBypassesGlobalActiveAndInvalidConfigurationFailsEarly() {
var p = active(); p.getScenarios().put(DecisionType.GOAL_CONTINUATION, DecisionMode.OFF);
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(p, List.of(), store, new SimpleMeterRegistry())) {
assertEquals(DecisionMode.OFF, service.decide(DecisionServiceTest.request(null)).mode()); verifyNoInteractions(store);
}
p.setConfidenceThreshold(Double.NaN);
assertThrows(IllegalArgumentException.class, () -> new DecisionService(p, List.of(), store, new SimpleMeterRegistry()));
}
@Test void shadowQueueDropsAreObservableAndNeverDelayCaller() throws Exception {
var p = active(); p.setMode(DecisionMode.SHADOW); p.setShadowThreads(1); p.setQueueCapacity(1); p.setTimeoutMs(1000);
var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var metrics = new SimpleMeterRegistry();
var provider = provider(r -> { entered.countDown(); try { release.await(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } return DecisionResult.unavailable(); });
try (var service = new DecisionService(p, List.of(provider), mock(DecisionRecordStore.class), metrics)) {
service.decide(DecisionServiceTest.request(null)); assertTrue(entered.await(1, TimeUnit.SECONDS));
service.decide(DecisionServiceTest.request(null)); service.decide(DecisionServiceTest.request(null));
assertEquals(1, metrics.get("mate.decision.failure").tag("stage", "dropped").counter().count());
} finally { release.countDown(); }
}
@Test void shadowOutcomeOnlyWritesAfterSuccessfulCommit() {
var jdbc = DecisionRecordStoreTest.database("h2"); var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = mock(DecisionRecordStore.class); var p = active(); p.setMode(DecisionMode.SHADOW);
var value = new DecisionValue.Choice("CONTINUE");
try (var service = new DecisionService(p, List.of(), store, new SimpleMeterRegistry())) {
new TransactionTemplate(manager).executeWithoutResult(status -> {
service.recordOutcome(new DecisionTicket("id", DecisionMode.SHADOW, value), DecisionOutcome.OBSERVED, value);
verifyNoInteractions(store);
});
verify(store, timeout(1000)).outcome("id", DecisionOutcome.OBSERVED, value);
}
}
@Test void comparisonMetricsExcludeGuardsAndPreserveLowConfidenceDivergence() {
var metrics = new SimpleMeterRegistry();
try (var service = new DecisionService(active(), List.of(provider(r -> DecisionResult.proposed(new DecisionValue.Choice("DEFER"), .5, "v1"))), mock(DecisionRecordStore.class), metrics)) {
service.decide(DecisionServiceTest.request(new DecisionValue.Choice("CONTINUE")));
service.decide(DecisionServiceTest.request(null));
assertEquals(2, metrics.get("mate.decision.requests").counter().count());
assertEquals(1, metrics.get("mate.decision.comparison").tag("stage", "proposed").tag("agreement", "false").counter().count());
assertEquals(1, metrics.get("mate.decision.comparison").tag("stage", "effective").tag("agreement", "true").counter().count());
}
}
static DecisionProperties active() { var p = new DecisionProperties(); p.setMode(DecisionMode.ACTIVE); p.setProvider("test"); return p; }
static DecisionProvider provider(Function<DecisionRequest, DecisionResult> action) {
return new DecisionProvider() {
public String id() { return "test"; }
public DecisionCapability capability() { return DecisionCapability.standard(); }
public DecisionResult decide(DecisionRequest request) { return action.apply(request); }
};
}
}

View File

@ -0,0 +1,149 @@
package vip.mate.decision;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.core.io.ClassPathResource;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.record.*;
import java.util.UUID;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
class DecisionRecordStoreTest {
@Test void allDialectScriptsExecuteInCompatibilityModes() {
for (String dialect : new String[]{"h2", "mysql", "kingbase"}) database(dialect);
}
@Test void proposalSurvivesDomainRollbackAndOutcomeDoesNot() {
var jdbc = database("h2");
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = new JdbcDecisionRecordStore(jdbc, manager, new DecisionProperties());
var tx = new TransactionTemplate(manager);
tx.executeWithoutResult(status -> {
store.insert(record("id1"));
store.outcome("id1", DecisionOutcome.APPLIED, new DecisionValue.Choice("DEFER"));
status.setRollbackOnly();
});
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
@Test void outcomeBeforeProposalIsPreservedAndRetentionIsBounded() {
var jdbc = database("h2");
var properties = new DecisionProperties(); properties.setRetentionBatchSize(1);
var store = new JdbcDecisionRecordStore(jdbc, new DataSourceTransactionManager(jdbc.getDataSource()), properties);
for (String id : new String[]{"id1", "id2"}) {
store.outcome(id, DecisionOutcome.OBSERVED, new DecisionValue.Choice("CONTINUE"));
store.insert(record(id));
}
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
jdbc.update("UPDATE mate_decision_record SET create_time = TIMESTAMP '2000-01-01 00:00:00'");
jdbc.update("UPDATE mate_decision_outcome SET create_time = TIMESTAMP '2000-01-01 00:00:00'");
store.purgeExpired();
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
}
@Test void expiredPendingProposalsArePurgedButRecentRacingOutcomesSurvive() {
var jdbc = database("h2"); var properties = new DecisionProperties(); properties.setRetentionBatchSize(1);
var store = new JdbcDecisionRecordStore(jdbc, new DataSourceTransactionManager(jdbc.getDataSource()), properties);
store.insert(record("pending"));
jdbc.update("UPDATE mate_decision_record SET create_time = TIMESTAMP '2000-01-01 00:00:00'");
store.outcome("racing", DecisionOutcome.OBSERVED, new DecisionValue.Choice("CONTINUE"));
store.purgeExpired();
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
@Test void outcomeIsIdempotentButConflictingHistoryCannotBeRewritten() {
var jdbc = database("h2");
var store = new JdbcDecisionRecordStore(jdbc, new DataSourceTransactionManager(jdbc.getDataSource()), new DecisionProperties());
store.outcome("id", DecisionOutcome.APPLIED, new DecisionValue.Choice("DEFER"));
store.outcome("id", DecisionOutcome.APPLIED, new DecisionValue.Choice("DEFER"));
assertThrows(IllegalStateException.class, () -> store.outcome("id", DecisionOutcome.NOT_APPLIED, new DecisionValue.Choice("CONTINUE")));
assertEquals("APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
}
@Test void nonnumericConversationAndAttemptIdsRoundTripInAllDialects() {
for (String dialect : new String[]{"h2", "mysql", "kingbase"}) {
var jdbc = database(dialect);
var store = new JdbcDecisionRecordStore(jdbc, new DataSourceTransactionManager(jdbc.getDataSource()), new DecisionProperties());
var original = record("id");
var scope = new DecisionScope(1L, 2L, 3L, "conversation-abc", "ab051cc7-4cdb-41c1-a72c-1638107d3f24");
store.insert(new DecisionRecord(original.id(), original.type(), scope, original.phase(), original.questionVersion(), original.mode(),
original.provider(), original.providerVersion(), original.valueKind(), original.baseline(), original.proposed(), original.effective(),
original.confidence(), original.reason(), original.overrideReason(), original.elapsedMs()));
assertEquals(scope.conversationId(), jdbc.queryForObject("SELECT conversation_id FROM mate_decision_record", String.class));
assertEquals(scope.attemptId(), jdbc.queryForObject("SELECT attempt_id FROM mate_decision_record", String.class));
assertEquals("v1", jdbc.queryForObject("SELECT policy_version FROM mate_decision_record", String.class));
}
}
@Test void concurrentIdenticalOutcomesCommitBothDomainTransactions() throws Exception {
var database = database("h2");
database.execute("CREATE TABLE domain_change (id INT PRIMARY KEY)");
var bothInserts = new CountDownLatch(2);
var jdbc = new JdbcTemplate(database.getDataSource()) {
@Override public int update(String sql, Object... args) {
if (sql.startsWith("INSERT INTO mate_decision_outcome")) {
bothInserts.countDown();
try { assertTrue(bothInserts.await(5, TimeUnit.SECONDS)); }
catch (InterruptedException ex) { throw new IllegalStateException(ex); }
}
return super.update(sql, args);
}
};
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = new JdbcDecisionRecordStore(jdbc, manager, new DecisionProperties());
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> new TransactionTemplate(manager).executeWithoutResult(status -> {
jdbc.update("INSERT INTO domain_change VALUES (1)");
store.outcome("racing", DecisionOutcome.APPLIED, new DecisionValue.Choice("DEFER"));
}));
var second = executor.submit(() -> new TransactionTemplate(manager).executeWithoutResult(status -> {
jdbc.update("INSERT INTO domain_change VALUES (2)");
store.outcome("racing", DecisionOutcome.APPLIED, new DecisionValue.Choice("DEFER"));
}));
first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS);
}
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM domain_change", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
@Test void retentionRechecksFreshOutcomeForSameExpiredPendingProposal() throws Exception {
var database = database("h2"); var selected = new CountDownLatch(1); var inserted = new CountDownLatch(1);
var jdbc = new JdbcTemplate(database.getDataSource()) {
@Override public <T> List<T> queryForList(String sql, Class<T> elementType, Object... args) {
var result = super.queryForList(sql, elementType, args);
if (sql.contains("SELECT r.id FROM mate_decision_record")) {
selected.countDown();
try { assertTrue(inserted.await(5, TimeUnit.SECONDS)); }
catch (InterruptedException ex) { throw new IllegalStateException(ex); }
}
return result;
}
};
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var store = new JdbcDecisionRecordStore(jdbc, manager, new DecisionProperties());
store.insert(record("pending"));
jdbc.update("UPDATE mate_decision_record SET create_time = TIMESTAMP '2000-01-01 00:00:00'");
try (var executor = Executors.newSingleThreadExecutor()) {
var purge = executor.submit(store::purgeExpired);
assertTrue(selected.await(5, TimeUnit.SECONDS));
store.outcome("pending", DecisionOutcome.APPLIED, new DecisionValue.Choice("CONTINUE"));
inserted.countDown(); purge.get(10, TimeUnit.SECONDS);
}
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
static DecisionRecord record(String id) {
return new DecisionRecord(id, DecisionType.GOAL_CONTINUATION, new DecisionScope(1L, 2L, 3L), "FOLLOWUP", "v1",
DecisionMode.ACTIVE, "rule", "rule-v1", "CHOICE", "CONTINUE", null, "CONTINUE", null, "BASELINE", "NONE", 1);
}
static JdbcTemplate database(String dialect) {
String mode = dialect.equals("kingbase") ? "PostgreSQL" : "MySQL";
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;MODE=" + mode, "sa", "");
new ResourceDatabasePopulator(new ClassPathResource("db/migration/" + dialect + "/V203__decision_record.sql")).execute(ds);
return new JdbcTemplate(ds);
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.decision;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.DecisionRecordStore;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DecisionServiceTest {
static DecisionRequest request(DecisionValue guard) {
return new DecisionRequest(DecisionType.GOAL_CONTINUATION, new DecisionScope(1L, 2L, 3L), "FOLLOWUP",
new DecisionQuestion.Choice("v1", "Private description", List.of(new DecisionQuestion.Option("CONTINUE", "go"), new DecisionQuestion.Option("DEFER", "wait"))),
new DecisionFacts(Map.of(), Map.of(), List.of("private evidence")), new DecisionValue.Choice("CONTINUE"), guard, null);
}
@Test void offHasNoProviderOrStorageDependencies() {
var p = new DecisionProperties(); p.setMode(DecisionMode.OFF);
var provider = mock(DecisionProvider.class); when(provider.id()).thenReturn("test");
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(p, List.of(provider), store, new SimpleMeterRegistry())) {
clearInvocations(provider);
var ticket = service.decide(request(null));
assertNull(ticket.id()); assertEquals(request(null).baseline(), ticket.effectiveValue());
service.recordOutcome(ticket, DecisionOutcome.APPLIED, ticket.effectiveValue());
verifyNoInteractions(provider, store);
}
}
@Test void guardShortCircuitsAndActiveAuditFailureIsDistinct() {
var p = new DecisionProperties(); p.setMode(DecisionMode.ACTIVE);
var provider = mock(DecisionProvider.class); when(provider.id()).thenReturn("rule");
var store = mock(DecisionRecordStore.class);
try (var service = new DecisionService(p, List.of(provider), store, new SimpleMeterRegistry())) {
clearInvocations(provider);
assertEquals(new DecisionValue.Choice("DEFER"), service.decide(request(new DecisionValue.Choice("DEFER"))).effectiveValue());
verifyNoInteractions(provider);
doThrow(new IllegalStateException("private database exception")).when(store).insert(any());
assertThrows(DecisionRecordingException.class, () -> service.decide(request(new DecisionValue.Choice("DEFER"))));
}
}
@Test void activeAcceptsConfidentTypedSuggestion() {
var p = new DecisionProperties(); p.setMode(DecisionMode.ACTIVE); p.setProvider("test");
var provider = mock(DecisionProvider.class); when(provider.id()).thenReturn("test");
when(provider.capability()).thenReturn(DecisionCapability.standard());
when(provider.decide(any())).thenReturn(DecisionResult.proposed(new DecisionValue.Choice("DEFER"), .95, "v1"));
try (var service = new DecisionService(p, List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry())) {
assertEquals(new DecisionValue.Choice("DEFER"), service.decide(request(null)).effectiveValue());
}
}
}

View File

@ -0,0 +1,40 @@
package vip.mate.decision;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionType;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.LlmDecisionProvider;
import vip.mate.decision.provider.RuleDecisionProvider;
import vip.mate.decision.record.DecisionRecordStore;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DecisionWiringTest {
@Test void beansBindDefaultsAndScenarioOverridesWithoutModelInfrastructure() {
new ApplicationContextRunner().withUserConfiguration(Config.class)
.withPropertyValues("mate.decision.scenarios.AGENT_ROUTING=OFF")
.run(context -> {
assertNull(context.getStartupFailure());
var p = context.getBean(DecisionProperties.class);
assertEquals(DecisionMode.SHADOW, p.getMode());
assertEquals(DecisionMode.OFF, p.modeFor(DecisionType.AGENT_ROUTING));
assertNotNull(context.getBean(DecisionService.class));
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(DecisionProperties.class)
@Import({DecisionService.class, RuleDecisionProvider.class, LlmDecisionProvider.class})
static class Config {
@Bean DecisionRecordStore store() { return mock(DecisionRecordStore.class); }
@Bean MeterRegistry metrics() { return new SimpleMeterRegistry(); }
}
}

View File

@ -1,5 +1,12 @@
package vip.mate.goal.service;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionValue;
import vip.mate.goal.model.GoalContinuationDecision;
import vip.mate.goal.model.GoalContinuationDecision.Action;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -53,6 +60,53 @@ class GoalContinuationSupervisorTest {
Clock.fixed(now.toInstant(ZoneOffset.UTC),ZoneOffset.UTC), Runnable::run);
}
@Test void auditFailureLeavesLeaseForRecoveryWithoutPausingOrRunning() {
var adapter = mock(GoalDecisionAdapter.class);
supervisor.setDecisionAdapter(adapter);
when(adapter.select(any(), any(), any(), any(), any())).thenThrow(new DecisionRecordingException());
supervisor.tick();
verify(goals, never()).pause(any(), any());
verifyNoInteractions(runner);
verify(coordinator, never()).settle(any(), any(), any());
}
@Test void explicitTicketIsConsumedAtStartAndNotAtLaterSettlement() {
var adapter = mock(GoalDecisionAdapter.class);
supervisor.setDecisionAdapter(adapter);
var ticket = new DecisionTicket("decision", DecisionMode.ACTIVE,
new DecisionValue.Choice("CONTINUE"));
var selection = new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(Action.CONTINUE,
"continue", now, "remaining_criteria"));
when(adapter.select(any(), any(), any(), eq("attempt"), eq("DURABLE"))).thenReturn(selection);
when(coordinator.markRunning(claimed, now, ticket)).thenReturn(true);
supervisor.tick();
verify(coordinator).markRunning(claimed, now, ticket);
verify(runner).run(claimed, "continue", false);
verify(coordinator).settle(eq(claimed), isA(SegmentOutcome.Continue.class), eq(now));
}
@Test void wrappedGraphAuditFailureRetainsLeaseWithoutPausing() {
when(runner.run(eq(claimed), anyString(), anyBoolean()))
.thenThrow(new IllegalStateException("graph failed", new DecisionRecordingException()));
supervisor.tick();
verify(goals, never()).pause(any(), any());
verify(coordinator, never()).settle(any(), any(), any());
}
@Test void activeDeferAndRetrySettleWithoutStartingSegment() {
var adapter = mock(GoalDecisionAdapter.class); supervisor.setDecisionAdapter(adapter);
for (Action action : List.of(Action.DEFER, Action.RETRY)) {
var ticket = new DecisionTicket(action.name(), DecisionMode.ACTIVE, new DecisionValue.Choice(action.name()));
when(adapter.select(any(), any(), any(), any(), any())).thenReturn(new GoalDecisionAdapter.Selection(ticket,
new GoalContinuationDecision(action, "prompt", now.plusSeconds(30), "decision_delay")));
supervisor.tick();
verify(coordinator).settle(eq(claimed), eq(new SegmentOutcome.Defer("decision_delay", now.plusSeconds(30))), eq(now), eq(ticket));
}
verifyNoInteractions(runner);
verify(coordinator, never()).markRunning(any(), any(), any());
}
@Test void incompleteGoalIsRescheduledAcrossMultipleSegments() {
supervisor.tick(); supervisor.tick(); supervisor.tick();
verify(runner,times(3)).run(eq(claimed),contains("full goal"),eq(false));

View File

@ -0,0 +1,199 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.DecisionRecordStore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import vip.mate.decision.api.*;
import vip.mate.decision.core.DecisionService;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class GoalDecisionAdapterTest {
final GoalFollowupService baseline = new GoalFollowupService(new GoalProperties(), new ObjectMapper());
final DecisionService decisions = mock(DecisionService.class);
final GoalDecisionAdapter adapter = new GoalDecisionAdapter(baseline, decisions);
final LocalDateTime now = LocalDateTime.of(2026, 9, 28, 12, 0);
GoalEntity goal() {
var g = new GoalEntity(); g.setId(1L); g.setWorkspaceId(3L); g.setConversationId("conversation");
g.setStatus(GoalStatus.ACTIVE); g.setAutoFollowupEnabled(true); g.setPersistentExecution(true);
g.setTurnBudget(0); g.setLlmCallBudget(0); g.setTitle("objective"); return g;
}
GoalEvaluationResult evaluation() {
return new GoalEvaluationResult(0, "remaining", "continue", false, "", 0, 0, List.of(), null);
}
@Test void offAndShadowPreserveEveryBaselineFieldAcrossFixtures() {
for (DecisionMode mode : List.of(DecisionMode.OFF, DecisionMode.SHADOW)) {
doAnswer(i -> new DecisionTicket("ticket", mode,
((DecisionRequest)i.getArgument(0)).baseline())).when(decisions).decide(any());
for (int fixture = 0; fixture < 8; fixture++) {
var g = goal(); var result = evaluation();
switch (fixture) {
case 1 -> { g.setLastFollowupAt(now); g.setFollowupCooldownSeconds(90); }
case 2 -> result = GoalEvaluationResult.fallback("sensitive reason");
case 3 -> { g.setTurnBudget(1); g.setTurnsUsed(1); }
case 4 -> g.setStatus(GoalStatus.COMPLETED);
case 5 -> g.setAutoFollowupEnabled(false);
case 6 -> g.setStatus(GoalStatus.PAUSED);
case 7 -> result = null;
}
assertEquals(baseline.decide(g, result, now), adapter.select(g, result, now, "attempt", "DURABLE").decision());
}
}
}
@Test void activeOnlyChangesContinueToDomainTimedRetryOrDefer() {
for (String code : List.of("DEFER", "RETRY")) {
when(decisions.decide(any())).thenReturn(new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice(code)));
var chosen = adapter.select(goal(), evaluation(), now, "attempt", "DURABLE");
assertEquals(code, chosen.decision().action().name());
assertEquals(now.plusSeconds(30), chosen.decision().nextRunAt());
assertEquals(baseline.decide(goal(), evaluation(), now).prompt(), chosen.decision().prompt());
}
}
@Test void nonContinueBaselineIsGuardedAndScopeContainsWorkspace() {
when(decisions.decide(any())).thenAnswer(i -> {
DecisionRequest request = i.getArgument(0);
assertEquals(3L, request.scope().workspaceId()); assertEquals("attempt", request.scope().attemptId());
assertEquals(request.baseline(), request.guardValue());
return new DecisionTicket("ticket", DecisionMode.ACTIVE, request.guardValue());
});
var g = goal(); g.setStatus(GoalStatus.PAUSED);
assertEquals(baseline.decide(g, evaluation(), now), adapter.select(g, evaluation(), now, "attempt", "DURABLE").decision());
}
@Test void successfulShadowOutcomeIsObservedAndRejectedOutcomeIsNotApplied() {
var ticket = new DecisionTicket("ticket", DecisionMode.SHADOW, new DecisionValue.Choice("CONTINUE"));
adapter.outcome(ticket, true, "RUN_STARTED");
verify(decisions).recordOutcome(ticket, DecisionOutcome.OBSERVED, new DecisionValue.Choice("RUN_STARTED"));
adapter.outcome(ticket, false, "FENCE_REJECTED");
verify(decisions).recordOutcome(ticket, DecisionOutcome.NOT_APPLIED, new DecisionValue.Choice("FENCE_REJECTED"));
}
@Test void realPipelineRejectsCompletionAndPermissionProposalsAndFallsBackUnchanged() {
for (String proposal : List.of("COMPLETE", "BUDGET_LIMITED", "DISABLED", "WAITING_INPUT", "unknown")) {
var provider = new TestProvider(proposal);
try (var service = core(provider)) {
var actual = new GoalDecisionAdapter(baseline, service).select(goal(), evaluation(), now, null, "DURABLE");
assertEquals(baseline.decide(goal(), evaluation(), now), actual.decision());
assertEquals(1, provider.calls.get());
}
}
}
@Test void realPipelineNeverCallsProviderForDomainGuardsOrGraphCapsOrTerminalObservations() {
var provider = new TestProvider("CONTINUE");
try (var service = core(provider)) {
var actual = new GoalDecisionAdapter(baseline, service);
var g = goal();
actual.selectGraph(g, Optional.of("original prompt"), now, true);
actual.select(null, null, now, null, "DURABLE");
actual.select(g, null, now, null, "DURABLE");
g.setLastFollowupAt(now); g.setFollowupCooldownSeconds(30);
actual.select(g, evaluation(), now, null, "DURABLE");
g.setStatus(GoalStatus.COMPLETED);
actual.observeTransition(g, GoalContinuationDecision.Action.COMPLETE);
g.setStatus(GoalStatus.PAUSED);
actual.observeTransition(g, GoalContinuationDecision.Action.BUDGET_LIMITED);
assertEquals(0, provider.calls.get());
}
}
@Test void terminalObservationRecordsActualCommittedStatus() {
var ticket = new DecisionTicket("ticket", DecisionMode.ACTIVE, new DecisionValue.Choice("BUDGET_LIMITED"));
when(decisions.decide(any())).thenReturn(ticket);
var g = goal(); g.setStatus(GoalStatus.PAUSED);
adapter.observeTransition(g, GoalContinuationDecision.Action.BUDGET_LIMITED);
verify(decisions).recordOutcome(ticket, DecisionOutcome.OBSERVED, new DecisionValue.Choice("PAUSED"));
}
@Test void missingFieldsAndOversizeCorrelationDoNotAlterBaseline() {
doAnswer(i -> new DecisionTicket(null, DecisionMode.OFF, ((DecisionRequest)i.getArgument(0)).baseline()))
.when(decisions).decide(any());
var g = goal(); g.setWorkspaceId(null); g.setConversationId("x".repeat(200));
assertEquals(baseline.decide(g, null, now), adapter.select(g, null, now, " ", "DURABLE").decision());
assertEquals(baseline.decide(null, null, now), adapter.select(null, null, now, null, "DURABLE").decision());
}
@Test void graphUsesOriginalPlanningSnapshotWithoutRecomputingAtCooldownBoundary() {
var followups = mock(GoalFollowupService.class);
doAnswer(i -> new DecisionTicket("ticket", DecisionMode.SHADOW, ((DecisionRequest)i.getArgument(0)).baseline()))
.when(decisions).decide(any());
var graph = new GoalDecisionAdapter(followups, decisions);
assertEquals(GoalContinuationDecision.Action.DISABLED,
graph.selectGraph(goal(), Optional.empty(), now.plusSeconds(1), false).decision().action());
assertEquals("original", graph.selectGraph(goal(), Optional.of("original"), now, false).decision().prompt());
verifyNoInteractions(followups);
}
@Test void realOffShadowPairPreservesDomainFixturesEvenWhenProviderDisagreesOrStoreFails() throws Exception {
for (DecisionMode mode : List.of(DecisionMode.OFF, DecisionMode.SHADOW)) {
for (boolean storeFails : List.of(false, true)) {
var config = new DecisionProperties(); config.setMode(mode); config.setProvider("fixture");
var records = mock(DecisionRecordStore.class);
var recorded = new CountDownLatch(13);
doAnswer(invocation -> {
recorded.countDown();
if (storeFails) throw new IllegalStateException("record unavailable");
return null;
}).when(records).insert(any());
var provider = new TestProvider("DEFER");
try (var service = new DecisionService(config, List.of(provider), records, new SimpleMeterRegistry())) {
var actual = new GoalDecisionAdapter(baseline, service);
for (int fixture = 0; fixture < 13; fixture++) {
var g = goal(); var result = evaluation();
switch (fixture) {
case 1 -> { g.setLastFollowupAt(now); g.setFollowupCooldownSeconds(90); }
case 2 -> result = GoalEvaluationResult.fallback("sensitive detail");
case 3 -> { g.setTurnBudget(1); g.setTurnsUsed(1); }
case 4 -> g.setStatus(GoalStatus.COMPLETED);
case 5 -> g.setAutoFollowupEnabled(false);
case 6 -> g.setStatus(GoalStatus.PAUSED);
case 7 -> result = null;
case 8 -> { g.setLlmCallBudget(2); g.setAgentLlmCallsUsed(2); }
case 9, 10, 11 -> {
g.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true,\"evidence\":\"verified\"}]");
result = new GoalEvaluationResult(1, "verified", "completed", true, "", 0, 0, List.of(), null);
if (fixture == 9) g.setJsonAcceptanceRequired(true);
if (fixture == 10) g.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true}]");
}
case 12 -> { g.setLlmCallBudget(null); g.setTurnBudget(null); }
}
assertEquals(baseline.decide(g, result, now), actual.select(g, result, now, "attempt", "DURABLE").decision(),
"mode=" + mode + ", storeFails=" + storeFails + ", fixture=" + fixture);
}
if (mode == DecisionMode.OFF) assertEquals(0, provider.calls.get());
else {
assertTrue(recorded.await(5, TimeUnit.SECONDS));
assertTrue(provider.calls.get() > 0);
}
}
}
}
}
private DecisionService core(TestProvider provider) {
var config = new DecisionProperties(); config.setMode(DecisionMode.ACTIVE); config.setProvider("fixture");
return new DecisionService(config, List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry());
}
private static final class TestProvider implements DecisionProvider {
private final String proposal;
private final AtomicInteger calls = new AtomicInteger();
TestProvider(String proposal) { this.proposal = proposal; }
public String id() { return "fixture"; }
public DecisionCapability capability() { return DecisionCapability.standard(); }
public DecisionResult decide(DecisionRequest request) {
calls.incrementAndGet();
return DecisionResult.proposed(new DecisionValue.Choice(proposal), 1, "fixture-v1");
}
}
}

View File

@ -1,12 +1,30 @@
package vip.mate.goal.service;
import org.h2.jdbcx.JdbcDataSource;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.record.JdbcDecisionRecordStore;
import vip.mate.decision.provider.RuleDecisionProvider;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.goal.model.GoalEvaluationResult;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import vip.mate.decision.provider.DecisionResult;
import vip.mate.decision.api.DecisionRequest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import vip.mate.goal.config.GoalProperties;
import vip.mate.decision.api.DecisionTicket;
import vip.mate.decision.api.DecisionMode;
import vip.mate.decision.api.DecisionValue;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalStatus;
import vip.mate.goal.model.SegmentOutcome;
@ -50,6 +68,156 @@ class GoalRunCoordinatorTest {
continuations.discover(now);
}
@Test void runningAndImmediateSettlementRecordOnlyAfterSuccessfulFence() {
var adapter = mock(GoalDecisionAdapter.class);
coordinator.setDecisionAdapter(adapter);
var ticket = new DecisionTicket("decision", DecisionMode.ACTIVE, new DecisionValue.Choice("CONTINUE"));
var run = coordinator.claim(continuations.get(1L), goal, now);
assertTrue(coordinator.markRunning(run, now, ticket));
verify(adapter).outcome(ticket, true, "RUN_STARTED");
assertTrue(coordinator.settle(run, new SegmentOutcome.Continue("normal"), now));
assertFalse(coordinator.markRunning(run, now, ticket));
verify(adapter).outcome(ticket, false, "FENCE_REJECTED");
verifyNoMoreInteractions(adapter);
var next = continuations.get(1L);
var deferred = coordinator.claim(next, goal, next.nextRunAt());
assertTrue(coordinator.settle(deferred, new SegmentOutcome.Defer("decision_defer", now.plusMinutes(1)),
next.nextRunAt(), ticket));
verify(adapter).outcome(ticket, true, "QUEUED");
}
@Test void freshCompletionOverridesRequestedDeferInRecordedActualOutcome() {
var adapter = mock(GoalDecisionAdapter.class); coordinator.setDecisionAdapter(adapter);
var ticket = new DecisionTicket("decision", DecisionMode.ACTIVE, new DecisionValue.Choice("DEFER"));
var run = coordinator.claim(continuations.get(1L), goal, now);
goal.setStatus(GoalStatus.COMPLETED);
assertTrue(coordinator.settle(run, new SegmentOutcome.Defer("defer", now.plusSeconds(30)), now, ticket));
assertEquals("completed", continuations.get(1L).state());
verify(adapter).outcome(ticket, false, "COMPLETED");
}
@Test void freshBudgetPauseSettlesAuthorityButDoesNotApplyDelayProposal() {
var adapter = mock(GoalDecisionAdapter.class); coordinator.setDecisionAdapter(adapter);
var ticket = new DecisionTicket("decision", DecisionMode.ACTIVE, new DecisionValue.Choice("RETRY"));
var run = coordinator.claim(continuations.get(1L), goal, now);
goal.setStatus(GoalStatus.PAUSED);
when(goals.isBudgetExhausted(goal)).thenReturn(true);
when(goals.exhaustionReason(goal)).thenReturn("turn_budget");
assertTrue(coordinator.settle(run, new SegmentOutcome.Defer("retry", now.plusSeconds(30)), now, ticket));
assertEquals("budget_limited", continuations.get(1L).state());
verify(adapter).outcome(ticket, false, "BUDGET_LIMITED");
}
@Test void activeOutcomeFailureRollsBackRunningAndSettlementButRetainsProposal() {
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var transaction = new TransactionTemplate(manager);
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V203__decision_record.sql")).execute(jdbc.getDataSource());
var config = new DecisionProperties(); config.setMode(DecisionMode.ACTIVE);
var records = spy(new JdbcDecisionRecordStore(jdbc, manager, config));
try (var service = new DecisionService(config, List.of(new RuleDecisionProvider()), records, new SimpleMeterRegistry())) {
var adapter = new GoalDecisionAdapter(new GoalFollowupService(properties, new ObjectMapper()), service);
coordinator.setDecisionAdapter(adapter);
var evaluation = new GoalEvaluationResult(0, "remaining", "continue", false, "", 0, 0, List.of(), null);
var run = coordinator.claim(continuations.get(1L), goal, now);
var selection = adapter.select(goal, evaluation, now, run.attempt().id(), "DURABLE");
doThrow(new IllegalStateException("audit unavailable")).when(records).outcome(any(), any(), any());
assertThrows(DecisionRecordingException.class, () -> transaction.executeWithoutResult(status ->
coordinator.markRunning(run, now, selection.ticket())));
assertEquals("claimed", attempts.get(run.attempt().id()).state());
assertThrows(DecisionRecordingException.class, () -> transaction.executeWithoutResult(status ->
coordinator.settle(run, new SegmentOutcome.Defer("defer", now.plusSeconds(30)), now, selection.ticket())));
assertEquals(run.attempt().id(), continuations.get(1L).currentAttemptId());
assertEquals("claimed", attempts.get(run.attempt().id()).state());
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
}
}
@Test void successfulOutcomeJoinsCommitAndLateRollbackKeepsOnlyIndependentProposal() {
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var transaction = new TransactionTemplate(manager);
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V203__decision_record.sql")).execute(jdbc.getDataSource());
var config = new DecisionProperties(); config.setMode(DecisionMode.ACTIVE);
var records = new JdbcDecisionRecordStore(jdbc, manager, config);
try (var service = new DecisionService(config, List.of(new RuleDecisionProvider()), records, new SimpleMeterRegistry())) {
var adapter = new GoalDecisionAdapter(new GoalFollowupService(properties, new ObjectMapper()), service);
coordinator.setDecisionAdapter(adapter);
var run = coordinator.claim(continuations.get(1L), goal, now);
transaction.executeWithoutResult(status -> {
var selection = adapter.select(goal, null, now, run.attempt().id(), "DURABLE");
assertTrue(coordinator.settle(run, new SegmentOutcome.Defer("retry", now.plusSeconds(30)), now, selection.ticket()));
status.setRollbackOnly();
});
assertEquals("claimed", attempts.get(run.attempt().id()).state());
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
var selection = adapter.select(goal, null, now, run.attempt().id(), "DURABLE");
transaction.executeWithoutResult(status -> coordinator.settle(run,
new SegmentOutcome.Defer("retry", now.plusSeconds(30)), now, selection.ticket()));
assertEquals("QUEUED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
assertEquals("APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
}
}
@Test void activeStartRechecksStoppedGoalAfterDecisionButShadowKeepsBaseline() {
var adapter = mock(GoalDecisionAdapter.class); coordinator.setDecisionAdapter(adapter);
var run = coordinator.claim(continuations.get(1L), goal, now);
goal.setStatus(GoalStatus.PAUSED);
var active = new DecisionTicket("active", DecisionMode.ACTIVE, new DecisionValue.Choice("CONTINUE"));
assertFalse(coordinator.markRunning(run, now, active));
assertEquals("claimed", attempts.get(run.attempt().id()).state());
verify(adapter).outcome(active, false, "GOAL_NOT_RUNNABLE");
var shadow = new DecisionTicket("shadow", DecisionMode.SHADOW, new DecisionValue.Choice("CONTINUE"));
assertTrue(coordinator.markRunning(run, now, shadow));
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void activeDelayCannotQueueAfterConcurrentPauseOrAutoFollowupDisable(boolean paused) {
var manager = new DataSourceTransactionManager(jdbc.getDataSource());
var transaction = new TransactionTemplate(manager);
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V203__decision_record.sql")).execute(jdbc.getDataSource());
var config = new DecisionProperties(); config.setMode(DecisionMode.ACTIVE);
var records = new JdbcDecisionRecordStore(jdbc, manager, config);
var provider = new RuleDecisionProvider() {
@Override public DecisionResult decide(DecisionRequest request) {
return DecisionResult.proposed(new DecisionValue.Choice("DEFER"), 1, "delay-v1");
}
};
try (var service = new DecisionService(config, List.of(provider), records, new SimpleMeterRegistry())) {
var adapter = new GoalDecisionAdapter(new GoalFollowupService(properties, new ObjectMapper()), service);
coordinator.setDecisionAdapter(adapter);
var run = coordinator.claim(continuations.get(1L), goal, now);
var evaluation = new GoalEvaluationResult(0, "remaining", "continue", false, "", 0, 0, List.of(), null);
var selected = adapter.select(goal, evaluation, now, run.attempt().id(), "DURABLE");
assertEquals("DEFER", selected.decision().action().name());
if (paused) {
goal.setStatus(GoalStatus.PAUSED);
jdbc.update("UPDATE mate_agent_goal SET status='paused' WHERE id=1");
} else {
goal.setAutoFollowupEnabled(false);
jdbc.update("UPDATE mate_agent_goal SET auto_followup_enabled=FALSE WHERE id=1");
}
transaction.executeWithoutResult(status -> assertFalse(coordinator.settle(run,
new SegmentOutcome.Defer(selected.decision().reason(), selected.decision().nextRunAt()), now, selected.ticket())));
assertEquals("claimed", attempts.get(run.attempt().id()).state());
assertEquals("running", continuations.get(1L).state());
assertEquals("NOT_APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
assertEquals("GOAL_NOT_RUNNABLE", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
}
}
@ParameterizedTest
@ValueSource(strings = {"OFF", "SHADOW"})
void observationModesKeepLegacyDelaySettlementAfterPause(String mode) {
var adapter = mock(GoalDecisionAdapter.class); coordinator.setDecisionAdapter(adapter);
var run = coordinator.claim(continuations.get(1L), goal, now);
goal.setStatus(GoalStatus.PAUSED);
var ticket = new DecisionTicket("ticket", DecisionMode.valueOf(mode), new DecisionValue.Choice("DEFER"));
assertTrue(coordinator.settle(run, new SegmentOutcome.Defer("cooldown", now.plusSeconds(30)), now, ticket));
assertEquals("queued", continuations.get(1L).state());
}
@Test void claimBindsAttemptAndStaleSettlementCannotOverwriteNewProjection() {
var claim=coordinator.claim(continuations.get(1L),goal,now);
assertNotNull(claim);

View File

@ -16,6 +16,7 @@ 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.GoalContinuationDecision.Action;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
@ -553,6 +554,37 @@ class GoalServiceTest {
assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget"));
}
@Test void terminalTransitionsObserveOnlySuccessfulStateChanges() {
var adapter = mock(GoalDecisionAdapter.class); service.setDecisionAdapter(adapter);
var active = persisted(1L, GoalStatus.ACTIVE);
var completed = statusFlipped(active, GoalStatus.COMPLETED);
when(goalMapper.selectById(1L)).thenReturn(active, completed);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.markCompleted(1L, null);
verify(adapter).observeTransition(completed, Action.COMPLETE);
service.markCompleted(1L, null);
verify(adapter, times(1)).observeTransition(any(), any());
}
@Test void budgetTransitionObservesCommittedState() {
var adapter = mock(GoalDecisionAdapter.class); service.setDecisionAdapter(adapter);
var active = persisted(1L, GoalStatus.ACTIVE);
var exhausted = statusFlipped(active, GoalStatus.EXHAUSTED);
when(goalMapper.selectById(1L)).thenReturn(active, exhausted);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
service.markExhausted(1L, "turn_budget");
verify(adapter).observeTransition(exhausted, Action.BUDGET_LIMITED);
}
@Test void userPauseIsIndependentOfDecisionAuditAvailability() {
var adapter = mock(GoalDecisionAdapter.class); service.setDecisionAdapter(adapter);
var active = persisted(1L, GoalStatus.ACTIVE);
when(goalMapper.selectById(1L)).thenReturn(active, statusFlipped(active, GoalStatus.PAUSED));
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
assertEquals(GoalStatus.PAUSED, service.pause(1L, "alice").getStatus());
verifyNoInteractions(adapter);
}
// ==================== evaluation bookkeeping ====================
@Test

View File

@ -0,0 +1,119 @@
package vip.mate.planning.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.mockito.ArgumentCaptor;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.*;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.DecisionRecordStore;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.LongStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AgentRoutingDecisionAdapterTest {
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(),"routing-test"),AgentEntity.class);
}
AgentMapper agents = mock(AgentMapper.class);
DecisionProperties properties = new DecisionProperties();
AtomicInteger calls = new AtomicInteger();
DecisionService core;
AgentRoutingDecisionAdapter adapter;
String proposed = "AGENT:3";
@BeforeEach void setup() {
properties.setMode(DecisionMode.ACTIVE);
core = new DecisionService(properties, List.of(new RuleDecisionProvider() {
@Override public DecisionResult decide(DecisionRequest request) {
calls.incrementAndGet();
return DecisionResult.proposed(new DecisionValue.Choice(proposed), 1, "test");
}
}), mock(DecisionRecordStore.class), new SimpleMeterRegistry());
adapter = new AgentRoutingDecisionAdapter(core, properties, agents);
when(agents.selectList(any())).thenReturn(List.of(agent(2L, "Research"), agent(3L, "Writing")));
}
@AfterEach void close() { core.close(); }
static AgentEntity agent(long id, String name) {
var agent = new AgentEntity(); agent.setId(id); agent.setName(name);
agent.setWorkspaceId(10L); agent.setEnabled(true); return agent;
}
@Test void selectsPeerAndPreservesStepCardinality() {
var selected = adapter.select(10L, "1", "conversation", "summarize", 2, Arrays.asList(2L, null));
assertEquals(List.of(3L, 3L), adapter.assignments(selected));
assertEquals(2, selected.steps().size());
}
@Test void explicitNameGuardsAllBaselineStepsEvenWhenPlannerMissesOrMismatches() {
var selected = adapter.select(10L, "1", "conversation", "Ask Research to handle it", 2, Arrays.asList(3L, null));
assertEquals(Arrays.asList(3L, null), adapter.assignments(selected));
assertEquals(0, calls.get());
}
@Test void ineligibleCandidatesCannotBeAdoptedAndStaleBaselineFallsBackLocal() {
var disabled = agent(3, "Writing"); disabled.setEnabled(false);
var other = agent(4, "Other"); other.setWorkspaceId(11L);
when(agents.selectList(any())).thenReturn(List.of(agent(1,"Parent"), disabled, other, agent(2,"Research")));
var selection = adapter.select(10L,"1","conversation","summarize",1,List.of(2L));
assertEquals(List.of(2L), adapter.assignments(selection));
when(agents.selectList(any())).thenReturn(List.of());
assertNull(adapter.assignments(selection));
}
@Test void staleProposedPeerFallsBackToStillEligibleBaseline() {
var selected = adapter.select(10L,"1","conversation","summarize",1,List.of(2L));
when(agents.selectList(any())).thenReturn(List.of(agent(2,"Research")));
assertEquals(List.of(2L), adapter.assignments(selected));
}
@Test void oversizedCandidateSetGuardsBaselineWithoutTruncating() {
when(agents.selectList(any())).thenReturn(LongStream.range(2,66).mapToObj(id -> agent(id,"Peer"+id)).toList());
var selected = adapter.select(10L,"1","conversation","summarize",2,Arrays.asList(65L,null));
assertEquals(Arrays.asList(65L,null), adapter.assignments(selected));
assertEquals(0,calls.get());
}
@Test void shadowRetainsExcludedLegacyBaselineInTicketAndAssignments() {
properties.setMode(DecisionMode.SHADOW);
var selected = adapter.select(10L,"1","conversation","summarize",2,Arrays.asList(99L,null));
assertEquals(new DecisionValue.Choice("AGENT:99"), selected.steps().getFirst().ticket().effectiveValue());
assertEquals(Arrays.asList(99L,null),adapter.assignments(selected));
}
@Test void shadowPreparationFailureReturnsLegacyPathAndActiveFailsClosed() {
when(agents.selectList(any())).thenThrow(new IllegalStateException("unavailable"));
properties.setMode(DecisionMode.SHADOW);
assertNull(adapter.select(10L,"1","conversation","summarize",1,List.of(2L)));
properties.setMode(DecisionMode.ACTIVE);
assertThrows(DecisionRecordingException.class, () -> adapter.select(10L,"1","conversation","summarize",1,List.of(2L)));
}
@Test void disabledOrParentUserNameStillGuardsLocalButPlannerNameAloneDoesNot() {
var disabled = agent(4,"Unavailable"); disabled.setEnabled(false);
when(agents.selectList(any())).thenReturn(List.of(disabled,agent(1,"Parent"),agent(3,"Writing")));
assertNull(adapter.assignments(adapter.select(10L,"1","conversation","Ask Unavailable",1,null)));
assertNull(adapter.assignments(adapter.select(10L,"1","conversation","Ask Parent",1,null)));
assertEquals(0,calls.get());
assertEquals(List.of(3L),adapter.assignments(adapter.select(10L,"1","conversation","summarize",1,List.of(3L))));
assertEquals(1,calls.get());
}
@Test void revalidationIsScopedBeforeLockingAndNullWorkspaceDoesNotLock() {
var selected=adapter.select(10L,"1","conversation","summarize",1,List.of(99L));
clearInvocations(agents);assertNull(adapter.assignments(selected));
ArgumentCaptor<LambdaQueryWrapper<AgentEntity>> query=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(agents).selectList(query.capture());
assertTrue(query.getValue().getSqlSegment().contains("workspace_id"));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(10L));
var unscoped=adapter.select(null,"1","conversation","summarize",1,List.of(2L));
clearInvocations(agents);assertNull(adapter.assignments(unscoped));verifyNoInteractions(agents);
}
@Test void localAssignmentsRetainNullListAndOffDoesNotReadCandidates() {
proposed="LOCAL";
assertNull(adapter.assignments(adapter.select(10L,"1","conversation","summarize",2,null)));
properties.setMode(DecisionMode.OFF); clearInvocations(agents);
assertNull(adapter.select(10L,"1","conversation","summarize",2,null));
verifyNoInteractions(agents);
}
}

View File

@ -0,0 +1,172 @@
package vip.mate.planning.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import vip.mate.agent.model.AgentEntity;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.JdbcDecisionRecordStore;
import vip.mate.planning.repository.*;
import vip.mate.planning.model.SubPlanEntity;
import java.util.*;
import java.time.LocalDateTime;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AgentRoutingTransactionTest {
JdbcTemplate jdbc;
DataSourceTransactionManager manager;
DecisionProperties properties = new DecisionProperties();
DecisionService core;
JdbcDecisionRecordStore records;
AgentMapper agents = mock(AgentMapper.class);
AgentRoutingDecisionAdapter adapter;
PlanningService planning;
SubPlanMapper subs;
AgentMapper realAgents;
String proposal = "AGENT:3";
@BeforeEach void setup() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:routing_"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds); manager = new DataSourceTransactionManager(ds);
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V203__decision_record.sql")).execute(ds);
jdbc.execute("CREATE TABLE mate_plan(id BIGINT PRIMARY KEY,agent_id VARCHAR(64),conversation_id VARCHAR(64),goal TEXT,status VARCHAR(32),total_steps INT,completed_steps INT,create_time TIMESTAMP,update_time TIMESTAMP)");
jdbc.execute("CREATE TABLE mate_sub_plan(id BIGINT PRIMARY KEY,plan_id BIGINT,step_index INT,description TEXT,status VARCHAR(32),assigned_agent_id BIGINT,create_time TIMESTAMP,update_time TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.addMapper(PlanMapper.class); configuration.addMapper(SubPlanMapper.class); configuration.addMapper(AgentMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject()); realAgents=session.getMapper(AgentMapper.class); subs = spy(session.getMapper(SubPlanMapper.class));
properties.setMode(DecisionMode.ACTIVE); records = spy(new JdbcDecisionRecordStore(jdbc,manager,properties));
core = new DecisionService(properties,List.of(new RuleDecisionProvider() {
@Override public DecisionResult decide(DecisionRequest request) {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
return DecisionResult.proposed(new DecisionValue.Choice(proposal),1,"test");
}
}),records,new SimpleMeterRegistry());
when(agents.selectList(any())).thenReturn(List.of(AgentRoutingDecisionAdapterTest.agent(2,"Research"),AgentRoutingDecisionAdapterTest.agent(3,"Writing")));
adapter = new AgentRoutingDecisionAdapter(core,properties,agents);
var target = new PlanningService(session.getMapper(PlanMapper.class),subs); target.setRoutingAdapter(adapter);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); planning=(PlanningService)proxy.getProxy();
}
@AfterEach void close() { core.close(); }
AgentRoutingDecisionAdapter.Selection select(List<Long> baseline) { return adapter.select(10L,"1","conversation","summarize",2,baseline); }
void persist(AgentRoutingDecisionAdapter.Selection selection, List<Long> baseline) {
planning.createPlan("1","conversation","summarize",List.of("read","write"),baseline,selection);
}
List<Long> assignments() { return jdbc.query("SELECT assigned_agent_id FROM mate_sub_plan ORDER BY step_index",(rs,i)->rs.getObject(1,Long.class)); }
@Test void activePersistsActualFallbackWithMatchingOutcomesAfterAllInserts() {
var baseline=Arrays.asList(2L,null); var selected=select(baseline);
when(agents.selectList(any())).thenReturn(List.of(AgentRoutingDecisionAdapterTest.agent(2,"Research")));
persist(selected,baseline);
assertEquals(baseline,assignments());
assertEquals(List.of("AGENT:2","LOCAL"),jdbc.queryForList("SELECT actual_value FROM mate_decision_outcome ORDER BY actual_value",String.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome WHERE outcome='NOT_APPLIED'",Integer.class));
}
@Test void auditFailureRollsBackPlanAndEverySubplan() {
var selected=select(null); doThrow(new IllegalStateException("unavailable")).when(records).outcome(any(),any(),any());
assertThrows(DecisionRecordingException.class,()->persist(selected,null)); assertEmptyBusiness();
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record",Integer.class));
}
@Test void failedOrZeroRowInsertCannotCreateSuccessfulOutcome() {
var selected=select(null); doAnswer(call -> {
SubPlanEntity sub=call.getArgument(0); return sub.getStepIndex()==1 ? 0 : call.callRealMethod();
}).when(subs).insert(any(SubPlanEntity.class));
assertThrows(DecisionRecordingException.class,()->persist(selected,null)); assertEmptyBusiness();
}
@Test void outerRollbackRemovesActualOutcomesButKeepsProposal() {
var selected=select(null);
new TransactionTemplate(manager).executeWithoutResult(status->{persist(selected,null);status.setRollbackOnly();});
assertEmptyBusiness();
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record",Integer.class));
}
@Test void shadowStoresExactExcludedBaselineAndObservesOnlyCommittedPlan() throws Exception {
properties.setMode(DecisionMode.SHADOW); var baseline=Arrays.asList(99L,null); var selected=select(baseline);
var observed=new CountDownLatch(2);
doAnswer(call->{call.callRealMethod();observed.countDown();return null;}).when(records).outcome(any(),any(),any());
persist(selected,baseline); assertTrue(observed.await(5,TimeUnit.SECONDS)); assertEquals(baseline,assignments());
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome WHERE outcome='OBSERVED'",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record WHERE baseline_value='AGENT:99'",Integer.class));
}
@Test void healthyShadowEvaluatesDifferentChoiceButObservesExactLegacyAssignments() throws Exception {
properties.setMode(DecisionMode.SHADOW);
var recorded=new CountDownLatch(2);var observed=new CountDownLatch(2);
doAnswer(call->{call.callRealMethod();recorded.countDown();return null;}).when(records).insert(any());
doAnswer(call->{call.callRealMethod();observed.countDown();return null;}).when(records).outcome(any(),any(),any());
var baseline=Arrays.asList(2L,null);persist(select(baseline),baseline);
assertTrue(recorded.await(5,TimeUnit.SECONDS));assertTrue(observed.await(5,TimeUnit.SECONDS));
assertEquals(baseline,assignments());
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record WHERE proposed_value='AGENT:3'",Integer.class));
assertEquals(List.of("AGENT:2","LOCAL"),jdbc.queryForList("SELECT actual_value FROM mate_decision_outcome WHERE outcome='OBSERVED' ORDER BY actual_value",String.class));
}
@Test void shadowRollbackDoesNotScheduleOutcome() {
properties.setMode(DecisionMode.SHADOW);var selected=select(null);
new TransactionTemplate(manager).executeWithoutResult(status->{persist(selected,null);status.setRollbackOnly();});
verify(records,never()).outcome(any(),any(),any()); assertEmptyBusiness();
}
@Test void offShadowPairedBusinessRowsSurviveCandidateChangesAndStoreFailures() {
for (List<Long> baseline : Arrays.asList(null,Arrays.asList(2L,null),Arrays.asList(99L,null),List.of(3L,2L))) {
for (boolean storeFailure : List.of(false,true)) {
properties.setMode(DecisionMode.OFF); persist(null,baseline); var expected=businessRows(); clearBusiness();
properties.setMode(DecisionMode.SHADOW);
if(storeFailure)doThrow(new IllegalStateException("unavailable")).when(records).insert(any()); else doCallRealMethod().when(records).insert(any());
when(agents.selectList(any())).thenReturn(List.of(AgentRoutingDecisionAdapterTest.agent(2,"Research"),AgentRoutingDecisionAdapterTest.agent(3,"Writing")));
var selected=select(baseline); when(agents.selectList(any())).thenReturn(List.of());
persist(selected,baseline);assertEquals(expected,businessRows());clearBusiness();
}
}
}
@Test void snowflakeAgentIdRemainsExactInAssignmentsAndAudit() {
long id=9007199254740993L;proposal="AGENT:"+id;
when(agents.selectList(any())).thenReturn(List.of(AgentRoutingDecisionAdapterTest.agent(id,"Specialist")));
persist(select(null),null);assertEquals(List.of(id,id),assignments());
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome WHERE actual_value=?",Integer.class,proposal));
}
@Test void candidateLookupFailureAtPersistenceFailsClosed() {
var selected=select(null); when(agents.selectList(any())).thenThrow(new IllegalStateException("unavailable"));
assertThrows(DecisionRecordingException.class,()->persist(selected,null)); assertEmptyBusiness();
}
@Test void realLockingReadRejectsConcurrentDisableAndKeepsEligibleBaseline() throws Exception {
var info=TableInfoHelper.getTableInfo(AgentEntity.class);
var columns=new ArrayList<String>(); columns.add("id BIGINT PRIMARY KEY");
info.getFieldList().forEach(field -> columns.add(field.getColumn()+" "+
(field.getPropertyType()==Boolean.class?"BOOLEAN":field.getPropertyType()==Long.class?"BIGINT":
field.getPropertyType()==Integer.class?"INT":field.getPropertyType()==LocalDateTime.class?"TIMESTAMP":"VARCHAR(2000)")));
jdbc.execute("CREATE TABLE mate_agent("+String.join(",",columns)+")");
jdbc.update("INSERT INTO mate_agent(id,name,workspace_id,enabled,deleted) VALUES(2,'Research',10,TRUE,0),(3,'Writing',10,TRUE,0)");
var realAdapter=new AgentRoutingDecisionAdapter(core,properties,realAgents);planning.setRoutingAdapter(realAdapter);
var selected=realAdapter.select(10L,"1","conversation","summarize",2,Arrays.asList(2L,null));
var started=new CountDownLatch(1);
try(var executor=Executors.newSingleThreadExecutor()) {
Future<?> pending=new TransactionTemplate(manager).execute(status->{
jdbc.queryForList("SELECT id FROM mate_agent WHERE id=3 FOR UPDATE");
var future=executor.submit(()->{started.countDown();persist(selected,Arrays.asList(2L,null));});
try { assertTrue(started.await(5,TimeUnit.SECONDS));assertThrows(TimeoutException.class,()->future.get(100,TimeUnit.MILLISECONDS)); }
catch(InterruptedException failure){throw new RuntimeException(failure);}
jdbc.update("UPDATE mate_agent SET enabled=FALSE WHERE id=3");return future;
});
pending.get(5,TimeUnit.SECONDS);
}
assertEquals(Arrays.asList(2L,null),assignments());
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome WHERE outcome='NOT_APPLIED'",Integer.class));
}
List<Map<String,Object>> businessRows(){return jdbc.queryForList("SELECT p.agent_id,p.conversation_id,p.goal,p.status AS plan_status,p.total_steps,p.completed_steps,s.step_index,s.description,s.status,s.assigned_agent_id FROM mate_plan p JOIN mate_sub_plan s ON p.id=s.plan_id ORDER BY s.step_index");}
void clearBusiness(){jdbc.update("DELETE FROM mate_sub_plan");jdbc.update("DELETE FROM mate_plan");}
void assertEmptyBusiness(){assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM mate_plan",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM mate_sub_plan",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome",Integer.class));}
}

View File

@ -1,5 +1,7 @@
package vip.mate.team.service;
import vip.mate.decision.api.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -140,6 +142,39 @@ class TeamDispatchServiceTest {
verifyNoInteractions(agentService);
}
@Test
void activeSemanticRejectionFailsWithoutAnotherAttempt() {
var adapter = mock(WorkerDecisionAdapter.class); service.setDecisionAdapter(adapter); when(adapter.enabled()).thenReturn(true);
when(adapter.active()).thenReturn(true);
var running = WorkerDecisionAdapterTest.task();
var snapshot = WorkerDecisionAdapter.Snapshot.capture(running);
var ticket = new DecisionTicket("worker", DecisionMode.ACTIVE, new DecisionValue.BooleanValue(false));
when(taskService.getTask(1L)).thenReturn(running);
when(adapter.judge(running, "VALID", "analysis finished"))
.thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false));
service.settleOutcome(running, "analysis finished");
verify(taskService).failTask(1L, "worker result did not satisfy task requirements", ticket, snapshot);
verify(taskService, never()).requeueUnusableResult(any(), anyString(), any(), any());
verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any());
}
@Test
void staleActiveReplyDoesNotAttachArtifactOrCompleteNewAttempt() {
var adapter = mock(WorkerDecisionAdapter.class); service.setDecisionAdapter(adapter); when(adapter.enabled()).thenReturn(true);
when(adapter.active()).thenReturn(true);
var original = WorkerDecisionAdapterTest.task();
var replacement = WorkerDecisionAdapterTest.task(); replacement.setDispatchCount(2);
when(taskService.getTask(1L)).thenReturn(replacement);
var ticket = new DecisionTicket("worker", DecisionMode.ACTIVE, new DecisionValue.BooleanValue(false));
when(adapter.judge(eq(original), eq("STALE_ATTEMPT"), isNull()))
.thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false));
service.settleOutcome(original, "[file](/api/v1/files/generated/abc)");
verify(adapter).outcome(ticket, false, "STALE_ATTEMPT");
verify(taskService, never()).addDeliverable(any(), any(), anyString(), anyString());
verify(taskService, never()).addWorkerDeliverable(any(), any(), anyString(), anyString());
verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any());
}
// ==================== outcome settling ====================
@Test

View File

@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.decision.api.*;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskCommentEntity;
@ -52,6 +53,42 @@ class TeamTaskServiceTest {
private TeamRunService runService;
private TeamTaskService service;
@Test
void staleActiveCompletionCannotMutateOrProduceSuccessfulOutcome() {
var adapter = mock(WorkerDecisionAdapter.class);
service.setDecisionAdapter(adapter); when(adapter.enabled()).thenReturn(true);
var original = WorkerDecisionAdapterTest.task();
var snapshot = WorkerDecisionAdapter.Snapshot.capture(original);
var replacement = WorkerDecisionAdapterTest.task(); replacement.setDispatchCount(2);
when(taskMapper.selectOne(any())).thenReturn(replacement);
var ticket = new DecisionTicket("worker", DecisionMode.ACTIVE, new DecisionValue.BooleanValue(true));
assertFalse(service.completeTask(1L, null, "result", ticket, snapshot).applied());
verify(taskMapper, never()).update(any(), any());
verify(adapter).outcome(ticket, false, "STALE_ATTEMPT");
}
@Test
void failedActiveCompletionUpdateRecordsNoAppliedMutation() {
var adapter = mock(WorkerDecisionAdapter.class); service.setDecisionAdapter(adapter);
var task = WorkerDecisionAdapterTest.task();
when(taskMapper.selectOne(any())).thenReturn(task);
var ticket = new DecisionTicket("worker", DecisionMode.ACTIVE, new DecisionValue.BooleanValue(true));
assertFalse(service.completeTask(1L, null, "done", ticket, WorkerDecisionAdapter.Snapshot.capture(task)).applied());
verify(adapter).outcome(ticket, false, "TRANSITION_REJECTED");
verifyNoInteractions(eventMapper);
}
@Test
void explicitCompletionObservesActualApprovalStatusAfterMutation() {
var adapter = mock(WorkerDecisionAdapter.class);
service.setDecisionAdapter(adapter); when(adapter.enabled()).thenReturn(true);
var task = WorkerDecisionAdapterTest.task(); task.setRequireApproval(true);
when(taskMapper.selectById(1L)).thenReturn(task);
when(taskMapper.update(isNull(), any())).thenReturn(1);
service.completeTask(1L, null, "result");
verify(adapter).observeCompletion(task, TeamTaskStatus.IN_REVIEW);
}
@BeforeAll
static void initTableInfo() {
// Lambda wrappers resolve column names from MyBatis-Plus's static

View File

@ -3,6 +3,7 @@ package vip.mate.team.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService;
import vip.mate.decision.api.DecisionRecordingException;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService;
@ -72,7 +73,7 @@ class TeamWorkerInterventionServiceTest {
verify(conversationService).removeApprovalPlaceholders("worker-101");
verify(replayPersistenceService).persist(101L, "pending-42", "worker-101",
"tool completed", AgentService.ChatResult.contentOnly("tool completed"));
verify(dispatchService).settleOutcome(task, "tool completed");
verify(dispatchService).settleOutcome(eq(task), eq("tool completed"), any(WorkerDecisionAdapter.Snapshot.class));
verify(approvalService).consumeReplayClaim("pending-42", "alice");
}
@ -121,7 +122,7 @@ class TeamWorkerInterventionServiceTest {
verify(conversationService).saveMessage("worker-101", "user", "tighten the summary");
verify(conversationService).saveMessage("worker-101", "assistant", "revised summary",
null, "completed", 0, 0, null, null);
verify(dispatchService).settleOutcome(completed, "revised summary");
verify(dispatchService).settleOutcome(eq(completed), eq("revised summary"), any(WorkerDecisionAdapter.Snapshot.class));
when(approvalService.findPendingByConversation("worker-101"))
.thenReturn(pending("pending-next"));
@ -129,6 +130,18 @@ class TeamWorkerInterventionServiceTest {
() -> service.feedback(7L, 101L, "run another command", "alice"));
}
@Test
void feedbackAuditFailureDoesNotFailWorker() {
TeamTaskEntity completed = task(TeamTaskStatus.COMPLETED);
when(taskService.getTask(101L)).thenReturn(completed);
when(governance.resolve("worker-101", 11L, 101L)).thenReturn(Optional.of(context()));
when(taskService.resumeForWorkerFeedback(101L)).thenReturn(true);
when(agentService.chatWithUsage(any(), any(), any(), any()))
.thenThrow(new IllegalStateException("wrapper", new DecisionRecordingException()));
assertThrows(DecisionRecordingException.class, () -> service.feedback(7L, 101L, "revise", "alice"));
verify(taskService, never()).failTask(any(), any());
}
@Test
void replayFailureReparksApprovedPayloadForSafeRetry() {
TeamTaskEntity task = task(TeamTaskStatus.AWAITING_APPROVAL);
@ -204,7 +217,7 @@ class TeamWorkerInterventionServiceTest {
verify(agentService, never()).chatWithReplayWithUsage(any(), any(), any(), any(), any());
verify(conversationService, never()).removeApprovalPlaceholders(anyString());
verify(dispatchService).settleOutcome(task, "tool completed");
verify(dispatchService).settleOutcome(eq(task), eq("tool completed"), any(WorkerDecisionAdapter.Snapshot.class));
}
@Test

View File

@ -0,0 +1,70 @@
package vip.mate.team.service;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.*;
import vip.mate.decision.record.DecisionRecordStore;
import vip.mate.team.model.*;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class WorkerDecisionAdapterTest {
static TeamTaskEntity task() {
var task = new TeamTaskEntity(); task.setId(1L); task.setTeamId(2L);
task.setOwnerAgentId(3L); task.setDispatchCount(1); task.setConversationId("worker-conversation");
task.setSubject("Analyze results"); task.setStatus(TeamTaskStatus.IN_PROGRESS); return task;
}
@Test void activeRejectionIsBooleanAndDeterministicGuardsBypassProvider() {
var properties = new DecisionProperties(); properties.setMode(DecisionMode.ACTIVE); properties.setProvider("judge");
var calls = new AtomicInteger();
DecisionProvider provider = new DecisionProvider() {
public String id() { return "judge"; }
public DecisionCapability capability() { return DecisionCapability.standard(); }
public DecisionResult decide(DecisionRequest request) {
calls.incrementAndGet();
assertInstanceOf(DecisionQuestion.BooleanQuestion.class, request.question());
return DecisionResult.proposed(new DecisionValue.BooleanValue(false), 1, "test-v1");
}
};
try (var core = new DecisionService(properties, List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry())) {
var adapter = new WorkerDecisionAdapter(core, properties, mock(TeamService.class));
var task = task();
assertFalse(adapter.judge(task, "VALID", "completed evidence").accepted());
for (String guard : List.of("BLANK", "PLACEHOLDER", "ARTIFACT_MISSING", "CLARIFICATION", "CHECKPOINT_WAIT", "STALE_ATTEMPT")) {
assertFalse(adapter.judge(task, guard, "sensitive evidence").accepted());
}
assertEquals(1, calls.get());
}
}
@Test void incompleteOrOversizeEvidenceKeepsBaselineWithoutProviderCall() {
var properties = new DecisionProperties(); properties.setMode(DecisionMode.ACTIVE); properties.setProvider("judge");
var provider = mock(DecisionProvider.class); when(provider.id()).thenReturn("judge");
try (var core = new DecisionService(properties, List.of(provider), mock(DecisionRecordStore.class), new SimpleMeterRegistry())) {
var teams = mock(TeamService.class);
var adapter = new WorkerDecisionAdapter(core, properties, teams);
assertTrue(adapter.judge(task(), "VALID", "x".repeat(9000)).accepted());
var incomplete = task(); incomplete.setSubject(null);
assertTrue(adapter.judge(incomplete, "VALID", "done").accepted());
verify(provider, never()).decide(any());
clearInvocations(teams);
properties.setMode(DecisionMode.OFF);
assertTrue(adapter.judge(task(), "VALID", "done").accepted());
verifyNoInteractions(teams);
}
}
@Test void attemptSnapshotRejectsMissingOrChangedIdentity() {
var task = task(); var snapshot = WorkerDecisionAdapter.Snapshot.capture(task);
assertTrue(snapshot.matches(task));
task.setConversationId("replacement"); assertFalse(snapshot.matches(task));
task = task(); task.setOwnerAgentId(99L); assertFalse(snapshot.matches(task));
task = task(); task.setDispatchCount(2); assertFalse(snapshot.matches(task));
task = task(); task.setStatus(TeamTaskStatus.COMPLETED); assertFalse(snapshot.matches(task));
task = task(); task.setConversationId(null); assertFalse(WorkerDecisionAdapter.Snapshot.capture(task).matches(task));
}
}

View File

@ -0,0 +1,304 @@
package vip.mate.team.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.decision.api.*;
import vip.mate.decision.config.DecisionProperties;
import vip.mate.decision.core.DecisionService;
import vip.mate.decision.provider.RuleDecisionProvider;
import vip.mate.decision.record.JdbcDecisionRecordStore;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.agent.AgentService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.workspace.conversation.ConversationService;
import cn.hutool.json.JSONUtil;
import vip.mate.decision.provider.DecisionResult;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Map;
import java.util.Arrays;
import vip.mate.team.repository.*;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class WorkerDecisionTransactionTest {
JdbcTemplate jdbc;
DataSourceTransactionManager manager;
JdbcDecisionRecordStore records;
DecisionService core;
WorkerDecisionAdapter adapter;
TeamTaskService tasks;
DecisionProperties properties;
TeamService teams;
boolean rejectProvider;
AtomicInteger providerCalls = new AtomicInteger();
@BeforeEach void setup() throws Exception {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:worker_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V172__agent_team_foundation.sql"),
new ClassPathResource("db/migration/h2/V174__team_task_event_timeline.sql"),
new ClassPathResource("db/migration/h2/V181__team_run_foundation.sql"),
new ClassPathResource("db/migration/h2/V203__decision_record.sql")).execute(source);
jdbc = new JdbcTemplate(source); manager = new DataSourceTransactionManager(source);
var config = new MybatisConfiguration();
config.addMapper(TeamTaskMapper.class); config.addMapper(TeamTaskEventMapper.class); config.addMapper(TeamTaskCommentMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
teams = mock(TeamService.class);
properties = new DecisionProperties(); properties.setMode(DecisionMode.ACTIVE);
records = spy(new JdbcDecisionRecordStore(jdbc, manager, properties));
core = new DecisionService(properties, List.of(new RuleDecisionProvider() {
@Override public DecisionResult decide(DecisionRequest request) {
providerCalls.incrementAndGet();
return rejectProvider ? DecisionResult.proposed(new DecisionValue.BooleanValue(false), 1, "test-v1")
: super.decide(request);
}
}), records, new SimpleMeterRegistry());
adapter = new WorkerDecisionAdapter(core, properties, teams);
var target = new TeamTaskService(session.getMapper(TeamTaskMapper.class), session.getMapper(TeamTaskCommentMapper.class),
session.getMapper(TeamTaskEventMapper.class), teams, mock(TeamRunProjectionScheduler.class), mock(TeamRunService.class));
target.setDecisionAdapter(adapter);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
tasks = (TeamTaskService) proxy.getProxy();
jdbc.update("INSERT INTO mate_team_task(id,team_id,task_number,subject,status,assignee_agent_id,owner_agent_id,dispatch_count,conversation_id,require_approval) VALUES(1,2,1,'Analyze results','in_progress',3,3,1,'worker-conversation',FALSE)");
}
@AfterEach void close() { core.close(); }
@ParameterizedTest @ValueSource(strings = {"complete", "retry", "fail", "checkpoint"})
void auditFailureRollsBackBusinessTransition(String action) {
var task = tasks.getTask(1L); var snapshot = WorkerDecisionAdapter.Snapshot.capture(task);
var ticket = adapter.judge(task, "VALID", "analysis done").ticket();
doThrow(new IllegalStateException("audit unavailable")).when(records).outcome(any(), any(), any());
assertThrows(DecisionRecordingException.class, () -> {
switch (action) {
case "complete" -> tasks.completeTask(1L, null, "done", ticket, snapshot);
case "retry" -> tasks.settleUnusableResult(1L, "member produced no result", true, ticket, snapshot);
case "fail" -> tasks.failTask(1L, "rejected", ticket, snapshot);
default -> tasks.parkWorkerCheckpoint(1L, 10, "waiting", ticket, snapshot);
}
});
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(1L).getStatus());
assertNull(tasks.getTask(1L).getResult());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_team_task_event", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
}
@Test void freshApprovalAuthorityAndActualOutcomeAreCommittedTogether() {
var task = tasks.getTask(1L); var snapshot = WorkerDecisionAdapter.Snapshot.capture(task);
var ticket = adapter.judge(task, "VALID", "analysis done").ticket();
jdbc.update("UPDATE mate_team_task SET require_approval=TRUE WHERE id=1");
assertTrue(tasks.completeTask(1L, null, "done", ticket, snapshot).applied());
assertEquals(TeamTaskStatus.IN_REVIEW, tasks.getTask(1L).getStatus());
assertEquals("IN_REVIEW", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
}
@Test void concurrentReassignmentWhileWaitingForLockRejectsOldReply() throws Exception {
var task = tasks.getTask(1L); var snapshot = WorkerDecisionAdapter.Snapshot.capture(task);
var ticket = adapter.judge(task, "VALID", "analysis done").ticket();
var started = new CountDownLatch(1);
try (var executor = Executors.newSingleThreadExecutor()) {
Future<TeamTaskService.Completion> pending = new TransactionTemplate(manager).execute(status -> {
jdbc.queryForList("SELECT id FROM mate_team_task WHERE id=1 FOR UPDATE");
var future = executor.submit(() -> { started.countDown(); return tasks.completeTask(1L, null, "old reply", ticket, snapshot); });
try {
assertTrue(started.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> future.get(100, TimeUnit.MILLISECONDS));
} catch (InterruptedException error) { throw new RuntimeException(error); }
jdbc.update("UPDATE mate_team_task SET owner_agent_id=4, dispatch_count=2, conversation_id='replacement' WHERE id=1");
return future;
});
assertFalse(pending.get(5, TimeUnit.SECONDS).applied());
}
assertEquals(4L, tasks.getTask(1L).getOwnerAgentId());
assertNull(tasks.getTask(1L).getResult());
assertEquals("NOT_APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
assertFalse(tasks.addWorkerDeliverable(snapshot, 3L, "old artifact", "/api/v1/files/generated/abc"));
assertNull(tasks.getTask(1L).getMetadata());
}
@Test void offAndShadowPreserveLegacySettlementAcrossGuardsAndObservationFailures() {
rejectProvider = true;
for (int fixture = 0; fixture < 13; fixture++) {
properties.setMode(DecisionMode.OFF);
List<Object> expected = settleFixture(fixture, false);
for (DecisionMode mode : List.of(DecisionMode.OFF, DecisionMode.SHADOW)) {
for (boolean failingStore : List.of(false, true)) {
properties.setMode(mode);
if (failingStore) doThrow(new IllegalStateException("unavailable")).when(records).insert(any());
else doCallRealMethod().when(records).insert(any());
for (boolean failingLookup : List.of(false, true)) {
if (failingLookup) doThrow(new IllegalStateException("metadata unavailable")).when(teams).getTeam(any());
else doReturn(null).when(teams).getTeam(any());
assertEquals(expected, settleFixture(fixture, true),
"fixture=" + fixture + ",mode=" + mode + ",store=" + failingStore + ",lookup=" + failingLookup);
}
}
}
}
}
private List<Object> settleFixture(int fixture, boolean enabled) {
jdbc.update("DELETE FROM mate_team_task_comment"); jdbc.update("DELETE FROM mate_team_task_event");
jdbc.update("UPDATE mate_team_task SET status='in_progress',owner_agent_id=3,dispatch_count=1,conversation_id='worker-conversation',subject='Analyze results',description=NULL,require_approval=FALSE,progress_percent=NULL,progress_step=NULL,result=NULL,reason=NULL,metadata=NULL WHERE id=1");
String reply = "analysis done";
switch (fixture) {
case 1 -> reply = "";
case 2 -> reply = "Failed to generate a response, please retry.";
case 3 -> { reply = ""; jdbc.update("UPDATE mate_team_task SET dispatch_count=2 WHERE id=1"); }
case 4 -> jdbc.update("UPDATE mate_team_task SET metadata=? WHERE id=1", "{\"deliverableRequired\":true}");
case 5 -> reply = "Could you provide more information?";
case 6 -> { reply = "[report](/api/v1/files/generated/12345678-abcd-1234-abcd-123456789abc)"; jdbc.update("UPDATE mate_team_task SET metadata=? WHERE id=1", "{\"deliverableRequired\":true}"); }
case 7, 8 -> {
jdbc.update("UPDATE mate_team_task SET subject='checkpoint R001-R300' WHERE id=1");
if (fixture == 8) jdbc.update("INSERT INTO mate_team_task_comment(id,task_id,team_id,content) VALUES(9,1,2,'[checkpoint:R300] acknowledged')");
}
case 9 -> jdbc.update("UPDATE mate_team_task SET require_approval=TRUE WHERE id=1");
case 10 -> jdbc.update("UPDATE mate_team_task SET status='completed', result='explicit result' WHERE id=1");
case 11 -> { reply = "Could you provide more information?"; jdbc.update("UPDATE mate_team_task SET dispatch_count=3 WHERE id=1"); }
case 12 -> { reply = "Failed to generate a response, please retry."; jdbc.update("UPDATE mate_team_task SET dispatch_count=2 WHERE id=1"); }
}
var events = new ArrayList<String>();
var announcements = new ArrayList<String>();
var channel = mock(TeamEventChannel.class);
doAnswer(call -> { events.add(call.getArgument(1)); return null; }).when(channel).publishTaskEvent(any(), anyString(), any());
var announce = mock(TeamAnnounceService.class);
doAnswer(call -> { announcements.add("settled"); return null; }).when(announce).announceTaskSettled(any());
var dispatch = new TeamDispatchService(teams, tasks, mock(AgentService.class), mock(ConversationService.class),
mock(ChatStreamTracker.class), announce, channel, mock(ApprovalWorkflowService.class));
if (enabled) dispatch.setDecisionAdapter(adapter);
dispatch.settleOutcome(tasks.getTask(1L), reply);
var result = tasks.getTask(1L);
String metadata = result.getMetadata();
if (fixture == 6 && metadata != null) {
var json = JSONUtil.parseObj(metadata);
json.getJSONArray("deliverables").getJSONObject(0).remove("time"); metadata = json.toString();
}
return Arrays.asList(result.getStatus(), result.getResult(), result.getReason(), result.getOwnerAgentId(),
result.getDispatchCount(), result.getRequireApproval(), result.getProgressPercent(), result.getProgressStep(),
metadata, events, announcements,
jdbc.queryForList("SELECT event_type FROM mate_team_task_event ORDER BY create_time,id", String.class));
}
@Test void healthyShadowActuallyEvaluatesAndObservesCommittedBaseline() throws Exception {
properties.setMode(DecisionMode.SHADOW); rejectProvider = true;
var observed = new CountDownLatch(1);
doAnswer(call -> { call.callRealMethod(); observed.countDown(); return null; })
.when(records).outcome(any(), any(), any());
dispatch(mock(AgentService.class)).settleOutcome(tasks.getTask(1L), "analysis done");
assertTrue(observed.await(5, TimeUnit.SECONDS));
assertEquals(1, providerCalls.get());
assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus());
assertEquals("false", jdbc.queryForObject("SELECT proposed_value FROM mate_decision_record", String.class));
assertEquals("OBSERVED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
assertEquals("COMPLETED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
}
@Test void successfulTransitionAndOutcomeBothDisappearOnOuterRollback() {
var task = tasks.getTask(1L); var ticket = adapter.judge(task, "VALID", "done").ticket();
new TransactionTemplate(manager).executeWithoutResult(status -> {
assertTrue(tasks.completeTask(1L, null, "done", ticket, WorkerDecisionAdapter.Snapshot.capture(task)).applied());
status.setRollbackOnly();
});
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(1L).getStatus());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_outcome", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_decision_record", Integer.class));
}
@Test void activeSemanticRejectionCommitsFailureWithoutRetry() {
rejectProvider = true;
var dispatch = dispatch(mock(AgentService.class));
dispatch.settleOutcome(tasks.getTask(1L), "analysis done");
assertEquals(TeamTaskStatus.FAILED, tasks.getTask(1L).getStatus());
assertEquals(1, tasks.getTask(1L).getDispatchCount());
assertEquals("FAILED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
assertEquals(List.of("failed"), jdbc.queryForList("SELECT event_type FROM mate_team_task_event", String.class));
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void dispatchedRunUsesOriginalIdentityWithItsNewChildConversation(boolean reassigned) {
var agent = mock(AgentService.class);
when(agent.chatWithUsage(eq(3L), anyString(), anyString())).thenAnswer(call -> {
if (reassigned) jdbc.update("UPDATE mate_team_task SET owner_agent_id=4,dispatch_count=2,conversation_id='new-run' WHERE id=1");
return AgentService.ChatResult.contentOnly("[report](/api/v1/files/generated/12345678-abcd-1234-abcd-123456789abc)");
});
dispatch(agent).runTask(2L, tasks.getTask(1L));
assertEquals(reassigned ? TeamTaskStatus.IN_PROGRESS : TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus());
assertEquals(reassigned ? 0 : 1, tasks.listDeliverables(tasks.getTask(1L)).size());
assertEquals(reassigned ? "NOT_APPLIED" : "APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
}
@Test void workerAuditFailureRetainsTaskInsteadOfGenericFailure() {
var agent = mock(AgentService.class);
when(agent.chatWithUsage(eq(3L), anyString(), anyString())).thenReturn(AgentService.ChatResult.contentOnly("done"));
doThrow(new IllegalStateException("unavailable")).when(records).outcome(any(), any(), any());
dispatch(agent).runTask(2L, tasks.getTask(1L));
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(1L).getStatus());
assertNull(tasks.getTask(1L).getReason());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_team_task_event", Integer.class));
}
@Test void decisionPreparationFailureRetainsWorkerState() {
var agent = mock(AgentService.class);
when(agent.chatWithUsage(eq(3L), anyString(), anyString())).thenReturn(AgentService.ChatResult.contentOnly("done"));
doReturn(null).doThrow(new IllegalStateException("workspace unavailable")).when(teams).getTeam(2L);
dispatch(agent).runTask(2L, tasks.getTask(1L));
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(1L).getStatus());
assertNull(tasks.getTask(1L).getReason());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_team_task_event", Integer.class));
}
@ParameterizedTest @ValueSource(strings = {"complete", "fail", "requeue", "checkpoint"})
void snapshotCannotAuthorizeMutationOfAnotherTask(String action) {
jdbc.update("INSERT INTO mate_team_task(id,team_id,task_number,subject,status,assignee_agent_id,owner_agent_id,dispatch_count,conversation_id,require_approval) VALUES(2,2,2,'Other task','in_progress',7,7,1,'other-worker',TRUE)");
var first = tasks.getTask(1L); var snapshot = WorkerDecisionAdapter.Snapshot.capture(first);
var ticket = adapter.judge(first, "VALID", "done").ticket();
boolean applied = switch (action) {
case "complete" -> tasks.completeTask(2L, null, "wrong task", ticket, snapshot).applied();
case "fail" -> tasks.failTask(2L, "wrong task", ticket, snapshot);
case "requeue" -> tasks.requeueUnusableResult(2L, "wrong task", ticket, snapshot);
default -> tasks.parkWorkerCheckpoint(2L, 10, "wrong task", ticket, snapshot);
};
assertFalse(applied);
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(2L).getStatus());
assertNull(tasks.getTask(2L).getResult());
assertNull(tasks.getTask(2L).getProgressPercent());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_team_task_event", Integer.class));
assertEquals("NOT_APPLIED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
}
private TeamDispatchService dispatch(AgentService agent) {
var dispatch = new TeamDispatchService(teams, tasks, agent, mock(ConversationService.class), mock(ChatStreamTracker.class),
mock(TeamAnnounceService.class), mock(TeamEventChannel.class), mock(ApprovalWorkflowService.class));
dispatch.setDecisionAdapter(adapter); return dispatch;
}
@Test void explicitCompletionUsesOneGuardedAcceptedObservation() {
tasks.completeTask(1L, 3L, "explicitly done");
assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus());
assertEquals("OBSERVED", jdbc.queryForObject("SELECT outcome FROM mate_decision_outcome", String.class));
assertEquals("true", jdbc.queryForObject("SELECT baseline_value FROM mate_decision_record", String.class));
assertEquals("GUARD", jdbc.queryForObject("SELECT reason FROM mate_decision_record", String.class));
}
}

View File

@ -1,6 +1,7 @@
package vip.mate.team.tool;
import org.junit.jupiter.api.AfterEach;
import vip.mate.decision.api.DecisionRecordingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -440,6 +441,16 @@ class TeamTasksToolTest {
// ==================== member execution actions ====================
@Test
void completionAuditFailureEscapesToolErrorFormatting() {
callerIs(MEMBER_ID);
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
when(taskService.completeTask(5L, MEMBER_ID, "done"))
.thenThrow(new IllegalStateException("wrapper", new DecisionRecordingException()));
assertThrows(DecisionRecordingException.class, () -> tool.team_tasks("complete", "5", null, null,
null, null, null, null, null, null, null, "done", null, null, null, null, null, null, null));
}
@Test
@DisplayName("complete requires a result and reports released dependents")
void completeReportsRelease() {

View File

@ -34,6 +34,7 @@
<spring-boot.version>3.5.16</spring-boot.version>
<spring-ai.version>1.1.8</spring-ai.version>
<spring-ai-alibaba.version>1.1.2.3</spring-ai-alibaba.version>
<jsonschema-generator.version>4.38.0</jsonschema-generator.version>
<springdoc.version>2.8.16</springdoc.version>
<!-- Persistence and core utilities -->
@ -95,6 +96,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Align schema modules used by Spring AI with the generator selected through SDK dependencies. -->
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator-bom</artifactId>
<version>${jsonschema-generator.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-bom</artifactId>