test_find_agent_v2_run_timeout.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. from __future__ import annotations
  2. import asyncio
  3. from collections.abc import Generator
  4. from contextlib import contextmanager
  5. from datetime import datetime, timedelta
  6. import pytest
  7. from sqlalchemy import create_engine, select
  8. from sqlalchemy.orm import Session, sessionmaker
  9. from api.services import find_agent_v2 as api_service
  10. from find_agent_v2 import service as find_agent_service
  11. from find_agent_v2.agent import FindAgentV2
  12. from find_agent_v2.models import (
  13. FindAgentV2Candidate,
  14. FindAgentV2Evidence,
  15. FindAgentV2Run,
  16. FindAgentV2Search,
  17. )
  18. from find_agent_v2.observability import NullObserver
  19. from find_agent_v2.service import FindAgentV2Service, RUN_TIMEOUT_REASON
  20. def test_service_progress_and_work_queues_are_not_limited_to_100(monkeypatch) -> None:
  21. engine = create_engine("sqlite+pysqlite:///:memory:")
  22. FindAgentV2Run.__table__.create(engine)
  23. FindAgentV2Search.__table__.create(engine)
  24. FindAgentV2Candidate.__table__.create(engine)
  25. FindAgentV2Evidence.__table__.create(engine)
  26. factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
  27. @contextmanager
  28. def session_scope() -> Generator[Session, None, None]:
  29. session = factory()
  30. try:
  31. yield session
  32. session.commit()
  33. finally:
  34. session.close()
  35. monkeypatch.setattr(find_agent_service, "get_session", session_scope)
  36. with factory.begin() as session:
  37. session.add(FindAgentV2Run(
  38. id=1,
  39. run_id="large-run",
  40. demand_word="批量测试",
  41. input_json='{"user_input":"test"}',
  42. rule_config_json="{}",
  43. status="running",
  44. candidate_count=137,
  45. ))
  46. candidates = []
  47. for candidate_id in range(1, 138):
  48. if candidate_id <= 50:
  49. detail_status, portrait_status, bucket = "pending", "pending", "pending_evaluation"
  50. elif candidate_id <= 90:
  51. detail_status, portrait_status, bucket = "success", "success", "pending_evaluation"
  52. elif candidate_id <= 120:
  53. detail_status, portrait_status, bucket = "failed", "success", "pending_evaluation"
  54. elif candidate_id <= 130:
  55. detail_status, portrait_status, bucket = "success", "success", "primary"
  56. else:
  57. detail_status, portrait_status, bucket = "success", "success", "rejected"
  58. candidates.append(FindAgentV2Candidate(
  59. id=candidate_id,
  60. run_id="large-run",
  61. aweme_id=f"video-{candidate_id}",
  62. detail_status=detail_status,
  63. portrait_status=portrait_status,
  64. decision_bucket=bucket,
  65. ))
  66. session.add_all(candidates)
  67. service = FindAgentV2Service()
  68. progress = service.get_candidate_progress("large-run")
  69. assert progress == {
  70. "total_count": 137,
  71. "pending_count": 120,
  72. "primary_count": 10,
  73. "rejected_count": 7,
  74. "detail_pending_count": 50,
  75. "detail_success_count": 40,
  76. "detail_failed_count": 30,
  77. "portrait_pending_count": 50,
  78. "portrait_success_count": 70,
  79. "portrait_failed_count": 0,
  80. "evidence_completed_count": 70,
  81. "evidence_success_count": 40,
  82. }
  83. assert service.snapshot("large-run").candidate_count == 137
  84. assert service.count_pending_candidates("large-run") == 120
  85. assert len(service.get_full_state("large-run")["candidates"]) == 100
  86. assert len(service.list_pending_evidence_ids("large-run", "detail", limit=16)) == 16
  87. assert len(service.list_ready_evaluation_ids("large-run", limit=100)) == 70
  88. report = service.get_report_state("large-run")
  89. assert len(report["primary_candidates"]) == 10
  90. assert report["summary"]["total_count"] == 137
  91. def test_missing_evidence_response_marks_requested_candidate_failed(monkeypatch) -> None:
  92. engine = create_engine("sqlite+pysqlite:///:memory:")
  93. FindAgentV2Candidate.__table__.create(engine)
  94. FindAgentV2Evidence.__table__.create(engine)
  95. factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
  96. next_evidence_id = 1
  97. @contextmanager
  98. def session_scope() -> Generator[Session, None, None]:
  99. nonlocal next_evidence_id
  100. session = factory()
  101. try:
  102. yield session
  103. for instance in session.new:
  104. if isinstance(instance, FindAgentV2Evidence) and instance.id is None:
  105. instance.id = next_evidence_id
  106. next_evidence_id += 1
  107. session.commit()
  108. finally:
  109. session.close()
  110. monkeypatch.setattr(find_agent_service, "get_session", session_scope)
  111. with factory.begin() as session:
  112. session.add_all([
  113. FindAgentV2Candidate(
  114. id=candidate_id,
  115. run_id="partial-response",
  116. aweme_id=f"video-{candidate_id}",
  117. detail_status="pending",
  118. portrait_status="pending",
  119. decision_bucket="pending_evaluation",
  120. )
  121. for candidate_id in (1, 2)
  122. ])
  123. service = FindAgentV2Service()
  124. service.save_details(
  125. "partial-response",
  126. [{"content_id": "video-2", "title": "success"}],
  127. [],
  128. requested_aweme_ids=["video-1", "video-2"],
  129. )
  130. service.save_portraits(
  131. "partial-response",
  132. [{"aweme_id": "video-2", "age_normalization": {}}],
  133. requested_aweme_ids=["video-1", "video-2"],
  134. )
  135. with factory() as session:
  136. missing = session.get(FindAgentV2Candidate, 1)
  137. successful = session.get(FindAgentV2Candidate, 2)
  138. evidence = list(session.scalars(select(FindAgentV2Evidence).order_by(
  139. FindAgentV2Evidence.id,
  140. )))
  141. assert missing is not None and successful is not None
  142. assert (missing.detail_status, missing.portrait_status) == ("failed", "failed")
  143. assert (successful.detail_status, successful.portrait_status) == ("success", "success")
  144. assert [item.status for item in evidence].count("failed") == 2
  145. assert [item.status for item in evidence].count("success") == 2
  146. assert all(
  147. "上游" in str(item.error_message)
  148. for item in evidence
  149. if item.status == "failed"
  150. )
  151. def test_admin_list_marks_runs_older_than_60_minutes_failed(monkeypatch) -> None:
  152. engine = create_engine("sqlite+pysqlite:///:memory:")
  153. FindAgentV2Run.__table__.create(engine)
  154. factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
  155. @contextmanager
  156. def session_scope() -> Generator[Session, None, None]:
  157. session = factory()
  158. try:
  159. yield session
  160. session.commit()
  161. finally:
  162. session.close()
  163. now = datetime(2026, 8, 14, 12, 0)
  164. with factory.begin() as session:
  165. session.add_all([
  166. FindAgentV2Run(
  167. id=1, run_id="overdue", demand_word="超时任务", input_json="{}",
  168. rule_config_json="{}", status="running",
  169. create_time=now - timedelta(minutes=61), update_time=now,
  170. ),
  171. FindAgentV2Run(
  172. id=2, run_id="active", demand_word="运行任务", input_json="{}",
  173. rule_config_json="{}", status="running",
  174. create_time=now - timedelta(minutes=59), update_time=now,
  175. ),
  176. ])
  177. expire = api_service._expire_overdue_runs
  178. monkeypatch.setattr(api_service, "get_session", session_scope)
  179. monkeypatch.setattr(
  180. api_service, "_expire_overdue_runs", lambda session: expire(session, now=now),
  181. )
  182. listed = api_service.list_runs()
  183. by_id = {item["run_id"]: item for item in listed["items"]}
  184. assert by_id["overdue"]["status"] == "failed"
  185. assert by_id["overdue"]["outcome_status"] == "failed"
  186. assert by_id["overdue"]["stop_reason"] == RUN_TIMEOUT_REASON
  187. assert by_id["active"]["status"] == "running"
  188. second_page = api_service.list_runs(page=2, page_size=1)
  189. assert second_page["page"] == 2
  190. assert second_page["page_size"] == 1
  191. assert second_page["total"] == 2
  192. assert second_page["total_pages"] == 2
  193. assert second_page["has_previous"] is True
  194. assert second_page["has_next"] is False
  195. assert len(second_page["items"]) == 1
  196. with factory() as session:
  197. stored = session.scalar(select(FindAgentV2Run).where(
  198. FindAgentV2Run.run_id == "overdue",
  199. ))
  200. assert stored is not None and stored.status == "failed"
  201. @pytest.mark.asyncio
  202. async def test_agent_hard_timeout_marks_run_failed(monkeypatch) -> None:
  203. class Service:
  204. failed_reason = ""
  205. def require_run(self, _run_id):
  206. return {
  207. "status": "failed" if self.failed_reason else "running",
  208. "outcome_status": "failed" if self.failed_reason else None,
  209. "demand_word": "测试", "current_round": 0, "valid_primary_count": 0,
  210. }
  211. def set_obagent_run_uid(self, *_args):
  212. return None
  213. def fail_run(self, _run_id, reason):
  214. self.failed_reason = reason
  215. def add_usage(self, *_args):
  216. return None
  217. class Runner:
  218. usage = {}
  219. def reset_usage(self):
  220. return None
  221. service = Service()
  222. agent = FindAgentV2(
  223. service=service, node_runner=Runner(), observer=NullObserver(),
  224. max_runtime_seconds=0.01,
  225. )
  226. async def slow_run(**_kwargs):
  227. await asyncio.sleep(0.1)
  228. monkeypatch.setattr(agent, "_arun_inner", slow_run)
  229. result = await agent.arun(run_id="timeout", user_input="task")
  230. assert result.status == "failed"
  231. assert result.stop_reason == RUN_TIMEOUT_REASON
  232. assert service.failed_reason == RUN_TIMEOUT_REASON