Parcourir la source

fix(candidate): 将空审核动作纳入批次完整性门禁

只要 TaskReport 含 CandidateRef,父级 review 无论 candidate_actions 是否为空都必须进入 CandidateService 并固化完整批次信封。

因此同一 review tool_call_id 的 [A] 到空数组、空数组到 [A] 两种篡改都会在协议状态和 Repository 副作用前失败关闭。

新增整批删除与空批次追加动作测试,并回归 TaskProtocol 和真实 Reference Runner 链。
SamLee il y a 14 heures
Parent
commit
ae0c4eb2cb

+ 0 - 2
cyber_agent/application/candidate_service.py

@@ -484,8 +484,6 @@ class CandidateService:
         command_id: str | None = None,
     ) -> list[CandidateLifecycleRecord]:
         """Apply parent-owned candidate decisions behind one root lock."""
-        if not actions:
-            return []
         parent = await self.store.get_trace(parent_trace_id)
         child = await self.store.get_trace(child_trace_id)
         if parent is None or child is None:

+ 1 - 1
cyber_agent/tools/builtin/task_protocol.py

@@ -433,7 +433,7 @@ async def _review_task_result_locked(
             return _error(f"Approved TaskBrief context is invalid: {exc}")
 
     reviewed_at_sequence = int(context.get("sequence", 0) or 0)
-    if review.candidate_actions:
+    if report.candidate_refs or review.candidate_actions:
         candidate_service = context.get("candidate_service")
         if candidate_service is None:
             return _error("Candidate actions require the bound CandidateService")

+ 54 - 0
tests/test_candidate_service.py

@@ -723,6 +723,18 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
                 await self.store.get_candidate_ledger("root-a")
             ).current_state(peer)
         ))
+        with self.assertRaisesRegex(
+            CandidateStateError,
+            "review batch payload changed",
+        ):
+            await self.service.apply_review_actions(
+                "root-a",
+                "child-a",
+                report_refs=[candidate, peer],
+                actions=[],
+                effective_at_sequence=1_002,
+                command_id="adoption-review-call",
+            )
 
     async def test_review_batch_replay_rejects_deleted_and_reordered_actions(self):
         await self._create_child("child-a")
@@ -768,6 +780,48 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
                     command_id="two-action-review",
                 )
 
+    async def test_empty_review_batch_cannot_gain_an_action_during_replay(self):
+        await self._create_child("child-a")
+        candidate = await self.service.manage(
+            "child-a",
+            operation="create",
+            content={"text": "A"},
+            parent_refs=[],
+            effective_at_sequence=4,
+        )
+        await self._record_validation("child-a", candidate)
+        self.assertEqual([], await self.service.apply_review_actions(
+            "root-a",
+            "child-a",
+            report_refs=[candidate],
+            actions=[],
+            effective_at_sequence=10,
+            command_id="empty-review-batch",
+        ))
+
+        with self.assertRaisesRegex(
+            CandidateStateError,
+            "review batch payload changed",
+        ):
+            await self.service.apply_review_actions(
+                "root-a",
+                "child-a",
+                report_refs=[candidate],
+                actions=[CandidateReviewAction(
+                    action="discard",
+                    candidate_ref=candidate,
+                    reason="injected after empty batch",
+                )],
+                effective_at_sequence=999,
+                command_id="empty-review-batch",
+            )
+        self.assertEqual(
+            "proposed",
+            CandidateLedger.model_validate(
+                await self.store.get_candidate_ledger("root-a")
+            ).current_state(candidate),
+        )
+
     async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
         await self._create_child("child-a")
         await self.store.create_trace(Trace(