test_orchestration_edges.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. from types import SimpleNamespace
  2. import pytest
  3. from agent.orchestration.config import OrchestrationConfig
  4. from agent.orchestration.coordinator import OrchestrationError, TaskConflict
  5. from agent.orchestration.executor import LocalAgentExecutor
  6. from agent.orchestration.models import (
  7. AcceptanceCriterion,
  8. CriterionResult,
  9. DecisionAction,
  10. TaskRecord,
  11. TaskSpec,
  12. ValidationVerdict,
  13. )
  14. from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
  15. def test_orchestration_config_rejects_invalid_limits():
  16. with pytest.raises(ValueError, match="at least"):
  17. OrchestrationConfig(max_parallel_tasks=0)
  18. with pytest.raises(ValueError, match="negative"):
  19. OrchestrationConfig(max_repair_continuations=-1)
  20. @pytest.mark.asyncio
  21. async def test_local_executor_rejects_remote_v1_and_normalizes_runner_errors():
  22. class BrokenRunner:
  23. async def run_result(self, **kwargs):
  24. raise RuntimeError("boom")
  25. executor = LocalAgentExecutor(BrokenRunner())
  26. worker = await executor.run_worker({"worker_preset": "remote_worker", "worker_trace_id": "w"})
  27. validator = await executor.run_validator({"validator_preset": "remote_validator", "validator_trace_id": "v"})
  28. assert worker.status == "failed" and "local" in worker.error.lower()
  29. assert validator.status == "failed" and "local" in validator.error.lower()
  30. worker = await executor.run_worker({
  31. "worker_preset": "worker", "worker_trace_id": "w", "root_trace_id": "root",
  32. "task_id": "task", "spec_version": 1, "attempt_id": "attempt",
  33. "task_spec": {}, "continue_trace_id": None,
  34. })
  35. validator = await executor.run_validator({
  36. "validator_preset": "validator", "validator_trace_id": "v", "root_trace_id": "root",
  37. "task_id": "task", "spec_version": 1, "attempt_id": "attempt",
  38. "snapshot_id": "snapshot", "validation_id": "validation",
  39. "task_spec": {}, "artifact_snapshot": {},
  40. })
  41. assert worker.error == "boom"
  42. assert validator.error == "boom"
  43. @pytest.mark.asyncio
  44. async def test_local_executor_repair_continuation_is_coordinator_validated():
  45. class Coordinator:
  46. called = None
  47. async def validate_continue_from(self, root, task, attempt):
  48. self.called = (root, task, attempt)
  49. return "worker-trace"
  50. class Store:
  51. updated = None
  52. async def get_trace(self, trace_id):
  53. return SimpleNamespace(context={"old": True})
  54. async def update_trace(self, trace_id, **updates):
  55. self.updated = (trace_id, updates)
  56. class Runner:
  57. def __init__(self):
  58. self.task_coordinator = Coordinator()
  59. self.trace_store = Store()
  60. async def run_result(self, **kwargs):
  61. return {"trace_id": "worker-trace", "status": "completed", "summary": "repaired"}
  62. runner = Runner()
  63. result = await LocalAgentExecutor(runner).run_worker({
  64. "worker_preset": "worker", "worker_trace_id": "worker-trace",
  65. "root_trace_id": "root", "task_id": "task", "spec_version": 1,
  66. "attempt_id": "new-attempt", "prior_attempt_id": "old-attempt",
  67. "task_spec": {}, "continue_trace_id": "worker-trace",
  68. })
  69. assert result.status == "completed"
  70. assert runner.task_coordinator.called == ("root", "task", "old-attempt")
  71. assert runner.trace_store.updated[1]["context"]["attempt_id"] == "new-attempt"
  72. @pytest.mark.asyncio
  73. async def test_planning_and_dispatch_input_guards(tmp_path):
  74. executor = FakeExecutor([])
  75. coordinator, _, _ = await make_coordinator(tmp_path, executor)
  76. with pytest.raises(ValueError, match="At least"):
  77. await coordinator.create_tasks("root", [])
  78. with pytest.raises(ValueError, match="objective"):
  79. await coordinator.create_tasks("root", [{"objective": ""}])
  80. with pytest.raises(ValueError, match="Parent"):
  81. await coordinator.create_tasks("root", [{"objective": "x"}], parent_task_id="missing")
  82. assert await coordinator.dispatch_tasks("root", []) == []
  83. task_id = await create_task(coordinator)
  84. with pytest.raises(TaskConflict, match="twice"):
  85. await coordinator.dispatch_tasks("root", [task_id, task_id])
  86. with pytest.raises(ValueError, match="worker_presets"):
  87. await coordinator.dispatch_tasks("root", [task_id], worker_presets=["a", "b"])
  88. focused = await coordinator.focus_task("root", task_id)
  89. assert focused["task_id"] == task_id
  90. coordinator.executor = None
  91. with pytest.raises(RuntimeError, match="Executor"):
  92. await coordinator.dispatch_tasks("root", [task_id])
  93. @pytest.mark.asyncio
  94. async def test_decision_and_revalidation_guards(tmp_path):
  95. executor = FakeExecutor([ValidationVerdict.PASSED])
  96. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  97. task_id = await create_task(coordinator)
  98. with pytest.raises(ValueError, match="reason"):
  99. await coordinator.decide_task("root", task_id, None, DecisionAction.BLOCK, {}, "missing-reason")
  100. with pytest.raises(TaskConflict, match="blocked"):
  101. await coordinator.decide_task("root", task_id, None, DecisionAction.UNBLOCK, {}, "not-blocked")
  102. with pytest.raises(OrchestrationError, match="snapshot"):
  103. await coordinator.revalidate_attempt("root", task_id, "missing")
  104. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  105. with pytest.raises(TaskConflict, match="(?i)passed"):
  106. await coordinator.decide_task(
  107. "root", task_id, cycle.validation_id, DecisionAction.REPAIR,
  108. {"reason": "should accept"}, "bad-repair",
  109. )
  110. await coordinator.decide_task(
  111. "root", task_id, cycle.validation_id, DecisionAction.ACCEPT,
  112. {"reason": "passed"}, "accept",
  113. )
  114. with pytest.raises(TaskConflict, match="(?i)terminal"):
  115. await coordinator.decide_task(
  116. "root", task_id, None, DecisionAction.BLOCK,
  117. {"reason": "too late"}, "late-block",
  118. )
  119. with pytest.raises(TaskConflict, match="already terminal"):
  120. await coordinator.decide_task(
  121. "root", task_id, None, DecisionAction.CANCEL,
  122. {"reason": "too late"}, "late-cancel",
  123. )
  124. def test_validation_report_requires_every_hard_criterion():
  125. task = TaskRecord(
  126. task_id="t", goal_id=None, parent_task_id=None, display_path="1",
  127. specs=[TaskSpec(
  128. version=1, objective="o",
  129. acceptance_criteria=[AcceptanceCriterion("c1", "required", hard=True)],
  130. )],
  131. )
  132. from agent.orchestration.coordinator import TaskCoordinator
  133. with pytest.raises(ValueError, match="missing"):
  134. TaskCoordinator._validate_report(task, ValidationVerdict.PASSED, [])
  135. with pytest.raises(ValueError, match="conflicts"):
  136. TaskCoordinator._validate_report(
  137. task,
  138. ValidationVerdict.PASSED,
  139. [CriterionResult("c1", ValidationVerdict.FAILED, "no")],
  140. )