test_recovery_scenarios.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import copy
  2. import pytest
  3. from app.core.mcp.identity import AgentIdentity
  4. from app.core.orchestration.agent.recovery import RecoveryAgent
  5. def identity():
  6. return AgentIdentity(
  7. subject="v54-recovery",
  8. roles=frozenset({"scheduler"}),
  9. business_domains=frozenset({"sales"}),
  10. environments=frozenset({"test"}),
  11. correlation_id="01900000-0000-7000-8000-000000000057",
  12. )
  13. def schedule_plan(concurrency=4):
  14. return {
  15. "schema_version": "1.0",
  16. "timezone": "Asia/Shanghai",
  17. "triggers": [{"type": "manual"}],
  18. "max_concurrency": concurrency,
  19. "conflict_policy": "skip",
  20. "timeout_seconds": 600,
  21. "retry": {"max_attempts": 2, "delay_seconds": 30},
  22. "backfill": {"max_days": 1, "max_runs": 1},
  23. }
  24. class Tools:
  25. def __init__(self):
  26. self.calls = []
  27. def call_tool(self, name, arguments):
  28. self.calls.append((name, copy.deepcopy(arguments)))
  29. return {
  30. "status": {
  31. "pause_schedule": "paused",
  32. "retry_failed_execution": "retry_started",
  33. "rollback_to_previous_version": "rolled_back",
  34. }[name]
  35. }
  36. class Audit:
  37. def __init__(self):
  38. self.events = []
  39. def record(self, event):
  40. self.events.append(copy.deepcopy(event))
  41. def test_pool_degradation_produces_deterministic_lower_concurrency_plan():
  42. tools = Tools()
  43. audit = Audit()
  44. agent = RecoveryAgent(scheduling_tools=tools, audit=audit)
  45. result = agent.recover(
  46. identity(),
  47. {
  48. "failure_kind": "datasource_pool_degraded",
  49. "business_domain": "sales",
  50. "environment": "test",
  51. "candidate_id": "candidate-v54",
  52. "schedule_plan": schedule_plan(4),
  53. },
  54. )
  55. assert result["status"] == "recovery_planned"
  56. assert result["action"] == "reduce_concurrency"
  57. assert result["schedule_plan"]["max_concurrency"] == 2
  58. assert result["schedule_plan"]["retry"]["max_attempts"] == 2
  59. assert tools.calls == []
  60. assert audit.events[-1]["decision"] == "allowed"
  61. def test_transient_failure_uses_one_bounded_gateway_retry():
  62. tools = Tools()
  63. agent = RecoveryAgent(scheduling_tools=tools, audit=Audit(), max_retries=2)
  64. result = agent.recover(
  65. identity(),
  66. {
  67. "failure_kind": "transient_execution_failure",
  68. "business_domain": "sales",
  69. "environment": "test",
  70. "execution_id": "execution-v54",
  71. "retry_count": 1,
  72. },
  73. )
  74. assert result["status"] == "recovery_executed"
  75. assert result["action"] == "retry_failed_execution"
  76. assert tools.calls == [
  77. (
  78. "retry_failed_execution",
  79. {
  80. "execution_id": "execution-v54",
  81. "business_domain": "sales",
  82. "environment": "test",
  83. "max_retries": 2,
  84. },
  85. )
  86. ]
  87. def test_exhausted_retry_rolls_back_to_previous_stable_version():
  88. tools = Tools()
  89. agent = RecoveryAgent(scheduling_tools=tools, audit=Audit(), max_retries=2)
  90. result = agent.recover(
  91. identity(),
  92. {
  93. "failure_kind": "transient_execution_failure",
  94. "business_domain": "sales",
  95. "environment": "test",
  96. "candidate_id": "candidate-v54",
  97. "execution_id": "execution-v54",
  98. "retry_count": 2,
  99. },
  100. )
  101. assert result["action"] == "rollback_to_previous_version"
  102. assert tools.calls[0][0] == "rollback_to_previous_version"
  103. assert tools.calls[0][1]["idempotency_key"].startswith("v54-recovery:")
  104. def test_runner_or_datasource_outage_pauses_candidate():
  105. for failure_kind in ("runner_unavailable", "datasource_unavailable"):
  106. tools = Tools()
  107. agent = RecoveryAgent(scheduling_tools=tools, audit=Audit())
  108. result = agent.recover(
  109. identity(),
  110. {
  111. "failure_kind": failure_kind,
  112. "business_domain": "sales",
  113. "environment": "test",
  114. "candidate_id": "candidate-v54",
  115. },
  116. )
  117. assert result["action"] == "pause_schedule"
  118. assert tools.calls[0][0] == "pause_schedule"
  119. def test_model_or_kestra_outage_keeps_published_fixed_schedule():
  120. for failure_kind in ("model_unavailable", "kestra_unavailable"):
  121. tools = Tools()
  122. agent = RecoveryAgent(scheduling_tools=tools, audit=Audit())
  123. result = agent.recover(
  124. identity(),
  125. {
  126. "failure_kind": failure_kind,
  127. "business_domain": "sales",
  128. "environment": "test",
  129. },
  130. )
  131. assert result["status"] == "degraded"
  132. assert result["action"] == "continue_published_schedule"
  133. assert tools.calls == []
  134. def test_recovery_rejects_model_selected_or_dangerous_actions():
  135. agent = RecoveryAgent(scheduling_tools=Tools(), audit=Audit())
  136. with pytest.raises(ValueError, match="unsupported fields"):
  137. agent.recover(
  138. identity(),
  139. {
  140. "failure_kind": "transient_execution_failure",
  141. "business_domain": "sales",
  142. "environment": "test",
  143. "requested_action": "delete_execution",
  144. },
  145. )