process_recovery_acceptance.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import asyncio
  2. import json
  3. import sys
  4. from datetime import datetime
  5. from pathlib import Path
  6. from time import perf_counter
  7. from uuid import UUID
  8. import pytest
  9. from sqlalchemy import func, select
  10. from test_after_sales import scenario # noqa: F401
  11. from test_agent_runtime import tool_run
  12. from zhifu.core.errors import BusinessError
  13. from zhifu.modules.after_sales.models import Ticket
  14. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
  15. from zhifu.modules.agent_runtime.runtime import build_tool_graph
  16. from zhifu.modules.agent_runtime.worker import claim
  17. from zhifu.modules.identity.auth import now
  18. @pytest.mark.parametrize("stage", ["claimed", "committed"])
  19. async def test_process_termination_and_natural_lease_recovery(scenario, stage): # noqa: F811
  20. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  21. for role in ("agent", "memory"):
  22. async for _ in state.scan_iter(match=f"monitor:worker:{role}:*"):
  23. raise RuntimeError("恢复验收要求停止生成工作进程")
  24. async with sessions() as session:
  25. count = await session.scalar(
  26. select(func.count())
  27. .select_from(AgentRun)
  28. .where(AgentRun.status.in_(["QUEUED", "RUNNING"]))
  29. )
  30. assert count == 0, "需要没有其他待执行任务的独立验收环境"
  31. run = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
  32. started = perf_counter()
  33. process = await asyncio.create_subprocess_exec(
  34. sys.executable,
  35. "scripts/recovery_probe.py",
  36. str(UUID(bytes=run.id)),
  37. stage,
  38. stdin=asyncio.subprocess.PIPE,
  39. stdout=asyncio.subprocess.PIPE,
  40. stderr=asyncio.subprocess.PIPE,
  41. )
  42. try:
  43. async with asyncio.timeout(180):
  44. async with asyncio.timeout(35):
  45. raw = await process.stdout.readline()
  46. assert raw, (await process.stderr.read()).decode(errors="replace")
  47. marker = json.loads(raw)
  48. assert marker["run_id"] == str(UUID(bytes=run.id))
  49. process.kill()
  50. await process.wait()
  51. assert process.returncode != 0
  52. # 等待数据库中实际125秒租约自然到期,期间不能重新领取。
  53. assert await claim(sessions, 2) is None
  54. expiry = datetime.fromisoformat(marker["lease_until"])
  55. await asyncio.sleep(max(0, (expiry - now()).total_seconds()) + 0.05)
  56. recovered = await claim(sessions, 2)
  57. assert recovered and recovered[0] == run.id and recovered[1] > marker["token"]
  58. graph = build_tool_graph(sessions)
  59. with pytest.raises(BusinessError, match="执行权已失效"):
  60. await graph.ainvoke({"run_id": run.id, "token": marker["token"]})
  61. await graph.ainvoke({"run_id": run.id, "token": recovered[1]})
  62. await graph.ainvoke({"run_id": run.id, "token": recovered[1]})
  63. async with sessions() as session:
  64. tickets = await session.scalar(
  65. select(func.count())
  66. .select_from(Ticket)
  67. .where(
  68. Ticket.tenant_id == scenario["tenant"].bytes,
  69. )
  70. )
  71. attempts = await session.scalar(
  72. select(func.count())
  73. .select_from(ModelAttempt)
  74. .where(
  75. ModelAttempt.tenant_id == scenario["tenant"].bytes,
  76. )
  77. )
  78. assert tickets == 1 and attempts == 0
  79. output = Path(".work/process-recovery")
  80. await asyncio.to_thread(output.mkdir, parents=True, exist_ok=True)
  81. report = {
  82. "stage": stage,
  83. "child_exit": process.returncode,
  84. "seconds": perf_counter() - started,
  85. "tickets": tickets,
  86. "generation_attempts": attempts,
  87. "natural_lease_expiry": True,
  88. "old_token": marker["token"],
  89. "recovered_token": recovered[1],
  90. }
  91. await asyncio.to_thread(
  92. (output / f"{stage}.json").write_text,
  93. json.dumps(report, ensure_ascii=False, indent=2) + "\n",
  94. encoding="utf-8",
  95. )
  96. finally:
  97. if process.returncode is None:
  98. process.kill()
  99. await process.wait()