diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java index cf65c4e0..c896e6a3 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java @@ -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 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 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"); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 15d9f077..b455dd62 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -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.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); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index f9576066..82a9acc1 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -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. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index 0e6a5637..dfabd6b4 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -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 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java index a5494894..64d16b6a 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -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()); + } + }