mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
439 lines
25 KiB
Java
439 lines
25 KiB
Java
package vip.mate.goal;
|
|
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
import vip.mate.memory.spi.MemoryManager;
|
|
import org.springframework.context.ApplicationEventPublisher;
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.test.context.TestPropertySource;
|
|
import org.springframework.transaction.PlatformTransactionManager;
|
|
import org.springframework.transaction.TransactionDefinition;
|
|
import org.springframework.transaction.support.TransactionSynchronization;
|
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
import org.springframework.transaction.support.TransactionTemplate;
|
|
import vip.mate.MateClawApplication;
|
|
import vip.mate.approval.event.ApprovalResolutionEvent;
|
|
import vip.mate.exception.MateClawException;
|
|
import vip.mate.goal.model.GoalCreateRequest;
|
|
import vip.mate.goal.model.GoalEntity;
|
|
import vip.mate.goal.model.GoalStatus;
|
|
import vip.mate.goal.service.GoalService;
|
|
import vip.mate.goal.service.GoalContinuationStore;
|
|
|
|
import java.sql.Timestamp;
|
|
import java.time.LocalDateTime;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
/**
|
|
* Integration tests for goal persistence and transaction boundaries:
|
|
*
|
|
* <ol>
|
|
* <li>{@code GoalStatus} persists as lowercase strings ({@code "active"}
|
|
* etc., NOT {@code "ACTIVE"}). The V120 predicate unique index
|
|
* compares {@code status = 'active'} as a literal — any uppercase
|
|
* write would silently defeat the uniqueness guarantee.</li>
|
|
* <li>The {@code uk_agent_goal_active_conv} unique index rejects a
|
|
* second active-row insert for the same conversation. Service-layer
|
|
* pre-check is a UX nicety; this is the source of truth.</li>
|
|
* <li>Resume and approval decisions commit both goal and continuation state
|
|
* before independent transaction observers are allowed to continue.</li>
|
|
* </ol>
|
|
*
|
|
* <p>Uses an in-memory H2 MySQL-compat database so Flyway runs V120
|
|
* exactly as it would in dev. The {@code DATABASE_TO_LOWER=TRUE} flag is
|
|
* standard across mateclaw's other Spring tests.
|
|
*/
|
|
@SpringBootTest(
|
|
classes = MateClawApplication.class,
|
|
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
|
)
|
|
@TestPropertySource(properties = {
|
|
"spring.datasource.url=jdbc:h2:mem:goal_persistence_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
|
|
"spring.ai.dashscope.api-key=test-key",
|
|
"spring.main.web-application-type=none",
|
|
"mateclaw.goal.enabled=false", "mateclaw.plugin.enabled=false", "mateclaw.skill.workspace.auto-init=false",
|
|
"mateclaw.skill.workspace.root=${java.io.tmpdir}/mateclaw-goal-persistence-skills-${random.uuid}"
|
|
})
|
|
class GoalPersistenceIntegrationTest {
|
|
|
|
@MockBean private MemoryManager memory;
|
|
@Autowired private GoalService goalService;
|
|
@Autowired private JdbcTemplate jdbc;
|
|
@Autowired private GoalContinuationStore continuations;
|
|
@Autowired private PlatformTransactionManager transactionManager;
|
|
@Autowired private ApplicationEventPublisher events;
|
|
|
|
private GoalCreateRequest req(String convId, String title) {
|
|
GoalCreateRequest r = new GoalCreateRequest();
|
|
r.setConversationId(convId);
|
|
r.setAgentId(1L);
|
|
r.setWorkspaceId(1L);
|
|
r.setTitle(title);
|
|
r.setDescription("desc");
|
|
return r;
|
|
}
|
|
|
|
@Test
|
|
void lateBootstrapCannotReplaceUserCriterionCommittedWhileModelWasRunning() {
|
|
GoalEntity created = goalService.create(req("bootstrap-append-boundary", "prepare a report"), "alice");
|
|
// The evaluator started with an empty checklist. A user append commits
|
|
// before its delayed bootstrap result reaches recordEvaluation.
|
|
goalService.appendCriterion(created.getId(), "include the user requested appendix", "alice");
|
|
var delayed = new vip.mate.goal.model.GoalEvaluationResult(0.0, "checklist created", "continue", false,
|
|
"fixture", 1, 0, java.util.List.of(), java.util.List.of(
|
|
new vip.mate.goal.model.GoalCriterion("C1", "model draft", false, "")));
|
|
goalService.recordEvaluation(created.getId(), delayed, 2, 1);
|
|
GoalEntity saved = goalService.getById(created.getId());
|
|
var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(saved.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper());
|
|
assertEquals(1, criteria.size());
|
|
assertEquals("include the user requested appendix", criteria.getFirst().text());
|
|
assertEquals(1, saved.getEvalLlmCallsUsed());
|
|
assertEquals(2, saved.getAgentLlmCallsUsed());
|
|
}
|
|
|
|
@Test
|
|
void lateVerdictProjectsProgressFromCurrentChecklist() throws Exception {
|
|
GoalEntity created = goalService.create(req("late-verdict-current-progress", "prepare a report"), "alice");
|
|
goalService.appendCriterion(created.getId(), "write the report", "alice");
|
|
// The model evaluated only C1. A second condition commits before the result.
|
|
goalService.appendCriterion(created.getId(), "include an appendix", "alice");
|
|
var delayed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true,
|
|
"fixture", 1, 0, java.util.List.of(
|
|
new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "report written")), null);
|
|
goalService.recordEvaluation(created.getId(), delayed, 2, 1);
|
|
GoalEntity saved = goalService.getById(created.getId());
|
|
assertEquals(0.5, saved.getCompletionScore());
|
|
var event = goalService.listEvents(created.getId(), 20).stream()
|
|
.filter(e -> "evaluated".equals(e.getEventType())).findFirst().orElseThrow();
|
|
var detail = new com.fasterxml.jackson.databind.ObjectMapper().readTree(event.getDetailJson());
|
|
assertEquals(0.5, detail.get("completionScore").asDouble());
|
|
assertEquals(1.0, detail.get("evaluatorScore").asDouble());
|
|
org.junit.jupiter.api.Assertions.assertTrue(detail.get("gap").asText().contains("include an appendix"));
|
|
org.junit.jupiter.api.Assertions.assertTrue(saved.getProgressSummary().contains("include an appendix"));
|
|
assertEquals(GoalStatus.ACTIVE, saved.getStatus());
|
|
assertEquals(1, saved.getEvalLlmCallsUsed());
|
|
assertThrows(MateClawException.class, () -> goalService.markEvaluatedCompleted(created.getId(), delayed));
|
|
}
|
|
|
|
@Test
|
|
void replacingExitCriteriaRevokesOldCompletion() {
|
|
GoalEntity created = goalService.create(req("edited-definition-completion", "report"), "alice");
|
|
goalService.appendCriterion(created.getId(), "old report", "alice");
|
|
var passed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
|
|
"C1", true, "old report written")), null);
|
|
goalService.recordEvaluation(created.getId(), passed, 1, 1);
|
|
var edit = new vip.mate.goal.model.GoalUpdateRequest();
|
|
edit.setExitCriteria("require a different report with an appendix");
|
|
goalService.update(created.getId(), edit, "alice");
|
|
assertThrows(MateClawException.class, () -> goalService.markEvaluatedCompleted(created.getId(), passed));
|
|
assertEquals(0.0, goalService.getById(created.getId()).getCompletionScore());
|
|
}
|
|
|
|
@Test
|
|
void staleDraftAndVerdictCannotCrossDefinitionRevisionButCurrentOnesCan() {
|
|
GoalEntity created = goalService.create(req("definition-revision-carriers", "report"), "alice");
|
|
var oldDraft = new vip.mate.goal.model.GoalEvaluationResult(0.0, "draft", "continue", false,
|
|
"fixture", 1, 0, java.util.List.of(), java.util.List.of(
|
|
new vip.mate.goal.model.GoalCriterion("C1", "old report", false, "")));
|
|
var edit = new vip.mate.goal.model.GoalUpdateRequest(); edit.setExitCriteria("new report");
|
|
goalService.update(created.getId(), edit, "alice");
|
|
assertEquals(1L, goalService.getById(created.getId()).getEvaluationRevision());
|
|
goalService.recordEvaluation(created.getId(), oldDraft, 1, 1);
|
|
assertEquals(null, goalService.getById(created.getId()).getCriteria());
|
|
var newDraft = new vip.mate.goal.model.GoalEvaluationResult(0.0, "draft", "continue", false,
|
|
"fixture", 1, 0, java.util.List.of(), java.util.List.of(
|
|
new vip.mate.goal.model.GoalCriterion("C1", "new report", false, ""))).withEvaluationRevision(1);
|
|
goalService.recordEvaluation(created.getId(), newDraft, 1, 1);
|
|
var oldPass = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
|
|
"C1", true, "old report evidence")), null);
|
|
goalService.recordEvaluation(created.getId(), oldPass, 1, 1);
|
|
assertEquals(0.0, goalService.getById(created.getId()).getCompletionScore());
|
|
var currentPass = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
|
|
"C1", true, "new report evidence")), null).withEvaluationRevision(1);
|
|
goalService.recordEvaluation(created.getId(), currentPass, 1, 1);
|
|
// Even after current criteria pass, an old result cannot perform the final transition.
|
|
assertThrows(MateClawException.class, () -> goalService.markEvaluatedCompleted(created.getId(), oldPass));
|
|
assertEquals(4, goalService.getById(created.getId()).getEvalLlmCallsUsed());
|
|
assertEquals(GoalStatus.COMPLETED, goalService.markEvaluatedCompleted(created.getId(), currentPass).getStatus());
|
|
}
|
|
|
|
@Test
|
|
void definitionRevisionSurvivesAbaAndIgnoresIdenticalAndBudgetEdits() {
|
|
var request = req("definition-revision-aba", "report"); request.setExitCriteria("A");
|
|
GoalEntity created = goalService.create(request, "alice");
|
|
var edit = new vip.mate.goal.model.GoalUpdateRequest(); edit.setExitCriteria("A"); edit.setTurnBudget(12);
|
|
goalService.update(created.getId(), edit, "alice");
|
|
assertEquals(0L, goalService.getById(created.getId()).getEvaluationRevision());
|
|
edit.setExitCriteria("B"); goalService.update(created.getId(), edit, "alice");
|
|
edit.setExitCriteria("A"); goalService.update(created.getId(), edit, "alice");
|
|
assertEquals(2L, goalService.getById(created.getId()).getEvaluationRevision());
|
|
var stale = new vip.mate.goal.model.GoalEvaluationResult(0.0, "draft", "continue", false,
|
|
"fixture", 1, 0, java.util.List.of(), java.util.List.of(
|
|
new vip.mate.goal.model.GoalCriterion("C1", "A", false, "")));
|
|
goalService.recordEvaluation(created.getId(), stale, 0, 1);
|
|
assertEquals(null, goalService.getById(created.getId()).getCriteria());
|
|
}
|
|
|
|
@Test
|
|
void contextEditPreservesCriterionTextButRevokesPriorPass() {
|
|
GoalEntity created = goalService.create(req("definition-context-edit", "report"), "alice");
|
|
goalService.appendCriterion(created.getId(), "user criterion", "alice");
|
|
var passed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
|
|
"C1", true, "old evidence")), null);
|
|
goalService.recordEvaluation(created.getId(), passed, 0, 1);
|
|
var edit = new vip.mate.goal.model.GoalUpdateRequest(); edit.setDescription("changed context");
|
|
var saved = goalService.update(created.getId(), edit, "alice");
|
|
var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(saved.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper());
|
|
assertEquals("user criterion", criteria.getFirst().text());
|
|
org.junit.jupiter.api.Assertions.assertFalse(criteria.getFirst().passed());
|
|
assertEquals("", criteria.getFirst().evidence());
|
|
assertEquals(1L, saved.getEvaluationRevision());
|
|
}
|
|
|
|
@Test
|
|
void repeatedCompletionWritesOneEventAndSyncsMemoryOnce() {
|
|
GoalEntity goal = goalService.create(req("completion-event-idempotence", "report"), "alice");
|
|
goalService.appendCriterion(goal.getId(), "report", "alice");
|
|
var passed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "report evidence")), null);
|
|
goalService.recordEvaluation(goal.getId(), passed, 0, 1);
|
|
goalService.markEvaluatedCompleted(goal.getId(), passed);
|
|
goalService.markEvaluatedCompleted(goal.getId(), passed);
|
|
goalService.markCompleted(goal.getId(), null);
|
|
assertEquals(1L, goalService.listEvents(goal.getId(), 30).stream()
|
|
.filter(event -> "completed".equals(event.getEventType())).count());
|
|
org.mockito.Mockito.verify(memory, org.mockito.Mockito.times(1)).syncAll(
|
|
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(goal.getConversationId()),
|
|
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
|
|
}
|
|
|
|
@Test
|
|
void abandonedGoalCannotBeRecordedAsCompletedByAnIdempotentCall() {
|
|
GoalEntity goal = goalService.create(req("abandoned-no-completion-event", "report"), "alice");
|
|
goalService.abandon(goal.getId(), "alice");
|
|
assertEquals(GoalStatus.ABANDONED, goalService.markCompleted(goal.getId(), null).getStatus());
|
|
assertEquals(0L, goalService.listEvents(goal.getId(), 30).stream()
|
|
.filter(event -> "completed".equals(event.getEventType())).count());
|
|
org.mockito.Mockito.verifyNoInteractions(memory);
|
|
}
|
|
|
|
private GoalEntity readyForCompletion(String conversation, String title) {
|
|
GoalEntity goal = goalService.create(req(conversation, title), "alice");
|
|
goalService.appendCriterion(goal.getId(), "report", "alice");
|
|
var passed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
|
|
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "report evidence")), null);
|
|
goalService.recordEvaluation(goal.getId(), passed, 0, 1);
|
|
return goalService.getById(goal.getId());
|
|
}
|
|
|
|
@Test
|
|
void rolledBackCompletionDoesNotSyncMemory() {
|
|
GoalEntity goal = readyForCompletion("completion-memory-rollback", "report");
|
|
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
|
|
goalService.markCompleted(goal.getId(), null);
|
|
status.setRollbackOnly();
|
|
});
|
|
assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus());
|
|
assertEquals(0L, goalService.listEvents(goal.getId(), 30).stream()
|
|
.filter(event -> "completed".equals(event.getEventType())).count());
|
|
org.mockito.Mockito.verifyNoInteractions(memory);
|
|
}
|
|
|
|
@Test
|
|
void completionMemoryRunsAfterCommitAndItsDatabaseWritesCommitIndependently() {
|
|
GoalEntity goal = readyForCompletion("completion-memory-after-commit", "original title");
|
|
jdbc.execute("CREATE TABLE IF NOT EXISTS goal_memory_callback_probe(goal_id BIGINT PRIMARY KEY)");
|
|
org.mockito.Mockito.doAnswer(call -> {
|
|
inIndependentTransaction(() -> assertEquals(GoalStatus.COMPLETED,
|
|
goalService.getById(goal.getId()).getStatus()));
|
|
assertEquals("[goal completed] original title", call.getArgument(2));
|
|
jdbc.update("INSERT INTO goal_memory_callback_probe(goal_id) VALUES(?)", goal.getId());
|
|
return null;
|
|
}).when(memory).syncAll(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(),
|
|
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
|
|
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
|
|
GoalEntity returned = goalService.markCompleted(goal.getId(), null);
|
|
returned.setTitle("mutated after return");
|
|
org.mockito.Mockito.verifyNoInteractions(memory);
|
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
@Override public void afterCommit() {
|
|
inIndependentTransaction(() -> assertEquals(1, jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM goal_memory_callback_probe WHERE goal_id=?", Integer.class, goal.getId())));
|
|
}
|
|
});
|
|
});
|
|
org.mockito.Mockito.verify(memory, org.mockito.Mockito.times(1)).syncAll(
|
|
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(),
|
|
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
|
|
}
|
|
|
|
@Test
|
|
void memoryFailureCannotUndoCommittedCompletion() {
|
|
GoalEntity goal = readyForCompletion("completion-memory-failure", "report");
|
|
org.mockito.Mockito.doThrow(new IllegalStateException("fixture failure")).when(memory).syncAll(
|
|
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(),
|
|
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
|
|
goalService.markCompleted(goal.getId(), null);
|
|
assertEquals(GoalStatus.COMPLETED, goalService.getById(goal.getId()).getStatus());
|
|
assertEquals(1L, goalService.listEvents(goal.getId(), 30).stream()
|
|
.filter(event -> "completed".equals(event.getEventType())).count());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv")
|
|
void status_persistsAsLowercaseString() {
|
|
GoalEntity created = goalService.create(req("conv-status-1", "lower-case check"), "alice");
|
|
String raw = jdbc.queryForObject(
|
|
"SELECT status FROM mate_agent_goal WHERE id = ?",
|
|
String.class, created.getId());
|
|
assertEquals("active", raw,
|
|
"GoalStatus must persist as lowercase 'active' — uppercase 'ACTIVE' would " +
|
|
"silently bypass the V120 predicate unique index uk_agent_goal_active_conv.");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Each terminal status also persists lowercase")
|
|
void terminalStatuses_alsoPersistLowercase() {
|
|
GoalEntity g = goalService.create(req("conv-status-terminal", "terminal check"), "alice");
|
|
|
|
goalService.abandon(g.getId(), "alice");
|
|
String s = jdbc.queryForObject(
|
|
"SELECT status FROM mate_agent_goal WHERE id = ?",
|
|
String.class, g.getId());
|
|
assertEquals("abandoned", s);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Service rejects a second active goal on the same conversation (UX pre-check 409)")
|
|
void servicePreCheck_blocksDuplicateActiveCreation() {
|
|
goalService.create(req("conv-dup-1", "first"), "alice");
|
|
MateClawException ex = assertThrows(MateClawException.class,
|
|
() -> goalService.create(req("conv-dup-1", "second"), "alice"));
|
|
assertEquals(409, ex.getCode());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("DB unique index rejects a second active row even when service pre-check is bypassed")
|
|
void uniqueIndex_isUltimateSourceOfTruth() {
|
|
// First goal — via service so it gets a real ID + workspace + timestamps.
|
|
goalService.create(req("conv-uq-1", "first"), "alice");
|
|
|
|
// Second insertion — bypass the service entirely and write through
|
|
// JdbcTemplate. Must hit DuplicateKeyException at the DB level.
|
|
LocalDateTime now = LocalDateTime.now();
|
|
try {
|
|
jdbc.update(
|
|
"INSERT INTO mate_agent_goal " +
|
|
"(id, conversation_id, agent_id, workspace_id, created_by, " +
|
|
" title, description, status, turn_budget, turns_used, " +
|
|
" llm_call_budget, agent_llm_calls_used, eval_llm_calls_used, " +
|
|
" auto_followup_enabled, followup_cooldown_seconds, " +
|
|
" version, deleted, create_time, update_time) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
99999L, "conv-uq-1", 1L, 1L, "alice",
|
|
"second", "desc", "active",
|
|
20, 0, 200, 0, 0,
|
|
false, 0,
|
|
0, 0, Timestamp.valueOf(now), Timestamp.valueOf(now));
|
|
fail("Expected DuplicateKeyException from uk_agent_goal_active_conv");
|
|
} catch (DuplicateKeyException expected) {
|
|
// good
|
|
}
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("A new active goal is allowed after the previous one entered a terminal state")
|
|
void terminalGoal_releasesUniquenessSlot() {
|
|
GoalEntity first = goalService.create(req("conv-recycle-1", "first"), "alice");
|
|
goalService.abandon(first.getId(), "alice");
|
|
|
|
// After abandon, the conversation should be free to host a new active goal.
|
|
GoalEntity second = goalService.create(req("conv-recycle-1", "second"), "alice");
|
|
assertNotNull(second);
|
|
assertEquals(GoalStatus.ACTIVE, second.getStatus());
|
|
}
|
|
|
|
@Test
|
|
void resumeCommitsGoalAndContinuationTogether() {
|
|
GoalEntity goal = persistentGoal("conv-resume-transaction", "paused");
|
|
goalService.pause(goal.getId(), "alice");
|
|
|
|
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
|
|
goalService.resume(goal.getId(), "alice");
|
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
@Override public void afterCommit() {
|
|
inIndependentTransaction(() -> assertEquals("queued", continuations.get(goal.getId()).state(),
|
|
"Resume must commit continuation state before after-commit consumers observe it"));
|
|
}
|
|
});
|
|
});
|
|
|
|
assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus());
|
|
assertEquals("queued", continuations.get(goal.getId()).state(),
|
|
"The continuation update must commit with the proxied resume transaction");
|
|
}
|
|
|
|
@Test
|
|
void approvalDenialAfterCommitDurablyPausesGoalAndContinuation() {
|
|
assertAfterCommitApprovalPauses("conv-denial-transaction", "USER_MANUAL", "denied");
|
|
}
|
|
|
|
@Test
|
|
void approvalTimeoutAfterCommitDurablyPausesGoalAndContinuation() {
|
|
assertAfterCommitApprovalPauses("conv-timeout-transaction", "TIMEOUT", null);
|
|
}
|
|
|
|
private void assertAfterCommitApprovalPauses(String conversationId, String decisionSource, String note) {
|
|
GoalEntity goal = persistentGoal(conversationId, "waiting_approval");
|
|
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
|
|
// Bind the JDBC resource just as ApprovalWorkflowService does while resolving approval.
|
|
jdbc.queryForObject("SELECT status FROM mate_agent_goal WHERE id=?", String.class, goal.getId());
|
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
@Override public void afterCommit() {
|
|
events.publishEvent(new ApprovalResolutionEvent("pending-" + goal.getId(), conversationId,
|
|
"1", "alice", "shell", "{}", null, null, decisionSource, note));
|
|
// A different connection must see the pause before the event returns;
|
|
// do not rely on original-connection cleanup incidentally committing JDBC writes.
|
|
inIndependentTransaction(() -> {
|
|
assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus());
|
|
assertEquals("paused", continuations.get(goal.getId()).state());
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus(),
|
|
"An approval callback must commit its own transaction after the approval transaction committed");
|
|
assertEquals("paused", continuations.get(goal.getId()).state());
|
|
}
|
|
|
|
private void inIndependentTransaction(Runnable assertion) {
|
|
TransactionTemplate independent = new TransactionTemplate(transactionManager);
|
|
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
|
independent.executeWithoutResult(status -> assertion.run());
|
|
}
|
|
|
|
private GoalEntity persistentGoal(String conversationId, String continuationState) {
|
|
GoalCreateRequest request = req(conversationId, "transaction boundary");
|
|
request.setPersistentExecution(true);
|
|
request.setAutoFollowupEnabled(true);
|
|
GoalEntity goal = goalService.create(request, "alice");
|
|
LocalDateTime now = LocalDateTime.now();
|
|
jdbc.update("INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,updated_at) VALUES(?,?,?,?)",
|
|
goal.getId(), continuationState, now, now);
|
|
return goal;
|
|
}
|
|
}
|