test_coordinator_integration.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import asyncio
  2. from collections import deque
  3. import pytest
  4. from agent.orchestration.config import OrchestrationConfig
  5. from agent.orchestration.coordinator import TaskConflict, TaskCoordinator
  6. from agent.orchestration.models import (
  7. AgentRole,
  8. ArtifactRef,
  9. AttemptSubmission,
  10. CriterionResult,
  11. DecisionAction,
  12. TaskStatus,
  13. ValidationVerdict,
  14. )
  15. from agent.orchestration.protocols import ValidatorRunResult, WorkerRunResult
  16. from agent.orchestration.store import FileSystemArtifactStore, FileSystemTaskStore, TraceEventSink
  17. from agent.trace.goal_models import GoalTree
  18. from agent.trace.models import Trace
  19. from agent.trace.store import FileSystemTraceStore
  20. from agent.core.runner import AgentRunner
  21. from agent.orchestration.wiring import wire_orchestration
  22. class FakeExecutor:
  23. def __init__(self, verdicts, submit_worker=True, submit_validator=True, delay=0):
  24. self.verdicts = deque(verdicts)
  25. self.submit_worker = submit_worker
  26. self.submit_validator = submit_validator
  27. self.delay = delay
  28. self.coordinator = None
  29. self.active = 0
  30. self.max_active = 0
  31. async def run_worker(self, context):
  32. self.active += 1
  33. self.max_active = max(self.max_active, self.active)
  34. if self.delay:
  35. await asyncio.sleep(self.delay)
  36. try:
  37. if self.submit_worker:
  38. await self.coordinator.submit_attempt(
  39. {
  40. **context,
  41. "role": AgentRole.WORKER.value,
  42. "trace_id": context["worker_trace_id"],
  43. "tool_call_id": f"submit-{context['attempt_id']}",
  44. },
  45. AttemptSubmission(
  46. summary="done",
  47. artifact_refs=[ArtifactRef(uri=f"memory://{context['attempt_id']}", version="1")],
  48. ),
  49. )
  50. return WorkerRunResult(context["worker_trace_id"], "completed")
  51. finally:
  52. self.active -= 1
  53. async def run_validator(self, context):
  54. verdict = self.verdicts.popleft() if self.verdicts else ValidationVerdict.PASSED
  55. if self.submit_validator:
  56. criteria = [
  57. CriterionResult(
  58. criterion_id=item["criterion_id"], verdict=verdict,
  59. reason="checked",
  60. )
  61. for item in context["task_spec"]["acceptance_criteria"]
  62. ]
  63. await self.coordinator.submit_validation(
  64. {
  65. **context,
  66. "role": AgentRole.VALIDATOR.value,
  67. "trace_id": context["validator_trace_id"],
  68. "tool_call_id": f"validate-{context['validation_id']}",
  69. },
  70. verdict,
  71. criteria,
  72. "independent report",
  73. [], [], [], "accept" if verdict == ValidationVerdict.PASSED else "replan",
  74. )
  75. return ValidatorRunResult(context["validator_trace_id"], "completed")
  76. async def make_coordinator(tmp_path, executor):
  77. trace_store = FileSystemTraceStore(str(tmp_path))
  78. await trace_store.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
  79. await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
  80. task_store = FileSystemTaskStore(str(tmp_path))
  81. coordinator = TaskCoordinator(
  82. task_store,
  83. FileSystemArtifactStore(str(tmp_path)),
  84. trace_store,
  85. OrchestrationConfig(max_parallel_tasks=4),
  86. TraceEventSink(str(tmp_path)),
  87. executor,
  88. )
  89. executor.coordinator = coordinator
  90. await coordinator.ensure_ledger("root", "mission")
  91. return coordinator, task_store, trace_store
  92. async def create_task(coordinator, objective="task", parent_task_id=None):
  93. result = await coordinator.create_tasks(
  94. "root",
  95. [{
  96. "objective": objective,
  97. "acceptance_criteria": [{"criterion_id": "c1", "description": "must pass", "hard": True}],
  98. }],
  99. parent_task_id=parent_task_id,
  100. )
  101. return result["tasks"][0]["task_id"]
  102. @pytest.mark.asyncio
  103. async def test_passed_validation_requires_planner_accept(tmp_path):
  104. executor = FakeExecutor([ValidationVerdict.PASSED])
  105. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  106. task_id = await create_task(coordinator)
  107. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  108. assert cycle.task_status == TaskStatus.AWAITING_DECISION
  109. ledger = await store.load("root")
  110. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  111. await coordinator.decide_task(
  112. "root", task_id, cycle.validation_id, DecisionAction.ACCEPT,
  113. {"reason": "all hard criteria passed"}, "decision-1",
  114. )
  115. assert (await store.load("root")).tasks[task_id].status == TaskStatus.COMPLETED
  116. @pytest.mark.asyncio
  117. async def test_failed_or_inconclusive_cannot_be_accepted(tmp_path):
  118. executor = FakeExecutor([ValidationVerdict.FAILED])
  119. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  120. task_id = await create_task(coordinator)
  121. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  122. with pytest.raises(TaskConflict, match="passed"):
  123. await coordinator.decide_task(
  124. "root", task_id, cycle.validation_id, DecisionAction.ACCEPT,
  125. {"reason": "override"}, "bad-accept",
  126. )
  127. assert (await store.load("root")).tasks[task_id].status == TaskStatus.AWAITING_DECISION
  128. @pytest.mark.asyncio
  129. async def test_repair_is_limited_to_once_per_spec_version(tmp_path):
  130. executor = FakeExecutor([ValidationVerdict.FAILED, ValidationVerdict.FAILED])
  131. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  132. task_id = await create_task(coordinator)
  133. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  134. await coordinator.decide_task(
  135. "root", task_id, first.validation_id, DecisionAction.REPAIR,
  136. {"reason": "small local correction"}, "repair-1",
  137. )
  138. second = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  139. ledger = await store.load("root")
  140. attempts = [ledger.attempts[x] for x in ledger.tasks[task_id].attempt_ids]
  141. assert attempts[0].worker_trace_id == attempts[1].worker_trace_id
  142. with pytest.raises(TaskConflict, match="limit"):
  143. await coordinator.decide_task(
  144. "root", task_id, second.validation_id, DecisionAction.REPAIR,
  145. {"reason": "try again"}, "repair-2",
  146. )
  147. @pytest.mark.asyncio
  148. async def test_worker_without_submit_attempt_needs_replan(tmp_path):
  149. executor = FakeExecutor([], submit_worker=False)
  150. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  151. task_id = await create_task(coordinator)
  152. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  153. ledger = await store.load("root")
  154. assert result.task_status == TaskStatus.NEEDS_REPLAN
  155. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  156. assert ledger.attempts[result.attempt_id].status.value == "failed"
  157. @pytest.mark.asyncio
  158. async def test_validator_without_submit_is_error_then_revalidates_with_new_trace(tmp_path):
  159. executor = FakeExecutor([ValidationVerdict.PASSED], submit_validator=False)
  160. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  161. task_id = await create_task(coordinator)
  162. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  163. ledger = await store.load("root")
  164. first_validation = ledger.validations[first.validation_id]
  165. assert first.task_status == TaskStatus.NEEDS_REPLAN
  166. assert first_validation.status.value == "error"
  167. assert first_validation.verdict is None
  168. executor.submit_validator = True
  169. executor.verdicts.append(ValidationVerdict.PASSED)
  170. second = await coordinator.revalidate_attempt(
  171. "root", task_id, first.attempt_id, "revalidate-1"
  172. )
  173. assert second.task_status == TaskStatus.AWAITING_DECISION
  174. assert second.validation_id != first.validation_id
  175. assert second.validation.validator_trace_id != first_validation.validator_trace_id
  176. @pytest.mark.asyncio
  177. async def test_retry_uses_new_trace_and_revise_invalidates_old_validation(tmp_path):
  178. executor = FakeExecutor([
  179. ValidationVerdict.FAILED,
  180. ValidationVerdict.INCONCLUSIVE,
  181. ValidationVerdict.PASSED,
  182. ])
  183. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  184. task_id = await create_task(coordinator)
  185. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  186. await coordinator.decide_task(
  187. "root", task_id, first.validation_id, DecisionAction.RETRY,
  188. {"reason": "use a fresh worker"}, "retry-1",
  189. )
  190. second = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  191. ledger = await store.load("root")
  192. first_attempt = ledger.attempts[first.attempt_id]
  193. second_attempt = ledger.attempts[second.attempt_id]
  194. assert first_attempt.worker_trace_id != second_attempt.worker_trace_id
  195. await coordinator.decide_task(
  196. "root", task_id, second.validation_id, DecisionAction.REVISE,
  197. {
  198. "reason": "clarify criterion",
  199. "objective": "revised task",
  200. "acceptance_criteria": [{"criterion_id": "c1", "description": "revised", "hard": True}],
  201. },
  202. "revise-1",
  203. )
  204. ledger = await store.load("root")
  205. assert ledger.tasks[task_id].current_spec_version == 2
  206. third = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  207. with pytest.raises(TaskConflict, match="obsolete"):
  208. await coordinator.decide_task(
  209. "root", task_id, first.validation_id, DecisionAction.ACCEPT,
  210. {"reason": "stale"}, "stale-accept",
  211. )
  212. await coordinator.decide_task(
  213. "root", task_id, third.validation_id, DecisionAction.ACCEPT,
  214. {"reason": "current validation passed"}, "current-accept",
  215. )
  216. @pytest.mark.asyncio
  217. async def test_block_unblock_cancel_and_supersede(tmp_path):
  218. executor = FakeExecutor([])
  219. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  220. task_id = await create_task(coordinator, "blocked")
  221. await coordinator.decide_task(
  222. "root", task_id, None, DecisionAction.BLOCK,
  223. {"reason": "external dependency"}, "block-1",
  224. )
  225. assert (await store.load("root")).tasks[task_id].blocked_reason == "external dependency"
  226. await coordinator.decide_task(
  227. "root", task_id, None, DecisionAction.UNBLOCK, {}, "unblock-1"
  228. )
  229. assert (await store.load("root")).tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  230. await coordinator.decide_task(
  231. "root", task_id, None, DecisionAction.CANCEL,
  232. {"reason": "no longer needed"}, "cancel-1",
  233. )
  234. assert (await store.load("root")).tasks[task_id].status == TaskStatus.CANCELLED
  235. old_id = await create_task(coordinator, "old")
  236. result = await coordinator.decide_task(
  237. "root", old_id, None, DecisionAction.SUPERSEDE,
  238. {"reason": "replace spec", "replacement": {"objective": "replacement"}},
  239. "supersede-1",
  240. )
  241. replacement_id = result["payload"]["replacement_task_id"]
  242. ledger = await store.load("root")
  243. assert ledger.tasks[old_id].status == TaskStatus.SUPERSEDED
  244. assert ledger.tasks[replacement_id].status == TaskStatus.PENDING
  245. @pytest.mark.asyncio
  246. async def test_insert_after_keeps_stable_ids_and_updates_display_order(tmp_path):
  247. executor = FakeExecutor([])
  248. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  249. first = await create_task(coordinator, "first")
  250. third = await create_task(coordinator, "third")
  251. inserted = await coordinator.create_tasks(
  252. "root",
  253. [{"objective": "second"}],
  254. placement={"after_task_id": first},
  255. idempotency_key="insert",
  256. )
  257. second = inserted["tasks"][0]["task_id"]
  258. ledger = await store.load("root")
  259. assert ledger.tasks[first].display_path == "1"
  260. assert ledger.tasks[second].display_path == "2"
  261. assert ledger.tasks[third].display_path == "3"
  262. assert len({first, second, third}) == 3
  263. repeated = await coordinator.create_tasks(
  264. "root",
  265. [{"objective": "second"}],
  266. placement={"after_task_id": first},
  267. idempotency_key="insert",
  268. )
  269. assert repeated["tasks"][0]["task_id"] == second
  270. @pytest.mark.asyncio
  271. async def test_split_children_do_not_auto_complete_parent(tmp_path):
  272. executor = FakeExecutor([ValidationVerdict.FAILED, ValidationVerdict.PASSED, ValidationVerdict.PASSED])
  273. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  274. parent_id = await create_task(coordinator, "parent")
  275. first = (await coordinator.dispatch_tasks("root", [parent_id]))[0]
  276. decision = await coordinator.decide_task(
  277. "root", parent_id, first.validation_id, DecisionAction.SPLIT,
  278. {
  279. "reason": "split work",
  280. "tasks": [
  281. {"objective": "child one", "acceptance_criteria": [{"criterion_id": "c1", "description": "pass"}]},
  282. {"objective": "child two", "acceptance_criteria": [{"criterion_id": "c1", "description": "pass"}]},
  283. ],
  284. },
  285. "split-1",
  286. )
  287. child_ids = decision["payload"]["child_task_ids"]
  288. cycles = await coordinator.dispatch_tasks("root", child_ids)
  289. for child_id, cycle in zip(child_ids, cycles):
  290. await coordinator.decide_task(
  291. "root", child_id, cycle.validation_id, DecisionAction.ACCEPT,
  292. {"reason": "passed"}, f"accept-{child_id}",
  293. )
  294. ledger = await store.load("root")
  295. assert all(ledger.tasks[x].status == TaskStatus.COMPLETED for x in child_ids)
  296. assert ledger.tasks[parent_id].status == TaskStatus.NEEDS_REPLAN
  297. @pytest.mark.asyncio
  298. async def test_parallel_tasks_are_isolated_and_bounded(tmp_path):
  299. executor = FakeExecutor([ValidationVerdict.PASSED] * 4, delay=0.02)
  300. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  301. task_ids = [await create_task(coordinator, f"task-{i}") for i in range(4)]
  302. results = await coordinator.dispatch_tasks("root", task_ids, idempotency_key="batch")
  303. assert [x.task_id for x in results] == task_ids
  304. assert executor.max_active <= 4
  305. assert all(x.task_status == TaskStatus.AWAITING_DECISION for x in results)
  306. ledger = await store.load("root")
  307. assert len({ledger.attempts[ledger.tasks[x].attempt_ids[-1]].worker_trace_id for x in task_ids}) == 4
  308. repeated = await coordinator.dispatch_tasks("root", task_ids, idempotency_key="batch")
  309. assert [x.attempt_id for x in repeated] == [x.attempt_id for x in results]
  310. @pytest.mark.asyncio
  311. async def test_real_runner_local_executor_creates_independent_terminal_traces(tmp_path):
  312. import json
  313. async def fake_llm(messages, tools, **kwargs):
  314. names = {item["function"]["name"] for item in tools or []}
  315. if "submit_attempt" in names:
  316. arguments = {
  317. "summary": "implemented",
  318. "artifact_refs": [{"uri": "memory://result", "version": "1"}],
  319. "evidence_refs": [],
  320. }
  321. tool_name = "submit_attempt"
  322. elif "submit_validation" in names:
  323. arguments = {
  324. "verdict": "passed",
  325. "criterion_results": [{"criterion_id": "c1", "verdict": "passed", "reason": "verified"}],
  326. "summary": "independent pass",
  327. "evidence_refs": [],
  328. "unverified_claims": [],
  329. "risks": [],
  330. "recommendation": "accept",
  331. }
  332. tool_name = "submit_validation"
  333. else:
  334. raise AssertionError(f"unexpected tools: {names}")
  335. return {
  336. "content": "",
  337. "tool_calls": [{
  338. "id": f"call-{tool_name}",
  339. "type": "function",
  340. "function": {"name": tool_name, "arguments": json.dumps(arguments)},
  341. }],
  342. "finish_reason": "tool_calls",
  343. }
  344. trace_store = FileSystemTraceStore(str(tmp_path))
  345. await trace_store.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
  346. await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
  347. runner = AgentRunner(trace_store=trace_store, llm_call=fake_llm)
  348. coordinator = wire_orchestration(
  349. runner,
  350. FileSystemTaskStore(str(tmp_path)),
  351. FileSystemArtifactStore(str(tmp_path)),
  352. )
  353. await coordinator.ensure_ledger("root", "mission")
  354. task_id = await create_task(coordinator)
  355. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  356. ledger = await coordinator.task_store.load("root")
  357. worker = await trace_store.get_trace(ledger.attempts[cycle.attempt_id].worker_trace_id)
  358. validator = await trace_store.get_trace(cycle.validation.validator_trace_id)
  359. assert cycle.task_status == TaskStatus.AWAITING_DECISION
  360. assert worker.agent_role == "worker" and worker.result_summary
  361. assert validator.agent_role == "validator" and validator.result_summary
  362. assert worker.trace_id != validator.trace_id