test_persistence.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. from __future__ import annotations
  2. import json
  3. import tempfile
  4. import unittest
  5. from pathlib import Path
  6. from typing import TypedDict, get_type_hints
  7. from langchain_core.messages import AIMessage, ToolMessage
  8. from langgraph.graph import END, START, StateGraph
  9. from production_build_agents.agents.invocation import recovered_tool_progress
  10. from production_build_agents.run.langgraph_checkpointer import (
  11. create_sqlite_checkpointer,
  12. )
  13. from production_build_agents.run.lock import RunLockError, acquire_run_lock
  14. from production_build_agents.run.operation_journal import (
  15. OperationJournal,
  16. OperationOutcomeUnknownError,
  17. UncertainSideEffectError,
  18. )
  19. from production_build_agents.run.records import (
  20. VersionConflictError,
  21. save_executor_candidate,
  22. save_run_summary,
  23. write_once_json,
  24. write_once_text,
  25. )
  26. class RunPersistenceTest(unittest.TestCase):
  27. def test_executor_candidate_writer_has_resolvable_annotations(self) -> None:
  28. hints = get_type_hints(save_executor_candidate)
  29. self.assertEqual(hints["candidate"].__name__, "ExecutorCandidate")
  30. def test_run_summary_is_written_once_with_merged_event_log(self) -> None:
  31. with tempfile.TemporaryDirectory() as temp_dir:
  32. root = Path(temp_dir)
  33. state = {
  34. "run_id": "Run-summary",
  35. "status": "RUNNING",
  36. "event_log": ["started"],
  37. }
  38. update = {
  39. "status": "COMPLETED",
  40. "phase": "FINALIZE",
  41. "event_log": ["completed"],
  42. }
  43. path = save_run_summary(root, state, update)
  44. self.assertEqual(path, save_run_summary(root, state, update))
  45. saved = json.loads(path.read_text(encoding="utf-8"))
  46. self.assertEqual(saved["status"], "COMPLETED")
  47. self.assertEqual(saved["event_log"], ["started", "completed"])
  48. with self.assertRaises(VersionConflictError):
  49. save_run_summary(
  50. root,
  51. state,
  52. {**update, "status": "FAILED"},
  53. )
  54. def test_recovered_progress_counts_known_failed_side_effect(self) -> None:
  55. messages = [
  56. AIMessage(
  57. content="",
  58. tool_calls=[
  59. {
  60. "name": "run_tool",
  61. "args": {
  62. "tool_id": "fake",
  63. "params": {"value": 1},
  64. },
  65. "id": "call-1",
  66. "type": "tool_call",
  67. }
  68. ],
  69. ),
  70. ToolMessage(
  71. content=json.dumps(
  72. {
  73. "success": False,
  74. "_operation_id": "executor:1",
  75. }
  76. ),
  77. tool_call_id="call-1",
  78. name="run_tool",
  79. ),
  80. ]
  81. inspected, completed = recovered_tool_progress(
  82. messages,
  83. side_effect_tools={"run_tool"},
  84. )
  85. self.assertEqual(inspected, set())
  86. self.assertEqual(completed, 1)
  87. def test_write_once_reuses_identical_content_and_rejects_conflict(
  88. self,
  89. ) -> None:
  90. with tempfile.TemporaryDirectory() as temp_dir:
  91. root = Path(temp_dir)
  92. json_path = root / "record.json"
  93. text_path = root / "record.txt"
  94. self.assertEqual(
  95. write_once_json(json_path, {"value": 1}),
  96. write_once_json(json_path, {"value": 1}),
  97. )
  98. self.assertEqual(
  99. write_once_text(text_path, "stable\n"),
  100. write_once_text(text_path, "stable\n"),
  101. )
  102. with self.assertRaises(VersionConflictError):
  103. write_once_json(json_path, {"value": 2})
  104. with self.assertRaises(VersionConflictError):
  105. write_once_text(text_path, "changed\n")
  106. def test_operation_journal_replays_success(self) -> None:
  107. with tempfile.TemporaryDirectory() as temp_dir:
  108. root = Path(temp_dir)
  109. calls = 0
  110. def operation() -> dict[str, object]:
  111. nonlocal calls
  112. calls += 1
  113. return {"success": True, "artifact": "local://one"}
  114. first = OperationJournal(root, "executor-Task1-v1")
  115. result = first.execute(
  116. tool_name="run_tool",
  117. params={"prompt": "stable"},
  118. operation=operation,
  119. )
  120. restarted = OperationJournal(root, "executor-Task1-v1")
  121. replay = restarted.execute(
  122. tool_name="run_tool",
  123. params={"prompt": "stable"},
  124. operation=operation,
  125. )
  126. self.assertTrue(result["success"])
  127. self.assertTrue(replay["_operation_replayed"])
  128. self.assertEqual(
  129. replay["_operation_id"],
  130. result["_operation_id"],
  131. )
  132. self.assertEqual(calls, 1)
  133. def test_operation_journal_rejects_parameter_drift(self) -> None:
  134. with tempfile.TemporaryDirectory() as temp_dir:
  135. root = Path(temp_dir)
  136. OperationJournal(root, "executor-Task1-v1").execute(
  137. tool_name="run_tool",
  138. params={"prompt": "first"},
  139. operation=lambda: {"success": True},
  140. )
  141. with self.assertRaises(VersionConflictError):
  142. OperationJournal(root, "executor-Task1-v1").execute(
  143. tool_name="run_tool",
  144. params={"prompt": "changed"},
  145. operation=lambda: {"success": True},
  146. )
  147. def test_operation_journal_replays_known_failure(self) -> None:
  148. with tempfile.TemporaryDirectory() as temp_dir:
  149. root = Path(temp_dir)
  150. first = OperationJournal(root, "executor-Task1-v1")
  151. result = first.execute(
  152. tool_name="crop_image",
  153. params={"image": "missing.png"},
  154. operation=lambda: (_ for _ in ()).throw(
  155. RuntimeError("known failure")
  156. ),
  157. )
  158. self.assertFalse(result["success"])
  159. self.assertIn("known failure", result["error"])
  160. calls = 0
  161. def should_not_run() -> dict[str, object]:
  162. nonlocal calls
  163. calls += 1
  164. return {"success": True}
  165. replay = OperationJournal(
  166. root,
  167. "executor-Task1-v1",
  168. ).execute(
  169. tool_name="crop_image",
  170. params={"image": "missing.png"},
  171. operation=should_not_run,
  172. )
  173. self.assertFalse(replay["success"])
  174. self.assertTrue(replay["_operation_replayed"])
  175. self.assertEqual(
  176. replay["_operation_id"],
  177. result["_operation_id"],
  178. )
  179. self.assertEqual(calls, 0)
  180. def test_operation_journal_never_replays_unknown_outcome(self) -> None:
  181. with tempfile.TemporaryDirectory() as temp_dir:
  182. root = Path(temp_dir)
  183. first = OperationJournal(root, "executor-Task1-v1")
  184. with self.assertRaises(OperationOutcomeUnknownError):
  185. first.execute(
  186. tool_name="run_tool",
  187. params={"prompt": "test"},
  188. operation=lambda: {
  189. "success": False,
  190. "outcome": "OUTCOME_UNKNOWN",
  191. },
  192. )
  193. calls = 0
  194. def should_not_run() -> dict[str, object]:
  195. nonlocal calls
  196. calls += 1
  197. return {"success": True}
  198. with self.assertRaises(OperationOutcomeUnknownError):
  199. OperationJournal(root, "executor-Task1-v1").execute(
  200. tool_name="run_tool",
  201. params={"prompt": "test"},
  202. operation=should_not_run,
  203. )
  204. self.assertEqual(calls, 0)
  205. entries = list((root / "tool_operations").glob("*.json"))
  206. self.assertEqual(len(entries), 1)
  207. self.assertEqual(
  208. json.loads(entries[0].read_text(encoding="utf-8"))["status"],
  209. "OUTCOME_UNKNOWN",
  210. )
  211. def test_uncertain_side_effect_exception_is_never_retried(self) -> None:
  212. with tempfile.TemporaryDirectory() as temp_dir:
  213. root = Path(temp_dir)
  214. with self.assertRaises(OperationOutcomeUnknownError):
  215. OperationJournal(root, "executor-Task1-v1").execute(
  216. tool_name="video_concat",
  217. params={"videos": ["a.mp4", "b.mp4"]},
  218. operation=lambda: (_ for _ in ()).throw(
  219. UncertainSideEffectError("upload response lost")
  220. ),
  221. )
  222. with self.assertRaises(OperationOutcomeUnknownError):
  223. OperationJournal(root, "executor-Task1-v1").execute(
  224. tool_name="video_concat",
  225. params={"videos": ["a.mp4", "b.mp4"]},
  226. operation=lambda: {"success": True},
  227. )
  228. def test_sqlite_checkpoint_survives_reopen(self) -> None:
  229. class CounterState(TypedDict):
  230. value: int
  231. builder = StateGraph(CounterState)
  232. builder.add_node(
  233. "increment",
  234. lambda state: {"value": state["value"] + 1},
  235. )
  236. builder.add_edge(START, "increment")
  237. builder.add_edge("increment", END)
  238. config = {"configurable": {"thread_id": "durable-thread"}}
  239. with tempfile.TemporaryDirectory() as temp_dir:
  240. root = Path(temp_dir)
  241. with create_sqlite_checkpointer(root) as checkpointer:
  242. graph = builder.compile(checkpointer=checkpointer)
  243. self.assertEqual(
  244. graph.invoke({"value": 0}, config)["value"],
  245. 1,
  246. )
  247. with create_sqlite_checkpointer(root) as checkpointer:
  248. graph = builder.compile(checkpointer=checkpointer)
  249. self.assertEqual(graph.get_state(config).values["value"], 1)
  250. def test_run_lock_rejects_second_owner(self) -> None:
  251. with tempfile.TemporaryDirectory() as temp_dir:
  252. root = Path(temp_dir)
  253. with acquire_run_lock(root):
  254. with self.assertRaises(RunLockError):
  255. with acquire_run_lock(root):
  256. self.fail("不应取得第二把锁")
  257. if __name__ == "__main__":
  258. unittest.main()