Preserve consecutive managed approvals and bounded settlement handoff

This commit is contained in:
mateaix 2026-09-15 03:34:05 +08:00
parent 041bdd30f7
commit 89b7dc79e1
8 changed files with 122 additions and 14 deletions

View File

@ -160,8 +160,14 @@ public final class GraphEventPublisher {
public static GraphEvent toolApprovalRequested(String pendingId, String toolName, public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason) { String arguments, String reason) {
return toolApprovalRequested(null, pendingId, toolName, arguments, reason);
}
public static GraphEvent toolApprovalRequested(String toolCallId, String pendingId, String toolName,
String arguments, String reason) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of( return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"pendingId", pendingId, "pendingId", pendingId,
"toolName", toolName != null ? toolName : "", "toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? arguments : "", "arguments", arguments != null ? arguments : "",
@ -177,8 +183,16 @@ public final class GraphEventPublisher {
String arguments, String reason, String arguments, String reason,
String summary, String maxSeverity, String summary, String maxSeverity,
List<Map<String, Object>> findings) { List<Map<String, Object>> findings) {
return toolApprovalRequested(null, pendingId, toolName, arguments, reason, summary, maxSeverity, findings);
}
public static GraphEvent toolApprovalRequested(String toolCallId, String pendingId, String toolName,
String arguments, String reason,
String summary, String maxSeverity,
List<Map<String, Object>> findings) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>(); java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("toolCallId", toolCallId != null ? toolCallId : "");
data.put("pendingId", pendingId); data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : ""); data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? arguments : ""); data.put("arguments", arguments != null ? arguments : "");

View File

@ -34,7 +34,10 @@ public class GoalApprovalReplayStream {
.doOnSubscribe(subscription -> execution.source = subscription) .doOnSubscribe(subscription -> execution.source = subscription)
.doOnNext(execution::observe) .doOnNext(execution::observe)
.takeUntilOther(execution.lost.asMono()) .takeUntilOther(execution.lost.asMono())
.doOnComplete(execution::complete), Execution::close); .doOnComplete(execution::complete), Execution::close)
.retryWhen(reactor.util.retry.Retry.fixedDelay(20, java.time.Duration.ofMillis(25))
.filter(GoalApprovalRunService.SettlementPending.class::isInstance)
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
} }
private final class Execution implements AutoCloseable { private final class Execution implements AutoCloseable {
@ -85,6 +88,9 @@ public class GoalApprovalReplayStream {
checkpoint("uncertain", "tool_completed"); checkpoint("uncertain", "tool_completed");
} else if ("tool_approval_requested".equals(event)) { } else if ("tool_approval_requested".equals(event)) {
awaitingApproval=true; awaitingApproval=true;
// This exact call was deferred by the guard and has not executed.
Object id = data == null ? null : data.get("toolCallId");
if (id != null && !String.valueOf(id).isBlank()) inFlight.remove(String.valueOf(id));
} else if ("goal_evaluated".equals(event) && data != null) { } else if ("goal_evaluated".equals(event) && data != null) {
evaluationUnavailable = Boolean.TRUE.equals(data.get("skipped")) || "fallback".equals(data.get("decision")); evaluationUnavailable = Boolean.TRUE.equals(data.get("skipped")) || "fallback".equals(data.get("decision"));
} else if ("finish_reason".equals(event) && data != null && data.get("reason") != null) { } else if ("finish_reason".equals(event) && data != null && data.get("reason") != null) {

View File

@ -46,14 +46,26 @@ public class GoalApprovalRunService {
public ReplayRun claim(ChatOrigin requested, String toolCallPayload) { public ReplayRun claim(ChatOrigin requested, String toolCallPayload) {
ExecutionAttribution link = requested == null ? null : requested.executionAttribution(); ExecutionAttribution link = requested == null ? null : requested.executionAttribution();
if (link == null || link.goalId() == null || link.goalAttemptId() == null if (link == null || link.goalId() == null || link.goalAttemptId() == null
|| link.ownerFence() == null || link.approvalId() == null) throw rejected(); || link.ownerFence() == null || link.approvalId() == null
|| link.cronRunId() != null || requested.cronOrigin()) throw rejected();
// Preserve the normal user -> conversation -> Goal lock order. // Preserve the normal user -> conversation -> Goal lock order.
var owners = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0", var owners = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0",
String.class, requested.conversationId()); String.class, requested.conversationId());
if (owners.size()!=1) throw rejected(); if (owners.size()!=1) throw rejected();
var scope = acceptance.authorizedGoal(link.goalId(), owners.getFirst(), true); var scope = acceptance.authorizedGoal(link.goalId(), owners.getFirst(), true);
if (!scope.required() || !"active".equals(scope.status())) throw rejected(); if (!scope.required() || !"active".equals(scope.status())) throw rejected();
if (!Objects.equals(scope.conversationId(), requested.conversationId())
|| !Objects.equals(scope.workspaceId(), requested.workspaceId())
|| !Objects.equals(scope.agentId(), requested.agentId())) throw rejected();
var candidate = continuations.getForUpdate(link.goalId()); var candidate = continuations.getForUpdate(link.goalId());
if (candidate != null && "running".equals(candidate.state())
&& Objects.equals(candidate.currentAttemptId(), link.goalAttemptId())
&& Objects.equals(candidate.leaseOwner(), link.ownerFence())
&& continuations.matchesFence(link.goalId(), link.ownerFence(), link.goalAttemptId(),
candidate.revision(), Instant.now().getEpochSecond())
&& attempts.hasLiveFence(link.goalAttemptId(), link.ownerFence(), Instant.now().getEpochSecond())) {
throw new SettlementPending();
}
if (candidate == null || !"waiting_approval".equals(candidate.state())) throw rejected(); if (candidate == null || !"waiting_approval".equals(candidate.state())) throw rejected();
var parent = attempts.getForUpdate(link.goalAttemptId()); var parent = attempts.getForUpdate(link.goalAttemptId());
if (parent == null || !Objects.equals(parent.goalId(), link.goalId()) if (parent == null || !Objects.equals(parent.goalId(), link.goalId())
@ -73,6 +85,7 @@ public class GoalApprovalRunService {
try { persisted = json.readValue(approval.origin(), ChatOrigin.class); } try { persisted = json.readValue(approval.origin(), ChatOrigin.class); }
catch (Exception error) { throw rejected(); } catch (Exception error) { throw rejected(); }
if (persisted == null || persisted.executionAttribution() == null if (persisted == null || persisted.executionAttribution() == null
|| persisted.cronOrigin() || persisted.executionAttribution().cronRunId() != null
|| !Objects.equals(persisted.executionAttribution().goalId(), link.goalId()) || !Objects.equals(persisted.executionAttribution().goalId(), link.goalId())
|| !Objects.equals(persisted.executionAttribution().goalAttemptId(), link.goalAttemptId()) || !Objects.equals(persisted.executionAttribution().goalAttemptId(), link.goalAttemptId())
|| !Objects.equals(persisted.executionAttribution().ownerFence(), link.ownerFence()) || !Objects.equals(persisted.executionAttribution().ownerFence(), link.ownerFence())
@ -101,6 +114,9 @@ public class GoalApprovalRunService {
} }
private record Approval(String conversationId, String agentId, String status, String payload, String origin) { } private record Approval(String conversationId, String agentId, String status, String payload, String origin) { }
static final class SettlementPending extends MateClawException {
SettlementPending() { super(409, "The original Goal attempt is still settling its approval"); }
}
private static MateClawException rejected() { private static MateClawException rejected() {
return new MateClawException(409, "Approved Goal execution cannot acquire a current owner; resume from current Goal state"); return new MateClawException(409, "Approved Goal execution cannot acquire a current owner; resume from current Goal state");
} }

View File

@ -65,6 +65,7 @@ public final class ToolExecutionGuardHelper {
// SSE 直推审批事件增强版包含 findings // SSE 直推审批事件增强版包含 findings
if (streamTracker != null) { if (streamTracker != null) {
Map<String, Object> eventData = new java.util.LinkedHashMap<>(); Map<String, Object> eventData = new java.util.LinkedHashMap<>();
eventData.put("toolCallId", toolCall.id() != null ? toolCall.id() : "");
eventData.put("pendingId", pendingId); eventData.put("pendingId", pendingId);
eventData.put("toolName", toolName != null ? toolName : ""); eventData.put("toolName", toolName != null ? toolName : "");
eventData.put("arguments", arguments != null ? GraphEventPublisher.truncateForBroadcast(arguments) : ""); eventData.put("arguments", arguments != null ? GraphEventPublisher.truncateForBroadcast(arguments) : "");
@ -78,7 +79,7 @@ public final class ToolExecutionGuardHelper {
} }
events.add(GraphEventPublisher.toolApprovalRequested( events.add(GraphEventPublisher.toolApprovalRequested(
pendingId, toolName, arguments, reason, toolCall.id(), pendingId, toolName, arguments, reason,
evaluation.summary(), evaluation.summary(),
evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null, evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null,
evaluation.findingsToMapList())); evaluation.findingsToMapList()));
@ -117,6 +118,7 @@ public final class ToolExecutionGuardHelper {
if (streamTracker != null) { if (streamTracker != null) {
streamTracker.broadcastObject(conversationId, "tool_approval_requested", Map.of( streamTracker.broadcastObject(conversationId, "tool_approval_requested", Map.of(
"toolCallId", toolCall.id() != null ? toolCall.id() : "",
"pendingId", pendingId, "pendingId", pendingId,
"toolName", toolName != null ? toolName : "", "toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? GraphEventPublisher.truncateForBroadcast(arguments) : "", "arguments", arguments != null ? GraphEventPublisher.truncateForBroadcast(arguments) : "",
@ -125,7 +127,7 @@ public final class ToolExecutionGuardHelper {
)); ));
} }
events.add(GraphEventPublisher.toolApprovalRequested(pendingId, toolName, arguments, guardResult.reason())); events.add(GraphEventPublisher.toolApprovalRequested(toolCall.id(), pendingId, toolName, arguments, guardResult.reason()));
return "[APPROVAL_PENDING] tool=" + toolName + " awaiting user decision"; return "[APPROVAL_PENDING] tool=" + toolName + " awaiting user decision";
} }

View File

@ -67,6 +67,6 @@ JWT requests match the signed userId to the current enabled account ID. Recreati
After interactive Web approval, Plan execution restores the original plan and approved call, retaining the requester and managed acceptance requirements. Approval itself does not replace a JSON check or complete the goal. After interactive Web approval, Plan execution restores the original plan and approved call, retaining the requester and managed acceptance requirements. Approval itself does not replace a JSON check or complete the goal.
When a background Goal settles into awaiting approval, its original attempt lease is released. Replaying that persisted identity cannot access managed artifacts or complete the Goal. The existing replay flow creates an exactly linked fresh attempt and lease for a Goal with selected JSON requirements. That attempt can also reuse still-eligible evidence. A single background approval has been verified through the real ReAct/Plan runtime; repeated approval and arrival during original settlement remain under verification. When a background Goal settles into awaiting approval, its original attempt lease is released. Replaying that persisted identity cannot access managed artifacts or complete the Goal. The existing replay flow creates an exactly linked fresh attempt and lease for a Goal with selected JSON requirements. That attempt can also reuse still-eligible evidence. Single and two consecutive background approvals have been verified through the real ReAct/Plan runtime, retaining exact call and parent-attempt associations. Approval arriving during original settlement gets a short bounded wait only while both original leases and identities still match; expired or different owners remain rejected.
V200 records the exact attempt that settled into approval waiting and makes the approval-to-new-attempt association unique. Older waiting rows remain unbound and cannot be inferred into new execution authority. Replay renews its lease every 20 seconds and settles on normal completion. Cancellation, failure, or lease loss preserves an uncertain checkpoint; expiry recovery pauses for review instead of blindly replaying side effects. Because graph tool events can arrive in batches, an intermediate completion event does not make the whole replay safe to retry. V200 records the exact attempt that settled into approval waiting and makes the approval-to-new-attempt association unique. Older waiting rows remain unbound and cannot be inferred into new execution authority. Replay renews its lease every 20 seconds and settles on normal completion. Cancellation, failure, or lease loss preserves an uncertain checkpoint; expiry recovery pauses for review instead of blindly replaying side effects. Because graph tool events can arrive in batches, an intermediate completion event does not make the whole replay safe to retry.

View File

@ -69,6 +69,6 @@ JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户
交互式Web审批后的Plan执行会恢复原计划和已批准调用并保留请求者及受管验收要求审批通过本身不能替代JSON检查或完成目标。 交互式Web审批后的Plan执行会恢复原计划和已批准调用并保留请求者及受管验收要求审批通过本身不能替代JSON检查或完成目标。
后台 Goal 进入待审批并结算后,原 attempt 的租约已经释放;其持久化审批身份不能再读写托管产物或完成 Goal。现有重放流会为已选定JSON要求的Goal建立一个准确关联的新 attempt使用新租约执行已批准调用新 attempt 也可复用仍合格的已有版本。单次后台审批已通过真实ReAct/Plan运行时验证连续再次审批及结算瞬间的竞争仍在验证 后台 Goal 进入待审批并结算后,原 attempt 的租约已经释放;其持久化审批身份不能再读写托管产物或完成 Goal。现有重放流会为已选定JSON要求的Goal建立一个准确关联的新 attempt使用新租约执行已批准调用新 attempt 也可复用仍合格的已有版本。单次及连续两次后台审批已通过真实ReAct/Plan运行时验证每次批准都保留准确的调用和父attempt关联。批准早于原结算时只对两侧租约仍有效且身份匹配的原attempt进行短暂有界等待失租或不同owner仍拒绝
V200 保存待审批结算对应的准确 attempt并对审批生成的新 attempt 设置唯一关联。升级前的待审批行保持未绑定不能推断为任何新的执行权限。重放每20秒续租正常结束后结算取消、异常或失租保留不确定检查点到期恢复会暂停并要求核实不盲目重放工具副作用。图的工具事件可能延后汇总因此中途完成事件不等于整段执行可安全重试。 V200 保存待审批结算对应的准确 attempt并对审批生成的新 attempt 设置唯一关联。升级前的待审批行保持未绑定不能推断为任何新的执行权限。重放每20秒续租正常结束后结算取消、异常或失租保留不确定检查点到期恢复会暂停并要求核实不盲目重放工具副作用。图的工具事件可能延后汇总因此中途完成事件不等于整段执行可安全重试。

View File

@ -748,6 +748,44 @@ class GoalJsonAcceptanceIntegrationTest {
: goals.markRuntimeCompleted(goal.getId(), evaluation, origin); : goals.markRuntimeCompleted(goal.getId(), evaluation, origin);
} }
@Test void earlyApprovalWaitsForOriginalSettlementInsteadOfLosingTheConsumedCall() throws Exception {
GoalEntity goal = goal(true);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var original = claimed(goal);
var origin = attemptOrigin(goal, original);
String pending;
vip.mate.agent.context.ChatOriginHolder.set(origin);
try {
pending = approvals.createPending(goal.getConversationId(), alice, "getManagedGoalJsonSlots", "{}",
"offline settlement race fixture", "[]", null, "1");
} finally { vip.mate.agent.context.ChatOriginHolder.clear(); }
assertNotNull(approvals.resolveAndConsume(pending, alice).consumedSnapshot());
try (var pool = java.util.concurrent.Executors.newSingleThreadExecutor()) {
var started = new java.util.concurrent.CountDownLatch(1);
var invocationCount = new java.util.concurrent.atomic.AtomicInteger();
var future = pool.submit(() -> {
started.countDown();
return approvalStream.replay(origin.withApprovalId(pending), "[]", fresh -> {
invocationCount.incrementAndGet();
return reactor.core.publisher.Flux.just(
vip.mate.agent.AgentService.StreamDelta.event("tool_call_started", java.util.Map.of("toolCallId", "approved")),
vip.mate.agent.AgentService.StreamDelta.event("tool_call_completed", java.util.Map.of("toolCallId", "approved")));
}).collectList().block(java.time.Duration.ofSeconds(3));
});
assertTrue(started.await(2, java.util.concurrent.TimeUnit.SECONDS));
Thread.sleep(100);
boolean waitedForSettlement = !future.isDone();
assertEquals(0, invocationCount.get(), "The approved call must not execute under the original owner");
assertTrue(coordinator.settle(original, new SegmentOutcome.AwaitApproval("approval_required"), java.time.LocalDateTime.now()));
assertEquals(2, future.get(3, java.util.concurrent.TimeUnit.SECONDS).size());
assertTrue(waitedForSettlement, "The consumed approval should wait briefly for its exact original attempt");
assertEquals(1, invocationCount.get());
assertEquals("queued", continuations.get(goal.getId()).state());
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE goal_id=?", Integer.class, goal.getId()));
assertFalse(coordinator.renew(original, java.time.LocalDateTime.now()));
}
}
@ParameterizedTest @ValueSource(strings = {"normal", "cancel", "error", "unpaired", "renew", "lost"}) @ParameterizedTest @ValueSource(strings = {"normal", "cancel", "error", "unpaired", "renew", "lost"})
void approvalStreamOwnsLeaseAndConservativelyRecoversInterruptedTools(String kind) throws Exception { void approvalStreamOwnsLeaseAndConservativelyRecoversInterruptedTools(String kind) throws Exception {
GoalEntity goal = goal(true); GoalEntity goal = goal(true);
@ -821,12 +859,14 @@ class GoalJsonAcceptanceIntegrationTest {
} finally { subscription.dispose(); } } finally { subscription.dispose(); }
} }
@ParameterizedTest @ValueSource(strings = {"valid", "pending", "payload", "paused", "running", "legacy", "archived", "agent", "disabled", "wrong-parent"}) @ParameterizedTest @ValueSource(strings = {"valid", "pending", "payload", "paused", "running", "legacy", "archived", "agent", "disabled", "wrong-parent", "cron", "stored-cron"})
void consumedApprovalCanClaimOnlyItsExactWaitingGoalOnce(String kind) throws Exception { void consumedApprovalCanClaimOnlyItsExactWaitingGoalOnce(String kind) throws Exception {
GoalEntity goal = goal(true); GoalEntity goal = goal(true);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var original = claimed(goal); var original = claimed(goal);
var origin = attemptOrigin(goal, original); var origin = kind.endsWith("cron") ? attemptOrigin(goal, original).withExecutionAttribution(
new vip.mate.agent.context.ExecutionAttribution(goal.getId(), original.attempt().id(), 1L, null, original.attempt().leaseToken()))
: attemptOrigin(goal, original);
String payload = "[{\"name\":\"getManagedGoalJsonSlots\",\"arguments\":\"{}\"}]"; String payload = "[{\"name\":\"getManagedGoalJsonSlots\",\"arguments\":\"{}\"}]";
String pending; String pending;
vip.mate.agent.context.ChatOriginHolder.set(origin); vip.mate.agent.context.ChatOriginHolder.set(origin);
@ -843,7 +883,7 @@ class GoalJsonAcceptanceIntegrationTest {
if (kind.equals("archived")) jdbc.update("UPDATE mate_conversation SET archived=1 WHERE conversation_id=?", goal.getConversationId()); if (kind.equals("archived")) jdbc.update("UPDATE mate_conversation SET archived=1 WHERE conversation_id=?", goal.getConversationId());
if (kind.equals("agent")) jdbc.update("UPDATE mate_conversation SET agent_id=99 WHERE conversation_id=?", goal.getConversationId()); if (kind.equals("agent")) jdbc.update("UPDATE mate_conversation SET agent_id=99 WHERE conversation_id=?", goal.getConversationId());
if (kind.equals("disabled")) jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice); if (kind.equals("disabled")) jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice);
var replay = origin.withApprovalId(pending); var replay = kind.equals("stored-cron") ? attemptOrigin(goal, original).withApprovalId(pending) : origin.withApprovalId(pending);
if (!kind.equals("valid")) { if (!kind.equals("valid")) {
assertThrows(MateClawException.class, () -> approvalRuns.claim(replay, kind.equals("payload") ? "[]" : payload)); assertThrows(MateClawException.class, () -> approvalRuns.claim(replay, kind.equals("payload") ? "[]" : payload));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE goal_id=?", Integer.class, goal.getId()), assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE goal_id=?", Integer.class, goal.getId()),

View File

@ -79,11 +79,13 @@ class GoalJsonHttpRuntimeIntegrationTest {
"false,queued,true", "false,reuse,true", "true,reuse,true", "false,recheck,true", "true,recheck,true", "false,queued,true", "false,reuse,true", "true,reuse,true", "false,recheck,true", "true,recheck,true",
"false,supervised,true", "true,supervised,true", "false,supervised-recovered,true", "true,supervised-recovered,true", "false,supervised,true", "true,supervised,true", "false,supervised-recovered,true", "true,supervised-recovered,true",
"false,supervised,false", "true,supervised,false", "false,supervised-recovered,false", "true,supervised-recovered,false", "false,supervised,false", "true,supervised,false", "false,supervised-recovered,false", "true,supervised-recovered,false",
"false,approval,true", "true,approval,true", "false,scheduled-approval,true", "true,scheduled-approval,true"}) "false,approval,true", "true,approval,true", "false,scheduled-approval,true", "true,scheduled-approval,true",
"false,scheduled-double-approval,true", "true,scheduled-double-approval,true"})
void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception { void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception {
boolean approval = entry.endsWith("approval"); boolean approval = entry.endsWith("approval");
boolean doubleApproval = entry.equals("scheduled-double-approval");
boolean supervised = entry.startsWith("supervised"); boolean supervised = entry.startsWith("supervised");
boolean scheduled = entry.equals("scheduled") || entry.equals("scheduled-approval") || entry.equals("recovered") || supervised; boolean scheduled = entry.startsWith("scheduled") || entry.equals("recovered") || supervised;
boolean reuse = entry.equals("reuse"); boolean reuse = entry.equals("reuse");
boolean recheck = entry.equals("recheck"); boolean recheck = entry.equals("recheck");
boolean queued = entry.equals("queued"); boolean queued = entry.equals("queued");
@ -159,11 +161,12 @@ class GoalJsonHttpRuntimeIntegrationTest {
java.util.concurrent.atomic.AtomicReference<String> revision = new java.util.concurrent.atomic.AtomicReference<>(); java.util.concurrent.atomic.AtomicReference<String> revision = new java.util.concurrent.atomic.AtomicReference<>();
java.util.concurrent.atomic.AtomicReference<String> originalCheck = new java.util.concurrent.atomic.AtomicReference<>(); java.util.concurrent.atomic.AtomicReference<String> originalCheck = new java.util.concurrent.atomic.AtomicReference<>();
var planApprovalReplay = new java.util.concurrent.atomic.AtomicBoolean(); var planApprovalReplay = new java.util.concurrent.atomic.AtomicBoolean();
var approvedToolName = new java.util.concurrent.atomic.AtomicReference<>("getManagedGoalJsonSlots");
org.mockito.stubbing.Answer<ChatResponse> script = invocation -> { org.mockito.stubbing.Answer<ChatResponse> script = invocation -> {
if (approval && plan && planApprovalReplay.compareAndSet(true, false)) { if (approval && plan && planApprovalReplay.compareAndSet(true, false)) {
// Plan replay asks again for the persisted approved call; ReAct forces it without an LLM call. // Plan replay asks again for the persisted approved call; ReAct forces it without an LLM call.
return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("") return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("")
.toolCalls(List.of(new AssistantMessage.ToolCall("approved-read", "function", "getManagedGoalJsonSlots", "{}"))).build()))); .toolCalls(List.of(new AssistantMessage.ToolCall("approved-" + approvedToolName.get(), "function", approvedToolName.get(), "{}"))).build())));
} }
Prompt prompt = invocation.getArgument(0); Prompt prompt = invocation.getArgument(0);
int step = calls.getAndIncrement(); int step = calls.getAndIncrement();
@ -272,7 +275,16 @@ class GoalJsonHttpRuntimeIntegrationTest {
rule.setToolName("getManagedGoalJsonSlots"); rule.setParamName("args"); rule.setToolName("getManagedGoalJsonSlots"); rule.setParamName("args");
rule.setCategory("RESOURCE_ABUSE"); rule.setSeverity("MEDIUM"); rule.setDecision("NEEDS_APPROVAL"); rule.setCategory("RESOURCE_ABUSE"); rule.setSeverity("MEDIUM"); rule.setDecision("NEEDS_APPROVAL");
rule.setPattern("getManagedGoalJsonSlots"); rule.setBuiltin(false); rule.setEnabled(true); rule.setPriority(1000); rule.setDeleted(0); rule.setPattern("getManagedGoalJsonSlots"); rule.setBuiltin(false); rule.setEnabled(true); rule.setPriority(1000); rule.setDeleted(0);
guardRules.insert(rule); guardRegistry.reload(); guardRules.insert(rule);
vip.mate.tool.guard.model.ToolGuardRuleEntity publishRule = null;
if (doubleApproval) {
publishRule = new vip.mate.tool.guard.model.ToolGuardRuleEntity();
org.springframework.beans.BeanUtils.copyProperties(rule, publishRule);
publishRule.setId(IdWorker.getId()); publishRule.setRuleId(rule.getRuleId() + "-publish");
publishRule.setToolName("publishManagedGoalJson"); publishRule.setPattern("publishManagedGoalJson");
guardRules.insert(publishRule);
}
guardRegistry.reload();
var guard = guardConfig.getConfig(); guard.setEnabled(true); guardConfig.updateConfig(guard); var guard = guardConfig.getConfig(); guard.setEnabled(true); guardConfig.updateConfig(guard);
try { try {
String waiting; String waiting;
@ -299,17 +311,34 @@ class GoalJsonHttpRuntimeIntegrationTest {
planApprovalReplay.set(plan); planApprovalReplay.set(plan);
String replay = requestBody("POST", "/api/v1/chat/stream", token, String replay = requestBody("POST", "/api/v1/chat/stream", token,
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", "/approve", "pendingApprovalId", pendingId)); Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", "/approve", "pendingApprovalId", pendingId));
String expectedParent = scheduled ? run.attempt().id() : null;
if (doubleApproval) {
String firstReplayAttempt = jdbc.queryForObject("SELECT attempt_id FROM mate_goal_attempt WHERE approval_pending_id=?", String.class, pendingId);
assertEquals(expectedParent, attempts.get(firstReplayAttempt).parentAttemptId());
assertEquals("succeeded", attempts.get(firstReplayAttempt).state());
assertEquals("waiting_approval", continuations.get(goal.getId()).state(), replay);
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
JsonNode next = request("GET", "/api/v1/chat/" + conversation + "/pending-approvals", token, null).path("data");
assertEquals(1, next.size(), replay);
assertEquals("publishManagedGoalJson", next.get(0).path("toolName").asText());
pendingId = next.get(0).path("pendingId").asText();
approvedToolName.set("publishManagedGoalJson"); planApprovalReplay.set(plan);
expectedParent = firstReplayAttempt;
replay = requestBody("POST", "/api/v1/chat/stream", token,
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", "/approve", "pendingApprovalId", pendingId));
}
assertTrue(replay.contains("Managed JSON fixture completed."), replay); assertTrue(replay.contains("Managed JSON fixture completed."), replay);
assertEquals("CONSUMED", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId)); assertEquals("CONSUMED", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId));
if (scheduled) { if (scheduled) {
String freshAttempt = jdbc.queryForObject("SELECT attempt_id FROM mate_goal_attempt WHERE approval_pending_id=?", String.class, pendingId); String freshAttempt = jdbc.queryForObject("SELECT attempt_id FROM mate_goal_attempt WHERE approval_pending_id=?", String.class, pendingId);
var fresh = attempts.get(freshAttempt); var fresh = attempts.get(freshAttempt);
assertEquals(run.attempt().id(), fresh.parentAttemptId()); assertEquals(expectedParent, fresh.parentAttemptId());
assertNotEquals(run.attempt().leaseToken(), fresh.leaseToken()); assertNotEquals(run.attempt().leaseToken(), fresh.leaseToken());
assertEquals("succeeded", fresh.state()); assertEquals("succeeded", fresh.state());
assertEquals("completed", continuations.get(goal.getId()).state()); assertEquals("completed", continuations.get(goal.getId()).state());
assertFalse(coordinator.renew(run, java.time.LocalDateTime.now())); assertFalse(coordinator.renew(run, java.time.LocalDateTime.now()));
assertEquals(freshAttempt, jdbc.queryForObject("SELECT producer_id FROM mate_goal_json_artifact WHERE goal_id=?", String.class, goal.getId())); assertEquals(freshAttempt, jdbc.queryForObject("SELECT producer_id FROM mate_goal_json_artifact WHERE goal_id=?", String.class, goal.getId()));
assertEquals(doubleApproval ? 3 : 2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE goal_id=?", Integer.class, goal.getId()));
} }
if (plan) { if (plan) {
assertEquals(approvedPlan, jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation), assertEquals(approvedPlan, jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation),
@ -318,6 +347,7 @@ class GoalJsonHttpRuntimeIntegrationTest {
} }
} finally { } finally {
jdbc.update("DELETE FROM mate_tool_guard_rule WHERE id=?", rule.getId()); jdbc.update("DELETE FROM mate_tool_guard_rule WHERE id=?", rule.getId());
if (publishRule != null) jdbc.update("DELETE FROM mate_tool_guard_rule WHERE id=?", publishRule.getId());
guardRegistry.reload(); guardRegistry.reload();
} }
} else if (queued) { } else if (queued) {