mateclaw/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java
taobig 2fa2e60170 feat(goal): persist continuous execution across bounded turns
- Add a database-backed supervisor with durable scheduling, fenced leases,
  cooldowns, bounded worker concurrency and expired-lease restart recovery.
- Default new goals to persistent execution with zero meaning unlimited
  cumulative budget; preserve legacy goals and explicit positive limits.
- Yield bounded graph segments to the supervisor instead of ending unfinished
  goals at graph-local continuation limits. Require persisted checklist
  evidence before accepting completion, including concurrent criterion edits.
- Share conversation admission across interactive and background execution;
  preserve partial replies, usage and queued user input during interruption.
- Persist Stop and missing-input pauses, respect approval boundaries, and
  commit resume and approval-denial transitions with correct transactions.
- Retry identifiable transient failures with backoff; retain visible pauses
  for budget limits and errors that require review instead of replaying tools.
- Expose owner-authorized execution status and reconnectable scheduling events;
  add H2, MySQL and Kingbase migrations, API types and bilingual documentation.

Validation: 298 focused backend tests passed, including persistence, restart
scheduling, approval races, cancellation, admission and existing runtime tests.
Frontend type checking, bundled-doc parity and ID precision checks passed.
V188 is registered and all three dialects have unique migration versions;
the migration-map audit still reports 95 pre-existing missing registrations.

Scope: single-backend native runtime. Recovery checks existing state before
repeating effects; this does not promise exactly-once external tool execution.
2026-08-26 05:46:15 -04:00

194 lines
11 KiB
Java

package vip.mate.goal.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.goal.model.GoalEntity;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class GoalSegmentRunnerTest {
AgentService agents=mock(AgentService.class);
ConversationService conversations=mock(ConversationService.class);
ApprovalWorkflowService approvals=mock(ApprovalWorkflowService.class);
ChatStreamTracker streams=new ChatStreamTracker(new ObjectMapper());
ConversationTurnGate gate=new ConversationTurnGate();
GoalEntity goal=new GoalEntity();
GoalSegmentRunner runner=new GoalSegmentRunner(agents,conversations,approvals,streams,new ObjectMapper(),gate);
@BeforeEach void setup() {
goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L);goal.setWorkspaceId(3L);goal.setCreatedBy("alice");
ConversationEntity conv=new ConversationEntity();
conv.setConversationId("conv");conv.setAgentId(2L);conv.setWorkspaceId(3L);conv.setUsername("alice");
when(conversations.findByConversationId("conv")).thenReturn(conv);
AgentEntity agent=new AgentEntity();agent.setEnabled(true);agent.setRuntimeType("native");
when(agents.getAgent(2L)).thenReturn(agent);
}
@Test void persistsStreamedResultAndUsage() {
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.just(new AgentService.StreamDelta("actual output",null),
AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))));
var result=runner.run(goal,"continue",false);
assertEquals("normal",result.finishReason());
verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("actual output"),anyList(),eq("completed"),
anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString());
assertFalse(streams.isRunning("conv"));
assertNotNull(gate.tryAcquire("conv"));
}
@Test void rejectsChangedConversationIdentityBeforeAnyModelCall() {
goal.setWorkspaceId(99L);
assertThrows(IllegalStateException.class,()->runner.run(goal,"continue",false));
verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any());
}
@Test void busyConversationIsNotRegisteredOrMutated() {
var user=gate.tryAcquire("conv");
assertThrows(vip.mate.exception.MateClawException.class,()->runner.run(goal,"continue",false));
verifyNoInteractions(conversations);
user.close();
}
@Test void drainsUserInputAcceptedDuringBackgroundTurn() {
var calls=new java.util.concurrent.atomic.AtomicInteger();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenAnswer(inv -> Flux.defer(() -> {
if(calls.incrementAndGet()==1) streams.enqueueMessage("conv","new user instruction",2L,false);
return Flux.just(new AgentService.StreamDelta("output",null),
AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")));
}));
runner.run(goal,"continue",false);
assertEquals(2,calls.get());
assertFalse(streams.hasQueuedMessage("conv"));
verify(agents).chatStructuredStream(eq(2L),eq("new user instruction"),eq("conv"),eq("alice"),isNull(),any());
verify(conversations).saveMessage("conv","user","new user instruction",null,"queued");
}
@Test void workerCancellationPersistsPartialEvidenceAndReleasesAdmission() throws Exception {
var subscribed=new java.util.concurrent.CountDownLatch(1);
var toolCancelled=new java.util.concurrent.atomic.AtomicBoolean();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.concat(Flux.just(new AgentService.StreamDelta("partial evidence",null)),
Flux.<AgentService.StreamDelta>never().doOnSubscribe(s -> {
streams.registerCancellationHook("conv",()->toolCancelled.set(true));
subscribed.countDown();
})));
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
var result=new java.util.concurrent.atomic.AtomicReference<GoalSegmentRunner.Result>();
Thread worker=Thread.ofVirtual().start(() -> {
try { result.set(runner.run(goal,"continue",false)); } catch(Throwable error) { failure.set(error); }
});
assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS));
runner.cancel(1L);
worker.join(3000);
assertFalse(worker.isAlive());
assertTrue(toolCancelled.get(),"escaped tool process must be cancelled as well as the stream");
// Cancellation may finish the Flux before the worker receives its interrupt.
// Both paths must preserve evidence and report a stopped/interrupted outcome.
if (failure.get()==null) assertEquals("stopped",result.get().finishReason());
verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial evidence"),anyList(),
argThat(status -> "interrupted".equals(status) || "stopped".equals(status)),
anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString());
assertNotNull(gate.tryAcquire("conv"));
}
@Test void permanentFailurePersistsAcceptedQueuedInput() {
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.defer(() -> {
streams.enqueueMessage("conv","user instruction",2L,false);
return Flux.error(new IllegalArgumentException("bad config"));
}));
assertThrows(IllegalArgumentException.class,()->runner.run(goal,"continue",false));
verify(conversations).saveMessage("conv","user","user instruction",null,"queued");
assertFalse(streams.hasQueuedMessage("conv"));
}
@Test void userSteeringPreservesInterruptedStatusThenRunsQueuedInput() throws Exception {
var subscribed=new java.util.concurrent.CountDownLatch(1);
var calls=new java.util.concurrent.atomic.AtomicInteger();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenAnswer(inv -> calls.incrementAndGet()==1
? Flux.concat(Flux.just(new AgentService.StreamDelta("partial",null)),
Flux.<AgentService.StreamDelta>never().doOnSubscribe(s -> subscribed.countDown()))
: Flux.just(new AgentService.StreamDelta("answer to steering",null)));
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
Thread worker=Thread.ofVirtual().start(() -> {
try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); }
});
assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS));
assertTrue(streams.requestInterrupt("conv","new instruction",2L,false));
worker.join(3000);
assertFalse(worker.isAlive());
assertNull(failure.get());
assertEquals(2,calls.get());
verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial"),anyList(),eq("interrupted"),
anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString());
}
@Test void goalCancellationDoesNotKillQueuedInteractiveWork() throws Exception {
var entered=new java.util.concurrent.CountDownLatch(1);
var finish=reactor.core.publisher.Sinks.<AgentService.StreamDelta>one();
var calls=new java.util.concurrent.atomic.AtomicInteger();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenAnswer(inv -> {
if(calls.incrementAndGet()==1) {
streams.enqueueMessage("conv","new question",2L,false);
return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")));
}
return finish.asMono().flux().doOnSubscribe(s -> entered.countDown());
});
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
Thread worker=Thread.ofVirtual().start(() -> {
try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); }
});
assertTrue(entered.await(3,java.util.concurrent.TimeUnit.SECONDS));
runner.cancel(1L);
finish.tryEmitValue(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")));
worker.join(3000);
assertFalse(worker.isAlive());
assertNull(failure.get());
}
@Test void explicitStopLatchesAcrossQueuedInputRegistrationGap() throws Exception {
var saving=new java.util.concurrent.CountDownLatch(1);
var release=new java.util.concurrent.CountDownLatch(1);
var calls=new java.util.concurrent.atomic.AtomicInteger();
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenAnswer(inv -> {
calls.incrementAndGet();
streams.enqueueMessage("conv","new question",2L,false);
return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")));
});
when(conversations.saveMessage("conv","user","new question",null,"queued")).thenAnswer(inv -> {
saving.countDown();
boolean interrupted=false;
while(true) {
try { if(release.await(3,java.util.concurrent.TimeUnit.SECONDS)) break; else throw new AssertionError("release timeout"); }
catch(InterruptedException ignored) { interrupted=true; }
}
if(interrupted) Thread.currentThread().interrupt();
return null;
});
Thread worker=Thread.ofVirtual().start(() -> {
try { runner.run(goal,"continue",false); } catch(RuntimeException expected) { }
});
assertTrue(saving.await(3,java.util.concurrent.TimeUnit.SECONDS));
runner.stopConversation("conv");
release.countDown();
worker.join(3000);
assertFalse(worker.isAlive());
assertEquals(1,calls.get(),"no queued model request may start after explicit Stop");
}
}