test_candidate_service.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  1. import asyncio
  2. import json
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from pydantic import ValidationError
  7. from cyber_agent.application import (
  8. AgentApplication,
  9. AgentRole,
  10. ApplicationRegistry,
  11. ApplicationServices,
  12. ProviderRef,
  13. )
  14. from cyber_agent.application.candidate import (
  15. CandidateAdoptionReceipt,
  16. CandidateLedger,
  17. CandidatePointer,
  18. CandidateReviewAction,
  19. )
  20. from cyber_agent.application.quality import CandidateValidationRecord
  21. from cyber_agent.application.candidate_service import (
  22. CandidateService,
  23. CandidateStateError,
  24. )
  25. from cyber_agent.core.artifacts import (
  26. ArtifactRef,
  27. ValidationMaterial,
  28. material_content_hash,
  29. )
  30. from cyber_agent.core.task_protocol import TaskBrief, new_task_protocol
  31. from cyber_agent.core.validation import (
  32. ScopeValidationResult,
  33. ValidationCheck,
  34. ValidationResult,
  35. )
  36. from cyber_agent.tools.registry import ToolRegistry
  37. from cyber_agent.trace.models import Message as TraceMessage, Trace
  38. from cyber_agent.trace.store import (
  39. FileSystemTraceStore,
  40. TraceStoreCorruptionError,
  41. )
  42. class MemoryCandidateRepository:
  43. def __init__(self):
  44. self.contents = {}
  45. self.put_calls = 0
  46. self.merge_calls = 0
  47. self.bad_artifact = False
  48. self.adoptions = {}
  49. self.adoption_calls = 0
  50. async def put_version(self, request):
  51. self.put_calls += 1
  52. return self._save(request)
  53. async def merge(self, request):
  54. self.merge_calls += 1
  55. return self._save(request)
  56. def _save(self, request):
  57. key = request.operation_id
  58. self.contents.setdefault(key, request)
  59. persisted = self.contents[key]
  60. digest = material_content_hash(persisted.content)
  61. if self.bad_artifact:
  62. digest = "0" * 64
  63. return ArtifactRef(
  64. artifact_id=(
  65. f"candidate:{persisted.candidate_id}:{persisted.revision}"
  66. ),
  67. version=str(persisted.revision),
  68. content_hash=digest,
  69. kind="candidate.output",
  70. mime_type="application/json",
  71. )
  72. async def resolve(self, ref, root_trace_id, uid):
  73. request = next(
  74. item
  75. for item in self.contents.values()
  76. if (
  77. ref.artifact_id
  78. == f"candidate:{item.candidate_id}:{item.revision}"
  79. )
  80. )
  81. return ValidationMaterial(
  82. **ref.model_dump(),
  83. root_trace_id=root_trace_id,
  84. uid=uid,
  85. content=request.content,
  86. )
  87. async def load_version(self, candidate_ref):
  88. return next(
  89. item.content
  90. for item in self.contents.values()
  91. if item.candidate_id == candidate_ref.candidate_id
  92. and item.revision == candidate_ref.revision
  93. )
  94. async def commit_adoption(self, request):
  95. self.adoption_calls += 1
  96. self.adoptions.setdefault(request.operation_id, request.candidate_ref)
  97. return CandidateAdoptionReceipt(
  98. operation_id=request.operation_id,
  99. external_id=f"published:{request.candidate_ref.candidate_id}",
  100. )
  101. def application():
  102. return AgentApplication(
  103. application_id="candidate.test",
  104. application_version="1",
  105. root_role="writer",
  106. roles=(AgentRole(
  107. role_id="writer",
  108. model="fake",
  109. system_prompt="write",
  110. ),),
  111. artifact_resolver_ref=ProviderRef(
  112. provider_id="artifacts",
  113. provider_version="1",
  114. ),
  115. candidate_repository_ref=ProviderRef(
  116. provider_id="candidates",
  117. provider_version="1",
  118. ),
  119. )
  120. class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
  121. async def asyncSetUp(self):
  122. self.temp = tempfile.TemporaryDirectory()
  123. self.store = FileSystemTraceStore(self.temp.name)
  124. self.repository = MemoryCandidateRepository()
  125. self.binding = ApplicationRegistry().register(
  126. application(),
  127. ApplicationServices(
  128. tool_registry=ToolRegistry(),
  129. artifact_resolver=self.repository,
  130. candidate_repository=self.repository,
  131. ),
  132. )
  133. self.service = CandidateService(
  134. store=self.store,
  135. application_binding=self.binding,
  136. repository=self.repository,
  137. artifact_resolver=self.repository,
  138. )
  139. await self._create_root("root-a")
  140. async def asyncTearDown(self):
  141. self.temp.cleanup()
  142. def _context(self, root_trace_id, *, task_brief=None):
  143. return {
  144. "agent_mode": "recursive",
  145. "agent_mode_revision": 3,
  146. "root_trace_id": root_trace_id,
  147. "root_task_anchor_hash": "a" * 64,
  148. "application_ref": self.binding.application_ref.model_dump(mode="json"),
  149. "application_role_id": "writer",
  150. "application_role_hash": self.binding.role("writer").role_hash,
  151. "task_protocol": new_task_protocol(task_brief),
  152. }
  153. async def _create_root(self, trace_id, *, uid="user-1"):
  154. await self.store.create_trace(Trace(
  155. trace_id=trace_id,
  156. mode="agent",
  157. agent_type="writer",
  158. uid=uid,
  159. context=self._context(trace_id),
  160. ))
  161. async def _create_child(self, trace_id, root_trace_id="root-a"):
  162. await self.store.create_trace(Trace(
  163. trace_id=trace_id,
  164. mode="agent",
  165. agent_type="writer",
  166. uid="user-1",
  167. parent_trace_id=root_trace_id,
  168. context=self._context(
  169. root_trace_id,
  170. task_brief=TaskBrief(
  171. objective="write candidate",
  172. reason="the root needs an option",
  173. completion_criteria=["candidate is complete"],
  174. expected_outputs=["one candidate"],
  175. ),
  176. ),
  177. ))
  178. async def _record_validation(self, trace_id, candidate, outcome="passed"):
  179. plan_hash = material_content_hash({
  180. "candidate": candidate.model_dump(mode="json"),
  181. "outcome": outcome,
  182. })
  183. status = "passed" if outcome == "passed" else "failed"
  184. scope = ScopeValidationResult(
  185. validator_trace_id=f"{trace_id}@validator",
  186. scope="output",
  187. outcome=outcome,
  188. checks=[ValidationCheck(
  189. check_id="quality.rule",
  190. status=status,
  191. issue=None if status == "passed" else "needs revision",
  192. )],
  193. reason="checked",
  194. retry_from=None if outcome == "passed" else "output",
  195. plan_hash=plan_hash,
  196. )
  197. result = ValidationResult(
  198. evaluated_trace_id=trace_id,
  199. outcome=outcome,
  200. scope_results=[scope],
  201. issues=[] if outcome == "passed" else ["needs revision"],
  202. retry_from=None if outcome == "passed" else "output",
  203. plan_hash=plan_hash,
  204. )
  205. await self.service.record_validation(
  206. trace_id,
  207. CandidateValidationRecord(
  208. candidate_ref=candidate,
  209. plan_hash=plan_hash,
  210. validation_result=result.model_dump(mode="json"),
  211. validated_at_sequence=candidate.created_at_sequence,
  212. ),
  213. )
  214. async def test_create_fork_merge_and_idempotent_recovery(self):
  215. first, duplicate = await asyncio.gather(
  216. self.service.manage(
  217. "root-a",
  218. operation="create",
  219. content={"text": "A"},
  220. parent_refs=[],
  221. effective_at_sequence=1,
  222. ),
  223. self.service.manage(
  224. "root-a",
  225. operation="create",
  226. content={"text": "A"},
  227. parent_refs=[],
  228. effective_at_sequence=1,
  229. ),
  230. )
  231. self.assertEqual(first, duplicate)
  232. self.assertEqual(1, self.repository.put_calls)
  233. second = await self.service.manage(
  234. "root-a",
  235. operation="create",
  236. content={"text": "B"},
  237. parent_refs=[],
  238. effective_at_sequence=2,
  239. )
  240. fork = await self.service.manage(
  241. "root-a",
  242. operation="fork",
  243. content={"text": "A2"},
  244. parent_refs=[CandidatePointer(
  245. candidate_id=first.candidate_id,
  246. revision=first.revision,
  247. )],
  248. effective_at_sequence=3,
  249. )
  250. self.assertEqual(first.candidate_id, fork.candidate_id)
  251. self.assertEqual(2, fork.revision)
  252. merged = await self.service.manage(
  253. "root-a",
  254. operation="merge",
  255. content={"text": "AB"},
  256. parent_refs=[
  257. CandidatePointer(
  258. candidate_id=fork.candidate_id,
  259. revision=fork.revision,
  260. ),
  261. CandidatePointer(
  262. candidate_id=second.candidate_id,
  263. revision=second.revision,
  264. ),
  265. ],
  266. effective_at_sequence=4,
  267. )
  268. self.assertNotIn(
  269. merged.candidate_id,
  270. {first.candidate_id, second.candidate_id},
  271. )
  272. self.assertEqual(2, len(merged.parent_refs))
  273. ledger = CandidateLedger.model_validate(
  274. await self.store.get_candidate_ledger("root-a")
  275. )
  276. self.assertEqual(4, len(ledger.candidates))
  277. self.assertTrue(all(
  278. ledger.current_state(item) == "proposed"
  279. for item in ledger.candidates
  280. ))
  281. async def test_finalize_crash_retries_same_semantic_command_identity(self):
  282. original_replace = self.store.replace_candidate_ledger
  283. writes = 0
  284. async def fail_completed_publish(root_trace_id, ledger):
  285. nonlocal writes
  286. writes += 1
  287. if writes == 2:
  288. raise OSError("simulated ledger finalize crash")
  289. await original_replace(root_trace_id, ledger)
  290. with patch.object(
  291. self.store,
  292. "replace_candidate_ledger",
  293. side_effect=fail_completed_publish,
  294. ):
  295. with self.assertRaisesRegex(OSError, "finalize crash"):
  296. await self.service.manage(
  297. "root-a",
  298. operation="create",
  299. content={"text": "survives retry"},
  300. parent_refs=[],
  301. effective_at_sequence=2,
  302. command_id="candidate-crash-call",
  303. )
  304. pending = CandidateLedger.model_validate(
  305. await self.store.get_candidate_ledger("root-a")
  306. )
  307. self.assertEqual("pending", pending.operations[0].status)
  308. operation_id = pending.operations[0].operation_id
  309. recovered = await self.service.manage(
  310. "root-a",
  311. operation="create",
  312. content={"text": "survives retry"},
  313. parent_refs=[],
  314. effective_at_sequence=99,
  315. command_id="candidate-crash-call",
  316. )
  317. ledger = CandidateLedger.model_validate(
  318. await self.store.get_candidate_ledger("root-a")
  319. )
  320. self.assertEqual(operation_id, ledger.operations[0].operation_id)
  321. self.assertEqual("completed", ledger.operations[0].status)
  322. self.assertEqual(1, len(ledger.candidates))
  323. self.assertEqual(1, len(self.repository.contents))
  324. self.assertEqual(
  325. recovered,
  326. ledger.candidate(ledger.operations[0].candidate),
  327. )
  328. async def test_distinct_tool_calls_with_identical_payload_create_versions(self):
  329. first = await self.service.manage(
  330. "root-a",
  331. operation="create",
  332. content={"text": "same payload"},
  333. parent_refs=[],
  334. effective_at_sequence=10,
  335. command_id="candidate-call-one",
  336. )
  337. await self.store.add_message(TraceMessage.create(
  338. trace_id="root-a",
  339. role="tool",
  340. sequence=10,
  341. tool_call_id="candidate-call-one",
  342. content={"tool_name": "manage_candidate", "result": "registered"},
  343. ))
  344. await self.store.update_trace("root-a", head_sequence=10)
  345. second = await self.service.manage(
  346. "root-a",
  347. operation="create",
  348. content={"text": "same payload"},
  349. parent_refs=[],
  350. effective_at_sequence=20,
  351. command_id="candidate-call-two",
  352. )
  353. await self.store.add_message(TraceMessage.create(
  354. trace_id="root-a",
  355. role="tool",
  356. sequence=20,
  357. parent_sequence=10,
  358. tool_call_id="candidate-call-two",
  359. content={"tool_name": "manage_candidate", "result": "registered"},
  360. ))
  361. await self.store.update_trace("root-a", head_sequence=20)
  362. self.assertNotEqual(first, second)
  363. self.assertEqual(2, self.repository.put_calls)
  364. ledger = CandidateLedger.model_validate(
  365. await self.store.get_candidate_ledger("root-a")
  366. )
  367. self.assertEqual(2, len(ledger.candidates))
  368. self.assertEqual(2, len(ledger.operations))
  369. async def test_rewind_makes_future_branch_candidate_unreportable(self):
  370. await self.store.add_message(TraceMessage.create(
  371. trace_id="root-a",
  372. role="user",
  373. sequence=1,
  374. content="before candidate",
  375. ))
  376. await self.store.update_trace("root-a", head_sequence=1)
  377. candidate = await self.service.manage(
  378. "root-a",
  379. operation="create",
  380. content={"text": "future branch"},
  381. parent_refs=[],
  382. effective_at_sequence=10,
  383. command_id="future-candidate-call",
  384. )
  385. await self.store.add_message(TraceMessage.create(
  386. trace_id="root-a",
  387. role="tool",
  388. sequence=10,
  389. parent_sequence=1,
  390. tool_call_id="future-candidate-call",
  391. content={"tool_name": "manage_candidate", "result": "registered"},
  392. ))
  393. await self.store.update_trace("root-a", head_sequence=10)
  394. await self.service.validate_report_refs("root-a", [candidate])
  395. await self.store.update_trace("root-a", head_sequence=1)
  396. with self.assertRaisesRegex(
  397. CandidateStateError,
  398. "current owner Trace branch",
  399. ):
  400. await self.service.validate_report_refs("root-a", [candidate])
  401. async def test_checkpoint_update_cannot_mutate_identity_or_sequence(self):
  402. candidate = await self.service.manage(
  403. "root-a",
  404. operation="create",
  405. content={"text": "checkpoint"},
  406. parent_refs=[],
  407. effective_at_sequence=3,
  408. )
  409. plan_hash = "a" * 64
  410. await self.service.begin_validation_checkpoint(
  411. "root-a",
  412. candidate,
  413. plan={"checks": []},
  414. plan_hash=plan_hash,
  415. validated_at_sequence=3,
  416. material_usage_recorded=False,
  417. )
  418. with self.assertRaisesRegex(
  419. CandidateStateError,
  420. "fields are immutable",
  421. ):
  422. await self.service.update_validation_checkpoint(
  423. "root-a",
  424. candidate,
  425. plan_hash,
  426. validated_at_sequence=-1,
  427. )
  428. ledger = CandidateLedger.model_validate(
  429. await self.store.get_candidate_ledger("root-a")
  430. )
  431. self.assertEqual(
  432. 3,
  433. ledger.validation_checkpoints[0]["validated_at_sequence"],
  434. )
  435. async def test_cross_root_and_cross_task_parent_access_is_rejected(self):
  436. first = await self.service.manage(
  437. "root-a",
  438. operation="create",
  439. content={"text": "A"},
  440. parent_refs=[],
  441. effective_at_sequence=1,
  442. )
  443. await self._create_root("root-b")
  444. with self.assertRaisesRegex(CandidateStateError, "not found"):
  445. await self.service.manage(
  446. "root-b",
  447. operation="fork",
  448. content={"text": "stolen"},
  449. parent_refs=[CandidatePointer(
  450. candidate_id=first.candidate_id,
  451. revision=first.revision,
  452. )],
  453. effective_at_sequence=2,
  454. )
  455. await self._create_child("child-a")
  456. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  457. await self.service.manage(
  458. "child-a",
  459. operation="fork",
  460. content={"text": "stolen"},
  461. parent_refs=[CandidatePointer(
  462. candidate_id=first.candidate_id,
  463. revision=first.revision,
  464. )],
  465. effective_at_sequence=3,
  466. )
  467. async def test_bad_artifact_fails_without_registering_candidate(self):
  468. self.repository.bad_artifact = True
  469. with self.assertRaisesRegex(CandidateStateError, "invalid ArtifactRef"):
  470. await self.service.manage(
  471. "root-a",
  472. operation="create",
  473. content={"text": "bad"},
  474. parent_refs=[],
  475. effective_at_sequence=1,
  476. )
  477. ledger = CandidateLedger.model_validate(
  478. await self.store.get_candidate_ledger("root-a")
  479. )
  480. self.assertEqual(0, len(ledger.candidates))
  481. self.assertEqual("failed", ledger.operations[0].status)
  482. async def test_report_validation_rejects_tamper_and_wrong_owner(self):
  483. candidate = await self.service.manage(
  484. "root-a",
  485. operation="create",
  486. content={"text": "A"},
  487. parent_refs=[],
  488. effective_at_sequence=1,
  489. )
  490. await self.service.validate_report_refs("root-a", [candidate])
  491. tampered = candidate.model_copy(update={
  492. "artifact_ref": candidate.artifact_ref.model_copy(update={
  493. "version": "tampered",
  494. }),
  495. })
  496. with self.assertRaisesRegex(CandidateStateError, "does not match"):
  497. await self.service.validate_report_refs("root-a", [tampered])
  498. await self._create_child("child-a")
  499. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  500. await self.service.validate_report_refs("child-a", [candidate])
  501. async def test_ledger_rejects_future_lineage_and_store_reports_corruption(self):
  502. candidate = await self.service.manage(
  503. "root-a",
  504. operation="create",
  505. content={"text": "A"},
  506. parent_refs=[],
  507. effective_at_sequence=1,
  508. )
  509. raw = (await self.store.get_candidate_ledger("root-a"))
  510. future = candidate.model_copy(update={
  511. "revision": 2,
  512. "parent_refs": (CandidatePointer(
  513. candidate_id=candidate.candidate_id,
  514. revision=3,
  515. ),),
  516. })
  517. raw["candidates"].append(future.model_dump(mode="json"))
  518. with self.assertRaisesRegex(ValidationError, "unknown parent"):
  519. CandidateLedger.model_validate(raw)
  520. ledger_path = self.store._get_candidate_ledger_file("root-a")
  521. ledger_path.write_text("{broken", encoding="utf-8")
  522. with self.assertRaisesRegex(TraceStoreCorruptionError, "candidate ledger"):
  523. await self.store.get_candidate_ledger("root-a")
  524. async def test_cyclic_lineage_is_rejected_before_repository_side_effect(self):
  525. first = await self.service.manage(
  526. "root-a",
  527. operation="create",
  528. content={"text": "A"},
  529. parent_refs=[],
  530. effective_at_sequence=1,
  531. )
  532. raw = await self.store.get_candidate_ledger("root-a")
  533. second = first.model_copy(update={
  534. "candidate_id": "candidate-b",
  535. "parent_refs": (CandidatePointer(
  536. candidate_id=first.candidate_id,
  537. revision=first.revision,
  538. ),),
  539. })
  540. raw["candidates"][0]["parent_refs"] = [{
  541. "candidate_id": second.candidate_id,
  542. "revision": second.revision,
  543. }]
  544. raw["candidates"].append(second.model_dump(mode="json"))
  545. ledger_path = self.store._get_candidate_ledger_file("root-a")
  546. ledger_path.write_text(json.dumps(raw), encoding="utf-8")
  547. put_calls = self.repository.put_calls
  548. with self.assertRaisesRegex(ValidationError, "lineage cycle"):
  549. await self.service.manage(
  550. "root-a",
  551. operation="create",
  552. content={"text": "must not be persisted"},
  553. parent_refs=[],
  554. effective_at_sequence=2,
  555. )
  556. self.assertEqual(put_calls, self.repository.put_calls)
  557. async def test_review_adoption_is_idempotent_and_creates_rewind_barrier(self):
  558. await self._create_child("child-a")
  559. candidate = await self.service.manage(
  560. "child-a",
  561. operation="create",
  562. content={"text": "A"},
  563. parent_refs=[],
  564. effective_at_sequence=4,
  565. )
  566. await self._record_validation("child-a", candidate)
  567. action = CandidateReviewAction(
  568. action="adopt",
  569. candidate_ref=candidate,
  570. reason="selected exact revision",
  571. )
  572. first = await self.service.apply_review_actions(
  573. "root-a",
  574. "child-a",
  575. report_refs=[candidate],
  576. actions=[action],
  577. effective_at_sequence=9,
  578. )
  579. second = await self.service.apply_review_actions(
  580. "root-a",
  581. "child-a",
  582. report_refs=[candidate],
  583. actions=[action],
  584. effective_at_sequence=9,
  585. )
  586. self.assertEqual(first, second)
  587. self.assertEqual(1, self.repository.adoption_calls)
  588. self.assertEqual(1, len(self.repository.adoptions))
  589. with self.assertRaisesRegex(CandidateStateError, "committed"):
  590. await self.service.assert_rewind_allowed("root-a", 8)
  591. await self.service.assert_rewind_allowed("root-a", 9)
  592. with self.assertRaisesRegex(CandidateStateError, "terminal"):
  593. await self.service.apply_review_actions(
  594. "root-a",
  595. "child-a",
  596. report_refs=[candidate],
  597. actions=[CandidateReviewAction(
  598. action="discard",
  599. candidate_ref=candidate,
  600. reason="too late",
  601. )],
  602. effective_at_sequence=10,
  603. )
  604. async def test_adoption_retry_uses_same_command_after_parent_sequence_changes(self):
  605. await self._create_child("child-a")
  606. candidate = await self.service.manage(
  607. "child-a",
  608. operation="create",
  609. content={"text": "publish once"},
  610. parent_refs=[],
  611. effective_at_sequence=4,
  612. )
  613. await self._record_validation("child-a", candidate)
  614. peer = await self.service.manage(
  615. "child-a",
  616. operation="create",
  617. content={"text": "peer"},
  618. parent_refs=[],
  619. effective_at_sequence=5,
  620. )
  621. await self._record_validation("child-a", peer)
  622. action = CandidateReviewAction(
  623. action="adopt",
  624. candidate_ref=candidate,
  625. reason="stable parent decision",
  626. )
  627. first = await self.service.apply_review_actions(
  628. "root-a",
  629. "child-a",
  630. report_refs=[candidate, peer],
  631. actions=[action],
  632. effective_at_sequence=10,
  633. command_id="adoption-review-call",
  634. )
  635. recovered = await self.service.apply_review_actions(
  636. "root-a",
  637. "child-a",
  638. report_refs=[candidate, peer],
  639. actions=[action],
  640. effective_at_sequence=999,
  641. command_id="adoption-review-call",
  642. )
  643. self.assertEqual(first, recovered)
  644. self.assertEqual(1, self.repository.adoption_calls)
  645. self.assertEqual(1, len(self.repository.adoptions))
  646. with self.assertRaisesRegex(
  647. CandidateStateError,
  648. "review batch payload changed",
  649. ):
  650. await self.service.apply_review_actions(
  651. "root-a",
  652. "child-a",
  653. report_refs=[candidate, peer],
  654. actions=[action.model_copy(update={
  655. "reason": "tampered decision payload",
  656. })],
  657. effective_at_sequence=1_000,
  658. command_id="adoption-review-call",
  659. )
  660. self.assertEqual(1, self.repository.adoption_calls)
  661. with self.assertRaisesRegex(
  662. CandidateStateError,
  663. "review batch payload changed",
  664. ):
  665. await self.service.apply_review_actions(
  666. "root-a",
  667. "child-a",
  668. report_refs=[candidate, peer],
  669. actions=[
  670. action,
  671. CandidateReviewAction(
  672. action="discard",
  673. candidate_ref=peer,
  674. reason="injected tail action",
  675. ),
  676. ],
  677. effective_at_sequence=1_001,
  678. command_id="adoption-review-call",
  679. )
  680. self.assertEqual("proposed", (
  681. CandidateLedger.model_validate(
  682. await self.store.get_candidate_ledger("root-a")
  683. ).current_state(peer)
  684. ))
  685. async def test_review_batch_replay_rejects_deleted_and_reordered_actions(self):
  686. await self._create_child("child-a")
  687. candidates = []
  688. for sequence, text in ((4, "A"), (5, "B")):
  689. candidate = await self.service.manage(
  690. "child-a",
  691. operation="create",
  692. content={"text": text},
  693. parent_refs=[],
  694. effective_at_sequence=sequence,
  695. )
  696. await self._record_validation("child-a", candidate)
  697. candidates.append(candidate)
  698. actions = [
  699. CandidateReviewAction(
  700. action="discard",
  701. candidate_ref=candidate,
  702. reason=f"discard {candidate.candidate_id}",
  703. )
  704. for candidate in candidates
  705. ]
  706. await self.service.apply_review_actions(
  707. "root-a",
  708. "child-a",
  709. report_refs=candidates,
  710. actions=actions,
  711. effective_at_sequence=10,
  712. command_id="two-action-review",
  713. )
  714. for altered in (actions[:1], list(reversed(actions))):
  715. with self.assertRaisesRegex(
  716. CandidateStateError,
  717. "review batch payload changed",
  718. ):
  719. await self.service.apply_review_actions(
  720. "root-a",
  721. "child-a",
  722. report_refs=candidates,
  723. actions=altered,
  724. effective_at_sequence=999,
  725. command_id="two-action-review",
  726. )
  727. async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
  728. await self._create_child("child-a")
  729. await self.store.create_trace(Trace(
  730. trace_id="grandchild-a",
  731. mode="agent",
  732. agent_type="writer",
  733. uid="user-1",
  734. parent_trace_id="child-a",
  735. context=self._context(
  736. "root-a",
  737. task_brief=TaskBrief(
  738. objective="write descendant candidate",
  739. reason="the child needs an option",
  740. completion_criteria=["candidate is complete"],
  741. expected_outputs=["one candidate"],
  742. ),
  743. ),
  744. ))
  745. candidate = await self.service.manage(
  746. "grandchild-a",
  747. operation="create",
  748. content={"text": "nested candidate"},
  749. parent_refs=[],
  750. effective_at_sequence=4,
  751. )
  752. await self._record_validation("grandchild-a", candidate)
  753. await self.service.apply_review_actions(
  754. "child-a",
  755. "grandchild-a",
  756. report_refs=[candidate],
  757. actions=[CandidateReviewAction(
  758. action="adopt",
  759. candidate_ref=candidate,
  760. reason="commit descendant output",
  761. )],
  762. effective_at_sequence=9,
  763. )
  764. with self.assertRaisesRegex(CandidateStateError, "committed"):
  765. await self.service.assert_rewind_allowed("root-a", 100)
  766. async def test_failed_candidate_can_only_be_revised_or_discarded(self):
  767. await self._create_child("child-a")
  768. candidate = await self.service.manage(
  769. "child-a",
  770. operation="create",
  771. content={"text": "placeholder"},
  772. parent_refs=[],
  773. effective_at_sequence=4,
  774. )
  775. await self._record_validation("child-a", candidate, outcome="failed")
  776. with self.assertRaisesRegex(CandidateStateError, "passed"):
  777. await self.service.apply_review_actions(
  778. "root-a",
  779. "child-a",
  780. report_refs=[candidate],
  781. actions=[CandidateReviewAction(
  782. action="adopt",
  783. candidate_ref=candidate,
  784. reason="invalid",
  785. )],
  786. effective_at_sequence=9,
  787. )
  788. records = await self.service.apply_review_actions(
  789. "root-a",
  790. "child-a",
  791. report_refs=[candidate],
  792. actions=[CandidateReviewAction(
  793. action="revise",
  794. candidate_ref=candidate,
  795. reason="remove placeholder",
  796. )],
  797. effective_at_sequence=10,
  798. )
  799. self.assertEqual("discarded", records[0].state)
  800. self.assertIn("retry_from=output", records[0].reason)
  801. async def test_adoption_replays_same_operation_after_finalize_crash(self):
  802. await self._create_child("child-a")
  803. candidate = await self.service.manage(
  804. "child-a",
  805. operation="create",
  806. content={"text": "A"},
  807. parent_refs=[],
  808. effective_at_sequence=4,
  809. )
  810. await self._record_validation("child-a", candidate)
  811. action = CandidateReviewAction(
  812. action="adopt",
  813. candidate_ref=candidate,
  814. reason="publish once",
  815. )
  816. original_replace = self.store.replace_candidate_ledger
  817. calls = 0
  818. async def fail_finalize(root_trace_id, ledger):
  819. nonlocal calls
  820. calls += 1
  821. if calls == 3:
  822. raise OSError("crash before framework finalize")
  823. await original_replace(root_trace_id, ledger)
  824. with patch.object(
  825. self.store,
  826. "replace_candidate_ledger",
  827. side_effect=fail_finalize,
  828. ):
  829. with self.assertRaisesRegex(OSError, "finalize"):
  830. await self.service.apply_review_actions(
  831. "root-a",
  832. "child-a",
  833. report_refs=[candidate],
  834. actions=[action],
  835. effective_at_sequence=9,
  836. )
  837. pending = CandidateLedger.model_validate(
  838. await self.store.get_candidate_ledger("root-a")
  839. )
  840. self.assertEqual("adoption_pending", pending.current_state(candidate))
  841. await self.service.apply_review_actions(
  842. "root-a",
  843. "child-a",
  844. report_refs=[candidate],
  845. actions=[action],
  846. effective_at_sequence=9,
  847. )
  848. self.assertEqual(2, self.repository.adoption_calls)
  849. self.assertEqual(1, len(self.repository.adoptions))
  850. completed = CandidateLedger.model_validate(
  851. await self.store.get_candidate_ledger("root-a")
  852. )
  853. self.assertEqual("adopted", completed.current_state(candidate))
  854. if __name__ == "__main__":
  855. unittest.main()