release: v2.3.0

This commit is contained in:
matevip 2026-09-20 02:22:47 -04:00
parent 637497b043
commit 472d184d09
369 changed files with 21336 additions and 2489 deletions

View File

@ -88,6 +88,13 @@ You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Bac
### Agent Runtime: native or DSH (2.2.0+)
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** manages `dsh-jsonrpc-agent` as an authenticated child process and streams thinking, text, tool calls, usage, completion, and cancellation back as normalized runtime events. DSH owns the external Agent loop; MateClaw still owns the session, workspace, credentials, tools, approvals, messages, and UI projection. Runtime availability and capabilities are validated before startup, and DSH can be installed, verified, connection-tested, enabled, or disabled from the console. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
For file-producing work, ask the employee to keep a progress ledger, append small verifiable units, inspect the existing tail after recovery, and complete the Goal only after reproducible acceptance checks pass. The runtime does not promise exactly-once behavior for arbitrary external side effects; payments, sends, publishes, and destructive calls still need provider idempotency or review. [Run and verify durable Goals →](https://claw.mate.vip/docs/en/goals)
> Prompt pattern: “Create a persistent Goal first. Save the plan and progress in the workspace, write in small checkpoints, resume from existing evidence after errors or restart, and call `completeGoal` only after every criterion has verifiable evidence.”
### Team Runs (2.1.0+)
One request, one durable **Team Run**. A stable `runId` links the user's objective, task DAG, worker executions, final synthesis, and deliverables. Chat is the outcome surface, Agents Live groups the workers for real-time observation, and Teams owns history and governance — all three consume the same server projection. Worker conversations no longer flood the normal sidebar; summaries and files lead, while tasks, evidence, approvals, and read-only worker records drill down on demand. Underneath, the 2.0 shared board still provides dependency orchestration, parallel dispatch, prerequisite hand-off, execution leases, cancel-interrupt, and human approval gates.

View File

@ -88,6 +88,13 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
### Agent RuntimeNative 或 DSH2.2.0+
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loopMateClaw 继续掌管 session、workspace、凭证、工具、审批、消息和 UI 投影。启动前会校验 runtime 可用性与能力;控制台可完成 DSH 的安装、配置、校验、连接测试和启停。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
### 持久长任务检查点、重启、继续2.2.0+
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease以及员工忙碌期间已经接收的用户输入。单后端实例重启后supervisor 会先核对被中断的 attempt读取持久检查点和已有产物再调度下一段安全工作不要求用户重新描述任务。
对于写文件的任务,应要求员工维护进度账本、以小块追加可验证内容、恢复时先检查文件尾部,并且只有在可复现验收全部通过后才完成 Goal。运行时不承诺任意外部副作用严格一次付款、发送、发布和破坏性操作仍需使用服务商幂等键或人工复核。[运行并验证持久目标 →](https://claw.mate.vip/docs/zh/goals)
> 提示词模板:“第一步创建持续目标;把计划和进度保存在工作区;按小检查点写入;发生错误或重启后从已有证据继续;只有每条验收标准都有可验证证据时才调用 `completeGoal`。”
### Team Run2.1.0+
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面Agents Live 按运行聚合成员并展示实时状态Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。

View File

@ -1,6 +1,6 @@
{
"name": "mateclaw-desktop",
"version": "2.2.0",
"version": "2.3.0",
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
"author": "MateClaw Team",
"license": "Apache-2.0",

View File

@ -11,7 +11,7 @@ import java.util.List;
*
* @author MateClaw Team
*/
public interface PluginMemoryProvider {
public interface PluginMemoryProvider extends AutoCloseable {
/**
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
@ -114,4 +114,9 @@ public interface PluginMemoryProvider {
*/
default void onSessionEnd(Long agentId, String conversationId) {
}
/** Release provider-owned resources when the plugin is unloaded. */
@Override
default void close() {
}
}

View File

@ -13,6 +13,7 @@ package vip.mate.plugin.mem0;
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
* @param maxResults cap on memories returned per recall
* @param timeoutMs HTTP timeout for both recall and sync
* @param syncQueueCapacity maximum number of turns waiting for asynchronous sync
* @author MateClaw Team
*/
record Mem0Config(
@ -21,10 +22,18 @@ record Mem0Config(
boolean searchEnabled,
boolean syncEnabled,
int maxResults,
int timeoutMs
int timeoutMs,
int syncQueueCapacity
) {
static final int DEFAULT_MAX_RESULTS = 5;
static final int DEFAULT_TIMEOUT_MS = 3000;
static final int DEFAULT_SYNC_QUEUE_CAPACITY = 256;
Mem0Config(String baseUrl, String apiKey, boolean searchEnabled, boolean syncEnabled,
int maxResults, int timeoutMs) {
this(baseUrl, apiKey, searchEnabled, syncEnabled, maxResults, timeoutMs,
DEFAULT_SYNC_QUEUE_CAPACITY);
}
/**
* Whether this provider should participate at all.

View File

@ -3,9 +3,8 @@ package vip.mate.plugin.mem0;
/**
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
* <p>
* Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
* gracefully (empty recall / dropped sync) without affecting the agent's
* response path.
* Sync failures are caught by {@link Mem0Provider}; recall failures propagate
* to the platform provider boundary for timeout/circuit-breaker accounting.
*
* @author MateClaw Team
*/

View File

@ -39,6 +39,7 @@ public class Mem0Plugin implements MateClawPlugin {
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
private static final String CONFIG_MAX_RESULTS = "maxResults";
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
private static final String CONFIG_SYNC_QUEUE_CAPACITY = "syncQueueCapacity";
private Logger log;
@ -54,11 +55,16 @@ public class Mem0Plugin implements MateClawPlugin {
Mem0Client client = new Mem0Client(config);
Mem0Provider provider = new Mem0Provider(config, client, log);
try {
context.registerMemoryProvider(provider);
} catch (RuntimeException e) {
provider.close();
throw e;
}
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}",
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}, syncQueueCapacity={}",
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
config.maxResults(), config.timeoutMs());
config.maxResults(), config.timeoutMs(), config.syncQueueCapacity());
}
@Override
@ -78,6 +84,7 @@ public class Mem0Plugin implements MateClawPlugin {
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
Integer syncQueueCapacity = ctx.getConfig(CONFIG_SYNC_QUEUE_CAPACITY, Integer.class);
return new Mem0Config(
baseUrl,
@ -85,7 +92,9 @@ public class Mem0Plugin implements MateClawPlugin {
searchEnabled == null ? true : searchEnabled,
syncEnabled == null ? true : syncEnabled,
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs,
syncQueueCapacity == null ? Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY
: Math.max(1, syncQueueCapacity)
);
}

View File

@ -4,9 +4,11 @@ import org.slf4j.Logger;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
@ -17,13 +19,14 @@ import java.util.concurrent.Executors;
* <li>{@code systemPromptBlock} no-op (returns ""), aligns with SessionSearchProvider</li>
* <li>{@code prefetch(agentId, query, ownerKey)} when {@code searchEnabled}
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
* or when disabled.</li>
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
* platform's timeout/circuit-breaker boundary.</li>
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} when
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
* pushes the turn to {@code POST /memories/} under {@code user_id =
* ownerKey}, the same identifier prefetch recalls by. Failures are
* logged and swallowed; never blocks the response path. The four-arg
* logged and swallowed; never blocks the response path. The bounded
* queue drops new writes when saturated. The four-arg
* variant (no ownerKey) skips writing under any other identifier
* would produce memories that owner-scoped recall can never surface.</li>
* <li>{@code getToolBeans} empty (no agent-facing tools in v1)</li>
@ -34,8 +37,8 @@ import java.util.concurrent.Executors;
* When {@code ownerKey} is null/blank, both recall and sync are skipped Mem0
* requires {@code user_id}.
*
* <p>Asynchronous sync: a single-thread daemon executor is used
* so that bursts of turns don't pile up on the platform's request thread.
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
* prevents an unavailable Mem0 service from growing heap usage without limit.
*
* @author MateClaw Team
*/
@ -46,21 +49,19 @@ class Mem0Provider implements PluginMemoryProvider {
private final Mem0Config config;
private final Mem0Client client;
private final Logger log;
private final Executor async;
private final ThreadPoolExecutor async;
private final AtomicLong droppedSyncCount = new AtomicLong();
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
this.config = config;
this.client = client;
this.log = log;
// Single-thread executor is enough syncTurn calls are sequential per
// agent and not latency-sensitive; the platform's request thread must
// not be blocked. A bounded single-thread queue keeps memory footprint
// predictable even under burst load.
this.async = Executors.newSingleThreadExecutor(r -> {
this.async = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(Math.max(1, config.syncQueueCapacity())), r -> {
Thread t = new Thread(r, "mem0-sync");
t.setDaemon(true);
return t;
});
}, new ThreadPoolExecutor.AbortPolicy());
}
@Override
@ -104,20 +105,12 @@ class Mem0Provider implements PluginMemoryProvider {
if (userQuery == null || userQuery.isBlank()) {
return "";
}
try {
List<String> memories = client.searchMemories(
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
if (memories.isEmpty()) {
return "";
}
return formatRecallBlock(memories);
} catch (Exception e) {
// Fault isolation: log and return empty so the platform falls back
// to the other (local) providers without affecting the response.
log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
return "";
}
}
@Override
@ -143,7 +136,8 @@ class Mem0Provider implements PluginMemoryProvider {
&& (assistantReply == null || assistantReply.isBlank())) {
return;
}
CompletableFuture.runAsync(() -> {
try {
async.execute(() -> {
try {
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
conversationId, userMessage, assistantReply);
@ -151,7 +145,43 @@ class Mem0Provider implements PluginMemoryProvider {
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
}
}, async);
});
} catch (RejectedExecutionException e) {
long dropped = droppedSyncCount.incrementAndGet();
log.warn("[Mem0] sync queue full or provider closed; dropped turn for agent={} owner={} (totalDropped={})",
agentId, ownerKey, dropped);
}
}
int queuedSyncCount() {
return async.getQueue().size();
}
long droppedSyncCount() {
return droppedSyncCount.get();
}
boolean isClosed() {
return async.isShutdown();
}
@Override
public void close() {
async.shutdown();
List<Runnable> dropped = List.of();
try {
long drainMs = Math.min(1000L, Math.max(100L, config.timeoutMs()));
if (!async.awaitTermination(drainMs, TimeUnit.MILLISECONDS)) {
dropped = async.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
dropped = async.shutdownNow();
}
if (!dropped.isEmpty()) {
droppedSyncCount.addAndGet(dropped.size());
log.warn("[Mem0] provider closed with {} queued sync turn(s) discarded", dropped.size());
}
}
@Override

View File

@ -43,6 +43,12 @@
"required": false,
"secret": false,
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
},
"syncQueueCapacity": {
"type": "integer",
"required": false,
"secret": false,
"description": "Maximum pending asynchronous sync turns. New writes are dropped when full. Default 256."
}
}
}

View File

@ -35,4 +35,10 @@ class Mem0ConfigTest {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
}
@Test
void legacyConstructorUsesBoundedQueueDefault() {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.syncQueueCapacity()).isEqualTo(Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY);
}
}

View File

@ -90,6 +90,7 @@ class Mem0PluginTest {
PluginContext ctx = new StubContext(config, registered) {
@Override
public void registerMemoryProvider(PluginMemoryProvider provider) {
registered.set(provider);
throw new PluginException("Only one external memory provider allowed");
}
};
@ -98,6 +99,7 @@ class Mem0PluginTest {
assertThatThrownBy(() -> plugin.onLoad(ctx))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Only one");
assertThat(((Mem0Provider) registered.get()).isClosed()).isTrue();
}
/**

View File

@ -14,8 +14,11 @@ import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Mem0ProviderTest {
@ -43,6 +46,7 @@ class Mem0ProviderTest {
@AfterEach
void tearDown() {
if (provider != null) provider.close();
if (server != null) server.stop(0);
}
@ -129,16 +133,15 @@ class Mem0ProviderTest {
}
@Test
void threeArgPrefetch_returnsEmptyOnServerError() {
// Replace handler to fail; the provider should swallow and return "".
void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
server.removeContext("/");
server.createContext("/", ex -> {
ex.sendResponseHeaders(500, 0);
ex.close();
});
String result = provider.prefetch(1L, "q", "user:42");
assertThat(result).isEmpty();
assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
.isInstanceOf(Mem0Exception.class);
}
@Test
@ -215,5 +218,42 @@ class Mem0ProviderTest {
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
assertThat(searchCount.get()).isZero();
p.close();
}
@Test
void syncQueueIsBoundedAndCloseReleasesExecutor() throws Exception {
CountDownLatch firstStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
AtomicInteger writes = new AtomicInteger();
Mem0Config cfg = new Mem0Config("http://localhost:8080", null,
false, true, 3, 3000, 1);
Mem0Client blockingClient = new Mem0Client(cfg) {
@Override
void addMemories(String userId, String agentId, String conversationId,
String userMessage, String assistantReply) {
writes.incrementAndGet();
firstStarted.countDown();
try {
releaseFirst.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
Mem0Provider bounded = new Mem0Provider(cfg, blockingClient, LoggerFactory.getLogger("test"));
try {
bounded.syncTurn(1L, "one", "u", "a", "user:1");
assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
bounded.syncTurn(1L, "two", "u", "a", "user:1");
bounded.syncTurn(1L, "three", "u", "a", "user:1");
assertThat(bounded.queuedSyncCount()).isEqualTo(1);
assertThat(bounded.droppedSyncCount()).isEqualTo(1);
} finally {
releaseFirst.countDown();
bounded.close();
}
assertThat(bounded.isClosed()).isTrue();
}
}

View File

@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
@ -103,6 +104,13 @@ public class AgentGraphBuilder {
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
private ExecutionEvidenceRecorder executionEvidenceRecorder;
@Autowired
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
@org.springframework.beans.factory.annotation.Value(
"${mate.agent.markdown-normalize-enabled:true}")
@ -675,6 +683,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -998,6 +1007,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {

View File

@ -28,7 +28,6 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List;
import java.util.Locale;
import java.time.Duration;
import java.util.Map;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap;
@ -90,6 +89,12 @@ public class AgentService {
@Autowired(required = false)
private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService;
@Autowired
private vip.mate.agent.runtime.dsh.DshConversationHistory dshConversationHistory;
@Autowired(required = false)
private vip.mate.goal.service.GoalApprovalReplayStream goalApprovalReplay;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
@ -325,11 +330,11 @@ public class AgentService {
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
if (isDshAgent(agentId)) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -366,13 +371,13 @@ public class AgentService {
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
if (isDshAgent(agentId)) {
return chatStructuredStream(agentId, message, conversationId, "", null,
origin != null ? origin : ChatOrigin.EMPTY)
.filter(delta -> delta.content() != null)
.map(StreamDelta::content);
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
@ -409,7 +414,7 @@ public class AgentService {
String requesterId, String thinkingLevel,
ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
trackMemoryRecalls(agentId, message, origin);
if (isDshAgent(agentId)) {
AgentEntity dshAgent = getAgent(agentId);
return withLifecycleFlux(agentId, message, conversationId,
@ -418,7 +423,8 @@ public class AgentService {
dshAgent.getModelName(), dshWorkingDirectory(dshAgent),
dshWorkingDirectory(dshAgent)),
connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt(
connection.prompt(msg)),
connection.prompt(dshConversationHistory.enrich(
convId, message, msg, origin))),
connection -> connection.close()),
StreamDelta::content)
.doFinally(signal -> ThinkingLevelHolder.clear());
@ -469,7 +475,7 @@ public class AgentService {
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, goal);
trackMemoryRecalls(agentId, goal, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -496,7 +502,7 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage);
trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -524,23 +530,7 @@ public class AgentService {
* {@code _usage_final} event for token and model attribution.
*/
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
return ChatResultCollector.collect(stream);
}
/**
@ -560,9 +550,25 @@ public class AgentService {
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId,
ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage);
trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
if (goalApprovalReplay != null && goalApprovalReplay.applies(captured)) {
return Flux.using(() -> acquireTurn(conversationId), permit ->
goalApprovalReplay.replay(captured, toolCallPayload, fresh -> {
ChatOrigin previous = ChatOriginHolder.get();
ChatOriginHolder.set(fresh);
try {
return vip.mate.agent.context.GoalContinuationContext.call(true, () ->
invokeWithLifecycleFlux(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""), StreamDelta::content));
} finally {
if (previous == ChatOrigin.EMPTY) ChatOriginHolder.clear();
else ChatOriginHolder.set(previous);
}
}), vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
}
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, userMessage, conversationId,
@ -772,6 +778,13 @@ public class AgentService {
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
}
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
: null;
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
}
private void validateDshConfiguration(AgentEntity agent) {
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
if (dshRuntimeService == null) {
@ -970,10 +983,15 @@ public class AgentService {
* post-approval replays).
*/
public record ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider, String finishReason) {
public ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider) {
this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null);
}
public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null);
return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
}
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.agent;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
/** Collapses a structured agent stream without discarding terminal metadata. */
final class ChatResultCollector {
private ChatResultCollector() {
}
static AgentService.ChatResult collect(Flux<AgentService.StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
final String[] finishReason = {null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData() != null ? delta.eventData() : Map.of();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.isEvent() && "finish_reason".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
Object reason = data != null ? data.get("reason") : null;
if (reason != null) finishReason[0] = reason.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new AgentService.ChatResult(content.toString(), usage[0], usage[1],
modelInfo[0], modelInfo[1], finishReason[0]);
}
}

View File

@ -160,8 +160,14 @@ public final class GraphEventPublisher {
public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason) {
return toolApprovalRequested(null, pendingId, toolName, arguments, reason);
}
public static GraphEvent toolApprovalRequested(String toolCallId, String pendingId, String toolName,
String arguments, String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"pendingId", pendingId,
"toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? arguments : "",
@ -177,8 +183,16 @@ public final class GraphEventPublisher {
String arguments, String reason,
String summary, String maxSeverity,
List<Map<String, Object>> findings) {
return toolApprovalRequested(null, pendingId, toolName, arguments, reason, summary, maxSeverity, findings);
}
public static GraphEvent toolApprovalRequested(String toolCallId, String pendingId, String toolName,
String arguments, String reason,
String summary, String maxSeverity,
List<Map<String, Object>> findings) {
long ts = System.currentTimeMillis();
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("toolCallId", toolCallId != null ? toolCallId : "");
data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? arguments : "");

View File

@ -730,6 +730,10 @@ public class AgentBindingService implements AgentBindingResolver {
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
// User-selected managed JSON protocol, authorized again inside each tool service.
"getManagedGoalJsonSlots",
"publishManagedGoalJson",
"checkManagedGoalJson",
"waitForGoalInput",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting
@ -769,6 +773,7 @@ public class AgentBindingService implements AgentBindingResolver {
"read_file",
"send_file",
"write_file",
"append_file",
"edit_file",
"execute_shell_command",
// Inline code execution an agent-wide capability alongside shell.

View File

@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;
import java.util.Map;
import java.util.Objects;
/**
* Immutable value object that travels alongside an agent invocation describing
@ -76,9 +77,37 @@ public record ChatOrigin(
* from "this is an external/anonymous identifier" (RFC: identity typing).
*/
@Nullable Long requesterUserId,
@Nullable Long originMessageId
@Nullable Long originMessageId,
@Nullable ExecutionAttribution executionAttribution,
/** Managed Goal captured for this turn: null=legacy unknown, 0=observed unselected. */
@Nullable Long selectedGoalId
) {
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
@Nullable String senderName, @Nullable String channelType,
@Nullable String chatId, @Nullable String baseUrl,
@Nullable Long requesterUserId, @Nullable Long originMessageId,
@Nullable ExecutionAttribution executionAttribution) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, originMessageId, executionAttribution, null);
}
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
@Nullable String senderName, @Nullable String channelType,
@Nullable String chatId, @Nullable String baseUrl,
@Nullable Long requesterUserId, @Nullable Long originMessageId) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, originMessageId, null);
}
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@ -149,27 +178,32 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId,
executionAttribution, selectedGoalId);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId,
executionAttribution, selectedGoalId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId,
Objects.equals(conversationId, newConversationId) ? executionAttribution : null,
selectedGoalId);
}
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId,
executionAttribution, selectedGoalId);
}
/**
@ -183,13 +217,34 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId,
executionAttribution, selectedGoalId);
}
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId,
executionAttribution, selectedGoalId);
}
public ChatOrigin withApprovalId(String pendingId) {
ExecutionAttribution attribution = executionAttribution == null
? new ExecutionAttribution(null, null, null, pendingId, null)
: executionAttribution.withApproval(pendingId);
return withExecutionAttribution(attribution);
}
public ChatOrigin withExecutionAttribution(ExecutionAttribution attribution) {
return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl,
requesterUserId, originMessageId, attribution, selectedGoalId);
}
public ChatOrigin withSelectedGoalId(@Nullable Long goalId) {
return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl,
requesterUserId, originMessageId, executionAttribution, goalId);
}
// ---------------- Spring AI ToolContext interop ----------------

View File

@ -1410,6 +1410,17 @@ public class ConversationWindowManager {
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
String finishReason = response.getResult().getMetadata() != null
? response.getResult().getMetadata().getFinishReason() : null;
if ("length".equalsIgnoreCase(finishReason)) {
// A non-empty response can still be structurally incomplete
// when the provider exhausts max tokens. Persisting it would
// poison every later iterative summary.
log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
conversationId);
setSummaryCooldown(conversationId);
return null;
}
String summary = response.getResult().getOutput().getText();
if (summary != null && !summary.isBlank()) {
// 成功保存摘要供下次迭代更新清除冷却

View File

@ -0,0 +1,12 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/** Server-issued execution linkage. This record is never a tool argument or an HTTP request body. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExecutionAttribution(Long goalId, String goalAttemptId, Long cronRunId,
String approvalId, String ownerFence) {
public ExecutionAttribution withApproval(String pendingId) {
return new ExecutionAttribution(goalId, goalAttemptId, cronRunId, pendingId, ownerFence);
}
}

View File

@ -26,6 +26,7 @@ import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
@ -554,6 +555,7 @@ public class NodeStreamingChatHelper {
// ("credit balance is too low") use these phrases in 402-class responses.
// Chinese provider patterns (Zhipu 1113, DashScope, general) same hard
// failure semantics: retrying the same provider won't refill the balance.
String lowerMsg = msg.toLowerCase(Locale.ROOT);
if (msg.contains("402") || msg.contains("insufficient_quota")
|| msg.contains("credit balance is too low")
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
@ -562,7 +564,13 @@ public class NodeStreamingChatHelper {
|| msg.contains("余额不足") || msg.contains("请充值")
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|| msg.contains("AccountBalanceNotEnough")
|| msg.contains("balance not enough")) {
|| msg.contains("balance not enough")
|| lowerMsg.contains("invalidsubscription")
|| lowerMsg.contains("subscription has expired")
|| lowerMsg.contains("arrearage")
|| lowerMsg.contains("account is in good standing")
|| lowerMsg.contains("insufficient_balance")
|| lowerMsg.contains("insufficient balance")) {
return ErrorType.BILLING;
}
// RFC-009 P3.2: MODEL_NOT_FOUND provider rejects the requested model id.
@ -1169,6 +1177,7 @@ public class NodeStreamingChatHelper {
List<ToolCallAccumulator> toolCallAccumulators = new ArrayList<>();
AtomicReference<AssistantMessage> lastAssistantMessage = new AtomicReference<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicReference<String> finishReason = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = new AtomicInteger(0);
// Prompt cache / reasoning counters; providers that don't report them stay 0.
@ -1284,6 +1293,11 @@ public class NodeStreamingChatHelper {
return;
}
var generation = chatResponse.getResult();
if (generation.getMetadata() != null
&& generation.getMetadata().getFinishReason() != null
&& !generation.getMetadata().getFinishReason().isBlank()) {
finishReason.set(generation.getMetadata().getFinishReason());
}
AssistantMessage msg = generation.getOutput();
lastAssistantMessage.set(msg);
@ -1572,7 +1586,10 @@ public class NodeStreamingChatHelper {
// ===== 成功检查是否因 thinking-only 软上限或内容重复被截断 =====
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
boolean truncatedByContentRepeat = contentRepeatCapTriggered.get();
boolean truncated = truncatedByThinkingCap || truncatedByContentRepeat;
boolean thinkingTokenLimit = "length".equalsIgnoreCase(finishReason.get())
&& thinkingAccum.length() > 0 && contentAccum.toString().isBlank()
&& toolCallAccumulators.isEmpty();
boolean truncated = truncatedByThinkingCap || truncatedByContentRepeat || thinkingTokenLimit;
if (truncatedByThinkingCap) {
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
phase, conversationId);
@ -1601,6 +1618,7 @@ public class NodeStreamingChatHelper {
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
: truncatedByContentRepeat ? "content_repetition"
: thinkingTokenLimit ? "thinking_token_limit"
: null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
@ -2028,9 +2046,26 @@ public class NodeStreamingChatHelper {
|| combined.contains("model not found") || combined.contains("not_found_error")) {
return "Model name not available on this provider — verify the model exists and is supported (Settings → Models)";
}
// Do not mislabel unsupported model parameters as image errors (#640).
// Only emit known parameter names and fixed guidance, never raw response
// bodies, which may contain credentials or echoed conversation content.
String lowerError = combined.toLowerCase(java.util.Locale.ROOT);
if (lowerError.contains("unsupported parameter") || lowerError.contains("unsupported_parameter")
|| lowerError.contains("unsupported value") || lowerError.contains("unsupported_value")) {
if (lowerError.contains("max_tokens") && lowerError.contains("max_completion_tokens")) {
return "模型不支持 max_tokens请改用 max_completion_tokens。请更新 MateClaw并检查模型及提供商生成参数配置。";
}
for (String parameter : java.util.List.of("max_completion_tokens", "max_tokens", "temperature", "top_p",
"reasoning_effort", "stream_options", "parallel_tool_calls", "tool_choice", "response_format")) {
if (lowerError.contains(parameter)) {
return "模型不支持生成参数或参数值:" + parameter + "。请检查模型及提供商生成参数配置。";
}
}
return "模型不支持当前生成参数或参数值,请检查模型及提供商生成参数配置。";
}
// Jackson 反序列化错误提取关键信息
if (msg.contains("engine_overloaded")) return "Model service overloaded, please retry later";
if (msg.contains("unsupported image format") || msg.contains("unsupported")) return "Unsupported file format (e.g. SVG), use PNG/JPG instead";
if (lowerError.contains("unsupported image format")) return "Unsupported file format (e.g. SVG), use PNG/JPG instead";
if (msg.contains("invalid_request_error") || msg.contains("400 Bad Request")) return "Bad request, please check input";
if (msg.contains("rate_limit") || msg.contains("429")) return "Rate limit exceeded, please retry later";
if (msg.contains("timeout") || msg.contains("Timeout")) return "Request timeout, please retry";
@ -2509,11 +2544,41 @@ public class NodeStreamingChatHelper {
acc.id,
acc.type != null ? acc.type : "function",
acc.name,
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
}
return result;
}
/**
* Finalize a streamed tool call for local execution.
*
* <p>Blank arguments are a common zero-argument representation and remain
* normalized to an empty object. Invalid non-blank JSON, however, must be
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
* {@code {}} loses the distinction between a truncated stream and a real
* empty call and can execute the wrong operation. The outgoing-history
* normalization path still calls {@link #sanitizeToolCallArguments} before
* a later provider request.</p>
*/
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
if (arguments == null || arguments.isBlank()) {
return "{}";
}
try {
TOOL_ARG_JSON_MAPPER.readTree(arguments);
return arguments;
} catch (Exception e) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
+ "Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
e.getMessage());
return arguments;
}
}
/**
* Ensure {@code function.arguments} is always a well-formed JSON string.
* <p>

View File

@ -700,6 +700,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
if (active.isJsonAcceptanceRequired()) {
inputs.put(SYSTEM_PROMPT, inputs.get(SYSTEM_PROMPT) + "\n\n"
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());

View File

@ -0,0 +1,60 @@
package vip.mate.agent.graph.executor;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
/** Interrupts cooperative callbacks in place, preserving their thread-local context. */
final class ToolCallDeadline {
private static final ScheduledThreadPoolExecutor TIMER = new ScheduledThreadPoolExecutor(
1, Thread.ofPlatform().daemon().name("tool-deadline-", 0).factory());
static {
TIMER.setRemoveOnCancelPolicy(true);
}
private final Thread owner = Thread.currentThread();
private boolean active = true;
private boolean expired;
private synchronized void expire() {
if (active) {
expired = true;
owner.interrupt();
}
}
private synchronized boolean finish() {
active = false;
return expired;
}
static <T> T call(String toolName, long timeoutMs, Supplier<T> callback) throws TimeoutException {
ToolCallDeadline deadline = new ToolCallDeadline();
ScheduledFuture<?> timer = TIMER.schedule(deadline::expire, Math.max(1L, timeoutMs), TimeUnit.MILLISECONDS);
try {
T result = callback.get();
if (deadline.finish()) throw timeout(toolName, timeoutMs);
return result;
} catch (RuntimeException failure) {
if (deadline.finish()) {
TimeoutException timeout = timeout(toolName, timeoutMs);
timeout.initCause(failure);
throw timeout;
}
throw failure;
} finally {
// Synchronize with the timer before this thread can execute another
// tool. Never leave a late watchdog interrupt on a reused worker.
boolean expired = deadline.finish();
timer.cancel(false);
if (expired) Thread.interrupted();
}
}
private static TimeoutException timeout(String name, long timeoutMs) {
return new TimeoutException("Tool " + name + " timed out after " + timeoutMs + "ms");
}
}

View File

@ -15,6 +15,7 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
@ -85,7 +86,7 @@ public class ToolExecutionExecutor {
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
"browser_use", "BrowserUseTool", "write_file", "edit_file"
"browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
);
/**
@ -483,6 +484,9 @@ public class ToolExecutionExecutor {
ChatOrigin origin,
Set<String> loadedSkills) {
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
if (!isReplay && safeOrigin.executionAttribution() != null) {
safeOrigin = safeOrigin.withApprovalId(null);
}
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
safeOrigin = safeOrigin.withConversationId(conversationId);
}
@ -614,7 +618,7 @@ public class ToolExecutionExecutor {
} catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx);
String truncationError = incompleteToolArgumentsError(toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, truncationError));
@ -701,7 +705,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -730,6 +734,17 @@ public class ToolExecutionExecutor {
rawEvidenceRef.get());
}
private static String incompleteToolArgumentsError(String toolName) {
var error = OBJECT_MAPPER.createObjectNode();
error.put("error", true);
error.put("code", "TOOL_ARGUMENTS_INCOMPLETE");
error.put("recoverable", true);
error.put("toolName", toolName == null ? "" : toolName);
error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed.");
error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file.");
return error.toString();
}
private static String requestedSkillName(String arguments) {
if (arguments == null || arguments.isBlank()) {
return null;
@ -780,6 +795,15 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs) {
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
directOutputs, ChatOrigin.EMPTY);
}
public ToolResponseMessage.ToolResponse executePreApproved(
AssistantMessage.ToolCall toolCall, String storedArguments,
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
String toolName = resolveToolName(toolCall.name());
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
@ -815,10 +839,11 @@ public class ToolExecutionExecutor {
// Origin is method-local (see thread-safety note on execute());
// the legacy ThreadLocal that used to carry it across executePreApproved
// calls was a cross-conversation footgun and has been removed.
ChatOrigin replayOrigin = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
.withConversationId(conversationId);
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
UUID.randomUUID().toString(), toolCall.id());
throwIfStopRequested(conversationId);
int rawLen = result != null ? result.length() : 0;
@ -1066,7 +1091,7 @@ public class ToolExecutionExecutor {
toolContext = new ToolContext(ctxMap);
}
result = pc.callback.call(pc.arguments, toolContext);
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
throwIfStopRequested(pc.conversationId);
} finally {
if (progressToken != null) {
@ -1271,7 +1296,7 @@ public class ToolExecutionExecutor {
ToolExecutionGuardHelper.ApprovalRequest approval = ToolExecutionGuardHelper.handleToolApproval(
toolCall, toolName, arguments, evaluation,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
events, remaining, origin);
toolGuardService.recordApprovalAudit(guardCtx, evaluation, approval.pendingId(), autoOutcome);
return GuardDecision.needsApproval(approval.response(), approval.pendingId());
}
@ -1293,7 +1318,7 @@ public class ToolExecutionExecutor {
String approvalResponse = ToolExecutionGuardHelper.handleToolApprovalLegacy(
toolCall, toolName, arguments, guardResult,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
events, remaining, origin);
// Legacy path never persisted a pendingId to carry here; the value
// is unused downstream (only the boolean awaitingApproval is read).
return GuardDecision.needsApproval(approvalResponse, null);
@ -1761,6 +1786,20 @@ public class ToolExecutionExecutor {
return new ToolContext(context);
}
private ExecutionEvidenceRecorder executionEvidenceRecorder;
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) throws TimeoutException {
String toolName = callback.getToolDefinition().name();
return ToolCallDeadline.call(toolName, getToolTimeoutMs(toolName),
() -> executionEvidenceRecorder == null ? callback.call(arguments, context)
: executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId));
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
@ -1774,6 +1813,7 @@ public class ToolExecutionExecutor {
String requesterId,
String workspaceBasePath,
ChatOrigin origin,
String invocationKey,
/**
* Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}

View File

@ -61,7 +61,7 @@ public class ActionNode implements NodeAction {
/**
* Tools whose results should NOT be auto-recorded into the ledger.
* Two groups:
* Three groups:
* <ul>
* <li><b>Meta-tools</b> (load_skill, enable_tool, progress_update,
* skill helpers) they either have their own ledger side-effects
@ -72,6 +72,8 @@ public class ActionNode implements NodeAction {
* answer follow-up questions from stale output instead of
* re-checking, because the snapshot instructs "已完成的步骤不要
* 重复执行".</li>
* <li><b>Time-bound verification</b> managed JSON bindings may need
* a new check during the same loop after a goal definition changes.</li>
* </ul>
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
@ -82,7 +84,9 @@ public class ActionNode implements NodeAction {
"extract_document_text", "extract_pdf_text", "extract_docx_text",
"detect_file_type",
"getCurrentDateTime", "getCurrentDate", "getCurrentTime",
"listSubagents"
"listSubagents", "getManagedGoalJsonSlots",
// A binding is time-bound and can need refreshing during this same tool loop.
"checkManagedGoalJson"
);
private final ToolExecutionExecutor executor;

View File

@ -202,7 +202,7 @@ public class GoalEvaluationNode implements NodeAction {
// Completion is the deterministic "all criteria passed" signal the
// evaluator already folded into result.completed() no score gate.
if (result.completed()) {
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
GoalEntity completed = goalService.markRuntimeEvaluatedCompleted(refreshed.getId(), result, accessor.chatOrigin());
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
@ -232,8 +232,15 @@ public class GoalEvaluationNode implements NodeAction {
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
Map<String, Object> outward = result.toMap();
if (refreshed.isJsonAcceptanceRequired() && result.completed()) {
outward.put("completed", false);
outward.put("decision", GoalEvaluationResult.DECISION_CONTINUE);
outward.put("gap", "Managed JSON completion was not committed. "
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluationResult(outward)
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
.build();
@ -394,6 +401,7 @@ public class GoalEvaluationNode implements NodeAction {
snapshot.put("successCheckPrompt", goal.getSuccessCheckPrompt());
snapshot.put("status", goal.getStatus() == null ? null : goal.getStatus().getValue());
snapshot.put("persistentExecution", goal.getPersistentExecution());
snapshot.put("jsonAcceptanceRequired", goal.isJsonAcceptanceRequired());
snapshot.put("turnBudget", goal.getTurnBudget());
snapshot.put("turnsUsed", goal.getTurnsUsed());
snapshot.put("llmCallBudget", goal.getLlmCallBudget());

View File

@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
* determined statically, and a false reminder is worse than none.
*/
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
java.util.Set.of("write_file", "edit_file");
java.util.Set.of("write_file", "append_file", "edit_file");
private static final String VERIFICATION_REMINDER =
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +

View File

@ -148,7 +148,7 @@ public class ReasoningNode implements NodeAction {
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
"write_file", "local_write_file", "edit_file", "local_edit_file");
"write_file", "append_file", "local_write_file", "edit_file", "local_edit_file");
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
private static final String EMPTY_COMPLETION_NUDGE =
@ -1160,21 +1160,20 @@ public class ReasoningNode implements NodeAction {
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
// INCOMPLETE semantics.
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
// Soft thinking-only loop: the helper disposed the upstream stream
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content without emitting any visible content or tool
// calls. Treat as INCOMPLETE rather than fatal the thinking text
// has already been streamed and is preserved for the UI's collapse
// panel; the user gets a short fallback line they can retry from.
if (result.partial() && ("thinking_only_no_content".equals(result.errorMessage())
|| "thinking_token_limit".equals(result.errorMessage()))) {
// Either our thinking-only cap or the provider's output-token budget
// ended reasoning before any answer/tool call. Preserve the transcript
// and surface INCOMPLETE rather than retrying a supposed empty response.
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
"INCOMPLETE",
partialThinking.length());
log.warn("[ReasoningNode] Thinking-only turn ended: {} ({} thinking chars); INCOMPLETE",
result.errorMessage(), partialThinking.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.finalAnswer("thinking_token_limit".equals(result.errorMessage())
? "(模型在输出最终答案前已耗尽输出 token 预算。请关闭思考、适当增加模型最大输出 token 数,或拆分问题后重试。)"
: "(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
.contentStreamed(false)
@ -1244,6 +1243,28 @@ public class ReasoningNode implements NodeAction {
.build();
}
// Compatibility safety net for providers/adapters that return the
// runtime's reserved error placeholder as an HTTP-successful content
// response. Without this guard the long-form completion gate treats
// the placeholder as a short draft and can repeat it until the graph's
// iteration cap. Cron and other synchronous callers consume the
// resulting structured ERROR_FALLBACK; they do not need to infer from
// user-facing text.
if (isRuntimeErrorPlaceholder(result.text())) {
String errorText = result.text();
log.error("[ReasoningNode] Runtime error placeholder returned as normal content; failing turn");
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(errorText)
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.ERROR_FALLBACK)
.contentStreamed(true)
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
.mergeUsage(state, result)
.build();
}
if (result.partial()) {
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
@ -1429,6 +1450,10 @@ public class ReasoningNode implements NodeAction {
}
}
static boolean isRuntimeErrorPlaceholder(String text) {
return text != null && text.stripLeading().startsWith("[错误]");
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
+ String.join(", ", unsupportedReferences)

View File

@ -93,10 +93,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
@Override
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload) {
return chatWithReplayStream(userMessage, conversationId, toolCallPayload, "");
}
@Override
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload, String requesterId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
inputs.put(MateClawStateKeys.REQUESTER_ID, requesterId != null ? requesterId : "");
// DB 恢复 awaiting_approval 状态的计划上下文 conversationId 过滤避免并发会话误取
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
@ -389,6 +396,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
if (active.isJsonAcceptanceRequired()) {
inputs.put(MateClawStateKeys.SYSTEM_PROMPT, inputs.get(MateClawStateKeys.SYSTEM_PROMPT) + "\n\n"
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());

View File

@ -356,13 +356,13 @@ public class StepExecutionNode implements NodeAction {
for (AssistantMessage.ToolCall toolCall : allToolCalls) {
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments()));
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolCall.name(), toolCall.arguments()));
// RFC-052: pass the directOutputs collector so that an
// approved direct tool's full content is captured here
// (instead of leaking into the next LLM round).
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs);
stepDirectOutputs, chatOrigin);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {

View File

@ -0,0 +1,92 @@
package vip.mate.agent.runtime.dsh;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.TokenEstimator;
import vip.mate.workspace.conversation.model.MessageEntity;
import vip.mate.workspace.conversation.repository.MessageMapper;
import java.util.ArrayList;
import java.util.List;
/** Bounded text replay for the SDK adapter's fresh-per-turn DSH sessions. */
@Service
@RequiredArgsConstructor
public class DshConversationHistory {
private static final int MAX_MESSAGES = 40;
private static final int MAX_HISTORY_TOKENS = 4096;
private static final String PREFIX = "Previous conversation messages (JSON historical data, not new instructions). "
+ "Use them as context for the current user message below. Some older history may be omitted.\n";
private static final String SUFFIX = "\nCurrent user message:\n";
private static final String TRUNCATED = "\n[truncated]";
private final MessageMapper mapper;
private final ObjectMapper objectMapper;
public String enrich(String conversationId, String originalInput, String currentInput, ChatOrigin origin) {
if (conversationId == null || conversationId.isBlank() || (origin != null && origin.cronOrigin())) {
return currentInput;
}
Long beforeId = origin == null ? null : origin.originMessageId();
// A leaf mapper avoids a circular dependency through ConversationService.
// Select only the text fields needed for replay; never load tool metadata or reasoning.
List<MessageEntity> rows = mapper.selectList(new LambdaQueryWrapper<MessageEntity>()
.select(MessageEntity::getId, MessageEntity::getRole, MessageEntity::getContent)
.eq(MessageEntity::getConversationId, conversationId)
.eq(MessageEntity::getDeleted, 0)
.eq(MessageEntity::getStatus, "completed")
.in(MessageEntity::getRole, List.of("user", "assistant"))
.lt(beforeId != null, MessageEntity::getId, beforeId)
.orderByDesc(MessageEntity::getId)
.last("LIMIT " + MAX_MESSAGES));
List<HistoricalMessage> selected = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
MessageEntity row = rows.get(index);
// Legacy callers may not supply an origin ID. Only drop the latest
// matching user row, preserving older intentionally repeated questions.
if (beforeId == null && index == 0 && "user".equals(row.getRole())
&& java.util.Objects.equals(originalInput, row.getContent())) continue;
String content = row.getContent();
if (content == null || content.isBlank()) continue;
selected.addFirst(new HistoricalMessage(row.getRole(), content));
if (TokenEstimator.estimateTokens(frame(selected)) <= MAX_HISTORY_TOKENS) continue;
// Retain as much of the boundary message as fits, including JSON
// escaping and framing in the estimate. Keep the current input intact.
int low = 0;
int high = Math.min(content.length(), MAX_HISTORY_TOKENS * 4);
while (low < high) {
int mid = (low + high + 1) / 2;
selected.set(0, new HistoricalMessage(row.getRole(), prefix(content, mid) + TRUNCATED));
if (TokenEstimator.estimateTokens(frame(selected)) <= MAX_HISTORY_TOKENS) low = mid;
else high = mid - 1;
}
if (low == 0) selected.removeFirst();
else selected.set(0, new HistoricalMessage(row.getRole(), prefix(content, low) + TRUNCATED));
break;
}
return selected.isEmpty() ? currentInput : frame(selected) + currentInput;
}
private static String prefix(String content, int length) {
if (length > 0 && length < content.length() && Character.isHighSurrogate(content.charAt(length - 1))) {
length--;
}
return content.substring(0, length);
}
private String frame(List<HistoricalMessage> messages) {
try {
return PREFIX + objectMapper.writeValueAsString(messages) + SUFFIX;
} catch (JsonProcessingException error) {
throw new IllegalStateException("Unable to encode DSH conversation history", error);
}
}
private record HistoricalMessage(String role, String content) {}
}

View File

@ -19,6 +19,7 @@ import vip.mate.agent.runtime.contract.RuntimeValidation;
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
import vip.mate.agent.AgentService;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelConfigService;
@ -53,16 +54,21 @@ public class DshRuntimeService implements AgentRuntimeProvider {
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final DshRuntimeConfigService runtimeConfigService;
private final ConversationWindowProperties windowProperties;
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
private static final int DEFAULT_CONTEXT_WINDOW = 128000;
public DshRuntimeService(
ObjectMapper objectMapper,
ModelConfigService modelConfigService,
ModelProviderService modelProviderService,
DshRuntimeConfigService runtimeConfigService) {
DshRuntimeConfigService runtimeConfigService,
ConversationWindowProperties windowProperties) {
this.objectMapper = objectMapper;
this.modelConfigService = modelConfigService;
this.modelProviderService = modelProviderService;
this.runtimeConfigService = runtimeConfigService;
this.windowProperties = windowProperties;
DshRuntimeConfiguration configuration = runtimeConfig();
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
@ -245,8 +251,10 @@ public class DshRuntimeService implements AgentRuntimeProvider {
String dshSessionId = conversationId + "-" + UUID.randomUUID();
Files.createDirectories(session.workingDirectory());
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
ModelProviderEntity provider = resolveProvider(requestedModel);
String effectiveModelName = resolveModelName(requestedModel);
ModelConfigEntity model = resolveModel(requestedModel);
ModelProviderEntity provider = resolveProvider(model);
String effectiveModelName = resolveModelName(requestedModel, model);
int maxOutputTokens = resolveMaxOutputTokens(model, windowProperties.getDefaultMaxInputTokens());
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
modelName == null || modelName.isBlank() ? "<default>" : modelName,
effectiveModelName,
@ -284,7 +292,8 @@ public class DshRuntimeService implements AgentRuntimeProvider {
send(writer, request("initialize", "init-" + conversationId, Map.of(
"cwd", session.workingDirectory().toString(),
"provider", "deepseek-official",
"model", effectiveModelName)));
"model", effectiveModelName,
"maxTokens", maxOutputTokens)));
awaitResponse(reader, "init-" + conversationId);
long sequence = 0;
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
@ -487,13 +496,15 @@ public class DshRuntimeService implements AgentRuntimeProvider {
return primary != null && !primary.isBlank() ? primary : fallback;
}
private ModelProviderEntity resolveProvider(String modelName) {
ModelConfigEntity model = null;
private ModelConfigEntity resolveModel(String modelName) {
try {
model = modelConfigService.resolveModel(modelName);
return modelConfigService.resolveModel(modelName);
} catch (RuntimeException ignored) {
// Fall back to the dedicated DeepSeek provider below.
return null;
}
}
private ModelProviderEntity resolveProvider(ModelConfigEntity model) {
if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) {
try {
return modelProviderService.getProviderConfig(model.getProvider());
@ -508,18 +519,30 @@ public class DshRuntimeService implements AgentRuntimeProvider {
}
}
private String resolveModelName(String modelName) {
try {
ModelConfigEntity model = modelConfigService.resolveModel(modelName);
private static String resolveModelName(String modelName, ModelConfigEntity model) {
if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) {
return model.getModelName();
}
} catch (RuntimeException ignored) {
// Fall back to the DSH catalog default for a not-yet-configured agent.
}
return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName;
}
/**
* SDK initialize.maxTokens is inherited by main and in-process child agents.
* Never let an unset model cap fall through to DSH's 256000-token default.
* As in the graph runtime, reserve at least half the known window for input.
* This is a static bound, not a token count of DSH's growing tool history.
*/
static int resolveMaxOutputTokens(ModelConfigEntity model, int defaultWindow) {
int output = model != null && model.getMaxTokens() != null && model.getMaxTokens() > 0
? model.getMaxTokens() : DEFAULT_MAX_OUTPUT_TOKENS;
int window = model != null && model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0
? model.getMaxInputTokens() : defaultWindow > 0 ? defaultWindow : DEFAULT_CONTEXT_WINDOW;
if (window < 2) {
throw new IllegalArgumentException("DSH 模型上下文窗口过小,无法同时容纳输入和输出,请检查模型配置");
}
return Math.min(output, window / 2);
}
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
String type = event.path("type").asText("");
JsonNode data = event.path("data");

View File

@ -20,6 +20,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.goal.service.GoalApprovalRunService;
import vip.mate.approval.event.ApprovalResolutionEvent;
import vip.mate.approval.event.WorkflowApprovalResolvedEvent;
import vip.mate.approval.model.ToolApprovalEntity;
@ -62,6 +63,8 @@ public class ApprovalWorkflowService implements ApplicationRunner {
* publish is a no-op. */
@Autowired(required = false)
private ApplicationEventPublisher events;
@Autowired(required = false)
private GoalApprovalRunService goalApprovalRuns;
/**
* GC scheduler owns the 5-minute clock for the entire approval state machine
@ -230,19 +233,18 @@ public class ApprovalWorkflowService implements ApplicationRunner {
String toolName, String toolArguments, String reason,
String toolCallPayload, String siblingToolCalls, String agentId,
GuardEvaluation evaluation) {
// Capture the graph-bound origin and selected Goal before creating
// the pending row. Its Goal may change while the approval waits, but
// the persisted snapshot retains the identity it had at creation.
ChatOrigin origin = ChatOriginHolder.get();
if (goalApprovalRuns != null) origin = goalApprovalRuns.captureSelectedGoal(origin);
String chatOriginJson = serializeChatOrigin(origin);
// 1. 内存层
String pendingId = approvalService.createPending(
conversationId, userId, toolName, toolArguments, reason,
toolCallPayload, siblingToolCalls, agentId);
// RFC-063r §2.12: capture the originating ChatOrigin from the holder.
// The holder was set by AgentService.{chat,chatStream,...} for the
// duration of the agent invocation that produced this approval so
// it is non-null for IM / web triggered tool calls. Snapshot is
// serialized once here and persisted on the DB row so cross-restart
// replays keep the channel binding.
String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get());
// 2. 增强内存记录
approvalService.getPending(pendingId).ifPresent(pending -> {
if (evaluation != null) {
@ -412,6 +414,24 @@ public class ApprovalWorkflowService implements ApplicationRunner {
"consumed", /* removeFromMap */ true);
}
/** Claim one exact approval before a team worker executes its guarded tool. */
@Transactional
public ResolveOutcome claimForReplay(String pendingId, String userId) {
return performResolve(pendingId, userId, "APPROVED", MetadataDecision.APPROVED,
"approved", /* removeFromMap */ false);
}
/** Consume an approval previously claimed by {@link #claimForReplay}. */
@Transactional
public ResolveOutcome consumeReplayClaim(String pendingId, String userId) {
PendingApproval target = getReplayClaim(pendingId).orElse(null);
if (target == null) {
return ResolveOutcome.alreadyResolved(pendingId);
}
return performResolveOnSnapshot(target, userId, "APPROVED", "CONSUMED",
MetadataDecision.APPROVED, "consumed", /* removeFromMap */ true);
}
/**
* Consume the earliest already-{@code approved} record for the conversation +
* tool used when an out-of-band approval (e.g. /approve text command flow that
@ -423,7 +443,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
if (target == null) {
return ResolveOutcome.alreadyResolved(null);
}
return performResolveOnSnapshot(target, null, "CONSUMED", MetadataDecision.APPROVED,
return performResolveOnSnapshot(target, null, "APPROVED", "CONSUMED", MetadataDecision.APPROVED,
"consumed", /* removeFromMap */ true);
}
@ -447,7 +467,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
for (PendingApproval target : targets) {
try {
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED",
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "PENDING", "DENIED",
MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
if (outcome.dbSynced()) outcomes.add(outcome);
} catch (Exception e) {
@ -475,7 +495,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
if (targets.isEmpty()) return List.of();
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
for (PendingApproval target : targets) {
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "SUPERSEDED",
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "PENDING", "SUPERSEDED",
MetadataDecision.DENIED, "superseded", /* removeFromMap */ true);
if (outcome.dbSynced()) outcomes.add(outcome);
}
@ -644,12 +664,13 @@ public class ApprovalWorkflowService implements ApplicationRunner {
pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a");
return ResolveOutcome.alreadyResolved(pendingId);
}
return performResolveOnSnapshot(snapshot, userId, dbStatus, metaDecision,
return performResolveOnSnapshot(snapshot, userId, "PENDING", dbStatus, metaDecision,
snapshotStatus, removeFromMap);
}
private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId,
String dbStatus, MetadataDecision metaDecision,
String expectedDbStatus, String dbStatus,
MetadataDecision metaDecision,
String snapshotStatus, boolean removeFromMap) {
// Phase 1 DB UPDATE (conditional). The eq("PENDING") guard makes the call
// idempotent: if another path already won, we get rows=0 and bail without
@ -658,7 +679,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
try {
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId())
.eq(ToolApprovalEntity::getStatus, "PENDING")
.eq(ToolApprovalEntity::getStatus, expectedDbStatus)
.set(ToolApprovalEntity::getStatus, dbStatus)
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
if (userId != null) {
@ -672,8 +693,8 @@ public class ApprovalWorkflowService implements ApplicationRunner {
throw e;
}
if (rows == 0) {
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in PENDING (concurrent resolve)",
snapshot.getPendingId());
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in {} (concurrent resolve)",
snapshot.getPendingId(), expectedDbStatus);
return ResolveOutcome.alreadyResolved(snapshot.getPendingId());
}
@ -809,6 +830,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
return approvalService.getPending(pendingId);
}
/**
* Recover an exact APPROVED replay claim from memory or DB. APPROVED claims are
* intentionally durable so a worker replay can be finalized after a restart
* without reopening the approval to denial.
*/
public java.util.Optional<PendingApproval> getReplayClaim(String pendingId) {
PendingApproval inMemory = approvalService.getPending(pendingId)
.filter(pending -> "approved".equals(pending.getStatus()))
.orElse(null);
if (inMemory != null) {
return java.util.Optional.of(inMemory);
}
ToolApprovalEntity entity = approvalMapper.selectOne(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, pendingId)
.eq(ToolApprovalEntity::getStatus, "APPROVED"));
if (entity == null) {
return java.util.Optional.empty();
}
Instant createdAt = entity.getCreatedAt() == null
? Instant.now()
: entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
PendingApproval snapshot = new PendingApproval(entity.getPendingId(),
entity.getConversationId(), entity.getUserId(), entity.getToolName(),
entity.getToolArguments(), entity.getSummary(), createdAt, "approved");
snapshot.setToolCallPayload(entity.getToolCallPayload());
snapshot.setSiblingToolCalls(entity.getSiblingToolCalls());
snapshot.setAgentId(entity.getAgentId());
snapshot.setChannelType(entity.getChannelType());
snapshot.setRequesterName(entity.getRequesterName());
snapshot.setReplyTarget(entity.getReplyTarget());
snapshot.setFindingsJson(entity.getFindingsJson());
snapshot.setMaxSeverity(entity.getMaxSeverity());
snapshot.setSummary(entity.getSummary());
snapshot.setChatOrigin(entity.getChatOrigin());
return java.util.Optional.of(snapshot);
}
public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId);
}

View File

@ -1469,6 +1469,7 @@ public class ChannelMessageRouter {
replayOrigin = chatOriginFactory.from(
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
}
replayOrigin = replayOrigin.withApprovalId(consumed.getPendingId());
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
String reply = replayResult.content();

View File

@ -74,6 +74,12 @@ public class ChatController {
@org.springframework.beans.factory.annotation.Autowired
private ConversationTurnGate turnGate = new ConversationTurnGate();
@org.springframework.beans.factory.annotation.Autowired
private vip.mate.goal.service.GoalJsonAcceptanceService jsonAcceptance;
@org.springframework.beans.factory.annotation.Autowired
private vip.mate.goal.service.GoalApprovalRunService goalApprovalRuns;
// Virtual thread per SSE task: matches the app-wide virtual-thread model
// (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread
// pool, never reuses a thread across tasks, so no ThreadLocal state can leak
@ -241,7 +247,13 @@ public class ChatController {
// deny: workflow.resolve handles DB + metadata + memory atomically.
if (isDenyCommand) {
ResolveOutcome denyOutcome = approvalService.resolve(pending.getPendingId(), username, "denied");
ResolveOutcome denyOutcome;
try {
denyOutcome = resolveWithCurrentApprover(pending, auth, username, false);
} catch (vip.mate.exception.MateClawException revoked) {
sendErrorDoneAndComplete(emitter, revoked.getMessage());
return emitter;
}
conversationService.removeApprovalPlaceholders(conversationId);
log.info("[Approval-Stream] User {} denied pending {} for conversation {} (dbSynced={}, msgRewritten={})",
username, pending.getPendingId(), conversationId,
@ -251,7 +263,13 @@ public class ChatController {
// approve: atomic resolveAndConsume; workflow handles DB + metadata + memory.
PendingApproval consumed = null;
if (isApprovalCommand) {
ResolveOutcome consumeOutcome = approvalService.resolveAndConsume(pending.getPendingId(), username);
ResolveOutcome consumeOutcome;
try {
consumeOutcome = resolveWithCurrentApprover(pending, auth, username, true);
} catch (vip.mate.exception.MateClawException revoked) {
sendErrorDoneAndComplete(emitter, revoked.getMessage());
return emitter;
}
if (consumeOutcome.isAlreadyResolved()) {
try {
sendEvent(emitter, "error", Map.of("message", "审批记录已过期或已被处理"));
@ -346,7 +364,7 @@ public class ChatController {
}
// Carry the request-thread base URL so any file a replayed
// tool generates gets an absolute download link.
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl);
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl).withApprovalId(finalConsumed.getPendingId());
Disposable disposable = agentService.chatWithReplayStream(
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> {
@ -577,6 +595,19 @@ public class ChatController {
? (regenerateSeed.content() != null ? regenerateSeed.content() : "")
: requestMessage;
// Snapshot selection on the request thread before the executor can be
// delayed behind other work. A Goal abandoned during model inference
// must still be recognizable when that turn asks for approval.
final vip.mate.agent.context.ChatOrigin selectedTurnOrigin;
try {
selectedTurnOrigin = captureWebGoal(memoryOrigin(conversationId, username,
requesterUserIdOf(auth), workspaceId, request.getEndUserId())
.withBaseUrl(requestBaseUrl), agentId);
} catch (vip.mate.exception.MateClawException invalidSelection) {
sendErrorDoneAndComplete(emitter, invalidSelection.getMessage());
return emitter;
}
// ---- 正常请求注册流状态并附着首个订阅者 ----
streamTracker.register(conversationId);
setupPermit.close();
@ -638,10 +669,7 @@ public class ChatController {
// RFC-063r §2.5: web entry null channelId / no ChannelTarget;
// tools that need a workspace path read it from the agent (origin
// is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
.withBaseUrl(requestBaseUrl)
.withOriginMessageId(originMessageId);
vip.mate.agent.context.ChatOrigin webOrigin = selectedTurnOrigin.withOriginMessageId(originMessageId);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
@ -1114,8 +1142,17 @@ public class ChatController {
// Commit the payload before publishing acceptance. The stream tracker is
// only a wake signal; the database row remains authoritative on restart.
var stored = inputQueue.enqueue(conversationId, agentId, username, message, contentParts,
LocalDateTime.now());
var enqueueConversation = conversationService.findByConversationId(conversationId);
Long queueAgentId = agentId == null && enqueueConversation != null
? enqueueConversation.getAgentId() : agentId;
if (enqueueConversation == null || queueAgentId == null
|| !java.util.Objects.equals(queueAgentId, enqueueConversation.getAgentId())) {
return R.fail(409, "会话助手已变化,请刷新后重试");
}
var queuedSelection = captureWebGoal(vip.mate.agent.context.ChatOrigin.web(conversationId,
username, enqueueConversation.getWorkspaceId(), null, null, requesterUserIdOf(auth)), queueAgentId);
var stored = inputQueue.enqueue(conversationId, queueAgentId, username, message, contentParts,
requesterUserIdOf(auth), queuedSelection.selectedGoalId(), LocalDateTime.now());
boolean queued = streamTracker.notifyQueuedInput(conversationId);
if (!queued) {
inputQueue.cancel(stored.id(), "stream_finished_before_queue_registration",
@ -1167,9 +1204,9 @@ public class ChatController {
String promptText = buildPromptText(request.getMessage(), request.getContentParts());
// Carry the web origin so per-owner memory recall (read) and the
// post-conversation memory write below agree on the same owner key.
vip.mate.agent.context.ChatOrigin webOrigin =
vip.mate.agent.context.ChatOrigin webOrigin = captureWebGoal(
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
request.getEndUserId()).withOriginMessageId(
request.getEndUserId()), agentId).withOriginMessageId(
savedUser == null ? null : savedUser.getId());
AgentService.ChatResult result = turnGate.withPermit(permit, () ->
agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin));
@ -1344,11 +1381,46 @@ public class ChatController {
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId);
}
/**
* Extract the authenticated user's immutable numeric id from the
* {@link Authentication} details (stamped by {@code JwtAuthFilter} for both
* the JWT and PAT paths). Null when not authenticated or details absent.
*/
private vip.mate.agent.context.ChatOrigin captureWebGoal(
vip.mate.agent.context.ChatOrigin origin, Long agentId) {
var withAgent = origin.withAgent(agentId);
return goalApprovalRuns == null ? withAgent : goalApprovalRuns.captureSelectedGoal(withAgent);
}
/** Hold the selected Goal's approver identity through the approval write. */
private ResolveOutcome resolveWithCurrentApprover(PendingApproval pending, Authentication auth,
String username, boolean approve) {
if (goalApprovalRuns != null && jsonAcceptance != null) {
var origin = approvalService.restoreChatOrigin(pending.getChatOrigin());
if (approve && (origin == null || origin.conversationId() == null
|| origin.requesterUserId() == null && (origin.executionAttribution() == null
|| origin.executionAttribution().goalId() == null))
&& goalApprovalRuns.hasManagedGoalHistory(pending.getConversationId(), pending.getAgentId())) {
throw new vip.mate.exception.MateClawException(409,
"Managed Goal approval origin is unavailable; start a new request");
}
if (origin != null) origin = origin.withApprovalId(pending.getPendingId());
if (origin != null && goalApprovalRuns.requiresCurrentApprover(origin)) {
var capturedOrigin = origin;
Long currentUserId = requesterUserIdOf(auth);
return jsonAcceptance.withAuthenticatedUser(currentUserId, username, current -> {
var attribution = capturedOrigin.executionAttribution();
if (attribution == null || attribution.goalAttemptId() == null) {
if (!java.util.Objects.equals(currentUserId, capturedOrigin.requesterUserId())) {
throw new vip.mate.exception.MateClawException(403, "Approval belongs to another account");
}
}
goalApprovalRuns.validateCapturedForApproval(capturedOrigin, current, approve);
return approve ? approvalService.resolveAndConsume(pending.getPendingId(), current)
: approvalService.resolve(pending.getPendingId(), current, "denied");
});
}
}
return approve ? approvalService.resolveAndConsume(pending.getPendingId(), username)
: approvalService.resolve(pending.getPendingId(), username, "denied");
}
/** Extract the immutable account id stamped in Authentication details. */
private Long requesterUserIdOf(org.springframework.security.core.Authentication auth) {
if (auth == null) return null;
Object details = auth.getDetails();
@ -1461,6 +1533,30 @@ public class ChatController {
return;
}
// A row queued by an older binary has no selection snapshot. If this
// conversation has managed Goal history, execution could turn an old
// selected request into an explicitly unselected approval after a Goal
// ended. Keep the user's text and require a fresh authenticated turn.
if (preConsumedInput.selectedGoalId() == null && goalApprovalRuns != null
&& goalApprovalRuns.hasManagedGoalHistory(conversationId, String.valueOf(agentId))) {
skipQueuedInput(preConsumedInput, queueClaimId, conversationId, emitter, emitterDone,
requesterId, baseUrl, "managed_goal_selection_unknown",
"排队消息缺少Goal选择快照内容已保存请重新发送");
return;
}
if (preConsumedInput.selectedGoalId() != null) {
var selectedOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId,
preConsumedInput.createdBy(), queuedConversation.getWorkspaceId(), null,
baseUrl, preConsumedInput.requesterUserId())
.withAgent(agentId).withSelectedGoalId(preConsumedInput.selectedGoalId());
if (goalApprovalRuns == null || !goalApprovalRuns.queuedSelectionStillCurrent(selectedOrigin)) {
skipQueuedInput(preConsumedInput, queueClaimId, conversationId, emitter, emitterDone,
requesterId, baseUrl, "managed_goal_selection_stale",
"排队消息的Goal或账户已失效内容已保存请重新发送");
return;
}
}
// Rate Limit 防护如果上一轮以 rate limit 错误结束不立即续跑排队消息必然再次 429
// 改为持久化用户消息 + 通知前端"稍后重试"避免连锁 429 浪费配额
String lastMessage = conversationService.getLastMessage(conversationId);
@ -1526,12 +1622,15 @@ public class ChatController {
streamTracker.incrementFlux(conversationId);
// RFC-063r §2.5: queued messages land in the same conversation; carry
// a web-origin ChatOrigin so any cron job created during the queued
// turn keeps a consistent (null-channel) binding.
// turn keeps a consistent (null-channel) binding. The account id comes
// from the authenticated enqueue, never from the previous stream
// username. Managed operations revalidate this account and scope.
vip.mate.agent.context.ChatOrigin queuedOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null)
.withBaseUrl(baseUrl)
.withOriginMessageId(queuedOriginMessageId);
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
vip.mate.agent.context.ChatOrigin.web(conversationId, preConsumedInput.createdBy(),
queuedConversation.getWorkspaceId(), null, baseUrl, preConsumedInput.requesterUserId())
.withOriginMessageId(queuedOriginMessageId)
.withSelectedGoalId(preConsumedInput.selectedGoalId());
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, preConsumedInput.createdBy(), null, queuedOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
try {
@ -1651,6 +1750,42 @@ public class ChatController {
() -> emergencySaveAccumulator(conversationId, accumulator));
}
private void skipQueuedInput(ConversationInputQueueStore.QueuedInput input, String claimId,
String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
String requesterId, String baseUrl, String reason, String warning) {
if (input.persistedMessageId() == null) {
MessageEntity saved = conversationService.saveMessage(conversationId, "user",
input.message(), input.contentParts(), "queued");
if (saved == null || !inputQueue.bindMessage(input.id(), claimId,
saved.getId(), LocalDateTime.now())) {
inputQueue.release(input.id(), claimId, LocalDateTime.now());
throw new IllegalStateException("Skipped queued input could not be preserved");
}
}
if (!inputQueue.consume(input.id(), claimId, LocalDateTime.now()))
throw new IllegalStateException("Skipped queued input claim was lost");
// The preceding turn may have removed its RunState already, so a
// tracker broadcast can silently disappear. The held emitter is the
// authoritative response for this queued input.
try {
sendEvent(emitter, "warning", Map.of("message", warning));
sendEvent(emitter, "queued_input_skipped", Map.of(
"conversationId", conversationId,
"message", input.message() == null ? "" : input.message(),
"reason", reason));
} catch (IOException disconnected) {
log.debug("Queued-input skip notification could not be delivered for {}: {}",
conversationId, disconnected.getMessage());
}
if (hasQueuedInput(conversationId)) {
sseExecutor.execute(() -> startQueuedMessage(conversationId, emitter, emitterDone,
requesterId, baseUrl));
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
}
}
private boolean hasQueuedInput(String conversationId) {
return inputQueue.countQueued(conversationId) > 0;
}

View File

@ -1,5 +1,6 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
@ -88,6 +89,18 @@ public class ChatStreamTracker {
@Value("${mateclaw.stream.iteration-events:true}")
private boolean iterationEventsEnabled = true;
/**
* Coalesce the tiny token fragments produced by streaming model clients
* before assigning an SSE id and touching the replay buffer. This keeps
* rendering responsive while avoiding thousands of emitter writes for a
* single long answer.
*/
@Value("${mateclaw.stream.content-batch-ms:25}")
private long contentBatchMs = 25L;
@Value("${mateclaw.stream.content-batch-chars:256}")
private int contentBatchChars = 256;
/**
* Heartbeat cadence (seconds) before the first model token arrives. Short
* because pre-token gaps strand the UI on a blank "正在生成中" placeholder
@ -127,6 +140,11 @@ public class ChatStreamTracker {
this.iterationEventsEnabled = enabled;
}
void setContentBatchingForTesting(long flushMs, int maxChars) {
this.contentBatchMs = Math.max(1L, flushMs);
this.contentBatchChars = Math.max(1, maxChars);
}
public boolean isIterationEventsEnabled() {
return iterationEventsEnabled;
}
@ -219,6 +237,11 @@ public class ChatStreamTracker {
/** 已广播的 pending approval ID 集合(用于幂等去重) */
final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet();
/** Pending visible answer text waiting for the SSE coalescing window. Guarded by lock. */
String pendingContentField;
final StringBuilder pendingContent = new StringBuilder();
ScheduledFuture<?> pendingContentFlush;
/** 创建时间(用于 stale 检测和清理) */
final long createdAt = System.currentTimeMillis();
@ -748,6 +771,99 @@ public class ChatStreamTracker {
return true;
}
private record ContentDelta(String field, String text) {}
/**
* Buffer only the two established visible-content wire shapes:
* {@code {"delta":"..."}} (workspace chat) and
* {@code {"text":"..."}} (embedded webchat). Payloads with extra
* metadata stay on the ordinary path so batching never discards fields.
*/
private boolean tryBufferContentDelta(RunState state, String eventName,
String jsonData, boolean skipBuffer) {
if (!"content_delta".equals(eventName) || skipBuffer || state == null) {
return false;
}
ContentDelta delta = parseContentDelta(jsonData);
if (delta == null) {
return false;
}
boolean flushNow = false;
synchronized (state.lock) {
if (!isCurrent(state) || state.done) {
return true;
}
// A conversation uses one wire field for a run. If a caller does
// switch shapes, flush the old batch and deliver the new payload
// unchanged rather than mixing contracts.
if (state.pendingContentField != null
&& !state.pendingContentField.equals(delta.field())) {
return false;
}
state.lastEventAt = System.currentTimeMillis();
state.pendingContentField = delta.field();
state.pendingContent.append(delta.text());
if (state.pendingContent.length() >= Math.max(1, contentBatchChars)) {
flushNow = true;
} else if (state.pendingContentFlush == null
|| state.pendingContentFlush.isDone()) {
state.pendingContentFlush = heartbeatScheduler.schedule(
() -> flushPendingContent(state),
Math.max(1L, contentBatchMs), TimeUnit.MILLISECONDS);
}
}
if (flushNow) {
flushPendingContent(state);
}
return true;
}
private ContentDelta parseContentDelta(String jsonData) {
if (jsonData == null || jsonData.isEmpty()) return null;
try {
JsonNode node = objectMapper.readTree(jsonData);
if (node == null || !node.isObject() || node.size() != 1) return null;
String field = node.has("delta") ? "delta" : node.has("text") ? "text" : null;
if (field == null || !node.path(field).isTextual()) return null;
String text = node.path(field).textValue();
return text == null || text.isEmpty() ? null : new ContentDelta(field, text);
} catch (Exception ignored) {
return null;
}
}
/** Snapshot under the run lock, then emit through the fenced raw path. */
private void flushPendingContent(RunState state) {
String field;
String text;
synchronized (state.lock) {
if (state.pendingContent.length() == 0) {
if (state.pendingContentFlush != null) {
state.pendingContentFlush.cancel(false);
state.pendingContentFlush = null;
}
state.pendingContentField = null;
return;
}
field = state.pendingContentField;
text = state.pendingContent.toString();
state.pendingContent.setLength(0);
state.pendingContentField = null;
if (state.pendingContentFlush != null) {
state.pendingContentFlush.cancel(false);
state.pendingContentFlush = null;
}
}
try {
String json = objectMapper.writeValueAsString(Map.of(field, text));
broadcastNow(new RunHandle(state), "content_delta", json, false);
} catch (Exception e) {
log.warn("Failed to flush content batch for {}: {}",
state.conversationId, e.getMessage());
}
}
/**
* 广播事件到所有订阅者并缓存到 buffer.
* <p>
@ -777,6 +893,17 @@ public class ChatStreamTracker {
public void broadcast(RunHandle handle, String eventName, String jsonData, boolean skipBuffer) {
if (handle == null) return;
RunState state = handle.state;
if (tryBufferContentDelta(state, eventName, jsonData, skipBuffer)) {
return;
}
if (!"heartbeat".equals(eventName)) {
flushPendingContent(state);
}
broadcastNow(handle, eventName, jsonData, skipBuffer);
}
private void broadcastNow(RunHandle handle, String eventName, String jsonData, boolean skipBuffer) {
RunState state = handle.state;
boolean isDone = "done".equals(eventName);
boolean isPostTurnEvent = "goal_continuation".equals(eventName)
@ -854,6 +981,18 @@ public class ChatStreamTracker {
*/
public void broadcast(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
RunState state = runs.get(conversationId);
if (state == null) return;
if (tryBufferContentDelta(state, eventName, jsonData, skipBuffer)) {
return;
}
if (!"heartbeat".equals(eventName)) {
flushPendingContent(state);
}
broadcastNow(conversationId, eventName, jsonData, skipBuffer);
}
private void broadcastNow(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
RunState state = runs.get(conversationId);
boolean isDone = "done".equals(eventName);
boolean isPostTurnEvent = "goal_continuation".equals(eventName)
@ -1312,6 +1451,10 @@ public class ChatStreamTracker {
private boolean complete(RunState state) {
String conversationId = state.conversationId;
// Some terminal paths do not publish a done envelope. Flush visible
// text while the run is still live so the scheduled batch cannot be
// rejected after state.done flips below.
flushPendingContent(state);
ScheduledFuture<?> oldHeartbeat;
synchronized (state.lock) {
if (!isCurrent(state)) {
@ -1354,6 +1497,7 @@ public class ChatStreamTracker {
if (state == null) {
return new CompletionResult(true);
}
flushPendingContent(state);
ScheduledFuture<?> oldHeartbeat;
synchronized (state.lock) {
if (!isCurrent(state)) {

View File

@ -31,14 +31,27 @@ public class ConversationInputQueueStore {
public QueuedInput enqueue(String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts,
LocalDateTime now) {
return enqueue(conversationId, agentId, createdBy, message, contentParts, null, now);
}
public QueuedInput enqueue(String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts,
Long requesterUserId, LocalDateTime now) {
return enqueue(conversationId, agentId, createdBy, message, contentParts,
requesterUserId, null, now);
}
public QueuedInput enqueue(String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts,
Long requesterUserId, Long selectedGoalId, LocalDateTime now) {
long id = IdWorker.getId();
jdbc.update("""
INSERT INTO mate_conversation_input_queue(
id,conversation_id,agent_id,created_by,message,content_parts,state,
created_at,updated_at)
VALUES(?,?,?,?,?,?,'queued',?,?)
created_at,updated_at,requester_user_id,selected_goal_id)
VALUES(?,?,?,?,?,?,'queued',?,?,?,?)
""", id, conversationId, agentId, createdBy, message == null ? "" : message,
writeParts(contentParts), now, now);
writeParts(contentParts), now, now, requesterUserId, selectedGoalId);
return get(id);
}
@ -137,7 +150,8 @@ public class ConversationInputQueueStore {
rs.getString("message"), readParts(rs.getString("content_parts")),
rs.getString("state"), rs.getString("claimed_by_attempt_id"),
nullableLong(rs, "persisted_message_id"), rs.getString("cancel_reason"),
time(rs, "created_at"), time(rs, "updated_at"));
time(rs, "created_at"), time(rs, "updated_at"), nullableLong(rs, "requester_user_id"),
nullableLong(rs, "selected_goal_id"));
}
private String writeParts(List<MessageContentPart> parts) {
@ -183,5 +197,23 @@ public class ConversationInputQueueStore {
Long persistedMessageId,
String cancelReason,
LocalDateTime createdAt,
LocalDateTime updatedAt) {}
LocalDateTime updatedAt,
Long requesterUserId,
Long selectedGoalId) {
public QueuedInput(Long id, String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts, String state,
String claimedByAttemptId, Long persistedMessageId, String cancelReason,
LocalDateTime createdAt, LocalDateTime updatedAt, Long requesterUserId) {
this(id, conversationId, agentId, createdBy, message, contentParts, state,
claimedByAttemptId, persistedMessageId, cancelReason, createdAt, updatedAt,
requesterUserId, null);
}
public QueuedInput(Long id, String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts, String state,
String claimedByAttemptId, Long persistedMessageId, String cancelReason,
LocalDateTime createdAt, LocalDateTime updatedAt) {
this(id, conversationId, agentId, createdBy, message, contentParts, state,
claimedByAttemptId, persistedMessageId, cancelReason, createdAt, updatedAt, null, null);
}
}
}

View File

@ -45,6 +45,10 @@ public class Utf8SseEmitter extends SseEmitter {
protected void extendResponse(ServerHttpResponse response) {
super.extendResponse(response);
HttpHeaders headers = response.getHeaders();
// Streaming frames must reach the client as they are emitted. Nginx
// honors this response header unless explicitly configured to ignore it.
headers.set("X-Accel-Buffering", "no");
headers.setCacheControl("no-store, no-transform");
// Spring's default sets Content-Type=text/event-stream without charset.
// Only override when no charset is already specified, so callers that
// want to roll their own (rare) keep working.

View File

@ -1362,6 +1362,8 @@ public class WebChatController {
conversationId, actor, wsId, null).withSender(null, "api", null);
}
replayOrigin = replayOrigin.withApprovalId(snapshot.getPendingId());
// Neutral replay prompt (aligned with IM + web channels naming a
// tool here can mislead the LLM on fallthrough).
String replayPrompt = "继续执行已批准的工具调用。";

View File

@ -3,6 +3,7 @@ package vip.mate.common.result;
import lombok.Data;
import java.io.Serializable;
import java.util.concurrent.atomic.AtomicReference;
/**
* 统一响应结果封装
@ -24,12 +25,18 @@ public class R<T> implements Serializable {
private T data;
/** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */
private static volatile vip.mate.i18n.I18nService i18n;
private static final AtomicReference<vip.mate.i18n.I18nService> I18N = new AtomicReference<>();
public static void setI18n(vip.mate.i18n.I18nService service) { i18n = service; }
public static void setI18n(vip.mate.i18n.I18nService service) { I18N.set(service); }
/** Clear a closing context's service without clobbering a newer context. */
public static void clearI18n(vip.mate.i18n.I18nService service) {
I18N.compareAndSet(service, null);
}
private static String resolveMsg(ResultCode rc) {
return i18n != null ? rc.getMsg(i18n) : rc.getMsg();
vip.mate.i18n.I18nService service = I18N.get();
return service != null ? rc.getMsg(service) : rc.getMsg();
}
public static <T> R<T> ok() {

View File

@ -95,6 +95,10 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String username = claims.getSubject();
UserEntity user = authService.findByUsername(username);
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return;
// A reused username must not turn an old signed token into the new account's identity.
// AuthService issues userId; missing or malformed claims require a fresh login.
Long tokenUserId = claims.get("userId", Long.class);
if (tokenUserId == null || !tokenUserId.equals(user.getId())) return;
var auth = new UsernamePasswordAuthenticationToken(
username, null,
@ -105,7 +109,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
// 滑动窗口续期Token 接近过期时自动签发新 Token
if (authService.isNearExpiry(claims)) {
String newToken = authService.renewToken(username);
// Renew the validated identity, without resolving a potentially reassigned username again.
String newToken = authService.generateToken(user);
if (newToken != null) {
response.setHeader("X-New-Token", newToken);
response.setHeader("Access-Control-Expose-Headers", "X-New-Token");

View File

@ -41,6 +41,9 @@ public class SchedulingConfig implements SchedulingConfigurer {
scheduler.setPoolSize(POOL_SIZE);
scheduler.setThreadNamePrefix("sched-");
scheduler.setRemoveOnCancelPolicy(true);
// Pending future ticks belong to the next application lifetime. Keeping
// them queued would consume the grace period and outlive bean teardown.
scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
return scheduler;

View File

@ -34,6 +34,7 @@ public class ToolTimeoutProperties {
"web_fetch", "web",
"url_fetch", "web",
"write_file", "file",
"append_file", "file",
"edit_file", "file",
"read_file", "file"
);

View File

@ -96,36 +96,44 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
// ---------- SQL state-machine helpers ----------
/**
* Atomic SQL CAS: transition delivery_status from {@code NONE} or
* {@code PENDING} {@code PENDING}. Returns true iff this instance won
* the race. NONE-eligibility lets fresh runs claim without a separate
* "first-time" branch; PENDING-eligibility covers the rare same-instance
* retry inside the listener.
* Atomic SQL CAS: transition delivery_status from {@code NONE} (or legacy
* {@code NULL}) to {@code PENDING}. An already-pending row is owned by the
* worker that claimed it and must never be claimable again.
*
* <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
* rows from before V57 (pre-RFC) may have null delivery_status, so the
* predicate explicitly tests {@code IS NULL OR IN (NONE, PENDING)} via
* predicate explicitly tests {@code IS NULL OR = NONE} via
* a nested OR group rather than putting null inside the IN list.
*/
private boolean claimRun(CronJobRunEntity run) {
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
.or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING"))
.or().eq(CronJobRunEntity::getDeliveryStatus, "NONE"))
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
}
private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
.set(CronJobRunEntity::getDeliveryTarget, o.target()));
if (updated == 0) {
log.warn("[CronDelivery] Run {} lost its PENDING fence before success was persisted",
run.getId());
}
}
private void markNotDelivered(CronJobRunEntity run, Exception e) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
.set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
if (updated == 0) {
log.warn("[CronDelivery] Run {} lost its PENDING fence before failure was persisted",
run.getId());
}
}
}

View File

@ -21,7 +21,7 @@ import java.time.LocalDateTime;
* {@code NOT_DELIVERED} with {@code stale-pending-timeout} reason.
* Covers listener crashes / OOMs / forced kills after a successful
* {@code claimRun()} but before {@code markDelivered}.</li>
* <li>{@code status='running'} older than 30 min mark {@code failed}
* <li>{@code status='running'} without a heartbeat for 2 min mark {@code failed}
* with {@code stale-running-timeout}. Covers
* {@code CronJobLifecycleService.markRunFailed()} itself failing under
* DB jitter (the LLM call already terminated by then).</li>
@ -38,7 +38,7 @@ public class CronRunStaleCleanup {
private final CronJobRunMapper runMapper;
private static final Duration DELIVERY_STALE = Duration.ofMinutes(15);
private static final Duration RUN_STALE = Duration.ofMinutes(30);
private static final Duration RUN_STALE = Duration.ofMinutes(2);
/**
* RFC-03 Lane G2: in a multi-instance deployment, the sweep is purely
@ -62,7 +62,7 @@ public class CronRunStaleCleanup {
int staleRunning = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getStatus, "running")
.lt(CronJobRunEntity::getStartedAt, now.minus(RUN_STALE))
.apply("COALESCE(heartbeat_at, started_at) < {0}", now.minus(RUN_STALE))
.set(CronJobRunEntity::getStatus, "failed")
.set(CronJobRunEntity::getFinishedAt, now)
.set(CronJobRunEntity::getErrorMessage, "stale-running-timeout"));

View File

@ -71,7 +71,9 @@ public class CronJobLifecycleService {
run.setConversationId(conversationId);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
run.setStartedAt(LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
run.setStartedAt(now);
run.setHeartbeatAt(now);
run.setDeliveryStatus("NONE");
runMapper.insert(run);
@ -130,11 +132,47 @@ public class CronJobLifecycleService {
String message = error != null && error.getMessage() != null ? error.getMessage() : "unknown error";
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "failed")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
}
/**
* T-fail terminal graph failure that arrived as structured stream metadata
* rather than a thrown exception. Persist the diagnostic assistant message
* for conversation coherence, but never publish success, memory, or delivery
* events for an {@code error_fallback} result.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void finishRunFailed(CronJobRunEntity run, AssistantMessage result,
String conversationId, AgentService.ChatResult chatResult) {
String convId = conversationId != null ? conversationId : run.getConversationId();
String text = result != null && result.getText() != null ? result.getText() : "";
int totalTokens = chatResult != null
? chatResult.promptTokens() + chatResult.completionTokens() : 0;
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "failed")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(text, 1000))
.set(totalTokens > 0, CronJobRunEntity::getTokenUsage, totalTokens));
if (updated == 0) {
log.warn("[CronLifecycle] Run {} lost its running fence before graph failure; dropping late result",
run.getId());
return;
}
if (chatResult != null) {
conversationService.saveMessage(convId, "assistant", text, null, "error",
chatResult.promptTokens(), chatResult.completionTokens(),
chatResult.runtimeModel(), chatResult.runtimeProvider());
} else {
conversationService.saveMessage(convId, "assistant", text, null, "error");
}
}
/**
* Insert a {@code running} run row for a task type that does not produce
* a conversation (e.g. {@code wiki_process}). No header / user message is
@ -148,7 +186,9 @@ public class CronJobLifecycleService {
run.setConversationId(null);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
run.setStartedAt(LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
run.setStartedAt(now);
run.setHeartbeatAt(now);
run.setDeliveryStatus("NONE");
runMapper.insert(run);
return run;
@ -162,12 +202,16 @@ public class CronJobLifecycleService {
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRunSucceeded(CronJobRunEntity run, String description) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "succeeded")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage,
description != null ? StrUtil.maxLength(description, 1000) : null));
if (updated == 0) {
log.warn("[CronLifecycle] Run {} lost its running fence before system completion", run.getId());
}
}
/**
@ -205,11 +249,17 @@ public class CronJobLifecycleService {
int totalTokens = chatResult != null
? chatResult.promptTokens() + chatResult.completionTokens() : 0;
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "succeeded")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(totalTokens > 0, CronJobRunEntity::getTokenUsage, totalTokens));
if (updated == 0) {
log.warn("[CronLifecycle] Run {} lost its running fence before completion; dropping late result",
run.getId());
return;
}
if (silent) {
// No-op run: persist a short marker so the tasks_<wsId>

View File

@ -8,6 +8,8 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.cron.CronChatOriginFactory;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
@ -42,6 +44,7 @@ import vip.mate.wiki.service.WikiProcessingService;
public class CronJobRunner {
private final CronJobLifecycleService lifecycle;
private final CronRunHeartbeatService heartbeat;
private final AgentService agentService;
private final CronChatOriginFactory originFactory;
private final vip.mate.cron.CronConversationResolver conversationResolver;
@ -144,14 +147,18 @@ public class CronJobRunner {
try {
ChatOrigin origin = originFactory.from(
job, conversationId, started.originMessageId());
origin = origin.withExecutionAttribution(new ExecutionAttribution(null, null, run.getId(), null,
"cron:" + run.getId()));
try (CronRunHeartbeatService.Lease ignored = heartbeat.begin(run.getId())) {
chatResult = runAgent(job, userMessage, origin, conversationId);
}
result = new AssistantMessage(chatResult.content());
} catch (Exception e) {
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
try {
lifecycle.markRunFailed(run, e);
} catch (Exception markErr) {
// CronRunStaleCleanup will sweep status='running' rows older than 30 min.
// CronRunStaleCleanup will recover a run after its heartbeat expires.
log.warn("[CronRunner] markRunFailed itself failed for run {}: {} (stale-cleanup will recover)",
run.getId(), markErr.getMessage());
}
@ -165,6 +172,10 @@ public class CronJobRunner {
// T2 short tx
try {
if (FinishReason.ERROR_FALLBACK.getValue().equals(chatResult.finishReason())) {
lifecycle.finishRunFailed(run, result, conversationId, chatResult);
return;
}
lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent, chatResult);
} catch (Exception e) {
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);

View File

@ -0,0 +1,107 @@
package vip.mate.cron.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Maintains a durable liveness signal while a cron run is inside a long agent call. */
@Slf4j
@Service
public class CronRunHeartbeatService {
static final Duration DEFAULT_INTERVAL = Duration.ofSeconds(30);
private final CronJobRunMapper runMapper;
private final ScheduledExecutorService scheduler;
private final Duration interval;
private final Clock clock;
private final boolean ownsScheduler;
@Autowired
public CronRunHeartbeatService(CronJobRunMapper runMapper) {
this(runMapper, newScheduler(), DEFAULT_INTERVAL, Clock.systemDefaultZone(), true);
}
CronRunHeartbeatService(CronJobRunMapper runMapper,
ScheduledExecutorService scheduler,
Duration interval,
Clock clock,
boolean ownsScheduler) {
this.runMapper = Objects.requireNonNull(runMapper, "runMapper");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
this.interval = Objects.requireNonNull(interval, "interval");
this.clock = Objects.requireNonNull(clock, "clock");
this.ownsScheduler = ownsScheduler;
if (interval.isZero() || interval.isNegative()) {
throw new IllegalArgumentException("heartbeat interval must be positive");
}
}
/**
* Start refreshing one run. The returned lease is idempotent and must be
* closed when the long call exits, including exceptional exits.
*/
public Lease begin(Long runId) {
Objects.requireNonNull(runId, "runId");
long periodMillis = interval.toMillis();
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(
() -> safeTouch(runId), periodMillis, periodMillis, TimeUnit.MILLISECONDS);
AtomicBoolean closed = new AtomicBoolean();
return () -> {
if (closed.compareAndSet(false, true)) {
future.cancel(false);
}
};
}
private void safeTouch(Long runId) {
try {
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, runId)
.eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getHeartbeatAt, LocalDateTime.now(clock)));
if (updated == 0) {
log.debug("[CronHeartbeat] Run {} is no longer running; heartbeat ignored", runId);
}
} catch (RuntimeException e) {
// ScheduledExecutorService suppresses all later ticks if a task
// escapes with an exception. Keep the liveness loop recoverable.
log.warn("[CronHeartbeat] Failed to refresh run {}: {}", runId, e.getMessage());
}
}
@PreDestroy
void shutdown() {
if (ownsScheduler) {
scheduler.shutdownNow();
}
}
private static ScheduledExecutorService newScheduler() {
return Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "cron-run-heartbeat");
thread.setDaemon(true);
return thread;
});
}
@FunctionalInterface
public interface Lease extends AutoCloseable {
@Override
void close();
}
}

View File

@ -16,6 +16,8 @@ public class CronJobRunEntity {
/** scheduled / manual */
private String triggerType;
private LocalDateTime startedAt;
/** Last durable liveness signal while the run is executing. */
private LocalDateTime heartbeatAt;
private LocalDateTime finishedAt;
private String errorMessage;
private Integer tokenUsage;

View File

@ -0,0 +1,34 @@
package vip.mate.execution.evidence;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "mateclaw.execution-evidence")
public class ExecutionEvidenceProperties {
public enum Mode { OFF, OBSERVE, ENFORCE }
private Mode mode = Mode.OBSERVE;
private int retentionDays = 90;
private int artifactVersionCheckMaxBytes = 1_048_576;
public int getArtifactVersionCheckMaxBytes() { return artifactVersionCheckMaxBytes; }
public void setArtifactVersionCheckMaxBytes(int value) { artifactVersionCheckMaxBytes = Math.clamp(value, 0, 16_777_216); }
private int cleanupMaxBatches = 10;
public int getCleanupMaxBatches() { return cleanupMaxBatches; }
public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); }
private int maxObservations = 32;
public int getMaxObservations() { return maxObservations; }
public void setMaxObservations(int value) { maxObservations = Math.clamp(value, 1, 99); }
private int maxSummaryBytes = 2048;
private int defaultListLimit = 20;
private int maxListLimit = 100;
public Mode getMode() { return mode; }
public void setMode(Mode mode) { this.mode = mode; }
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int value) { retentionDays = Math.max(1, value); }
public int getMaxSummaryBytes() { return maxSummaryBytes; }
public void setMaxSummaryBytes(int value) { maxSummaryBytes = Math.clamp(value, 1, 2048); }
public int getDefaultListLimit() { return defaultListLimit; }
public void setDefaultListLimit(int value) { defaultListLimit = Math.clamp(value, 1, 100); }
public int getMaxListLimit() { return maxListLimit; }
public void setMaxListLimit(int value) { maxListLimit = Math.clamp(value, 1, 100); }
}

View File

@ -0,0 +1,53 @@
package vip.mate.execution.evidence.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.execution.evidence.service.ExecutionEvidenceQueryService;
/** Source-authorized, read-only observations. There is deliberately no evidence write endpoint. */
@RestController
@RequestMapping("/api/v1/execution-evidence")
@RequiredArgsConstructor
public class ExecutionEvidenceController {
private final ExecutionEvidenceQueryService queries;
@GetMapping
public R<ExecutionEvidenceQueryService.Page> list(Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam String conversationId,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) Long goalId,
@RequestParam(required = false) Long teamTaskId) {
return R.ok(queries.list(auth == null ? null : auth.getName(), workspaceId, conversationId,
cursor, limit, goalId, teamTaskId));
}
@GetMapping("/{id}")
public R<ExecutionEvidenceQueryService.View> detail(Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@PathVariable Long id) {
return R.ok(queries.detail(auth == null ? null : auth.getName(), workspaceId, id));
}
public record JsonCheckRequest(List<String> requiredFields) { }
@PostMapping("/{id}/json-check")
public R<JsonArtifactRecipe.Result> checkJson(Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@PathVariable Long id, @RequestBody JsonCheckRequest request) {
return R.ok(queries.checkJson(auth == null ? null : auth.getName(), workspaceId, id,
request == null ? null : request.requiredFields()));
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum AttemptState { STARTED, SUCCEEDED, FAILED, CANCELLED, UNKNOWN, BLOCKED }

View File

@ -0,0 +1,4 @@
package vip.mate.execution.evidence.model;
/** Only the successful inserter may initiate a new execution. */
public record BeginResult(ExecutionAttempt attempt, boolean created) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EffectOutcome { NONE, CONFIRMED, UNCERTAIN }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EvidenceKind { TOOL_RETURNED, COMMAND_EXIT, CHECK_RESULT, ARTIFACT_SNAPSHOT }

View File

@ -0,0 +1,15 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
/** Allowlisted execution metadata; raw invocation parameters are never accepted. */
public record EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
SourceLevel sourceLevel, Long scopeId, Long generation, String inputFingerprint,
String recipeId, Long recipeRevision, String checkScope, String artifactRef,
String artifactDigest, String summary, String payloadRef, Instant observedAt, Instant expiresAt) {
public EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
SourceLevel sourceLevel, String summary) {
this(sourceKey, kind, result, sourceLevel, null, null, null, null, null, null,
null, null, summary, null, null, null);
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EvidenceResult { OBSERVED, PASS, FAIL, UNKNOWN }

View File

@ -0,0 +1,5 @@
package vip.mate.execution.evidence.model;
/** Persisted resource identity reserved for managed validation scopes. */
public record EvidenceScope(Long id, Long workspaceId, String resourceKey, String hostId,
String rootId, long generation, int activeMutations, boolean tainted) { }

View File

@ -0,0 +1,6 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
public record ExecutionAttempt(Long id, ExecutionIdentity identity, AttemptState state, EffectOutcome effectOutcome,
Instant startedAt, Instant finishedAt) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public record ExecutionEvidence(Long id, Long workspaceId, Long attemptId, String conversationId, EvidenceObservation observation) { }

View File

@ -0,0 +1,6 @@
package vip.mate.execution.evidence.model;
public record ExecutionIdentity(Long workspaceId, String conversationId, String runtimeKind, String runtimeSessionId,
String invocationKey, String logicalCallId, int attemptNo, String providerToolCallId,
String toolName, Long goalId, String goalAttemptId, Long teamRunId, Long teamTaskId,
Long cronRunId, String approvalId, String ownerFence) { }

View File

@ -0,0 +1,7 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
/** Versioned binding metadata; creating a binding requires separate source authorization. */
public record GoalCriterionEvidence(Long id, Long workspaceId, Long goalId, String criterionId,
long criterionRevision, Long evidenceId, Instant boundAt) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum SourceLevel { PLATFORM_OBSERVED, ADAPTER_ATTESTED, EXTERNAL_REPORTED, LEGACY_TEXT }

View File

@ -0,0 +1,35 @@
package vip.mate.execution.evidence.service;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import java.time.Instant;
/** Metadata retention is independent of source-file retention and respects conversation deletion. */
@Component
public class ExecutionEvidenceLifecycle {
private final ExecutionEvidenceStore store;
private final ExecutionEvidenceProperties properties;
public ExecutionEvidenceLifecycle(ExecutionEvidenceStore store, ExecutionEvidenceProperties properties) {
this.store = store;
this.properties = properties;
}
@EventListener
public void onConversationDeleted(ConversationDeletedEvent event) {
store.purgeConversation(event.conversationId());
}
@Scheduled(fixedDelayString = "${mateclaw.execution-evidence.cleanup-interval-ms:60000}")
public void cleanup() {
Instant now = Instant.now();
for (int batch = 0; batch < properties.getCleanupMaxBatches(); batch++) {
if (store.purgeExpiredMetadata(now, 100) < 100) break;
}
}
}

View File

@ -0,0 +1,180 @@
package vip.mate.execution.evidence.service;
import org.springframework.stereotype.Service;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.SourceLevel;
import vip.mate.auth.service.AuthService;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.core.service.WorkspaceService;
import io.micrometer.core.instrument.MeterRegistry;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.model.ExecutionEvidence;
import vip.mate.execution.evidence.model.ExecutionAttempt;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Service
public class ExecutionEvidenceQueryService {
public record View(Long id, Long attemptId, String conversationId, String toolName, AttemptState state,
EffectOutcome effectOutcome, EvidenceKind kind, EvidenceResult result, SourceLevel sourceLevel,
String validity, String summary, Instant observedAt, Instant expiresAt,
String artifactRef, String artifactDigest, String checkScope) { }
public record Page(List<View> items, String nextCursor) { }
private record Cursor(Instant observedAt, Long id) { }
private final ExecutionEvidenceStore store;
private final ConversationService conversations;
private final TeamWorkerConversationGovernanceService teams;
private final GeneratedFileCache files;
private final AuthService auth;
private final WorkspaceService workspaces;
private final ExecutionEvidenceProperties properties;
private final MeterRegistry metrics;
public ExecutionEvidenceQueryService(ExecutionEvidenceStore store, ConversationService conversations,
TeamWorkerConversationGovernanceService teams, GeneratedFileCache files,
AuthService auth, WorkspaceService workspaces, ExecutionEvidenceProperties properties, MeterRegistry metrics) {
this.store = store;
this.conversations = conversations;
this.teams = teams;
this.files = files;
this.auth = auth;
this.workspaces = workspaces;
this.properties = properties;
this.metrics = metrics;
}
public Page list(String username, Long workspaceId, String conversationId, String cursor, Integer limit,
Long goalId, Long teamTaskId) {
long started = System.nanoTime();
try {
Long canonicalWorkspace = authorize(username, workspaceId, conversationId);
int bounded = Math.clamp(limit == null ? properties.getDefaultListLimit() : limit, 1, properties.getMaxListLimit());
Cursor before = decode(cursor);
List<ExecutionEvidence> rows = store.list(canonicalWorkspace, conversationId, before.observedAt(),
before.id(), bounded + 1, goalId, teamTaskId);
boolean hasMore = rows.size() > bounded;
List<ExecutionEvidence> page = rows.stream().limit(bounded).toList();
if (page.isEmpty()) return new Page(List.of(), null);
var attempts = store.findAttempts(canonicalWorkspace, conversationId,
page.stream().map(ExecutionEvidence::attemptId).distinct().toList());
return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()), false)).toList(),
hasMore ? encode(page.getLast()) : null);
} finally {
metrics.timer("mateclaw.execution.evidence.query.latency").record(
System.nanoTime() - started, TimeUnit.NANOSECONDS);
}
}
public View detail(String username, Long workspaceId, Long id) {
return authorizedDetail(username, workspaceId, id, true);
}
private View authorizedDetail(String username, Long workspaceId, Long id, boolean inspectVersion) {
if (username == null || username.isBlank() || id == null) throw hidden();
ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden);
Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId());
if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden();
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), inspectVersion);
}
public JsonArtifactRecipe.Result checkJson(String username, Long workspaceId, Long id, List<String> fields) {
// Reuse source, attempt and file authorization before any content read.
View view = authorizedDetail(username, workspaceId, id, false);
List<String> required = JsonArtifactRecipe.validate(fields);
if (view.kind() != EvidenceKind.ARTIFACT_SNAPSHOT || view.artifactRef() == null
|| "UNAVAILABLE".equals(view.validity())) {
return JsonArtifactRecipe.outcome("UNAVAILABLE", required, List.of());
}
Long ownerWorkspace = authorize(username, workspaceId, view.conversationId());
var read = files.readDurableArtifactSnapshot(view.artifactRef(), ownerWorkspace, view.conversationId(),
view.artifactDigest(), properties.getArtifactVersionCheckMaxBytes());
return "READ".equals(read.status()) ? JsonArtifactRecipe.check(read.bytes(), required)
: JsonArtifactRecipe.outcome(read.status(), required, List.of());
}
private Long authorize(String username, Long workspaceId, String conversationId) {
if (username == null || username.isBlank() || conversationId == null || conversationId.isBlank()) throw hidden();
var conversation = conversations.findByConversationId(conversationId);
if (conversation == null || conversation.getWorkspaceId() == null || Integer.valueOf(1).equals(conversation.getDeleted())
|| workspaceId != null && !workspaceId.equals(conversation.getWorkspaceId())) throw hidden();
if (!conversations.isConversationOwner(conversationId, username)
&& !teams.canReadTranscript(conversationId, null, null, username)) throw hidden();
return conversation.getWorkspaceId();
}
private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt, boolean inspectVersion) {
if (attempt == null || !Objects.equals(attempt.id(), row.attemptId())) throw hidden();
if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId())
|| !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden();
var evidence = row.observation();
String validity = "UNKNOWN";
String artifact = evidence.artifactRef();
String digest = evidence.artifactDigest();
String summary = evidence.summary();
if (evidence.expiresAt() != null && !evidence.expiresAt().isAfter(Instant.now())) validity = "UNAVAILABLE";
if (evidence.kind() == EvidenceKind.ARTIFACT_SNAPSHOT) {
var user = auth.findByUsername(username);
boolean canReadFile = user != null && ("admin".equalsIgnoreCase(user.getRole())
|| workspaces.hasPermissionCached(row.workspaceId(), user.getId(), "viewer"));
if (!canReadFile) {
artifact = null;
digest = null;
summary = null;
validity = "UNAVAILABLE";
} else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) {
validity = "UNAVAILABLE";
artifact = null;
} else if (inspectVersion && !"UNAVAILABLE".equals(validity)) {
var version = files.probeDurableArtifactVersion(artifact, row.workspaceId(), row.conversationId(),
digest, properties.getArtifactVersionCheckMaxBytes());
if (version == GeneratedFileCache.ArtifactVersion.CHANGED) {
validity = "STALE";
} else if (version == GeneratedFileCache.ArtifactVersion.UNAVAILABLE) {
validity = "UNAVAILABLE";
artifact = null;
}
// Equality/budget exhaustion remains UNKNOWN; there is no managed generation fence.
}
}
metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment();
// An available observation is not a freshness or correctness certificate.
return new View(row.id(), row.attemptId(), row.conversationId(), attempt.identity().toolName(), attempt.state(),
attempt.effectOutcome(), evidence.kind(), evidence.result(), evidence.sourceLevel(), validity,
summary, evidence.observedAt(), evidence.expiresAt(), artifact, digest, evidence.checkScope());
}
private Cursor decode(String cursor) {
if (cursor == null || cursor.isBlank()) return new Cursor(null, null);
try {
if (cursor.length() > 256) throw new IllegalArgumentException();
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\\|", -1);
if (parts.length != 2) throw new IllegalArgumentException();
long id = Long.parseLong(parts[1]);
if (id <= 0) throw new IllegalArgumentException();
return new Cursor(Instant.parse(parts[0]), id);
} catch (RuntimeException invalid) {
throw new MateClawException(400, "Invalid execution evidence cursor");
}
}
private String encode(ExecutionEvidence row) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(
(row.observation().observedAt() + "|" + row.id()).getBytes(StandardCharsets.UTF_8));
}
private MateClawException hidden() {
metrics.counter("mateclaw.execution.evidence.query.denied").increment();
return new MateClawException(404, "Execution evidence not found");
}
}

View File

@ -0,0 +1,128 @@
package vip.mate.execution.evidence.service;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Service;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.ExecutionAttempt;
import vip.mate.execution.evidence.model.SourceLevel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.CancellationException;
import java.util.concurrent.TimeUnit;
/** Observes the actual callback boundary. It never interprets returned text as a check result. */
@Service
public class ExecutionEvidenceRecorder {
private static final Logger log = LoggerFactory.getLogger(ExecutionEvidenceRecorder.class);
private final ExecutionEvidenceStore store;
private final ExecutionIdentityResolver identities;
private final ExecutionEvidenceProperties properties;
private final MeterRegistry metrics;
public ExecutionEvidenceRecorder(ExecutionEvidenceStore store, ExecutionIdentityResolver identities,
ExecutionEvidenceProperties properties, MeterRegistry metrics) {
this.store = store;
this.identities = identities;
this.properties = properties;
this.metrics = metrics;
metrics.gauge("mateclaw.execution.evidence.attempts.unresolved", store, ExecutionEvidenceStore::countUnresolved);
if (properties.getMode() == ExecutionEvidenceProperties.Mode.ENFORCE) {
throw new IllegalStateException("Execution evidence enforcement requires managed verification scopes; use observe or off");
}
}
public String invoke(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) {
if (properties.getMode() == ExecutionEvidenceProperties.Mode.OFF) return callback.call(arguments, context);
ExecutionAttempt attempt = null;
boolean duplicate = false;
long began = System.nanoTime();
try {
var identity = identities.resolve(ChatOrigin.from(context), invocationKey, providerCallId,
callback.getToolDefinition().name());
if (identity != null) {
var reservation = store.reserve(identity);
attempt = reservation.attempt();
duplicate = !reservation.created();
}
else metrics.counter("mateclaw.execution.evidence.unattributed").increment();
} catch (IllegalStateException conflict) {
throw conflict;
} catch (RuntimeException failure) {
failure("begin");
} finally {
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "begin")
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
}
// An existing receipt is not a license to repeat an approved side effect.
if (duplicate) {
throw new IllegalStateException("Execution already observed; recover the existing approval result");
}
boolean direct = callback.getToolMetadata() != null && callback.getToolMetadata().returnDirect();
var sink = new ExecutionObservationSink(direct, properties.getMaxObservations());
try {
ToolContext observedContext = context;
if (attempt != null) {
var values = new HashMap<String, Object>(context.getContext());
ChatOrigin canonical = ChatOrigin.from(context).withWorkspace(attempt.identity().workspaceId(),
ChatOrigin.from(context).workspaceBasePath());
values.put(ChatOrigin.CTX_KEY, canonical);
observedContext = sink.attach(new ToolContext(values));
}
String result = callback.call(arguments, observedContext);
finish(attempt, sink, null, "Tool callback returned");
return result;
} catch (RuntimeException | Error error) {
AttemptState state = error instanceof CancellationException || Thread.currentThread().isInterrupted()
? AttemptState.CANCELLED : AttemptState.FAILED;
finish(attempt, sink, state, "Tool callback did not complete normally");
throw error;
}
}
private void finish(ExecutionAttempt attempt, ExecutionObservationSink sink, AttemptState overrideState, String summary) {
var captured = sink.sealAndSnapshot();
AttemptState state = overrideState != null ? overrideState : captured.state();
if (attempt == null) return;
long began = System.nanoTime();
try {
if (!identities.isCurrent(attempt.identity())) {
failure("owner_lost");
return;
}
var observations = new ArrayList<>(captured.observations());
observations.add(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL,
SourceLevel.PLATFORM_OBSERVED, summary));
store.finish(attempt.id(), attempt.identity().ownerFence(), state,
state == AttemptState.BLOCKED ? EffectOutcome.NONE : EffectOutcome.UNCERTAIN, observations);
for (var observation : observations) {
metrics.counter("mateclaw.execution.evidence.observations", "kind", observation.kind().name()).increment();
}
} catch (RuntimeException failure) {
// Preserve STARTED as uncertain; the existing recovery authority owns any retry.
failure("finish");
} finally {
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "finish")
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
}
}
private void failure(String phase) {
metrics.counter("mateclaw.execution.evidence.capture.failures", "phase", phase).increment();
log.warn("Execution evidence capture unavailable (phase={}); consult execution recovery state", phase);
}
}

View File

@ -0,0 +1,341 @@
package vip.mate.execution.evidence.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.common.text.SecretRedactor;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.BeginResult;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.ExecutionAttempt;
import vip.mate.execution.evidence.model.ExecutionEvidence;
import vip.mate.execution.evidence.model.ExecutionIdentity;
import vip.mate.execution.evidence.model.SourceLevel;
import java.nio.charset.StandardCharsets;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
/** Short, independent transactions never span tool execution. No public write API exists. */
@Service
public class ExecutionEvidenceStore {
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
private final ExecutionEvidenceProperties properties;
private static final String EVIDENCE_QUERY = "SELECT e.*, a.conversation_id FROM mate_execution_evidence e JOIN mate_execution_attempt a ON a.id=e.attempt_id WHERE e.deleted=0 AND a.deleted=0";
public ExecutionEvidenceStore(JdbcTemplate jdbc, PlatformTransactionManager manager,
ExecutionEvidenceProperties properties) {
this.jdbc = jdbc;
this.properties = properties;
transaction = new TransactionTemplate(manager);
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
private ExecutionIdentityResolver ownershipValidator;
@Autowired
public void setOwnershipValidator(ExecutionIdentityResolver validator) {
this.ownershipValidator = validator;
}
public ExecutionAttempt begin(ExecutionIdentity identity) {
return reserve(identity).attempt();
}
public BeginResult reserve(ExecutionIdentity identity) {
validate(identity);
try {
return transaction.execute(status -> {
if (ownershipValidator != null) ownershipValidator.lockCurrentForUpdate(identity, false);
var existing = byInvocation(identity);
if (existing.isPresent()) return new BeginResult(sameIdentity(existing.get(), identity), false);
long id = IdWorker.getId();
Instant now = now();
jdbc.update("""
INSERT INTO mate_execution_attempt
(id,workspace_id,conversation_id,runtime_kind,runtime_session_id,invocation_key,
logical_call_id,attempt_no,provider_tool_call_id,tool_name,goal_id,goal_attempt_id,
team_run_id,team_task_id,cron_run_id,approval_id,owner_fence,state,effect_outcome,started_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'STARTED','UNCERTAIN',?)
""", id, identity.workspaceId(), identity.conversationId(), identity.runtimeKind(),
identity.runtimeSessionId(), identity.invocationKey(), identity.logicalCallId(),
identity.attemptNo(), identity.providerToolCallId(), identity.toolName(), identity.goalId(),
identity.goalAttemptId(), identity.teamRunId(), identity.teamTaskId(), identity.cronRunId(),
identity.approvalId(), identity.ownerFence(), timestamp(now));
return new BeginResult(new ExecutionAttempt(id, identity, AttemptState.STARTED, EffectOutcome.UNCERTAIN, now, null), true);
});
} catch (DuplicateKeyException conflict) {
return new BeginResult(sameIdentity(byInvocation(identity).orElseThrow(() ->
new IllegalStateException("Logical execution attempt already exists")), identity), false);
}
}
public List<ExecutionEvidence> finish(Long attemptId, String ownerFence, AttemptState state,
EffectOutcome effect, List<EvidenceObservation> observations) {
if (state == null || state == AttemptState.STARTED || effect == null || observations == null)
throw new IllegalArgumentException("A terminal execution outcome is required");
if (observations.size() > 100) throw new IllegalArgumentException("Too many observations");
return transaction.execute(status -> {
var snapshot = findAttempt(attemptId).orElseThrow(() -> new IllegalStateException("Execution attempt unavailable"));
boolean currentOwner = ownershipValidator == null || ownershipValidator.lockCurrentForUpdate(snapshot.identity(), true);
var rows = jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0 FOR UPDATE",
this::attempt, attemptId);
if (rows.isEmpty()) throw new IllegalStateException("Execution attempt unavailable");
var attempt = rows.getFirst();
if (!Objects.equals(attempt.identity().ownerFence(), ownerFence))
throw new IllegalStateException("Execution owner fence rejected");
var existing = jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
var bySource = new LinkedHashMap<String, ExecutionEvidence>();
existing.forEach(row -> bySource.put(row.observation().sourceKey(), row));
var normalized = new LinkedHashMap<String, EvidenceObservation>();
for (var observation : observations) {
var prior = bySource.get(observation.sourceKey());
var baseline = prior == null ? normalized.get(observation.sourceKey()) : prior.observation();
var clean = normalize(observation, baseline);
var duplicate = normalized.putIfAbsent(clean.sourceKey(), clean);
if (duplicate != null && !duplicate.equals(clean)) throw conflict();
if (prior != null && !prior.observation().equals(clean)) throw conflict();
}
if (attempt.state() != AttemptState.STARTED) {
if (attempt.state() != state || attempt.effectOutcome() != effect
|| bySource.size() != normalized.size() || !bySource.keySet().equals(normalized.keySet()))
throw conflict();
return existing;
}
if (!currentOwner) throw new IllegalStateException("Execution owner fence rejected");
int updated = jdbc.update("""
UPDATE mate_execution_attempt SET state=?,effect_outcome=?,finished_at=?,update_time=?
WHERE id=? AND owner_fence=? AND state='STARTED' AND deleted=0
""", state.name(), effect.name(), timestamp(now()), timestamp(now()), attemptId, ownerFence);
if (updated != 1) throw new IllegalStateException("Execution owner fence rejected");
for (var observation : normalized.values()) {
long id = IdWorker.getId();
jdbc.update("""
INSERT INTO mate_execution_evidence
(id,workspace_id,attempt_id,source_key,kind,result,source_level,scope_id,generation,
input_fingerprint,recipe_id,recipe_revision,check_scope,artifact_ref,artifact_digest,
summary,payload_ref,observed_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", id, attempt.identity().workspaceId(), attemptId, observation.sourceKey(),
observation.kind().name(), observation.result().name(), observation.sourceLevel().name(),
observation.scopeId(), observation.generation(), observation.inputFingerprint(),
observation.recipeId(), observation.recipeRevision(), observation.checkScope(),
observation.artifactRef(), observation.artifactDigest(), observation.summary(),
observation.payloadRef(), timestamp(observation.observedAt()), timestamp(observation.expiresAt()));
}
return jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
});
}
/** Delete bounded, unreferenced terminal metadata; unresolved executions and bindings remain pinned. */
public int purgeExpiredMetadata(Instant now, int batchLimit) {
Objects.requireNonNull(now, "Retention time required");
if (batchLimit < 1) throw new IllegalArgumentException("Positive cleanup batch required");
var cutoff = timestamp(now.minus(properties.getRetentionDays(), ChronoUnit.DAYS));
return transaction.execute(status -> {
var ids = jdbc.queryForList("""
SELECT a.id FROM mate_execution_attempt a
WHERE a.state IN ('SUCCEEDED','FAILED','CANCELLED','BLOCKED') AND a.update_time<?
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
WHERE e.attempt_id=a.id AND e.observed_at>=?)
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
JOIN mate_goal_criterion_evidence b ON b.evidence_id=e.id WHERE e.attempt_id=a.id)
ORDER BY a.id LIMIT ? FOR UPDATE
""", Long.class, cutoff, cutoff, Math.min(batchLimit,100));
for (var id : ids) {
// Foreign keys also prevent deletion if a new binding races the candidate selection.
jdbc.update("DELETE FROM mate_execution_evidence WHERE attempt_id=?", id);
jdbc.update("DELETE FROM mate_execution_attempt WHERE id=?", id);
}
return ids.size();
});
}
/** Erase copied content while retaining non-content tombstones for existing evidence bindings. */
public int purgeConversation(String conversationId) {
required(conversationId,128);
return transaction.execute(status -> {
// Lock attempts before receipts, matching finish, so a late writer cannot restore deleted content.
jdbc.update("""
UPDATE mate_execution_attempt SET
effect_outcome=CASE WHEN state='STARTED' THEN 'UNCERTAIN' ELSE effect_outcome END,
finished_at=CASE WHEN state='STARTED' THEN ? ELSE finished_at END,
state=CASE WHEN state='STARTED' THEN 'UNKNOWN' ELSE state END,
failure_reason=NULL,deleted=1,update_time=?
WHERE conversation_id=? AND deleted=0
""", timestamp(now()), timestamp(now()), conversationId);
return jdbc.update("""
UPDATE mate_execution_evidence SET summary=NULL,input_fingerprint=NULL,check_scope=NULL,
artifact_ref=NULL,artifact_digest=NULL,payload_ref=NULL,recipe_id=NULL,
deleted=1,update_time=? WHERE deleted=0 AND attempt_id IN
(SELECT id FROM mate_execution_attempt WHERE conversation_id=?)
""", timestamp(now()), conversationId);
});
}
public long countUnresolved() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt WHERE deleted=0 AND state IN ('STARTED','UNKNOWN')", Long.class);
return count == null ? 0 : count;
}
public Optional<ExecutionAttempt> findAttempt(Long id) {
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0", this::attempt, id).stream().findFirst();
}
/** Load one page of attempts without allowing cross-conversation reads. */
public Map<Long, ExecutionAttempt> findAttempts(Long workspaceId, String conversationId, List<Long> ids) {
scope(workspaceId, conversationId);
Objects.requireNonNull(ids, "Attempt IDs required");
if (ids.size() > properties.getMaxListLimit())
throw new IllegalArgumentException("Attempt batch exceeds page limit");
if (ids.isEmpty()) return Map.of();
if (ids.stream().anyMatch(Objects::isNull))
throw new IllegalArgumentException("Attempt ID required");
var distinct = ids.stream().distinct().toList();
var args = new ArrayList<Object>(List.of(workspaceId, conversationId));
args.addAll(distinct);
String placeholders = String.join(",", Collections.nCopies(distinct.size(), "?"));
var result = new LinkedHashMap<Long, ExecutionAttempt>();
jdbc.query("SELECT * FROM mate_execution_attempt WHERE workspace_id=? AND conversation_id=?"
+ " AND deleted=0 AND id IN (" + placeholders + ")", this::attempt, args.toArray())
.forEach(attempt -> result.put(attempt.id(), attempt));
return result;
}
/** Internal lookup for source authorization; callers must authorize before exposing the result. */
public Optional<ExecutionEvidence> findById(Long id) {
return jdbc.query(EVIDENCE_QUERY + " AND e.id=?", this::evidence, id).stream().findFirst();
}
public Optional<ExecutionEvidence> find(Long workspaceId, String conversationId, Long id) {
scope(workspaceId, conversationId);
return jdbc.query(EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=? AND e.id=?",
this::evidence, workspaceId, conversationId, id).stream().findFirst();
}
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
Long beforeId, int limit) {
return list(workspaceId, conversationId, beforeObservedAt, beforeId, limit, null, null);
}
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
Long beforeId, int limit, Long goalId, Long teamTaskId) {
scope(workspaceId, conversationId);
if ((beforeObservedAt == null) != (beforeId == null))
throw new IllegalArgumentException("Both cursor components are required");
int bounded = Math.min(properties.getMaxListLimit() + 1,
limit <= 0 ? properties.getDefaultListLimit() : limit);
var args = new ArrayList<Object>(List.of(workspaceId, conversationId));
String query = EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=?";
if (goalId != null) { query += " AND a.goal_id=?"; args.add(goalId); }
if (teamTaskId != null) { query += " AND a.team_task_id=?"; args.add(teamTaskId); }
if (beforeObservedAt != null) {
query += " AND (e.observed_at<? OR (e.observed_at=? AND e.id<?))";
args.add(timestamp(beforeObservedAt)); args.add(timestamp(beforeObservedAt)); args.add(beforeId);
}
args.add(bounded);
return jdbc.query(query + " ORDER BY e.observed_at DESC,e.id DESC LIMIT ?", this::evidence, args.toArray());
}
private Optional<ExecutionAttempt> byInvocation(ExecutionIdentity identity) {
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE workspace_id=? AND invocation_key=? AND deleted=0",
this::attempt, identity.workspaceId(), identity.invocationKey()).stream().findFirst();
}
private ExecutionAttempt sameIdentity(ExecutionAttempt attempt, ExecutionIdentity identity) {
if (!attempt.identity().equals(identity)) throw conflict();
return attempt;
}
private EvidenceObservation normalize(EvidenceObservation value, EvidenceObservation prior) {
Objects.requireNonNull(value.kind(), "Evidence kind required");
Objects.requireNonNull(value.result(), "Evidence result required");
Objects.requireNonNull(value.sourceLevel(), "Evidence source required");
required(value.sourceKey(), 191);
Instant observed = value.observedAt() == null ? (prior == null ? now() : prior.observedAt())
: value.observedAt().truncatedTo(ChronoUnit.MICROS);
return new EvidenceObservation(value.sourceKey(), value.kind(), value.result(), value.sourceLevel(),
value.scopeId(), value.generation(), bounded(value.inputFingerprint(),128), bounded(value.recipeId(),191),
value.recipeRevision(), bounded(value.checkScope(),2048), bounded(value.artifactRef(),512),
bounded(value.artifactDigest(),128), bounded(value.summary(),properties.getMaxSummaryBytes()),
bounded(value.payloadRef(),512), observed,
value.expiresAt() == null ? null : value.expiresAt().truncatedTo(ChronoUnit.MICROS));
}
private String bounded(String value, int bytes) {
String clean = SecretRedactor.redact(value);
if (clean == null) return null;
int end = 0, used = 0;
while (end < clean.length()) {
int cp = clean.codePointAt(end);
int length = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
if (used + length > bytes) break;
used += length; end += Character.charCount(cp);
}
return clean.substring(0,end);
}
private void validate(ExecutionIdentity identity) {
Objects.requireNonNull(identity, "Execution identity required");
scope(identity.workspaceId(), identity.conversationId());
required(identity.runtimeKind(),40); required(identity.invocationKey(),191);
required(identity.logicalCallId(),191); required(identity.toolName(),191); required(identity.ownerFence(),191);
if (identity.attemptNo() < 1) throw new IllegalArgumentException("Attempt number must be positive");
}
private void scope(Long workspaceId, String conversationId) {
if (workspaceId == null) throw new IllegalArgumentException("Workspace required");
required(conversationId,128);
}
private void required(String value, int max) {
if (value == null || value.isBlank() || value.length() > max)
throw new IllegalArgumentException("Missing or oversized execution identity");
}
private IllegalStateException conflict() { return new IllegalStateException("Immutable execution evidence conflict"); }
private static Instant now() { return Instant.now().truncatedTo(ChronoUnit.MICROS); }
private static Timestamp timestamp(Instant instant) { return instant == null ? null : Timestamp.from(instant); }
private static Instant instant(ResultSet row, String column) throws SQLException {
var value = row.getTimestamp(column); return value == null ? null : value.toInstant();
}
private ExecutionAttempt attempt(ResultSet row, int number) throws SQLException {
var identity = new ExecutionIdentity(row.getObject("workspace_id",Long.class),row.getString("conversation_id"),
row.getString("runtime_kind"),row.getString("runtime_session_id"),row.getString("invocation_key"),
row.getString("logical_call_id"),row.getInt("attempt_no"),row.getString("provider_tool_call_id"),
row.getString("tool_name"),row.getObject("goal_id",Long.class),row.getString("goal_attempt_id"),
row.getObject("team_run_id",Long.class),row.getObject("team_task_id",Long.class),
row.getObject("cron_run_id",Long.class),row.getString("approval_id"),row.getString("owner_fence"));
return new ExecutionAttempt(row.getLong("id"), identity,AttemptState.valueOf(row.getString("state")),
EffectOutcome.valueOf(row.getString("effect_outcome")),instant(row,"started_at"),instant(row,"finished_at"));
}
private ExecutionEvidence evidence(ResultSet row, int number) throws SQLException {
var observation = new EvidenceObservation(row.getString("source_key"),EvidenceKind.valueOf(row.getString("kind")),
EvidenceResult.valueOf(row.getString("result")),SourceLevel.valueOf(row.getString("source_level")),
row.getObject("scope_id",Long.class),row.getObject("generation",Long.class),row.getString("input_fingerprint"),
row.getString("recipe_id"),row.getObject("recipe_revision",Long.class),row.getString("check_scope"),
row.getString("artifact_ref"),row.getString("artifact_digest"),row.getString("summary"),row.getString("payload_ref"),
instant(row,"observed_at"),instant(row,"expires_at"));
return new ExecutionEvidence(row.getLong("id"),row.getLong("workspace_id"),row.getLong("attempt_id"),
row.getString("conversation_id"),observation);
}
}

View File

@ -0,0 +1,100 @@
package vip.mate.execution.evidence.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.execution.evidence.model.ExecutionIdentity;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import java.util.Objects;
import java.time.LocalDateTime;
/** Resolves business linkage from persisted rows, never from tool arguments. */
@Service
public class ExecutionIdentityResolver {
private final JdbcTemplate jdbc;
private final TeamWorkerConversationGovernanceService teamGovernance;
public ExecutionIdentityResolver(JdbcTemplate jdbc, TeamWorkerConversationGovernanceService teamGovernance) {
this.jdbc = jdbc;
this.teamGovernance = teamGovernance;
}
public ExecutionIdentity resolve(ChatOrigin origin, String invocationKey, String providerCallId, String toolName) {
if (origin == null || origin.conversationId() == null) return null;
var workspaces = jdbc.queryForList("SELECT workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0",
Long.class, origin.conversationId());
if (workspaces.size() != 1 || workspaces.getFirst() == null) return null;
Long workspaceId = workspaces.getFirst();
if (origin.workspaceId() != null && !workspaceId.equals(origin.workspaceId())) return null;
ExecutionAttribution source = origin.executionAttribution();
Long goalId = source == null ? null : source.goalId();
String goalAttemptId = source == null ? null : source.goalAttemptId();
Long cronRunId = source == null ? null : source.cronRunId();
String approvalId = source == null ? null : source.approvalId();
if (goalId == null && cronRunId == null) {
var activeGoals = jdbc.queryForList("SELECT id FROM mate_agent_goal WHERE conversation_id=? AND workspace_id=? AND status='active' AND deleted=0",
Long.class, origin.conversationId(), workspaceId);
if (activeGoals.size() == 1) goalId = activeGoals.getFirst();
}
if (goalId != null && !exists("SELECT COUNT(*) FROM mate_agent_goal WHERE id=? AND conversation_id=? AND workspace_id=? AND deleted=0",
goalId, origin.conversationId(), workspaceId)) return null;
if (goalAttemptId != null && !exists("SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=? AND lease_token=?",
goalAttemptId, goalId, origin.conversationId(), source.ownerFence())) return null;
if (cronRunId != null && !exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=?",
cronRunId, origin.conversationId())) return null;
if (approvalId != null && !exists("SELECT COUNT(*) FROM mate_tool_approval WHERE pending_id=? AND conversation_id=?",
approvalId, origin.conversationId())) return null;
var team = teamGovernance.resolve(origin.conversationId(), null, null).orElse(null);
String fence = source != null && source.ownerFence() != null ? source.ownerFence() : invocationKey;
String key = approvalId == null ? invocationKey : "approval:" + approvalId;
return new ExecutionIdentity(workspaceId, origin.conversationId(), "native", goalAttemptId,
key, key, 1, approvalId == null ? providerCallId : null, toolName, goalId, goalAttemptId,
team == null ? null : team.runId(), team == null ? null : team.taskId(), cronRunId,
approvalId, goalAttemptId != null ? fence : approvalId == null ? fence : "approval:" + approvalId);
}
/** An expired business owner may leave historical observations, but cannot publish a new terminal result. */
public boolean isCurrent(ExecutionIdentity identity) {
if (identity.goalAttemptId() != null && !exists("""
SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=?
AND state IN ('claimed','running') AND lease_until>CURRENT_TIMESTAMP
""", identity.goalAttemptId(), identity.goalId(), identity.conversationId())) return false;
return identity.cronRunId() == null || exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=? AND status='running'",
identity.cronRunId(), identity.conversationId());
}
/** Lock order: conversation, business owner, then execution attempt. Called inside the store transaction. */
public boolean lockCurrentForUpdate(ExecutionIdentity identity, boolean terminal) {
var conversations = jdbc.queryForList("SELECT workspace_id,deleted FROM mate_conversation WHERE conversation_id=? FOR UPDATE",
identity.conversationId());
if (conversations.size() != 1 || !Objects.equals(((Number) conversations.getFirst().get("workspace_id")).longValue(), identity.workspaceId())
|| ((Number) conversations.getFirst().get("deleted")).intValue() != 0) {
throw new IllegalStateException("Execution conversation unavailable");
}
if (!terminal) return true;
if (identity.goalAttemptId() != null) {
var owners = jdbc.query("SELECT goal_id,conversation_id,lease_token,state,lease_until FROM mate_goal_attempt WHERE attempt_id=? FOR UPDATE",
(row, index) -> Objects.equals(row.getLong("goal_id"), identity.goalId())
&& Objects.equals(row.getString("conversation_id"), identity.conversationId())
&& Objects.equals(row.getString("lease_token"), identity.ownerFence())
&& ("claimed".equals(row.getString("state")) || "running".equals(row.getString("state")))
&& row.getTimestamp("lease_until") != null
&& row.getTimestamp("lease_until").toLocalDateTime().isAfter(LocalDateTime.now()),
identity.goalAttemptId());
if (owners.size() != 1 || !owners.getFirst()) return false;
}
if (identity.cronRunId() != null) {
var owners = jdbc.query("SELECT conversation_id,status FROM mate_cron_job_run WHERE id=? FOR UPDATE",
(row, index) -> Objects.equals(row.getString("conversation_id"), identity.conversationId())
&& "running".equals(row.getString("status")), identity.cronRunId());
if (owners.size() != 1 || !owners.getFirst()) return false;
}
return true;
}
private boolean exists(String sql, Object... values) {
return Objects.equals(1L, jdbc.queryForObject(sql, Long.class, values));
}
}

View File

@ -0,0 +1,102 @@
package vip.mate.execution.evidence.service;
import org.springframework.ai.chat.model.ToolContext;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.SourceLevel;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/** Typed, invocation-local channel for trusted implementations to report observations. */
public final class ExecutionObservationSink {
private static final String KEY = "mateclaw.executionObservationSink";
private final boolean metadataOnly;
private final int maxObservations;
private final List<EvidenceObservation> observations = new ArrayList<>();
private AttemptState state = AttemptState.SUCCEEDED;
private boolean sealed;
private long commandCount;
public ExecutionObservationSink(boolean metadataOnly) { this(metadataOnly, 32); }
public ExecutionObservationSink(boolean metadataOnly, int maxObservations) {
this.metadataOnly = metadataOnly;
this.maxObservations = Math.clamp(maxObservations, 1, 99);
}
public ToolContext attach(ToolContext context) {
var values = new HashMap<String, Object>(context.getContext());
values.put(KEY, this);
return new ToolContext(values);
}
public static ExecutionObservationSink from(ToolContext context) {
if (context == null) return null;
Object sink = context.getContext().get(KEY);
return sink instanceof ExecutionObservationSink typed ? typed : null;
}
public boolean metadataOnly() { return metadataOnly; }
public synchronized AttemptState state() { return state; }
public synchronized List<EvidenceObservation> observations() { return List.copyOf(observations); }
public synchronized void seal() { sealed = true; }
public record Snapshot(AttemptState state, List<EvidenceObservation> observations) {
public Snapshot { observations = List.copyOf(observations); }
}
/** State and rows share one linearization point; later observations are ignored. */
public synchronized Snapshot sealAndSnapshot() {
sealed = true;
return new Snapshot(state, observations);
}
/** Called by the process adapter, never by parsing a tool's returned text. */
public void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked) {
command(exitCode, timedOut, cancelled, blocked, null);
}
public synchronized void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked, String workingDirectory) {
if (sealed) return;
AttemptState commandState = cancelled ? AttemptState.CANCELLED : blocked ? AttemptState.BLOCKED
: timedOut || exitCode == null ? AttemptState.UNKNOWN
: exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
state = commandCount == 0 ? commandState : aggregate(state, commandState);
commandCount++;
EvidenceResult result = commandState == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
: commandState == AttemptState.UNKNOWN || commandState == AttemptState.CANCELLED
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL;
append(new EvidenceObservation(commandCount == 1 ? "command" : "command:" + commandCount, EvidenceKind.COMMAND_EXIT, result,
SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, workingDirectory,
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
+ "; cancelled=" + cancelled + "; blocked=" + blocked, null, null, null));
}
private static AttemptState aggregate(AttemptState prior, AttemptState current) {
if (prior == AttemptState.CANCELLED || current == AttemptState.CANCELLED) return AttemptState.CANCELLED;
if (prior == AttemptState.UNKNOWN || current == AttemptState.UNKNOWN) return AttemptState.UNKNOWN;
if (prior == AttemptState.FAILED || current == AttemptState.FAILED) return AttemptState.FAILED;
if (prior == current) return prior;
// A blocked command mixed with actual execution cannot certify no effects.
return AttemptState.UNKNOWN;
}
/** Called only after file bytes and owner metadata have survived durable read-back. */
public synchronized void artifact(String id, String digest, long length, String mimeType, Instant expiresAt) {
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
null, id, digest, "bytes=" + length + "; mime=" + mimeType, null, null, expiresAt));
}
private void append(EvidenceObservation observation) {
if (!sealed && !metadataOnly && observations.size() < maxObservations
&& observations.stream().noneMatch(e -> e.sourceKey().equals(observation.sourceKey()))) {
observations.add(observation);
}
}
}

View File

@ -0,0 +1,66 @@
package vip.mate.execution.evidence.service;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.exception.MateClawException;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
/** Explicit, read-only check of captured bytes; never an acceptance binding. */
public final class JsonArtifactRecipe {
private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(32)
.maxStringLength(1_048_576).maxNameLength(1024).build())
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private JsonArtifactRecipe() { }
public record Result(String recipeId, int recipeRevision, String status, List<String> requiredFields,
List<String> missingFields, Instant checkedAt, boolean acceptanceEligible) { }
public static List<String> validate(List<String> fields) {
if (fields == null || fields.isEmpty() || fields.size() > 16
|| fields.stream().anyMatch(f -> f == null || f.isBlank() || f.length() > 128
|| f.chars().anyMatch(Character::isISOControl))
|| new HashSet<>(fields).size() != fields.size()) {
throw new MateClawException(400, "Specify 116 unique top-level JSON fields, each 1128 characters");
}
return List.copyOf(fields);
}
public static Result outcome(String status, List<String> fields, List<String> missing) {
return new Result("json-required-fields", 1, status, List.copyOf(fields), List.copyOf(missing),
Instant.now(), false);
}
/** Shared strict parser for managed publication and diagnostic checks. */
public static com.fasterxml.jackson.databind.JsonNode parseObject(byte[] bytes) {
if (bytes == null || bytes.length > 1_048_576) throw new MateClawException(400, "JSON must be at most 1 MiB");
try {
var document = JSON.readTree(bytes);
if (document == null || !document.isObject()) throw new IllegalArgumentException();
return document;
} catch (Exception invalid) {
throw new MateClawException(400, "A strict JSON object is required");
}
}
public static Result check(byte[] bytes, List<String> requestedFields) {
List<String> fields = validate(requestedFields);
if (bytes == null || bytes.length > 1_048_576) return outcome("UNKNOWN", fields, List.of());
try {
var document = parseObject(bytes);
List<String> missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList();
return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing);
} catch (Exception invalid) {
// Parser diagnostics can contain file content; do not return or log them.
return outcome("INVALID_JSON", fields, List.of());
}
}
}

View File

@ -82,6 +82,15 @@ public class GoalController {
return R.ok(goalService.toResponse(goalService.findActiveByConversation(conversationId)));
}
@Operation(summary = "Read this conversation's goal history, including paused and terminal goals")
@GetMapping("/by-conversation/{conversationId}/history")
public R<List<GoalResponse>> history(@PathVariable String conversationId,
@RequestParam(required = false) Long beforeId,
@RequestParam(defaultValue = "20") int limit, Authentication auth) {
requireOwner(conversationId, currentUsername(auth));
return R.ok(goalService.toResponseList(goalService.listByConversation(conversationId, beforeId, limit)));
}
@Operation(summary = "Get goal detail by id")
@GetMapping("/{id}")
public R<GoalResponse> get(@PathVariable Long id, Authentication auth) {

View File

@ -0,0 +1,68 @@
package vip.mate.goal.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.goal.service.GoalJsonAcceptanceService;
import vip.mate.goal.service.ManagedGoalJsonService;
/** Authenticated user surface, intentionally not a model tool. */
@RestController
@RequestMapping("/api/v1/goals/{goalId}/json-acceptance")
@RequiredArgsConstructor
public class GoalJsonAcceptanceController {
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
private final vip.mate.goal.service.GoalJsonBindingService bindings;
@GetMapping
public R<GoalJsonAcceptanceService.View> get(@PathVariable Long goalId, Authentication auth) {
return authenticated(auth, username -> acceptance.get(goalId, username));
}
@PutMapping("/requirements/{criterionKey}")
public R<GoalJsonAcceptanceService.Requirement> configure(@PathVariable Long goalId, @PathVariable String criterionKey,
@RequestBody GoalJsonAcceptanceService.ConfigureRequest request, Authentication auth) {
return authenticated(auth, username -> acceptance.configure(goalId, criterionKey, request, username));
}
@GetMapping("/artifacts")
public R<java.util.List<ManagedGoalJsonService.Slot>> artifacts(@PathVariable Long goalId, Authentication auth) {
return authenticated(auth, username -> artifacts.list(goalId, username));
}
@PostMapping("/artifacts/{slot}")
public R<ManagedGoalJsonService.Artifact> publish(@PathVariable Long goalId, @PathVariable String slot,
@RequestBody ManagedGoalJsonService.PublishRequest request, Authentication auth) {
return authenticated(auth, username -> artifacts.publish(goalId, slot, request, username));
}
@GetMapping("/artifacts/versions/{artifactId}")
public R<ManagedGoalJsonService.Content> version(@PathVariable Long goalId, @PathVariable String artifactId, Authentication auth) {
return authenticated(auth, username -> artifacts.read(goalId, artifactId, username));
}
@GetMapping("/snapshot")
public R<vip.mate.goal.service.GoalJsonBindingService.Snapshot> snapshot(@PathVariable Long goalId, Authentication auth) {
return authenticated(auth, username -> bindings.snapshot(goalId, username));
}
@GetMapping("/checks")
public R<java.util.List<vip.mate.goal.service.GoalJsonBindingService.State>> checks(@PathVariable Long goalId, Authentication auth) {
return authenticated(auth, username -> bindings.state(goalId, username));
}
@PostMapping("/checks/{criterionKey}")
public R<vip.mate.goal.service.GoalJsonBindingService.Check> check(@PathVariable Long goalId, @PathVariable String criterionKey,
@RequestBody vip.mate.goal.service.GoalJsonBindingService.CheckRequest request, Authentication auth) {
return authenticated(auth, username -> bindings.check(goalId, criterionKey, request, username));
}
private <T> R<T> authenticated(Authentication auth, java.util.function.Function<String, T> operation) {
if (auth == null || !auth.isAuthenticated() || !(auth.getDetails() instanceof Long userId)) {
throw new vip.mate.exception.MateClawException(401, "Authenticated account ID required");
}
return R.ok(acceptance.withAuthenticatedUser(userId, auth.getName(), operation));
}
}

View File

@ -58,8 +58,9 @@ public final class GoalCriteriaCodec {
/**
* Merge a per-round verdict delta into the full checklist by id. Criteria
* absent from the delta are preserved unchanged; the criterion text is
* always kept from the existing item (the verdict never carries text).
* absent from the delta retain their state unless their pass lacks evidence.
* The criterion text is always kept from the existing item (the verdict never carries text).
* Duplicate verdict ids are ambiguous and rejected instead of taking the last value.
*/
public static List<GoalCriterion> merge(List<GoalCriterion> existing,
List<GoalChecklistVerdict.CriterionVerdict> verdicts) {
@ -70,25 +71,31 @@ public final class GoalCriteriaCodec {
if (verdicts != null) {
for (GoalChecklistVerdict.CriterionVerdict v : verdicts) {
if (v != null && v.id() != null) {
byId.put(v.id(), v);
if (byId.putIfAbsent(v.id(), v) != null) {
throw new IllegalArgumentException("Duplicate criterion verdict id");
}
}
}
}
List<GoalCriterion> merged = new ArrayList<>(existing.size());
for (GoalCriterion c : existing) {
GoalChecklistVerdict.CriterionVerdict v = byId.get(c.id());
merged.add(v == null
? c
GoalCriterion candidate = v == null ? c
: new GoalCriterion(c.id(), c.text(), v.passed(),
v.evidence() != null ? v.evidence() : ""));
v.evidence() != null ? v.evidence() : "");
// A model boolean alone cannot satisfy even the legacy semantic
// checklist. This checks presence, not truth or execution provenance.
merged.add(candidate.passed() && !hasEvidence(candidate)
? new GoalCriterion(candidate.id(), candidate.text(), false, candidate.evidence())
: candidate);
}
return merged;
}
/** True only when the list is non-empty and every criterion is passed. */
/** True only when the list is non-empty and every criterion is passed with nonblank evidence. */
public static boolean allPassed(List<GoalCriterion> criteria) {
return criteria != null && !criteria.isEmpty()
&& criteria.stream().allMatch(GoalCriterion::passed);
&& criteria.stream().allMatch(c -> c != null && c.passed() && hasEvidence(c));
}
/** Criteria not yet passed (used for the continuation prompt + gap text). */
@ -96,7 +103,11 @@ public final class GoalCriteriaCodec {
if (criteria == null) {
return List.of();
}
return criteria.stream().filter(c -> !c.passed()).toList();
return criteria.stream().filter(c -> !c.passed() || !hasEvidence(c)).toList();
}
private static boolean hasEvidence(GoalCriterion criterion) {
return criterion.evidence() != null && !criterion.evidence().isBlank();
}
/** Reassign stable ids {@code C1..Cn} in list order. */

View File

@ -53,11 +53,17 @@ public class GoalEntity {
/** Long-form objective. Always non-null but may be short. */
private String description;
/** Advances on evaluation-definition edits, independently of optimistic-lock/usage version. */
private long evaluationRevision;
/** User-selected managed JSON acceptance; never falls back to semantic completion. */
private boolean jsonAcceptanceRequired;
/** LLM-readable exit criteria; evaluator scores against this. Nullable. */
@TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS)
private String exitCriteria;
/** Optional per-goal evaluator prompt override; nullable -> default. */
/** Optional evaluation guidance; does not replace platform evidence/output rules. */
@TableField(value = "success_check_prompt", updateStrategy = FieldStrategy.ALWAYS)
private String successCheckPrompt;

View File

@ -37,7 +37,22 @@ public record GoalEvaluationResult(
int llmCallsConsumed,
long latencyMs,
List<GoalChecklistVerdict.CriterionVerdict> criterionVerdicts,
List<GoalCriterion> bootstrapCriteria) {
List<GoalCriterion> bootstrapCriteria,
long evaluationRevision) {
/** Compatibility for pre-revision callers: valid only for an unedited definition (revision zero). */
public GoalEvaluationResult(double score, String gap, String decision, boolean completed,
String evaluatorModel, int llmCallsConsumed, long latencyMs,
List<GoalChecklistVerdict.CriterionVerdict> criterionVerdicts, List<GoalCriterion> bootstrapCriteria) {
this(score, gap, decision, completed, evaluatorModel, llmCallsConsumed, latencyMs,
criterionVerdicts, bootstrapCriteria, 0L);
}
/** Stamp the server-captured revision; the model does not choose this value. */
public GoalEvaluationResult withEvaluationRevision(long revision) {
return new GoalEvaluationResult(score, gap, decision, completed, evaluatorModel, llmCallsConsumed,
latencyMs, criterionVerdicts, bootstrapCriteria, revision);
}
public static final String DECISION_COMPLETED = "completed";
public static final String DECISION_CONTINUE = "continue";

View File

@ -30,6 +30,8 @@ public class GoalResponse {
private GoalStatus status;
private boolean jsonAcceptanceRequired;
/** Opts into durable continuation; zero budgets mean unlimited only in this mode. */
private Boolean persistentExecution;

View File

@ -0,0 +1,124 @@
package vip.mate.goal.service;
import org.springframework.stereotype.Service;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.exception.MateClawException;
import vip.mate.goal.model.SegmentOutcome;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/** Owns the lease and durable checkpoints while the existing channel owns its replay messages. */
@Service
public class GoalApprovalReplayStream {
private final GoalApprovalRunService runs;
private final GoalRunCoordinator coordinator;
public GoalApprovalReplayStream(GoalApprovalRunService runs, GoalRunCoordinator coordinator) {
this.runs=runs; this.coordinator=coordinator;
}
public boolean applies(ChatOrigin origin) { return runs.requiresHandoff(origin); }
public Flux<StreamDelta> replay(ChatOrigin origin, String payload, Function<ChatOrigin, Flux<StreamDelta>> invoke) {
return Flux.using(() -> new Execution(runs.claim(origin, payload)), execution ->
Flux.defer(() -> invoke.apply(execution.claim.origin()))
.doOnSubscribe(subscription -> execution.source = subscription)
.doOnNext(execution::observe)
.takeUntilOther(execution.lost.asMono())
.doOnComplete(execution::complete), Execution::close)
.retryWhen(reactor.util.retry.Retry.fixedDelay(20, java.time.Duration.ofMillis(25))
.filter(GoalApprovalRunService.SettlementPending.class::isInstance)
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
}
private final class Execution implements AutoCloseable {
private final GoalApprovalRunService.ReplayRun claim;
private final Sinks.One<StreamDelta> lost = Sinks.one();
private final Set<String> inFlight = new HashSet<>();
private final Disposable renewal;
private volatile org.reactivestreams.Subscription source;
private volatile boolean sourceCompleted;
private boolean unknown = true;
private boolean awaitingApproval;
private boolean evaluationUnavailable;
private String finishReason = "approval_replay_completed";
Execution(GoalApprovalRunService.ReplayRun claim) {
this.claim=claim;
// A crash before the forced approved call reports its outcome must not replay it blindly.
checkpoint("uncertain", "approval_replay_started");
renewal = Schedulers.boundedElastic().schedulePeriodically(() -> {
try {
if (!coordinator.renew(claim.run(), LocalDateTime.now())) {
lost.tryEmitError(new MateClawException(409, "Approved Goal execution lost its owner lease"));
}
} catch (RuntimeException error) { lost.tryEmitError(error); }
}, 20, 20, TimeUnit.SECONDS);
}
private void checkpoint(String safety, String kind) {
if (!coordinator.checkpoint(claim.run(), safety, kind, null, LocalDateTime.now())) {
throw new MateClawException(409, "Approved Goal execution lost its checkpoint fence");
}
}
void observe(StreamDelta delta) {
String event = delta.eventType();
var data = delta.eventData();
if ("tool_call_started".equals(event)) {
Object id = data == null ? null : data.get("toolCallId");
if (id != null && !String.valueOf(id).isBlank()) { inFlight.add(String.valueOf(id)); unknown=false; }
else { inFlight.add("<unknown>"); unknown=true; }
checkpoint("uncertain", "tool_started");
} else if ("tool_call_completed".equals(event)) {
Object id = data == null ? null : data.get("toolCallId");
if (id != null) inFlight.remove(String.valueOf(id));
// ReAct may deliver these events after a whole action node returns. A later
// tool can already be executing before its start delta arrives, so retain
// uncertainty until the entire replay stream terminates normally.
checkpoint("uncertain", "tool_completed");
} else if ("tool_approval_requested".equals(event)) {
awaitingApproval=true;
// This exact call was deferred by the guard and has not executed.
Object id = data == null ? null : data.get("toolCallId");
if (id != null && !String.valueOf(id).isBlank()) inFlight.remove(String.valueOf(id));
} else if ("goal_evaluated".equals(event) && data != null) {
evaluationUnavailable = Boolean.TRUE.equals(data.get("skipped")) || "fallback".equals(data.get("decision"));
} else if ("finish_reason".equals(event) && data != null && data.get("reason") != null) {
finishReason=String.valueOf(data.get("reason"));
}
}
void complete() {
sourceCompleted=true;
// On error/cancellation, or an unresolved tool even on normal termination, keep the
// last checkpoint for existing expiry recovery (which pauses uncertain side effects).
if (unknown || !inFlight.isEmpty()) return;
checkpoint("resolved", "approval_replay_finished");
SegmentOutcome outcome = awaitingApproval ? new SegmentOutcome.AwaitApproval("approval_required")
: "error_fallback".equals(finishReason) ? new SegmentOutcome.Blocked("approval", finishReason)
: evaluationUnavailable ? new SegmentOutcome.Retry("evaluation", "evaluation_unavailable")
: new SegmentOutcome.Continue(finishReason);
if (!coordinator.settle(claim.run(), outcome, LocalDateTime.now())) {
throw new MateClawException(409, "Approved Goal execution lost its settlement fence");
}
}
@Override public void close() {
renewal.dispose();
// Explicitly cancel the producer when lease failure wins the other publisher.
// Merely signalling an error downstream must not leave the graph subscribed.
var subscription = source;
if (!sourceCompleted && subscription != null) subscription.cancel();
}
}
}

View File

@ -0,0 +1,237 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.exception.MateClawException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
/** A consumed approval may start one new owner; it never revives its original lease. */
@Service
public class GoalApprovalRunService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
private final GoalContinuationStore continuations;
private final GoalAttemptStore attempts;
private final GoalRunCoordinator coordinator;
private final GoalService goals;
public GoalApprovalRunService(JdbcTemplate jdbc, ObjectMapper json, GoalJsonAcceptanceService acceptance,
ManagedGoalJsonService artifacts, GoalContinuationStore continuations,
GoalAttemptStore attempts, GoalRunCoordinator coordinator, GoalService goals) {
this.jdbc=jdbc; this.json=json; this.acceptance=acceptance; this.artifacts=artifacts;
this.continuations=continuations; this.attempts=attempts; this.coordinator=coordinator; this.goals=goals;
}
public record ReplayRun(GoalRunCoordinator.ClaimedRun run, ChatOrigin origin) { }
/** Persist the selected interactive Goal on the approval's origin snapshot. */
public ChatOrigin captureSelectedGoal(ChatOrigin origin) {
if (origin == null || origin.cronOrigin() || origin.requesterUserId() == null
|| origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null
|| (origin.selectedGoalId() != null && origin.selectedGoalId() > 0)
|| (origin.executionAttribution() != null
&& origin.executionAttribution().goalAttemptId() != null)) return origin;
var selected = jdbc.queryForList("""
SELECT id FROM mate_agent_goal
WHERE conversation_id=? AND agent_id=? AND workspace_id=?
AND json_acceptance_required=TRUE AND status IN ('active','paused') AND deleted=0
""", Long.class, origin.conversationId(), origin.agentId(), origin.workspaceId());
if (selected.size() > 1) throw rejected();
// Zero records an explicit non-managed snapshot. Null is reserved for
// approvals persisted by older binaries that had no capture field.
if (selected.isEmpty()) return origin.withSelectedGoalId(0L);
return origin.withSelectedGoalId(selected.getFirst());
}
/** A queued selection snapshot, including explicit zero, must still match before execution starts. */
public boolean queuedSelectionStillCurrent(ChatOrigin origin) {
if (origin == null || origin.selectedGoalId() == null || origin.selectedGoalId() < 0
|| origin.requesterUserId() == null || origin.requesterId() == null
|| origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null)
return false;
try {
return acceptance.withAuthenticatedUser(origin.requesterUserId(), origin.requesterId(), current -> {
if (origin.selectedGoalId() == 0) {
Integer selected = jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_agent_goal
WHERE conversation_id=? AND agent_id=? AND workspace_id=?
AND json_acceptance_required=TRUE AND status IN ('active','paused') AND deleted=0
""", Integer.class, origin.conversationId(), origin.agentId(), origin.workspaceId());
return selected != null && selected == 0;
}
var scope = acceptance.authorizedGoal(origin.selectedGoalId(), current, true);
return scope.required() && java.util.List.of("active", "paused").contains(scope.status())
&& Objects.equals(scope.conversationId(), origin.conversationId())
&& Objects.equals(scope.workspaceId(), origin.workspaceId())
&& Objects.equals(scope.agentId(), origin.agentId());
});
} catch (vip.mate.exception.MateClawException stale) {
return false;
}
}
public boolean requiresHandoff(ChatOrigin origin) {
var link = origin == null ? null : origin.executionAttribution();
if (link == null || link.goalId() == null || link.approvalId() == null) return false;
var required = jdbc.queryForList("SELECT json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0",
Boolean.class, link.goalId());
return required.isEmpty() || Boolean.TRUE.equals(required.getFirst());
}
/** Selected interactive Goals must retain their original authenticated requester at approval time. */
public boolean requiresCurrentApprover(ChatOrigin origin) {
if (requiresHandoff(origin)) return true;
if (origin != null && origin.selectedGoalId() != null && origin.selectedGoalId() > 0) return true;
if (origin == null || origin.conversationId() == null || origin.agentId() == null) return false;
if (legacyTerminalSelection(origin)) return true;
Integer selected = jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_agent_goal
WHERE conversation_id=? AND agent_id=? AND json_acceptance_required=TRUE
AND status IN ('active','paused') AND deleted=0
""", Integer.class, origin.conversationId(), origin.agentId());
return selected != null && selected > 0;
}
/** Revalidate the captured Goal before an approval can be consumed. */
public void validateCapturedForApproval(ChatOrigin origin, String username, boolean approve) {
if (approve && legacyTerminalSelection(origin)) throw rejected();
var link = origin == null ? null : origin.executionAttribution();
Long goalId = origin == null ? null : origin.selectedGoalId();
if (link != null && link.goalId() != null && link.approvalId() != null
&& (link.goalAttemptId() == null || link.ownerFence() == null)) throw rejected();
boolean scheduled = link != null && link.goalAttemptId() != null;
if (goalId != null && goalId == 0L) goalId = null;
if (goalId == null && link != null) goalId = link.goalId();
if (goalId == null) return;
var scope = acceptance.authorizedGoal(goalId, username, true);
if (!scope.required() || (approve && !(scheduled ? "active".equals(scope.status())
: java.util.List.of("active", "paused").contains(scope.status())))
|| !Objects.equals(scope.conversationId(), origin.conversationId())
|| !Objects.equals(scope.workspaceId(), origin.workspaceId())
|| !Objects.equals(scope.agentId(), origin.agentId())) throw rejected();
}
/** Without a persisted origin, a managed approval cannot safely execute after an upgrade. */
public boolean hasManagedGoalHistory(String conversationId, String agentId) {
if (conversationId == null) return false;
Long agent = null;
try { if (agentId != null) agent = Long.parseLong(agentId); }
catch (NumberFormatException invalid) { /* unknown agent: check the whole conversation */ }
Integer count = agent == null
? jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_agent_goal
WHERE conversation_id=? AND json_acceptance_required=TRUE
""", Integer.class, conversationId)
: jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_agent_goal
WHERE conversation_id=? AND agent_id=? AND json_acceptance_required=TRUE
""", Integer.class, conversationId, agent);
return count != null && count > 0;
}
/** Old pending rows lacked a selection snapshot; ambiguity near a terminal Goal fails closed. */
private boolean legacyTerminalSelection(ChatOrigin origin) {
if (origin == null || origin.selectedGoalId() != null || origin.requesterUserId() == null
|| origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null)
return false;
var link = origin.executionAttribution();
if (link == null || link.approvalId() == null) return false;
Integer count = jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_agent_goal g
JOIN mate_tool_approval p ON p.pending_id=? AND p.deleted=0
WHERE g.conversation_id=? AND g.agent_id=? AND g.workspace_id=?
AND g.json_acceptance_required=TRUE
AND (g.status IN ('completed','abandoned','exhausted') OR g.deleted<>0)
""", Integer.class, link.approvalId(), origin.conversationId(), origin.agentId(), origin.workspaceId());
return count != null && count > 0;
}
@Transactional
public ReplayRun claim(ChatOrigin requested, String toolCallPayload) {
ExecutionAttribution link = requested == null ? null : requested.executionAttribution();
if (link == null || link.goalId() == null || link.goalAttemptId() == null
|| link.ownerFence() == null || link.approvalId() == null
|| link.cronRunId() != null || requested.cronOrigin()) throw rejected();
// Preserve the normal user -> conversation -> Goal lock order.
var owners = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0",
String.class, requested.conversationId());
if (owners.size()!=1) throw rejected();
var scope = acceptance.authorizedGoal(link.goalId(), owners.getFirst(), true);
if (!scope.required() || !"active".equals(scope.status())) throw rejected();
if (!Objects.equals(scope.conversationId(), requested.conversationId())
|| !Objects.equals(scope.workspaceId(), requested.workspaceId())
|| !Objects.equals(scope.agentId(), requested.agentId())) throw rejected();
var candidate = continuations.getForUpdate(link.goalId());
if (candidate != null && "running".equals(candidate.state())
&& Objects.equals(candidate.currentAttemptId(), link.goalAttemptId())
&& Objects.equals(candidate.leaseOwner(), link.ownerFence())
&& continuations.matchesFence(link.goalId(), link.ownerFence(), link.goalAttemptId(),
candidate.revision(), Instant.now().getEpochSecond())
&& attempts.hasLiveFence(link.goalAttemptId(), link.ownerFence(), Instant.now().getEpochSecond())) {
throw new SettlementPending();
}
if (candidate == null || !"waiting_approval".equals(candidate.state())) throw rejected();
var parent = attempts.getForUpdate(link.goalAttemptId());
if (parent == null || !Objects.equals(parent.goalId(), link.goalId())
|| !Objects.equals(parent.conversationId(), requested.conversationId())
|| !Objects.equals(parent.leaseToken(), link.ownerFence()) || !"succeeded".equals(parent.state())) throw rejected();
var approvals = jdbc.query("""
SELECT conversation_id,agent_id,status,tool_call_payload,chat_origin
FROM mate_tool_approval WHERE pending_id=? AND deleted=0 FOR UPDATE
""", (r,i) -> new Approval(r.getString("conversation_id"), r.getString("agent_id"),
r.getString("status"), r.getString("tool_call_payload"), r.getString("chat_origin")), link.approvalId());
if (approvals.size()!=1) throw rejected();
var approval = approvals.getFirst();
if (!"CONSUMED".equals(approval.status()) || !Objects.equals(toolCallPayload, approval.payload())
|| !Objects.equals(requested.conversationId(), approval.conversationId())
|| !Objects.equals(String.valueOf(requested.agentId()), approval.agentId())) throw rejected();
ChatOrigin persisted;
try { persisted = json.readValue(approval.origin(), ChatOrigin.class); }
catch (Exception error) { throw rejected(); }
if (persisted == null || persisted.executionAttribution() == null
|| persisted.cronOrigin() || persisted.executionAttribution().cronRunId() != null
|| !Objects.equals(persisted.executionAttribution().goalId(), link.goalId())
|| !Objects.equals(persisted.executionAttribution().goalAttemptId(), link.goalAttemptId())
|| !Objects.equals(persisted.executionAttribution().ownerFence(), link.ownerFence())
|| !Objects.equals(persisted.agentId(), requested.agentId())
|| !Objects.equals(persisted.workspaceId(), requested.workspaceId())
|| !Objects.equals(persisted.conversationId(), requested.conversationId())) throw rejected();
if (!jdbc.queryForList("SELECT attempt_id FROM mate_goal_attempt WHERE approval_pending_id=? FOR UPDATE",
String.class, link.approvalId()).isEmpty()) throw rejected();
Instant instant = Instant.now();
LocalDateTime now = LocalDateTime.ofInstant(instant, java.time.ZoneId.systemDefault());
long untilEpoch = instant.getEpochSecond()+60;
String token = UUID.randomUUID().toString();
if (!continuations.claimApproval(link.goalId(), parent.id(), token, now, untilEpoch)) throw rejected();
var claimed = continuations.get(link.goalId());
var attempt = attempts.create(link.goalId(), requested.conversationId(), parent.id(), "approval",
token, GoalLeaseTime.local(untilEpoch), null, now, untilEpoch);
jdbc.update("UPDATE mate_goal_attempt SET approval_pending_id=? WHERE attempt_id=?", link.approvalId(), attempt.id());
if (!continuations.bindAttempt(link.goalId(), token, attempt.id(), claimed.revision())) throw rejected();
var run = new GoalRunCoordinator.ClaimedRun(candidate, goals.getById(link.goalId()), attempt, claimed.revision()+1);
if (!coordinator.markRunning(run, now)) throw rejected();
ChatOrigin origin = persisted.withBaseUrl(requested.baseUrl()).withExecutionAttribution(
new ExecutionAttribution(link.goalId(), attempt.id(), null, link.approvalId(), token));
// Recheck current conversation scope and both new lease rows before committing the claim.
ManagedGoalJsonService.verifyLease(artifacts.runtimeGoal(origin));
return new ReplayRun(run, origin);
}
private record Approval(String conversationId, String agentId, String status, String payload, String origin) { }
static final class SettlementPending extends MateClawException {
SettlementPending() { super(409, "The original Goal attempt is still settling its approval"); }
}
private static MateClawException rejected() {
return new MateClawException(409, "Approved Goal execution cannot acquire a current owner; resume from current Goal state");
}
}

View File

@ -23,22 +23,37 @@ public class GoalAttemptStore {
public GoalAttempt create(Long goalId, String conversationId, String parentAttemptId,
String triggerType, String leaseToken, LocalDateTime leaseUntil,
Long inputItemId, LocalDateTime now) {
return create(goalId, conversationId, parentAttemptId, triggerType, leaseToken,
leaseUntil, inputItemId, now, GoalLeaseTime.epoch(leaseUntil));
}
GoalAttempt create(Long goalId, String conversationId, String parentAttemptId,
String triggerType, String leaseToken, LocalDateTime leaseUntil,
Long inputItemId, LocalDateTime now, long leaseEpoch) {
String id = UUID.randomUUID().toString();
jdbc.update("""
INSERT INTO mate_goal_attempt(
attempt_id,goal_id,conversation_id,parent_attempt_id,trigger_type,state,
lease_token,lease_until,input_item_id,replay_safety,checkpoint_type,
lease_token,lease_until,lease_until_epoch_second,input_item_id,replay_safety,checkpoint_type,
created_at,updated_at)
VALUES(?,?,?,?,?,'claimed',?,?,?,'safe','claimed',?,?)
VALUES(?,?,?,?,?,'claimed',?,?,?,?,'safe','claimed',?,?)
""", id, goalId, conversationId, parentAttemptId, triggerType, leaseToken,
leaseUntil, inputItemId, now, now);
leaseUntil, leaseEpoch, inputItemId, now, now);
return get(id);
}
public GoalAttempt get(String id) {
return get(id, false);
}
GoalAttempt getForUpdate(String id) {
return get(id, true);
}
private GoalAttempt get(String id, boolean lock) {
List<GoalAttempt> rows = jdbc.query("""
SELECT * FROM mate_goal_attempt WHERE attempt_id=?
""", (rs, row) -> read(rs), id);
""" + (lock ? " FOR UPDATE" : ""), (rs, row) -> read(rs), id);
return rows.isEmpty() ? null : rows.getFirst();
}
@ -49,6 +64,13 @@ public class GoalAttemptStore {
""", (rs, row) -> read(rs), goalId, Math.max(1, Math.min(limit, 100)));
}
public boolean hasLiveFence(String id, String token, long nowEpoch) {
return jdbc.queryForList("""
SELECT attempt_id FROM mate_goal_attempt WHERE attempt_id=? AND lease_token=?
AND state IN ('claimed','running') AND lease_until_epoch_second>? FOR UPDATE
""", String.class, id, token, nowEpoch).size() == 1;
}
public boolean markRunning(String id, String leaseToken, LocalDateTime now) {
return jdbc.update("""
UPDATE mate_goal_attempt SET state='running',started_at=?,updated_at=?
@ -58,10 +80,14 @@ public class GoalAttemptStore {
public boolean renew(String id, String leaseToken, LocalDateTime leaseUntil,
LocalDateTime now) {
return renew(id, leaseToken, leaseUntil, now, GoalLeaseTime.epoch(leaseUntil));
}
boolean renew(String id, String leaseToken, LocalDateTime leaseUntil, LocalDateTime now, long leaseEpoch) {
return jdbc.update("""
UPDATE mate_goal_attempt SET lease_until=?,updated_at=?
UPDATE mate_goal_attempt SET lease_until=?,lease_until_epoch_second=?,updated_at=?
WHERE attempt_id=? AND lease_token=? AND state IN ('claimed','running')
""", leaseUntil, now, id, leaseToken) == 1;
""", leaseUntil, leaseEpoch, now, id, leaseToken) == 1;
}
public boolean checkpoint(String id, String leaseToken, String replaySafety,
@ -89,11 +115,15 @@ public class GoalAttemptStore {
}
public List<GoalAttempt> expired(LocalDateTime now, int limit) {
return expired(GoalLeaseTime.epoch(now), limit);
}
List<GoalAttempt> expired(long nowEpoch, int limit) {
return jdbc.query("""
SELECT * FROM mate_goal_attempt
WHERE state IN ('claimed','running') AND lease_until<=?
ORDER BY lease_until,created_at LIMIT ?
""", (rs, row) -> read(rs), now, Math.max(1, Math.min(limit, 100)));
WHERE state IN ('claimed','running') AND lease_until_epoch_second<=?
ORDER BY lease_until_epoch_second,created_at LIMIT ?
""", (rs, row) -> read(rs), nowEpoch, Math.max(1, Math.min(limit, 100)));
}
private static GoalAttempt read(ResultSet rs) throws SQLException {
@ -101,7 +131,7 @@ public class GoalAttemptStore {
rs.getString("attempt_id"), rs.getLong("goal_id"),
rs.getString("conversation_id"), rs.getString("parent_attempt_id"),
rs.getString("trigger_type"), rs.getString("state"),
rs.getString("lease_token"), time(rs, "lease_until"),
rs.getString("lease_token"), GoalLeaseTime.local(rs.getLong("lease_until_epoch_second")),
nullableLong(rs, "input_item_id"), nullableLong(rs, "assistant_message_id"),
rs.getString("replay_safety"), rs.getString("checkpoint_type"),
rs.getString("finish_reason"), rs.getString("error_category"),

View File

@ -18,7 +18,7 @@ public class GoalContinuationStore {
""";
private static final String DUE = """
((c.state IN ('queued','retry') AND c.next_run_at<=?)
OR (c.state='running' AND c.lease_until<=?))
OR (c.state='running' AND c.lease_until_epoch_second<=?))
""";
public GoalContinuationStore(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@ -35,6 +35,11 @@ public class GoalContinuationStore {
}
}
/** Coordinate runtime publication, scheduler settlement and recovery using the goal lock first. */
public boolean lockGoal(Long goalId) {
return jdbc.queryForList("SELECT id FROM mate_agent_goal WHERE id=? FOR UPDATE", Long.class, goalId).size() == 1;
}
public void discover(LocalDateTime now) {
// Bounded discovery; another instance may insert the same goal concurrently.
List<Long> ids = jdbc.queryForList("""
@ -58,33 +63,56 @@ public class GoalContinuationStore {
SELECT c.*,g.conversation_id FROM mate_goal_continuation c
JOIN mate_agent_goal g ON g.id=c.goal_id WHERE
""" + ELIGIBLE + " AND " + DUE + " ORDER BY c.next_run_at,c.goal_id LIMIT ?",
(rs, row) -> read(rs), now, now, Math.max(1, Math.min(limit, 100)));
(rs, row) -> read(rs), now, GoalLeaseTime.epoch(now), Math.max(1, Math.min(limit, 100)));
}
public Continuation get(Long goalId) {
return get(goalId, false);
}
Continuation getForUpdate(Long goalId) {
return get(goalId, true);
}
private Continuation get(Long goalId, boolean lock) {
List<Continuation> rows = jdbc.query("""
SELECT c.*,g.conversation_id FROM mate_goal_continuation c
JOIN mate_agent_goal g ON g.id=c.goal_id WHERE c.goal_id=?
""", (rs, row) -> read(rs), goalId);
""" + (lock ? " FOR UPDATE" : ""), (rs, row) -> read(rs), goalId);
return rows.isEmpty() ? null : rows.getFirst();
}
public boolean claim(Long goalId, String token, LocalDateTime now, LocalDateTime until) {
return claim(goalId, token, now, until, GoalLeaseTime.epoch(now), GoalLeaseTime.epoch(until));
}
boolean claim(Long goalId, String token, LocalDateTime now, LocalDateTime until, long nowEpoch, long untilEpoch) {
return jdbc.update("""
UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,updated_at=?,
wake_requested=FALSE,revision=revision+1
UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,lease_until_epoch_second=?,updated_at=?,
wake_requested=FALSE,waiting_approval_attempt_id=NULL,revision=revision+1
WHERE goal_id=? AND
((state IN ('queued','retry') AND next_run_at<=?)
OR (state='running' AND lease_until<=?))
OR (state='running' AND lease_until_epoch_second<=?))
AND EXISTS(SELECT 1 FROM mate_agent_goal g WHERE g.id=goal_id AND
""" + ELIGIBLE + ")", token, until, now, goalId, now, now) == 1;
""" + ELIGIBLE + ")", token, until, untilEpoch, now, goalId, now, nowEpoch) == 1;
}
public boolean renew(Long goalId, String token, LocalDateTime until) {
return jdbc.update("""
UPDATE mate_goal_continuation SET lease_until=?
UPDATE mate_goal_continuation SET lease_until=?,lease_until_epoch_second=?
WHERE goal_id=? AND lease_owner=? AND state='running'
""", until, goalId, token) == 1;
""", until, GoalLeaseTime.epoch(until), goalId, token) == 1;
}
boolean claimApproval(Long goalId, String parentAttemptId, String token, LocalDateTime now, long untilEpoch) {
return jdbc.update("""
UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,
lease_until_epoch_second=?,updated_at=?,wake_requested=FALSE,
waiting_approval_attempt_id=NULL,revision=revision+1
WHERE goal_id=? AND state='waiting_approval' AND waiting_approval_attempt_id=?
AND current_attempt_id IS NULL AND lease_owner IS NULL
AND EXISTS(SELECT 1 FROM mate_agent_goal g WHERE g.id=goal_id AND
""" + ELIGIBLE + ")", token, GoalLeaseTime.local(untilEpoch), untilEpoch, now, goalId, parentAttemptId) == 1;
}
public boolean bindAttempt(Long goalId, String token, String attemptId, long expectedRevision) {
@ -95,21 +123,24 @@ public class GoalContinuationStore {
""", attemptId, LocalDateTime.now(), goalId, token, expectedRevision) == 1;
}
public boolean matchesFence(Long goalId, String token, String attemptId, long revision) {
Integer count=jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_goal_continuation
public boolean matchesFence(Long goalId, String token, String attemptId, long revision, long nowEpoch) {
return jdbc.queryForList("""
SELECT goal_id FROM mate_goal_continuation
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=?
AND revision=? AND state='running'
""",Integer.class,goalId,token,attemptId,revision);
return count!=null && count==1;
AND revision=? AND state='running' AND lease_until_epoch_second>? FOR UPDATE
""",Long.class,goalId,token,attemptId,revision,nowEpoch).size()==1;
}
public boolean renewFenced(Long goalId,String token,String attemptId,long revision,LocalDateTime until) {
return renewFenced(goalId, token, attemptId, revision, until, GoalLeaseTime.epoch(until));
}
boolean renewFenced(Long goalId,String token,String attemptId,long revision,LocalDateTime until,long untilEpoch) {
return jdbc.update("""
UPDATE mate_goal_continuation SET lease_until=?,updated_at=?
UPDATE mate_goal_continuation SET lease_until=?,lease_until_epoch_second=?,updated_at=?
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=?
AND revision=? AND state='running'
""",until,LocalDateTime.now(),goalId,token,attemptId,revision)==1;
""",until,untilEpoch,LocalDateTime.now(),goalId,token,attemptId,revision)==1;
}
public boolean settleFenced(Long goalId,String token,String attemptId,long revision,String state,
@ -117,36 +148,47 @@ public class GoalContinuationStore {
return jdbc.update("""
UPDATE mate_goal_continuation
SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END,
next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,
next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,
waiting_approval_attempt_id=CASE WHEN ?='waiting_approval' THEN current_attempt_id ELSE NULL END,
current_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND revision=? AND state='running'
""",state,state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,revision)==1;
""",state,state,nextRunAt,failures,bounded(reason),state,now,goalId,token,attemptId,revision)==1;
}
public boolean recoverExpired(Long goalId,String token,String attemptId,LocalDateTime expiredAt,
/** Current read under the caller's goal lock, before recovery mutates its attempt. */
boolean hasExpiredFence(Long goalId, String token, String attemptId, long cutoffEpoch) {
return jdbc.queryForList("""
SELECT goal_id FROM mate_goal_continuation
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND state='running'
AND lease_until_epoch_second<=? FOR UPDATE
""", Long.class, goalId, token, attemptId, cutoffEpoch).size() == 1;
}
public boolean recoverExpired(Long goalId,String token,String attemptId,long expiredEpoch,
String state,LocalDateTime nextRunAt,int failures,String reason,LocalDateTime now) {
return jdbc.update("""
UPDATE mate_goal_continuation
SET state=?,next_run_at=?,failures=?,reason=?,wake_requested=FALSE,
lease_owner=NULL,lease_until=NULL,current_attempt_id=NULL,revision=revision+1,updated_at=?
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,
waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND state='running'
AND lease_until<=?
""",state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,expiredAt)==1;
AND lease_until_epoch_second<=?
""",state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,expiredEpoch)==1;
}
public boolean settle(Long goalId, String token, String state, LocalDateTime nextRunAt,
int failures, String reason) {
return jdbc.update("""
UPDATE mate_goal_continuation SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END,
next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,updated_at=?
next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,updated_at=?
WHERE goal_id=? AND lease_owner=? AND state='running'
""", state, state, nextRunAt, failures, bounded(reason), LocalDateTime.now(), goalId, token) == 1;
}
public void suspendConversation(String conversationId, String reason) {
jdbc.update("""
UPDATE mate_goal_continuation SET state='paused',reason=?,lease_owner=NULL,lease_until=NULL,
current_attempt_id=NULL,revision=revision+1,updated_at=?
UPDATE mate_goal_continuation SET state='paused',reason=?,lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,
current_attempt_id=NULL,waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id IN (SELECT id FROM mate_agent_goal WHERE conversation_id=?)
""", bounded(reason), LocalDateTime.now(), conversationId);
}
@ -154,7 +196,8 @@ public class GoalContinuationStore {
public void resume(Long goalId, LocalDateTime now) {
jdbc.update("""
UPDATE mate_goal_continuation SET state='queued',next_run_at=?,failures=0,reason='resumed',
lease_owner=NULL,lease_until=NULL,current_attempt_id=NULL,revision=revision+1,updated_at=?
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,
waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND state<>'running'
""", now, now, goalId);
}
@ -172,7 +215,7 @@ public class GoalContinuationStore {
Timestamp until = rs.getTimestamp("lease_until");
return new Continuation(rs.getLong("goal_id"), rs.getString("conversation_id"), rs.getString("state"),
rs.getTimestamp("next_run_at").toLocalDateTime(), rs.getString("lease_owner"),
until == null ? null : until.toLocalDateTime(), rs.getInt("failures"), rs.getString("reason"),
until == null ? null : GoalLeaseTime.local(rs.getLong("lease_until_epoch_second")), rs.getInt("failures"), rs.getString("reason"),
rs.getString("current_attempt_id"),rs.getLong("revision"));
}

View File

@ -66,7 +66,7 @@ public class GoalContinuationSupervisor {
public void tick() {
if (closing || !properties.isEnabled() || !properties.isAllowAutoFollowup()) return;
LocalDateTime now = LocalDateTime.now(clock);
recovery.recoverExpired(now);
recovery.recoverExpired(clock.instant());
active.forEach((id, claimed) -> {
GoalEntity goal = goals.getById(id);
boolean cancelled = goal.getStatus()==GoalStatus.PAUSED || goal.getStatus()==GoalStatus.ABANDONED
@ -124,7 +124,11 @@ public class GoalContinuationSupervisor {
case CONTINUE -> { }
}
if(!coordinator.markRunning(claimed,now)) return;
SegmentOutcome outcome = runner.run(claimed,decision.prompt(),"running".equals(claimed.candidate().state()));
// 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
|| "running".equals(claimed.candidate().state());
SegmentOutcome outcome = runner.run(claimed,decision.prompt(),recovered);
if (outcome instanceof SegmentOutcome.Retry retry
&& ("provider".equals(retry.category()) || "evaluation".equals(retry.category()))) {
activateProviderBackoff(LocalDateTime.now(clock));

View File

@ -70,6 +70,7 @@ public class GoalEvaluationService implements Evaluator {
private static final int MAX_OUTPUT_TOKENS = 2000;
private static final int MAX_CONVERSATION_CHARS = 6_000;
private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000;
private static final int MAX_SUCCESS_CHECK_CHARS = 4_000;
private static final int MIN_BOOTSTRAP_CRITERIA = 1;
private static final int MAX_BOOTSTRAP_CRITERIA = 8;
/** Skip-retry template — the goal node has its own try/catch. */
@ -80,10 +81,8 @@ public class GoalEvaluationService implements Evaluator {
private final ProviderChatModelFactory chatModelFactory;
private final ObjectMapper objectMapper;
private final BeanOutputConverter<GoalCriteriaDraft> draftConverter =
new BeanOutputConverter<>(GoalCriteriaDraft.class);
private final BeanOutputConverter<GoalChecklistVerdict> verdictConverter =
new BeanOutputConverter<>(GoalChecklistVerdict.class);
private final BeanOutputConverter<GoalCriteriaDraft> draftConverter;
private final BeanOutputConverter<GoalChecklistVerdict> verdictConverter;
public GoalEvaluationService(GoalProperties properties,
ModelConfigService modelConfigService,
@ -93,6 +92,14 @@ public class GoalEvaluationService implements Evaluator {
this.modelConfigService = modelConfigService;
this.chatModelFactory = chatModelFactory;
this.objectMapper = objectMapper;
// Preserve converter tolerance for extra fields, but never silently
// choose the last value of an ambiguous model-produced JSON key.
ObjectMapper evaluatorJson = objectMapper.copy()
.disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
this.draftConverter = new BeanOutputConverter<>(GoalCriteriaDraft.class, evaluatorJson);
this.verdictConverter = new BeanOutputConverter<>(GoalChecklistVerdict.class, evaluatorJson);
}
/**
@ -122,6 +129,7 @@ public class GoalEvaluationService implements Evaluator {
List<GoalCriterion> existing = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper);
boolean bootstrap = existing.isEmpty();
long evaluationRevision = goal.getEvaluationRevision();
long start = System.currentTimeMillis();
try {
@ -148,16 +156,18 @@ public class GoalEvaluationService implements Evaluator {
if (body == null || body.isBlank()) {
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
// The call was really spent bill it.
return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed);
return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed)
.withEvaluationRevision(evaluationRevision);
}
return bootstrap
return (bootstrap
? parseBootstrap(body, model.getModelName(), elapsed)
: parseVerdict(body, existing, model.getModelName(), elapsed);
: parseVerdict(body, existing, model.getModelName(), elapsed))
.withEvaluationRevision(evaluationRevision);
} catch (Throwable t) {
long elapsed = System.currentTimeMillis() - start;
log.warn("[GoalEvaluation] evaluator call failed after {}ms: {}", elapsed, t.toString());
return GoalEvaluationResult.fallback("call_failed");
return GoalEvaluationResult.fallback("call_failed").withEvaluationRevision(evaluationRevision);
}
}
@ -229,7 +239,8 @@ public class GoalEvaluationService implements Evaluator {
+ "Revoke it when such contradictory evidence exists, citing that evidence. "
+ "An attempted action, a goal description or a claim of completion is not proof. "
+ "Newly passed criteria require concrete observable evidence. Return only changed "
+ "criterion verdicts; omitted criteria retain their previous state. Keep evidence concise. "
+ "criterion verdicts; omitted criteria retain their previous state. Return at most one "
+ "verdict per criterion id. Keep evidence concise. "
+ "Output only the requested JSON.";
private String buildUserPrompt(GoalEntity goal,
@ -247,6 +258,16 @@ public class GoalEvaluationService implements Evaluator {
}
sb.append('\n');
String guidance = goal.getSuccessCheckPrompt();
if (guidance != null && !guidance.isBlank()) {
sb.append("Goal-specific success-check guidance (within the checklist evidence and JSON output rules):\n");
sb.append(guidance, 0, Math.min(guidance.length(), MAX_SUCCESS_CHECK_CHARS));
if (guidance.length() > MAX_SUCCESS_CHECK_CHARS) {
sb.append("\n[success-check guidance truncated]");
}
sb.append("\n\n");
}
if (!bootstrap) {
sb.append("Current checklist (judge each by id):\n");
for (GoalCriterion c : existing) {

View File

@ -46,7 +46,8 @@ public class GoalFollowupService {
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
boolean claimedComplete = !fallback && (result.completed()
|| GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision()));
boolean completionUnverified = claimedComplete && persistent && !hasVerifiedChecklist(goal);
boolean completionUnverified = claimedComplete && (goal.isJsonAcceptanceRequired()
|| (persistent && !hasVerifiedChecklist(goal)));
if (claimedComplete && !completionUnverified) {
return decision(Action.COMPLETE, null, null, "criteria_completed");
}
@ -125,6 +126,7 @@ public class GoalFollowupService {
if (gap != null && !gap.isBlank()) {
prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000));
}
if (goal.isJsonAcceptanceRequired()) prompt.append("\n").append(GoalJsonProtocolHints.INSTRUCTIONS);
if (persistent) {
prompt.append("\nIf essential input or permission is still unavailable after checking existing state, ")
.append("call waitForGoalInput with the precise missing requirement and ask the user once. ")

View File

@ -0,0 +1,130 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.util.List;
import java.util.Objects;
/** User-managed requirements. Agent tools must not expose this configuration surface. */
@Service
public class GoalJsonAcceptanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public GoalJsonAcceptanceService(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
public record ConfigureRequest(Long expectedRevision, String artifactSlot, List<String> requiredFields) { }
public record Requirement(String criterionKey, String artifactSlot, long revision,
List<String> requiredFields, String configuredBy) { }
public record View(boolean required, String status, List<Requirement> requirements) { }
record GoalScope(long id, String conversationId, long workspaceId, long agentId, String status, boolean required) { }
/** Keep the HTTP caller's immutable identity locked through the complete managed operation. */
@Transactional
public <T> T withAuthenticatedUser(Long userId, String username, java.util.function.Function<String, T> operation) {
if (userId == null || username == null || username.isBlank()) throw failure(401, "Authenticated account ID required");
List<String> names = jdbc.queryForList("SELECT username FROM mate_user WHERE id=? AND enabled=TRUE AND deleted=0 FOR UPDATE", String.class, userId);
if (names.size() != 1 || !username.equals(names.getFirst())) throw failure(403, "Authenticated account is no longer current");
return operation.apply(names.getFirst());
}
@Transactional
public View get(Long goalId, String username) {
GoalScope goal = authorizedGoal(goalId, username, true);
return new View(goal.required(), goal.status(), requirements(goalId));
}
@Transactional
public Requirement configure(Long goalId, String criterionKey, ConfigureRequest request, String username) {
GoalScope goal = authorizedGoal(goalId, username, true);
if (!List.of("active", "paused").contains(goal.status())) {
throw failure(409, "JSON requirements can only change on an active or paused goal");
}
if (request == null || request.expectedRevision() == null || request.expectedRevision() < 0) {
throw failure(400, "expectedRevision is required (0 for a new requirement)");
}
validateKey(criterionKey);
validateKey(request.artifactSlot());
List<String> fields = JsonArtifactRecipe.validate(request.requiredFields());
List<Requirement> current = requirements(goalId);
Requirement previous = current.stream().filter(r -> r.criterionKey().equals(criterionKey)).findFirst().orElse(null);
long revision = previous == null ? 0 : previous.revision();
if (request.expectedRevision() != revision) throw failure(409, "JSON requirement revision changed; reload before editing");
if (previous != null && previous.artifactSlot().equals(request.artifactSlot()) && previous.requiredFields().equals(fields)) {
return previous;
}
if (previous == null && current.size() >= 8) throw failure(400, "At most 8 JSON requirements per goal");
long next = Math.addExact(revision, 1);
String encoded = encode(fields);
if (previous == null) {
jdbc.update("""
INSERT INTO mate_goal_json_requirement
(goal_id,criterion_key,artifact_slot,revision,required_fields,created_by,updated_by,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
""", goalId, criterionKey, request.artifactSlot(), next, encoded, username, username);
} else {
jdbc.update("""
UPDATE mate_goal_json_requirement SET artifact_slot=?,revision=?,required_fields=?,updated_by=?,updated_at=CURRENT_TIMESTAMP
WHERE goal_id=? AND criterion_key=?
""", request.artifactSlot(), next, encoded, username, goalId, criterionKey);
}
// This version write races safely with existing GoalService CAS completion.
// No removal/disable endpoint: opting in never silently restores text-only completion.
jdbc.update("UPDATE mate_agent_goal SET json_acceptance_required=TRUE,version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goalId);
return new Requirement(criterionKey, request.artifactSlot(), next, fields, username);
}
GoalScope authorizedGoal(Long goalId, String username, boolean lock) {
if (username == null || username.isBlank() || "anonymous".equals(username)) throw failure(401, "Authentication required");
// Deliberately stricter than legacy system-conversation ownership fallback.
List<String> roles = jdbc.queryForList("SELECT role FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0" + (lock ? " FOR UPDATE" : ""), String.class, username);
if (roles.size() != 1) throw failure(403, "An enabled user account is required");
GoalScope initial = goal(goalId, false);
var conversations = jdbc.query("SELECT username,workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> Objects.equals(row.getString("username"), username) || "admin".equalsIgnoreCase(roles.getFirst())
? row.getLong("workspace_id") : null, initial.conversationId());
if (conversations.size() != 1 || !Objects.equals(conversations.getFirst(), initial.workspaceId())) throw failure(403, "Goal owner permission required");
GoalScope current = lock ? goal(goalId, true) : initial;
if (!Objects.equals(current.conversationId(), initial.conversationId()) || current.workspaceId() != initial.workspaceId()) {
throw failure(409, "Goal scope changed; reload before editing");
}
return current;
}
private GoalScope goal(Long id, boolean lock) {
var rows = jdbc.query("SELECT id,conversation_id,workspace_id,agent_id,status,json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> new GoalScope(row.getLong("id"), row.getString("conversation_id"), row.getLong("workspace_id"), row.getLong("agent_id"), row.getString("status"), row.getBoolean("json_acceptance_required")), id);
if (rows.size() != 1) throw failure(404, "Goal not found");
return rows.getFirst();
}
List<Requirement> requirements(Long goalId) {
return jdbc.query("SELECT criterion_key,artifact_slot,revision,required_fields,updated_by FROM mate_goal_json_requirement WHERE goal_id=? ORDER BY criterion_key FOR UPDATE",
(row, i) -> new Requirement(row.getString("criterion_key"), row.getString("artifact_slot"), row.getLong("revision"), decode(row.getString("required_fields")), row.getString("updated_by")), goalId);
}
private static void validateKey(String key) {
if (key == null || !key.matches("[a-z][a-z0-9_-]{0,63}")) throw failure(400, "Keys must be 164 lowercase letters, digits, underscores or hyphens, starting with a letter");
}
private String encode(List<String> fields) {
try { return json.writeValueAsString(fields); }
catch (Exception e) { throw new IllegalStateException("Cannot encode JSON requirements", e); }
}
private List<String> decode(String fields) {
try { return JsonArtifactRecipe.validate(json.readValue(fields, new TypeReference<List<String>>() { })); }
catch (Exception e) { throw new IllegalStateException("Stored JSON requirements are invalid", e); }
}
private static MateClawException failure(int code, String message) { return new MateClawException(code, message); }
}

View File

@ -0,0 +1,188 @@
package vip.mate.goal.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
/** Only this service creates bindings, by checking managed bytes under the same goal lock. */
@Service
public class GoalJsonBindingService {
private final JdbcTemplate jdbc;
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
public GoalJsonBindingService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance, ManagedGoalJsonService artifacts) {
this.jdbc = jdbc; this.acceptance = acceptance; this.artifacts = artifacts;
}
public record CheckRequest(Long expectedRequirementRevision, String artifactId, Long expectedGeneration) { }
public record Check(String criterionKey, long requirementRevision, String artifactId, long generation,
String status, List<String> missingFields, String recipeId, int recipeRevision,
Instant checkedAt, Instant expiresAt, boolean acceptanceEligible) { }
public record State(String criterionKey, long requirementRevision, String artifactId, Long generation,
String status, boolean acceptanceEligible) { }
public record Snapshot(boolean required, String status, int versionCount,
List<GoalJsonAcceptanceService.Requirement> requirements,
List<ManagedGoalJsonService.Slot> slots, List<State> checks) { }
@Transactional
public Snapshot snapshot(Long goalId, String username) {
return snapshotLocked(acceptance.authorizedGoal(goalId, username, true));
}
@Transactional
public Snapshot snapshotForRuntime(ChatOrigin origin) {
return snapshotLocked(artifacts.runtimeGoal(origin).goal());
}
private Snapshot snapshotLocked(GoalJsonAcceptanceService.GoalScope goal) {
int count = jdbc.queryForList("SELECT artifact_id FROM mate_goal_json_artifact WHERE goal_id=? FOR UPDATE", String.class, goal.id()).size();
return new Snapshot(goal.required(), goal.status(), count, acceptance.requirements(goal.id()), artifacts.slots(goal.id()), statesLocked(goal.id()));
}
record Stored(String artifactId, long generation, String body, String sha256, int byteLength, Instant expiresAt) { }
record Binding(long requirementRevision, long evaluationRevision, String artifactId, long generation,
String sha256, String recipeId, int recipeRevision, String status, Instant expiresAt) { }
@Transactional
public Check check(Long goalId, String key, CheckRequest request, String username) {
var goal = acceptance.authorizedGoal(goalId, username, true);
return checkLocked(goal, key, request);
}
@Transactional
public Check checkForRuntime(ChatOrigin origin, String key, CheckRequest request) {
var runtime = artifacts.runtimeGoal(origin);
var result = checkLocked(runtime.goal(), key, request);
ManagedGoalJsonService.verifyLease(runtime);
return result;
}
@Transactional
public List<State> state(Long goalId, String username) {
acceptance.authorizedGoal(goalId, username, true);
return statesLocked(goalId);
}
@Transactional
public List<State> stateForRuntime(ChatOrigin origin) {
return statesLocked(artifacts.runtimeGoal(origin).goal().id());
}
private Check checkLocked(GoalJsonAcceptanceService.GoalScope goal, String key, CheckRequest request) {
if (!goal.required() || !List.of("active", "paused").contains(goal.status())) throw failure("Goal does not accept JSON checks");
var requirement = acceptance.requirements(goal.id()).stream().filter(r -> r.criterionKey().equals(key)).findFirst()
.orElseThrow(() -> failure("Current JSON requirement not found"));
if (request == null || request.expectedRequirementRevision() == null || request.expectedGeneration() == null || request.artifactId() == null) {
throw new MateClawException(400, "Expected requirement revision, artifact ID and generation are required");
}
var current = current(goal.id(), requirement.artifactSlot());
if (request.expectedRequirementRevision() != requirement.revision() || current == null
|| !current.artifactId().equals(request.artifactId()) || request.expectedGeneration() != current.generation()) {
throw failure("Requirement or artifact changed; reload before checking");
}
Instant checkedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS);
if (!current.expiresAt().isAfter(Instant.now())) throw failure("Current JSON version has expired");
if (!intact(current)) throw failure("Managed JSON integrity check failed");
var result = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), requirement.requiredFields());
long evaluationRevision = evaluationRevision(goal.id());
// Goal serialization makes replacement safe across all supported database dialects.
jdbc.update("DELETE FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=?", goal.id(), key);
jdbc.update("""
INSERT INTO mate_goal_json_binding
(goal_id,criterion_key,requirement_revision,evaluation_revision,artifact_id,generation,sha256,
recipe_id,recipe_revision,check_status,checked_at,expires_at,expires_epoch_second) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", goal.id(), key, requirement.revision(), evaluationRevision, current.artifactId(), current.generation(), current.sha256(),
result.recipeId(), result.recipeRevision(), result.status(), Timestamp.from(checkedAt), Timestamp.from(current.expiresAt()), current.expiresAt().getEpochSecond());
jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id());
return new Check(key, requirement.revision(), current.artifactId(), current.generation(), result.status(), result.missingFields(),
result.recipeId(), result.recipeRevision(), checkedAt, current.expiresAt(), "MATCH".equals(result.status()));
}
List<State> statesLocked(Long goalId) {
long revision = evaluationRevision(goalId);
return acceptance.requirements(goalId).stream().map(r -> {
Stored current = current(goalId, r.artifactSlot());
Binding binding = binding(goalId, r.criterionKey());
String status;
if (current == null) status = "NO_ARTIFACT";
else if (!current.expiresAt().isAfter(Instant.now())) status = "EXPIRED";
else if (!intact(current)) status = "CORRUPT";
else if (binding == null) status = "UNBOUND";
else if (binding.requirementRevision() != r.revision()) status = "REQUIREMENT_CHANGED";
else if (binding.evaluationRevision() != revision) status = "GOAL_CHANGED";
else if (!Objects.equals(binding.artifactId(), current.artifactId()) || binding.generation() != current.generation()
|| !Objects.equals(binding.sha256(), current.sha256())) status = "SUPERSEDED";
else if (!"json-required-fields".equals(binding.recipeId()) || binding.recipeRevision() != 1) status = "RECIPE_CHANGED";
else if (!binding.expiresAt().equals(current.expiresAt()) || !binding.expiresAt().isAfter(Instant.now())) status = "EXPIRED";
else if (!"MATCH".equals(binding.status())) status = binding.status();
else status = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), r.requiredFields()).status();
return new State(r.criterionKey(), r.revision(), current == null ? null : current.artifactId(),
current == null ? null : current.generation(), status, "MATCH".equals(status));
}).toList();
}
/** Shared completion gate. The held goal lock protects every reference until the status CAS commits. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<State> requireForCompletion(vip.mate.goal.model.GoalEntity expected) {
var rows = jdbc.query("""
SELECT version,evaluation_revision,json_acceptance_required,status FROM mate_agent_goal
WHERE id=? AND deleted=0 FOR UPDATE
""", (r, i) -> expected.getVersion() != null && r.getLong("version") == expected.getVersion().longValue()
&& r.getLong("evaluation_revision") == expected.getEvaluationRevision()
&& r.getBoolean("json_acceptance_required")
&& Objects.equals(r.getString("status"), expected.getStatus().getValue()), expected.getId());
if (rows.size() != 1 || !rows.getFirst()) throw failure("Goal changed before JSON completion; retry with current state");
List<State> states = statesLocked(expected.getId());
if (states.isEmpty()) throw failure("Required JSON contracts are unavailable");
for (State state : states) {
if (!state.acceptanceEligible()) throw failure("JSON requirement " + state.criterionKey() + " is not current: " + state.status());
}
// Recheck expiry after all recipes have run, immediately before returning to the status CAS.
Instant now = Instant.now();
for (State state : states) {
Binding binding = binding(expected.getId(), state.criterionKey());
if (binding == null || !binding.expiresAt().isAfter(now)) throw failure("JSON binding expired before completion");
}
return states;
}
private long evaluationRevision(Long goalId) {
Long revision = jdbc.queryForObject("SELECT evaluation_revision FROM mate_agent_goal WHERE id=? AND deleted=0 FOR UPDATE", Long.class, goalId);
if (revision == null) throw failure("Goal definition unavailable");
return revision;
}
private Stored current(Long goalId, String slot) {
var rows = jdbc.query("""
SELECT a.* FROM mate_goal_json_slot s JOIN mate_goal_json_artifact a
ON a.artifact_id=s.artifact_id AND a.goal_id=s.goal_id AND a.artifact_slot=s.artifact_slot AND a.generation=s.generation
WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE
""", (r, i) -> new Stored(r.getString("artifact_id"), r.getLong("generation"), r.getString("json_body"),
r.getString("sha256"), r.getInt("byte_length"), Instant.ofEpochSecond(r.getLong("expires_epoch_second"))), goalId, slot);
return rows.size() == 1 ? rows.getFirst() : null;
}
private Binding binding(Long goalId, String key) {
var rows = jdbc.query("SELECT * FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=? FOR UPDATE",
(r, i) -> new Binding(r.getLong("requirement_revision"), r.getLong("evaluation_revision"), r.getString("artifact_id"),
r.getLong("generation"), r.getString("sha256"), r.getString("recipe_id"), r.getInt("recipe_revision"),
r.getString("check_status"), Instant.ofEpochSecond(r.getLong("expires_epoch_second"))), goalId, key);
return rows.size() == 1 ? rows.getFirst() : null;
}
private static boolean intact(Stored current) {
byte[] bytes = current.body().getBytes(StandardCharsets.UTF_8);
return bytes.length <= 1_048_576 && bytes.length == current.byteLength() && ManagedGoalJsonService.digest(bytes).equals(current.sha256());
}
private static MateClawException failure(String message) { return new MateClawException(409, message); }
}

View File

@ -0,0 +1,20 @@
package vip.mate.goal.service;
/** Stable runtime guidance; user-controlled requirement text is retrieved through authorized tools. */
public final class GoalJsonProtocolHints {
private GoalJsonProtocolHints() { }
public static final String INSTRUCTIONS = """
This goal has user-selected managed JSON acceptance requirements. Before claiming completion,
call getManagedGoalJsonSlots to read current requirements and generations. Reuse a current version
when it satisfies the request; call checkManagedGoalJson on that version if its binding is missing
or outdated. Publish with publishManagedGoalJson only when content needs changing or the version
is unusable. Each publication consumes one of 32 versions; a retry does not require a new version,
and existing versions can still be checked at the limit. Every requirement needs a current binding
using its exact revision, artifact ID and generation. Publishing a version alone is not a check.
A new version, an edited requirement or goal definition, or expiry invalidates earlier bindings.
Reload after conflicts and check current versions; do not invent PASS results, overwrite blindly,
or substitute ordinary file checks or textual claims. Existing semantic criteria still apply.
Only the platform's committed Goal status establishes completion. If runtime identity or access
is unavailable, report the precise missing access instead of claiming success.
""";
}

View File

@ -0,0 +1,12 @@
package vip.mate.goal.service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/** Absolute persisted lease deadline; LocalDateTime is retained at scheduler API boundaries. */
final class GoalLeaseTime {
private GoalLeaseTime() { }
static long epoch(LocalDateTime value) { return value.atZone(ZoneId.systemDefault()).toEpochSecond(); }
static LocalDateTime local(long epoch) { return LocalDateTime.ofInstant(Instant.ofEpochSecond(epoch), ZoneId.systemDefault()); }
}

View File

@ -21,16 +21,18 @@ public class GoalRecoveryService {
private final GoalContinuationStore continuations;
private final ConversationInputQueueStore inputs;
private final GoalService goals;
private final org.springframework.transaction.support.TransactionTemplate transactions;
private final LocalDateTime startupCutoff=LocalDateTime.now();
private volatile boolean orphanClaimsReleased;
public GoalRecoveryService(GoalAttemptStore attempts,GoalContinuationStore continuations,
ConversationInputQueueStore inputs,GoalService goals) {
ConversationInputQueueStore inputs,GoalService goals, org.springframework.transaction.PlatformTransactionManager manager) {
this.attempts=attempts;this.continuations=continuations;this.inputs=inputs;this.goals=goals;
this.transactions=new org.springframework.transaction.support.TransactionTemplate(manager);
}
public RecoveryDecision classify(GoalAttempt attempt) {
if("tool_started".equals(attempt.checkpointType()) && "uncertain".equals(attempt.replaySafety())) {
if("uncertain".equals(attempt.replaySafety())) {
return RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT;
}
if("message_saved".equals(attempt.checkpointType()) && attempt.assistantMessageId()!=null) {
@ -42,7 +44,9 @@ public class GoalRecoveryService {
return RecoveryDecision.RETRY_SAFE;
}
public int recoverExpired(LocalDateTime now) {
public int recoverExpired(java.time.Instant moment) {
long nowEpoch=moment.getEpochSecond();
LocalDateTime now=GoalLeaseTime.local(nowEpoch);
if(!orphanClaimsReleased) {
synchronized(this) {
if(!orphanClaimsReleased) {
@ -52,17 +56,21 @@ public class GoalRecoveryService {
}
}
int recovered=0;
for(GoalAttempt attempt:attempts.expired(now,100)) {
if(recover(attempt,now)) recovered++;
for(GoalAttempt attempt:attempts.expired(nowEpoch,100)) {
if(Boolean.TRUE.equals(transactions.execute(status -> recover(attempt,now,nowEpoch)))) recovered++;
}
return recovered;
}
@Transactional
boolean recover(GoalAttempt attempt,LocalDateTime now) {
boolean recover(GoalAttempt attempt,LocalDateTime now,long nowEpoch) {
if(!continuations.lockGoal(attempt.goalId())) return false;
var continuation=continuations.get(attempt.goalId());
if(continuation==null || !attempt.id().equals(continuation.currentAttemptId())
|| !attempt.leaseToken().equals(continuation.leaseOwner())) return false;
// The scan only proves the attempt expired. A live or changed projection
// is not recoverable yet and must not abort recovery of later goals.
if(!continuations.hasExpiredFence(attempt.goalId(),attempt.leaseToken(),attempt.id(),nowEpoch)) return false;
RecoveryDecision decision=classify(attempt);
String attemptState=decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT ? "blocked" : "retryable";
String projectionState=decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT ? "blocked" : "retry";
@ -70,7 +78,7 @@ public class GoalRecoveryService {
? "uncertain_tool_outcome_requires_review" : "restart_recovery";
if(!attempts.finish(attempt.id(),attempt.leaseToken(),attemptState,reason,
decision.name().toLowerCase(),now)) return false;
if(!continuations.recoverExpired(attempt.goalId(),attempt.leaseToken(),attempt.id(),now,
if(!continuations.recoverExpired(attempt.goalId(),attempt.leaseToken(),attempt.id(),nowEpoch,
projectionState,now,continuation.failures()+1,reason,now)) {
throw new IllegalStateException("Expired goal projection changed during recovery");
}

View File

@ -19,10 +19,18 @@ public class GoalRunCoordinator {
private final GoalAttemptStore attempts;
private final GoalService goals;
private final GoalProperties properties;
private final java.time.Clock clock;
@org.springframework.beans.factory.annotation.Autowired
public GoalRunCoordinator(GoalContinuationStore continuations,GoalAttemptStore attempts,GoalService goals,
GoalProperties properties) {
this(continuations, attempts, goals, properties, java.time.Clock.systemDefaultZone());
}
GoalRunCoordinator(GoalContinuationStore continuations,GoalAttemptStore attempts,GoalService goals,
GoalProperties properties,java.time.Clock clock) {
this.continuations=continuations;this.attempts=attempts;this.goals=goals;this.properties=properties;
this.clock=clock;
}
public record ClaimedRun(GoalContinuationStore.Continuation candidate,GoalEntity goal,
@ -31,17 +39,28 @@ public class GoalRunCoordinator {
@Transactional
public ClaimedRun claim(GoalContinuationStore.Continuation candidate,GoalEntity goal,LocalDateTime now) {
if(candidate==null || goal==null || candidate.currentAttemptId()!=null) return null;
if(!continuations.lockGoal(goal.getId())) return null;
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
String token=UUID.randomUUID().toString();
LocalDateTime until=now.plusSeconds(LEASE_SECONDS);
if(!continuations.claim(goal.getId(),token,now,until)) return null;
long untilEpoch=nowEpoch+LEASE_SECONDS;
LocalDateTime until=GoalLeaseTime.local(untilEpoch);
if(!continuations.claim(goal.getId(),token,now,until,nowEpoch,untilEpoch)) return null;
GoalContinuationStore.Continuation claimed=continuations.get(goal.getId());
String parentAttemptId=null;
if("restart_recovery".equals(candidate.reason())) {
var recent=attempts.listRecent(goal.getId(),1);
if(!recent.isEmpty()) parentAttemptId=recent.getFirst().id();
if(!recent.isEmpty()) {
var previous=recent.getFirst();
// A recovered attempt may be deferred before reaching the provider.
// Keep that pending recovery context until a segment actually starts.
if("restart_recovery".equals(candidate.reason())
|| previous.parentAttemptId()!=null && "claimed".equals(previous.checkpointType())) {
parentAttemptId=previous.id();
}
}
GoalAttempt attempt=attempts.create(goal.getId(),goal.getConversationId(),parentAttemptId,
"continuation",token,until,null,now);
"continuation",token,until,null,now,untilEpoch);
if(!continuations.bindAttempt(goal.getId(),token,attempt.id(),claimed.revision())) {
throw new IllegalStateException("Goal attempt could not be bound to its continuation");
}
@ -50,29 +69,50 @@ public class GoalRunCoordinator {
@Transactional
public boolean markRunning(ClaimedRun run,LocalDateTime now) {
if(!current(run)) return false;
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
return attempts.markRunning(run.attempt().id(),run.attempt().leaseToken(),now);
}
@Transactional
public boolean renew(ClaimedRun run,LocalDateTime now) {
LocalDateTime until=now.plusSeconds(LEASE_SECONDS);
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
long untilEpoch=nowEpoch+LEASE_SECONDS;
LocalDateTime until=GoalLeaseTime.local(untilEpoch);
if(!continuations.renewFenced(run.goal().getId(),run.attempt().leaseToken(),
run.attempt().id(),run.revision(),until)) return false;
return attempts.renew(run.attempt().id(),run.attempt().leaseToken(),until,now);
run.attempt().id(),run.revision(),until,untilEpoch)) return false;
if(!attempts.renew(run.attempt().id(),run.attempt().leaseToken(),until,now,untilEpoch)) {
throw new IllegalStateException("Goal attempt fence changed during renewal");
}
return true;
}
@Transactional
public boolean checkpoint(ClaimedRun run,String replaySafety,String checkpointType,
Long assistantMessageId,LocalDateTime now) {
if(!current(run)) return false;
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
return attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),replaySafety,
checkpointType,assistantMessageId,now);
}
@Transactional
public boolean settle(ClaimedRun run,SegmentOutcome outcome,LocalDateTime now) {
if(!current(run)) return false;
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
java.time.Instant instant=currentInstant(now);
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
GoalEntity fresh=goals.getById(run.goal().getId());
Settlement settlement=classify(run,outcome,fresh,now);
if((outcome instanceof SegmentOutcome.Continue || outcome instanceof SegmentOutcome.Complete)
@ -88,16 +128,31 @@ public class GoalRunCoordinator {
return true;
}
private boolean current(ClaimedRun run) {
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.
java.time.Instant observed=clock.instant();
java.time.Instant supplied=requested.atZone(java.time.ZoneId.systemDefault()).toInstant();
return observed.isAfter(supplied) ? observed : supplied;
}
private boolean current(ClaimedRun run,long nowEpoch) {
return run!=null && continuations.matchesFence(run.goal().getId(),run.attempt().leaseToken(),
run.attempt().id(),run.revision());
run.attempt().id(),run.revision(),nowEpoch)
&& attempts.hasLiveFence(run.attempt().id(),run.attempt().leaseToken(),nowEpoch);
}
private Settlement classify(ClaimedRun run,SegmentOutcome outcome,GoalEntity fresh,LocalDateTime now) {
int failures=run.candidate().failures();
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED || outcome instanceof SegmentOutcome.Complete) {
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED
|| outcome instanceof SegmentOutcome.Complete && (fresh==null || !fresh.isJsonAcceptanceRequired())) {
return new Settlement("succeeded","completed",now,0,"goal_completed",null);
}
if(outcome instanceof SegmentOutcome.Complete && fresh!=null && fresh.isJsonAcceptanceRequired()) {
return eligible(fresh)
? new Settlement("retryable","retry",now.plusSeconds(5),Math.min(1000,failures+1),"json_completion_not_committed","acceptance")
: new Settlement("cancelled","paused",now,0,"goal_not_runnable",null);
}
if(fresh!=null && fresh.getStatus()==GoalStatus.PAUSED && goals.isBudgetExhausted(fresh)) {
return new Settlement("succeeded","budget_limited",now,0,goals.exhaustionReason(fresh),null);
}

View File

@ -5,6 +5,7 @@ import org.springframework.stereotype.Component;
import reactor.core.Disposable;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService;
@ -17,6 +18,7 @@ import vip.mate.goal.model.SegmentOutcome;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.time.LocalDateTime;
@ -50,6 +52,8 @@ public class GoalSegmentRunner {
private GoalService goals;
@org.springframework.beans.factory.annotation.Autowired
private GoalRunCoordinator coordinator;
@org.springframework.beans.factory.annotation.Autowired
private GoalApprovalRunService approvalRuns;
public GoalSegmentRunner(AgentService agents, ConversationService conversations,
ApprovalWorkflowService approvals, ChatStreamTracker streams, ObjectMapper mapper,
@ -126,8 +130,11 @@ public class GoalSegmentRunner {
String guidance=recovered ? "The previous execution was interrupted by a runtime restart. "
+ "Inspect the workspace, progress ledger and existing async handles before acting. "
+ "Do not replay side effects whose outcome is unknown; request review if their outcome cannot be verified.\n" : "";
ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId());
SegmentResult result;
ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId())
.withExecutionAttribution(new ExecutionAttribution(goal.getId(),
claimedRun == null ? null : claimedRun.attempt().id(), null, null,
claimedRun == null ? null : claimedRun.attempt().leaseToken()));
SegmentResult result=null;
ConversationInputQueueStore.QueuedInput queued=claimNextInput(convId,claimedRun);
do {
String input=guidance+prompt;
@ -138,15 +145,37 @@ public class GoalSegmentRunner {
claimedInput.set(null);
throw new IllegalStateException("Queued input targets a different agent; user review required");
}
Long originMessageId=queued.persistedMessageId();
if (originMessageId==null) {
var saved=conversations.saveMessage(convId,"user",queued.message(),queued.contentParts(),"queued");
originMessageId=saved==null ? null : saved.getId();
if (originMessageId==null || !inputQueue.bindMessage(queued.id(),queued.claimedByAttemptId(),
originMessageId,LocalDateTime.now())) {
throw new IllegalStateException("Queued input could not be bound to its persisted message");
GoalEntity currentGoal=goals==null ? goal : goals.getById(goal.getId());
if (currentGoal!=null && currentGoal.getStatus()==vip.mate.goal.model.GoalStatus.PAUSED)
return new SegmentOutcome.Cancelled("paused");
boolean required=currentGoal!=null && currentGoal.isJsonAcceptanceRequired();
boolean selected=queued.selectedGoalId()!=null && queued.selectedGoalId()>0;
boolean unavailable=currentGoal==null
|| currentGoal.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE;
boolean ambiguousLegacy=queued.selectedGoalId()==null && !required && approvalRuns!=null
&& approvalRuns.hasManagedGoalHistory(convId,String.valueOf(goal.getAgentId()));
if (required || selected || ambiguousLegacy || unavailable) {
var queuedOrigin=ChatOrigin.web(convId,queued.createdBy(),goal.getWorkspaceId(),
null,null,queued.requesterUserId()).withAgent(goal.getAgentId())
.withSelectedGoalId(queued.selectedGoalId());
if (unavailable || !required
|| !Objects.equals(queued.selectedGoalId(),goal.getId())
|| approvalRuns==null || !approvalRuns.queuedSelectionStillCurrent(queuedOrigin)) {
persistQueuedInput(convId,queued);
if (!inputQueue.consume(queued.id(),queued.claimedByAttemptId(),LocalDateTime.now()))
throw new IllegalStateException("Rejected queued input claim was lost");
claimedInput.set(null);
conversations.saveMessage(convId,"assistant",
"Queued input was not run because its selected Goal or account is no longer current. Review and resend it.",
List.of(),"completed");
streams.broadcastObject(convId,"warning",Map.of("message",
"Queued input selected a different or unavailable Goal; text was saved for review."));
queued=claimNextInput(convId,claimedRun);
if (queued==null) break;
continue;
}
}
Long originMessageId=persistQueuedInput(convId,queued);
if (!inputQueue.consume(queued.id(),queued.claimedByAttemptId(),LocalDateTime.now())) {
throw new IllegalStateException("Queued input claim was lost before execution");
}
@ -163,6 +192,7 @@ public class GoalSegmentRunner {
if ("stopped".equals(result.finishReason())) return new SegmentOutcome.Cancelled("stopped");
queued=claimNextInput(convId,claimedRun);
} while (queued!=null);
if(result==null) return new SegmentOutcome.Continue("queued_input_rejected");
if(result.evaluationUnavailable()) return new SegmentOutcome.Retry("evaluation","evaluation_unavailable");
if("error_fallback".equals(result.finishReason())) {
return new SegmentOutcome.Blocked("graph","graph_error_requires_review");
@ -284,6 +314,17 @@ public class GoalSegmentRunner {
return inputQueue.claimNext(conversationId,claimant,LocalDateTime.now()).orElse(null);
}
private Long persistQueuedInput(String conversationId,ConversationInputQueueStore.QueuedInput queued) {
if (queued.persistedMessageId()!=null) return queued.persistedMessageId();
var saved=conversations.saveMessage(conversationId,"user",queued.message(),queued.contentParts(),"queued");
Long messageId=saved==null ? null : saved.getId();
if (messageId==null || !inputQueue.bindMessage(queued.id(),queued.claimedByAttemptId(),
messageId,LocalDateTime.now())) {
throw new IllegalStateException("Queued input could not be bound to its persisted message");
}
return messageId;
}
private String queuedPrompt(ConversationInputQueueStore.QueuedInput queued) {
if (queued.contentParts()==null || queued.contentParts().isEmpty()) return queued.message();
var message=new vip.mate.workspace.conversation.model.MessageEntity();

View File

@ -35,9 +35,15 @@ public interface GoalService {
/** Active goal for the conversation, or null. Used by buildInitialState. */
GoalEntity findActiveByConversation(String conversationId);
/** Most recently created goal for the conversation, regardless of status, or null. */
GoalEntity findLatestByConversation(String conversationId);
/** Paged list filtered by status / owner. */
List<GoalEntity> list(String status, String username, int limit);
/** Conversation-scoped history, newest id first, with an exclusive cursor. */
List<GoalEntity> listByConversation(String conversationId, Long beforeId, int limit);
/** Sparse update. Throws if any terminal-state goal is targeted. */
GoalEntity update(Long id, GoalUpdateRequest req, String username);
@ -53,9 +59,16 @@ public interface GoalService {
GoalEntity resume(Long id, String username);
GoalEntity abandon(Long id, String username);
/** Flip active->completed. Writes a 'completed' event. */
/** Trusted platform completion. Runtime callers must use markRuntimeCompleted to carry their identity. */
GoalEntity markCompleted(Long id, GoalEvaluationResult result);
/** Trusted platform evaluation completion. Runtime callers must use markRuntimeEvaluatedCompleted. */
GoalEntity markEvaluatedCompleted(Long id, GoalEvaluationResult result);
/** Runtime entry points carry server-issued identity; selected JSON goals also fence the completing owner. */
GoalEntity markRuntimeCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin);
GoalEntity markRuntimeEvaluatedCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin);
/** Flip active->exhausted with the reason that triggered it. */
GoalEntity markExhausted(Long id, String reason);

View File

@ -11,6 +11,11 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties;
@ -32,6 +37,7 @@ import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Default implementation. Concurrency safety relies on:
@ -56,6 +62,7 @@ public class GoalServiceImpl implements GoalService {
private final AuditEventService auditEventService;
private final ObjectMapper objectMapper;
private ApplicationEventPublisher applicationEventPublisher;
private PlatformTransactionManager transactionManager;
/**
* Optional only set when the memory subsystem is wired. On goal
@ -65,6 +72,16 @@ public class GoalServiceImpl implements GoalService {
* effort: memory should never block the state-machine write.
*/
private vip.mate.memory.spi.MemoryManager memoryManager;
private GoalJsonBindingService jsonBindings;
private ManagedGoalJsonService managedArtifacts;
@Autowired
public void setManagedArtifacts(ManagedGoalJsonService managedArtifacts) { this.managedArtifacts = managedArtifacts; }
@Autowired
public void setJsonBindings(GoalJsonBindingService jsonBindings) {
this.jsonBindings = jsonBindings;
}
public GoalServiceImpl(GoalMapper goalMapper,
GoalEventMapper eventMapper,
@ -88,6 +105,11 @@ public class GoalServiceImpl implements GoalService {
this.applicationEventPublisher = publisher;
}
@Autowired(required = false)
public void setTransactionManager(PlatformTransactionManager manager) {
this.transactionManager = manager;
}
// ==================== CRUD ====================
@Override
@ -180,6 +202,28 @@ public class GoalServiceImpl implements GoalService {
.last("LIMIT 1"));
}
@Override
public GoalEntity findLatestByConversation(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return goalMapper.selectOne(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.orderByDesc(GoalEntity::getCreateTime)
.orderByDesc(GoalEntity::getId)
.last("LIMIT 1"));
}
@Override
public List<GoalEntity> listByConversation(String conversationId, Long beforeId, int limit) {
if (conversationId == null || conversationId.isBlank()) return List.of();
return goalMapper.selectList(new LambdaQueryWrapper<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.lt(beforeId != null, GoalEntity::getId, beforeId)
.orderByDesc(GoalEntity::getId)
.last("LIMIT " + Math.max(1, Math.min(50, limit))));
}
@Override
public List<GoalEntity> list(String status, String username, int limit) {
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
@ -247,10 +291,32 @@ public class GoalServiceImpl implements GoalService {
if (!changed) {
return null; // idempotent no-op
}
boolean exitsChanged = req.getExitCriteria() != null
&& !Objects.equals(req.getExitCriteria(), fresh.getExitCriteria());
boolean definitionChanged = exitsChanged
|| req.getPersistentExecution() != null
&& !Objects.equals(req.getPersistentExecution(), Boolean.TRUE.equals(fresh.getPersistentExecution()))
|| req.getTitle() != null && !req.getTitle().isBlank()
&& !Objects.equals(req.getTitle().trim(), fresh.getTitle())
|| req.getDescription() != null && !Objects.equals(req.getDescription(), fresh.getDescription())
|| req.getSuccessCheckPrompt() != null
&& !Objects.equals(req.getSuccessCheckPrompt(), fresh.getSuccessCheckPrompt());
if (definitionChanged) {
// Replacing the free-text exit definition requires a new draft.
// Other context edits preserve user criterion text but revoke its old verdicts.
String criteria = exitsChanged ? null : GoalCriteriaCodec.serialize(
GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper).stream()
.map(c -> new GoalCriterion(c.id(), c.text(), false, "")).toList(), objectMapper);
w.set(GoalEntity::getEvaluationRevision, Math.addExact(fresh.getEvaluationRevision(), 1L))
.set(GoalEntity::getCriteria, criteria)
.set(GoalEntity::getCompletionScore, 0.0)
.set(GoalEntity::getProgressSummary, "Goal definition changed; reevaluation required");
}
bumpVersionAndTime(w);
return w;
});
recordAudit("goal.updated", updated, Map.of("by", username));
recordAudit("goal.updated", updated, Map.of("by", username,
"evaluationRevision", updated.getEvaluationRevision()));
return updated;
}
@ -328,15 +394,70 @@ public class GoalServiceImpl implements GoalService {
@Override
@Transactional
public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
return completeGoal(id, result, false, null, false);
}
@Override
@Transactional
public GoalEntity markEvaluatedCompleted(Long id, GoalEvaluationResult result) {
if (result == null || !result.completed()
|| !GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires a completed evaluation");
}
return completeGoal(id, result, true, null, false);
}
@Override
@Transactional
public GoalEntity markRuntimeCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) {
return completeGoal(id, result, false, origin, true);
}
@Override
@Transactional
public GoalEntity markRuntimeEvaluatedCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) {
if (result == null || !result.completed()
|| !GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires a completed evaluation");
}
return completeGoal(id, result, true, origin, true);
}
private GoalEntity completeGoal(Long id, GoalEvaluationResult result, boolean evaluated,
vip.mate.agent.context.ChatOrigin origin, boolean runtimeCaller) {
boolean[] transitioned = {false};
var jsonProof = new java.util.concurrent.atomic.AtomicReference<List<GoalJsonBindingService.State>>(List.of());
GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
if (fresh.getStatus().isTerminal()) return null; // idempotent
// A failed CAS may retry against another worker's completed row.
transitioned[0] = false;
jsonProof.set(List.of());
if (fresh.getStatus().isTerminal()) {
if (evaluated && fresh.getStatus() != GoalStatus.COMPLETED) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion cannot replace another terminal state");
}
return null; // idempotent
}
ManagedGoalJsonService.RuntimeScope runtime = null;
if (runtimeCaller && fresh.isJsonAcceptanceRequired()) {
if (managedArtifacts == null) throw new MateClawException(409, "Managed JSON runtime verification is unavailable");
runtime = managedArtifacts.runtimeGoal(origin);
if (runtime.goal().id() != fresh.getId()) throw new MateClawException(403, "Completion runtime goal mismatch");
}
if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires the current evaluation definition revision");
}
boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution());
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
if (persistent && (fresh.getStatus() != GoalStatus.ACTIVE || existing.isEmpty()
|| existing.stream().anyMatch(c -> c == null || !c.passed()
|| c.evidence() == null || c.evidence().isBlank()))) {
// Rechecked against the fresh row on every CAS retry. A stale
// evaluator result must never force-pass newly added criteria.
if ((persistent || evaluated) && (fresh.getStatus() != GoalStatus.ACTIVE
|| !GoalCriteriaCodec.allPassed(existing))) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Persistent completion requires an active goal and evidence for every current criterion");
"Completion requires an active goal and evidence for every current criterion");
}
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
@ -344,9 +465,9 @@ public class GoalServiceImpl implements GoalService {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
// Preserve verified persistent evidence verbatim. Legacy manual
// Preserve automatically evaluated and persistent evidence verbatim. Legacy manual
// completion retains its historical force-passed checklist snapshot.
if (!persistent && !existing.isEmpty()) {
if (!persistent && !evaluated && !existing.isEmpty()) {
List<GoalCriterion> allPassed = existing.stream()
.map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true,
c.evidence() == null || c.evidence().isBlank()
@ -354,35 +475,68 @@ public class GoalServiceImpl implements GoalService {
.toList();
w.set(GoalEntity::getCriteria, GoalCriteriaCodec.serialize(allPassed, objectMapper));
}
if (fresh.isJsonAcceptanceRequired()) {
if (jsonBindings == null) throw new MateClawException("err.goal.json_acceptance_required", 409,
"Managed JSON verification service is unavailable");
jsonProof.set(jsonBindings.requireForCompletion(fresh));
if (runtime != null) ManagedGoalJsonService.verifyLease(runtime);
}
bumpVersionAndTime(w);
transitioned[0] = true;
return w;
});
if (!transitioned[0]) return g;
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
if (g.isJsonAcceptanceRequired()) {
detail.put("jsonAcceptanceRequired", true);
detail.put("jsonBindings", jsonProof.get());
}
writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, detail);
// Forward to long-term memory on completion. Best-effort: a failing
// memory pipeline must not roll back the DB transition.
if (memoryManager != null) {
syncCompletionMemoryAfterCommit(g, result);
return g;
}
private void syncCompletionMemoryAfterCommit(GoalEntity goal, GoalEvaluationResult result) {
var target = memoryManager;
if (target == null) return;
// Snapshot values before returning the mutable entity to the caller.
Long agentId = goal.getAgentId();
String conversationId = goal.getConversationId();
String subject = "[goal completed] " + goal.getTitle();
String summary = goal.getProgressSummary() != null && !goal.getProgressSummary().isBlank()
? goal.getProgressSummary() : "Final score: " + (result != null ? result.score() : "");
Runnable sync = () -> {
try {
String summary = g.getProgressSummary() != null && !g.getProgressSummary().isBlank()
? g.getProgressSummary()
: "Final score: " + (result != null ? result.score() : "");
memoryManager.syncAll(
g.getAgentId(),
g.getConversationId(),
"[goal completed] " + g.getTitle(),
summary);
if (transactionManager != null) {
// afterCommit still has the old transaction's resources bound.
// Adapter DB writes need their own transaction to commit reliably.
TransactionTemplate independent = new TransactionTemplate(transactionManager);
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
independent.executeWithoutResult(status -> target.syncAll(agentId, conversationId, subject, summary));
} else {
target.syncAll(agentId, conversationId, subject, summary);
}
} catch (Exception e) {
log.debug("[GoalService] memory syncAll on goal completion failed: {}", e.getMessage());
}
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { sync.run(); }
});
} else {
log.debug("[GoalService] skipped completion memory: no commit synchronization available");
}
} else {
sync.run(); // Direct/non-transactional callers retain best-effort behavior.
}
return g;
}
@Override
@ -437,10 +591,8 @@ public class GoalServiceImpl implements GoalService {
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
// Late model results still consume usage, but cannot overwrite a
// persistent pause/input boundary established while the call ran.
if (result != null && (!Boolean.TRUE.equals(fresh.getPersistentExecution())
|| fresh.getStatus() == GoalStatus.ACTIVE)) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
if (result != null && result.evaluationRevision() == fresh.getEvaluationRevision()
&& (!Boolean.TRUE.equals(fresh.getPersistentExecution()) || fresh.getStatus() == GoalStatus.ACTIVE)) {
// Persist the checklist by carrier: bootstrap writes the fresh
// draft; verdict merges the per-criterion delta into the
// current list (re-read on the locked `fresh` to avoid races).
@ -448,6 +600,16 @@ public class GoalServiceImpl implements GoalService {
if (criteriaJson != null) {
w.set(GoalEntity::getCriteria, criteriaJson);
}
List<GoalCriterion> current = GoalCriteriaCodec.parse(
criteriaJson != null ? criteriaJson : fresh.getCriteria(), objectMapper);
if (!current.isEmpty() && !GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision())) {
// The model may have seen fewer criteria. Derive the public
// projection from the same fresh checklist this CAS writes.
setChecklistProgress(w, current);
} else {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
}
bumpVersionAndTime(w);
return w;
@ -455,9 +617,14 @@ public class GoalServiceImpl implements GoalService {
Map<String, Object> detail = new LinkedHashMap<>();
if (result != null) {
detail.put("completionScore", result.score());
detail.put("gap", result.gap());
detail.put("completionScore", g.getCompletionScore());
detail.put("gap", g.getProgressSummary());
detail.put("decision", result.decision());
detail.put("evaluatorScore", result.score());
detail.put("evaluatorGap", result.gap());
detail.put("evaluatedRevision", result.evaluationRevision());
detail.put("currentEvaluationRevision", g.getEvaluationRevision());
detail.put("staleEvaluation", result.evaluationRevision() != g.getEvaluationRevision());
detail.put("evaluatorModel", result.evaluatorModel());
detail.put("latencyMs", result.latencyMs());
}
@ -472,11 +639,17 @@ public class GoalServiceImpl implements GoalService {
/**
* Compute the next criteria JSON for a record-evaluation write, or
* {@code null} when the result carries no checklist change. Bootstrap
* results replace the list with the freshly derived draft; verdict
* results initialize a still-empty list with the derived draft; verdict
* results merge their per-criterion delta into the locked-row list.
*/
private String nextCriteriaJson(GoalEntity fresh, GoalEvaluationResult result) {
if (result.bootstrapCriteria() != null && !result.bootstrapCriteria().isEmpty()) {
// A user append or another evaluator may have initialized the
// checklist while this model call ran. The fresh canonical list
// wins, including after an optimistic-lock retry.
if (!GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper).isEmpty()) {
return null;
}
return GoalCriteriaCodec.serialize(result.bootstrapCriteria(), objectMapper);
}
if (result.criterionVerdicts() != null && !result.criterionVerdicts().isEmpty()) {
@ -553,6 +726,7 @@ public class GoalServiceImpl implements GoalService {
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getCriteria, criteriaJson)
.set(GoalEntity::getExitCriteria, mergedText);
setChecklistProgress(w, list);
bumpVersionAndTime(w);
return w;
});
@ -566,6 +740,16 @@ public class GoalServiceImpl implements GoalService {
return g;
}
/** Both evaluation and user edits project the checklist written by this CAS. */
private static void setChecklistProgress(LambdaUpdateWrapper<GoalEntity> update, List<GoalCriterion> criteria) {
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(criteria);
double score = criteria.isEmpty() ? 0.0 : (double) (criteria.size() - remaining.size()) / criteria.size();
String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream()
.map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; "));
update.set(GoalEntity::getCompletionScore, score)
.set(GoalEntity::getProgressSummary, gap);
}
// ==================== Internals ====================
/**
@ -609,6 +793,7 @@ public class GoalServiceImpl implements GoalService {
r.setExitCriteria(e.getExitCriteria());
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
r.setStatus(e.getStatus());
r.setJsonAcceptanceRequired(e.isJsonAcceptanceRequired());
r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution()));
r.setTurnBudget(e.getTurnBudget());
r.setTurnsUsed(e.getTurnsUsed());

View File

@ -0,0 +1,208 @@
package vip.mate.goal.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.agent.context.ChatOrigin;
import java.util.Objects;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.sql.Timestamp;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
/** Managed JSON is independent of mutable workspace files and cache metadata.
* Database credentials and the service host are trusted; hashes do not isolate a hostile host. */
@Service
public class ManagedGoalJsonService {
private final JdbcTemplate jdbc;
private final GoalJsonAcceptanceService acceptance;
public ManagedGoalJsonService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance) {
this.jdbc = jdbc;
this.acceptance = acceptance;
}
public record PublishRequest(Long expectedGeneration, String jsonContent) { }
public record Artifact(String artifactId, String artifactSlot, long generation, String sha256,
int byteLength, String producerKind, Instant createdAt, Instant expiresAt) { }
public record Content(Artifact artifact, String jsonContent) { }
public record Slot(String artifactSlot, long generation, Artifact current) { }
@Transactional
public List<Slot> list(Long goalId, String username) {
acceptance.authorizedGoal(goalId, username, true);
return slots(goalId);
}
@Transactional
public Artifact publish(Long goalId, String slot, PublishRequest request, String username) {
var goal = acceptance.authorizedGoal(goalId, username, true);
return publishLocked(goal, slot, request, "user", username);
}
@Transactional
public Content read(Long goalId, String artifactId, String username) {
acceptance.authorizedGoal(goalId, username, true);
var rows = jdbc.query("SELECT * FROM mate_goal_json_artifact WHERE goal_id=? AND artifact_id=? FOR UPDATE",
(r, i) -> new Content(artifact(r), r.getString("json_body")), goalId, artifactId);
if (rows.size() != 1) throw failure(404, "Managed JSON version not found");
return rows.getFirst();
}
@Transactional
public Artifact publishForRuntime(ChatOrigin origin, String slot, PublishRequest request) {
var runtime = runtimeGoal(origin);
var result = publishLocked(runtime.goal(), slot, request, runtime.producerKind(), runtime.producerId());
verifyLease(runtime);
return result;
}
static void verifyLease(RuntimeScope runtime) {
if (runtime.leaseUntil() != null && !runtime.leaseUntil().isAfter(Instant.now())) {
throw failure(409, "Goal attempt lease expired during managed JSON operation");
}
}
record RuntimeScope(GoalJsonAcceptanceService.GoalScope goal, String producerKind,
String producerId, Instant leaseUntil) { }
// All identity comes from server-created ToolContext, never model arguments.
// Lock order: enabled user -> conversation -> goal -> continuation -> goal attempt.
RuntimeScope runtimeGoal(ChatOrigin origin) {
if (origin == null || origin.conversationId() == null || origin.workspaceId() == null || origin.agentId() == null) {
throw failure(403, "A bound goal runtime is required");
}
var attribution = origin.executionAttribution();
boolean attempt = attribution != null && (attribution.goalId() != null || attribution.goalAttemptId() != null || attribution.ownerFence() != null);
if (attempt && (attribution.goalId() == null || attribution.goalAttemptId() == null || attribution.ownerFence() == null)) {
throw failure(403, "Incomplete goal attempt identity");
}
if (origin.cronOrigin() || (attribution != null && attribution.cronRunId() != null)) {
throw failure(403, "Cron publication is not supported by this goal protocol");
}
List<Long> ids = attempt ? List.of(attribution.goalId()) : jdbc.queryForList("""
SELECT id FROM mate_agent_goal WHERE conversation_id=? AND workspace_id=?
AND status IN ('active','paused') AND deleted=0
""", Long.class, origin.conversationId(), origin.workspaceId());
if (ids.size() != 1) throw failure(409, "Exactly one current goal is required");
List<String> users;
if (attempt) {
users = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0", String.class, origin.conversationId());
} else {
if (origin.requesterUserId() == null) throw failure(403, "Authenticated account identity is required");
users = jdbc.queryForList("SELECT username FROM mate_user WHERE id=? AND enabled=TRUE AND deleted=0", String.class, origin.requesterUserId());
}
if (users.size() != 1) throw failure(403, "Runtime owner unavailable");
var goal = acceptance.authorizedGoal(ids.getFirst(), users.getFirst(), true);
if (!Objects.equals(goal.conversationId(), origin.conversationId()) || goal.workspaceId() != origin.workspaceId()
|| goal.agentId() != origin.agentId()) throw failure(403, "Runtime goal scope mismatch");
// authorizedGoal already holds this conversation row. Recheck mutable
// runtime scope as well as the Goal's original identity on every operation.
var currentConversations = jdbc.queryForList("""
SELECT conversation_id FROM mate_conversation
WHERE conversation_id=? AND workspace_id=? AND agent_id=? AND deleted=0
AND (archived IS NULL OR archived=0) FOR UPDATE
""", String.class, origin.conversationId(), origin.workspaceId(), origin.agentId());
if (currentConversations.size() != 1) throw failure(403, "Runtime conversation scope changed or was archived");
if (!attempt) {
// Recheck the immutable user id after authorizedGoal acquired the user lock.
Long userId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0", Long.class, users.getFirst());
if (!Objects.equals(userId, origin.requesterUserId())) throw failure(403, "Runtime account changed");
return new RuntimeScope(goal, "account-runtime", String.valueOf(userId), null);
}
var continuationLeases = jdbc.query("""
SELECT lease_owner,current_attempt_id,state,lease_until_epoch_second FROM mate_goal_continuation WHERE goal_id=? FOR UPDATE
""", (r, i) -> Objects.equals(r.getString("lease_owner"), attribution.ownerFence())
&& Objects.equals(r.getString("current_attempt_id"), attribution.goalAttemptId())
&& "running".equals(r.getString("state"))
? Instant.ofEpochSecond(r.getLong("lease_until_epoch_second")) : null, goal.id());
if (continuationLeases.size() != 1 || continuationLeases.getFirst() == null
|| !continuationLeases.getFirst().isAfter(Instant.now())) {
throw failure(409, "Goal continuation owner is no longer current");
}
var leases = jdbc.query("""
SELECT goal_id,conversation_id,lease_token,state,lease_until_epoch_second FROM mate_goal_attempt WHERE attempt_id=? FOR UPDATE
""", (r, i) -> r.getLong("goal_id") == goal.id()
&& Objects.equals(r.getString("conversation_id"), goal.conversationId())
&& Objects.equals(r.getString("lease_token"), attribution.ownerFence())
&& List.of("claimed", "running").contains(r.getString("state"))
? Instant.ofEpochSecond(r.getLong("lease_until_epoch_second")) : null, attribution.goalAttemptId());
if (leases.size() != 1 || leases.getFirst() == null || !leases.getFirst().isAfter(Instant.now())) {
throw failure(409, "Goal attempt owner fence is no longer current");
}
return new RuntimeScope(goal, "goal-attempt", attribution.goalAttemptId(),
leases.getFirst().isBefore(continuationLeases.getFirst()) ? leases.getFirst() : continuationLeases.getFirst());
}
// Caller must hold the authorized goal lock in the same transaction.
Artifact publishLocked(GoalJsonAcceptanceService.GoalScope goal, String slot, PublishRequest request,
String producerKind, String producerId) {
if (!List.of("active", "paused").contains(goal.status())) throw failure(409, "Goal is no longer writable");
if (!goal.required() || acceptance.requirements(goal.id()).stream().noneMatch(r -> r.artifactSlot().equals(slot))) {
throw failure(409, "Only a currently required JSON slot can be published");
}
if (request == null || request.expectedGeneration() == null || request.expectedGeneration() < 0) {
throw failure(400, "expectedGeneration is required (0 for an empty slot)");
}
String content = request.jsonContent();
if (content == null || content.length() > 1_048_576) throw failure(400, "JSON must be at most 1 MiB");
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
if (!content.equals(new String(bytes, StandardCharsets.UTF_8))) throw failure(400, "JSON must be valid UTF-8");
JsonArtifactRecipe.parseObject(bytes);
var generations = jdbc.queryForList("SELECT generation FROM mate_goal_json_slot WHERE goal_id=? AND artifact_slot=? FOR UPDATE", Long.class, goal.id(), slot);
long generation = generations.isEmpty() ? 0 : generations.getFirst();
if (request.expectedGeneration() != generation) throw failure(409, "JSON slot generation changed; reload before publishing");
int count = jdbc.queryForList("SELECT artifact_id FROM mate_goal_json_artifact WHERE goal_id=? FOR UPDATE", String.class, goal.id()).size();
if (count >= 32) throw failure(409, "Managed JSON limit reached (32 versions per goal)");
long next = Math.addExact(generation, 1);
// Epoch seconds are authoritative across JDBC/JVM timezone changes; SQL timestamps are audit-only.
Instant created = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
Artifact artifact = new Artifact(UUID.randomUUID().toString(), slot, next, digest(bytes), bytes.length,
producerKind, created, created.plusSeconds(86_400));
jdbc.update("""
INSERT INTO mate_goal_json_artifact
(artifact_id,goal_id,artifact_slot,generation,json_body,sha256,byte_length,producer_kind,producer_id,created_at,expires_at,created_epoch_second,expires_epoch_second)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", artifact.artifactId(), goal.id(), slot, next, content, artifact.sha256(), bytes.length,
producerKind, producerId, Timestamp.from(created), Timestamp.from(artifact.expiresAt()),
created.getEpochSecond(), artifact.expiresAt().getEpochSecond());
if (generation == 0) jdbc.update("INSERT INTO mate_goal_json_slot(goal_id,artifact_slot,generation,artifact_id) VALUES (?,?,?,?)",
goal.id(), slot, next, artifact.artifactId());
else jdbc.update("UPDATE mate_goal_json_slot SET generation=?,artifact_id=? WHERE goal_id=? AND artifact_slot=?",
next, artifact.artifactId(), goal.id(), slot);
jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id());
return artifact;
}
List<Slot> slots(Long goalId) {
return acceptance.requirements(goalId).stream().map(GoalJsonAcceptanceService.Requirement::artifactSlot).distinct().sorted()
.map(slot -> {
var rows = jdbc.query("""
SELECT a.* FROM mate_goal_json_slot s JOIN mate_goal_json_artifact a
ON a.artifact_id=s.artifact_id AND a.goal_id=s.goal_id AND a.artifact_slot=s.artifact_slot AND a.generation=s.generation
WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE
""", (r, i) -> artifact(r), goalId, slot);
if (rows.isEmpty()) return new Slot(slot, 0, null);
var current = rows.getFirst();
return new Slot(slot, current.generation(), current);
}).toList();
}
private static Artifact artifact(java.sql.ResultSet r) throws java.sql.SQLException {
return new Artifact(r.getString("artifact_id"), r.getString("artifact_slot"), r.getLong("generation"),
r.getString("sha256"), r.getInt("byte_length"), r.getString("producer_kind"),
r.getLong("created_epoch_second") == 0 ? r.getTimestamp("created_at").toInstant() : Instant.ofEpochSecond(r.getLong("created_epoch_second")),
Instant.ofEpochSecond(r.getLong("expires_epoch_second")));
}
static String digest(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static MateClawException failure(int code, String message) { return new MateClawException(code, message); }
}

View File

@ -1,6 +1,7 @@
package vip.mate.i18n;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import vip.mate.common.result.R;
@ -21,4 +22,9 @@ public class I18nAutoConfig {
public void init() {
R.setI18n(i18nService);
}
@PreDestroy
public void destroy() {
R.clearI18n(i18nService);
}
}

View File

@ -93,7 +93,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
return new DeepSeekV4ThinkingDecorator(raw);
}
return raw;
return VllmThinkingDecorator.supports(provider, options) ? new VllmThinkingDecorator(raw) : raw;
}
// ==================== chat options ====================
@ -252,6 +252,9 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
restClientBuilder.requestInterceptor(OpenAiReasoningResponseNormalizer.blockingInterceptor());
webClientBuilder.filter(OpenAiReasoningResponseNormalizer.streamingFilter());
// Spring AI's OpenAiApi constructor sets User-Agent to "spring-ai" first, then addAll's
// our headers, so a custom User-Agent is appended rather than replaced. For providers
// that must masquerade as a specific client (e.g. kimi-code), force-override headers

View File

@ -0,0 +1,97 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/** Bridges vLLM's newer reasoning field to Spring AI 1.1.x's reasoning_content. */
final class OpenAiReasoningResponseNormalizer {
private static final ObjectMapper JSON = new ObjectMapper();
private OpenAiReasoningResponseNormalizer() {}
static String normalize(String body) {
if (!body.contains("\"reasoning\"")) return body;
try {
JsonNode root = JSON.readTree(body);
boolean changed = false;
for (JsonNode choice : root.path("choices")) {
changed |= normalizeMessage(choice.path("delta"));
changed |= normalizeMessage(choice.path("message"));
}
return changed ? JSON.writeValueAsString(root) : body;
} catch (JsonProcessingException ignored) {
// Keep malformed input intact: the SDK owns protocol error handling.
return body;
}
}
private static boolean normalizeMessage(JsonNode node) {
if (node instanceof ObjectNode message && !message.hasNonNull("reasoning_content")
&& message.path("reasoning").isTextual()) {
message.set("reasoning_content", message.get("reasoning"));
return true;
}
return false;
}
static ExchangeFilterFunction streamingFilter() {
return (request, next) -> next.exchange(request).map(response -> {
if (!response.statusCode().is2xxSuccessful()
|| !response.headers().contentType().map(MediaType.TEXT_EVENT_STREAM::isCompatibleWith).orElse(false)) {
return response;
}
// Decode complete SSE data events, not TCP/DataBuffer fragments. This
// preserves split UTF-8, multiline data, [DONE], backpressure and cancellation.
var events = response.bodyToFlux(String.class).map(OpenAiReasoningResponseNormalizer::normalize)
.map(data -> "data: " + data.replace("\n", "\ndata: ") + "\n\n")
.<DataBuffer>map(data -> DefaultDataBufferFactory.sharedInstance.wrap(data.getBytes(StandardCharsets.UTF_8)));
// The function overload retains the source body. body(Flux) would
// release it immediately and subscribe to the HTTP body twice.
return response.mutate().headers(headers -> headers.remove(HttpHeaders.CONTENT_LENGTH)).body(original -> events).build();
});
}
static ClientHttpRequestInterceptor blockingInterceptor() {
return (request, body, execution) -> {
ClientHttpResponse response = execution.execute(request, body);
if (!response.getStatusCode().is2xxSuccessful()
|| response.getHeaders().getContentType() == null
|| !MediaType.APPLICATION_JSON.isCompatibleWith(response.getHeaders().getContentType())) {
return response;
}
try {
byte[] bytes = normalize(new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.setContentLength(bytes.length);
return new ClientHttpResponse() {
private final InputStream input = new ByteArrayInputStream(bytes);
@Override public HttpStatusCode getStatusCode() throws IOException { return response.getStatusCode(); }
@Override public String getStatusText() throws IOException { return response.getStatusText(); }
@Override public HttpHeaders getHeaders() { return headers; }
@Override public InputStream getBody() { return input; }
@Override public void close() { response.close(); }
};
} catch (IOException | RuntimeException error) {
response.close();
throw error;
}
};
}
}

View File

@ -0,0 +1,70 @@
package vip.mate.llm.chatmodel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import reactor.core.publisher.Flux;
import vip.mate.llm.model.ModelProviderEntity;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/** Per-request template switch for vLLM/Qwen, including arbitrary served model aliases. */
final class VllmThinkingDecorator implements ChatModel {
private final ChatModel delegate;
VllmThinkingDecorator(ChatModel delegate) {
this.delegate = delegate;
}
static boolean supports(ModelProviderEntity provider, OpenAiChatOptions defaults) {
String id = provider.getProviderId();
if (id != null && id.toLowerCase(Locale.ROOT).contains("vllm")) return true;
// Custom providers can opt in by explicitly configuring the template switch.
return defaults.getExtraBody() != null
&& defaults.getExtraBody().get("chat_template_kwargs") instanceof Map<?, ?> template
&& template.containsKey("enable_thinking");
}
@Override public ChatResponse call(Prompt prompt) { return delegate.call(transform(prompt)); }
@Override public Flux<ChatResponse> stream(Prompt prompt) { return delegate.stream(transform(prompt)); }
@Override public ChatOptions getDefaultOptions() { return delegate.getDefaultOptions(); }
private Prompt transform(Prompt prompt) {
// Capture on the caller's thread, before Reactor subscription or worker handoff.
String level = ThinkingLevelHolder.get();
if (level == null || level.isBlank()) return prompt;
ChatOptions runtime = prompt.getOptions();
OpenAiChatOptions patched = runtime instanceof OpenAiChatOptions options
? OpenAiChatOptions.fromOptions(options)
: runtime == null ? new OpenAiChatOptions()
: runtime instanceof ToolCallingChatOptions toolOptions
? ModelOptionsUtils.copyToTarget(toolOptions, ToolCallingChatOptions.class, OpenAiChatOptions.class)
: ModelOptionsUtils.copyToTarget(runtime, ChatOptions.class, OpenAiChatOptions.class);
Map<String, Object> extra = new LinkedHashMap<>();
Map<String, Object> template = new LinkedHashMap<>();
if (delegate.getDefaultOptions() instanceof OpenAiChatOptions defaults) {
mergeExtra(extra, template, defaults.getExtraBody());
}
mergeExtra(extra, template, patched.getExtraBody());
template.put("enable_thinking", !"off".equalsIgnoreCase(level));
extra.put("chat_template_kwargs", template);
patched.setExtraBody(extra);
// vLLM template switching does not use OpenAI's effort levels.
patched.setReasoningEffort(null);
return new Prompt(prompt.getInstructions(), patched);
}
private static void mergeExtra(Map<String, Object> extra, Map<String, Object> template, Map<String, Object> source) {
if (source == null) return;
extra.putAll(source);
if (source.get("chat_template_kwargs") instanceof Map<?, ?> values) {
values.forEach((key, value) -> { if (key instanceof String name) template.put(name, value); });
}
}
}

View File

@ -681,8 +681,9 @@ public class ModelDiscoveryService {
/**
* Build the smoke-test request body for the OpenAI-compatible test-prompt path.
* The core fields (model/messages/max_tokens/temperature) are fixed by design
* this is a minimal-token connectivity probe, not a real chat turn but any
* Probe limits follow the same ModelFamily constraints as runtime chat.
* Reasoning models need a completion budget that includes reasoning tokens;
* the standard ten-token probe is insufficient for those models. Any
* unrecognized top-level {@code generateKwargs} key (e.g. vLLM's
* {@code chat_template_kwargs} used to disable Qwen thinking mode) is forwarded
* verbatim, same as the runtime chat path in
@ -694,8 +695,21 @@ public class ModelDiscoveryService {
Map<String, Object> requestBody = new LinkedHashMap<>(ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs));
requestBody.put("model", modelId);
requestBody.put("messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")));
ModelFamily family = ModelFamily.detect(modelId);
if (family.useMaxCompletionTokens()) {
requestBody.put("max_completion_tokens", 4096);
// Let OpenAI/Azure use model defaults: reasoning deployments may
// reject explicit sampling options even in a connectivity probe.
} else {
requestBody.put("max_tokens", 10);
requestBody.put("temperature", 0);
Object probeTemperature;
if (family.fixedTemperatureOne()) {
probeTemperature = 1.0d;
} else {
probeTemperature = 0;
}
requestBody.put("temperature", probeTemperature);
}
return requestBody;
}

View File

@ -162,6 +162,18 @@ public class MemoryProperties {
/** Enable provider metrics collection */
private boolean providerMetricsEnabled = false;
/** Maximum time allowed for a single provider prefetch; 0 = no per-provider limit. */
private long providerPrefetchTimeoutMs = 1500;
/** Maximum time allowed for the complete prefetch chain; 0 = no total limit. */
private long providerPrefetchTotalBudgetMs = 2500;
/** Consecutive prefetch failures before a provider circuit opens. */
private int providerCircuitFailureThreshold = 3;
/** Time an open provider circuit waits before allowing one probe request. */
private long providerCircuitCooldownSeconds = 30;
// --- Phase 3: Fact projection ---
/** Fact projection configuration */

View File

@ -9,6 +9,7 @@ import vip.mate.memory.fact.extraction.CompositeEntityExtractor;
import vip.mate.memory.fact.extraction.ExtractedFact;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.repository.FactMapper;
import vip.mate.memory.identity.MemoryScope;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
@ -20,7 +21,8 @@ import java.util.List;
* Rebuilds the fact projection from canonical sources.
* <p>
* Derived columns are overwritten; accumulated columns (use_count, last_used_at)
* are preserved via select-then-update keyed on (agent_id, source_ref).
* are preserved via select-then-update keyed on
* (agent_id, source_ref, scope, owner_key).
* <p>
* Only this class may write derived columns to mate_fact (core invariant).
* Uses MyBatis Plus CRUD (dialect-safe for both H2 and MySQL).
@ -47,38 +49,42 @@ public class FactProjectionBuilder {
return 0;
}
List<ExtractedFact> allFacts = new ArrayList<>();
List<ProjectedFact> allFacts = new ArrayList<>();
// Extract from structured/*.md files
// Extract every canonical memory row with its visibility identity. A
// shared agent can have the same filename/key for many personal owners,
// so filename/sourceRef alone is not a projection identity.
List<WorkspaceFileEntity> files = workspaceFileService.listFiles(agentId);
for (WorkspaceFileEntity file : files) {
String filename = file.getFilename();
if (filename == null) continue;
if (filename.startsWith("structured/") && filename.endsWith(".md")) {
WorkspaceFileEntity full = workspaceFileService.getFile(agentId, filename);
if (full != null && full.getContent() != null && !full.getContent().isBlank()) {
allFacts.addAll(extractor.extract(agentId, filename, full.getContent()));
}
}
}
boolean canonical = "MEMORY.md".equals(filename)
|| filename.startsWith("structured/") && filename.endsWith(".md");
if (!canonical) continue;
// Extract from MEMORY.md
WorkspaceFileEntity memoryFile = workspaceFileService.getFile(agentId, "MEMORY.md");
if (memoryFile != null && memoryFile.getContent() != null && !memoryFile.getContent().isBlank()) {
allFacts.addAll(extractor.extract(agentId, "MEMORY.md", memoryFile.getContent()));
String scope = normalizeScope(file.getScope());
String ownerKey = normalizeOwner(file.getOwnerKey(), scope);
WorkspaceFileEntity full = MemoryScope.PERSONAL.equals(scope)
? workspaceFileService.getMemoryFile(agentId, filename, ownerKey)
: workspaceFileService.getFile(agentId, filename);
if (full == null || full.getContent() == null || full.getContent().isBlank()) continue;
for (ExtractedFact fact : extractor.extract(agentId, filename, full.getContent())) {
allFacts.add(new ProjectedFact(fact, ownerKey, scope));
}
}
// Upsert all extracted facts (dialect-safe)
LocalDateTime now = LocalDateTime.now();
List<String> keepRefs = new ArrayList<>();
for (ExtractedFact fact : allFacts) {
upsertDerived(agentId, fact, now);
keepRefs.add(fact.sourceRef());
List<Long> keepIds = new ArrayList<>();
for (ProjectedFact projected : allFacts) {
Long id = upsertDerived(agentId, projected.fact(), projected.ownerKey(), projected.scope(), now);
if (id != null) keepIds.add(id);
}
// Remove stale facts
if (!keepRefs.isEmpty()) {
factMapper.deleteByAgentIdAndSourceRefNotIn(agentId, keepRefs, now);
// Remove stale facts by row ID. source_ref is intentionally not unique
// across owners, so a source-ref keep set cannot express owner identity.
if (!keepIds.isEmpty() && keepIds.size() == allFacts.size()) {
factMapper.deleteByAgentIdAndIdNotIn(agentId, keepIds, now);
}
log.info("[FactProjection] rebuildAll: agent={}, facts={}", agentId, allFacts.size());
@ -89,27 +95,42 @@ public class FactProjectionBuilder {
* Incremental rebuild for a single file change.
*/
public int rebuildOne(Long agentId, String filename, String content) {
return rebuildOne(agentId, filename, content, "", MemoryScope.TEAM);
}
/** Incremental owner-aware rebuild for one canonical memory row. */
public int rebuildOne(Long agentId, String filename, String content, String ownerKey, String scope) {
if (!properties.getFact().isProjectionEnabled()) return 0;
List<ExtractedFact> facts = extractor.extract(agentId, filename, content);
LocalDateTime now = LocalDateTime.now();
for (ExtractedFact fact : facts) {
upsertDerived(agentId, fact, now);
String normalizedScope = normalizeScope(scope);
upsertDerived(agentId, fact, normalizeOwner(ownerKey, normalizedScope), normalizedScope, now);
}
log.debug("[FactProjection] rebuildOne: agent={}, file={}, facts={}", agentId, filename, facts.size());
return facts.size();
}
/**
* Dialect-safe upsert: select by (agent_id, source_ref), then insert or update.
* Dialect-safe upsert: select by owner-aware projection identity, then insert or update.
* Preserves accumulated columns (use_count, last_used_at) on update.
*/
private void upsertDerived(Long agentId, ExtractedFact fact, LocalDateTime now) {
FactEntity existing = factMapper.selectOne(
new LambdaQueryWrapper<FactEntity>()
private Long upsertDerived(Long agentId, ExtractedFact fact, String ownerKey,
String scope, LocalDateTime now) {
LambdaQueryWrapper<FactEntity> identity = new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getSourceRef, fact.sourceRef())
.last("LIMIT 1"));
.eq(FactEntity::getScope, scope);
if (MemoryScope.PERSONAL.equals(scope)) {
identity.eq(FactEntity::getOwnerKey, ownerKey);
} else {
// V137 backfilled scope but historical fact rows may still have a
// null owner, while newer shared canonical rows use the "" sentinel.
identity.and(w -> w.isNull(FactEntity::getOwnerKey)
.or().eq(FactEntity::getOwnerKey, ""));
}
FactEntity existing = factMapper.selectOne(identity.last("LIMIT 1"));
if (existing != null) {
// Update derived columns only; preserve accumulated columns
@ -119,12 +140,15 @@ public class FactProjectionBuilder {
existing.setObjectValue(fact.objectValue());
existing.setConfidence(fact.confidence());
existing.setExtractedBy(fact.extractedBy());
existing.setOwnerKey(ownerKey);
existing.setScope(scope);
// Trust derived from canonical feedback metadata, then time-decayed
double baseTrust = fact.trust();
existing.setTrust(applyTimeDecay(baseTrust, existing.getUpdateTime(), now));
existing.setUpdateTime(now);
existing.setDeleted(0); // un-delete if previously soft-deleted
factMapper.updateById(existing);
return existing.getId();
} else {
FactEntity entity = new FactEntity();
entity.setAgentId(agentId);
@ -137,13 +161,27 @@ public class FactProjectionBuilder {
entity.setTrust(fact.trust());
entity.setUseCount(0);
entity.setExtractedBy(fact.extractedBy());
entity.setOwnerKey(ownerKey);
entity.setScope(scope);
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setDeleted(0);
factMapper.insert(entity);
return entity.getId();
}
}
private String normalizeScope(String scope) {
return MemoryScope.PERSONAL.equals(scope) || MemoryScope.GLOBAL.equals(scope)
? scope : MemoryScope.TEAM;
}
private String normalizeOwner(String ownerKey, String scope) {
return MemoryScope.PERSONAL.equals(scope) && ownerKey != null ? ownerKey : "";
}
private record ProjectedFact(ExtractedFact fact, String ownerKey, String scope) {}
/**
* Apply exponential time decay to trust score.
* Formula: trust * 2^(-daysSinceLastUpdate / halfLifeDays)

Some files were not shown because too many files have changed in this diff Show More