fix(cron): post-deploy bug bundle — flakiness, scheduler, channel UI

User-reported field issues + a deeper code audit revealed multiple
overlapping bugs in the prior cron-channel delivery change. This fixes
all six.

#1 — Concurrency race on ToolExecutionExecutor (root cause of 'sometimes
   succeeds, sometimes fails' tool calls). The volatile instance fields
   currentRequesterId / currentWorkspaceBasePath / currentChatOrigin
   were shared by every conversation routed through the same per-agent
   executor; one user mid-build-loop while another's execute()
   overwrote the field would cross-contaminate the captured values into
   PreparedToolCall. Fix: kill the instance fields, thread
   origin/requester/workspace as method params straight into
   PreparedToolCall snapshot. Comment pins the rule so it cannot regress.

#2 — CHAT_ORIGIN missing from KeyStrategyFactory (latent timebomb,
   masked by spring-ai-alibaba-graph-core's non-filtering builder path).
   Without an addStrategy registration, multi-node state merges in long
   ReAct / Plan-Execute loops drop the key, ActionNode reads
   ChatOrigin.EMPTY, and the cron persists with channel_id=NULL. Also
   caught 4 more keys that were latently unregistered:
   WORKSPACE_BASE_PATH, STOP_REQUESTED, RETURN_DIRECT_TRIGGERED,
   DIRECT_TOOL_OUTPUTS. All five now registered in both ReAct and
   Plan-Execute factories.

#3 — CronJobs UI didn't surface channel binding. CronJobDTO carried
   channelId / deliveryConfig but the list page never rendered them.
   Added: (a) 'channel' column on list page, (b) channel + targetId
   rows in the detail modal, (c) backend batch-loads channel names via
   ChannelMapper.selectBatchIds so the column shows the human-readable
   name, (d) i18n keys (zh + en), (e) channelName field on TS CronJob
   type.

#4a — DingTalk targetId expiry. ChannelChatOriginFactory.resolveTargetId
   used to prefer ChannelMessage.replyToken which for DingTalk encodes
   a sessionWebhook URL that expires ~90 minutes after the inbound
   message. Cron persisted with that webhook then dies with 401/403 and
   marks NOT_DELIVERED forever. Fix: prefer the stable chatId, fall
   back to senderId — both work indefinitely via DingTalk's Robot API.

#4b — Scheduler pool exhaustion under long LLM. CronJobService's
   ThreadPoolTaskScheduler ran with poolSize=4 AND the LLM call lived
   on the scheduler thread. Four concurrent crons saturated the pool
   and the 5th silently missed its tick. Fix: keep scheduler tiny (it
   just fires triggers) and offload runAgent to a dedicated
   virtual-thread executor (cron-execute-* threads). LLM workload is
   I/O-bound — virtual threads scale to thousands at trivial cost.

#5 — Minor latent bugs:
   - AbstractCronResultDelivery.claimRun used .in(... 'NONE','PENDING',null),
     but SQL IN never matches NULL. Rewrote as IS NULL OR IN
     (NONE,PENDING) so legacy pre-V57 rows can still claim.
   - CronDeliveryListener.onCompletedRaw was an empty @EventListener
     with a wrong-headed comment about test fallbackExecution. Removed.
   - CronJobTool.resolveAgentId silently returned 1L when origin
     lacked an agentId — would silently bind to whatever agent #1
     happens to be. Replaced with explicit error so wiring bugs surface
     immediately instead of producing scheduled-but-never-runs crons.

State-key registration guard. New StateKeyRegistrationCoverageTest
scans MateClawStateKeys via reflection and parses
AgentGraphBuilder.java to extract every
.addStrategy(MateClawStateKeys.X, ...). Asserts every non-_NODE
constant appears in at least one factory. Caught the 4 unregistered
keys above on first run; will catch any future 'forgot to register'
regression.

Tests: 33 unit/arch tests + 27 regression in touched areas — all green.
Vue typecheck clean.

Refs: #25, #16
This commit is contained in:
matevip 2026-04-28 21:45:07 +08:00
parent 4011050ceb
commit b4697f2806
12 changed files with 223 additions and 79 deletions

View File

@ -353,6 +353,25 @@ public class AgentGraphBuilder {
// 审批重放键
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// sub-graph nodes (StepExecutionNode + DelegateAgentTool's
// child agents) can read the originating channel binding.
// Without explicit REPLACE the framework's merge drops it
// on multi-iteration paths root cause of the channel-binding
// flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest these state keys
// were silently unregistered before the post-deploy audit.
// WORKSPACE_BASE_PATH: written by buildInitialState; sub-graph
// tools read it via WorkspacePathGuard.
// STOP_REQUESTED: external cancel flag checked by every node.
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS (RFC-052):
// Plan-Execute itself doesn't trigger returnDirect, but
// DelegateAgentTool sub-agents could; register defensively.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
@ -483,6 +502,22 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
// 审批重放
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// ActionNode (and DelegateAgentTool's child agents) can read
// the originating channel binding across multi-iteration ReAct
// loops. Without explicit REPLACE the framework's merge drops
// it after the first node transition root cause of the
// channel-binding flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest silently
// unregistered before the audit. WORKSPACE_BASE_PATH from
// initial state; STOP_REQUESTED is the external cancel flag;
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS are RFC-052
// returnDirect short-circuit signals consumed by ObservationDispatcher.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)

View File

@ -200,13 +200,6 @@ public class ToolExecutionExecutor {
return execute(toolCalls, conversationId, agentId, isReplay, "");
}
/** 当前执行的 requesterId传递给 ToolExecutionContext */
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<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay, String requesterId) {
@ -232,15 +225,23 @@ public class ToolExecutionExecutor {
* ThreadLocal is also populated, so existing tools that read from it keep
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
* removed.
*
* <p><b>Thread safety</b>: this executor instance is shared across all
* concurrent invocations of a single agent (one executor per agent, per
* {@code AgentGraphBuilder.build}). Origin / requester / workspace are
* therefore <em>method-local</em> they live as parameters all the way
* down into {@link PreparedToolCall} and never touch instance state. An
* earlier draft used {@code volatile} fields here; concurrent users hitting
* the same agent (Web + IM at once) raced on those fields and the channel
* binding was occasionally cross-contaminated. Do not reintroduce the
* fields pass via parameters.
*/
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> 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;
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
// RFC-052: accumulate full-text outputs from returnDirect tools so the
@ -335,7 +336,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
conversationId, requesterId, workspaceBasePath, safeOrigin));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -353,7 +354,7 @@ public class ToolExecutionExecutor {
// response in turn until the cumulative size fits the budget.
if (resultStorage != null && !allResponses.isEmpty()) {
allResponses = new ArrayList<>(resultStorage.enforceTurnBudget(
allResponses, conversationId, currentWorkspaceBasePath));
allResponses, conversationId, workspaceBasePath));
}
boolean hasApprovalPending = barrier != null;
@ -410,10 +411,12 @@ public class ToolExecutionExecutor {
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
// 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
// Origin is method-local (see thread-safety note on execute());
// the legacy ThreadLocal that used to carry it across executePreApproved
// calls was a cross-conversation footgun and has been removed.
ChatOrigin replayOrigin = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
.withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, replayOrigin.toToolContext());
int rawLen = result != null ? result.length() : 0;
@ -471,7 +474,10 @@ public class ToolExecutionExecutor {
public ToolResponseMessage.ToolResponse executePreApproved(
AssistantMessage.ToolCall toolCall, String storedArguments,
List<GraphEventPublisher.GraphEvent> events) {
return executePreApproved(toolCall, storedArguments, events, null, currentWorkspaceBasePath);
// Workspace base path is no longer carried as instance state legacy
// callers that don't supply one get unrestricted file access (matches
// pre-RFC behavior when WorkspacePathGuard.basePath was null).
return executePreApproved(toolCall, storedArguments, events, null, null);
}
// ==================== Phase 2: 并发执行 ====================

View File

@ -41,14 +41,30 @@ public class ChannelChatOriginFactory {
}
/**
* 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.
* Resolve the IM target id used for proactive sends.
*
* <p><b>Critical</b>: must NOT use {@link ChannelMessage#getReplyToken()}
* for DingTalk the reply token encodes a {@code sessionWebhook} URL that
* expires ~90 minutes after the inbound message. A cron persisted with an
* expired sessionWebhook fails proactive delivery with 401/403 forever
* after the window lapses.
*
* <p>Resolution order both fields are stable identifiers across all
* supported channels:
* <ol>
* <li>{@code chatId} group / channel / room identifier; preferred so
* cron messages land in the same conversation the user triggered
* the cron from</li>
* <li>{@code senderId} user identifier; fallback for private chats
* where {@code chatId} is null. DingTalk's {@code proactiveSend}
* routes a userId through the Robot API ({@code oToMessages/batchSend}),
* which works indefinitely.</li>
* </ol>
*/
private String resolveTargetId(ChannelMessage message) {
if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
return message.getReplyToken();
if (message.getChatId() != null && !message.getChatId().isBlank()) {
return message.getChatId();
}
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
return message.getSenderId();
}
}

View File

@ -100,12 +100,18 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
* {@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).
* retry inside the listener.
*
* <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
* rows from before V57 (pre-RFC) may have null delivery_status, so the
* predicate explicitly tests {@code IS NULL OR IN (NONE, PENDING)} via
* a nested OR group rather than putting null inside the IN list.
*/
private boolean claimRun(CronJobRunEntity run) {
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING", null)
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
.or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING"))
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
}

View File

@ -2,7 +2,6 @@ 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;
@ -67,20 +66,6 @@ public class CronDeliveryListener {
}
}
/**
* 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

View File

@ -31,6 +31,13 @@ public class CronJobDTO {
/** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
private Long channelId;
/**
* Read-only display name for the bound channel populated by
* {@code CronJobService.list()} via a batch lookup so the UI can show
* "钉钉 / 飞书 / 微信" alongside the cron row without an extra request.
*/
private String channelName;
/** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
private DeliveryConfig deliveryConfig;

View File

@ -14,6 +14,8 @@ import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
@ -24,7 +26,10 @@ import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@ -42,6 +47,7 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
private final ChannelMapper channelMapper;
/**
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
* (separate bean) so the three-segment transactional model in
@ -59,6 +65,20 @@ public class CronJobService implements ApplicationRunner {
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final ReentrantLock schedulerLock = new ReentrantLock();
/**
* RFC-063r post-deploy fix: dedicated executor for the actual cron
* execution work (LLM call + DB writes). The {@link #scheduler} thread
* pool is intentionally tiny its only job is to fire the trigger and
* hand the runnable off here. If the LLM call ran on the scheduler
* thread, 4 concurrent crons would saturate the pool and queued ones
* would silently miss their tick.
*
* <p>Virtual threads (JDK 21) are perfect for this workload LLM HTTP
* is I/O-bound, virtual threads scale to thousands at trivial cost.
*/
private final ExecutorService cronExecutor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("cron-execute-", 0).factory());
// ==================== 初始化与销毁 ====================
/**
@ -66,6 +86,9 @@ public class CronJobService implements ApplicationRunner {
*/
@Override
public void run(ApplicationArguments args) {
// Pool size = trigger firing parallelism only. Actual execution lives
// on cronExecutor (virtual threads), so 2 is plenty for the trigger
// pump and even 4 was excessive; we keep 4 for headroom.
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("cron-job-");
scheduler.initialize();
@ -86,6 +109,7 @@ public class CronJobService implements ApplicationRunner {
@PreDestroy
public void destroy() {
scheduler.shutdown();
cronExecutor.shutdown();
}
// ==================== CRUD ====================
@ -105,8 +129,26 @@ public class CronJobService implements ApplicationRunner {
agentMapper.selectBatchIds(agentIds).stream()
.collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName));
// RFC-063r post-deploy fix: surface channel name on the list so the
// UI can show which crons are bound to which IM channel addresses
// user's "看不到与 channel 有什么关联" complaint.
List<Long> channelIds = entities.stream()
.map(CronJobEntity::getChannelId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, String> channelNameMap = channelIds.isEmpty() ? Map.of() :
channelMapper.selectBatchIds(channelIds).stream()
.collect(Collectors.toMap(ChannelEntity::getId, ChannelEntity::getName));
return entities.stream()
.map(e -> CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown")))
.map(e -> {
CronJobDTO dto = CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown"));
if (e.getChannelId() != null) {
dto.setChannelName(channelNameMap.get(e.getChannelId()));
}
return dto;
})
.collect(Collectors.toList());
}
@ -118,7 +160,12 @@ public class CronJobService implements ApplicationRunner {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
AgentEntity agent = agentMapper.selectById(entity.getAgentId());
return CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown");
CronJobDTO dto = CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown");
if (entity.getChannelId() != null) {
ChannelEntity channel = channelMapper.selectById(entity.getChannelId());
if (channel != null) dto.setChannelName(channel.getName());
}
return dto;
}
public CronJobDTO create(CronJobDTO dto) {
@ -229,12 +276,11 @@ public class CronJobService implements ApplicationRunner {
// 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(() -> {
// Run on the virtual-thread cronExecutor never block the scheduler.
cronExecutor.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());
}
});
@ -249,16 +295,19 @@ public class CronJobService implements ApplicationRunner {
String springCron = toSpringCron(job.getCronExpression());
ZoneId zoneId = ZoneId.of(job.getTimezone());
CronTrigger trigger = new CronTrigger(springCron, zoneId);
// 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);
// RFC-063r §2.7.1 + post-deploy fix: scheduler thread fires the
// trigger and immediately offloads to the virtual-thread
// cronExecutor the LLM call must NOT run on a scheduler
// worker (4 concurrent long crons would otherwise saturate the
// pool and the 5th would miss its tick).
ScheduledFuture<?> future = scheduler.schedule(() ->
cronExecutor.submit(() -> {
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());

View File

@ -47,15 +47,24 @@ public class CronJobTool {
@Nullable ToolContext ctx) {
try {
// 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.
// RFC-063r §2.5: the ChatOrigin must carry agentId buildInitialState
// injects it from the agent that owns the StateGraph. If it's missing
// here, something upstream broke (no holder set, KeyStrategyFactory
// dropped CHAT_ORIGIN, etc.) fail loudly rather than silently
// binding to agent #1, which could be disabled / non-existent /
// user-renamed and would surface as "scheduled but never runs".
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);
Long agentId = origin.agentId();
if (agentId == null) {
log.warn("[CronJobTool] create_cron_job invoked without an agentId in ChatOrigin " +
"(conv={}); refusing to silently bind to a default agent.", conversationId);
return errorResult("Cannot create cron job: agent context unavailable. " +
"This is an internal wiring bug — the originating agent id was not threaded " +
"through ToolContext. Re-issue the request; if it persists, see RFC-063r §2.5.");
}
CronJobDTO dto = new CronJobDTO();
dto.setName(name);
@ -159,25 +168,6 @@ public class CronJobTool {
}
}
/**
* Resolve agent ID from conversation ID.
* Convention: cron conversations use "cron:{jobId}", normal chats use "{agentId}:{uuid}".
*/
private Long resolveAgentId(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return 1L; // default agent
}
// Try to extract agent ID from conversation metadata
// For now, use default agent ID 1 (the conversation's agent binding is handled by the caller)
try {
// Convention: conversationId might contain agent context info
// Fallback to first enabled agent
return 1L;
} catch (Exception e) {
return 1L;
}
}
private String errorResult(String message) {
JSONObject result = new JSONObject();
result.set("success", false);

View File

@ -1434,6 +1434,9 @@ export default {
actions: 'Actions',
// RFC-063r §2.14: list-page "Last Delivery" column
lastDelivery: 'Last Delivery',
// RFC-063r post-deploy: channel binding columns
channel: 'Channel',
targetId: 'Delivery Target',
},
// RFC-063r §2.14: delivery_status state machine labels
lastDelivery: {

View File

@ -1444,6 +1444,9 @@ export default {
actions: '操作',
// RFC-063r §2.14: 列表页"最近投递"列
lastDelivery: '最近投递',
// RFC-063r post-deploy: 关联渠道列
channel: '关联渠道',
targetId: '投递目标',
},
// RFC-063r §2.14: delivery_status 状态机展示
lastDelivery: {

View File

@ -791,6 +791,7 @@ export interface CronJob {
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
channelId?: number | null
channelName?: string | null
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
lastDeliveryError?: string | null

View File

@ -23,6 +23,7 @@
<th>{{ t('cronJobs.columns.name') }}</th>
<th>{{ t('cronJobs.columns.cron') }}</th>
<th>{{ t('tokenUsage.date') }}</th>
<th>{{ t('cronJobs.columns.channel') }}</th>
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
<th>{{ t('cronJobs.columns.enabled') }}</th>
<th>{{ t('cronJobs.columns.actions') }}</th>
@ -54,6 +55,15 @@
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
</div>
</td>
<td>
<!-- Channel binding visibility RFC-063r post-deploy fix.
Cron created from web (no channelId) shows "—". -->
<span v-if="job.channelId" class="channel-binding"
:title="job.deliveryConfig?.targetId ? t('cronJobs.columns.targetId') + ': ' + job.deliveryConfig.targetId : ''">
{{ job.channelName || ('#' + job.channelId) }}
</span>
<span v-else class="time-empty"></span>
</td>
<td>
<!-- RFC-063r §2.14: most-recent delivery status badge.
hover surfaces the error detail when not delivered. -->
@ -96,7 +106,7 @@
</td>
</tr>
<tr v-if="store.jobs.length === 0">
<td colspan="6" class="empty-row">
<td colspan="7" class="empty-row">
<div class="empty-state">
<span class="empty-icon">&#9201;</span>
<p>{{ t('cronJobs.noJobs') }}</p>
@ -146,6 +156,26 @@
<div class="detail-label">{{ t('cronJobs.columns.lastRun') }}</div>
<div class="detail-value">{{ detailJob.lastRunTime ? formatTime(detailJob.lastRunTime) : '-' }}</div>
</div>
<!-- RFC-063r post-deploy: channel binding visibility in detail page -->
<div class="detail-item" v-if="detailJob.channelId">
<div class="detail-label">{{ t('cronJobs.columns.channel') }}</div>
<div class="detail-value">{{ detailJob.channelName || ('#' + detailJob.channelId) }}</div>
</div>
<div class="detail-item" v-if="detailJob.deliveryConfig?.targetId">
<div class="detail-label">{{ t('cronJobs.columns.targetId') }}</div>
<div class="detail-value mono">{{ detailJob.deliveryConfig.targetId }}</div>
</div>
<div class="detail-item" v-if="detailJob.lastDeliveryStatus && detailJob.lastDeliveryStatus !== 'NONE'">
<div class="detail-label">{{ t('cronJobs.columns.lastDelivery') }}</div>
<div class="detail-value">
<span class="delivery-badge" :class="'delivery-' + detailJob.lastDeliveryStatus.toLowerCase()">
{{ t('cronJobs.lastDelivery.' + detailJob.lastDeliveryStatus.toLowerCase()) }}
</span>
<div v-if="detailJob.lastDeliveryError" class="detail-subvalue" style="color: rgb(239,68,68);">
{{ detailJob.lastDeliveryError }}
</div>
</div>
</div>
<div class="detail-item detail-item-full" v-if="detailJob.taskType === 'text'">
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
@ -677,6 +707,19 @@ function formatTime(datetime: string | undefined): string {
.delivery-delivered { background: rgba(34, 197, 94, 0.12); color: rgb(34, 197, 94); }
.delivery-not_delivered { background: rgba(239, 68, 68, 0.12); color: rgb(239, 68, 68); }
/* Channel binding pill — surfaces which IM channel a cron is bound to. */
.channel-binding {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--mc-primary-bg);
color: var(--mc-primary);
white-space: nowrap;
}
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }