mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat: require current managed JSON bindings at goal completion
This commit is contained in:
parent
5cc8765681
commit
4277172529
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 1–16 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.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
在 Goal 面板展开“JSON 验收要求”,由对话所有者或管理员显式保存要求。每个 Goal 最多 8 条要求,每条绑定一个产物槽和 1–16 个顶层字段。字段检查表示字段存在且不为 null;false、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 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user