feat: require current managed JSON bindings at goal completion

This commit is contained in:
mateaix 2026-09-14 21:46:16 +08:00
parent 5cc8765681
commit 4277172529
5 changed files with 162 additions and 10 deletions

View File

@ -112,6 +112,31 @@ public class GoalJsonBindingService {
}).toList();
}
/** Shared completion gate. The held goal lock protects every reference until the status CAS commits. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<State> requireForCompletion(vip.mate.goal.model.GoalEntity expected) {
var rows = jdbc.query("""
SELECT version,evaluation_revision,json_acceptance_required,status FROM mate_agent_goal
WHERE id=? AND deleted=0 FOR UPDATE
""", (r, i) -> expected.getVersion() != null && r.getLong("version") == expected.getVersion().longValue()
&& r.getLong("evaluation_revision") == expected.getEvaluationRevision()
&& r.getBoolean("json_acceptance_required")
&& Objects.equals(r.getString("status"), expected.getStatus().getValue()), expected.getId());
if (rows.size() != 1 || !rows.getFirst()) throw failure("Goal changed before JSON completion; retry with current state");
List<State> states = statesLocked(expected.getId());
if (states.isEmpty()) throw failure("Required JSON contracts are unavailable");
for (State state : states) {
if (!state.acceptanceEligible()) throw failure("JSON requirement " + state.criterionKey() + " is not current: " + state.status());
}
// Recheck expiry after all recipes have run, immediately before returning to the status CAS.
Instant now = Instant.now();
for (State state : states) {
Binding binding = binding(expected.getId(), state.criterionKey());
if (binding == null || !binding.expiresAt().isAfter(now)) throw failure("JSON binding expired before completion");
}
return states;
}
private long evaluationRevision(Long goalId) {
Long revision = jdbc.queryForObject("SELECT evaluation_revision FROM mate_agent_goal WHERE id=? AND deleted=0 FOR UPDATE", Long.class, goalId);
if (revision == null) throw failure("Goal definition unavailable");

View File

@ -72,6 +72,12 @@ public class GoalServiceImpl implements GoalService {
* effort: memory should never block the state-machine write.
*/
private vip.mate.memory.spi.MemoryManager memoryManager;
private GoalJsonBindingService jsonBindings;
@Autowired
public void setJsonBindings(GoalJsonBindingService jsonBindings) {
this.jsonBindings = jsonBindings;
}
public GoalServiceImpl(GoalMapper goalMapper,
GoalEventMapper eventMapper,
@ -390,9 +396,11 @@ public class GoalServiceImpl implements GoalService {
private GoalEntity completeGoal(Long id, GoalEvaluationResult result, boolean evaluated) {
boolean[] transitioned = {false};
var jsonProof = new java.util.concurrent.atomic.AtomicReference<List<GoalJsonBindingService.State>>(List.of());
GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
// A failed CAS may retry against another worker's completed row.
transitioned[0] = false;
jsonProof.set(List.of());
if (fresh.getStatus().isTerminal()) {
if (evaluated && fresh.getStatus() != GoalStatus.COMPLETED) {
throw new MateClawException("err.goal.completion_not_verified", 409,
@ -400,12 +408,6 @@ public class GoalServiceImpl implements GoalService {
}
return null; // idempotent
}
if (fresh.isJsonAcceptanceRequired()) {
// User-selected requirements never fall back to model text or
// the legacy explicit-completion path while bindings are absent.
throw new MateClawException("err.goal.json_acceptance_required", 409,
"Managed JSON acceptance requires verified current artifact bindings");
}
if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires the current evaluation definition revision");
@ -435,6 +437,11 @@ public class GoalServiceImpl implements GoalService {
.toList();
w.set(GoalEntity::getCriteria, GoalCriteriaCodec.serialize(allPassed, objectMapper));
}
if (fresh.isJsonAcceptanceRequired()) {
if (jsonBindings == null) throw new MateClawException("err.goal.json_acceptance_required", 409,
"Managed JSON verification service is unavailable");
jsonProof.set(jsonBindings.requireForCompletion(fresh));
}
bumpVersionAndTime(w);
transitioned[0] = true;
return w;
@ -445,6 +452,10 @@ public class GoalServiceImpl implements GoalService {
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
if (g.isJsonAcceptanceRequired()) {
detail.put("jsonAcceptanceRequired", true);
detail.put("jsonBindings", jsonProof.get());
}
writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, detail);

View File

@ -2,7 +2,7 @@
Expand JSON acceptance requirements in the Goal panel. The conversation owner or an administrator explicitly saves up to eight requirements, each mapping an artifact slot to 116 top-level fields. Fields must exist and be non-null; false, zero and empty strings are allowed. This is a presence check, not a quality judgment. Opt-in is durable: requirements can be revised using their current revision, but required mode cannot be disabled. Stale revisions produce a conflict.
The current stage provides user configuration and independent managed version storage. Binding verification is available; the successful strong-completion path is still pending. Selected goals temporarily reject completion without falling back to textual claims. Unselected goals retain existing behavior.
User configuration, independent managed versions, binding checks and the shared completion gate are connected. Every current requirement needs a matching valid binding before a selected goal can complete under its existing completion rules. Automatic evaluation, explicit completeGoal and retries share that gate. Unselected goals retain existing behavior.
## Managed version API
@ -29,4 +29,6 @@ Publication and scheduler settlement serialize through the goal lock, rejecting
After publication, call `POST /checks/{criterionKey}` with `expectedRequirementRevision`, `artifactId` and `expectedGeneration`, or use the agent tool `checkManagedGoalJson` with the same fields. Tool revisions and generations are strings. The server checks the specified current slot version using its own fields recipe; it never accepts a caller-provided PASS. `acceptanceEligible=true` applies to that requirement at the time of checking, not to whole-goal completion.
`GET /checks` reads each requirement's current eligibility. Requirement edits, goal-definition edits, a new slot version, expiry or failed body integrity checks invalidate previous bindings. Recheck the current inputs. Binding and goal-version updates share a transaction; rollback cannot leave a passing credential. Historical diagnostic APIs retain `acceptanceEligible=false`; only managed checks create bindings. Integration with the shared completion entry point is still pending.
`GET /checks` reads each requirement's current eligibility. Requirement edits, goal-definition edits, a new slot version, expiry or failed body integrity checks invalidate previous bindings. Recheck the current inputs. Binding and goal-version updates share a transaction; rollback cannot leave a passing credential. Historical diagnostic APIs retain `acceptanceEligible=false`; only managed checks create bindings. Completion events retain the accepted requirement revisions, artifact IDs and generations. Transaction rollback emits neither a completion event nor completion memory.
This is an explicit per-goal managed JSON protocol with a limited scope. The broad execution-evidence ledger retains its existing prerequisites for global ENFORCE. Ordinary tool-success text and diagnostic MATCH results never become bindings automatically. Backend services cover success, invalidation, races and rollback; full browser/service flows, restart and external database validation are still in progress.

View File

@ -2,7 +2,7 @@
在 Goal 面板展开“JSON 验收要求”,由对话所有者或管理员显式保存要求。每个 Goal 最多 8 条要求,每条绑定一个产物槽和 116 个顶层字段。字段检查表示字段存在且不为 nullfalse、0 和空字符串允许,不等于内容质量判断。保存后不可关闭强验收模式,可以带当前 revision 修改要求;旧修订会返回冲突。
当前阶段已提供用户配置与独立受管版本存储;绑定检查已接通,强验收成功完成路径尚未接通。选中此模式的 Goal 暂时拒绝完成,不会回退到文字声明;未选中的 Goal 保持既有行为。
当前已接通用户配置、独立受管版本、绑定检查及共享完成检查。选中模式后,所有当前要求必须具有匹配的有效绑定,才可在既有完成规则满足时完成;自动评估、显式 completeGoal 和重试均使用同一完成检查。未选中的 Goal 保持既有行为。
## 受管版本接口
@ -29,4 +29,6 @@
发布后,调用 `POST /checks/{criterionKey}`,提交 `expectedRequirementRevision`、`artifactId`、`expectedGeneration`;代理使用 `checkManagedGoalJson` 传相同字段。所有修订和 generation 在代理工具里都是字符串。服务端只检查当前槽的指定版本,运行自己的字段 recipe不接受调用方提供的 PASS。返回 `acceptanceEligible=true` 表示这条要求当前匹配,不能代表整个 Goal 已完成。
`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。当前统一完成入口仍在接入中。
`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。完成事件保留本次受管绑定的条件修订、产物 ID 和 generation 引用;事务回滚不发布完成事件或完成记忆。
这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。

View File

@ -442,4 +442,116 @@ class GoalJsonAcceptanceIntegrationTest {
assertFalse(bindings.state(goal.getId(), alice).getFirst().acceptanceEligible());
}
@ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"false,false", "false,true", "true,false", "true,true"})
void currentManagedBindingsPermitBothCompletionPaths(boolean persistent, boolean automatic) {
GoalEntity goal = goal(persistent);
goals.appendCriterion(goal.getId(), "Produce the report", alice);
var evaluation = new GoalEvaluationResult(1, "report checked", "completed", true, "fixture", 1, 0,
List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "fixture semantic verdict")), null);
goals.recordEvaluation(goal.getId(), evaluation, 1, 1);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
GoalEntity completed = assertDoesNotThrow(() -> automatic
? goals.markEvaluatedCompleted(goal.getId(), evaluation) : goals.markCompleted(goal.getId(), evaluation));
assertEquals(GoalStatus.COMPLETED, completed.getStatus());
assertEquals(1, goals.listEvents(goal.getId(), 30).stream().filter(e -> "completed".equals(e.getEventType())).count());
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), evaluation).getStatus());
assertEquals(1, goals.listEvents(goal.getId(), 30).stream().filter(e -> "completed".equals(e.getEventType())).count());
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice));
}
@Test void allCurrentRequirementsMustBindBeforeCompletion() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
acceptance.configure(goal.getId(), "s", new GoalJsonAcceptanceService.ConfigureRequest(0L, "sources", List.of("items")), alice);
var report = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, report), alice);
assertThrows(MateClawException.class, () -> goals.markCompleted(goal.getId(), null));
var sources = artifacts.publish(goal.getId(), "sources", publication(0, "{\"items\":[]}"), alice);
assertThrows(MateClawException.class, () -> goals.markCompleted(goal.getId(), null));
bindings.check(goal.getId(), "s", checkRequest(1, sources), alice);
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus());
String proof = goals.listEvents(goal.getId(), 30).stream().filter(e -> "completed".equals(e.getEventType())).findFirst().orElseThrow().getDetailJson();
assertTrue(proof.contains(report.artifactId())); assertTrue(proof.contains(sources.artifactId()));
}
@Test void completionRejectsEveryInvalidationAndFreshBindingRestoresSuccess() {
for (String invalidation : List.of("requirement", "definition", "superseded", "expired", "corrupt")) {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true,\"sources\":[]}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
long revision = 1;
switch (invalidation) {
case "requirement" -> { acceptance.configure(goal.getId(), "r", request(1, "summary", "sources"), alice); revision = 2; }
case "definition" -> { GoalUpdateRequest edit = new GoalUpdateRequest(); edit.setDescription("new definition"); goals.update(goal.getId(), edit, alice); }
case "superseded" -> version = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":true}"), alice);
case "expired" -> jdbc.update("UPDATE mate_goal_json_artifact SET expires_at=? WHERE artifact_id=?", java.sql.Timestamp.from(java.time.Instant.now().minusSeconds(1)), version.artifactId());
case "corrupt" -> jdbc.update("UPDATE mate_goal_json_artifact SET json_body='{}' WHERE artifact_id=?", version.artifactId());
}
assertThrows(MateClawException.class, () -> goals.markCompleted(goal.getId(), null), invalidation);
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
if (List.of("expired", "corrupt").contains(invalidation)) version = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(revision, version), alice);
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus());
}
}
@Test void completionRollbackPreservesActiveGoalAndDoesNotEmitSuccessOrMemory() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus());
status.setRollbackOnly();
});
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
assertTrue(goals.listEvents(goal.getId(), 30).stream().noneMatch(e -> "completed".equals(e.getEventType())));
org.mockito.Mockito.verify(memory, org.mockito.Mockito.never()).syncAll(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus());
}
@Test void newPublicationAndCompletionCannotBothWinUsingAnOldBinding() throws Exception {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
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.publish(goal.getId(), "report", publication(1, "{}"), alice); return true; }
catch (MateClawException terminal) { return false; }
});
var complete = workers.submit(() -> {
start.await();
try { goals.markCompleted(goal.getId(), null); return true; }
catch (MateClawException stale) { return false; }
});
start.countDown();
boolean published = publish.get(10, java.util.concurrent.TimeUnit.SECONDS);
boolean completed = complete.get(10, java.util.concurrent.TimeUnit.SECONDS);
assertNotEquals(published, completed);
assertEquals(completed ? GoalStatus.COMPLETED : GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
assertEquals(published ? 2 : 1, artifacts.list(goal.getId(), alice).getFirst().generation());
}
}
@Test void actualExplicitCompletionToolCannotBypassBindingsButCanCompleteAfterCheck() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var properties = new vip.mate.goal.config.GoalProperties(); properties.setEnabled(true);
var tool = new vip.mate.tool.builtin.GoalManagementTool(goals, properties, new com.fasterxml.jackson.databind.ObjectMapper(), null);
var context = accountOrigin(goal, alice).toToolContext();
assertTrue(tool.completeGoal(context).contains("error"));
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
assertTrue(tool.completeGoal(context).contains("\"status\":\"completed\""));
assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus());
}
}