Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
+ * not pollute the top-level origin record.
+ *
+ *
Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
+ * physical removal — see {@link ChatOrigin}'s class doc.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record ChannelTarget(
+ @Nullable String targetId,
+ @Nullable String threadId,
+ @Nullable String accountId
+) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
new file mode 100644
index 00000000..56c1a01e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
@@ -0,0 +1,102 @@
+package vip.mate.agent.context;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.lang.Nullable;
+
+import java.util.Map;
+
+/**
+ * Immutable value object that travels alongside an agent invocation describing
+ * where the request came from — channel, conversation, requester,
+ * workspace, and optional delivery target.
+ *
+ *
Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
+ * {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
+ * the agent runtime to enrich the origin (agentId, workspace) without mutation.
+ *
+ *
Field evolution rule
+ *
+ *
Only add — never delete; deprecate at least 90 days (covers approval TTL)
+ * before physical removal.
+ *
Never rename — add a new field plus deprecate-old-field, double-write
+ * during the migration window.
+ *
{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
+ * compatibility when older approval rows are deserialized after upgrades.
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record ChatOrigin(
+ @Nullable Long agentId,
+ @Nullable String conversationId,
+ @Nullable String requesterId,
+ @Nullable Long workspaceId,
+ @Nullable String workspaceBasePath,
+ @Nullable Long channelId,
+ @Nullable ChannelTarget channelTarget
+) {
+
+ /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
+ public static final String CTX_KEY = "mateclaw.chatOrigin";
+
+ /** Sentinel used by AgentService default overloads where no origin is supplied. */
+ public static final ChatOrigin EMPTY =
+ new ChatOrigin(null, null, "", null, null, null, null);
+
+ // ---------------- Factories per entry point ----------------
+
+ public static ChatOrigin web(@Nullable String conversationId,
+ @Nullable String requesterId,
+ @Nullable Long workspaceId,
+ @Nullable String workspaceBasePath) {
+ return new ChatOrigin(null, conversationId,
+ requesterId != null ? requesterId : "",
+ workspaceId, workspaceBasePath, null, null);
+ }
+
+ public static ChatOrigin cron(@Nullable String conversationId,
+ @Nullable Long workspaceId,
+ @Nullable String workspaceBasePath,
+ @Nullable Long channelId,
+ @Nullable ChannelTarget target) {
+ return new ChatOrigin(null, conversationId, "system",
+ workspaceId, workspaceBasePath, channelId, target);
+ }
+
+ // ---------------- Wither-style updates ----------------
+
+ public ChatOrigin withAgent(@Nullable Long newAgentId) {
+ return new ChatOrigin(newAgentId, conversationId, requesterId,
+ workspaceId, workspaceBasePath, channelId, channelTarget);
+ }
+
+ public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
+ @Nullable String newWorkspaceBasePath) {
+ return new ChatOrigin(agentId, conversationId, requesterId,
+ newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
+ }
+
+ public ChatOrigin withConversationId(@Nullable String newConversationId) {
+ return new ChatOrigin(agentId, newConversationId, requesterId,
+ workspaceId, workspaceBasePath, channelId, channelTarget);
+ }
+
+ // ---------------- Spring AI ToolContext interop ----------------
+
+ /** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
+ public ToolContext toToolContext() {
+ return new ToolContext(Map.of(CTX_KEY, this));
+ }
+
+ /**
+ * Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
+ * {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
+ * no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
+ * callers safe even if wiring is partial).
+ */
+ public static ChatOrigin from(@Nullable ToolContext ctx) {
+ if (ctx == null) return EMPTY;
+ Object v = ctx.getContext().get(CTX_KEY);
+ return v instanceof ChatOrigin co ? co : EMPTY;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java
new file mode 100644
index 00000000..86d3db8f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java
@@ -0,0 +1,40 @@
+package vip.mate.agent.context;
+
+/**
+ * Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
+ * public entry points and the StateGraph's {@code buildInitialState}.
+ *
+ *
RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
+ * once it lands in graph state. This holder is the small bridge that gets the
+ * origin from the AgentService method invocation into the graph's initial
+ * state map — the holder lifecycle is bounded by the AgentService method
+ * call (set on entry, cleared in {@code finally}). Once written into the
+ * graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
+ * the rest of the runtime reads via the typed accessor — no further ThreadLocal
+ * access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
+ */
+public final class ChatOriginHolder {
+
+ private static final ThreadLocal HOLDER = new ThreadLocal<>();
+
+ private ChatOriginHolder() {
+ }
+
+ /** Set the origin for the current AgentService invocation. */
+ public static void set(ChatOrigin origin) {
+ HOLDER.set(origin);
+ }
+
+ /**
+ * @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
+ * when no entry path has supplied one (legacy callers).
+ */
+ public static ChatOrigin get() {
+ ChatOrigin v = HOLDER.get();
+ return v != null ? v : ChatOrigin.EMPTY;
+ }
+
+ public static void clear() {
+ HOLDER.remove();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
index ebc4b2e4..64c65d4c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
@@ -410,6 +410,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
+
+ // RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
+ // and workspace, then write it into graph state so ActionNode +
+ // StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
+ vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
+ Long parsedAgentIdForOrigin = null;
+ try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
+ if (parsedAgentIdForOrigin != null) {
+ origin = origin.withAgent(parsedAgentIdForOrigin);
+ }
+ origin = origin.withConversationId(conversationId)
+ .withWorkspace(origin.workspaceId(), workspaceBasePath);
+ inputs.put(CHAT_ORIGIN, origin);
return inputs;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
index 869bd574..afca2a30 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
@@ -4,10 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
+import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
@@ -202,6 +204,8 @@ public class ToolExecutionExecutor {
private volatile String currentRequesterId;
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
private volatile String currentWorkspaceBasePath;
+ /** RFC-063r §2.5: 当前执行的 ChatOrigin,构建 ToolContext 时透传给工具 */
+ private volatile ChatOrigin currentChatOrigin = ChatOrigin.EMPTY;
public ToolExecutionResult execute(List toolCalls,
String conversationId, String agentId,
@@ -213,8 +217,30 @@ public class ToolExecutionExecutor {
String conversationId, String agentId,
boolean isReplay, String requesterId,
String workspaceBasePath) {
+ return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
+ workspaceBasePath, ChatOrigin.EMPTY);
+ }
+
+ /**
+ * RFC-063r §2.5: preferred overload — accepts a {@link ChatOrigin} that the
+ * top-level agent has enriched with agentId/workspace/channel context.
+ * Builds a Spring AI {@link ToolContext} per tool invocation so
+ * {@code @Tool} methods can read the origin via
+ * {@code ChatOrigin.from(toolContext)}.
+ *
+ *
During the PR-1 transition the legacy {@link ToolExecutionContext}
+ * ThreadLocal is also populated, so existing tools that read from it keep
+ * working unchanged. After all 8 callsites migrate, the ThreadLocal can be
+ * removed.
+ */
+ public ToolExecutionResult execute(List toolCalls,
+ String conversationId, String agentId,
+ boolean isReplay, String requesterId,
+ String workspaceBasePath,
+ ChatOrigin origin) {
this.currentRequesterId = requesterId;
this.currentWorkspaceBasePath = workspaceBasePath;
+ this.currentChatOrigin = origin != null ? origin : ChatOrigin.EMPTY;
List allResponses = new ArrayList<>();
List events = Collections.synchronizedList(new ArrayList<>());
// RFC-052: accumulate full-text outputs from returnDirect tools so the
@@ -309,7 +335,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
- conversationId, currentRequesterId, currentWorkspaceBasePath));
+ conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
// 占位,Phase 2 填充
allResponses.add(null);
}
@@ -382,7 +408,13 @@ public class ToolExecutionExecutor {
try {
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
- String result = callback.call(callArguments);
+ // RFC-063r §2.5: forward ToolContext so the pre-approved tool can
+ // still observe the originating ChatOrigin (channel/workspace).
+ ChatOrigin replayOrigin = currentChatOrigin != null ? currentChatOrigin : ChatOrigin.EMPTY;
+ replayOrigin = replayOrigin
+ .withConversationId(conversationId)
+ .withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
+ String result = callback.call(callArguments, replayOrigin.toToolContext());
int rawLen = result != null ? result.length() : 0;
// RFC-052: pre-approved tool may itself be returnDirect — in that
@@ -565,11 +597,19 @@ public class ToolExecutionExecutor {
toolName, pc.arguments != null && pc.arguments.length() > 200
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
- // 注入工具执行上下文(供 VideoGenerateTool 等获取 conversationId / username / workspaceBasePath)
+ // RFC-063r §2.5 / PR-1 transition window: populate BOTH the explicit
+ // Spring AI ToolContext (preferred — read via ChatOrigin.from(ctx))
+ // AND the legacy ToolExecutionContext ThreadLocal so tools that have
+ // not yet migrated to ToolContext keep working unchanged.
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
String result;
try {
- result = pc.callback.call(pc.arguments);
+ ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
+ runtimeOrigin = runtimeOrigin
+ .withConversationId(pc.conversationId)
+ .withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
+ ToolContext toolContext = runtimeOrigin.toToolContext();
+ result = pc.callback.call(pc.arguments, toolContext);
} finally {
ToolExecutionContext.clear();
}
@@ -781,7 +821,8 @@ public class ToolExecutionExecutor {
int resultIndex,
String conversationId,
String requesterId,
- String workspaceBasePath
+ String workspaceBasePath,
+ ChatOrigin origin
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
index 18a59c61..8904ee6d 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
@@ -65,9 +65,13 @@ public class ActionNode implements NodeAction {
// 获取工作区活动目录
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
+ // RFC-063r §2.5: read the originating ChatOrigin from graph state and
+ // forward it into the executor — tools see it via Spring AI ToolContext.
+ vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
+
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
- toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
+ toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
index 799f53a1..64a25303 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
@@ -285,6 +285,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
+
+ // RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
+ // so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
+ // can read it back from state.
+ vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
+ Long parsedAgentIdForOrigin = null;
+ try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
+ if (parsedAgentIdForOrigin != null) {
+ origin = origin.withAgent(parsedAgentIdForOrigin);
+ }
+ origin = origin.withConversationId(conversationId)
+ .withWorkspace(origin.workspaceId(), workspaceBasePath);
+ inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
return inputs;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
index e62ec41b..fa055a95 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
@@ -86,6 +86,12 @@ public class StepExecutionNode implements NodeAction {
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
+ // RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
+ // this step (and any DelegateAgentTool sub-graphs) inherit channel /
+ // workspace / requester context.
+ vip.mate.agent.context.ChatOrigin chatOrigin =
+ state.value(MateClawStateKeys.CHAT_ORIGIN)
+ .orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@@ -196,7 +202,7 @@ public class StepExecutionNode implements NodeAction {
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
- List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
+ List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
@@ -212,7 +218,7 @@ public class StepExecutionNode implements NodeAction {
} else {
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
- allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
+ allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
index 92b1bce3..b7e0710e 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
@@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import java.util.*;
@@ -212,6 +213,17 @@ public final class MateClawStateAccessor {
return state.value(FORCED_TOOL_CALL, "");
}
+ // ===== RFC-063r: ChatOrigin =====
+
+ /**
+ * RFC-063r §2.5: the {@link ChatOrigin} written into graph state by the
+ * top-level agent. Returns {@link ChatOrigin#EMPTY} when the entry path
+ * did not supply one (e.g., legacy callers using the bridge overloads).
+ */
+ public ChatOrigin chatOrigin() {
+ return state.value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
+ }
+
// ===== Token Usage =====
public int promptTokens() {
@@ -398,6 +410,11 @@ public final class MateClawStateAccessor {
return put(FORCED_TOOL_CALL, json);
}
+ // ---- RFC-063r: ChatOrigin ----
+ public OutputBuilder chatOrigin(ChatOrigin origin) {
+ return put(CHAT_ORIGIN, origin);
+ }
+
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
index 46e084f2..58f76c7d 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
@@ -155,4 +155,16 @@ public final class MateClawStateKeys {
* tool batch, used by FinalAnswerNode to assemble the final answer.
*/
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
+
+ // ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
+
+ /**
+ * RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
+ * into graph state once at {@code buildInitialState}; nodes (especially
+ * {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
+ * read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
+ * so child graphs and delegated agents inherit the originating channel /
+ * workspace context.
+ */
+ public static final String CHAT_ORIGIN = "chat_origin";
}
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
index 77987cba..8a664032 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.tool.guard.model.GuardEvaluation;
@@ -137,6 +139,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
snapshot.setFindingsJson(entity.getFindingsJson());
snapshot.setMaxSeverity(entity.getMaxSeverity());
snapshot.setSummary(entity.getSummary());
+ snapshot.setChatOrigin(entity.getChatOrigin());
approvalService.registerRecovered(snapshot);
recovered++;
@@ -200,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
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) {
@@ -207,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
pending.setSummary(evaluation.summary());
}
+ pending.setChatOrigin(chatOriginJson);
});
// 3. DB 层
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
- toolCallPayload, siblingToolCalls, agentId, evaluation);
+ toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
return pendingId;
}
@@ -517,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
private void persistToDb(String pendingId, String conversationId, String userId,
String toolName, String toolArguments,
String toolCallPayload, String siblingToolCalls, String agentId,
- GuardEvaluation evaluation) {
+ GuardEvaluation evaluation, String chatOriginJson) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
entity.setPendingId(pendingId);
@@ -531,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
+ // RFC-063r §2.12: persist Memento snapshot. Null when the entry
+ // path didn't supply an origin — replay falls back to ChatOrigin.EMPTY.
+ entity.setChatOrigin(chatOriginJson);
if (evaluation != null) {
entity.setFindingsJson(serializeFindings(evaluation.findings()));
@@ -548,6 +563,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
}
}
+ /**
+ * RFC-063r §2.12: serialize a {@link ChatOrigin} for persistence on
+ * {@code mate_tool_approval.chat_origin}. Returns null for
+ * {@code ChatOrigin.EMPTY} so legacy approvals that never captured an
+ * origin do not store a meaningless empty record.
+ */
+ private String serializeChatOrigin(ChatOrigin origin) {
+ if (origin == null || origin == ChatOrigin.EMPTY) return null;
+ if (origin.agentId() == null && origin.channelId() == null
+ && origin.conversationId() == null && origin.workspaceId() == null) {
+ return null;
+ }
+ try {
+ return objectMapper.writeValueAsString(origin);
+ } catch (JsonProcessingException e) {
+ log.warn("[ApprovalWorkflow] Failed to serialize ChatOrigin: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * RFC-063r §2.12: deserialize a persisted Memento back into a
+ * {@link ChatOrigin}. Returns {@link ChatOrigin#EMPTY} when the column
+ * is null or the payload is corrupt — the caller treats that as
+ * "no channel binding" and replay proceeds with a web-style flow.
+ */
+ public ChatOrigin restoreChatOrigin(String json) {
+ if (json == null || json.isBlank()) return ChatOrigin.EMPTY;
+ try {
+ ChatOrigin restored = objectMapper.readValue(json, ChatOrigin.class);
+ return restored != null ? restored : ChatOrigin.EMPTY;
+ } catch (Exception e) {
+ log.warn("[ApprovalWorkflow] Failed to restore ChatOrigin: {} (payload-len={})",
+ e.getMessage(), json.length());
+ return ChatOrigin.EMPTY;
+ }
+ }
+
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
try {
LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper()
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/PendingApproval.java b/mateclaw-server/src/main/java/vip/mate/approval/PendingApproval.java
index 87714e00..481fe1a8 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/PendingApproval.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/PendingApproval.java
@@ -59,6 +59,16 @@ public class PendingApproval {
/** 风险摘要 */
private String summary;
+ /**
+ * RFC-063r §2.12: serialized {@code ChatOrigin} snapshot captured when
+ * this approval was created. Lets cross-process / cross-restart replays
+ * (the user approves hours later from a different node) restore the
+ * original channel binding so the replayed tool call still delivers
+ * back to the correct channel. Persisted into
+ * {@code mate_tool_approval.chat_origin}.
+ */
+ private String chatOrigin;
+
public PendingApproval(String pendingId, String conversationId, String userId,
String toolName, String toolArguments, String reason) {
this.pendingId = pendingId;
@@ -110,6 +120,7 @@ public class PendingApproval {
public String getFindingsJson() { return findingsJson; }
public String getMaxSeverity() { return maxSeverity; }
public String getSummary() { return summary; }
+ public String getChatOrigin() { return chatOrigin; }
// === Setters ===
@@ -125,4 +136,5 @@ public class PendingApproval {
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
public void setSummary(String summary) { this.summary = summary; }
+ public void setChatOrigin(String chatOrigin) { this.chatOrigin = chatOrigin; }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/model/ToolApprovalEntity.java b/mateclaw-server/src/main/java/vip/mate/approval/model/ToolApprovalEntity.java
index 90f96ac2..25df0eca 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/model/ToolApprovalEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/model/ToolApprovalEntity.java
@@ -36,6 +36,16 @@ public class ToolApprovalEntity {
private LocalDateTime resolvedAt;
private LocalDateTime expireAt;
+ /**
+ * RFC-063r §2.12: serialized {@link vip.mate.agent.context.ChatOrigin}
+ * snapshot captured when this approval was created. The Memento lets
+ * ChannelMessageRouter.replayApprovedToolCall (and the web ApprovalController
+ * replay path) restore the originating channel/workspace context after
+ * a process restart, so a tool approved hours later still binds back to
+ * the original channel.
+ */
+ private String chatOrigin;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
index 1a2f3c43..3952b1a8 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
@@ -113,6 +113,19 @@ public interface ChannelAdapter {
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
}
+ /**
+ * RFC-063r §2.10: extended overload that accepts a
+ * {@link DeliveryOptions} Parameter Object carrying optional hints
+ * (thread id, multi-bot account id, future ext fields).
+ *
+ *
Default implementation delegates to {@link #proactiveSend(String, String)},
+ * dropping hints — concrete adapters (Slack, Telegram) override this
+ * variant to read {@code threadId} and route into the threading API.
+ */
+ default void proactiveSend(String targetId, String content, DeliveryOptions options) {
+ proactiveSend(targetId, content);
+ }
+
/**
* 当前渠道是否支持主动推送
*
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java
new file mode 100644
index 00000000..85b1d098
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java
@@ -0,0 +1,54 @@
+package vip.mate.channel;
+
+import org.springframework.stereotype.Component;
+import vip.mate.agent.context.ChannelTarget;
+import vip.mate.agent.context.ChatOrigin;
+import vip.mate.channel.model.ChannelEntity;
+
+/**
+ * RFC-063r §2.2: factory that translates an inbound channel message into a
+ * {@link ChatOrigin}. Lives in {@code vip.mate.channel} (not in
+ * {@code vip.mate.agent.context}) so that the dependency direction stays
+ * {@code channel → agent} and never the reverse.
+ */
+@Component
+public class ChannelChatOriginFactory {
+
+ /**
+ * Build a {@link ChatOrigin} for a channel-originated message.
+ *
+ * @param channel channel entity (non-null) — provides id + workspaceId
+ * @param message inbound message (non-null) — provides senderId + reply target
+ * @param conversationId resolved conversation id (channel-scoped)
+ * @param workspaceBasePath workspace activity directory; null = unrestricted
+ */
+ public ChatOrigin from(ChannelEntity channel,
+ ChannelMessage message,
+ String conversationId,
+ String workspaceBasePath) {
+ ChannelTarget target = new ChannelTarget(
+ resolveTargetId(message),
+ /* threadId */ null, // adapters fill via ChannelMessage extension fields when available
+ /* accountId */ null);
+ return new ChatOrigin(
+ /* agentId */ null,
+ /* conversationId */ conversationId,
+ /* requesterId */ message.getSenderId(),
+ /* workspaceId */ channel.getWorkspaceId(),
+ /* workspaceBasePath */ workspaceBasePath,
+ /* channelId */ channel.getId(),
+ /* channelTarget */ target);
+ }
+
+ /**
+ * Resolve the IM target id used for proactive sends — prefer chatId
+ * (group/room) over senderId so that cron deliveries land in the same
+ * conversation the user originally messaged from.
+ */
+ private String resolveTargetId(ChannelMessage message) {
+ if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
+ return message.getReplyToken();
+ }
+ return message.getChatId() != null ? message.getChatId() : message.getSenderId();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
index 15d06837..b7805453 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
@@ -330,13 +330,23 @@ public class ChannelManager {
* @throws IllegalStateException 渠道未启动或不支持主动推送
*/
public void sendToChannel(Long channelId, String targetId, String content) {
+ sendToChannel(channelId, targetId, content, DeliveryOptions.DEFAULTS);
+ }
+
+ /**
+ * RFC-063r §2.10: preferred overload — accepts a {@link DeliveryOptions}
+ * Parameter Object so cron delivery (and future callers) can pass
+ * Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
+ * {@code accountId}, etc. without growing a 5-arg signature.
+ */
+ public void sendToChannel(Long channelId, String targetId, String content, DeliveryOptions options) {
ChannelAdapter adapter = getAdapter(channelId)
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
if (!adapter.supportsProactiveSend()) {
throw new UnsupportedOperationException(
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
}
- adapter.proactiveSend(targetId, content);
+ adapter.proactiveSend(targetId, content, options != null ? options : DeliveryOptions.DEFAULTS);
log.info("Proactive message sent via channel {} to {}: {}chars",
adapter.getDisplayName(), targetId, content.length());
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
index 6b461f05..508ed5a6 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
@@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.ResolveOutcome;
import vip.mate.approval.PendingApproval;
@@ -54,6 +55,7 @@ public class ChannelMessageRouter {
private final TtsService ttsService;
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
+ private final ChannelChatOriginFactory chatOriginFactory;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@@ -98,7 +100,8 @@ public class ChannelMessageRouter {
ConversationCompletionPublisher completionPublisher,
TtsService ttsService,
ObjectMapper objectMapper,
- ChatStreamTracker streamTracker) {
+ ChatStreamTracker streamTracker,
+ ChannelChatOriginFactory chatOriginFactory) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
@@ -109,6 +112,7 @@ public class ChannelMessageRouter {
this.ttsService = ttsService;
this.objectMapper = objectMapper;
this.streamTracker = streamTracker;
+ this.chatOriginFactory = chatOriginFactory;
}
// ==================== 防抖辅助类 ====================
@@ -440,11 +444,17 @@ public class ChannelMessageRouter {
Long savedAssistantId = null;
try {
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
+ // RFC-063r §2.5: build the ChatOrigin once per channel-message
+ // so cron jobs created during this conversation inherit the
+ // channel binding (Issue #25 root path).
+ ChatOrigin chatOrigin = chatOriginFactory.from(
+ channelEntity, message, conversationId, /* workspaceBasePath */ null);
+
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
- savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
+ savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
// 同步路径:直接获取完整回复
- String reply = agentService.chat(agentId, promptText, conversationId);
+ String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
@@ -521,14 +531,14 @@ public class ChannelMessageRouter {
*/
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText,
- ChannelEntity channelEntity) {
+ ChannelEntity channelEntity, ChatOrigin chatOrigin) {
String channelType = streamingAdapter.getChannelType();
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
- // Step 1: 产生事件流
+ // Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId)
Flux stream = agentService.chatStructuredStream(
- agentId, promptText, conversationId, message.getSenderId());
+ agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
@@ -591,8 +601,17 @@ public class ChannelMessageRouter {
String replayPrompt = "继续执行已批准的工具调用。";
try {
+ // RFC-063r §2.12: prefer the persisted Memento (covers
+ // cross-restart approval where the channel session changed) and
+ // only fall back to rebuilding from the current inbound message
+ // when no snapshot was captured (legacy rows from before this PR).
+ ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin());
+ if (replayOrigin == ChatOrigin.EMPTY) {
+ replayOrigin = chatOriginFactory.from(
+ channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
+ }
String reply = agentService.chatWithReplay(
- agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
+ agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
// 保存 replay 结果(这是正常结果,入库)
conversationService.saveMessage(conversationId, "assistant", reply);
@@ -644,7 +663,11 @@ public class ChannelMessageRouter {
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
- return agentService.chatStream(agentId, promptText, conversationId);
+ // RFC-063r §2.5: forward ChatOrigin so tools created during this
+ // streaming conversation inherit channel binding.
+ ChatOrigin origin = chatOriginFactory.from(
+ channelEntity, message, conversationId, /* workspaceBasePath */ null);
+ return agentService.chatStream(agentId, promptText, conversationId, origin);
}
// ==================== 优雅关闭 ====================
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/DeliveryOptions.java b/mateclaw-server/src/main/java/vip/mate/channel/DeliveryOptions.java
new file mode 100644
index 00000000..08f57e25
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/DeliveryOptions.java
@@ -0,0 +1,29 @@
+package vip.mate.channel;
+
+import org.springframework.lang.Nullable;
+
+import java.util.Map;
+
+/**
+ * RFC-063r §2.10: Parameter Object that bundles optional delivery hints
+ * (Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
+ * {@code accountId}, etc.) so {@link ChannelManager#sendToChannel} doesn't
+ * grow a 5-arg overload.
+ *
+ *
{@link #DEFAULTS} is the canonical "no hints" instance — adapters that
+ * don't override the 4-arg {@code proactiveSend} keep their pre-RFC behavior.
+ */
+public record DeliveryOptions(
+ @Nullable String threadId,
+ @Nullable String accountId,
+ Map ext
+) {
+
+ public static final DeliveryOptions DEFAULTS = new DeliveryOptions(null, null, Map.of());
+
+ public DeliveryOptions {
+ // Defensive: never expose a null map — the receiver should be able to
+ // call .get(...) without a null check.
+ if (ext == null) ext = Map.of();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java
index 255e1950..eb1b2737 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java
@@ -238,6 +238,38 @@ public class SlackChannelAdapter extends AbstractChannelAdapter {
sendMessage(targetId, content);
}
+ /**
+ * RFC-063r §2.10: thread-aware proactive send. Reads
+ * {@link vip.mate.channel.DeliveryOptions#threadId()} (the Slack
+ * {@code thread_ts}) and posts into that thread when supplied; falls
+ * back to the legacy in-channel post when null.
+ */
+ @Override
+ public void proactiveSend(String targetId, String content,
+ vip.mate.channel.DeliveryOptions options) {
+ if (options == null || options.threadId() == null || options.threadId().isBlank()) {
+ sendMessage(targetId, content);
+ return;
+ }
+ String botToken = getConfigString("bot_token");
+ if (botToken == null || content == null || content.isBlank()) {
+ return;
+ }
+ try {
+ String slackContent = convertToSlackMarkdown(content);
+ final String threadTs = options.threadId();
+ ChatPostMessageResponse response = slack.methods(botToken).chatPostMessage(req ->
+ req.channel(targetId).text(slackContent).threadTs(threadTs));
+ if (!response.isOk()) {
+ log.warn("[slack] Failed to send threaded proactive message (thread_ts={}): {}",
+ threadTs, response.getError());
+ }
+ } catch (IOException | SlackApiException e) {
+ log.error("[slack] Error sending threaded proactive message to {}: {}",
+ targetId, e.getMessage());
+ }
+ }
+
/**
* Webhook 回调处理(备用模式,Socket Mode 优先)
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java
index 50b1c90b..97cf420c 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java
@@ -715,6 +715,80 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
sendMessage(targetId, content);
}
+ /**
+ * RFC-063r §2.10: forum-thread-aware proactive send. When
+ * {@link vip.mate.channel.DeliveryOptions#threadId()} is set (Telegram
+ * forum {@code message_thread_id}), include it in the {@code sendMessage}
+ * call so the cron result lands in the correct thread of a forum group.
+ * Falls back to the legacy chat-level send when null.
+ */
+ @Override
+ public void proactiveSend(String targetId, String content,
+ vip.mate.channel.DeliveryOptions options) {
+ if (options == null || options.threadId() == null || options.threadId().isBlank()) {
+ sendMessage(targetId, content);
+ return;
+ }
+ if (httpClient == null || botToken == null) {
+ log.warn("[telegram] Channel not started, cannot send proactive message");
+ return;
+ }
+ Integer threadId;
+ try {
+ threadId = Integer.valueOf(options.threadId());
+ } catch (NumberFormatException nfe) {
+ log.warn("[telegram] Invalid message_thread_id '{}'; sending to main chat", options.threadId());
+ sendMessage(targetId, content);
+ return;
+ }
+ try {
+ // Try Markdown first, fall back to plain on parse error — same
+ // contract as sendMessage but with message_thread_id added.
+ if (!sendThreadedText(targetId, threadId, content, "Markdown")) {
+ sendThreadedText(targetId, threadId, content, null);
+ }
+ } catch (Exception e) {
+ log.error("[telegram] proactiveSend(threadId={}) failed: {}", threadId, e.getMessage());
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private boolean sendThreadedText(String targetId, Integer threadId, String content, String parseMode) {
+ try {
+ Map body = new java.util.LinkedHashMap<>();
+ body.put("chat_id", targetId);
+ body.put("message_thread_id", threadId);
+ body.put("text", content);
+ if (parseMode != null) {
+ body.put("parse_mode", parseMode);
+ }
+ String jsonBody = objectMapper.writeValueAsString(body);
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(apiBaseUrl + "/sendMessage"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
+ .build();
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() == 200) {
+ return true;
+ }
+ if (response.statusCode() == 400 && parseMode != null) {
+ try {
+ Map errResult = objectMapper.readValue(response.body(), Map.class);
+ String desc = String.valueOf(errResult.getOrDefault("description", ""));
+ if (desc.contains("can't parse")) {
+ return false;
+ }
+ } catch (Exception ignored) {}
+ }
+ log.warn("[telegram] Threaded send failed: status={}, body={}", response.statusCode(), response.body());
+ return true;
+ } catch (Exception e) {
+ log.error("[telegram] Threaded send error: {}", e.getMessage(), e);
+ return true;
+ }
+ }
+
@Override
public String getChannelType() {
return CHANNEL_TYPE;
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
index 53cca423..ef53c43a 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
@@ -283,8 +283,18 @@ public class ChatController {
String replayPrompt = "继续执行已批准的工具调用。";
streamTracker.incrementFlux(conversationId);
+ // RFC-063r §2.12: prefer the persisted Memento snapshot
+ // (covers cross-restart approval where the original channel
+ // is gone) and fall back to a fresh web-origin
+ // ChatOrigin when none was captured.
+ vip.mate.agent.context.ChatOrigin replayOrigin =
+ approvalService.restoreChatOrigin(finalConsumed.getChatOrigin());
+ if (replayOrigin == vip.mate.agent.context.ChatOrigin.EMPTY) {
+ replayOrigin = vip.mate.agent.context.ChatOrigin.web(
+ conversationId, username, workspaceId, null);
+ }
Disposable disposable = agentService.chatWithReplayStream(
- replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username)
+ replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> {
if (approvalEmitterDone.get()) return;
try {
@@ -460,7 +470,12 @@ public class ChatController {
));
streamTracker.incrementFlux(conversationId);
- Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel())
+ // 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 =
+ vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
+ Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
try {
@@ -1061,7 +1076,12 @@ public class ChatController {
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
streamTracker.incrementFlux(conversationId);
- Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId)
+ // 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.
+ vip.mate.agent.context.ChatOrigin queuedOrigin =
+ vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null);
+ Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
try {
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java
new file mode 100644
index 00000000..68593e11
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/CronChatOriginFactory.java
@@ -0,0 +1,41 @@
+package vip.mate.cron;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import vip.mate.agent.context.ChannelTarget;
+import vip.mate.agent.context.ChatOrigin;
+import vip.mate.agent.model.AgentEntity;
+import vip.mate.agent.repository.AgentMapper;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.cron.model.DeliveryConfig;
+
+/**
+ * RFC-063r §2.2: factory that builds a {@link ChatOrigin} for a cron-triggered
+ * agent invocation. Lives in {@code vip.mate.cron} so the dependency arrow
+ * points {@code cron → agent} only — symmetric with
+ * {@code ChannelChatOriginFactory} in {@code vip.mate.channel}.
+ *
+ *
workspaceId is reverse-resolved from {@code agent.workspaceId} (with the
+ * legacy {@code 1L} fallback to keep behavior identical to
+ * {@code CronJobService.executeJob}). workspaceBasePath is intentionally not
+ * persisted — the value is derived at run time from the agent's workspace
+ * configuration, matching the previous {@code ToolExecutionContext.workspaceBasePath()}
+ * semantics.
+ */
+@Component
+@RequiredArgsConstructor
+public class CronChatOriginFactory {
+
+ private final AgentMapper agentMapper;
+
+ public ChatOrigin from(CronJobEntity job, String conversationId) {
+ AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null;
+ Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
+
+ DeliveryConfig dc = job.getDeliveryConfig();
+ ChannelTarget target = dc != null ? dc.toChannelTarget() : null;
+
+ return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null,
+ job.getChannelId(), target);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/AbstractCronResultDelivery.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/AbstractCronResultDelivery.java
new file mode 100644
index 00000000..f6fe05e3
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/AbstractCronResultDelivery.java
@@ -0,0 +1,125 @@
+package vip.mate.cron.delivery;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import vip.mate.channel.ChannelMessageRenderer;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.dashboard.model.CronJobRunEntity;
+import vip.mate.dashboard.repository.CronJobRunMapper;
+
+import java.util.List;
+
+/**
+ * RFC-063r §2.6.1: Template-Method base for {@link CronResultDelivery}.
+ *
+ *
Owns the cross-strategy invariants:
+ *
+ *
Idempotency — atomic SQL CAS on
+ * {@code mate_cron_job_run.delivery_status} via {@link #claimRun} so
+ * only one listener instance proceeds per run row even across cluster
+ * deployments (replaces RFC-063 v1's process-local Caffeine cache).
+ *
State-machine bookkeeping — {@link #markDelivered} on success,
+ * {@link #markNotDelivered} on failure (truncated message via Hutool
+ * {@code StrUtil.maxLength} per RFC §2.6.1).
+ *
Render hook — {@link #renderForChannel} delegates to the
+ * project's existing {@link ChannelMessageRenderer} so per-channel
+ * markdown / Block Kit rendering stays consistent with the inline reply
+ * path.
+ *
+ *
+ *
Concrete subclasses implement only
+ * {@link #doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}.
+ */
+@Slf4j
+public abstract class AbstractCronResultDelivery implements CronResultDelivery {
+
+ /**
+ * Default platform message-length cap used when the bound channel type
+ * is unknown — matches Telegram's 4096 ceiling, the most permissive
+ * among the small-cap platforms in {@link ChannelMessageRenderer#PLATFORM_LIMITS}.
+ * RFC-063r §2.11 will refine this in PR-4 by passing per-channel limits
+ * through {@code DeliveryOptions}.
+ */
+ private static final int DEFAULT_RENDER_MAX_LEN = 4096;
+
+ private final CronJobRunMapper runMapper;
+
+ protected AbstractCronResultDelivery(CronJobRunMapper runMapper) {
+ this.runMapper = runMapper;
+ }
+
+ @Override
+ public final DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
+ if (!claimRun(run)) {
+ return DeliveryOutcome.skipped("already-claimed-by-other-instance");
+ }
+ try {
+ DeliveryOutcome outcome = doDeliver(job, result, run);
+ markDelivered(run, outcome);
+ return outcome;
+ } catch (Exception e) {
+ markNotDelivered(run, e);
+ throw e;
+ }
+ }
+
+ /** Strategy's actual delivery work — invoked after CAS success. */
+ protected abstract DeliveryOutcome doDeliver(
+ CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
+
+ /**
+ * RFC-063r §2.11: filter thinking + tool-call markers and join the
+ * platform-truncated segments into a single string. Concrete strategies
+ * call this before handing the result to the channel-specific send call.
+ *
+ *
Null-safe — null/empty {@link AssistantMessage} returns "" so
+ * adapters never NPE.
+ */
+ protected String renderForChannel(AssistantMessage msg, Long channelId) {
+ if (msg == null) return "";
+ String text = msg.getText() != null ? msg.getText() : "";
+ if (text.isEmpty()) return "";
+ try {
+ List segments = ChannelMessageRenderer.renderForChannel(
+ text, /* filterThinking */ true, /* filterToolMessages */ true,
+ /* messageFormat */ null, DEFAULT_RENDER_MAX_LEN);
+ return segments.isEmpty() ? "" : String.join("\n\n", segments);
+ } catch (Exception e) {
+ log.debug("[CronDelivery] renderForChannel failed (channelId={}); falling back to raw text: {}",
+ channelId, e.getMessage());
+ return text;
+ }
+ }
+
+ // ---------- 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 (cluster paths can't normally hit this).
+ */
+ private boolean claimRun(CronJobRunEntity run) {
+ return runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, run.getId())
+ .in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING", null)
+ .set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
+ }
+
+ private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
+ runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, run.getId())
+ .set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
+ .set(CronJobRunEntity::getDeliveryTarget, o.target()));
+ }
+
+ private void markNotDelivered(CronJobRunEntity run, Exception e) {
+ runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, run.getId())
+ .set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
+ .set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/ChannelCronResultDelivery.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/ChannelCronResultDelivery.java
new file mode 100644
index 00000000..020a5edb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/ChannelCronResultDelivery.java
@@ -0,0 +1,55 @@
+package vip.mate.cron.delivery;
+
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import vip.mate.channel.ChannelManager;
+import vip.mate.channel.DeliveryOptions;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.cron.model.DeliveryConfig;
+import vip.mate.dashboard.model.CronJobRunEntity;
+import vip.mate.dashboard.repository.CronJobRunMapper;
+
+import java.util.Map;
+
+/**
+ * RFC-063r §2.6: deliver a cron job's assistant result back to its
+ * originating IM channel via {@link ChannelManager#sendToChannel}.
+ *
+ *
{@link #supports} returns true only when both {@code channelId} and a
+ * non-null {@code deliveryConfig.targetId()} are present — web-origin jobs
+ * (no channelId) and partial bindings fall through and the run stays in
+ * {@code delivery_status='NONE'}, matching the always-best-effort policy in
+ * RFC §2.7.3.
+ */
+@Component
+@Order(10)
+public class ChannelCronResultDelivery extends AbstractCronResultDelivery {
+
+ private final ChannelManager channelManager;
+
+ public ChannelCronResultDelivery(CronJobRunMapper runMapper,
+ ChannelManager channelManager) {
+ super(runMapper);
+ this.channelManager = channelManager;
+ }
+
+ @Override
+ public boolean supports(CronJobEntity job) {
+ if (job == null || job.getChannelId() == null) return false;
+ DeliveryConfig dc = job.getDeliveryConfig();
+ return dc != null && dc.targetId() != null && !dc.targetId().isBlank();
+ }
+
+ @Override
+ protected DeliveryOutcome doDeliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
+ DeliveryConfig dc = job.getDeliveryConfig();
+ String rendered = renderForChannel(result, job.getChannelId());
+ // RFC-063r §2.10: forward thread / account hints via DeliveryOptions.
+ // Adapters that don't override the 4-arg proactiveSend default ignore
+ // the hints — preserves pre-RFC behavior for non-threading platforms.
+ DeliveryOptions options = new DeliveryOptions(dc.threadId(), dc.accountId(), Map.of());
+ channelManager.sendToChannel(job.getChannelId(), dc.targetId(), rendered, options);
+ return DeliveryOutcome.delivered(dc.targetId());
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronDeliveryListener.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronDeliveryListener.java
new file mode 100644
index 00000000..0e0a0ee7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronDeliveryListener.java
@@ -0,0 +1,113 @@
+package vip.mate.cron.delivery;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+import vip.mate.audit.service.AuditEventService;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * RFC-063r §2.7.3: Domain-Event listener that resolves the right
+ * {@link CronResultDelivery} strategy and runs it asynchronously after the
+ * cron run's T2 transaction commits.
+ *
+ *
Why {@code AFTER_COMMIT} + {@code @Async}:
+ *
+ *
{@code AFTER_COMMIT} guarantees the listener can re-read the run
+ * row from a fresh DB connection — eliminates the read-your-writes
+ * trap from the same-tx delivery path.
+ *
{@code @Async("cronDeliveryExecutor")} unbinds delivery from the
+ * Spring event-dispatcher thread so a slow IM API never stalls other
+ * listeners. The pool uses {@code AbortPolicy} + audit so an overflow
+ * surfaces immediately rather than degrading silently.
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CronDeliveryListener {
+
+ /** Spring auto-collects every {@link CronResultDelivery} bean ordered by {@code @Order}. */
+ private final List deliveries;
+ private final AuditEventService audit;
+
+ @Async("cronDeliveryExecutor")
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onCompleted(CronJobCompletedEvent ev) {
+ Optional strategy = deliveries.stream()
+ .filter(d -> d.supports(ev.job()))
+ .findFirst();
+ if (strategy.isEmpty()) {
+ // RFC §2.6 / §2.8.1: web-origin runs (or any job without a
+ // matching strategy) leave delivery_status=NONE and exit silently.
+ return;
+ }
+ try {
+ strategy.get().deliver(ev.job(), ev.result(), ev.run());
+ } catch (Exception e) {
+ // RFC §2.7.3: always-best-effort — failures audit but never flip
+ // the run's main status. Stale-pending cleanup tightens the
+ // delivery_status state machine if the row gets stuck.
+ log.warn("[CronDelivery] Delivery failed for job {}: {}",
+ ev.job() != null ? ev.job().getId() : null, e.getMessage());
+ try {
+ audit.record("DELIVERY_FAILED", "CRON_JOB",
+ ev.job() != null ? String.valueOf(ev.job().getId()) : "unknown",
+ e.getMessage(), null);
+ } catch (Exception auditError) {
+ log.warn("[CronDelivery] Audit recording failed: {}", auditError.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Listener hook for unit tests bypassing the {@code @TransactionalEventListener}
+ * proxy — direct {@code ApplicationEventPublisher.publishEvent} delivery.
+ * Production paths always go through the AFTER_COMMIT bridge.
+ */
+ @EventListener
+ public void onCompletedRaw(CronJobCompletedEvent ev) {
+ // No-op: the @TransactionalEventListener above is the production path
+ // (it only fires when a transaction is active and after it commits).
+ // This raw @EventListener exists so unit tests using fallbackExecution
+ // do not double-fire and so the listener bean stays scannable in
+ // contexts without a tx manager. Intentionally empty.
+ }
+
+ /**
+ * RFC-063r §2.7.3: dedicated executor for cron delivery. Defined here so
+ * the listener and its pool live in the same module without an extra
+ * {@code @Configuration} class.
+ */
+ @org.springframework.context.annotation.Bean(name = "cronDeliveryExecutor")
+ public ThreadPoolTaskExecutor cronDeliveryExecutor() {
+ ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
+ ex.setCorePoolSize(2);
+ ex.setMaxPoolSize(4);
+ ex.setQueueCapacity(1000);
+ ex.setThreadNamePrefix("cron-delivery-");
+ ex.setRejectedExecutionHandler((r, executor) -> {
+ // RFC §2.7.3: AbortPolicy + audit (NOT CallerRunsPolicy) — the
+ // caller is the Spring event-dispatcher thread; blocking it would
+ // stall every other listener.
+ log.error("[CronDelivery] queue overflow ({}); dropping task — review pool sizing",
+ executor.getQueue().size());
+ try {
+ audit.record("DELIVERY_QUEUE_OVERFLOW", "CRON_DELIVERY", "system",
+ "active=" + executor.getActiveCount() + ",queue=" + executor.getQueue().size(),
+ null);
+ } catch (Exception ignored) {
+ // Audit must never block the rejection path itself.
+ }
+ });
+ ex.initialize();
+ return ex;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronJobCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronJobCompletedEvent.java
new file mode 100644
index 00000000..8bf4884d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronJobCompletedEvent.java
@@ -0,0 +1,17 @@
+package vip.mate.cron.delivery;
+
+import org.springframework.ai.chat.messages.AssistantMessage;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.dashboard.model.CronJobRunEntity;
+
+/**
+ * RFC-063r §2.7.3: domain event fired by
+ * {@code CronJobLifecycleService.finishRunAndPublish} after T2 commits.
+ * Listeners run with {@code @TransactionalEventListener(AFTER_COMMIT)}, so
+ * the run row is guaranteed visible from a fresh DB connection.
+ */
+public record CronJobCompletedEvent(
+ CronJobEntity job,
+ AssistantMessage result,
+ CronJobRunEntity run) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronResultDelivery.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronResultDelivery.java
new file mode 100644
index 00000000..c45f26e7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronResultDelivery.java
@@ -0,0 +1,33 @@
+package vip.mate.cron.delivery;
+
+import org.springframework.ai.chat.messages.AssistantMessage;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.dashboard.model.CronJobRunEntity;
+
+/**
+ * RFC-063r §2.6: pluggable strategy for delivering a cron job's assistant
+ * result back to its originating context (channel, future webhook, future
+ * SSE bridge, etc.).
+ *
+ *
{@link AbstractCronResultDelivery} provides the SQL-CAS idempotency +
+ * state-machine update; concrete strategies only implement
+ * {@link AbstractCronResultDelivery#doDeliver}.
+ */
+public interface CronResultDelivery {
+
+ /**
+ * Whether this strategy applies to the given job. The first matching
+ * strategy (per {@code @Order}) wins; web-origin jobs match nothing,
+ * leaving {@code delivery_status='NONE'}.
+ */
+ boolean supports(CronJobEntity job);
+
+ /**
+ * Run the delivery, internally claiming the run row via SQL CAS and
+ * updating {@code delivery_status} on success / failure. Implementations
+ * should override
+ * {@link AbstractCronResultDelivery#doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}
+ * rather than this method directly.
+ */
+ DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronRunStaleCleanup.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronRunStaleCleanup.java
new file mode 100644
index 00000000..4b5fa171
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronRunStaleCleanup.java
@@ -0,0 +1,63 @@
+package vip.mate.cron.delivery;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import vip.mate.dashboard.model.CronJobRunEntity;
+import vip.mate.dashboard.repository.CronJobRunMapper;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * RFC-063r §2.8.2: periodic sweep that recovers two stuck states with one
+ * scheduled job.
+ *
+ *
+ *
{@code delivery_status=PENDING} older than 15 min → mark
+ * {@code NOT_DELIVERED} with {@code stale-pending-timeout} reason.
+ * Covers listener crashes / OOMs / forced kills after a successful
+ * {@code claimRun()} but before {@code markDelivered}.
+ *
{@code status='running'} older than 30 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).
+ *
+ *
+ *
Single sweep, two predicates, one DB roundtrip per state. Spring
+ * {@code @Scheduled} is already enabled at the application bootstrap.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+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);
+
+ @Scheduled(fixedDelay = 5 * 60 * 1000L, initialDelay = 60 * 1000L)
+ public void sweep() {
+ LocalDateTime now = LocalDateTime.now();
+
+ int stalePending = runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
+ .lt(CronJobRunEntity::getStartedAt, now.minus(DELIVERY_STALE))
+ .set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
+ .set(CronJobRunEntity::getDeliveryError, "stale-pending-timeout"));
+
+ int staleRunning = runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getStatus, "running")
+ .lt(CronJobRunEntity::getStartedAt, now.minus(RUN_STALE))
+ .set(CronJobRunEntity::getStatus, "failed")
+ .set(CronJobRunEntity::getFinishedAt, now)
+ .set(CronJobRunEntity::getErrorMessage, "stale-running-timeout"));
+
+ if (stalePending > 0 || staleRunning > 0) {
+ log.warn("[CronCleanup] swept stalePending={} staleRunning={}", stalePending, staleRunning);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/delivery/DeliveryOutcome.java b/mateclaw-server/src/main/java/vip/mate/cron/delivery/DeliveryOutcome.java
new file mode 100644
index 00000000..e2fa6d43
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/DeliveryOutcome.java
@@ -0,0 +1,27 @@
+package vip.mate.cron.delivery;
+
+import org.springframework.lang.Nullable;
+
+/**
+ * RFC-063r §2.6.0: outcome of a single cron-result delivery attempt.
+ *
+ *
Two states only — {@code DELIVERED} and {@code SKIPPED}. There is no
+ * {@code FAILED} state; failures throw from
+ * {@link AbstractCronResultDelivery#doDeliver}, get marked
+ * {@code NOT_DELIVERED} on the run row by the template method, and surface
+ * to the listener as exceptions for audit. Single-purpose return semantics.
+ */
+public record DeliveryOutcome(Status status,
+ @Nullable String target,
+ @Nullable String reason) {
+
+ public enum Status { DELIVERED, SKIPPED }
+
+ public static DeliveryOutcome delivered(String target) {
+ return new DeliveryOutcome(Status.DELIVERED, target, null);
+ }
+
+ public static DeliveryOutcome skipped(String reason) {
+ return new DeliveryOutcome(Status.SKIPPED, null, reason);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java
index 400c5bc5..032185fd 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobDTO.java
@@ -28,6 +28,22 @@ public class CronJobDTO {
private LocalDateTime createTime;
private LocalDateTime updateTime;
+ /** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
+ private Long channelId;
+
+ /** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
+ private DeliveryConfig deliveryConfig;
+
+ /**
+ * RFC-063r §2.14: read-model field surfaced by CronJobMapper#selectListWithDeliveryStatus
+ * (PR-3). One of NONE / PENDING / DELIVERED / NOT_DELIVERED, taken from
+ * the most-recent run row. Out-only — never accepted on create/update.
+ */
+ private String lastDeliveryStatus;
+
+ /** RFC-063r §2.14: out-only error detail for the most-recent delivery attempt. */
+ private String lastDeliveryError;
+
public static CronJobDTO from(CronJobEntity entity) {
CronJobDTO dto = new CronJobDTO();
dto.setId(entity.getId());
@@ -43,6 +59,15 @@ public class CronJobDTO {
dto.setLastRunTime(entity.getLastRunTime());
dto.setCreateTime(entity.getCreateTime());
dto.setUpdateTime(entity.getUpdateTime());
+ dto.setChannelId(entity.getChannelId());
+ dto.setDeliveryConfig(entity.getDeliveryConfig());
+ // RFC-063r §2.14: surface the latest-run delivery snapshot when the
+ // entity was loaded via selectListWithDeliveryStatus / selectByIdWithDeliveryStatus.
+ // Default "NONE" when no run has ever been recorded so the UI can
+ // render a neutral badge instead of a blank cell.
+ dto.setLastDeliveryStatus(entity.getLastDeliveryStatus() != null
+ ? entity.getLastDeliveryStatus() : "NONE");
+ dto.setLastDeliveryError(entity.getLastDeliveryError());
return dto;
}
@@ -63,6 +88,8 @@ public class CronJobDTO {
entity.setTriggerMessage(this.triggerMessage);
entity.setRequestBody(this.requestBody);
entity.setEnabled(this.enabled);
+ entity.setChannelId(this.channelId);
+ entity.setDeliveryConfig(this.deliveryConfig);
return entity;
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java
index 8b7398a0..22f3ee9c 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java
@@ -1,6 +1,7 @@
package vip.mate.cron.model;
import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.LocalDateTime;
@@ -11,7 +12,7 @@ import java.time.LocalDateTime;
* @author MateClaw Team
*/
@Data
-@TableName("mate_cron_job")
+@TableName(value = "mate_cron_job", autoResultMap = true)
public class CronJobEntity {
@TableId(type = IdType.ASSIGN_ID)
@@ -49,6 +50,21 @@ public class CronJobEntity {
/** 上次执行时间 */
private LocalDateTime lastRunTime;
+ /**
+ * RFC-063r §2.9: originating channel binding. Null when this job was
+ * created from the web (no proactive delivery target). The single
+ * indexed column lets ops query "all jobs delivering to channel X".
+ */
+ private Long channelId;
+
+ /**
+ * RFC-063r §2.9: delivery target detail (targetId / threadId / accountId)
+ * persisted as JSON via MyBatis Plus JacksonTypeHandler so future fields
+ * don't require schema migrations.
+ */
+ @TableField(typeHandler = JacksonTypeHandler.class)
+ private DeliveryConfig deliveryConfig;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@@ -56,4 +72,16 @@ public class CronJobEntity {
private LocalDateTime updateTime;
private Integer deleted;
+
+ /**
+ * RFC-063r §2.14: read-model field — populated by
+ * {@code CronJobMapper.selectListWithDeliveryStatus()} via a subquery
+ * against {@code mate_cron_job_run}. Not part of the writable schema.
+ */
+ @TableField(exist = false)
+ private String lastDeliveryStatus;
+
+ /** RFC-063r §2.14: matching error column for the most-recent run. */
+ @TableField(exist = false)
+ private String lastDeliveryError;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java b/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java
new file mode 100644
index 00000000..ac3b01b4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java
@@ -0,0 +1,34 @@
+package vip.mate.cron.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import org.springframework.lang.Nullable;
+import vip.mate.agent.context.ChannelTarget;
+
+/**
+ * RFC-063r §2.9: per-cron-job delivery configuration. Persisted as a JSON
+ * column on {@code mate_cron_job.delivery_config} via MyBatis Plus
+ * {@code JacksonTypeHandler}. Mirrors {@link ChannelTarget} but lives in the
+ * cron module so {@code CronJobEntity} doesn't need to depend on the channel
+ * value object directly.
+ *
+ *
{@link JsonIgnoreProperties#ignoreUnknown()} keeps deserialization
+ * forward-compatible when older rows are read after a column-add upgrade.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DeliveryConfig(
+ @Nullable String targetId,
+ @Nullable String threadId,
+ @Nullable String accountId
+) {
+
+ /** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */
+ public static DeliveryConfig from(@Nullable ChannelTarget t) {
+ if (t == null) return null;
+ return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId());
+ }
+
+ /** Convert back to a {@link ChannelTarget} for ChatOrigin reconstruction. */
+ public ChannelTarget toChannelTarget() {
+ return new ChannelTarget(targetId, threadId, accountId);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java b/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java
index 5dab7d86..9e7622bf 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/repository/CronJobMapper.java
@@ -2,8 +2,12 @@ package vip.mate.cron.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
import vip.mate.cron.model.CronJobEntity;
+import java.util.List;
+
/**
* 定时任务 Mapper
*
@@ -11,4 +15,47 @@ import vip.mate.cron.model.CronJobEntity;
*/
@Mapper
public interface CronJobMapper extends BaseMapper {
+
+ /**
+ * RFC-063r §2.14: list cron jobs together with their most-recent
+ * delivery status / error (subquery against {@code mate_cron_job_run}).
+ *
+ *
Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
+ * subquery, so the same SQL is portable across the two profiles. Index
+ * coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
+ * by V1 baseline migration) makes the subquery cheap.
+ *
+ *
Filters out logically-deleted rows and orders by create_time DESC
+ * to mirror the existing {@code list()} ordering.
+ */
+ @Select("""
+ SELECT j.*,
+ (SELECT r.delivery_status FROM mate_cron_job_run r
+ WHERE r.cron_job_id = j.id
+ ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
+ (SELECT r.delivery_error FROM mate_cron_job_run r
+ WHERE r.cron_job_id = j.id
+ ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
+ FROM mate_cron_job j
+ WHERE j.deleted = 0
+ ORDER BY j.create_time DESC
+ """)
+ List selectListWithDeliveryStatus();
+
+ /**
+ * RFC-063r §2.14: per-job variant for the detail page. Same subquery
+ * pattern, restricted to a single id.
+ */
+ @Select("""
+ SELECT j.*,
+ (SELECT r.delivery_status FROM mate_cron_job_run r
+ WHERE r.cron_job_id = j.id
+ ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
+ (SELECT r.delivery_error FROM mate_cron_job_run r
+ WHERE r.cron_job_id = j.id
+ ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
+ FROM mate_cron_job j
+ WHERE j.id = #{id} AND j.deleted = 0
+ """)
+ CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java
new file mode 100644
index 00000000..7386f6e7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java
@@ -0,0 +1,128 @@
+package vip.mate.cron.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import vip.mate.cron.delivery.CronJobCompletedEvent;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.dashboard.model.CronJobRunEntity;
+import vip.mate.dashboard.repository.CronJobRunMapper;
+import vip.mate.memory.event.ConversationCompletionPublisher;
+import vip.mate.workspace.conversation.ConversationService;
+
+import java.time.LocalDateTime;
+
+/**
+ * RFC-063r §2.7.2: three-segment transactional support for {@link CronJobRunner}.
+ *
+ *
Each method runs in its own short {@code REQUIRES_NEW} transaction so
+ * the long LLM call between T1 and T2 never holds a DB connection.
+ *
+ *
+ *
{@code T1} — {@link #startRun}: insert run row + persist user message.
+ *
{@code T-fail} — {@link #markRunFailed}: terminal state when the LLM
+ * call throws.
Lives in a separate {@code @Service} bean so cross-bean invocation from
+ * {@link CronJobRunner} routes through the Spring AOP proxy (RFC §5.2 hard
+ * rule). The lifecycle service is deliberately the only place
+ * {@code @Transactional} appears in the cron-execution path.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CronJobLifecycleService {
+
+ private final CronJobRunMapper runMapper;
+ private final ConversationService conversationService;
+ private final ConversationCompletionPublisher completionPublisher;
+ private final ApplicationEventPublisher events;
+
+ /**
+ * T1 — short transaction: persist a run row in {@code running} state,
+ * persist the user message that triggered the run, and commit. Returns
+ * the persisted entity so callers can observe its assigned id without a
+ * second SELECT.
+ *
+ * @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType) {
+ CronJobRunEntity run = new CronJobRunEntity();
+ run.setCronJobId(job.getId());
+ run.setConversationId("cron:" + job.getId());
+ run.setStatus("running");
+ run.setTriggerType(triggerType != null ? triggerType : "scheduled");
+ run.setStartedAt(LocalDateTime.now());
+ run.setDeliveryStatus("NONE");
+ runMapper.insert(run);
+
+ // Persist the user message before the LLM call so history reads
+ // see a coherent (user → assistant) ordering even if the agent
+ // throws mid-run.
+ if (userMessage != null && !userMessage.isBlank()) {
+ conversationService.saveMessage(run.getConversationId(), "user", userMessage);
+ }
+ return run;
+ }
+
+ /**
+ * T-fail — short transaction: flag the run row as failed when the agent
+ * throws. Always-best-effort policy: delivery_status stays NONE; nothing
+ * is published.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void markRunFailed(CronJobRunEntity run, Throwable error) {
+ String message = error != null && error.getMessage() != null ? error.getMessage() : "unknown error";
+ runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, run.getId())
+ .set(CronJobRunEntity::getStatus, "failed")
+ .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
+ .set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
+ }
+
+ /**
+ * T2 — short transaction: persist the assistant reply, mark the run
+ * succeeded, then publish the two domain events. The
+ * {@code @TransactionalEventListener(AFTER_COMMIT)} listeners only run
+ * once this method's tx commits, so cross-connection reads in the
+ * delivery / memory pipelines always see the final state.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
+ String userMessage, AssistantMessage result) {
+ String convId = "cron:" + job.getId();
+ String text = result != null && result.getText() != null ? result.getText() : "";
+
+ runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, run.getId())
+ .set(CronJobRunEntity::getStatus, "succeeded")
+ .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()));
+
+ conversationService.saveMessage(convId, "assistant", text);
+
+ // Memory pipeline (existing behavior preserved — was inline in the
+ // old executeJob; now lives behind the same publisher used by the
+ // web / channel paths so cron is no longer special-cased).
+ try {
+ completionPublisher.publish(job.getAgentId(), convId, userMessage, text, "cron");
+ } catch (Exception e) {
+ // Memory failures must not break delivery — log + carry on.
+ log.warn("[CronLifecycle] completionPublisher failed for job {}: {}", job.getId(), e.getMessage());
+ }
+
+ // Delivery pipeline (RFC-063r §2.7.3) — fired here so listeners only
+ // run after T2 commits.
+ events.publishEvent(new CronJobCompletedEvent(job, result != null ? result : new AssistantMessage(""), run));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java
new file mode 100644
index 00000000..294d83c7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java
@@ -0,0 +1,152 @@
+package vip.mate.cron.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.cron.CronChatOriginFactory;
+import vip.mate.cron.model.CronJobEntity;
+import vip.mate.dashboard.model.CronJobRunEntity;
+
+/**
+ * RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron
+ * tick into the three transactional segments owned by
+ * {@link CronJobLifecycleService}, with the long-running LLM call sitting
+ * outside any transaction.
+ *
+ *
This class must NOT be annotated {@code @Transactional} — see
+ * RFC-063r §5.2:
+ *
+ *
The class is the entry point invoked by the
+ * {@code ThreadPoolTaskScheduler}'s lambda; the LLM HTTP call inside
+ * {@link #runAgent} is seconds-to-minutes long and must not hold a DB
+ * connection.
+ *
Self-invocation in the legacy {@code CronJobService} silently
+ * skipped {@code @Transactional}; that pattern is forbidden here.
+ *
An {@code ArchUnit} test (see PR-3) pins this rule so a future
+ * regression fails CI.
+ *
+ *
+ *
The three transactional segments live on
+ * {@link CronJobLifecycleService} (a separate bean), so cross-bean calls
+ * route through Spring AOP and {@code REQUIRES_NEW} works as advertised.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CronJobRunner {
+
+ private final CronJobLifecycleService lifecycle;
+ private final AgentService agentService;
+ private final CronChatOriginFactory originFactory;
+
+ /**
+ * Scheduler-facing entry. Runs three logical segments:
+ *
+ *
T1 — {@link CronJobLifecycleService#startRun} commits run row +
+ * user message.
+ *
Untransacted — {@link #runAgent} performs the LLM call.
+ *
T2 — {@link CronJobLifecycleService#finishRunAndPublish} commits
+ * assistant message and publishes the completion / delivery
+ * events. {@code AFTER_COMMIT} listeners fire from a fresh DB
+ * connection, so {@link CronJobRunEntity}'s persisted state is
+ * always visible by the time delivery resolves a strategy.
+ *
+ */
+ public void executeJob(CronJobEntity job) {
+ executeJob(job, /* triggerType */ "scheduled");
+ }
+
+ /** Variant with explicit trigger type — used by {@code runNow} (manual). */
+ public void executeJob(CronJobEntity job, String triggerType) {
+ if (job == null) {
+ log.warn("[CronRunner] executeJob called with null job — ignoring");
+ return;
+ }
+ String userMessage = "agent".equals(job.getTaskType())
+ ? job.getRequestBody()
+ : job.getTriggerMessage();
+
+ // T1 — short tx
+ CronJobRunEntity run;
+ try {
+ run = lifecycle.startRun(job, userMessage, triggerType);
+ } catch (Exception e) {
+ log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
+ return;
+ }
+
+ // No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold
+ // any DB connection during this call.
+ AssistantMessage result;
+ try {
+ ChatOrigin origin = originFactory.from(job, "cron:" + job.getId());
+ result = runAgent(job, userMessage, origin);
+ } 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.
+ log.warn("[CronRunner] markRunFailed itself failed for run {}: {} (stale-cleanup will recover)",
+ run.getId(), markErr.getMessage());
+ }
+ return;
+ }
+
+ // T2 — short tx
+ try {
+ lifecycle.finishRunAndPublish(job, run, userMessage, result);
+ } catch (Exception e) {
+ log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
+ try {
+ lifecycle.markRunFailed(run, e);
+ } catch (Exception markErr) {
+ log.warn("[CronRunner] markRunFailed after T2 failure also failed for run {}: {}",
+ run.getId(), markErr.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Runs the agent with the cron-derived {@link ChatOrigin} and the
+ * RFC-063r §2.13 system-prompt guard prepended when the cron is bound to
+ * a channel — fixes the Issue #25 LLM hallucination ("install
+ * mateclaw cli to send to wechat") by telling the model that delivery is
+ * framework-handled.
+ */
+ private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin) {
+ String guarded = wrapWithDeliveryGuard(userMessage, origin);
+ String text = "agent".equals(job.getTaskType())
+ ? agentService.execute(job.getAgentId(), guarded, "cron:" + job.getId(), origin)
+ : agentService.chat(job.getAgentId(), guarded, "cron:" + job.getId(), origin);
+ return new AssistantMessage(text != null ? text : "");
+ }
+
+ /**
+ * RFC-063r §2.13: when the cron is bound to a channel, prepend an
+ * explicit system note telling the LLM that delivery is handled by the
+ * framework. Without this, the model invents tools ("call CLI to send
+ * to wechat") and surfaces "command not found" style errors to users
+ * (Issue #25 second symptom).
+ *
+ *
Web-origin crons (no channelId) bypass the wrapper so the
+ * pre-RFC behavior is preserved.
+ */
+ static String wrapWithDeliveryGuard(String userMessage, ChatOrigin origin) {
+ String body = userMessage != null ? userMessage : "";
+ if (origin == null || origin.channelId() == null) {
+ return body;
+ }
+ return """
+ [系统说明]
+ 本次执行由定时任务触发,结果将由系统自动投递回原渠道,
+ 你只需直接给出最终回复内容,不要尝试调用 CLI / shell /
+ "发送到微信"等工具——这些操作由框架完成。
+
+ [用户原始消息]
+ """ + body;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java
index 2196ba43..cc08bd13 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java
@@ -12,15 +12,12 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;
-import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.exception.MateClawException;
-import vip.mate.memory.event.ConversationCompletionPublisher;
-import vip.mate.workspace.conversation.ConversationService;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -45,17 +42,23 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
- private final AgentService agentService;
- private final ConversationService conversationService;
- private final ConversationCompletionPublisher completionPublisher;
+ /**
+ * RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
+ * (separate bean) so the three-segment transactional model in
+ * {@link CronJobLifecycleService} works via Spring AOP — no more
+ * self-invocation footgun.
+ *
+ *
{@code agentService}, {@code conversationService}, and
+ * {@code completionPublisher} now live on {@link CronJobLifecycleService}
+ * and {@link CronJobRunner} so this service shrinks to CRUD + scheduler
+ * registration only.
+ */
+ private final CronJobRunner cronJobRunner;
private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
private final ConcurrentHashMap> scheduledTasks = new ConcurrentHashMap<>();
private final ReentrantLock schedulerLock = new ReentrantLock();
- /** 定时任务触发时使用的系统用户标识 */
- private static final String SYSTEM_USER = "system";
-
// ==================== 初始化与销毁 ====================
/**
@@ -88,9 +91,10 @@ public class CronJobService implements ApplicationRunner {
// ==================== CRUD ====================
public List list() {
- List entities = cronJobMapper.selectList(
- new LambdaQueryWrapper()
- .orderByDesc(CronJobEntity::getCreateTime));
+ // RFC-063r §2.14: use the variant that aggregates the most-recent
+ // delivery_status from mate_cron_job_run so the list page can
+ // render the "最近投递" badge without a per-row N+1 query.
+ List entities = cronJobMapper.selectListWithDeliveryStatus();
// 批量加载 Agent 名称
List agentIds = entities.stream()
@@ -107,7 +111,9 @@ public class CronJobService implements ApplicationRunner {
}
public CronJobDTO getById(Long id) {
- CronJobEntity entity = cronJobMapper.selectById(id);
+ // RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
+ // subquery shape, restricted to one id.
+ CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id);
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
@@ -219,8 +225,19 @@ public class CronJobService implements ApplicationRunner {
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
- // 异步执行,不阻塞请求线程
- scheduler.submit(() -> executeJob(entity));
+ // RFC-063r §2.7.1: delegate to CronJobRunner via Spring proxy so
+ // the three-segment REQUIRES_NEW transactions on
+ // CronJobLifecycleService work as advertised. "manual" trigger type
+ // distinguishes this from scheduler-driven runs in mate_cron_job_run.
+ scheduler.submit(() -> {
+ try {
+ cronJobRunner.executeJob(entity, "manual");
+ } finally {
+ // RFC-063r §2.7.1: bookkeep regardless of run outcome so a
+ // single bad run does not wedge all future ticks.
+ updateRunTimes(entity.getId(), entity.getCronExpression(), entity.getTimezone());
+ }
+ });
}
// ==================== 调度器管理 ====================
@@ -232,7 +249,16 @@ public class CronJobService implements ApplicationRunner {
String springCron = toSpringCron(job.getCronExpression());
ZoneId zoneId = ZoneId.of(job.getTimezone());
CronTrigger trigger = new CronTrigger(springCron, zoneId);
- ScheduledFuture> future = scheduler.schedule(() -> executeJob(job), trigger);
+ // RFC-063r §2.7.1: delegate to CronJobRunner — see runNow above.
+ // Bookkeeping (lastRunTime / nextRunTime) is wrapped in a finally
+ // so a failed run still advances the schedule.
+ ScheduledFuture> future = scheduler.schedule(() -> {
+ try {
+ cronJobRunner.executeJob(job, "scheduled");
+ } finally {
+ updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
+ }
+ }, trigger);
scheduledTasks.put(job.getId(), future);
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
job.getCronExpression(), job.getTimezone());
@@ -249,46 +275,18 @@ public class CronJobService implements ApplicationRunner {
}
// ==================== 任务执行 ====================
-
- private void executeJob(CronJobEntity job) {
- String conversationId = "cron:" + job.getId();
- try {
- log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType());
-
- // 确保会话存在(使用 SYSTEM_USER 作为定时触发的所有者标识,workspace 从 agent 获取)
- AgentEntity cronAgent = agentMapper.selectById(job.getAgentId());
- Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L;
- conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId);
-
- String userMessage;
- String result;
- if ("agent".equals(job.getTaskType())) {
- userMessage = job.getRequestBody();
- // 保存 user 消息
- conversationService.saveMessage(conversationId, "user", userMessage);
- result = agentService.execute(job.getAgentId(), userMessage, conversationId);
- } else {
- userMessage = job.getTriggerMessage();
- // 保存 user 消息
- conversationService.saveMessage(conversationId, "user", userMessage);
- result = agentService.chat(job.getAgentId(), userMessage, conversationId);
- }
-
- // 保存 assistant 消息
- conversationService.saveMessage(conversationId, "assistant", result);
-
- // 发布对话完成事件
- completionPublisher.publish(job.getAgentId(), conversationId, userMessage, result, "cron");
-
- // 合并更新 lastRunTime + nextRunTime,单次 DB 写入
- updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
-
- log.info("[CronJob] Job {} executed successfully, result length={}", job.getId(),
- result != null ? result.length() : 0);
- } catch (Exception e) {
- log.error("[CronJob] Job {} execution failed: {}", job.getId(), e.getMessage(), e);
- }
- }
+ //
+ // RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
+ // three-segment transactional model in CronJobLifecycleService runs
+ // through a Spring AOP proxy. CronJobService now only owns CRUD +
+ // scheduler registration, and the lastRunTime / nextRunTime bookkeeping
+ // hook below — which deliberately runs *after* the runner returns so a
+ // failed run still advances the next-run pointer (otherwise a single
+ // bad run wedges all future ticks).
+ //
+ // Both register() and runNow() now delegate to cronJobRunner.executeJob;
+ // see those methods above. The wrap below ensures next-run rolls forward
+ // regardless of run outcome.
/**
* 合并更新 lastRunTime 和 nextRunTime,单次 DB 写入替代原来的 4 次 selectById + updateById
diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java b/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java
index fe5a8d3e..91dbab9e 100644
--- a/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java
@@ -19,6 +19,24 @@ public class CronJobRunEntity {
private LocalDateTime finishedAt;
private String errorMessage;
private Integer tokenUsage;
+
+ // ===== RFC-063r §2.9: delivery state machine =====
+
+ /**
+ * Delivery state machine — see RFC-063r §2.8.1:
+ * {@code NONE} (web-origin) | {@code PENDING} (claimed) |
+ * {@code DELIVERED} | {@code NOT_DELIVERED} (failure or stale-cleanup).
+ * Orthogonal to {@link #status} (run main state); always-best-effort
+ * policy means delivery failure never flips the main state to failed.
+ */
+ private String deliveryStatus;
+
+ /** Resolved delivery target (IM userId / chat_id / Feishu webhook URL). */
+ private String deliveryTarget;
+
+ /** Delivery error reason — Hutool-truncated to 500 chars max. */
+ private String deliveryError;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/i18n/LocaleAwareToolCallback.java b/mateclaw-server/src/main/java/vip/mate/i18n/LocaleAwareToolCallback.java
index 76c3a072..d0ec8869 100644
--- a/mateclaw-server/src/main/java/vip/mate/i18n/LocaleAwareToolCallback.java
+++ b/mateclaw-server/src/main/java/vip/mate/i18n/LocaleAwareToolCallback.java
@@ -1,15 +1,23 @@
package vip.mate.i18n;
+import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+import org.springframework.lang.Nullable;
/**
- * 国际化工具回调装饰器
- *
- * 包装原始 ToolCallback,覆写 {@link #getToolDefinition()} 返回本地化描述。
- * 其他方法(call、name 等)全部委托给原始回调。
+ * I18n decorator around a {@link ToolCallback} — overrides
+ * {@link #getToolDefinition()} so the LLM sees the localized description.
*
- * @author MateClaw Team
+ *
RFC-063r §2.3 (P0): every {@code call(String, ToolContext)} invocation
+ * must be forwarded to the underlying delegate so {@link ToolContext} (carrying
+ * {@code ChatOrigin}) reaches downstream {@code @Tool} methods. The previous
+ * implementation only overrode {@code call(String)}, which silently dropped the
+ * ToolContext via the framework default.
+ *
+ *
{@link #getToolMetadata()} is forwarded so {@code returnDirect=true}
+ * tools keep their direct-return semantics under this decorator.
*/
public class LocaleAwareToolCallback implements ToolCallback {
@@ -31,8 +39,18 @@ public class LocaleAwareToolCallback implements ToolCallback {
.build();
}
+ @Override
+ public ToolMetadata getToolMetadata() {
+ return delegate.getToolMetadata();
+ }
+
@Override
public String call(String toolInput) {
return delegate.call(toolInput);
}
+
+ @Override
+ public String call(String toolInput, @Nullable ToolContext toolContext) {
+ return delegate.call(toolInput, toolContext);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
index 00bae242..87c27d78 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
@@ -12,8 +12,10 @@ import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
@@ -61,6 +63,15 @@ public class BrowserUseTool {
private final Object playwrightLock = new Object();
private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ /**
+ * RFC-063r §2.5 transition: ToolContext for the current invocation, set
+ * at the @Tool entry point and read by {@link #broadcastBrowserEvent}.
+ * Tool calls are serialized per ToolExecutionExecutor instance so this
+ * volatile field is safe; the field is read-only inside the action
+ * handlers.
+ */
+ private volatile ToolContext currentToolContext;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "browser-idle-watchdog");
t.setDaemon(true);
@@ -97,8 +108,15 @@ public class BrowserUseTool {
@ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code,
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
- @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort
+ @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
+ // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx
) {
+ // The conversationId resolution lives in broadcastBrowserEvent below;
+ // capture the ctx into a field so the helper can read it without
+ // passing it down every action handler. Race-free because tool calls
+ // are serialized per executor.
+ this.currentToolContext = ctx;
if (action == null || action.isBlank()) {
return error("action is required");
}
@@ -163,7 +181,7 @@ public class BrowserUseTool {
*/
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
String screenshot, long durationMs) {
- String conversationId = ToolExecutionContext.conversationId();
+ String conversationId = ToolExecutionContext.conversationId(currentToolContext);
if (conversationId == null || streamTracker == null) {
return;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
index 433654ba..df2638fe 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java
@@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.service.CronJobService;
@@ -38,12 +41,21 @@ public class CronJobTool {
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
- @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone) {
+ @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
+ // RFC-063r §2.4: ToolContext is *not* exposed to the LLM —
+ // JsonSchemaGenerator skips it (Spring AI 1.1 framework convention)
+ @Nullable ToolContext ctx) {
try {
- // Resolve current agent ID from conversation context
- String conversationId = ToolExecutionContext.conversationId();
- Long agentId = resolveAgentId(conversationId);
+ // RFC-063r §2.5: prefer the explicit ChatOrigin (channelId / channelTarget
+ // / agentId all live there). Fall back to the legacy ToolExecutionContext
+ // ThreadLocal during the PR-1 transition window so callers that have not
+ // yet migrated keep working.
+ ChatOrigin origin = ChatOrigin.from(ctx);
+ String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
+ ? origin.conversationId()
+ : ToolExecutionContext.conversationId();
+ Long agentId = origin.agentId() != null ? origin.agentId() : resolveAgentId(conversationId);
CronJobDTO dto = new CronJobDTO();
dto.setName(name);
@@ -54,6 +66,12 @@ public class CronJobTool {
dto.setTaskType("text");
dto.setEnabled(true);
+ // RFC-063r §2.4 / PR-2: when the originating context carries a
+ // channelId, the cron job inherits the binding so its results can
+ // be delivered back to the same channel. Fields are wired via
+ // reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
+ propagateChannelBinding(dto, origin);
+
CronJobDTO created = cronJobService.create(dto);
JSONObject result = new JSONObject();
@@ -166,4 +184,17 @@ public class CronJobTool {
result.set("error", message);
return JSONUtil.toJsonPrettyStr(result);
}
+
+ /**
+ * RFC-063r §2.4: propagate the originating channel binding into the cron
+ * job DTO so PR-3's delivery dispatcher can route results back to the
+ * originating channel.
+ */
+ private void propagateChannelBinding(CronJobDTO dto, ChatOrigin origin) {
+ if (origin == null || origin.channelId() == null) return;
+ dto.setChannelId(origin.channelId());
+ if (origin.channelTarget() != null) {
+ dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget()));
+ }
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
index 8d7a8626..e3793a80 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java
@@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
+import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.channel.web.ChatStreamTracker;
@@ -76,7 +79,12 @@ public class DelegateAgentTool {
For multiple parallel tasks, use delegateParallel instead.""")
public String delegateToAgent(
@ToolParam(description = "Target Agent name (exact match)") String agentName,
- @ToolParam(description = "Task description with complete context information") String task) {
+ @ToolParam(description = "Task description with complete context information") String task,
+ // RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding /
+ // workspace) propagates into the delegated child so a sub-agent
+ // creating a cron job still binds back to the originating channel.
+ // Hidden from the LLM by JsonSchemaGenerator.
+ @Nullable ToolContext ctx) {
if (agentName == null || agentName.isBlank()) {
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
@@ -111,8 +119,11 @@ public class DelegateAgentTool {
}
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
- // Execute child agent
- ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
+ // Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
+ // ChatOrigin and only swap the agentId, so channel binding /
+ // workspace / requester all flow into the child.
+ ChatOrigin parentOrigin = ChatOrigin.from(ctx);
+ ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin);
// Cleanup relay, then broadcast final result
if (stopRelay != null) stopRelay.run();
@@ -133,7 +144,9 @@ public class DelegateAgentTool {
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
public String delegateParallel(
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
- String tasksJson) {
+ String tasksJson,
+ // RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
+ @Nullable ToolContext ctx) {
// 1. Parse task list
List