fix(goal): preserve continuation instructions and verified progress

Keep autonomous prompts separate from persisted user messages while retaining
conversation history and queued user attachment routing. Carry verified
checklist evidence across segments and evaluate only changed criteria.

Cancel model streams without interrupting checkpoint database writes. Fence
late worker admission during shutdown, persist accepted queued messages and
attachments, and leave interrupted execution leases recoverable.

Add regressions for prompt selection, cumulative evidence, cancellation I/O,
queued input durability and shutdown recovery. Verify 250 focused tests,
200 real General Assistant conversation rounds, 12 checkpoints across 13
autonomous segments, and pause/resume/disconnect/restart boundaries.
This commit is contained in:
taobig 2026-08-26 06:17:05 -04:00
parent 2fa2e60170
commit 2090d09704
9 changed files with 239 additions and 16 deletions

View File

@ -13,6 +13,7 @@ import org.springframework.util.MimeType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder; import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.approval.ApprovalPlaceholderUtil; import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService; import vip.mate.llm.routing.MediaCaptionService;
@ -1264,6 +1265,12 @@ public abstract class BaseAgent {
* the primary model can't already handle. * the primary model can't already handle.
*/ */
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) { protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
// Autonomous segments have no new persisted user row. Reconstructing
// from the last user would replace the continuation/recovery instruction.
// History is still loaded normally; queued user turns retain attachment routing.
if (GoalContinuationContext.explicitPrompt()) {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
// Scheduled-job run (issue #142): the task text is the explicit // Scheduled-job run (issue #142): the task text is the explicit
// userMessageText argument. Never reconstruct it from the conversation // userMessageText argument. Never reconstruct it from the conversation
// a shared cron conversation under concurrent runs has no reliable // a shared cron conversation under concurrent runs has no reliable

View File

@ -1,14 +1,20 @@
package vip.mate.agent.context; package vip.mate.agent.context;
import java.util.function.Supplier;
/** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */ /** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */
public final class GoalContinuationContext { public final class GoalContinuationContext {
private static final ThreadLocal<Boolean> ACTIVE = new ThreadLocal<>(); private static final ThreadLocal<Boolean> EXPLICIT_PROMPT = new ThreadLocal<>();
private GoalContinuationContext() {} private GoalContinuationContext() {}
public static boolean active() { return Boolean.TRUE.equals(ACTIVE.get()); } public static boolean active() { return EXPLICIT_PROMPT.get() != null; }
public static <T> T call(java.util.function.Supplier<T> action) { public static boolean explicitPrompt() { return Boolean.TRUE.equals(EXPLICIT_PROMPT.get()); }
Boolean previous=ACTIVE.get(); public static <T> T call(Supplier<T> action) { return call(true, action); }
ACTIVE.set(true);
/** Queued user input keeps normal attachment reconstruction within the same worker. */
public static <T> T call(boolean explicitPrompt, Supplier<T> action) {
Boolean previous=EXPLICIT_PROMPT.get();
EXPLICIT_PROMPT.set(explicitPrompt);
try { return action.get(); } try { return action.get(); }
finally { if(previous==null) ACTIVE.remove(); else ACTIVE.set(previous); } finally { if(previous==null) EXPLICIT_PROMPT.remove(); else EXPLICIT_PROMPT.set(previous); }
} }
} }

View File

@ -92,6 +92,7 @@ public class GoalContinuationSupervisor {
private void execute(GoalEntity initial, GoalContinuationStore.Continuation candidate, String token) { private void execute(GoalEntity initial, GoalContinuationStore.Continuation candidate, String token) {
LocalDateTime now = LocalDateTime.now(clock); LocalDateTime now = LocalDateTime.now(clock);
try { try {
if (closing) return;
GoalEntity goal = goals.getById(initial.getId()); GoalEntity goal = goals.getById(initial.getId());
if (!eligible(goal)) { if (!eligible(goal)) {
settle(initial,token,"paused",now,0,"goal_not_runnable"); return; settle(initial,token,"paused",now,0,"goal_not_runnable"); return;
@ -112,6 +113,8 @@ public class GoalContinuationSupervisor {
case CONTINUE -> { } case CONTINUE -> { }
} }
GoalSegmentRunner.Result result = runner.run(goal,decision.prompt(),"running".equals(candidate.state())); GoalSegmentRunner.Result result = runner.run(goal,decision.prompt(),"running".equals(candidate.state()));
// Shutdown cancellation is not user Stop: retain the lease for recovery.
if (closing) return;
GoalEntity fresh = goals.getById(goal.getId()); GoalEntity fresh = goals.getById(goal.getId());
if (fresh.getStatus() == GoalStatus.COMPLETED) { if (fresh.getStatus() == GoalStatus.COMPLETED) {
settle(goal,token,"completed",now,0,"goal_completed"); settle(goal,token,"completed",now,0,"goal_completed");
@ -209,8 +212,10 @@ public class GoalContinuationSupervisor {
@PreDestroy public void close() { @PreDestroy public void close() {
closing=true; closing=true;
runner.cancelAll();
if (executor instanceof java.util.concurrent.ExecutorService workers) { if (executor instanceof java.util.concurrent.ExecutorService workers) {
workers.shutdownNow(); // Cancellation must finish checkpoint persistence without interrupting JDBC I/O.
workers.shutdown();
try { try {
if (!workers.awaitTermination(10,java.util.concurrent.TimeUnit.SECONDS)) { if (!workers.awaitTermination(10,java.util.concurrent.TimeUnit.SECONDS)) {
log.warn("Goal workers did not finish shutdown persistence within 10 seconds"); log.warn("Goal workers did not finish shutdown persistence within 10 seconds");

View File

@ -131,7 +131,9 @@ public class GoalEvaluationService implements Evaluator {
+ "\n\n" + format; + "\n\n" + format;
List<Message> messages = new ArrayList<>(2); List<Message> messages = new ArrayList<>(2);
messages.add(new SystemMessage(bootstrap ? BOOTSTRAP_SYSTEM_PROMPT : VERDICT_SYSTEM_PROMPT)); messages.add(new SystemMessage(bootstrap ? BOOTSTRAP_SYSTEM_PROMPT
: Boolean.TRUE.equals(goal.getPersistentExecution())
? PERSISTENT_VERDICT_SYSTEM_PROMPT : VERDICT_SYSTEM_PROMPT));
messages.add(new UserMessage(userPrompt)); messages.add(new UserMessage(userPrompt));
ChatOptions options = ChatOptions.builder() ChatOptions options = ChatOptions.builder()
@ -219,6 +221,17 @@ public class GoalEvaluationService implements Evaluator {
+ "'all requirements met'. If a criterion lacks specific evidence, " + "'all requirements met'. If a criterion lacks specific evidence, "
+ "mark it not passed. Output only the requested JSON."; + "mark it not passed. Output only the requested JSON.";
private static final String PERSISTENT_VERDICT_SYSTEM_PROMPT =
"Judge cumulative progress toward a persistent goal using the latest reply, "
+ "conversation evidence and previously verified checklist evidence. "
+ "A later step need not repeat completed earlier work. Preserve a prior "
+ "passed=true item with nonblank evidence unless new concrete evidence contradicts it. "
+ "Revoke it when such contradictory evidence exists, citing that evidence. "
+ "An attempted action, a goal description or a claim of completion is not proof. "
+ "Newly passed criteria require concrete observable evidence. Return only changed "
+ "criterion verdicts; omitted criteria retain their previous state. Keep evidence concise. "
+ "Output only the requested JSON.";
private String buildUserPrompt(GoalEntity goal, private String buildUserPrompt(GoalEntity goal,
List<GoalCriterion> existing, List<GoalCriterion> existing,
List<? extends Message> recentMessages, List<? extends Message> recentMessages,
@ -238,6 +251,12 @@ public class GoalEvaluationService implements Evaluator {
sb.append("Current checklist (judge each by id):\n"); sb.append("Current checklist (judge each by id):\n");
for (GoalCriterion c : existing) { for (GoalCriterion c : existing) {
sb.append("- ").append(c.id()).append(": ").append(c.text()).append('\n'); sb.append("- ").append(c.id()).append(": ").append(c.text()).append('\n');
if (Boolean.TRUE.equals(goal.getPersistentExecution())) {
String evidence = safe(c.evidence());
sb.append(" Previous passed=").append(c.passed()).append("; evidence: ")
.append(evidence.length() > 1000 ? evidence.substring(0, 1000) + " [truncated]" : evidence)
.append('\n');
}
} }
sb.append('\n'); sb.append('\n');
} }
@ -263,6 +282,10 @@ public class GoalEvaluationService implements Evaluator {
.append(MAX_BOOTSTRAP_CRITERIA) .append(MAX_BOOTSTRAP_CRITERIA)
.append(" criteria. Leave every 'passed' false and 'evidence' empty — " .append(" criteria. Leave every 'passed' false and 'evidence' empty — "
+ "this round only defines the checklist."); + "this round only defines the checklist.");
} else if (Boolean.TRUE.equals(goal.getPersistentExecution())) {
sb.append("Return only changed criteria with specific evidence. Preserve verified prior work "
+ "unless new evidence contradicts it; absence from the latest reply is not a contradiction. "
+ "Never treat passed=true without nonblank evidence as verified completion.");
} else { } else {
sb.append("For every criterion above, return its id with passed=true ONLY when " sb.append("For every criterion above, return its id with passed=true ONLY when "
+ "the reply shows concrete evidence; otherwise passed=false with a short " + "the reply shows concrete evidence; otherwise passed=false with a short "

View File

@ -5,6 +5,7 @@ import org.springframework.stereotype.Component;
import reactor.core.Disposable; import reactor.core.Disposable;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.AgentStreamAccumulator; import vip.mate.channel.web.AgentStreamAccumulator;
@ -30,10 +31,11 @@ public class GoalSegmentRunner {
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final ConversationTurnGate gate; private final ConversationTurnGate gate;
private final ConcurrentHashMap<Long,Worker> workers=new ConcurrentHashMap<>(); private final ConcurrentHashMap<Long,Worker> workers=new ConcurrentHashMap<>();
private volatile boolean closing;
private static final class Worker { private static final class Worker {
final String conversationId; final String conversationId;
final Thread thread=Thread.currentThread();
final AtomicBoolean cancelled=new AtomicBoolean(); final AtomicBoolean cancelled=new AtomicBoolean();
final AtomicBoolean interrupted=new AtomicBoolean();
final AtomicReference<ChatStreamTracker.RunHandle> handle=new AtomicReference<>(); final AtomicReference<ChatStreamTracker.RunHandle> handle=new AtomicReference<>();
volatile boolean interactive; volatile boolean interactive;
Worker(String conversationId) { this.conversationId=conversationId; } Worker(String conversationId) { this.conversationId=conversationId; }
@ -64,10 +66,16 @@ public class GoalSegmentRunner {
workers.values().stream().filter(w -> Objects.equals(w.conversationId,conversationId)).forEach(this::cancelWorker); workers.values().stream().filter(w -> Objects.equals(w.conversationId,conversationId)).forEach(this::cancelWorker);
} }
public void cancelAll() {
closing=true;
workers.values().forEach(this::cancelWorker);
}
private void cancelWorker(Worker worker) { private void cancelWorker(Worker worker) {
worker.cancelled.set(true); worker.cancelled.set(true);
streams.cancelRun(worker.handle.get()); streams.cancelRun(worker.handle.get());
worker.thread.interrupt(); // Stream disposal releases the completion latch. Never interrupt this
// worker: it also performs JDBC I/O on shared embedded database channels.
} }
public Result run(GoalEntity goal, String prompt, boolean recovered) { public Result run(GoalEntity goal, String prompt, boolean recovered) {
@ -77,6 +85,11 @@ public class GoalSegmentRunner {
Worker worker=new Worker(convId); Worker worker=new Worker(convId);
try { try {
workers.put(goal.getId(),worker); workers.put(goal.getId(),worker);
// Register before checking the shutdown fence so cancellation cannot miss us.
if (closing) {
worker.cancelled.set(true);
return new Result("stopped",false);
}
var conv=conversations.findByConversationId(convId); var conv=conversations.findByConversationId(convId);
if (conv==null || !Objects.equals(conv.getWorkspaceId(),goal.getWorkspaceId()) if (conv==null || !Objects.equals(conv.getWorkspaceId(),goal.getWorkspaceId())
|| !Objects.equals(conv.getAgentId(),goal.getAgentId()) || !Objects.equals(conv.getAgentId(),goal.getAgentId())
@ -121,6 +134,7 @@ public class GoalSegmentRunner {
} while (queued!=null); } while (queued!=null);
return result; return result;
} catch (RuntimeException error) { } catch (RuntimeException error) {
if (Thread.interrupted()) worker.interrupted.set(true);
// Accepted user input must survive even when this goal cannot continue. // Accepted user input must survive even when this goal cannot continue.
ChatStreamTracker.QueuedInput pending; ChatStreamTracker.QueuedInput pending;
while ((pending=streams.consumeQueuedInput(convId))!=null) { while ((pending=streams.consumeQueuedInput(convId))!=null) {
@ -130,8 +144,20 @@ public class GoalSegmentRunner {
"Goal execution interrupted. Queued input was saved; review the execution state before resuming.")); "Goal execution interrupted. Queued input was saved; review the execution state before resuming."));
throw error; throw error;
} finally { } finally {
workers.remove(goal.getId(),worker); try {
permit.close(); // Cooperative cancellation returns normally, bypassing the error path.
// Accepted input must still survive process exit, including attachments.
if (worker.cancelled.get()) {
ChatStreamTracker.QueuedInput pending;
while ((pending=streams.consumeQueuedInput(convId))!=null) {
if (!pending.persisted()) conversations.saveMessage(convId,"user",pending.message(),pending.contentParts(),"queued");
}
}
} finally {
workers.remove(goal.getId(),worker);
permit.close();
if (worker.interrupted.get()) Thread.currentThread().interrupt();
}
} }
} }
@ -153,7 +179,7 @@ public class GoalSegmentRunner {
if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) throw new InterruptedException(); if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) throw new InterruptedException();
conversations.updateStreamStatus(convId,"running"); conversations.updateStreamStatus(convId,"running");
streams.broadcastObject(convId,"message_start",Map.of("role","assistant","trigger","goal")); streams.broadcastObject(convId,"message_start",Map.of("role","assistant","trigger","goal"));
subscription=gate.withPermit(permit,() -> vip.mate.agent.context.GoalContinuationContext.call(() -> subscription=gate.withPermit(permit,() -> GoalContinuationContext.call(!worker.interactive, () ->
reactor.core.publisher.Flux.defer(() -> { reactor.core.publisher.Flux.defer(() -> {
if (worker.cancelled.get()) return reactor.core.publisher.Flux.empty(); if (worker.cancelled.get()) return reactor.core.publisher.Flux.empty();
return agents.chatStructuredStream(goal.getAgentId(),input, return agents.chatStructuredStream(goal.getAgentId(),input,
@ -184,10 +210,11 @@ public class GoalSegmentRunner {
} }
return new Result(reason,accumulator.isAwaitingApproval(),evaluationUnavailable.get()); return new Result(reason,accumulator.isAwaitingApproval(),evaluationUnavailable.get());
} catch (InterruptedException interrupted) { } catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); worker.interrupted.set(true);
throw new IllegalStateException("Goal worker interrupted; recover from persisted evidence",interrupted); throw new IllegalStateException("Goal worker interrupted; recover from persisted evidence",interrupted);
} finally { } finally {
if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) streams.cancelRun(handle); if (Thread.interrupted()) worker.interrupted.set(true);
if (worker.cancelled.get() || worker.interrupted.get()) streams.cancelRun(handle);
if (subscription!=null) subscription.dispose(); if (subscription!=null) subscription.dispose();
try { try {
if (!persisted.get()) persist(convId,accumulator,"interrupted"); if (!persisted.get()) persist(convId,accumulator,"interrupted");

View File

@ -6,12 +6,14 @@ import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder; import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity; import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -98,6 +100,45 @@ class BaseAgentCronIsolationTest {
"a normal turn keeps prior history (the trailing current user row is de-duplicated)"); "a normal turn keeps prior history (the trailing current user row is de-duplicated)");
} }
@Test
void goalContinuation_keepsHistoryButUsesExplicitInstruction() {
ConversationService conv = mock(ConversationService.class);
List<MessageEntity> stored = List.of(user("Reply READY"), assistant("READY"));
when(conv.countMessages("goal_conv")).thenReturn((long) stored.size());
when(conv.listMessages("goal_conv")).thenReturn(stored);
stubRender(conv);
TestAgent agent = newAgent(conv);
ChatOriginHolder.set(ChatOrigin.web("goal_conv", "u1", 1L, null));
GoalContinuationContext.call(() -> {
assertEquals(2, agent.history("goal_conv", "Continue with checkpoint 1").size());
assertEquals("Continue with checkpoint 1",
agent.currentMessage("goal_conv", "Continue with checkpoint 1"),
"the durable continuation must not replay the last persisted user instruction");
return null;
});
}
@Test
void queuedUserTurn_reconstructsStoredContentAndRestoresContinuationScope() {
ConversationService conv = mock(ConversationService.class);
when(conv.listMessages("goal_conv")).thenReturn(List.of(user("Current user with attachment text")));
stubRender(conv);
TestAgent agent = newAgent(conv);
GoalContinuationContext.call(() -> {
GoalContinuationContext.call(false, () -> {
assertTrue(GoalContinuationContext.active());
assertEquals("Current user with attachment text", agent.currentMessage("goal_conv", "fallback"));
return null;
});
assertEquals("Continue after queued input", agent.currentMessage("goal_conv", "Continue after queued input"));
return null;
});
assertFalse(GoalContinuationContext.active());
assertFalse(GoalContinuationContext.explicitPrompt());
}
// ---------- scaffold ---------- // ---------- scaffold ----------
private static TestAgent newAgent(ConversationService conv) { private static TestAgent newAgent(ConversationService conv) {

View File

@ -68,6 +68,16 @@ class GoalContinuationSupervisorTest {
verify(store).settle(eq(1L),anyString(),eq("completed"),any(),eq(0),anyString()); verify(store).settle(eq(1L),anyString(),eq("completed"),any(),eq(0),anyString());
} }
@Test void shutdownCancellationLeavesLeaseForRestartRecovery() {
when(runner.run(any(),anyString(),anyBoolean())).thenAnswer(inv -> {
supervisor.close();
return new GoalSegmentRunner.Result("stopped",false);
});
supervisor.tick();
verify(runner).cancelAll();
verify(store,never()).settle(any(),anyString(),anyString(),any(),anyInt(),anyString());
}
@Test void budgetReachedDuringSegmentIsReportedAsResumableBudgetLimit() { @Test void budgetReachedDuringSegmentIsReportedAsResumableBudgetLimit() {
when(runner.run(any(),anyString(),anyBoolean())).thenAnswer(inv -> { when(runner.run(any(),anyString(),anyBoolean())).thenAnswer(inv -> {
goal.setStatus(GoalStatus.PAUSED); goal.setStatus(GoalStatus.PAUSED);

View File

@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.UserMessage;
@ -95,6 +96,25 @@ class GoalEvaluationServiceTest {
// ==================== Pre-flight guards ==================== // ==================== Pre-flight guards ====================
@Test
void persistentVerdictReceivesPriorVerifiedEvidenceOutsideConversationWindow() {
GoalEntity g = goalWithCriteria();
g.setPersistentExecution(true);
g.setCriteria("[{\"id\":\"C1\",\"text\":\"DNS configured\",\"passed\":true,\"evidence\":\"verified DNS checkpoint\"},"
+ "{\"id\":\"C2\",\"text\":\"TLS enabled\",\"passed\":false,\"evidence\":\"\"}]");
stubChatResponse("{\"criterionVerdicts\":[{\"id\":\"C2\",\"passed\":true,\"evidence\":\"TLS handshake verified\"}],\"summary\":\"done\"}");
GoalEvaluationResult result = svc.evaluate(g,List.of(),"TLS handshake verified");
ArgumentCaptor<Prompt> request = ArgumentCaptor.forClass(Prompt.class);
verify(chatModel).call(request.capture());
String prompt = request.getValue().getContents();
assertTrue(prompt.contains("verified DNS checkpoint"), "persistent evaluation must retain prior evidence after history truncation");
assertTrue(prompt.contains("passed=true"));
assertTrue(prompt.contains("contradicts"));
assertTrue(result.completed(), "a new verified step can complete previously verified work without repeating it");
}
@Test @Test
void nullGoal_returnsFallback_withoutTouchingProviders() { void nullGoal_returnsFallback_withoutTouchingProviders() {
GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything"); GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything");

View File

@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
@ -12,7 +13,12 @@ import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEntity;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ -64,7 +70,10 @@ class GoalSegmentRunnerTest {
var calls=new java.util.concurrent.atomic.AtomicInteger(); var calls=new java.util.concurrent.atomic.AtomicInteger();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenAnswer(inv -> Flux.defer(() -> { .thenAnswer(inv -> Flux.defer(() -> {
if(calls.incrementAndGet()==1) streams.enqueueMessage("conv","new user instruction",2L,false); boolean autonomous = calls.incrementAndGet()==1;
assertTrue(GoalContinuationContext.active());
assertEquals(autonomous, GoalContinuationContext.explicitPrompt());
if(autonomous) streams.enqueueMessage("conv","new user instruction",2L,false);
return Flux.just(new AgentService.StreamDelta("output",null), return Flux.just(new AgentService.StreamDelta("output",null),
AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")));
})); }));
@ -103,6 +112,81 @@ class GoalSegmentRunnerTest {
assertNotNull(gate.tryAcquire("conv")); assertNotNull(gate.tryAcquire("conv"));
} }
@Test void cancellationDoesNotInterruptDatabasePersistence() throws Exception {
var saving = new CountDownLatch(1);
var release = new CountDownLatch(1);
var interrupted = new AtomicBoolean();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.just(new AgentService.StreamDelta("checkpoint", null)));
when(conversations.saveMessage(eq("conv"),eq("assistant"),anyString(),anyList(),anyString(),
anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()))
.thenAnswer(inv -> {
saving.countDown();
try { release.await(3, TimeUnit.SECONDS); }
catch (InterruptedException error) { interrupted.set(true); }
return null;
});
Thread worker = Thread.ofVirtual().start(() -> runner.run(goal,"continue",false));
assertTrue(saving.await(3,TimeUnit.SECONDS));
runner.cancel(1L);
release.countDown();
worker.join(3000);
assertFalse(worker.isAlive());
assertFalse(interrupted.get(), "cancellation must not close embedded database channels by interrupting I/O");
}
@Test void shutdownPersistsQueuedInputWithAttachments() throws Exception {
var ready = new CountDownLatch(1);
MessageContentPart attachment = new MessageContentPart();
attachment.setType("file");
attachment.setPath("test-evidence.txt");
var parts = List.of(attachment);
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.<AgentService.StreamDelta>never().doOnSubscribe(s -> ready.countDown()));
Thread worker = Thread.ofVirtual().start(() -> runner.run(goal,"continue",false));
assertTrue(ready.await(3,TimeUnit.SECONDS));
streams.enqueueMessage("conv","accepted steering",2L,false,parts);
runner.cancelAll();
worker.join(3000);
assertFalse(worker.isAlive());
verify(conversations).saveMessage("conv","user","accepted steering",parts,"queued");
assertFalse(streams.hasQueuedMessage("conv"));
}
@Test void shutdownRejectsLateWorkerAdmissionWithoutStartingModel() {
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))));
runner.cancelAll();
assertEquals("stopped", runner.run(goal,"continue",false).finishReason());
verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any());
}
@Test void externalInterruptIsRestoredOnlyAfterCheckpointPersistence() throws Exception {
var ready = new CountDownLatch(1);
var persisted = new AtomicBoolean();
var interruptRestored = new AtomicBoolean();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.concat(Flux.just(new AgentService.StreamDelta("partial",null)),
Flux.<AgentService.StreamDelta>never().doOnSubscribe(s -> ready.countDown())));
when(conversations.saveMessage(eq("conv"),eq("assistant"),anyString(),anyList(),anyString(),
anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()))
.thenAnswer(inv -> {
assertFalse(Thread.currentThread().isInterrupted());
persisted.set(true);
return null;
});
Thread worker = Thread.ofVirtual().start(() -> {
try { runner.run(goal,"continue",false); }
catch (IllegalStateException expected) { interruptRestored.set(Thread.currentThread().isInterrupted()); }
});
assertTrue(ready.await(3,TimeUnit.SECONDS));
worker.interrupt();
worker.join(3000);
assertFalse(worker.isAlive());
assertTrue(persisted.get());
assertTrue(interruptRestored.get());
}
@Test void permanentFailurePersistsAcceptedQueuedInput() { @Test void permanentFailurePersistsAcceptedQueuedInput() {
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.defer(() -> { .thenReturn(Flux.defer(() -> {