Add exact consumed approval to fenced Goal attempt handoff

This commit is contained in:
mateaix 2026-09-15 02:57:43 +08:00
parent 8a15c0b573
commit 6ccf8befa0
13 changed files with 220 additions and 10 deletions

View File

@ -0,0 +1,99 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.exception.MateClawException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
/** A consumed approval may start one new owner; it never revives its original lease. */
@Service
public class GoalApprovalRunService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
private final GoalContinuationStore continuations;
private final GoalAttemptStore attempts;
private final GoalRunCoordinator coordinator;
private final GoalService goals;
public GoalApprovalRunService(JdbcTemplate jdbc, ObjectMapper json, GoalJsonAcceptanceService acceptance,
ManagedGoalJsonService artifacts, GoalContinuationStore continuations,
GoalAttemptStore attempts, GoalRunCoordinator coordinator, GoalService goals) {
this.jdbc=jdbc; this.json=json; this.acceptance=acceptance; this.artifacts=artifacts;
this.continuations=continuations; this.attempts=attempts; this.coordinator=coordinator; this.goals=goals;
}
public record ReplayRun(GoalRunCoordinator.ClaimedRun run, ChatOrigin origin) { }
@Transactional
public ReplayRun claim(ChatOrigin requested, String toolCallPayload) {
ExecutionAttribution link = requested == null ? null : requested.executionAttribution();
if (link == null || link.goalId() == null || link.goalAttemptId() == null
|| link.ownerFence() == null || link.approvalId() == null) throw rejected();
// Preserve the normal user -> conversation -> Goal lock order.
var owners = jdbc.queryForList("SELECT username FROM mate_conversation WHERE conversation_id=? AND deleted=0",
String.class, requested.conversationId());
if (owners.size()!=1) throw rejected();
var scope = acceptance.authorizedGoal(link.goalId(), owners.getFirst(), true);
if (!scope.required() || !"active".equals(scope.status())) throw rejected();
var candidate = continuations.getForUpdate(link.goalId());
if (candidate == null || !"waiting_approval".equals(candidate.state())) throw rejected();
var parent = attempts.getForUpdate(link.goalAttemptId());
if (parent == null || !Objects.equals(parent.goalId(), link.goalId())
|| !Objects.equals(parent.conversationId(), requested.conversationId())
|| !Objects.equals(parent.leaseToken(), link.ownerFence()) || !"succeeded".equals(parent.state())) throw rejected();
var approvals = jdbc.query("""
SELECT conversation_id,agent_id,status,tool_call_payload,chat_origin
FROM mate_tool_approval WHERE pending_id=? AND deleted=0 FOR UPDATE
""", (r,i) -> new Approval(r.getString("conversation_id"), r.getString("agent_id"),
r.getString("status"), r.getString("tool_call_payload"), r.getString("chat_origin")), link.approvalId());
if (approvals.size()!=1) throw rejected();
var approval = approvals.getFirst();
if (!"CONSUMED".equals(approval.status()) || !Objects.equals(toolCallPayload, approval.payload())
|| !Objects.equals(requested.conversationId(), approval.conversationId())
|| !Objects.equals(String.valueOf(requested.agentId()), approval.agentId())) throw rejected();
ChatOrigin persisted;
try { persisted = json.readValue(approval.origin(), ChatOrigin.class); }
catch (Exception error) { throw rejected(); }
if (persisted == null || persisted.executionAttribution() == null
|| !Objects.equals(persisted.executionAttribution().goalId(), link.goalId())
|| !Objects.equals(persisted.executionAttribution().goalAttemptId(), link.goalAttemptId())
|| !Objects.equals(persisted.executionAttribution().ownerFence(), link.ownerFence())
|| !Objects.equals(persisted.agentId(), requested.agentId())
|| !Objects.equals(persisted.workspaceId(), requested.workspaceId())
|| !Objects.equals(persisted.conversationId(), requested.conversationId())) throw rejected();
if (!jdbc.queryForList("SELECT attempt_id FROM mate_goal_attempt WHERE approval_pending_id=? FOR UPDATE",
String.class, link.approvalId()).isEmpty()) throw rejected();
Instant instant = Instant.now();
LocalDateTime now = LocalDateTime.ofInstant(instant, java.time.ZoneId.systemDefault());
long untilEpoch = instant.getEpochSecond()+60;
String token = UUID.randomUUID().toString();
if (!continuations.claimApproval(link.goalId(), parent.id(), token, now, untilEpoch)) throw rejected();
var claimed = continuations.get(link.goalId());
var attempt = attempts.create(link.goalId(), requested.conversationId(), parent.id(), "approval",
token, GoalLeaseTime.local(untilEpoch), null, now, untilEpoch);
jdbc.update("UPDATE mate_goal_attempt SET approval_pending_id=? WHERE attempt_id=?", link.approvalId(), attempt.id());
if (!continuations.bindAttempt(link.goalId(), token, attempt.id(), claimed.revision())) throw rejected();
var run = new GoalRunCoordinator.ClaimedRun(candidate, goals.getById(link.goalId()), attempt, claimed.revision()+1);
if (!coordinator.markRunning(run, now)) throw rejected();
ChatOrigin origin = persisted.withBaseUrl(requested.baseUrl()).withExecutionAttribution(
new ExecutionAttribution(link.goalId(), attempt.id(), null, link.approvalId(), token));
// Recheck current conversation scope and both new lease rows before committing the claim.
ManagedGoalJsonService.verifyLease(artifacts.runtimeGoal(origin));
return new ReplayRun(run, origin);
}
private record Approval(String conversationId, String agentId, String status, String payload, String origin) { }
private static MateClawException rejected() {
return new MateClawException(409, "Approved Goal execution cannot acquire a current owner; resume from current Goal state");
}
}

View File

@ -43,9 +43,17 @@ public class GoalAttemptStore {
}
public GoalAttempt get(String id) {
return get(id, false);
}
GoalAttempt getForUpdate(String id) {
return get(id, true);
}
private GoalAttempt get(String id, boolean lock) {
List<GoalAttempt> rows = jdbc.query("""
SELECT * FROM mate_goal_attempt WHERE attempt_id=?
""", (rs, row) -> read(rs), id);
""" + (lock ? " FOR UPDATE" : ""), (rs, row) -> read(rs), id);
return rows.isEmpty() ? null : rows.getFirst();
}

View File

@ -67,10 +67,18 @@ public class GoalContinuationStore {
}
public Continuation get(Long goalId) {
return get(goalId, false);
}
Continuation getForUpdate(Long goalId) {
return get(goalId, true);
}
private Continuation get(Long goalId, boolean lock) {
List<Continuation> rows = jdbc.query("""
SELECT c.*,g.conversation_id FROM mate_goal_continuation c
JOIN mate_agent_goal g ON g.id=c.goal_id WHERE c.goal_id=?
""", (rs, row) -> read(rs), goalId);
""" + (lock ? " FOR UPDATE" : ""), (rs, row) -> read(rs), goalId);
return rows.isEmpty() ? null : rows.getFirst();
}
@ -81,7 +89,7 @@ public class GoalContinuationStore {
boolean claim(Long goalId, String token, LocalDateTime now, LocalDateTime until, long nowEpoch, long untilEpoch) {
return jdbc.update("""
UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,lease_until_epoch_second=?,updated_at=?,
wake_requested=FALSE,revision=revision+1
wake_requested=FALSE,waiting_approval_attempt_id=NULL,revision=revision+1
WHERE goal_id=? AND
((state IN ('queued','retry') AND next_run_at<=?)
OR (state='running' AND lease_until_epoch_second<=?))
@ -96,6 +104,17 @@ public class GoalContinuationStore {
""", until, GoalLeaseTime.epoch(until), goalId, token) == 1;
}
boolean claimApproval(Long goalId, String parentAttemptId, String token, LocalDateTime now, long untilEpoch) {
return jdbc.update("""
UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,
lease_until_epoch_second=?,updated_at=?,wake_requested=FALSE,
waiting_approval_attempt_id=NULL,revision=revision+1
WHERE goal_id=? AND state='waiting_approval' AND waiting_approval_attempt_id=?
AND current_attempt_id IS NULL AND lease_owner IS NULL
AND EXISTS(SELECT 1 FROM mate_agent_goal g WHERE g.id=goal_id AND
""" + ELIGIBLE + ")", token, GoalLeaseTime.local(untilEpoch), untilEpoch, now, goalId, parentAttemptId) == 1;
}
public boolean bindAttempt(Long goalId, String token, String attemptId, long expectedRevision) {
return jdbc.update("""
UPDATE mate_goal_continuation SET current_attempt_id=?,revision=revision+1,updated_at=?
@ -130,9 +149,10 @@ public class GoalContinuationStore {
UPDATE mate_goal_continuation
SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END,
next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,
waiting_approval_attempt_id=CASE WHEN ?='waiting_approval' THEN current_attempt_id ELSE NULL END,
current_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND revision=? AND state='running'
""",state,state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,revision)==1;
""",state,state,nextRunAt,failures,bounded(reason),state,now,goalId,token,attemptId,revision)==1;
}
/** Current read under the caller's goal lock, before recovery mutates its attempt. */
@ -149,7 +169,8 @@ public class GoalContinuationStore {
return jdbc.update("""
UPDATE mate_goal_continuation
SET state=?,next_run_at=?,failures=?,reason=?,wake_requested=FALSE,
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,revision=revision+1,updated_at=?
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,
waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND state='running'
AND lease_until_epoch_second<=?
""",state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,expiredEpoch)==1;
@ -167,7 +188,7 @@ public class GoalContinuationStore {
public void suspendConversation(String conversationId, String reason) {
jdbc.update("""
UPDATE mate_goal_continuation SET state='paused',reason=?,lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,
current_attempt_id=NULL,revision=revision+1,updated_at=?
current_attempt_id=NULL,waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id IN (SELECT id FROM mate_agent_goal WHERE conversation_id=?)
""", bounded(reason), LocalDateTime.now(), conversationId);
}
@ -175,7 +196,8 @@ public class GoalContinuationStore {
public void resume(Long goalId, LocalDateTime now) {
jdbc.update("""
UPDATE mate_goal_continuation SET state='queued',next_run_at=?,failures=0,reason='resumed',
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,revision=revision+1,updated_at=?
lease_owner=NULL,lease_until=NULL,lease_until_epoch_second=0,current_attempt_id=NULL,
waiting_approval_attempt_id=NULL,revision=revision+1,updated_at=?
WHERE goal_id=? AND state<>'running'
""", now, now, goalId);
}

View File

@ -0,0 +1,5 @@
-- Exact durable handoff from a settled approval to one newly fenced attempt.
-- Older waiting rows remain unbound; never guess the originating attempt.
ALTER TABLE mate_goal_continuation ADD COLUMN waiting_approval_attempt_id VARCHAR(36) NULL;
ALTER TABLE mate_goal_attempt ADD COLUMN approval_pending_id VARCHAR(64) NULL;
CREATE UNIQUE INDEX uq_goal_attempt_approval ON mate_goal_attempt(approval_pending_id);

View File

@ -0,0 +1,5 @@
-- Exact durable handoff from a settled approval to one newly fenced attempt.
-- Older waiting rows remain unbound; never guess the originating attempt.
ALTER TABLE mate_goal_continuation ADD COLUMN waiting_approval_attempt_id VARCHAR(36) NULL;
ALTER TABLE mate_goal_attempt ADD COLUMN approval_pending_id VARCHAR(64) NULL;
CREATE UNIQUE INDEX uq_goal_attempt_approval ON mate_goal_attempt(approval_pending_id);

View File

@ -0,0 +1,5 @@
-- Exact durable handoff from a settled approval to one newly fenced attempt.
-- Older waiting rows remain unbound; never guess the originating attempt.
ALTER TABLE mate_goal_continuation ADD COLUMN waiting_approval_attempt_id VARCHAR(36) NULL;
ALTER TABLE mate_goal_attempt ADD COLUMN approval_pending_id VARCHAR(64) NULL;
CREATE UNIQUE INDEX uq_goal_attempt_approval ON mate_goal_attempt(approval_pending_id);

View File

@ -68,3 +68,5 @@ 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.
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. A fresh attempt can reuse still-eligible evidence, but automatic transfer of a settled approval to a new lease is not yet provided; interactive approval verification does not cover this background path.
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. The controlled claim service is verified; automatic replay integration and lifecycle verification are still in progress.

View File

@ -70,3 +70,5 @@ JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户
交互式Web审批后的Plan执行会恢复原计划和已批准调用并保留请求者及受管验收要求审批通过本身不能替代JSON检查或完成目标。
后台 Goal 进入待审批并结算后,原 attempt 的租约已经释放;其持久化审批身份不能再读写托管产物或完成 Goal。新 attempt 可复用仍合格的已有版本,但当前尚未提供已结算审批到新租约的自动接续,不能将交互式审批验证视为这一后台路径已通过。
V200 保存待审批结算对应的准确 attempt并对审批生成的新 attempt 设置唯一关联。升级前的待审批行保持未绑定,不能推断为任何新的执行权限。受控领取服务已验证,但审批流的自动接入与生命周期验证仍在进行。

View File

@ -46,6 +46,7 @@ class GoalJsonAcceptanceIntegrationTest {
@Autowired private vip.mate.goal.service.GoalRecoveryService recovery;
@Autowired private vip.mate.goal.service.GoalAttemptStore attempts;
@Autowired private vip.mate.approval.ApprovalWorkflowService approvals;
@Autowired private vip.mate.goal.service.GoalApprovalRunService approvalRuns;
private String alice;
private String bob;
@ -746,6 +747,63 @@ class GoalJsonAcceptanceIntegrationTest {
: goals.markRuntimeCompleted(goal.getId(), evaluation, origin);
}
@ParameterizedTest @ValueSource(strings = {"valid", "pending", "payload", "paused", "running", "legacy", "archived", "agent", "disabled", "wrong-parent"})
void consumedApprovalCanClaimOnlyItsExactWaitingGoalOnce(String kind) 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 payload = "[{\"name\":\"getManagedGoalJsonSlots\",\"arguments\":\"{}\"}]";
String pending;
vip.mate.agent.context.ChatOriginHolder.set(origin);
try {
pending = approvals.createPending(goal.getConversationId(), alice, "getManagedGoalJsonSlots", "{}",
"offline exact handoff fixture", payload, null, "1");
} finally { vip.mate.agent.context.ChatOriginHolder.clear(); }
if (!kind.equals("running")) assertTrue(coordinator.settle(original,
new SegmentOutcome.AwaitApproval("approval_required"), java.time.LocalDateTime.now()));
if (!kind.equals("pending")) assertNotNull(approvals.resolveAndConsume(pending, alice).consumedSnapshot());
if (kind.equals("paused")) goals.pause(goal.getId(), alice);
if (kind.equals("legacy")) jdbc.update("UPDATE mate_goal_continuation SET waiting_approval_attempt_id=NULL WHERE goal_id=?", goal.getId());
if (kind.equals("wrong-parent")) jdbc.update("UPDATE mate_goal_continuation SET waiting_approval_attempt_id=? WHERE goal_id=?", UUID.randomUUID().toString(), goal.getId());
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("disabled")) jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice);
var replay = origin.withApprovalId(pending);
if (!kind.equals("valid")) {
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()),
"Rejected or late-scope-invalid handoff must roll back the new attempt");
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE approval_pending_id=?", Integer.class, pending));
return;
}
// Competing deliveries of one already-consumed approval must produce exactly one new owner.
try (var pool = java.util.concurrent.Executors.newFixedThreadPool(2)) {
var start = new java.util.concurrent.CountDownLatch(1);
java.util.concurrent.Callable<vip.mate.goal.service.GoalApprovalRunService.ReplayRun> invoke = () -> {
start.await();
try { return approvalRuns.claim(replay, payload); }
catch (MateClawException rejected) { return null; }
};
var first = pool.submit(invoke); var second = pool.submit(invoke); start.countDown();
var a = first.get(10, java.util.concurrent.TimeUnit.SECONDS);
var b = second.get(10, java.util.concurrent.TimeUnit.SECONDS);
assertNotEquals(a == null, b == null, "Exactly one delivery acquires the new owner");
var fresh = a != null ? a : b;
assertEquals(original.attempt().id(), fresh.run().attempt().parentAttemptId());
assertNotEquals(original.attempt().leaseToken(), fresh.run().attempt().leaseToken());
assertEquals(pending, fresh.origin().executionAttribution().approvalId());
assertTrue(coordinator.renew(fresh.run(), java.time.LocalDateTime.now()));
assertThrows(MateClawException.class, () -> bindings.snapshotForRuntime(replay));
var version = artifacts.publishForRuntime(fresh.origin(), "report", publication(0, "{\"summary\":false}"));
assertEquals("goal-attempt", version.producerKind());
assertTrue(bindings.checkForRuntime(fresh.origin(), "r", checkRequest(1, version)).acceptanceEligible());
assertTrue(coordinator.settle(fresh.run(), new SegmentOutcome.Continue("approval_fixture_done"), java.time.LocalDateTime.now()));
assertThrows(MateClawException.class, () -> approvalRuns.claim(replay, payload));
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_attempt WHERE goal_id=?", Integer.class, goal.getId()));
}
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void approvalAfterSettledAttemptCannotReviveFenceButFreshAttemptCanReuseEvidence(boolean automatic) {
GoalEntity goal = goal(true);

View File

@ -26,7 +26,8 @@ class GoalAttemptStoreTest {
new ClassPathResource("db/migration/h2/V120__agent_goal.sql"),
new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"),
new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql"),
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql"))
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql"),
new ClassPathResource("db/migration/h2/V200__goal_approval_attempt_handoff.sql"))
.execute(dataSource);
store = new GoalAttemptStore(new JdbcTemplate(dataSource));
}

View File

@ -24,7 +24,8 @@ class GoalContinuationStoreTest {
new ClassPathResource("db/migration/h2/V120__agent_goal.sql"),
new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"),
new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql"),
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql")).execute(ds);
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql"),
new ClassPathResource("db/migration/h2/V200__goal_approval_attempt_handoff.sql")).execute(ds);
jdbc = new JdbcTemplate(ds);
store = new GoalContinuationStore(jdbc);
}

View File

@ -37,6 +37,7 @@ class GoalRecoveryServiceTest {
new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"),
new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql"),
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql"),
new ClassPathResource("db/migration/h2/V200__goal_approval_attempt_handoff.sql"),
new ClassPathResource("db/migration/h2/V199__queued_input_account_identity.sql")).execute(ds);
jdbc=new JdbcTemplate(ds);attempts=new GoalAttemptStore(jdbc);continuations=new GoalContinuationStore(jdbc);
inputs=new ConversationInputQueueStore(jdbc,new ObjectMapper());

View File

@ -34,7 +34,8 @@ class GoalRunCoordinatorTest {
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V120__agent_goal.sql"),
new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"),
new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql"),
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql")).execute(ds);
new ClassPathResource("db/migration/h2/V198__goal_absolute_owner_leases.sql"),
new ClassPathResource("db/migration/h2/V200__goal_approval_attempt_handoff.sql")).execute(ds);
jdbc=new JdbcTemplate(ds);continuations=new GoalContinuationStore(jdbc);attempts=new GoalAttemptStore(jdbc);
coordinator=new GoalRunCoordinator(continuations,attempts,goals,properties,java.time.Clock.fixed(now.atZone(java.time.ZoneId.systemDefault()).toInstant(), java.time.ZoneId.systemDefault()));
jdbc.update("""