test_candidate_service.py 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442
  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. CandidateOperationRecord,
  18. CandidatePointer,
  19. CandidateReviewAction,
  20. MAX_CANDIDATES_PER_ROOT,
  21. MAX_CANDIDATE_OPERATIONS_PER_ROOT,
  22. )
  23. from cyber_agent.application.quality import CandidateValidationRecord
  24. from cyber_agent.application.candidate_service import (
  25. CandidateService,
  26. CandidateStateError,
  27. )
  28. from cyber_agent.core.runner import AgentRunner, RunConfig
  29. from cyber_agent.core.artifacts import (
  30. ArtifactRef,
  31. ValidationMaterial,
  32. material_content_hash,
  33. )
  34. from cyber_agent.core.task_protocol import TaskBrief, new_task_protocol
  35. from cyber_agent.core.validation import (
  36. ScopeValidationResult,
  37. ValidationCheck,
  38. ValidationResult,
  39. )
  40. from cyber_agent.tools import get_tool_registry
  41. from cyber_agent.tools.errors import RecoverableToolExecutionError
  42. from cyber_agent.tools.registry import ToolRegistry
  43. from cyber_agent.trace.models import Message as TraceMessage, Trace
  44. from cyber_agent.trace.store import (
  45. FileSystemTraceStore,
  46. TraceStoreCorruptionError,
  47. )
  48. class MemoryCandidateRepository:
  49. def __init__(self):
  50. self.contents = {}
  51. self.put_calls = 0
  52. self.merge_calls = 0
  53. self.bad_artifact = False
  54. self.adoptions = {}
  55. self.adoption_calls = 0
  56. async def put_version(self, request):
  57. self.put_calls += 1
  58. return self._save(request)
  59. async def merge(self, request):
  60. self.merge_calls += 1
  61. return self._save(request)
  62. def _save(self, request):
  63. key = request.operation_id
  64. self.contents.setdefault(key, request)
  65. persisted = self.contents[key]
  66. digest = material_content_hash(persisted.content)
  67. if self.bad_artifact:
  68. digest = "0" * 64
  69. return ArtifactRef(
  70. artifact_id=(
  71. f"candidate:{persisted.candidate_id}:{persisted.revision}"
  72. ),
  73. version=str(persisted.revision),
  74. content_hash=digest,
  75. kind="candidate.output",
  76. mime_type="application/json",
  77. )
  78. async def resolve(self, ref, root_trace_id, uid):
  79. request = next(
  80. item
  81. for item in self.contents.values()
  82. if (
  83. ref.artifact_id
  84. == f"candidate:{item.candidate_id}:{item.revision}"
  85. )
  86. )
  87. return ValidationMaterial(
  88. **ref.model_dump(),
  89. root_trace_id=root_trace_id,
  90. uid=uid,
  91. content=request.content,
  92. )
  93. async def load_version(self, candidate_ref):
  94. return next(
  95. item.content
  96. for item in self.contents.values()
  97. if item.candidate_id == candidate_ref.candidate_id
  98. and item.revision == candidate_ref.revision
  99. )
  100. async def commit_adoption(self, request):
  101. self.adoption_calls += 1
  102. self.adoptions.setdefault(request.operation_id, request.candidate_ref)
  103. return CandidateAdoptionReceipt(
  104. operation_id=request.operation_id,
  105. external_id=f"published:{request.candidate_ref.candidate_id}",
  106. )
  107. def application():
  108. return AgentApplication(
  109. application_id="candidate.test",
  110. application_version="1",
  111. root_role="writer",
  112. roles=(AgentRole(
  113. role_id="writer",
  114. model="fake",
  115. system_prompt="write",
  116. ),),
  117. artifact_resolver_ref=ProviderRef(
  118. provider_id="artifacts",
  119. provider_version="1",
  120. ),
  121. candidate_repository_ref=ProviderRef(
  122. provider_id="candidates",
  123. provider_version="1",
  124. ),
  125. )
  126. class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
  127. async def asyncSetUp(self):
  128. self.temp = tempfile.TemporaryDirectory()
  129. self.store = FileSystemTraceStore(self.temp.name)
  130. self.repository = MemoryCandidateRepository()
  131. self.binding = ApplicationRegistry().register(
  132. application(),
  133. ApplicationServices(
  134. tool_registry=ToolRegistry(),
  135. artifact_resolver=self.repository,
  136. candidate_repository=self.repository,
  137. ),
  138. )
  139. self.service = CandidateService(
  140. store=self.store,
  141. application_binding=self.binding,
  142. repository=self.repository,
  143. artifact_resolver=self.repository,
  144. )
  145. await self._create_root("root-a")
  146. async def asyncTearDown(self):
  147. self.temp.cleanup()
  148. def _context(self, root_trace_id, *, task_brief=None):
  149. return {
  150. "agent_mode": "recursive",
  151. "agent_mode_revision": 3,
  152. "root_trace_id": root_trace_id,
  153. "root_task_anchor_hash": "a" * 64,
  154. "application_ref": self.binding.application_ref.model_dump(mode="json"),
  155. "application_role_id": "writer",
  156. "application_role_hash": self.binding.role("writer").role_hash,
  157. "task_protocol": new_task_protocol(task_brief),
  158. }
  159. async def _create_root(self, trace_id, *, uid="user-1"):
  160. await self.store.create_trace(Trace(
  161. trace_id=trace_id,
  162. mode="agent",
  163. agent_type="writer",
  164. uid=uid,
  165. context=self._context(trace_id),
  166. ))
  167. async def _create_child(self, trace_id, root_trace_id="root-a"):
  168. await self.store.create_trace(Trace(
  169. trace_id=trace_id,
  170. mode="agent",
  171. agent_type="writer",
  172. uid="user-1",
  173. parent_trace_id=root_trace_id,
  174. context=self._context(
  175. root_trace_id,
  176. task_brief=TaskBrief(
  177. objective="write candidate",
  178. reason="the root needs an option",
  179. completion_criteria=["candidate is complete"],
  180. expected_outputs=["one candidate"],
  181. ),
  182. ),
  183. ))
  184. async def _record_validation(self, trace_id, candidate, outcome="passed"):
  185. plan_hash = material_content_hash({
  186. "candidate": candidate.model_dump(mode="json"),
  187. "outcome": outcome,
  188. })
  189. status = "passed" if outcome == "passed" else "failed"
  190. scope = ScopeValidationResult(
  191. validator_trace_id=f"{trace_id}@validator",
  192. scope="output",
  193. outcome=outcome,
  194. checks=[ValidationCheck(
  195. check_id="quality.rule",
  196. status=status,
  197. issue=None if status == "passed" else "needs revision",
  198. )],
  199. reason="checked",
  200. retry_from=None if outcome == "passed" else "output",
  201. plan_hash=plan_hash,
  202. )
  203. result = ValidationResult(
  204. evaluated_trace_id=trace_id,
  205. outcome=outcome,
  206. scope_results=[scope],
  207. issues=[] if outcome == "passed" else ["needs revision"],
  208. retry_from=None if outcome == "passed" else "output",
  209. plan_hash=plan_hash,
  210. )
  211. await self.service.record_validation(
  212. trace_id,
  213. CandidateValidationRecord(
  214. candidate_ref=candidate,
  215. plan_hash=plan_hash,
  216. validation_result=result.model_dump(mode="json"),
  217. validated_at_sequence=candidate.created_at_sequence,
  218. ),
  219. )
  220. async def test_create_fork_merge_and_idempotent_recovery(self):
  221. first, duplicate = await asyncio.gather(
  222. self.service.manage(
  223. "root-a",
  224. operation="create",
  225. content={"text": "A"},
  226. parent_refs=[],
  227. effective_at_sequence=1,
  228. ),
  229. self.service.manage(
  230. "root-a",
  231. operation="create",
  232. content={"text": "A"},
  233. parent_refs=[],
  234. effective_at_sequence=1,
  235. ),
  236. )
  237. self.assertEqual(first, duplicate)
  238. self.assertEqual(1, self.repository.put_calls)
  239. second = await self.service.manage(
  240. "root-a",
  241. operation="create",
  242. content={"text": "B"},
  243. parent_refs=[],
  244. effective_at_sequence=2,
  245. )
  246. fork = await self.service.manage(
  247. "root-a",
  248. operation="fork",
  249. content={"text": "A2"},
  250. parent_refs=[CandidatePointer(
  251. candidate_id=first.candidate_id,
  252. revision=first.revision,
  253. )],
  254. effective_at_sequence=3,
  255. )
  256. self.assertEqual(first.candidate_id, fork.candidate_id)
  257. self.assertEqual(2, fork.revision)
  258. merged = await self.service.manage(
  259. "root-a",
  260. operation="merge",
  261. content={"text": "AB"},
  262. parent_refs=[
  263. CandidatePointer(
  264. candidate_id=fork.candidate_id,
  265. revision=fork.revision,
  266. ),
  267. CandidatePointer(
  268. candidate_id=second.candidate_id,
  269. revision=second.revision,
  270. ),
  271. ],
  272. effective_at_sequence=4,
  273. )
  274. self.assertNotIn(
  275. merged.candidate_id,
  276. {first.candidate_id, second.candidate_id},
  277. )
  278. self.assertEqual(2, len(merged.parent_refs))
  279. ledger = CandidateLedger.model_validate(
  280. await self.store.get_candidate_ledger("root-a")
  281. )
  282. self.assertEqual(4, len(ledger.candidates))
  283. self.assertTrue(all(
  284. ledger.current_state(item) == "proposed"
  285. for item in ledger.candidates
  286. ))
  287. async def test_finalize_crash_retries_same_semantic_command_identity(self):
  288. original_replace = self.store.replace_candidate_ledger
  289. writes = 0
  290. async def fail_completed_publish(root_trace_id, ledger):
  291. nonlocal writes
  292. writes += 1
  293. if writes == 2:
  294. raise OSError("simulated ledger finalize crash")
  295. await original_replace(root_trace_id, ledger)
  296. with patch.object(
  297. self.store,
  298. "replace_candidate_ledger",
  299. side_effect=fail_completed_publish,
  300. ):
  301. with self.assertRaisesRegex(
  302. RecoverableToolExecutionError,
  303. "original command ID",
  304. ):
  305. await self.service.manage(
  306. "root-a",
  307. operation="create",
  308. content={"text": "survives retry"},
  309. parent_refs=[],
  310. effective_at_sequence=2,
  311. command_id="candidate-crash-call",
  312. )
  313. pending = CandidateLedger.model_validate(
  314. await self.store.get_candidate_ledger("root-a")
  315. )
  316. self.assertEqual("pending", pending.operations[0].status)
  317. operation_id = pending.operations[0].operation_id
  318. recovered = await self.service.manage(
  319. "root-a",
  320. operation="create",
  321. content={"text": "survives retry"},
  322. parent_refs=[],
  323. effective_at_sequence=99,
  324. command_id="candidate-crash-call",
  325. )
  326. ledger = CandidateLedger.model_validate(
  327. await self.store.get_candidate_ledger("root-a")
  328. )
  329. self.assertEqual(operation_id, ledger.operations[0].operation_id)
  330. self.assertEqual("completed", ledger.operations[0].status)
  331. self.assertEqual(1, len(ledger.candidates))
  332. self.assertEqual(1, len(self.repository.contents))
  333. self.assertEqual(
  334. recovered,
  335. ledger.candidate(ledger.operations[0].candidate),
  336. )
  337. async def test_distinct_tool_calls_with_identical_payload_create_versions(self):
  338. first = await self.service.manage(
  339. "root-a",
  340. operation="create",
  341. content={"text": "same payload"},
  342. parent_refs=[],
  343. effective_at_sequence=10,
  344. command_id="candidate-call-one",
  345. )
  346. await self.store.add_message(TraceMessage.create(
  347. trace_id="root-a",
  348. role="tool",
  349. sequence=10,
  350. tool_call_id="candidate-call-one",
  351. content={"tool_name": "manage_candidate", "result": "registered"},
  352. ))
  353. await self.store.update_trace("root-a", head_sequence=10)
  354. second = await self.service.manage(
  355. "root-a",
  356. operation="create",
  357. content={"text": "same payload"},
  358. parent_refs=[],
  359. effective_at_sequence=20,
  360. command_id="candidate-call-two",
  361. )
  362. await self.store.add_message(TraceMessage.create(
  363. trace_id="root-a",
  364. role="tool",
  365. sequence=20,
  366. parent_sequence=10,
  367. tool_call_id="candidate-call-two",
  368. content={"tool_name": "manage_candidate", "result": "registered"},
  369. ))
  370. await self.store.update_trace("root-a", head_sequence=20)
  371. self.assertNotEqual(first, second)
  372. self.assertEqual(2, self.repository.put_calls)
  373. ledger = CandidateLedger.model_validate(
  374. await self.store.get_candidate_ledger("root-a")
  375. )
  376. self.assertEqual(2, len(ledger.candidates))
  377. self.assertEqual(2, len(ledger.operations))
  378. async def test_rewind_makes_future_branch_candidate_unreportable(self):
  379. await self.store.add_message(TraceMessage.create(
  380. trace_id="root-a",
  381. role="user",
  382. sequence=1,
  383. content="before candidate",
  384. ))
  385. await self.store.update_trace("root-a", head_sequence=1)
  386. candidate = await self.service.manage(
  387. "root-a",
  388. operation="create",
  389. content={"text": "future branch"},
  390. parent_refs=[],
  391. effective_at_sequence=10,
  392. command_id="future-candidate-call",
  393. )
  394. await self.store.add_message(TraceMessage.create(
  395. trace_id="root-a",
  396. role="tool",
  397. sequence=10,
  398. parent_sequence=1,
  399. tool_call_id="future-candidate-call",
  400. content={"tool_name": "manage_candidate", "result": "registered"},
  401. ))
  402. await self.store.update_trace("root-a", head_sequence=10)
  403. await self.service.validate_report_refs("root-a", [candidate])
  404. await self.store.update_trace("root-a", head_sequence=1)
  405. with self.assertRaisesRegex(
  406. CandidateStateError,
  407. "current owner Trace branch",
  408. ):
  409. await self.service.validate_report_refs("root-a", [candidate])
  410. async def test_checkpoint_update_cannot_mutate_identity_or_sequence(self):
  411. candidate = await self.service.manage(
  412. "root-a",
  413. operation="create",
  414. content={"text": "checkpoint"},
  415. parent_refs=[],
  416. effective_at_sequence=3,
  417. )
  418. plan_hash = "a" * 64
  419. await self.service.begin_validation_checkpoint(
  420. "root-a",
  421. candidate,
  422. plan={"checks": []},
  423. plan_hash=plan_hash,
  424. validated_at_sequence=3,
  425. material_usage_recorded=False,
  426. )
  427. with self.assertRaisesRegex(
  428. CandidateStateError,
  429. "fields are immutable",
  430. ):
  431. await self.service.update_validation_checkpoint(
  432. "root-a",
  433. candidate,
  434. plan_hash,
  435. validated_at_sequence=-1,
  436. )
  437. ledger = CandidateLedger.model_validate(
  438. await self.store.get_candidate_ledger("root-a")
  439. )
  440. self.assertEqual(
  441. 3,
  442. ledger.validation_checkpoints[0]["validated_at_sequence"],
  443. )
  444. async def test_cross_root_and_cross_task_parent_access_is_rejected(self):
  445. first = await self.service.manage(
  446. "root-a",
  447. operation="create",
  448. content={"text": "A"},
  449. parent_refs=[],
  450. effective_at_sequence=1,
  451. )
  452. await self._create_root("root-b")
  453. with self.assertRaisesRegex(CandidateStateError, "not found"):
  454. await self.service.manage(
  455. "root-b",
  456. operation="fork",
  457. content={"text": "stolen"},
  458. parent_refs=[CandidatePointer(
  459. candidate_id=first.candidate_id,
  460. revision=first.revision,
  461. )],
  462. effective_at_sequence=2,
  463. )
  464. await self._create_child("child-a")
  465. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  466. await self.service.manage(
  467. "child-a",
  468. operation="fork",
  469. content={"text": "stolen"},
  470. parent_refs=[CandidatePointer(
  471. candidate_id=first.candidate_id,
  472. revision=first.revision,
  473. )],
  474. effective_at_sequence=3,
  475. )
  476. async def test_bad_artifact_fails_without_registering_candidate(self):
  477. self.repository.bad_artifact = True
  478. with self.assertRaisesRegex(CandidateStateError, "invalid ArtifactRef"):
  479. await self.service.manage(
  480. "root-a",
  481. operation="create",
  482. content={"text": "bad"},
  483. parent_refs=[],
  484. effective_at_sequence=1,
  485. )
  486. ledger = CandidateLedger.model_validate(
  487. await self.store.get_candidate_ledger("root-a")
  488. )
  489. self.assertEqual(0, len(ledger.candidates))
  490. self.assertEqual("failed", ledger.operations[0].status)
  491. async def test_report_validation_rejects_tamper_and_wrong_owner(self):
  492. candidate = await self.service.manage(
  493. "root-a",
  494. operation="create",
  495. content={"text": "A"},
  496. parent_refs=[],
  497. effective_at_sequence=1,
  498. )
  499. await self.service.validate_report_refs("root-a", [candidate])
  500. tampered = candidate.model_copy(update={
  501. "artifact_ref": candidate.artifact_ref.model_copy(update={
  502. "version": "tampered",
  503. }),
  504. })
  505. with self.assertRaisesRegex(CandidateStateError, "does not match"):
  506. await self.service.validate_report_refs("root-a", [tampered])
  507. await self._create_child("child-a")
  508. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  509. await self.service.validate_report_refs("child-a", [candidate])
  510. async def test_ledger_rejects_future_lineage_and_store_reports_corruption(self):
  511. candidate = await self.service.manage(
  512. "root-a",
  513. operation="create",
  514. content={"text": "A"},
  515. parent_refs=[],
  516. effective_at_sequence=1,
  517. )
  518. raw = (await self.store.get_candidate_ledger("root-a"))
  519. future = candidate.model_copy(update={
  520. "revision": 2,
  521. "parent_refs": (CandidatePointer(
  522. candidate_id=candidate.candidate_id,
  523. revision=3,
  524. ),),
  525. })
  526. raw["candidates"].append(future.model_dump(mode="json"))
  527. with self.assertRaisesRegex(ValidationError, "unknown parent"):
  528. CandidateLedger.model_validate(raw)
  529. ledger_path = self.store._get_candidate_ledger_file("root-a")
  530. ledger_path.write_text("{broken", encoding="utf-8")
  531. with self.assertRaisesRegex(TraceStoreCorruptionError, "candidate ledger"):
  532. await self.store.get_candidate_ledger("root-a")
  533. async def test_cyclic_lineage_is_rejected_before_repository_side_effect(self):
  534. first = await self.service.manage(
  535. "root-a",
  536. operation="create",
  537. content={"text": "A"},
  538. parent_refs=[],
  539. effective_at_sequence=1,
  540. )
  541. raw = await self.store.get_candidate_ledger("root-a")
  542. second = first.model_copy(update={
  543. "candidate_id": "candidate-b",
  544. "parent_refs": (CandidatePointer(
  545. candidate_id=first.candidate_id,
  546. revision=first.revision,
  547. ),),
  548. })
  549. raw["candidates"][0]["parent_refs"] = [{
  550. "candidate_id": second.candidate_id,
  551. "revision": second.revision,
  552. }]
  553. raw["candidates"].append(second.model_dump(mode="json"))
  554. ledger_path = self.store._get_candidate_ledger_file("root-a")
  555. ledger_path.write_text(json.dumps(raw), encoding="utf-8")
  556. put_calls = self.repository.put_calls
  557. with self.assertRaisesRegex(ValidationError, "lineage cycle"):
  558. await self.service.manage(
  559. "root-a",
  560. operation="create",
  561. content={"text": "must not be persisted"},
  562. parent_refs=[],
  563. effective_at_sequence=2,
  564. )
  565. self.assertEqual(put_calls, self.repository.put_calls)
  566. async def test_review_adoption_is_idempotent_and_creates_rewind_barrier(self):
  567. await self._create_child("child-a")
  568. candidate = await self.service.manage(
  569. "child-a",
  570. operation="create",
  571. content={"text": "A"},
  572. parent_refs=[],
  573. effective_at_sequence=4,
  574. )
  575. await self._record_validation("child-a", candidate)
  576. action = CandidateReviewAction(
  577. action="adopt",
  578. candidate_ref=candidate,
  579. reason="selected exact revision",
  580. )
  581. first = await self.service.apply_review_actions(
  582. "root-a",
  583. "child-a",
  584. report_refs=[candidate],
  585. actions=[action],
  586. effective_at_sequence=9,
  587. )
  588. second = await self.service.apply_review_actions(
  589. "root-a",
  590. "child-a",
  591. report_refs=[candidate],
  592. actions=[action],
  593. effective_at_sequence=9,
  594. )
  595. self.assertEqual(first, second)
  596. self.assertEqual(1, self.repository.adoption_calls)
  597. self.assertEqual(1, len(self.repository.adoptions))
  598. with self.assertRaisesRegex(CandidateStateError, "committed"):
  599. await self.service.assert_rewind_allowed("root-a", 8)
  600. await self.service.assert_rewind_allowed("root-a", 9)
  601. with self.assertRaisesRegex(CandidateStateError, "terminal"):
  602. await self.service.apply_review_actions(
  603. "root-a",
  604. "child-a",
  605. report_refs=[candidate],
  606. actions=[CandidateReviewAction(
  607. action="discard",
  608. candidate_ref=candidate,
  609. reason="too late",
  610. )],
  611. effective_at_sequence=10,
  612. )
  613. async def test_adoption_retry_uses_same_command_after_parent_sequence_changes(self):
  614. await self._create_child("child-a")
  615. candidate = await self.service.manage(
  616. "child-a",
  617. operation="create",
  618. content={"text": "publish once"},
  619. parent_refs=[],
  620. effective_at_sequence=4,
  621. )
  622. await self._record_validation("child-a", candidate)
  623. peer = await self.service.manage(
  624. "child-a",
  625. operation="create",
  626. content={"text": "peer"},
  627. parent_refs=[],
  628. effective_at_sequence=5,
  629. )
  630. await self._record_validation("child-a", peer)
  631. action = CandidateReviewAction(
  632. action="adopt",
  633. candidate_ref=candidate,
  634. reason="stable parent decision",
  635. )
  636. first = await self.service.apply_review_actions(
  637. "root-a",
  638. "child-a",
  639. report_refs=[candidate, peer],
  640. actions=[action],
  641. effective_at_sequence=10,
  642. command_id="adoption-review-call",
  643. )
  644. recovered = await self.service.apply_review_actions(
  645. "root-a",
  646. "child-a",
  647. report_refs=[candidate, peer],
  648. actions=[action],
  649. effective_at_sequence=999,
  650. command_id="adoption-review-call",
  651. )
  652. self.assertEqual(first, recovered)
  653. self.assertEqual(1, self.repository.adoption_calls)
  654. self.assertEqual(1, len(self.repository.adoptions))
  655. with self.assertRaisesRegex(
  656. CandidateStateError,
  657. "review batch payload changed",
  658. ):
  659. await self.service.apply_review_actions(
  660. "root-a",
  661. "child-a",
  662. report_refs=[candidate, peer],
  663. actions=[action.model_copy(update={
  664. "reason": "tampered decision payload",
  665. })],
  666. effective_at_sequence=1_000,
  667. command_id="adoption-review-call",
  668. )
  669. self.assertEqual(1, self.repository.adoption_calls)
  670. with self.assertRaisesRegex(
  671. CandidateStateError,
  672. "review batch payload changed",
  673. ):
  674. await self.service.apply_review_actions(
  675. "root-a",
  676. "child-a",
  677. report_refs=[candidate, peer],
  678. actions=[
  679. action,
  680. CandidateReviewAction(
  681. action="discard",
  682. candidate_ref=peer,
  683. reason="injected tail action",
  684. ),
  685. ],
  686. effective_at_sequence=1_001,
  687. command_id="adoption-review-call",
  688. )
  689. self.assertEqual("proposed", (
  690. CandidateLedger.model_validate(
  691. await self.store.get_candidate_ledger("root-a")
  692. ).current_state(peer)
  693. ))
  694. with self.assertRaisesRegex(
  695. CandidateStateError,
  696. "review batch payload changed",
  697. ):
  698. await self.service.apply_review_actions(
  699. "root-a",
  700. "child-a",
  701. report_refs=[candidate, peer],
  702. actions=[],
  703. effective_at_sequence=1_002,
  704. command_id="adoption-review-call",
  705. )
  706. async def test_review_batch_replay_rejects_deleted_and_reordered_actions(self):
  707. await self._create_child("child-a")
  708. candidates = []
  709. for sequence, text in ((4, "A"), (5, "B")):
  710. candidate = await self.service.manage(
  711. "child-a",
  712. operation="create",
  713. content={"text": text},
  714. parent_refs=[],
  715. effective_at_sequence=sequence,
  716. )
  717. await self._record_validation("child-a", candidate)
  718. candidates.append(candidate)
  719. actions = [
  720. CandidateReviewAction(
  721. action="discard",
  722. candidate_ref=candidate,
  723. reason=f"discard {candidate.candidate_id}",
  724. )
  725. for candidate in candidates
  726. ]
  727. await self.service.apply_review_actions(
  728. "root-a",
  729. "child-a",
  730. report_refs=candidates,
  731. actions=actions,
  732. effective_at_sequence=10,
  733. command_id="two-action-review",
  734. )
  735. for altered in (actions[:1], list(reversed(actions))):
  736. with self.assertRaisesRegex(
  737. CandidateStateError,
  738. "review batch payload changed",
  739. ):
  740. await self.service.apply_review_actions(
  741. "root-a",
  742. "child-a",
  743. report_refs=candidates,
  744. actions=altered,
  745. effective_at_sequence=999,
  746. command_id="two-action-review",
  747. )
  748. async def test_empty_review_batch_cannot_gain_an_action_during_replay(self):
  749. await self._create_child("child-a")
  750. candidate = await self.service.manage(
  751. "child-a",
  752. operation="create",
  753. content={"text": "A"},
  754. parent_refs=[],
  755. effective_at_sequence=4,
  756. )
  757. await self._record_validation("child-a", candidate)
  758. self.assertEqual([], await self.service.apply_review_actions(
  759. "root-a",
  760. "child-a",
  761. report_refs=[candidate],
  762. actions=[],
  763. effective_at_sequence=10,
  764. command_id="empty-review-batch",
  765. ))
  766. with self.assertRaisesRegex(
  767. CandidateStateError,
  768. "review batch payload changed",
  769. ):
  770. await self.service.apply_review_actions(
  771. "root-a",
  772. "child-a",
  773. report_refs=[candidate],
  774. actions=[CandidateReviewAction(
  775. action="discard",
  776. candidate_ref=candidate,
  777. reason="injected after empty batch",
  778. )],
  779. effective_at_sequence=999,
  780. command_id="empty-review-batch",
  781. )
  782. self.assertEqual(
  783. "proposed",
  784. CandidateLedger.model_validate(
  785. await self.store.get_candidate_ledger("root-a")
  786. ).current_state(candidate),
  787. )
  788. async def test_review_capacity_rejects_before_envelope_or_repository_write(self):
  789. await self._create_child("child-a")
  790. candidate = await self.service.manage(
  791. "child-a",
  792. operation="create",
  793. content={"text": "capacity"},
  794. parent_refs=[],
  795. effective_at_sequence=4,
  796. )
  797. await self._record_validation("child-a", candidate)
  798. ledger = CandidateLedger.model_validate(
  799. await self.store.get_candidate_ledger("root-a")
  800. )
  801. fillers = [
  802. CandidateOperationRecord(
  803. operation_id=f"capacity-filler-{index}",
  804. operation="review_batch",
  805. input_hash="0" * 64,
  806. status="completed",
  807. )
  808. for index in range(
  809. MAX_CANDIDATE_OPERATIONS_PER_ROOT - len(ledger.operations)
  810. )
  811. ]
  812. saturated = CandidateLedger.model_validate({
  813. **ledger.model_dump(mode="json"),
  814. "operations": [
  815. *[item.model_dump(mode="json") for item in ledger.operations],
  816. *[item.model_dump(mode="json") for item in fillers],
  817. ],
  818. })
  819. await self.store.replace_candidate_ledger(
  820. "root-a",
  821. saturated.model_dump(mode="json"),
  822. )
  823. before = await self.store.get_candidate_ledger("root-a")
  824. with self.assertRaisesRegex(
  825. CandidateStateError,
  826. "operation limit exceeded before execution",
  827. ):
  828. await self.service.apply_review_actions(
  829. "root-a",
  830. "child-a",
  831. report_refs=[candidate],
  832. actions=[CandidateReviewAction(
  833. action="adopt",
  834. candidate_ref=candidate,
  835. reason="must not reach repository",
  836. )],
  837. effective_at_sequence=10,
  838. command_id="capacity-overflow-review",
  839. )
  840. self.assertEqual(0, self.repository.adoption_calls)
  841. self.assertEqual(before, await self.store.get_candidate_ledger("root-a"))
  842. async def test_review_capacity_allows_exact_envelope_and_action_boundary(self):
  843. await self._create_child("child-a")
  844. candidate = await self.service.manage(
  845. "child-a",
  846. operation="create",
  847. content={"text": "exact capacity"},
  848. parent_refs=[],
  849. effective_at_sequence=4,
  850. )
  851. await self._record_validation("child-a", candidate)
  852. ledger = CandidateLedger.model_validate(
  853. await self.store.get_candidate_ledger("root-a")
  854. )
  855. target = MAX_CANDIDATE_OPERATIONS_PER_ROOT - 2
  856. fillers = [
  857. CandidateOperationRecord(
  858. operation_id=f"exact-filler-{index}",
  859. operation="review_batch",
  860. input_hash="1" * 64,
  861. status="completed",
  862. )
  863. for index in range(target - len(ledger.operations))
  864. ]
  865. near_limit = CandidateLedger.model_validate({
  866. **ledger.model_dump(mode="json"),
  867. "operations": [
  868. *[item.model_dump(mode="json") for item in ledger.operations],
  869. *[item.model_dump(mode="json") for item in fillers],
  870. ],
  871. })
  872. await self.store.replace_candidate_ledger(
  873. "root-a",
  874. near_limit.model_dump(mode="json"),
  875. )
  876. await self.service.apply_review_actions(
  877. "root-a",
  878. "child-a",
  879. report_refs=[candidate],
  880. actions=[CandidateReviewAction(
  881. action="discard",
  882. candidate_ref=candidate,
  883. reason="fits exact operation boundary",
  884. )],
  885. effective_at_sequence=10,
  886. command_id="exact-capacity-review",
  887. )
  888. completed = CandidateLedger.model_validate(
  889. await self.store.get_candidate_ledger("root-a")
  890. )
  891. self.assertEqual(MAX_CANDIDATE_OPERATIONS_PER_ROOT, len(completed.operations))
  892. self.assertEqual("discarded", completed.current_state(candidate))
  893. async def test_review_batch_reserves_action_slot_across_crash_recovery(self):
  894. await self._create_child("child-a")
  895. candidate = await self.service.manage(
  896. "child-a",
  897. operation="create",
  898. content={"text": "reserved review"},
  899. parent_refs=[],
  900. effective_at_sequence=4,
  901. )
  902. await self._record_validation("child-a", candidate)
  903. ledger = CandidateLedger.model_validate(
  904. await self.store.get_candidate_ledger("root-a")
  905. )
  906. target = MAX_CANDIDATE_OPERATIONS_PER_ROOT - 2
  907. fillers = [
  908. CandidateOperationRecord(
  909. operation_id=f"review-reservation-filler-{index}",
  910. operation="review_batch",
  911. input_hash="2" * 64,
  912. status="completed",
  913. )
  914. for index in range(target - len(ledger.operations))
  915. ]
  916. near_limit = CandidateLedger.model_validate({
  917. **ledger.model_dump(mode="json"),
  918. "operations": [
  919. *[item.model_dump(mode="json") for item in ledger.operations],
  920. *[item.model_dump(mode="json") for item in fillers],
  921. ],
  922. })
  923. await self.store.replace_candidate_ledger(
  924. "root-a",
  925. near_limit.model_dump(mode="json"),
  926. )
  927. action = CandidateReviewAction(
  928. action="adopt",
  929. candidate_ref=candidate,
  930. reason="reserve the complete review batch",
  931. )
  932. original_replace = self.store.replace_candidate_ledger
  933. writes = 0
  934. async def crash_after_batch_reservation(root_trace_id, raw_ledger):
  935. nonlocal writes
  936. writes += 1
  937. if writes == 2:
  938. raise OSError("crash after review reservation")
  939. await original_replace(root_trace_id, raw_ledger)
  940. with patch.object(
  941. self.store,
  942. "replace_candidate_ledger",
  943. side_effect=crash_after_batch_reservation,
  944. ):
  945. with self.assertRaisesRegex(OSError, "review reservation"):
  946. await self.service.apply_review_actions(
  947. "root-a",
  948. "child-a",
  949. report_refs=[candidate],
  950. actions=[action],
  951. effective_at_sequence=10,
  952. command_id="capacity-reserved-review",
  953. )
  954. reserved = CandidateLedger.model_validate(
  955. await self.store.get_candidate_ledger("root-a")
  956. )
  957. self.assertEqual(MAX_CANDIDATE_OPERATIONS_PER_ROOT, len(reserved.operations))
  958. self.assertEqual(2, sum(
  959. item.status == "pending" for item in reserved.operations
  960. ))
  961. repository_version_calls = self.repository.put_calls
  962. with self.assertRaisesRegex(
  963. CandidateStateError,
  964. "operation limit exceeded before execution",
  965. ):
  966. await self.service.manage(
  967. "child-a",
  968. operation="create",
  969. content={"text": "must not steal review capacity"},
  970. parent_refs=[],
  971. effective_at_sequence=11,
  972. command_id="sibling-after-review-reservation",
  973. )
  974. self.assertEqual(repository_version_calls, self.repository.put_calls)
  975. records = await self.service.apply_review_actions(
  976. "root-a",
  977. "child-a",
  978. report_refs=[candidate],
  979. actions=[action],
  980. effective_at_sequence=999,
  981. command_id="capacity-reserved-review",
  982. )
  983. self.assertEqual("adopted", records[0].state)
  984. self.assertEqual(1, self.repository.adoption_calls)
  985. completed = CandidateLedger.model_validate(
  986. await self.store.get_candidate_ledger("root-a")
  987. )
  988. self.assertEqual(MAX_CANDIDATE_OPERATIONS_PER_ROOT, len(completed.operations))
  989. self.assertTrue(all(
  990. item.status == "completed"
  991. for item in completed.operations
  992. if item.command_id == "capacity-reserved-review"
  993. ))
  994. async def test_candidate_capacity_rejects_before_repository_or_intent_write(self):
  995. await self._create_child("child-a")
  996. candidate = await self.service.manage(
  997. "child-a",
  998. operation="create",
  999. content={"text": "seed"},
  1000. parent_refs=[],
  1001. effective_at_sequence=4,
  1002. )
  1003. ledger = CandidateLedger.model_validate(
  1004. await self.store.get_candidate_ledger("root-a")
  1005. )
  1006. saturated_candidates = [candidate]
  1007. saturated_candidates.extend(
  1008. candidate.model_copy(update={
  1009. "candidate_id": f"capacity-candidate-{index}",
  1010. })
  1011. for index in range(1, MAX_CANDIDATES_PER_ROOT)
  1012. )
  1013. saturated = CandidateLedger.model_validate({
  1014. **ledger.model_dump(mode="json"),
  1015. "candidates": [
  1016. item.model_dump(mode="json") for item in saturated_candidates
  1017. ],
  1018. })
  1019. await self.store.replace_candidate_ledger(
  1020. "root-a",
  1021. saturated.model_dump(mode="json"),
  1022. )
  1023. before = await self.store.get_candidate_ledger("root-a")
  1024. repository_calls = self.repository.put_calls
  1025. with self.assertRaisesRegex(
  1026. CandidateStateError,
  1027. "candidate limit exceeded before execution",
  1028. ):
  1029. await self.service.manage(
  1030. "child-a",
  1031. operation="create",
  1032. content={"text": "must remain external-orphan free"},
  1033. parent_refs=[],
  1034. effective_at_sequence=5,
  1035. command_id="candidate-capacity-overflow",
  1036. )
  1037. self.assertEqual(repository_calls, self.repository.put_calls)
  1038. self.assertEqual(before, await self.store.get_candidate_ledger("root-a"))
  1039. async def test_pending_candidate_reserves_capacity_until_recovery(self):
  1040. await self._create_child("child-a")
  1041. seed = await self.service.manage(
  1042. "child-a",
  1043. operation="create",
  1044. content={"text": "seed"},
  1045. parent_refs=[],
  1046. effective_at_sequence=4,
  1047. )
  1048. ledger = CandidateLedger.model_validate(
  1049. await self.store.get_candidate_ledger("root-a")
  1050. )
  1051. near_limit_candidates = [seed]
  1052. near_limit_candidates.extend(
  1053. seed.model_copy(update={
  1054. "candidate_id": f"reserved-candidate-{index}",
  1055. })
  1056. for index in range(1, MAX_CANDIDATES_PER_ROOT - 1)
  1057. )
  1058. near_limit = CandidateLedger.model_validate({
  1059. **ledger.model_dump(mode="json"),
  1060. "candidates": [
  1061. item.model_dump(mode="json") for item in near_limit_candidates
  1062. ],
  1063. })
  1064. await self.store.replace_candidate_ledger(
  1065. "root-a",
  1066. near_limit.model_dump(mode="json"),
  1067. )
  1068. original_replace = self.store.replace_candidate_ledger
  1069. writes = 0
  1070. async def crash_after_repository(root_trace_id, raw_ledger):
  1071. nonlocal writes
  1072. writes += 1
  1073. if writes == 2:
  1074. raise OSError("crash before candidate publish")
  1075. await original_replace(root_trace_id, raw_ledger)
  1076. with patch.object(
  1077. self.store,
  1078. "replace_candidate_ledger",
  1079. side_effect=crash_after_repository,
  1080. ):
  1081. with self.assertRaisesRegex(
  1082. RecoverableToolExecutionError,
  1083. "original command ID",
  1084. ):
  1085. await self.service.manage(
  1086. "child-a",
  1087. operation="create",
  1088. content={"text": "reserved A"},
  1089. parent_refs=[],
  1090. effective_at_sequence=5,
  1091. command_id="reserved-candidate-A",
  1092. )
  1093. pending = CandidateLedger.model_validate(
  1094. await self.store.get_candidate_ledger("root-a")
  1095. )
  1096. self.assertEqual(MAX_CANDIDATES_PER_ROOT - 1, len(pending.candidates))
  1097. self.assertEqual(
  1098. 1,
  1099. sum(
  1100. item.status == "pending" and item.operation == "create"
  1101. for item in pending.operations
  1102. ),
  1103. )
  1104. calls_after_a = self.repository.put_calls
  1105. with self.assertRaisesRegex(
  1106. CandidateStateError,
  1107. "candidate limit exceeded before execution",
  1108. ):
  1109. await self.service.manage(
  1110. "child-a",
  1111. operation="create",
  1112. content={"text": "must not steal A's slot"},
  1113. parent_refs=[],
  1114. effective_at_sequence=6,
  1115. command_id="reserved-candidate-B",
  1116. )
  1117. self.assertEqual(calls_after_a, self.repository.put_calls)
  1118. recovered = await self.service.manage(
  1119. "child-a",
  1120. operation="create",
  1121. content={"text": "reserved A"},
  1122. parent_refs=[],
  1123. effective_at_sequence=99,
  1124. command_id="reserved-candidate-A",
  1125. )
  1126. completed = CandidateLedger.model_validate(
  1127. await self.store.get_candidate_ledger("root-a")
  1128. )
  1129. self.assertEqual(MAX_CANDIDATES_PER_ROOT, len(completed.candidates))
  1130. self.assertEqual(recovered, completed.candidate(recovered))
  1131. async def test_runner_replays_pending_candidate_with_original_tool_call_id(self):
  1132. await self._create_child("child-a")
  1133. registry = get_tool_registry().clone(["manage_candidate"])
  1134. runner = AgentRunner(
  1135. trace_store=self.store,
  1136. tool_registry=registry,
  1137. llm_call=lambda **_kwargs: None,
  1138. application_binding=self.binding,
  1139. candidate_service=self.service,
  1140. )
  1141. config = RunConfig(
  1142. tools=["manage_candidate"],
  1143. tool_groups=[],
  1144. auto_execute_tools=True,
  1145. )
  1146. request = {
  1147. "request": {
  1148. "operation": "create",
  1149. "content": {"text": "recover with the original command"},
  1150. "parent_refs": [],
  1151. },
  1152. }
  1153. assistant = TraceMessage.create(
  1154. trace_id="child-a",
  1155. role="assistant",
  1156. sequence=4,
  1157. content={
  1158. "text": "",
  1159. "tool_calls": [{
  1160. "id": "recoverable-candidate-call",
  1161. "type": "function",
  1162. "function": {
  1163. "name": "manage_candidate",
  1164. "arguments": json.dumps(request),
  1165. },
  1166. }],
  1167. },
  1168. )
  1169. await self.store.add_message(assistant)
  1170. await self.store.update_trace(
  1171. "child-a",
  1172. head_sequence=4,
  1173. last_sequence=4,
  1174. )
  1175. trace = await self.store.get_trace("child-a")
  1176. original_replace = self.store.replace_candidate_ledger
  1177. writes = 0
  1178. async def fail_first_publish(root_trace_id, raw_ledger):
  1179. nonlocal writes
  1180. writes += 1
  1181. if writes == 2:
  1182. raise OSError("candidate publish interrupted")
  1183. await original_replace(root_trace_id, raw_ledger)
  1184. with patch.object(
  1185. self.store,
  1186. "replace_candidate_ledger",
  1187. side_effect=fail_first_publish,
  1188. ):
  1189. with self.assertRaises(RecoverableToolExecutionError):
  1190. await registry.execute(
  1191. "manage_candidate",
  1192. request,
  1193. context=runner._build_tool_context(
  1194. config=config,
  1195. trace=trace,
  1196. trace_id=trace.trace_id,
  1197. goal_id=None,
  1198. goal_tree=None,
  1199. sequence=5,
  1200. head_sequence=4,
  1201. tool_call_id="recoverable-candidate-call",
  1202. side_branch_ctx=None,
  1203. trigger_event=None,
  1204. ),
  1205. allowed_tool_names={"manage_candidate"},
  1206. tool_call_id="recoverable-candidate-call",
  1207. )
  1208. pending = CandidateLedger.model_validate(
  1209. await self.store.get_candidate_ledger("root-a")
  1210. )
  1211. self.assertEqual("pending", pending.operations[-1].status)
  1212. self.assertEqual(1, len(self.repository.contents))
  1213. self.assertFalse(any(
  1214. item.role == "tool"
  1215. for item in await self.store.get_trace_messages("child-a")
  1216. ))
  1217. healed, next_sequence = await runner._heal_orphaned_tool_calls(
  1218. [assistant],
  1219. await self.store.get_trace("child-a"),
  1220. None,
  1221. config,
  1222. 5,
  1223. )
  1224. self.assertEqual(6, next_sequence)
  1225. self.assertEqual("tool", healed[-1].role)
  1226. self.assertEqual(
  1227. "recoverable-candidate-call",
  1228. healed[-1].tool_call_id,
  1229. )
  1230. self.assertEqual(1, len(self.repository.contents))
  1231. completed = CandidateLedger.model_validate(
  1232. await self.store.get_candidate_ledger("root-a")
  1233. )
  1234. matching = [
  1235. item for item in completed.operations
  1236. if item.command_id == "recoverable-candidate-call"
  1237. ]
  1238. self.assertEqual(1, len(matching))
  1239. self.assertEqual("completed", matching[0].status)
  1240. self.assertEqual(1, len(completed.candidates))
  1241. async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
  1242. await self._create_child("child-a")
  1243. await self.store.create_trace(Trace(
  1244. trace_id="grandchild-a",
  1245. mode="agent",
  1246. agent_type="writer",
  1247. uid="user-1",
  1248. parent_trace_id="child-a",
  1249. context=self._context(
  1250. "root-a",
  1251. task_brief=TaskBrief(
  1252. objective="write descendant candidate",
  1253. reason="the child needs an option",
  1254. completion_criteria=["candidate is complete"],
  1255. expected_outputs=["one candidate"],
  1256. ),
  1257. ),
  1258. ))
  1259. candidate = await self.service.manage(
  1260. "grandchild-a",
  1261. operation="create",
  1262. content={"text": "nested candidate"},
  1263. parent_refs=[],
  1264. effective_at_sequence=4,
  1265. )
  1266. await self._record_validation("grandchild-a", candidate)
  1267. await self.service.apply_review_actions(
  1268. "child-a",
  1269. "grandchild-a",
  1270. report_refs=[candidate],
  1271. actions=[CandidateReviewAction(
  1272. action="adopt",
  1273. candidate_ref=candidate,
  1274. reason="commit descendant output",
  1275. )],
  1276. effective_at_sequence=9,
  1277. )
  1278. with self.assertRaisesRegex(CandidateStateError, "committed"):
  1279. await self.service.assert_rewind_allowed("root-a", 100)
  1280. async def test_failed_candidate_can_only_be_revised_or_discarded(self):
  1281. await self._create_child("child-a")
  1282. candidate = await self.service.manage(
  1283. "child-a",
  1284. operation="create",
  1285. content={"text": "placeholder"},
  1286. parent_refs=[],
  1287. effective_at_sequence=4,
  1288. )
  1289. await self._record_validation("child-a", candidate, outcome="failed")
  1290. with self.assertRaisesRegex(CandidateStateError, "passed"):
  1291. await self.service.apply_review_actions(
  1292. "root-a",
  1293. "child-a",
  1294. report_refs=[candidate],
  1295. actions=[CandidateReviewAction(
  1296. action="adopt",
  1297. candidate_ref=candidate,
  1298. reason="invalid",
  1299. )],
  1300. effective_at_sequence=9,
  1301. )
  1302. records = await self.service.apply_review_actions(
  1303. "root-a",
  1304. "child-a",
  1305. report_refs=[candidate],
  1306. actions=[CandidateReviewAction(
  1307. action="revise",
  1308. candidate_ref=candidate,
  1309. reason="remove placeholder",
  1310. )],
  1311. effective_at_sequence=10,
  1312. )
  1313. self.assertEqual("discarded", records[0].state)
  1314. self.assertIn("retry_from=output", records[0].reason)
  1315. async def test_adoption_replays_same_operation_after_finalize_crash(self):
  1316. await self._create_child("child-a")
  1317. candidate = await self.service.manage(
  1318. "child-a",
  1319. operation="create",
  1320. content={"text": "A"},
  1321. parent_refs=[],
  1322. effective_at_sequence=4,
  1323. )
  1324. await self._record_validation("child-a", candidate)
  1325. action = CandidateReviewAction(
  1326. action="adopt",
  1327. candidate_ref=candidate,
  1328. reason="publish once",
  1329. )
  1330. original_replace = self.store.replace_candidate_ledger
  1331. calls = 0
  1332. async def fail_finalize(root_trace_id, ledger):
  1333. nonlocal calls
  1334. calls += 1
  1335. if calls == 3:
  1336. raise OSError("crash before framework finalize")
  1337. await original_replace(root_trace_id, ledger)
  1338. with patch.object(
  1339. self.store,
  1340. "replace_candidate_ledger",
  1341. side_effect=fail_finalize,
  1342. ):
  1343. with self.assertRaisesRegex(OSError, "finalize"):
  1344. await self.service.apply_review_actions(
  1345. "root-a",
  1346. "child-a",
  1347. report_refs=[candidate],
  1348. actions=[action],
  1349. effective_at_sequence=9,
  1350. )
  1351. pending = CandidateLedger.model_validate(
  1352. await self.store.get_candidate_ledger("root-a")
  1353. )
  1354. self.assertEqual("adoption_pending", pending.current_state(candidate))
  1355. await self.service.apply_review_actions(
  1356. "root-a",
  1357. "child-a",
  1358. report_refs=[candidate],
  1359. actions=[action],
  1360. effective_at_sequence=9,
  1361. )
  1362. self.assertEqual(2, self.repository.adoption_calls)
  1363. self.assertEqual(1, len(self.repository.adoptions))
  1364. completed = CandidateLedger.model_validate(
  1365. await self.store.get_candidate_ledger("root-a")
  1366. )
  1367. self.assertEqual("adopted", completed.current_state(candidate))
  1368. if __name__ == "__main__":
  1369. unittest.main()