test_application_reference.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. from copy import deepcopy
  2. import asyncio
  3. import json
  4. import tempfile
  5. import unittest
  6. from unittest.mock import patch
  7. from cyber_agent.application import (
  8. ApplicationRegistry,
  9. ApplicationRuntime,
  10. CandidateReviewAction,
  11. )
  12. from cyber_agent.application.candidate import (
  13. CandidateLedger,
  14. CandidatePointer,
  15. CandidateRef,
  16. )
  17. from cyber_agent.core.artifacts import ArtifactRef
  18. from cyber_agent.core.run_snapshot import (
  19. RunConfigSnapshotV2,
  20. persist_run_config_snapshot,
  21. )
  22. from cyber_agent.core.runner import RunConfig
  23. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  24. from cyber_agent.core.task_protocol import (
  25. Finding,
  26. Hypothesis,
  27. Question,
  28. TaskBrief,
  29. TaskProgress,
  30. WorkItem,
  31. initialize_task_progress,
  32. new_task_protocol,
  33. )
  34. from cyber_agent.trace.models import Message, Trace
  35. from cyber_agent.trace.store import FileSystemTraceStore
  36. from cyber_agent.tools.builtin.subagent import _record_pending_task_reports
  37. from examples.application_reference import build_reference_components
  38. def _tool_call(call_id, name, arguments):
  39. return {
  40. "content": "",
  41. "tool_calls": [{
  42. "id": call_id,
  43. "type": "function",
  44. "function": {
  45. "name": name,
  46. "arguments": json.dumps(arguments),
  47. },
  48. }],
  49. "finish_reason": "tool_calls",
  50. }
  51. def _has_tool_result(messages, name):
  52. return any(
  53. item.get("role") == "tool" and item.get("name") == name
  54. for item in messages
  55. )
  56. def _tool_names(schemas):
  57. return {item["function"]["name"] for item in schemas or []}
  58. class ApplicationReferenceEndToEndTest(unittest.IsolatedAsyncioTestCase):
  59. async def test_real_agent_recovers_orphaned_adoption_review_after_restart(self):
  60. with tempfile.TemporaryDirectory() as temp_dir:
  61. store = FileSystemTraceStore(temp_dir)
  62. components = build_reference_components()
  63. registry = ApplicationRegistry()
  64. registry.register(components.application, components.services)
  65. delegated = False
  66. reviewed = False
  67. async def llm_call(**kwargs):
  68. nonlocal delegated, reviewed
  69. messages = kwargs["messages"]
  70. if any(
  71. "recursive_validation_protocol" in str(item.get("content", ""))
  72. for item in messages
  73. if item.get("role") == "system"
  74. ):
  75. packet = next(
  76. json.loads(item["content"])
  77. for item in messages
  78. if item.get("role") == "user"
  79. and "recursive_validation_protocol"
  80. in str(item.get("content", ""))
  81. )
  82. scope = packet["validation_scope"]
  83. return {
  84. "content": json.dumps({
  85. "scope": scope,
  86. "outcome": "passed",
  87. "checks": [{
  88. "check_id": item["check_id"],
  89. "status": "passed",
  90. "evidence_refs": [],
  91. "issue": None,
  92. } for item in packet["validation_plan"]["checks"]
  93. if item["scope"] == scope],
  94. "reason": "reference output is complete",
  95. "retry_from": None,
  96. }),
  97. "tool_calls": [],
  98. "finish_reason": "stop",
  99. }
  100. system = "\n".join(
  101. str(item.get("content", ""))
  102. for item in messages
  103. if item.get("role") == "system"
  104. )
  105. names = _tool_names(kwargs.get("tools"))
  106. if "Create replaceable candidate versions" in system:
  107. if not _has_tool_result(messages, "manage_candidate"):
  108. self.assertIn("manage_candidate", names)
  109. self.assertIn("submit_task_report", names)
  110. return _tool_call("candidate-create", "manage_candidate", {
  111. "request": {
  112. "operation": "create",
  113. "content": {"text": "A finished candidate."},
  114. "parent_refs": [],
  115. },
  116. })
  117. candidate_message = next(
  118. item for item in reversed(messages)
  119. if item.get("role") == "tool"
  120. and item.get("name") == "manage_candidate"
  121. )
  122. candidate_ref = json.loads(
  123. candidate_message["content"]
  124. )["candidate_ref"]
  125. if not _has_tool_result(messages, "update_task_progress"):
  126. return _tool_call("writer-progress", "update_task_progress", {
  127. "expected_revision": 1,
  128. "progress": {
  129. "phase": "ready_to_submit",
  130. "questions": [],
  131. "blockers": [],
  132. "findings": [],
  133. "hypotheses": [],
  134. "work_items": [],
  135. "decision_rationale": "The exact candidate is registered.",
  136. },
  137. })
  138. if not _has_tool_result(messages, "submit_task_report"):
  139. return _tool_call("writer-report", "submit_task_report", {
  140. "task_report": {
  141. "summary": "One complete candidate is ready.",
  142. "outcome": "satisfied",
  143. "validation": {
  144. "hard_passed": True,
  145. "open_issues": [],
  146. },
  147. "next_step_suggestion": {
  148. "direction": "NONE",
  149. "reason": "Parent may adopt the exact revision.",
  150. },
  151. "outputs": [],
  152. "evidence": [],
  153. "remaining_issues": [],
  154. "candidate_refs": [candidate_ref],
  155. },
  156. })
  157. return {"content": "writer submitted", "tool_calls": []}
  158. if names == {"review_task_result"}:
  159. root = next(
  160. item for item in await store.list_traces(limit=20)
  161. if item.parent_trace_id is None
  162. )
  163. child_id, pending = next(iter(
  164. root.context["task_protocol"]["pending_reviews"].items()
  165. ))
  166. candidate_ref = pending["task_report"]["candidate_refs"][0]
  167. reviewed = True
  168. return _tool_call("adopt-review", "review_task_result", {
  169. "child_trace_id": child_id,
  170. "decision": "ASCEND",
  171. "reason": "Adopt the validated exact candidate revision.",
  172. "candidate_actions": [{
  173. "action": "adopt",
  174. "candidate_ref": candidate_ref,
  175. "reason": "The candidate passed independent validation.",
  176. }],
  177. })
  178. if not delegated:
  179. delegated = True
  180. return _tool_call("delegate-writer", "agent", {
  181. "agent_type": "writer",
  182. "task_brief": {
  183. "objective": "Produce one finished candidate",
  184. "reason": "The editor needs an adoptable output",
  185. "completion_criteria": ["No placeholder remains"],
  186. "expected_outputs": ["One candidate revision"],
  187. "validation_scopes": ["output"],
  188. },
  189. })
  190. if reviewed and not _has_tool_result(
  191. messages,
  192. "update_task_progress",
  193. ):
  194. return _tool_call("root-progress", "update_task_progress", {
  195. "expected_revision": 1,
  196. "progress": {
  197. "phase": "ready_to_submit",
  198. "questions": [],
  199. "blockers": [],
  200. "findings": [],
  201. "hypotheses": [],
  202. "work_items": [],
  203. "decision_rationale": "The validated candidate was adopted.",
  204. },
  205. })
  206. return {"content": "Final adopted content", "tool_calls": []}
  207. runtime = ApplicationRuntime(
  208. registry=registry,
  209. trace_store=store,
  210. llm_call=llm_call,
  211. )
  212. runner, config = runtime.new_run(
  213. "application_reference",
  214. "1",
  215. uid="reference-user",
  216. root_task_anchor={
  217. "objective": "Create one complete output",
  218. "completion_criteria": ["Adopt one validated candidate"],
  219. "constraints": ["No placeholders"],
  220. },
  221. )
  222. config.knowledge = KnowledgeConfig(
  223. enable_extraction=False,
  224. enable_completion_extraction=False,
  225. enable_injection=False,
  226. )
  227. original_update_trace = store.update_trace
  228. interrupted = False
  229. async def interrupt_after_adoption(trace_id, **updates):
  230. nonlocal interrupted
  231. context = updates.get("context")
  232. reviews = (
  233. context.get("task_protocol", {}).get("reviews", [])
  234. if isinstance(context, dict)
  235. else []
  236. )
  237. if (
  238. not interrupted
  239. and any(
  240. item.get("review_command_id") == "adopt-review"
  241. for item in reviews
  242. )
  243. ):
  244. interrupted = True
  245. raise asyncio.CancelledError()
  246. return await original_update_trace(trace_id, **updates)
  247. with patch.object(
  248. store,
  249. "update_trace",
  250. side_effect=interrupt_after_adoption,
  251. ), patch.dict(
  252. "os.environ",
  253. {"AGENT_MODE": "recursive"},
  254. clear=False,
  255. ):
  256. with self.assertRaises(asyncio.CancelledError):
  257. await runner.run_result(
  258. [{"role": "user", "content": "Create the output"}],
  259. config,
  260. )
  261. self.assertTrue(interrupted)
  262. roots = [
  263. item for item in await store.list_traces(limit=20)
  264. if item.parent_trace_id is None
  265. ]
  266. self.assertEqual(1, len(roots))
  267. root = roots[0]
  268. self.assertEqual(1, components.candidates.adoption_calls)
  269. self.assertTrue(
  270. root.context["task_protocol"]["pending_reviews"]
  271. )
  272. orphaned = [
  273. item for item in await store.get_trace_messages(root.trace_id)
  274. if item.role == "assistant"
  275. and isinstance(item.content, dict)
  276. and any(
  277. call.get("id") == "adopt-review"
  278. for call in item.content.get("tool_calls", [])
  279. )
  280. ]
  281. self.assertEqual(1, len(orphaned))
  282. self.assertFalse(any(
  283. item.role == "tool" and item.tool_call_id == "adopt-review"
  284. for item in await store.get_trace_messages(root.trace_id)
  285. ))
  286. restarted_registry = ApplicationRegistry()
  287. restarted_registry.register(
  288. components.application,
  289. components.services,
  290. )
  291. restarted_runtime = ApplicationRuntime(
  292. registry=restarted_registry,
  293. trace_store=store,
  294. llm_call=llm_call,
  295. )
  296. restored_runner, restored_config = await restarted_runtime.restore(
  297. root.trace_id
  298. )
  299. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  300. result = await restored_runner.run_result([], restored_config)
  301. self.assertEqual("completed", result["status"])
  302. self.assertTrue(reviewed)
  303. self.assertEqual(1, len(components.candidates.versions))
  304. self.assertEqual(1, components.candidates.adoption_calls)
  305. root = await store.get_trace(result["trace_id"])
  306. ledger = CandidateLedger.model_validate(
  307. await store.get_candidate_ledger(root.trace_id)
  308. )
  309. self.assertEqual("adopted", ledger.current_state(ledger.candidates[0]))
  310. self.assertIn(
  311. "candidate-create",
  312. {item.command_id for item in ledger.operations},
  313. )
  314. self.assertIn(
  315. "adopt-review",
  316. {item.command_id for item in ledger.operations},
  317. )
  318. self.assertEqual(1, len([
  319. item for item in await store.get_trace_messages(root.trace_id)
  320. if item.role == "tool" and item.tool_call_id == "adopt-review"
  321. ]))
  322. self.assertEqual(
  323. 1,
  324. len(root.context["task_protocol"]["reviews"]),
  325. )
  326. async def test_local_candidate_retry_preserves_fact_and_peer_validation(self):
  327. with tempfile.TemporaryDirectory() as temp_dir:
  328. store = FileSystemTraceStore(temp_dir)
  329. components = build_reference_components()
  330. registry = ApplicationRegistry()
  331. binding = registry.register(
  332. components.application,
  333. components.services,
  334. )
  335. llm_calls = 0
  336. async def llm_call(**kwargs):
  337. nonlocal llm_calls
  338. llm_calls += 1
  339. packet = json.loads(kwargs["messages"][-1]["content"])
  340. scope = packet["validation_scope"]
  341. return {
  342. "content": json.dumps({
  343. "scope": scope,
  344. "outcome": "passed",
  345. "checks": [
  346. {
  347. "check_id": item["check_id"],
  348. "status": "passed",
  349. "evidence_refs": [],
  350. "issue": None,
  351. }
  352. for item in packet["validation_plan"]["checks"]
  353. ],
  354. "reason": "reference candidate is complete",
  355. "retry_from": None,
  356. }),
  357. "tool_calls": [],
  358. }
  359. runtime = ApplicationRuntime(
  360. registry=registry,
  361. trace_store=store,
  362. llm_call=llm_call,
  363. )
  364. runner, config = runtime.new_run(
  365. "application_reference",
  366. "1",
  367. uid="reference-user",
  368. root_task_anchor={
  369. "objective": "Create an evidence-grounded explanation",
  370. "completion_criteria": ["Use verified facts and finished copy"],
  371. "constraints": ["Do not publish placeholder text"],
  372. },
  373. )
  374. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  375. root, _goal, _sequence = await runner._prepare_new_trace(
  376. [{"role": "user", "content": "write the explanation"}],
  377. config,
  378. )
  379. await store.add_message(Message.create(
  380. trace_id=root.trace_id,
  381. role="user",
  382. sequence=1,
  383. content="write the explanation",
  384. ))
  385. await store.add_message(Message.create(
  386. trace_id=root.trace_id,
  387. role="assistant",
  388. sequence=2,
  389. parent_sequence=1,
  390. content="drafting",
  391. ))
  392. await store.update_trace(root.trace_id, head_sequence=2)
  393. async def execute_tool(trace_id, tool_name, arguments, sequence):
  394. tool_call_id = f"reference-{tool_name}-{sequence}"
  395. result = await binding.tool_registry.execute(
  396. tool_name,
  397. arguments,
  398. uid=root.uid,
  399. context={
  400. "store": store,
  401. "trace_id": trace_id,
  402. "sequence": sequence,
  403. "tool_call_id": tool_call_id,
  404. "task_protocol_service": runner.task_protocol_service,
  405. "candidate_service": runner.candidate_service,
  406. "event_service": runner.event_service,
  407. },
  408. allowed_tool_names={tool_name},
  409. )
  410. if tool_name == "manage_candidate":
  411. trace = await store.get_trace(trace_id)
  412. await store.add_message(Message.create(
  413. trace_id=trace_id,
  414. role="tool",
  415. sequence=sequence,
  416. parent_sequence=(trace.head_sequence or None),
  417. tool_call_id=tool_call_id,
  418. content={"tool_name": tool_name, "result": "registered"},
  419. ))
  420. await store.update_trace(trace_id, head_sequence=sequence)
  421. if isinstance(result, str):
  422. return json.loads(result)
  423. text = result.get("text") if isinstance(result, dict) else None
  424. return json.loads(text) if isinstance(text, str) else result
  425. async def create_child(trace_id, role_id, brief):
  426. context = deepcopy(root.context)
  427. context.pop("run_config_snapshot", None)
  428. context["application_role_id"] = role_id
  429. context["application_role_hash"] = binding.role(role_id).role_hash
  430. context["agent_depth"] = 1
  431. state = new_task_protocol(brief)
  432. initialize_task_progress(state, effective_at_sequence=1)
  433. context["task_protocol"] = state
  434. child_config = RunConfig(
  435. trace_id=trace_id,
  436. uid=root.uid,
  437. parent_trace_id=root.trace_id,
  438. context=context,
  439. enable_research_flow=False,
  440. )
  441. binding.configure_run_config(child_config, role_id)
  442. context["effective_run_limits"] = dict(
  443. child_config.effective_run_limits
  444. )
  445. child_config.context = context
  446. persist_run_config_snapshot(
  447. context,
  448. RunConfigSnapshotV2.from_run_config(
  449. child_config,
  450. memory_identity=None,
  451. ),
  452. )
  453. child = Trace(
  454. trace_id=trace_id,
  455. mode="agent",
  456. agent_type=role_id,
  457. uid=root.uid,
  458. model=binding.role(role_id).role.model,
  459. parent_trace_id=root.trace_id,
  460. context=context,
  461. )
  462. await store.create_trace(child)
  463. return child
  464. fact_child = await create_child(
  465. "reference-fact",
  466. "fact_checker",
  467. TaskBrief(
  468. objective="Verify what an agent framework supplies",
  469. reason="The content needs one traceable fact",
  470. completion_criteria=["Return one sourced finding"],
  471. expected_outputs=["fact artifact"],
  472. validation_scopes=["evidence"],
  473. ),
  474. )
  475. raw_fact_result = await binding.tool_registry.execute(
  476. "verify_fact",
  477. {"question": "What does an agent framework supply?"},
  478. uid=root.uid,
  479. context={
  480. "trace_id": fact_child.trace_id,
  481. "store": store,
  482. },
  483. allowed_tool_names={"verify_fact"},
  484. )
  485. fact_ref = ArtifactRef.model_validate(
  486. json.loads(raw_fact_result)["artifact_ref"]
  487. )
  488. await runner.task_protocol_service.update_progress(
  489. fact_child.trace_id,
  490. expected_revision=1,
  491. progress=TaskProgress(
  492. phase="ready_to_submit",
  493. questions=[Question(
  494. item_id="fact-question",
  495. text="What does the framework supply?",
  496. state="answered",
  497. answer="Reusable execution contracts",
  498. artifact_refs=[fact_ref],
  499. )],
  500. findings=[Finding(
  501. item_id="fact-finding",
  502. statement="The framework supplies reusable execution contracts.",
  503. basis="reference fixture",
  504. artifact_refs=[fact_ref],
  505. )],
  506. hypotheses=[Hypothesis(
  507. item_id="fact-hypothesis",
  508. statement="The explanation can distinguish framework from business code.",
  509. state="supported",
  510. rationale="The verified definition supports the distinction.",
  511. artifact_refs=[fact_ref],
  512. )],
  513. work_items=[WorkItem(
  514. item_id="fact-work",
  515. description="verify the definition",
  516. state="done",
  517. result_summary="one fact artifact recorded",
  518. artifact_refs=[fact_ref],
  519. )],
  520. decision_rationale="The question is answered by one stable fixture.",
  521. ),
  522. effective_at_sequence=2,
  523. )
  524. writer = await create_child(
  525. "reference-writer",
  526. "writer",
  527. TaskBrief(
  528. objective="Produce two candidate explanations",
  529. reason="The editor needs alternatives",
  530. completion_criteria=["Both candidates use the verified fact"],
  531. expected_outputs=["candidate A", "candidate B"],
  532. validation_scopes=["output"],
  533. ),
  534. )
  535. candidate_a = CandidateRef.model_validate((await execute_tool(
  536. writer.trace_id,
  537. "manage_candidate",
  538. {"request": {
  539. "operation": "create",
  540. "content": {
  541. "text": "A finished explanation of reusable contracts."
  542. },
  543. "parent_refs": [],
  544. }},
  545. 4,
  546. ))["candidate_ref"])
  547. candidate_b = CandidateRef.model_validate((await execute_tool(
  548. writer.trace_id,
  549. "manage_candidate",
  550. {"request": {
  551. "operation": "create",
  552. "content": {"text": "B says {{placeholder}}."},
  553. "parent_refs": [],
  554. }},
  555. 5,
  556. ))["candidate_ref"])
  557. await runner.task_protocol_service.update_progress(
  558. writer.trace_id,
  559. expected_revision=1,
  560. progress=TaskProgress(
  561. phase="ready_to_submit",
  562. findings=[Finding(
  563. item_id="writer-finding",
  564. statement="Both candidates derive from the verified definition.",
  565. basis="fact artifact",
  566. artifact_refs=[fact_ref],
  567. )],
  568. work_items=[WorkItem(
  569. item_id="writer-work",
  570. description="draft two alternatives",
  571. state="done",
  572. result_summary="A and B registered",
  573. )],
  574. decision_rationale="Both candidate revisions are ready for validation.",
  575. ),
  576. effective_at_sequence=6,
  577. )
  578. validation_a = await runner.validate_recursive_trace(
  579. writer.trace_id,
  580. candidate_ref=candidate_a,
  581. )
  582. validation_b = await runner.validate_recursive_trace(
  583. writer.trace_id,
  584. candidate_ref=candidate_b,
  585. )
  586. self.assertEqual("passed", validation_a.result.outcome)
  587. self.assertEqual("failed", validation_b.result.outcome)
  588. self.assertEqual(1, components.facts.calls)
  589. self.assertEqual(
  590. 1,
  591. binding.tool_registry.get_stats("verify_fact")[
  592. "verify_fact"
  593. ]["call_count"],
  594. )
  595. self.assertEqual(1, llm_calls)
  596. frozen_a = validation_a.result.model_dump(mode="json")
  597. fresh_root = await store.get_trace(root.trace_id)
  598. fresh_writer = await store.get_trace(writer.trace_id)
  599. await runner._mark_trace_stopped(
  600. root.trace_id,
  601. fresh_root.head_sequence,
  602. )
  603. await runner._mark_trace_stopped(
  604. writer.trace_id,
  605. fresh_writer.head_sequence,
  606. )
  607. restarted_components = build_reference_components(
  608. artifacts=components.artifacts,
  609. candidates=components.candidates,
  610. projector=components.projector,
  611. )
  612. restarted_registry = ApplicationRegistry()
  613. restarted_registry.register(
  614. restarted_components.application,
  615. restarted_components.services,
  616. )
  617. restarted_runtime = ApplicationRuntime(
  618. registry=restarted_registry,
  619. trace_store=store,
  620. llm_call=llm_call,
  621. )
  622. restored_runner, restored_config = await restarted_runtime.restore(
  623. writer.trace_id
  624. )
  625. self.assertEqual("writer", restored_config.role_id)
  626. self.assertEqual(0, restarted_components.context.list_calls)
  627. self.assertEqual(0, restarted_components.context.resolve_calls)
  628. await restored_runner.candidate_service.apply_review_actions(
  629. root.trace_id,
  630. writer.trace_id,
  631. report_refs=[candidate_a, candidate_b],
  632. actions=[CandidateReviewAction(
  633. action="revise",
  634. candidate_ref=candidate_b,
  635. reason="remove the placeholder without repeating fact research",
  636. )],
  637. effective_at_sequence=7,
  638. )
  639. runner = restored_runner
  640. binding = restarted_registry.resolve("application_reference", "1")
  641. candidate_b2 = CandidateRef.model_validate((await execute_tool(
  642. writer.trace_id,
  643. "manage_candidate",
  644. {"request": {
  645. "operation": "fork",
  646. "content": {
  647. "text": (
  648. "B2 is a finished explanation of reusable contracts."
  649. )
  650. },
  651. "parent_refs": [CandidatePointer(
  652. candidate_id=candidate_b.candidate_id,
  653. revision=candidate_b.revision,
  654. ).model_dump(mode="json")],
  655. }},
  656. 8,
  657. ))["candidate_ref"])
  658. validation_b2 = await restored_runner.validate_recursive_trace(
  659. writer.trace_id,
  660. candidate_ref=candidate_b2,
  661. )
  662. self.assertEqual("passed", validation_b2.result.outcome)
  663. self.assertEqual(2, llm_calls)
  664. self.assertEqual(
  665. 1,
  666. components.facts.calls + restarted_components.facts.calls,
  667. )
  668. self.assertEqual(
  669. frozen_a,
  670. (await restored_runner.validate_recursive_trace(
  671. writer.trace_id,
  672. candidate_ref=candidate_a,
  673. )).result.model_dump(mode="json"),
  674. )
  675. self.assertEqual(2, llm_calls)
  676. messages = await store.get_trace_messages(root.trace_id)
  677. cutoff = min(item.sequence for item in messages)
  678. await restored_runner._rewind(root.trace_id, cutoff, None)
  679. await restored_runner.candidate_service.apply_review_actions(
  680. root.trace_id,
  681. writer.trace_id,
  682. report_refs=[candidate_a, candidate_b2],
  683. actions=[CandidateReviewAction(
  684. action="adopt",
  685. candidate_ref=candidate_b2,
  686. reason="publish the corrected exact revision",
  687. )],
  688. effective_at_sequence=20,
  689. )
  690. self.assertEqual(1, components.candidates.adoption_calls)
  691. self.assertEqual(1, len(components.candidates.adoptions))
  692. await restored_runner.event_service.try_pump(root.trace_id)
  693. before_replay = dict(components.projector.rows)
  694. await restarted_runtime.reconcile_events(root.trace_id)
  695. await restarted_runtime.reconcile_events(root.trace_id)
  696. self.assertEqual(before_replay, components.projector.rows)
  697. adopted_keys = [
  698. key for key in components.projector.rows
  699. if key.endswith(":adopted")
  700. ]
  701. self.assertEqual(1, len(adopted_keys))
  702. with self.assertRaisesRegex(ValueError, "committed candidate adoption"):
  703. await restored_runner._rewind(root.trace_id, cutoff, None)
  704. ledger = CandidateLedger.model_validate(
  705. await store.get_candidate_ledger(root.trace_id)
  706. )
  707. self.assertEqual(3, len(ledger.candidates))
  708. self.assertEqual(3, len(ledger.validations))
  709. self.assertEqual(
  710. [(candidate_b.candidate_id, candidate_b.revision)],
  711. [
  712. (item.candidate_id, item.revision)
  713. for item in candidate_b2.parent_refs
  714. ],
  715. )