feat: fence managed JSON publication to current goal runtime owners

This commit is contained in:
mateaix 2026-09-14 21:29:42 +08:00
parent bc6882e629
commit 2b6bde9971
12 changed files with 275 additions and 7 deletions

View File

@ -35,6 +35,11 @@ public class GoalContinuationStore {
}
}
/** Coordinate runtime publication, scheduler settlement and recovery using the goal lock first. */
public boolean lockGoal(Long goalId) {
return jdbc.queryForList("SELECT id FROM mate_agent_goal WHERE id=? FOR UPDATE", Long.class, goalId).size() == 1;
}
public void discover(LocalDateTime now) {
// Bounded discovery; another instance may insert the same goal concurrently.
List<Long> ids = jdbc.queryForList("""

View File

@ -26,7 +26,7 @@ public class GoalJsonAcceptanceService {
public record Requirement(String criterionKey, String artifactSlot, long revision,
List<String> requiredFields, String configuredBy) { }
public record View(boolean required, List<Requirement> requirements) { }
record GoalScope(long id, String conversationId, long workspaceId, String status, boolean required) { }
record GoalScope(long id, String conversationId, long workspaceId, long agentId, String status, boolean required) { }
@Transactional
public View get(Long goalId, String username) {
@ -92,8 +92,8 @@ public class GoalJsonAcceptanceService {
}
private GoalScope goal(Long id, boolean lock) {
var rows = jdbc.query("SELECT id,conversation_id,workspace_id,status,json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> new GoalScope(row.getLong("id"), row.getString("conversation_id"), row.getLong("workspace_id"), row.getString("status"), row.getBoolean("json_acceptance_required")), id);
var rows = jdbc.query("SELECT id,conversation_id,workspace_id,agent_id,status,json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> new GoalScope(row.getLong("id"), row.getString("conversation_id"), row.getLong("workspace_id"), row.getLong("agent_id"), row.getString("status"), row.getBoolean("json_acceptance_required")), id);
if (rows.size() != 1) throw failure(404, "Goal not found");
return rows.getFirst();
}

View File

@ -21,12 +21,14 @@ public class GoalRecoveryService {
private final GoalContinuationStore continuations;
private final ConversationInputQueueStore inputs;
private final GoalService goals;
private final org.springframework.transaction.support.TransactionTemplate transactions;
private final LocalDateTime startupCutoff=LocalDateTime.now();
private volatile boolean orphanClaimsReleased;
public GoalRecoveryService(GoalAttemptStore attempts,GoalContinuationStore continuations,
ConversationInputQueueStore inputs,GoalService goals) {
ConversationInputQueueStore inputs,GoalService goals, org.springframework.transaction.PlatformTransactionManager manager) {
this.attempts=attempts;this.continuations=continuations;this.inputs=inputs;this.goals=goals;
this.transactions=new org.springframework.transaction.support.TransactionTemplate(manager);
}
public RecoveryDecision classify(GoalAttempt attempt) {
@ -53,13 +55,14 @@ public class GoalRecoveryService {
}
int recovered=0;
for(GoalAttempt attempt:attempts.expired(now,100)) {
if(recover(attempt,now)) recovered++;
if(Boolean.TRUE.equals(transactions.execute(status -> recover(attempt,now)))) recovered++;
}
return recovered;
}
@Transactional
boolean recover(GoalAttempt attempt,LocalDateTime now) {
if(!continuations.lockGoal(attempt.goalId())) return false;
var continuation=continuations.get(attempt.goalId());
if(continuation==null || !attempt.id().equals(continuation.currentAttemptId())
|| !attempt.leaseToken().equals(continuation.leaseOwner())) return false;

View File

@ -31,6 +31,7 @@ public class GoalRunCoordinator {
@Transactional
public ClaimedRun claim(GoalContinuationStore.Continuation candidate,GoalEntity goal,LocalDateTime now) {
if(candidate==null || goal==null || candidate.currentAttemptId()!=null) return null;
if(!continuations.lockGoal(goal.getId())) return null;
String token=UUID.randomUUID().toString();
LocalDateTime until=now.plusSeconds(LEASE_SECONDS);
if(!continuations.claim(goal.getId(),token,now,until)) return null;
@ -50,12 +51,14 @@ public class GoalRunCoordinator {
@Transactional
public boolean markRunning(ClaimedRun run,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
if(!current(run)) return false;
return attempts.markRunning(run.attempt().id(),run.attempt().leaseToken(),now);
}
@Transactional
public boolean renew(ClaimedRun run,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
LocalDateTime until=now.plusSeconds(LEASE_SECONDS);
if(!continuations.renewFenced(run.goal().getId(),run.attempt().leaseToken(),
run.attempt().id(),run.revision(),until)) return false;
@ -65,6 +68,7 @@ public class GoalRunCoordinator {
@Transactional
public boolean checkpoint(ClaimedRun run,String replaySafety,String checkpointType,
Long assistantMessageId,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
if(!current(run)) return false;
return attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),replaySafety,
checkpointType,assistantMessageId,now);
@ -72,6 +76,7 @@ public class GoalRunCoordinator {
@Transactional
public boolean settle(ClaimedRun run,SegmentOutcome outcome,LocalDateTime now) {
if(run==null || !continuations.lockGoal(run.goal().getId())) return false;
if(!current(run)) return false;
GoalEntity fresh=goals.getById(run.goal().getId());
Settlement settlement=classify(run,outcome,fresh,now);

View File

@ -4,6 +4,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.agent.context.ChatOrigin;
import java.util.Objects;
import java.time.LocalDateTime;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.nio.charset.StandardCharsets;
@ -52,6 +55,87 @@ public class ManagedGoalJsonService {
return rows.getFirst();
}
public record RuntimeView(List<GoalJsonAcceptanceService.Requirement> requirements, List<Slot> slots) { }
@Transactional
public RuntimeView listForRuntime(ChatOrigin origin) {
long goalId = runtimeGoal(origin).goal().id();
return new RuntimeView(acceptance.requirements(goalId), slots(goalId));
}
@Transactional
public Artifact publishForRuntime(ChatOrigin origin, String slot, PublishRequest request) {
var runtime = runtimeGoal(origin);
var result = publishLocked(runtime.goal(), slot, request, runtime.producerKind(), runtime.producerId());
if (runtime.leaseUntil() != null && !runtime.leaseUntil().isAfter(LocalDateTime.now())) {
throw failure(409, "Goal attempt lease expired during publication");
}
return result;
}
record RuntimeScope(GoalJsonAcceptanceService.GoalScope goal, String producerKind,
String producerId, LocalDateTime leaseUntil) { }
// All identity comes from server-created ToolContext, never model arguments.
// Lock order: enabled user -> conversation -> goal -> continuation -> goal attempt.
RuntimeScope runtimeGoal(ChatOrigin origin) {
if (origin == null || origin.conversationId() == null || origin.workspaceId() == null || origin.agentId() == null) {
throw failure(403, "A bound goal runtime is required");
}
var attribution = origin.executionAttribution();
boolean attempt = attribution != null && (attribution.goalId() != null || attribution.goalAttemptId() != null || attribution.ownerFence() != null);
if (attempt && (attribution.goalId() == null || attribution.goalAttemptId() == null || attribution.ownerFence() == null)) {
throw failure(403, "Incomplete goal attempt identity");
}
if (origin.cronOrigin() || (attribution != null && attribution.cronRunId() != null)) {
throw failure(403, "Cron publication is not supported by this goal protocol");
}
List<Long> ids = attempt ? List.of(attribution.goalId()) : jdbc.queryForList("""
SELECT id FROM mate_agent_goal WHERE conversation_id=? AND workspace_id=?
AND status IN ('active','paused') AND deleted=0
""", Long.class, origin.conversationId(), origin.workspaceId());
if (ids.size() != 1) throw failure(409, "Exactly one current goal is required");
List<String> users;
if (attempt) {
users = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0", String.class, origin.conversationId());
} else {
if (origin.requesterUserId() == null) throw failure(403, "Authenticated account identity is required");
users = jdbc.queryForList("SELECT username FROM mate_user WHERE id=? AND enabled=TRUE AND deleted=0", String.class, origin.requesterUserId());
}
if (users.size() != 1) throw failure(403, "Runtime owner unavailable");
var goal = acceptance.authorizedGoal(ids.getFirst(), users.getFirst(), true);
if (!Objects.equals(goal.conversationId(), origin.conversationId()) || goal.workspaceId() != origin.workspaceId()
|| goal.agentId() != origin.agentId()) throw failure(403, "Runtime goal scope mismatch");
if (!attempt) {
// Recheck the immutable user id after authorizedGoal acquired the user lock.
Long userId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0", Long.class, users.getFirst());
if (!Objects.equals(userId, origin.requesterUserId())) throw failure(403, "Runtime account changed");
return new RuntimeScope(goal, "account-runtime", String.valueOf(userId), null);
}
var continuationLeases = jdbc.query("""
SELECT lease_owner,current_attempt_id,state,lease_until FROM mate_goal_continuation WHERE goal_id=? FOR UPDATE
""", (r, i) -> Objects.equals(r.getString("lease_owner"), attribution.ownerFence())
&& Objects.equals(r.getString("current_attempt_id"), attribution.goalAttemptId())
&& "running".equals(r.getString("state")) && r.getTimestamp("lease_until") != null
? r.getTimestamp("lease_until").toLocalDateTime() : null, goal.id());
if (continuationLeases.size() != 1 || continuationLeases.getFirst() == null
|| !continuationLeases.getFirst().isAfter(LocalDateTime.now())) {
throw failure(409, "Goal continuation owner is no longer current");
}
var leases = jdbc.query("""
SELECT goal_id,conversation_id,lease_token,state,lease_until FROM mate_goal_attempt WHERE attempt_id=? FOR UPDATE
""", (r, i) -> r.getLong("goal_id") == goal.id()
&& Objects.equals(r.getString("conversation_id"), goal.conversationId())
&& Objects.equals(r.getString("lease_token"), attribution.ownerFence())
&& List.of("claimed", "running").contains(r.getString("state"))
? r.getTimestamp("lease_until").toLocalDateTime() : null, attribution.goalAttemptId());
if (leases.size() != 1 || leases.getFirst() == null || !leases.getFirst().isAfter(LocalDateTime.now())) {
throw failure(409, "Goal attempt owner fence is no longer current");
}
return new RuntimeScope(goal, "goal-attempt", attribution.goalAttemptId(),
leases.getFirst().isBefore(continuationLeases.getFirst()) ? leases.getFirst() : continuationLeases.getFirst());
}
// Caller must hold the authorized goal lock in the same transaction.
Artifact publishLocked(GoalJsonAcceptanceService.GoalScope goal, String slot, PublishRequest request,
String producerKind, String producerId) {

View File

@ -134,6 +134,8 @@ public class DelegateAgentTool {
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
"getManagedGoalJsonSlots",
"publishManagedGoalJson",
"waitForGoalInput",
// Employee authoring spawns persistent agents; a delegated child
// doing so risks recursive team creation and privilege creep, so

View File

@ -0,0 +1,41 @@
package vip.mate.tool.builtin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.goal.service.ManagedGoalJsonService;
/** Runtime may produce versions, but only the authenticated user can configure requirements. */
@Component
@RequiredArgsConstructor
public class ManagedGoalJsonTool {
private final ManagedGoalJsonService artifacts;
private final ObjectMapper json;
@Tool(description = "Read the current conversation goal's managed JSON artifact slots and generations. "
+ "Only user-selected slots appear. Preserve generation strings exactly. This does not check or complete the goal.")
public String getManagedGoalJsonSlots(ToolContext context) throws JsonProcessingException {
return json.writeValueAsString(artifacts.listForRuntime(ChatOrigin.from(context)));
}
@Tool(description = "Publish a new immutable JSON object version to a user-selected slot of the current goal. "
+ "Read current slots first; use generation 0 for an empty slot. Maximum 1 MiB UTF-8 per version, "
+ "32 versions per goal, valid for 24 hours. Reload on generation conflict. "
+ "Publishing does not check requirements or complete the goal; workspace files and textual claims are not substitutes.")
public String publishManagedGoalJson(
@ToolParam(description = "An existing user-selected artifact slot") String artifactSlot,
@ToolParam(description = "Exact current generation string, or 0 for an empty slot") String expectedGeneration,
@ToolParam(description = "Raw strict JSON object content; not a file path") String jsonContent,
ToolContext context) throws JsonProcessingException {
Long generation;
try { generation = Long.valueOf(expectedGeneration); }
catch (RuntimeException invalid) { throw new vip.mate.exception.MateClawException(400, "A valid expectedGeneration is required"); }
return json.writeValueAsString(artifacts.publishForRuntime(ChatOrigin.from(context), artifactSlot,
new ManagedGoalJsonService.PublishRequest(generation, jsonContent)));
}
}

View File

@ -17,3 +17,9 @@ Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conver
Publication requires an active or paused goal and a slot referenced by a current requirement. Content must be a strict JSON object: duplicate keys, trailing documents, nesting beyond 32 levels and UTF-8 content over 1 MiB are rejected. Each goal can retain at most 32 versions; the limit rejects new publication instead of overwriting history. Each version expires after 24 hours. Republishing identical bytes still creates a new version. Reload after a generation conflict rather than automatically overwriting another publication.
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. MySQL and Kingbase/PostgreSQL migrations have not yet been exercised against external database instances; H2 service integration tests do not establish that coverage.
## Agent publication
`getManagedGoalJsonSlots` returns current user requirements, slots and generations. `publishManagedGoalJson` accepts `artifactSlot`, a string `expectedGeneration` and `jsonContent`. Tools cannot configure requirements or supply goal IDs, accounts or owner fences. Interactive sessions require the authenticated account's internal ID. Scheduled persistent-goal execution must match the current continuation, attempt, owner token and live leases. Both paths recheck the conversation, workspace, agent and enabled account. The default delegation deny list includes both tools; the service still independently validates identity.
Publication and scheduler settlement serialize through the goal lock, rejecting late writes by former owners. Ending a lease does not mutate previously published versions. Anonymous sessions and cron runs without a bound goal attempt are outside this publication protocol. Missing identity is rejected instead of trusting a display username.

View File

@ -17,3 +17,9 @@
仅当前要求引用的槽可发布Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取,不自动覆盖他人发布。
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。当前未对 MySQL、Kingbase/PostgreSQL 实例执行迁移验收H2 服务集成测试不等于外部数据库验证。
## 代理发布
`getManagedGoalJsonSlots` 返回当前 Goal 的用户要求、槽和 generation`publishManagedGoalJson` 接收 `artifactSlot`、字符串 `expectedGeneration``jsonContent`。工具不能配置要求,也不能传 Goal ID、账户或 owner fence。普通会话必须携带已认证账户的内部 ID持久 Goal 的调度执行必须同时匹配当前 continuation、attempt、owner token 和有效租约。两种入口都重新检查对话、工作区、Agent 和启用账户。代理委派的默认禁止列表包含这两个工具,服务仍独立检查身份。
发布与调度结算按 Goal 锁串行化,晚到的旧 owner 不得继续写入。租约结束不会改写已经合法发布的历史版本。匿名会话和没有绑定 Goal attempt 的 cron 不支持此发布协议;身份缺失直接拒绝,不以显示用户名代替认证。

View File

@ -38,6 +38,10 @@ class GoalJsonAcceptanceIntegrationTest {
@Autowired private vip.mate.goal.service.ManagedGoalJsonService artifacts;
@Autowired private JdbcTemplate jdbc;
@Autowired private PlatformTransactionManager transactions;
@Autowired private vip.mate.tool.builtin.ManagedGoalJsonTool managedTool;
@Autowired private vip.mate.goal.service.GoalContinuationStore continuations;
@Autowired private vip.mate.goal.service.GoalRunCoordinator coordinator;
private String alice;
private String bob;
@ -251,4 +255,105 @@ class GoalJsonAcceptanceIntegrationTest {
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice));
}
private vip.mate.agent.context.ChatOrigin accountOrigin(GoalEntity goal, String username) {
Long userId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=?", Long.class, username);
return vip.mate.agent.context.ChatOrigin.web(goal.getConversationId(), username, goal.getWorkspaceId(), null, null, userId)
.withAgent(goal.getAgentId());
}
@Test void actualManagedToolUsesServerAccountContextAndExposesRequirements() throws Exception {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var origin = accountOrigin(goal, alice);
var callbacks = org.springframework.ai.support.ToolCallbacks.from(managedTool);
assertEquals(2, callbacks.length);
for (var callback : callbacks) {
String schema = callback.getToolDefinition().inputSchema();
assertFalse(schema.contains("\"goalId\""));
assertFalse(schema.contains("\"ownerFence\""));
assertFalse(schema.contains("\"context\""));
assertFalse(schema.contains("\"requiredFields\""));
}
assertTrue(managedTool.getManagedGoalJsonSlots(origin.toToolContext()).contains("summary"));
String result = managedTool.publishManagedGoalJson("report", "0", "{\"summary\":false}", origin.toToolContext());
assertTrue(result.contains("account-runtime"));
assertTrue(result.contains("\"generation\":\"1\""));
assertThrows(MateClawException.class, () -> managedTool.publishManagedGoalJson("report", "1", "{}", accountOrigin(goal, bob).toToolContext()));
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin.withAgent(999L), "report", publication(1, "{}")));
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin.withWorkspace(999L, null), "report", publication(1, "{}")));
var anonymous = vip.mate.agent.context.ChatOrigin.web(goal.getConversationId(), alice, 1L, null).withAgent(1L);
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(anonymous, "report", publication(1, "{}")));
assertEquals(1, artifacts.list(goal.getId(), alice).getFirst().generation());
}
private vip.mate.goal.service.GoalRunCoordinator.ClaimedRun claimed(GoalEntity goal) {
jdbc.update("UPDATE mate_agent_goal SET auto_followup_enabled=TRUE WHERE id=?", goal.getId());
var now = java.time.LocalDateTime.now();
continuations.discover(now);
var run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), now);
assertNotNull(run);
assertTrue(coordinator.markRunning(run, now));
return run;
}
private vip.mate.agent.context.ChatOrigin attemptOrigin(GoalEntity goal, vip.mate.goal.service.GoalRunCoordinator.ClaimedRun run) {
return vip.mate.agent.context.ChatOrigin.web(goal.getConversationId(), alice, 1L, null).withAgent(1L)
.withExecutionAttribution(new vip.mate.agent.context.ExecutionAttribution(goal.getId(), run.attempt().id(), null, null, run.attempt().leaseToken()));
}
@Test void actualSchedulerOwnerCanPublishButWrongExpiredAndSupersededOwnersCannot() {
GoalEntity goal = goal(true);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var run = claimed(goal);
var origin = attemptOrigin(goal, run);
var version = artifacts.publishForRuntime(origin, "report", publication(0, "{}"));
assertEquals("goal-attempt", version.producerKind());
var wrong = origin.withExecutionAttribution(new vip.mate.agent.context.ExecutionAttribution(goal.getId(), run.attempt().id(), null, null, "forged"));
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(wrong, "report", publication(1, "{}")));
var foreign = origin.withConversationId(goal(false).getConversationId());
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(foreign, "report", publication(1, "{}")));
jdbc.update("UPDATE mate_goal_attempt SET lease_until=? WHERE attempt_id=?", java.time.LocalDateTime.now().minusSeconds(1), run.attempt().id());
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin, "report", publication(1, "{}")));
jdbc.update("UPDATE mate_goal_attempt SET lease_until=? WHERE attempt_id=?", java.time.LocalDateTime.now().plusMinutes(5), run.attempt().id());
jdbc.update("UPDATE mate_goal_continuation SET current_attempt_id=? WHERE goal_id=?", UUID.randomUUID().toString(), goal.getId());
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin, "report", publication(1, "{}")));
assertEquals(1, artifacts.list(goal.getId(), alice).getFirst().generation());
}
@Test void disabledOwnerAndIncompleteAttributionCannotUseSchedulerFallback() {
GoalEntity goal = goal(true);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var run = claimed(goal);
var origin = attemptOrigin(goal, run);
var incomplete = origin.withExecutionAttribution(new vip.mate.agent.context.ExecutionAttribution(goal.getId(), null, null, null, null));
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(incomplete, "report", publication(0, "{}")));
jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice);
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin, "report", publication(0, "{}")));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
}
@Test void schedulerSettlementAndPublicationSerializeWithoutLateOwnerWrites() throws Exception {
GoalEntity goal = goal(true);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var run = claimed(goal);
var origin = attemptOrigin(goal, run);
var start = new java.util.concurrent.CountDownLatch(1);
try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) {
var publish = workers.submit(() -> {
start.await();
try { artifacts.publishForRuntime(origin, "report", publication(0, "{}")); return true; }
catch (MateClawException ended) { return false; }
});
var settle = workers.submit(() -> {
start.await();
return coordinator.settle(run, new SegmentOutcome.Continue("fixture done"), java.time.LocalDateTime.now());
});
start.countDown();
boolean published = publish.get(10, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(settle.get(10, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(published ? 1 : 0, artifacts.list(goal.getId(), alice).getFirst().generation());
assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin, "report", publication(published ? 1 : 0, "{}")));
}
}
}

View File

@ -39,7 +39,7 @@ class GoalRecoveryServiceTest {
jdbc=new JdbcTemplate(ds);attempts=new GoalAttemptStore(jdbc);continuations=new GoalContinuationStore(jdbc);
inputs=new ConversationInputQueueStore(jdbc,new ObjectMapper());
coordinator=new GoalRunCoordinator(continuations,attempts,goals,new vip.mate.goal.config.GoalProperties());
recovery=new GoalRecoveryService(attempts,continuations,inputs,goals);
recovery=new GoalRecoveryService(attempts,continuations,inputs,goals,new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
jdbc.update("""
INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title,
description,status,persistent_execution,auto_followup_enabled,create_time,update_time)
@ -86,6 +86,17 @@ class GoalRecoveryServiceTest {
verify(goals).pause(1L,"alice");
}
@Test void recoveryFailureRollsBackAttemptAndContinuationTogether() {
var old=coordinator.claim(continuations.get(1L),goal,now);
assertTrue(coordinator.markRunning(old,now));
assertTrue(coordinator.checkpoint(old,"uncertain","tool_started",null,now.plusSeconds(1)));
doThrow(new IllegalStateException("fixture pause failure")).when(goals).pause(1L,"alice");
assertThrows(IllegalStateException.class, () -> recovery.recoverExpired(now.plusSeconds(61)));
assertEquals("running",attempts.get(old.attempt().id()).state());
assertEquals("running",continuations.get(1L).state());
assertEquals(old.attempt().id(),continuations.get(1L).currentAttemptId());
}
private GoalAttempt attempt(String checkpoint,String safety,Long messageId) {
return new GoalAttempt("a",1L,"conv",null,"continuation","running","lease",now,
null,messageId,safety,checkpoint,null,null,now,null,now,now);

View File

@ -78,7 +78,7 @@ class DelegateAgentToolDenyListTest {
// Memory writers (canonical Spring AI tool method names do not include
// any speculative names that would silently no-op).
assertThat(defaults).contains("remember", "remember_structured", "forget_structured");
assertThat(defaults).contains("waitForGoalInput");
assertThat(defaults).contains("waitForGoalInput", "getManagedGoalJsonSlots", "publishManagedGoalJson");
// Shell stays out by design see comment on DEFAULT_CHILD_DENIED_TOOLS.
assertThat(defaults).doesNotContain("execute_shell_command");
}