test_recovery.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. from typing import Any
  8. from unittest.mock import patch
  9. from langchain_core.messages import AIMessage
  10. from run_global_data import run_global_data
  11. from production_build_agents.contracts.models import (
  12. ExecutorDelivery,
  13. TaskPackage,
  14. ValidationReport,
  15. )
  16. from production_build_agents.agents.executor.agent import run_executor_agent as real_run_executor_agent
  17. from production_build_agents.run.records import VersionConflictError
  18. from production_build_agents.tools.registry import (
  19. create_default_tool_registry as real_create_default_tool_registry,
  20. )
  21. from production_build_agents.contracts.identifiers import validator_run_id_for
  22. from tests.support.executor_fixtures import (
  23. build_executor_candidate,
  24. build_executor_model,
  25. )
  26. from tests.support.fake_models import ToolAwareFakeChatModel
  27. from tests.support.planner_fixtures import (
  28. build_global_data_plan,
  29. build_planned_task,
  30. build_planner_model,
  31. build_task_package,
  32. )
  33. from tests.support.validator_fixtures import (
  34. build_stage_read_call,
  35. build_validator_candidate,
  36. build_validator_model,
  37. build_stage_validator_candidate,
  38. )
  39. INPUT_PATH = (
  40. Path(__file__).parents[1] / "fixtures" / "minimal_input.json"
  41. )
  42. def _tree_hashes(root: Path) -> dict[str, str]:
  43. return {
  44. str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
  45. for path in sorted(root.rglob("*"))
  46. if path.is_file()
  47. }
  48. def _completed_run(output_root: Path, run_id: str) -> dict[str, Any]:
  49. return run_global_data(
  50. INPUT_PATH,
  51. output_root=output_root,
  52. thread_id=run_id,
  53. registry_dir=output_root / ".registry",
  54. planner_model=build_planner_model(build_global_data_plan()),
  55. executor_model=build_executor_model(
  56. build_executor_candidate(run_id=run_id)
  57. ),
  58. validator_model=build_validator_model(
  59. build_validator_candidate(run_id=run_id)
  60. ),
  61. )
  62. class DurableRunTest(unittest.TestCase):
  63. def test_executor_agent_resumes_after_inspect_without_losing_gate(self) -> None:
  64. class CountingRemoteClient:
  65. def __init__(self) -> None:
  66. self.invoke_count = 0
  67. def search(
  68. self,
  69. query: str,
  70. *,
  71. limit: int = 5,
  72. ) -> dict[str, Any]:
  73. return {
  74. "success": True,
  75. "results": [
  76. {"tool_id": "fake-research", "summary": query}
  77. ],
  78. "total": 1,
  79. }
  80. def inspect(
  81. self,
  82. tool_ids: list[str],
  83. ) -> dict[str, Any]:
  84. return {
  85. "success": True,
  86. "tools": {
  87. tool_id: {
  88. "success": True,
  89. "input_schema": {"type": "object"},
  90. }
  91. for tool_id in tool_ids
  92. },
  93. }
  94. def invoke(
  95. self,
  96. tool_id: str,
  97. payload: dict[str, Any],
  98. ) -> dict[str, Any]:
  99. self.invoke_count += 1
  100. return {
  101. "success": True,
  102. "tool_id": tool_id,
  103. "payload": payload,
  104. "url": "https://example.test/recovered-source",
  105. }
  106. run_id = "Run-resume-agent-loop"
  107. planned_task = build_planned_task(
  108. skill_id="external-research",
  109. deliverable_type="research_result",
  110. )
  111. client = CountingRemoteClient()
  112. def registry_factory(**kwargs):
  113. return real_create_default_tool_registry(
  114. **kwargs,
  115. remote_client=client,
  116. )
  117. with tempfile.TemporaryDirectory() as temp_dir:
  118. root = Path(temp_dir)
  119. task = build_task_package(
  120. Path(__file__).parents[1]
  121. / "fixtures"
  122. / "minimal_brief.json",
  123. run_id=run_id,
  124. planned_task=planned_task,
  125. )
  126. interrupted_model = build_executor_model(
  127. build_executor_candidate(
  128. run_id=run_id,
  129. deliverable_type="research_result",
  130. artifact_uri="https://example.test/recovered-source",
  131. )
  132. )
  133. interrupted_model.responses.insert(
  134. 0,
  135. AIMessage(
  136. content="",
  137. tool_calls=[
  138. {
  139. "name": "inspect_tool",
  140. "args": {"tool_ids": ["fake-research"]},
  141. "id": "inspect-before-interrupt",
  142. "type": "tool_call",
  143. }
  144. ],
  145. ),
  146. )
  147. original_generate = interrupted_model._generate
  148. model_calls = 0
  149. def interrupt_second_model_call(*args, **kwargs):
  150. nonlocal model_calls
  151. model_calls += 1
  152. if model_calls == 2:
  153. raise RuntimeError("模拟 inspect 后进程中断")
  154. return original_generate(*args, **kwargs)
  155. with patch(
  156. "production_build_agents.agents.executor.agent.create_default_tool_registry",
  157. side_effect=registry_factory,
  158. ), patch.object(
  159. interrupted_model,
  160. "_generate",
  161. side_effect=interrupt_second_model_call,
  162. ):
  163. with self.assertRaisesRegex(RuntimeError, "进程中断"):
  164. real_run_executor_agent(
  165. task,
  166. run_dir=root,
  167. model=interrupted_model,
  168. )
  169. resumed_model = build_executor_model(
  170. build_executor_candidate(
  171. run_id=run_id,
  172. deliverable_type="research_result",
  173. artifact_uri="https://example.test/recovered-source",
  174. evidence_tool_call_ids=["run-after-resume"],
  175. )
  176. )
  177. resumed_model.responses.insert(
  178. 0,
  179. AIMessage(
  180. content="",
  181. tool_calls=[
  182. {
  183. "name": "run_tool",
  184. "args": {
  185. "tool_id": "fake-research",
  186. "params": {"query": "恢复测试"},
  187. },
  188. "id": "run-after-resume",
  189. "type": "tool_call",
  190. }
  191. ],
  192. ),
  193. )
  194. with patch(
  195. "production_build_agents.agents.executor.agent.create_default_tool_registry",
  196. side_effect=registry_factory,
  197. ):
  198. delivery = real_run_executor_agent(
  199. task,
  200. run_dir=root,
  201. model=resumed_model,
  202. )
  203. self.assertEqual(client.invoke_count, 1)
  204. self.assertEqual(delivery.task_id, "Task1")
  205. self.assertEqual(
  206. [record.tool_name for record in delivery.tool_calls],
  207. ["inspect_tool", "run_tool"],
  208. )
  209. def test_restart_after_executor_resumes_at_validator(self) -> None:
  210. run_id = "Run-resume-validator"
  211. with tempfile.TemporaryDirectory() as temp_dir:
  212. output_root = Path(temp_dir)
  213. with patch(
  214. "production_build_agents.global_data.nodes.validate_task."
  215. "run_validator_agent",
  216. side_effect=RuntimeError("模拟 Validator 前进程中断"),
  217. ):
  218. with self.assertRaisesRegex(RuntimeError, "进程中断"):
  219. run_global_data(
  220. INPUT_PATH,
  221. output_root=output_root,
  222. thread_id=run_id,
  223. registry_dir=output_root / ".registry",
  224. planner_model=build_planner_model(
  225. build_global_data_plan()
  226. ),
  227. executor_model=build_executor_model(
  228. build_executor_candidate(run_id=run_id)
  229. ),
  230. )
  231. run_dir = output_root / run_id
  232. delivery_path = (
  233. run_dir / "executor_results" / "Task1.v1.json"
  234. )
  235. original_delivery = delivery_path.read_bytes()
  236. result = run_global_data(
  237. INPUT_PATH,
  238. output_root=output_root,
  239. thread_id=run_id,
  240. registry_dir=output_root / ".registry",
  241. validator_model=build_validator_model(
  242. build_validator_candidate(run_id=run_id)
  243. ),
  244. )
  245. self.assertEqual(result["status"], "COMPLETED")
  246. self.assertEqual(result["executor_calls"], 1)
  247. self.assertEqual(result["validator_calls"], 1)
  248. self.assertEqual(delivery_path.read_bytes(), original_delivery)
  249. def test_restart_at_stage_validator_does_not_repeat_task_work(self) -> None:
  250. run_id = "Run-resume-stage-validator"
  251. with tempfile.TemporaryDirectory() as temp_dir:
  252. output_root = Path(temp_dir)
  253. with patch(
  254. "production_build_agents.global_data.nodes.validate_stage."
  255. "run_global_data_stage_validator",
  256. side_effect=RuntimeError("模拟 Stage Validator 前进程中断"),
  257. ):
  258. with self.assertRaisesRegex(RuntimeError, "进程中断"):
  259. run_global_data(
  260. INPUT_PATH,
  261. output_root=output_root,
  262. thread_id=run_id,
  263. registry_dir=output_root / ".registry",
  264. planner_model=build_planner_model(
  265. build_global_data_plan()
  266. ),
  267. executor_model=build_executor_model(
  268. build_executor_candidate(run_id=run_id)
  269. ),
  270. validator_model=build_validator_model(
  271. build_validator_candidate(run_id=run_id),
  272. include_stage=False,
  273. ),
  274. )
  275. task_delivery = (
  276. output_root
  277. / run_id
  278. / "executor_results"
  279. / "Task1.v1.json"
  280. )
  281. original = task_delivery.read_bytes()
  282. stage_model = ToolAwareFakeChatModel(
  283. responses=[
  284. build_stage_read_call("stage-resume-read"),
  285. AIMessage(
  286. content=build_stage_validator_candidate(
  287. run_id=run_id,
  288. ).model_dump_json()
  289. )
  290. ]
  291. )
  292. result = run_global_data(
  293. INPUT_PATH,
  294. output_root=output_root,
  295. thread_id=run_id,
  296. registry_dir=output_root / ".registry",
  297. validator_model=stage_model,
  298. )
  299. resumed = task_delivery.read_bytes()
  300. self.assertEqual(result["status"], "COMPLETED")
  301. self.assertEqual(result["executor_calls"], 1)
  302. self.assertEqual(result["validator_calls"], 1)
  303. self.assertEqual(result["stage_validator_calls"], 1)
  304. self.assertEqual(resumed, original)
  305. def test_restart_before_task2_does_not_repeat_passed_task1(self) -> None:
  306. run_id = "Run-resume-task2"
  307. plan = build_global_data_plan(
  308. tasks=[
  309. build_planned_task(task_id="Task1"),
  310. build_planned_task(
  311. task_id="Task2",
  312. objective="基于第一项资料完成第二项",
  313. depends_on=["Task1"],
  314. ),
  315. ]
  316. )
  317. def interrupt_task2(task, **kwargs):
  318. if task.task_id == "Task2":
  319. raise RuntimeError("模拟 Task2 前进程中断")
  320. return real_run_executor_agent(task, **kwargs)
  321. with tempfile.TemporaryDirectory() as temp_dir:
  322. output_root = Path(temp_dir)
  323. with patch(
  324. "production_build_agents.global_data.nodes.execute_task."
  325. "run_executor_agent",
  326. side_effect=interrupt_task2,
  327. ):
  328. with self.assertRaisesRegex(RuntimeError, "Task2"):
  329. run_global_data(
  330. INPUT_PATH,
  331. output_root=output_root,
  332. thread_id=run_id,
  333. registry_dir=output_root / ".registry",
  334. planner_model=build_planner_model(plan),
  335. executor_model=build_executor_model(
  336. build_executor_candidate(
  337. run_id=run_id,
  338. task_id="Task1",
  339. )
  340. ),
  341. validator_model=build_validator_model(
  342. build_validator_candidate(
  343. run_id=run_id,
  344. task_id="Task1",
  345. )
  346. ),
  347. )
  348. task1_delivery = (
  349. output_root
  350. / run_id
  351. / "executor_results"
  352. / "Task1.v1.json"
  353. )
  354. original = task1_delivery.read_bytes()
  355. result = run_global_data(
  356. INPUT_PATH,
  357. output_root=output_root,
  358. thread_id=run_id,
  359. registry_dir=output_root / ".registry",
  360. executor_model=build_executor_model(
  361. build_executor_candidate(
  362. run_id=run_id,
  363. task_id="Task2",
  364. )
  365. ),
  366. validator_model=build_validator_model(
  367. build_validator_candidate(
  368. run_id=run_id,
  369. task_id="Task2",
  370. )
  371. ),
  372. )
  373. self.assertEqual(result["status"], "COMPLETED")
  374. self.assertEqual(result["executor_calls"], 2)
  375. self.assertEqual(result["validator_calls"], 2)
  376. self.assertEqual(
  377. result["task_records"]["Task1"]["accepted_plan_version"],
  378. 1,
  379. )
  380. self.assertEqual(task1_delivery.read_bytes(), original)
  381. def test_completed_run_is_noop_and_delivery_is_byte_stable(self) -> None:
  382. run_id = "Run-completed-noop"
  383. with tempfile.TemporaryDirectory() as temp_dir:
  384. output_root = Path(temp_dir)
  385. first = _completed_run(output_root, run_id)
  386. delivery_path = Path(first["global_data_delivery_path"])
  387. original = delivery_path.read_bytes()
  388. before = _tree_hashes(output_root)
  389. second = run_global_data(
  390. INPUT_PATH,
  391. output_root=output_root,
  392. thread_id=run_id,
  393. registry_dir=output_root / ".registry",
  394. )
  395. self.assertEqual(second["status"], "COMPLETED")
  396. self.assertEqual(second["phase"], "FINALIZE")
  397. self.assertIsNone(second["replan_scope"])
  398. for field in (
  399. "planner_calls",
  400. "executor_calls",
  401. "validator_calls",
  402. "stage_validator_calls",
  403. "replan_count",
  404. "event_log",
  405. ):
  406. self.assertEqual(second[field], first[field])
  407. self.assertEqual(delivery_path.read_bytes(), original)
  408. self.assertEqual(_tree_hashes(output_root), before)
  409. def test_completed_noop_rejects_each_tampered_business_record(
  410. self,
  411. ) -> None:
  412. cases = (
  413. (
  414. "FinalDelivery",
  415. lambda result, run_dir: Path(
  416. result["global_data_delivery_path"]
  417. ),
  418. "plan_id",
  419. "TamperedPlan",
  420. ),
  421. (
  422. "Plan",
  423. lambda result, run_dir: Path(
  424. result["global_data_plan_path"]
  425. ),
  426. "plan_id",
  427. "TamperedPlan",
  428. ),
  429. (
  430. "Task",
  431. lambda result, run_dir: run_dir / "tasks" / "Task1.v1.json",
  432. "plan_id",
  433. "TamperedPlan",
  434. ),
  435. (
  436. "ExecutorDelivery",
  437. lambda result, run_dir: Path(
  438. result["task_records"]["Task1"][
  439. "executor_delivery_path"
  440. ]
  441. ),
  442. "plan_id",
  443. "TamperedPlan",
  444. ),
  445. (
  446. "TaskReport",
  447. lambda result, run_dir: Path(
  448. result["task_records"]["Task1"][
  449. "validation_report_path"
  450. ]
  451. ),
  452. "verdict",
  453. "FAIL",
  454. ),
  455. )
  456. for index, (name, select_path, field, value) in enumerate(cases):
  457. with (
  458. self.subTest(record=name),
  459. tempfile.TemporaryDirectory() as temp_dir,
  460. ):
  461. output_root = Path(temp_dir)
  462. run_id = f"Run-noop-tamper-{index}"
  463. result = _completed_run(output_root, run_id)
  464. path = select_path(result, output_root / run_id)
  465. payload = json.loads(path.read_text(encoding="utf-8"))
  466. payload[field] = value
  467. path.write_text(
  468. json.dumps(payload, ensure_ascii=False),
  469. encoding="utf-8",
  470. )
  471. with self.assertRaises(ValueError):
  472. run_global_data(
  473. INPUT_PATH,
  474. output_root=output_root,
  475. thread_id=run_id,
  476. registry_dir=output_root / ".registry",
  477. )
  478. def test_completed_noop_rejects_tampered_artifact_content(self) -> None:
  479. run_id = "Run-noop-artifact-tamper"
  480. with tempfile.TemporaryDirectory() as temp_dir:
  481. output_root = Path(temp_dir)
  482. result = _completed_run(output_root, run_id)
  483. delivery = ExecutorDelivery.model_validate_json(
  484. Path(
  485. result["task_records"]["Task1"][
  486. "executor_delivery_path"
  487. ]
  488. ).read_text(encoding="utf-8")
  489. )
  490. artifact_path = Path(delivery.artifacts[0].uri)
  491. artifact_path.write_text(
  492. '{"tampered": true}\n',
  493. encoding="utf-8",
  494. )
  495. with self.assertRaisesRegex(
  496. ValueError,
  497. "Artifact 内容已变化",
  498. ):
  499. run_global_data(
  500. INPUT_PATH,
  501. output_root=output_root,
  502. thread_id=run_id,
  503. registry_dir=output_root / ".registry",
  504. )
  505. def test_resume_rejects_input_drift(self) -> None:
  506. run_id = "Run-input-conflict"
  507. with tempfile.TemporaryDirectory() as temp_dir:
  508. output_root = Path(temp_dir)
  509. run_global_data(
  510. INPUT_PATH,
  511. output_root=output_root,
  512. thread_id=run_id,
  513. registry_dir=output_root / ".registry",
  514. planner_model=build_planner_model(
  515. build_global_data_plan()
  516. ),
  517. executor_model=build_executor_model(
  518. build_executor_candidate(run_id=run_id)
  519. ),
  520. validator_model=build_validator_model(
  521. build_validator_candidate(run_id=run_id)
  522. ),
  523. )
  524. copied_input = output_root / "same-content-new-path.json"
  525. copied_input.write_bytes(INPUT_PATH.read_bytes())
  526. with self.assertRaisesRegex(
  527. VersionConflictError,
  528. "input_path",
  529. ):
  530. run_global_data(
  531. copied_input,
  532. output_root=output_root,
  533. thread_id=run_id,
  534. registry_dir=output_root / ".registry",
  535. )
  536. def test_thread_registry_rejects_output_directory_drift(self) -> None:
  537. run_id = "Run-output-conflict"
  538. with tempfile.TemporaryDirectory() as temp_dir:
  539. root = Path(temp_dir)
  540. registry = root / ".registry"
  541. run_global_data(
  542. INPUT_PATH,
  543. output_root=root / "output-a",
  544. thread_id=run_id,
  545. registry_dir=registry,
  546. planner_model=build_planner_model(
  547. build_global_data_plan()
  548. ),
  549. executor_model=build_executor_model(
  550. build_executor_candidate(run_id=run_id)
  551. ),
  552. validator_model=build_validator_model(
  553. build_validator_candidate(run_id=run_id)
  554. ),
  555. )
  556. with self.assertRaisesRegex(
  557. VersionConflictError,
  558. "run_dir",
  559. ):
  560. run_global_data(
  561. INPUT_PATH,
  562. output_root=root / "output-b",
  563. thread_id=run_id,
  564. registry_dir=registry,
  565. )
  566. def test_thread_registry_rejects_same_path_content_drift(self) -> None:
  567. run_id = "Run-content-conflict"
  568. with tempfile.TemporaryDirectory() as temp_dir:
  569. root = Path(temp_dir)
  570. source = root / "input.json"
  571. source.write_bytes(INPUT_PATH.read_bytes())
  572. run_global_data(
  573. source,
  574. output_root=root / "output",
  575. thread_id=run_id,
  576. registry_dir=root / ".registry",
  577. planner_model=build_planner_model(
  578. build_global_data_plan()
  579. ),
  580. executor_model=build_executor_model(
  581. build_executor_candidate(run_id=run_id)
  582. ),
  583. validator_model=build_validator_model(
  584. build_validator_candidate(run_id=run_id)
  585. ),
  586. )
  587. source.write_text(
  588. source.read_text(encoding="utf-8") + "\n",
  589. encoding="utf-8",
  590. )
  591. with self.assertRaisesRegex(
  592. VersionConflictError,
  593. "input_sha256",
  594. ):
  595. run_global_data(
  596. source,
  597. output_root=root / "output",
  598. thread_id=run_id,
  599. registry_dir=root / ".registry",
  600. )
  601. def test_inconsistent_saved_report_is_rejected_on_resume(self) -> None:
  602. run_id = "Run-invalid-saved-report"
  603. with tempfile.TemporaryDirectory() as temp_dir:
  604. output_root = Path(temp_dir)
  605. registry = output_root / ".registry"
  606. with patch(
  607. "production_build_agents.global_data.nodes.validate_task."
  608. "run_validator_agent",
  609. side_effect=RuntimeError("模拟 Validator 前中断"),
  610. ):
  611. with self.assertRaises(RuntimeError):
  612. run_global_data(
  613. INPUT_PATH,
  614. output_root=output_root,
  615. thread_id=run_id,
  616. registry_dir=registry,
  617. planner_model=build_planner_model(
  618. build_global_data_plan()
  619. ),
  620. executor_model=build_executor_model(
  621. build_executor_candidate(run_id=run_id)
  622. ),
  623. )
  624. run_dir = output_root / run_id
  625. task = TaskPackage.model_validate_json(
  626. (run_dir / "tasks" / "Task1.v1.json").read_text(
  627. encoding="utf-8"
  628. )
  629. )
  630. delivery = ExecutorDelivery.model_validate_json(
  631. (
  632. run_dir / "executor_results" / "Task1.v1.json"
  633. ).read_text(encoding="utf-8")
  634. )
  635. failed = build_validator_candidate(
  636. run_id=run_id,
  637. verdict="FAIL",
  638. )
  639. inconsistent = ValidationReport(
  640. run_id=run_id,
  641. plan_id=task.plan_id,
  642. plan_version=1,
  643. task_id="Task1",
  644. executor_run_id=delivery.executor_run_id,
  645. validator_run_id=validator_run_id_for(delivery),
  646. criterion_results=failed.criterion_results,
  647. verdict="PASS",
  648. summary="故意构造的自相矛盾报告",
  649. )
  650. report_path = (
  651. run_dir / "validation_results" / "Task1.v1.json"
  652. )
  653. report_path.parent.mkdir(parents=True, exist_ok=True)
  654. report_path.write_text(
  655. inconsistent.model_dump_json(),
  656. encoding="utf-8",
  657. )
  658. result = run_global_data(
  659. INPUT_PATH,
  660. output_root=output_root,
  661. thread_id=run_id,
  662. registry_dir=registry,
  663. )
  664. self.assertEqual(result["status"], "FAILED")
  665. self.assertEqual(result["phase"], "VALIDATE_TASK")
  666. self.assertEqual(
  667. result["failure_code"],
  668. "VALIDATOR_OUTPUT_INVALID",
  669. )
  670. self.assertNotEqual(
  671. result["task_records"]["Task1"]["status"],
  672. "passed",
  673. )
  674. if __name__ == "__main__":
  675. unittest.main()