diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
index db0a2ae8..34b9f0a3 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
@@ -1410,6 +1410,17 @@ public class ConversationWindowManager {
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
+ String finishReason = response.getResult().getMetadata() != null
+ ? response.getResult().getMetadata().getFinishReason() : null;
+ if ("length".equalsIgnoreCase(finishReason)) {
+ // A non-empty response can still be structurally incomplete
+ // when the provider exhausts max tokens. Persisting it would
+ // poison every later iterative summary.
+ log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
+ conversationId);
+ setSummaryCooldown(conversationId);
+ return null;
+ }
String summary = response.getResult().getOutput().getText();
if (summary != null && !summary.isBlank()) {
// 成功:保存摘要供下次迭代更新,清除冷却
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
index a74d0fc9..437f1c3d 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/delivery/AbstractCronResultDelivery.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/AbstractCronResultDelivery.java
@@ -96,36 +96,44 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
// ---------- SQL state-machine helpers ----------
/**
- * Atomic SQL CAS: transition delivery_status from {@code NONE} or
- * {@code PENDING} → {@code PENDING}. Returns true iff this instance won
- * the race. NONE-eligibility lets fresh runs claim without a separate
- * "first-time" branch; PENDING-eligibility covers the rare same-instance
- * retry inside the listener.
+ * Atomic SQL CAS: transition delivery_status from {@code NONE} (or legacy
+ * {@code NULL}) to {@code PENDING}. An already-pending row is owned by the
+ * worker that claimed it and must never be claimable again.
*
*
SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
* rows from before V57 (pre-RFC) may have null delivery_status, so the
- * predicate explicitly tests {@code IS NULL OR IN (NONE, PENDING)} via
+ * predicate explicitly tests {@code IS NULL OR = NONE} via
* a nested OR group rather than putting null inside the IN list.
*/
private boolean claimRun(CronJobRunEntity run) {
return runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
- .or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING"))
+ .or().eq(CronJobRunEntity::getDeliveryStatus, "NONE"))
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
}
private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
- runMapper.update(null, new LambdaUpdateWrapper()
+ int updated = runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
+ .eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
.set(CronJobRunEntity::getDeliveryTarget, o.target()));
+ if (updated == 0) {
+ log.warn("[CronDelivery] Run {} lost its PENDING fence before success was persisted",
+ run.getId());
+ }
}
private void markNotDelivered(CronJobRunEntity run, Exception e) {
- runMapper.update(null, new LambdaUpdateWrapper()
+ int updated = runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
+ .eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
.set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
+ if (updated == 0) {
+ log.warn("[CronDelivery] Run {} lost its PENDING fence before failure was persisted",
+ run.getId());
+ }
}
}
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
index 7d5efb41..8eed2c05 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronRunStaleCleanup.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/delivery/CronRunStaleCleanup.java
@@ -21,7 +21,7 @@ import java.time.LocalDateTime;
* {@code NOT_DELIVERED} with {@code stale-pending-timeout} reason.
* Covers listener crashes / OOMs / forced kills after a successful
* {@code claimRun()} but before {@code markDelivered}.
- *
{@code status='running'} older than 30 min → mark {@code failed}
+ *
{@code status='running'} without a heartbeat for 2 min → mark {@code failed}
* with {@code stale-running-timeout}. Covers
* {@code CronJobLifecycleService.markRunFailed()} itself failing under
* DB jitter (the LLM call already terminated by then).
@@ -38,7 +38,7 @@ public class CronRunStaleCleanup {
private final CronJobRunMapper runMapper;
private static final Duration DELIVERY_STALE = Duration.ofMinutes(15);
- private static final Duration RUN_STALE = Duration.ofMinutes(30);
+ private static final Duration RUN_STALE = Duration.ofMinutes(2);
/**
* RFC-03 Lane G2: in a multi-instance deployment, the sweep is purely
@@ -62,7 +62,7 @@ public class CronRunStaleCleanup {
int staleRunning = runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getStatus, "running")
- .lt(CronJobRunEntity::getStartedAt, now.minus(RUN_STALE))
+ .apply("COALESCE(heartbeat_at, started_at) < {0}", now.minus(RUN_STALE))
.set(CronJobRunEntity::getStatus, "failed")
.set(CronJobRunEntity::getFinishedAt, now)
.set(CronJobRunEntity::getErrorMessage, "stale-running-timeout"));
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
index 5fa8d813..b90f6393 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java
@@ -71,7 +71,9 @@ public class CronJobLifecycleService {
run.setConversationId(conversationId);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
- run.setStartedAt(LocalDateTime.now());
+ LocalDateTime now = LocalDateTime.now();
+ run.setStartedAt(now);
+ run.setHeartbeatAt(now);
run.setDeliveryStatus("NONE");
runMapper.insert(run);
@@ -130,6 +132,7 @@ public class CronJobLifecycleService {
String message = error != null && error.getMessage() != null ? error.getMessage() : "unknown error";
runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
+ .eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "failed")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
@@ -148,7 +151,9 @@ public class CronJobLifecycleService {
run.setConversationId(null);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
- run.setStartedAt(LocalDateTime.now());
+ LocalDateTime now = LocalDateTime.now();
+ run.setStartedAt(now);
+ run.setHeartbeatAt(now);
run.setDeliveryStatus("NONE");
runMapper.insert(run);
return run;
@@ -162,12 +167,16 @@ public class CronJobLifecycleService {
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRunSucceeded(CronJobRunEntity run, String description) {
- runMapper.update(null, new LambdaUpdateWrapper()
+ int updated = runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
+ .eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "succeeded")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage,
description != null ? StrUtil.maxLength(description, 1000) : null));
+ if (updated == 0) {
+ log.warn("[CronLifecycle] Run {} lost its running fence before system completion", run.getId());
+ }
}
/**
@@ -205,11 +214,17 @@ public class CronJobLifecycleService {
int totalTokens = chatResult != null
? chatResult.promptTokens() + chatResult.completionTokens() : 0;
- runMapper.update(null, new LambdaUpdateWrapper()
+ int updated = runMapper.update(null, new LambdaUpdateWrapper()
.eq(CronJobRunEntity::getId, run.getId())
+ .eq(CronJobRunEntity::getStatus, "running")
.set(CronJobRunEntity::getStatus, "succeeded")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(totalTokens > 0, CronJobRunEntity::getTokenUsage, totalTokens));
+ if (updated == 0) {
+ log.warn("[CronLifecycle] Run {} lost its running fence before completion; dropping late result",
+ run.getId());
+ return;
+ }
if (silent) {
// No-op run: persist a short marker so the tasks_
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
index 796eb306..11004e11 100644
--- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java
@@ -42,6 +42,7 @@ import vip.mate.wiki.service.WikiProcessingService;
public class CronJobRunner {
private final CronJobLifecycleService lifecycle;
+ private final CronRunHeartbeatService heartbeat;
private final AgentService agentService;
private final CronChatOriginFactory originFactory;
private final vip.mate.cron.CronConversationResolver conversationResolver;
@@ -144,14 +145,16 @@ public class CronJobRunner {
try {
ChatOrigin origin = originFactory.from(
job, conversationId, started.originMessageId());
- chatResult = runAgent(job, userMessage, origin, conversationId);
+ try (CronRunHeartbeatService.Lease ignored = heartbeat.begin(run.getId())) {
+ chatResult = runAgent(job, userMessage, origin, conversationId);
+ }
result = new AssistantMessage(chatResult.content());
} catch (Exception e) {
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
try {
lifecycle.markRunFailed(run, e);
} catch (Exception markErr) {
- // CronRunStaleCleanup will sweep status='running' rows older than 30 min.
+ // CronRunStaleCleanup will recover a run after its heartbeat expires.
log.warn("[CronRunner] markRunFailed itself failed for run {}: {} (stale-cleanup will recover)",
run.getId(), markErr.getMessage());
}
diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronRunHeartbeatService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronRunHeartbeatService.java
new file mode 100644
index 00000000..a0151a4e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronRunHeartbeatService.java
@@ -0,0 +1,107 @@
+package vip.mate.cron.service;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import vip.mate.dashboard.model.CronJobRunEntity;
+import vip.mate.dashboard.repository.CronJobRunMapper;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Objects;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Maintains a durable liveness signal while a cron run is inside a long agent call. */
+@Slf4j
+@Service
+public class CronRunHeartbeatService {
+
+ static final Duration DEFAULT_INTERVAL = Duration.ofSeconds(30);
+
+ private final CronJobRunMapper runMapper;
+ private final ScheduledExecutorService scheduler;
+ private final Duration interval;
+ private final Clock clock;
+ private final boolean ownsScheduler;
+
+ @Autowired
+ public CronRunHeartbeatService(CronJobRunMapper runMapper) {
+ this(runMapper, newScheduler(), DEFAULT_INTERVAL, Clock.systemDefaultZone(), true);
+ }
+
+ CronRunHeartbeatService(CronJobRunMapper runMapper,
+ ScheduledExecutorService scheduler,
+ Duration interval,
+ Clock clock,
+ boolean ownsScheduler) {
+ this.runMapper = Objects.requireNonNull(runMapper, "runMapper");
+ this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
+ this.interval = Objects.requireNonNull(interval, "interval");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ this.ownsScheduler = ownsScheduler;
+ if (interval.isZero() || interval.isNegative()) {
+ throw new IllegalArgumentException("heartbeat interval must be positive");
+ }
+ }
+
+ /**
+ * Start refreshing one run. The returned lease is idempotent and must be
+ * closed when the long call exits, including exceptional exits.
+ */
+ public Lease begin(Long runId) {
+ Objects.requireNonNull(runId, "runId");
+ long periodMillis = interval.toMillis();
+ ScheduledFuture> future = scheduler.scheduleAtFixedRate(
+ () -> safeTouch(runId), periodMillis, periodMillis, TimeUnit.MILLISECONDS);
+ AtomicBoolean closed = new AtomicBoolean();
+ return () -> {
+ if (closed.compareAndSet(false, true)) {
+ future.cancel(false);
+ }
+ };
+ }
+
+ private void safeTouch(Long runId) {
+ try {
+ int updated = runMapper.update(null, new LambdaUpdateWrapper()
+ .eq(CronJobRunEntity::getId, runId)
+ .eq(CronJobRunEntity::getStatus, "running")
+ .set(CronJobRunEntity::getHeartbeatAt, LocalDateTime.now(clock)));
+ if (updated == 0) {
+ log.debug("[CronHeartbeat] Run {} is no longer running; heartbeat ignored", runId);
+ }
+ } catch (RuntimeException e) {
+ // ScheduledExecutorService suppresses all later ticks if a task
+ // escapes with an exception. Keep the liveness loop recoverable.
+ log.warn("[CronHeartbeat] Failed to refresh run {}: {}", runId, e.getMessage());
+ }
+ }
+
+ @PreDestroy
+ void shutdown() {
+ if (ownsScheduler) {
+ scheduler.shutdownNow();
+ }
+ }
+
+ private static ScheduledExecutorService newScheduler() {
+ return Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "cron-run-heartbeat");
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+
+ @FunctionalInterface
+ public interface Lease extends AutoCloseable {
+ @Override
+ void close();
+ }
+}
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 91dbab9e..5afd2596 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
@@ -16,6 +16,8 @@ public class CronJobRunEntity {
/** scheduled / manual */
private String triggerType;
private LocalDateTime startedAt;
+ /** Last durable liveness signal while the run is executing. */
+ private LocalDateTime heartbeatAt;
private LocalDateTime finishedAt;
private String errorMessage;
private Integer tokenUsage;
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V190__cron_run_heartbeat.sql b/mateclaw-server/src/main/resources/db/migration/h2/V190__cron_run_heartbeat.sql
new file mode 100644
index 00000000..392c1aac
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V190__cron_run_heartbeat.sql
@@ -0,0 +1,6 @@
+-- Durable liveness for long cron runs. Stale cleanup falls back to started_at
+-- for pre-migration rows whose heartbeat_at is null.
+ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMP;
+
+CREATE INDEX IF NOT EXISTS idx_cron_run_status_heartbeat
+ ON mate_cron_job_run(status, heartbeat_at);
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V190__cron_run_heartbeat.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V190__cron_run_heartbeat.sql
new file mode 100644
index 00000000..7290e2ed
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V190__cron_run_heartbeat.sql
@@ -0,0 +1,5 @@
+-- Durable liveness for long cron runs (Kingbase/PostgreSQL dialect).
+ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMP;
+
+CREATE INDEX IF NOT EXISTS idx_cron_run_status_heartbeat
+ ON mate_cron_job_run(status, heartbeat_at);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V190__cron_run_heartbeat.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V190__cron_run_heartbeat.sql
new file mode 100644
index 00000000..d336d786
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V190__cron_run_heartbeat.sql
@@ -0,0 +1,16 @@
+-- Durable liveness for long cron runs (MySQL dialect).
+SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run'
+ AND COLUMN_NAME = 'heartbeat_at');
+SET @s := IF(@c = 0,
+ 'ALTER TABLE mate_cron_job_run ADD COLUMN heartbeat_at TIMESTAMP NULL',
+ 'SELECT 1');
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run'
+ AND INDEX_NAME = 'idx_cron_run_status_heartbeat');
+SET @s := IF(@c = 0,
+ 'CREATE INDEX idx_cron_run_status_heartbeat ON mate_cron_job_run(status, heartbeat_at)',
+ 'SELECT 1');
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java
index 5c25415d..2c3bc2d8 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java
@@ -87,6 +87,28 @@ class ConversationWindowManagerSummaryBudgetTest {
"iterative-update: literal placeholder must not leak into the SystemMessage (the bug regression guard)");
}
+ @Test
+ @DisplayName("Token-limit summaries are rejected and never become iterative state")
+ void lengthFinishReasonRejectsTruncatedSummary() throws Exception {
+ stubResponse("TRUNCATED BUT NONEMPTY", "length");
+
+ String result = invokeGenerateSummary("conv-truncated", null);
+
+ assertNull(result);
+ assertFalse(previousSummaries().containsKey("conv-truncated"));
+ }
+
+ @Test
+ @DisplayName("Normally stopped summaries remain accepted")
+ void stopFinishReasonAcceptsCompleteSummary() throws Exception {
+ stubResponse("COMPLETE SUMMARY", "stop");
+
+ String result = invokeGenerateSummary("conv-complete", null);
+
+ assertEquals("COMPLETE SUMMARY", result);
+ assertEquals("COMPLETE SUMMARY", previousSummaries().get("conv-complete"));
+ }
+
/**
* Reflectively invoke the private {@code generateSummary} method and capture
* the {@link Prompt} sent to the mocked {@link ChatModel}.
@@ -106,4 +128,26 @@ class ConversationWindowManagerSummaryBudgetTest {
org.mockito.Mockito.verify(chatModel).call(captor.capture());
return captor.getValue();
}
+
+ private String invokeGenerateSummary(String conversationId, String memoryExtra) throws Exception {
+ List oldMessages = List.of(new UserMessage("hello"), new UserMessage("world"));
+ Method method = ConversationWindowManager.class.getDeclaredMethod(
+ "generateSummary", List.class, ChatModel.class, String.class, int.class, String.class);
+ method.setAccessible(true);
+ return (String) method.invoke(manager, oldMessages, chatModel, conversationId, 1500, memoryExtra);
+ }
+
+ private void stubResponse(String text, String finishReason) {
+ Generation generation = new Generation(
+ new org.springframework.ai.chat.messages.AssistantMessage(text),
+ ChatGenerationMetadata.builder().finishReason(finishReason).build());
+ when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(generation)));
+ }
+
+ @SuppressWarnings("unchecked")
+ private ConcurrentHashMap previousSummaries() throws Exception {
+ Field field = ConversationWindowManager.class.getDeclaredField("previousSummaries");
+ field.setAccessible(true);
+ return (ConcurrentHashMap) field.get(manager);
+ }
}
diff --git a/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java b/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java
index 49c8155a..9daaa2df 100644
--- a/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java
@@ -1,18 +1,21 @@
package vip.mate.cron.delivery;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.messages.AssistantMessage;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
@@ -101,6 +104,75 @@ class AbstractCronResultDeliveryTest {
verify(runMapper, times(1)).update(any(), any(Wrapper.class)); // only the failed claim
}
+ @Test
+ void claimRun_acceptsOnlyFreshOrLegacyDeliveryState() {
+ when(runMapper.update(any(), any(Wrapper.class))).thenReturn(0);
+
+ AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
+ @Override public boolean supports(CronJobEntity j) { return true; }
+ @Override protected DeliveryOutcome doDeliver(
+ CronJobEntity j, AssistantMessage r, CronJobRunEntity ignored) {
+ return DeliveryOutcome.delivered("never");
+ }
+ };
+
+ strategy.deliver(job, new AssistantMessage("hi"), run);
+
+ @SuppressWarnings("rawtypes")
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(runMapper).update(isNull(), captor.capture());
+ Wrapper> claim = captor.getValue();
+ assertTrue(claim.getSqlSegment().contains("delivery_status IS NULL"));
+ assertTrue(whereValues(claim).contains("NONE"));
+ assertFalse(whereValues(claim).contains("PENDING"),
+ "an already-PENDING delivery must not be claimable again");
+ }
+
+ @Test
+ void deliveredTerminalWrite_requiresPendingFence() {
+ when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
+
+ AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
+ @Override public boolean supports(CronJobEntity j) { return true; }
+ @Override protected DeliveryOutcome doDeliver(
+ CronJobEntity j, AssistantMessage r, CronJobRunEntity ignored) {
+ return DeliveryOutcome.delivered("user-x");
+ }
+ };
+
+ strategy.deliver(job, new AssistantMessage("hi"), run);
+
+ @SuppressWarnings("rawtypes")
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(runMapper, times(2)).update(isNull(), captor.capture());
+ List writes = captor.getAllValues();
+ assertTrue(whereValues(writes.get(1)).contains("PENDING"),
+ "late success must not overwrite a stale-cleanup terminal state");
+ }
+
+ @Test
+ void failedTerminalWrite_requiresPendingFence() {
+ when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
+
+ AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
+ @Override public boolean supports(CronJobEntity j) { return true; }
+ @Override protected DeliveryOutcome doDeliver(
+ CronJobEntity j, AssistantMessage r, CronJobRunEntity ignored) {
+ throw new IllegalStateException("delivery failed");
+ }
+ };
+
+ assertThrows(IllegalStateException.class,
+ () -> strategy.deliver(job, new AssistantMessage("hi"), run));
+
+ @SuppressWarnings("rawtypes")
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(runMapper, times(2)).update(isNull(), captor.capture());
+ List writes = captor.getAllValues();
+ assertTrue(whereValues(writes.get(1)).contains("PENDING"),
+ "late failure must not overwrite a stale-cleanup terminal state");
+ }
+
@Test
void deliver_doDeliverThrows_marksNotDeliveredAndRethrows() {
// Claim returns 1, then markNotDelivered returns 1
@@ -173,4 +245,16 @@ class AbstractCronResultDeliveryTest {
pool.shutdownNow();
}
}
+
+ private static Set