test_orchestration_v2_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. import httpx
  4. import pytest
  5. from agent.orchestration.api_v2 import create_orchestration_router
  6. from agent.orchestration.client_v2 import OrchestrationAPIError, OrchestrationClient
  7. from agent.orchestration.coordinator import TaskConflict
  8. from agent.orchestration.models import (
  9. AcceptanceCriterion,
  10. BackgroundOperation,
  11. EventPage,
  12. OperationKind,
  13. OperationStatus,
  14. OrchestrationEvent,
  15. TaskAttempt,
  16. TaskLedger,
  17. TaskRecord,
  18. TaskSpec,
  19. TaskStatus,
  20. ValidationReport,
  21. )
  22. from agent.orchestration.store import TaskStoreError, TaskStoreNotFound
  23. FastAPI = pytest.importorskip("fastapi").FastAPI
  24. class StubStore:
  25. def __init__(self, ledger: TaskLedger, event: OrchestrationEvent) -> None:
  26. self.ledger = ledger
  27. self.event = event
  28. async def load(self, root_trace_id):
  29. if root_trace_id != self.ledger.root_trace_id:
  30. raise TaskStoreNotFound(f"No task ledger for root trace {root_trace_id}")
  31. return self.ledger
  32. async def list_events(self, root_trace_id, cursor=None, limit=100):
  33. await self.load(root_trace_id)
  34. if cursor == "bad":
  35. raise ValueError("Invalid event cursor")
  36. events = [] if cursor == "end" else [self.event]
  37. return EventPage(events=events, next_cursor="end", has_more=False)
  38. class StubCoordinator:
  39. def __init__(self, store, operation):
  40. self.task_store = store
  41. self.operation = operation
  42. self.calls = []
  43. async def start_operation(self, root_trace_id, kind, **kwargs):
  44. self.calls.append(("start", root_trace_id, kind, kwargs))
  45. return replace(
  46. self.operation,
  47. root_trace_id=root_trace_id,
  48. kind=OperationKind(kind),
  49. )
  50. async def get_operation(self, root_trace_id, operation_id):
  51. if operation_id == "conflict":
  52. raise TaskConflict("operation conflict")
  53. if operation_id == "unavailable":
  54. raise TaskStoreError("storage unavailable")
  55. if operation_id != self.operation.operation_id:
  56. raise ValueError(f"Operation not found: {operation_id}")
  57. return self.operation
  58. async def stop_operation(self, root_trace_id, operation_id, idempotency_key=None):
  59. self.calls.append(("stop", idempotency_key))
  60. return replace(self.operation, status=OperationStatus.STOPPED)
  61. async def resume_operation(self, root_trace_id, operation_id, idempotency_key=None):
  62. self.calls.append(("resume", idempotency_key))
  63. return replace(self.operation, status=OperationStatus.PENDING)
  64. @pytest.fixture
  65. def api_fixture():
  66. task = TaskRecord(
  67. task_id="task-1",
  68. goal_id=None,
  69. parent_task_id=None,
  70. display_path="1",
  71. specs=[
  72. TaskSpec(
  73. version=1,
  74. objective="test",
  75. acceptance_criteria=[
  76. AcceptanceCriterion(
  77. criterion_id="done",
  78. description="The task is complete",
  79. )
  80. ],
  81. )
  82. ],
  83. status=TaskStatus.PENDING,
  84. )
  85. attempt = TaskAttempt(
  86. attempt_id="attempt-1",
  87. task_id=task.task_id,
  88. spec_version=1,
  89. worker_trace_id="worker-1",
  90. worker_preset="worker",
  91. execution_mode="new",
  92. )
  93. validation = ValidationReport(
  94. validation_id="validation-1",
  95. task_id=task.task_id,
  96. attempt_id=attempt.attempt_id,
  97. spec_version=1,
  98. snapshot_id="snapshot-1",
  99. validator_trace_id="validator-1",
  100. )
  101. operation = BackgroundOperation(
  102. operation_id="operation-1",
  103. root_trace_id="root",
  104. kind=OperationKind.DISPATCH,
  105. request={"kind": "dispatch", "task_ids": [task.task_id], "secret": "hidden"},
  106. request_fingerprint="private-fingerprint",
  107. task_ids=[task.task_id],
  108. )
  109. ledger = TaskLedger(
  110. root_trace_id="root",
  111. mission="test",
  112. root_task_id=task.task_id,
  113. revision=3,
  114. )
  115. ledger.tasks[task.task_id] = task
  116. ledger.attempts[attempt.attempt_id] = attempt
  117. ledger.validations[validation.validation_id] = validation
  118. event = OrchestrationEvent(
  119. schema_version=1,
  120. event_id="event-1",
  121. sequence=1,
  122. root_trace_id="root",
  123. ledger_revision=3,
  124. event_type="task_created",
  125. occurred_at="2026-07-18T00:00:00+00:00",
  126. )
  127. store = StubStore(ledger, event)
  128. coordinator = StubCoordinator(store, operation)
  129. app = FastAPI()
  130. app.include_router(create_orchestration_router(coordinator))
  131. transport = httpx.ASGITransport(app=app)
  132. http = httpx.AsyncClient(transport=transport, base_url="http://test")
  133. return coordinator, http
  134. @pytest.mark.asyncio
  135. async def test_router_start_queries_and_problem_details(api_fixture):
  136. coordinator, http = api_fixture
  137. response = await http.post(
  138. "/api/v2/roots/root/operations",
  139. headers={"Idempotency-Key": "dispatch-once"},
  140. json={"kind": "dispatch", "task_ids": ["task-1"]},
  141. )
  142. assert response.status_code == 202
  143. assert response.json()["operation_id"] == "operation-1"
  144. assert "request" not in response.json()
  145. assert "request_fingerprint" not in response.json()
  146. assert coordinator.calls[0][-1]["idempotency_key"] == "dispatch-once"
  147. task_list = (await http.get("/api/v2/roots/root/tasks")).json()
  148. assert task_list["revision"] == 3
  149. assert task_list["root_task_id"] == "task-1"
  150. assert (await http.get("/api/v2/roots/root/tasks/task-1")).json()["task_id"] == "task-1"
  151. assert (await http.get("/api/v2/roots/root/attempts")).json()["items"][0]["attempt_id"] == "attempt-1"
  152. assert (await http.get("/api/v2/roots/root/attempts/attempt-1")).json()["attempt_id"] == "attempt-1"
  153. assert (await http.get("/api/v2/roots/root/validations")).json()["items"][0]["validation_id"] == "validation-1"
  154. assert (
  155. await http.get("/api/v2/roots/root/validations/validation-1")
  156. ).json()["validation_id"] == "validation-1"
  157. assert (await http.get("/api/v2/roots/root/events")).json()["events"][0]["sequence"] == 1
  158. missing = await http.get("/api/v2/roots/root/tasks/missing")
  159. assert missing.status_code == 404
  160. assert missing.json()["code"] == "not_found"
  161. invalid = await http.post(
  162. "/api/v2/roots/root/operations", json={"kind": "unknown"}
  163. )
  164. assert invalid.status_code == 422
  165. assert invalid.json()["code"] == "invalid_request"
  166. invalid_shape = await http.post(
  167. "/api/v2/roots/root/operations", json=["not", "an", "object"]
  168. )
  169. assert invalid_shape.status_code == 422
  170. assert invalid_shape.json()["code"] == "invalid_request"
  171. missing_body = await http.post("/api/v2/roots/root/operations")
  172. assert missing_body.status_code == 422
  173. assert missing_body.json()["code"] == "invalid_request"
  174. for path in (
  175. "/api/v2/roots/root/attempts/missing",
  176. "/api/v2/roots/root/validations/missing",
  177. "/api/v2/roots/missing/tasks",
  178. ):
  179. response = await http.get(path)
  180. assert response.status_code == 404
  181. assert response.json()["code"] == "not_found"
  182. for query in ("limit=nope", "limit=0", "wait_seconds=31"):
  183. response = await http.get(f"/api/v2/roots/root/events?{query}")
  184. assert response.status_code == 422
  185. assert response.json()["code"] == "invalid_request"
  186. empty = await http.get(
  187. "/api/v2/roots/root/events?cursor=end&wait_seconds=0.001"
  188. )
  189. assert empty.status_code == 200
  190. assert empty.json()["events"] == []
  191. bad_cursor = await http.get("/api/v2/roots/root/events?cursor=bad")
  192. assert bad_cursor.status_code == 422
  193. for operation_id, status, code in (
  194. ("conflict", 409, "conflict"),
  195. ("unavailable", 503, "storage_unavailable"),
  196. ):
  197. response = await http.get(
  198. f"/api/v2/roots/root/operations/{operation_id}"
  199. )
  200. assert response.status_code == status
  201. assert response.json()["code"] == code
  202. for key in ("", "x" * 201, "contains space"):
  203. response = await http.post(
  204. "/api/v2/roots/root/operations",
  205. headers={"Idempotency-Key": key},
  206. json={"kind": "dispatch", "task_ids": ["task-1"]},
  207. )
  208. assert response.status_code == 422
  209. assert response.json()["code"] == "invalid_request"
  210. schema = (await http.get("/openapi.json")).json()
  211. operation_schema = schema["paths"]["/api/v2/roots/{root_trace_id}/operations"]["post"]
  212. assert "discriminator" in operation_schema["requestBody"]["content"]["application/json"]["schema"]
  213. assert operation_schema["responses"]["202"]["content"]["application/json"]["schema"]
  214. assert operation_schema["responses"]["409"]["content"]["application/json"]["schema"]
  215. await http.aclose()
  216. @pytest.mark.asyncio
  217. async def test_async_client_round_trip_and_watch(api_fixture):
  218. _, http = api_fixture
  219. client = OrchestrationClient("http://test", client=http)
  220. operation = await client.start_revalidation(
  221. "root",
  222. "task-1",
  223. "attempt-1",
  224. deadline_at="2026-07-19T00:00:00+00:00",
  225. idempotency_key="revalidate-once",
  226. )
  227. assert operation.kind == "revalidate"
  228. assert (await client.poll("root", "operation-1")).operation_id == "operation-1"
  229. assert (await client.stop("root", "operation-1")).status == "stopped"
  230. assert (await client.resume("root", "operation-1")).status == "pending"
  231. task_list = await client.list_tasks("root")
  232. assert task_list.root_task_id == "task-1"
  233. assert task_list.items[0].task_id == "task-1"
  234. assert (await client.get_task("root", "task-1")).task_id == "task-1"
  235. assert (await client.list_attempts("root")).items[0].attempt_id == "attempt-1"
  236. assert (await client.get_attempt("root", "attempt-1")).attempt_id == "attempt-1"
  237. assert (
  238. await client.list_validations("root")
  239. ).items[0].validation_id == "validation-1"
  240. assert (
  241. await client.get_validation("root", "validation-1")
  242. ).validation_id == "validation-1"
  243. assert (await client.list_events("root", cursor=None)).next_cursor == "end"
  244. stream = client.watch_events("root", wait_seconds=0)
  245. assert (await anext(stream)).event_id == "event-1"
  246. await stream.aclose()
  247. await http.aclose()
  248. @pytest.mark.asyncio
  249. async def test_client_transport_retry_and_non_problem_error():
  250. calls = 0
  251. async def flaky(request):
  252. nonlocal calls
  253. calls += 1
  254. if calls == 1:
  255. raise httpx.ConnectError("temporary", request=request)
  256. return httpx.Response(418, text="not problem details")
  257. http = httpx.AsyncClient(
  258. transport=httpx.MockTransport(flaky), base_url="http://test"
  259. )
  260. client = OrchestrationClient("http://test", client=http, max_retries=1)
  261. with pytest.raises(OrchestrationAPIError) as caught:
  262. await client.poll("root", "operation-1")
  263. assert calls == 2
  264. assert caught.value.code == "http_error"
  265. assert caught.value.status_code == 418
  266. await http.aclose()
  267. @pytest.mark.asyncio
  268. async def test_client_retries_mutation_only_with_idempotency_key():
  269. attempts = {"unsafe": 0, "safe": 0}
  270. async def handler(request):
  271. key = "safe" if request.headers.get("Idempotency-Key") else "unsafe"
  272. attempts[key] += 1
  273. if attempts[key] < 2:
  274. return httpx.Response(
  275. 503,
  276. json={
  277. "title": "Unavailable",
  278. "status": 503,
  279. "detail": "retry",
  280. "code": "service_unavailable",
  281. },
  282. )
  283. return httpx.Response(
  284. 200,
  285. json={
  286. "operation_id": "operation-1",
  287. "root_trace_id": "root",
  288. "kind": "dispatch",
  289. "status": "pending",
  290. "task_ids": ["task-1"],
  291. "attempt_ids": [],
  292. "validation_ids": [],
  293. "result_ref": None,
  294. "execution_epoch": 0,
  295. "deadline_at": None,
  296. "error": None,
  297. "started_at": None,
  298. "completed_at": None,
  299. "created_at": "now",
  300. "updated_at": "now",
  301. },
  302. )
  303. http = httpx.AsyncClient(
  304. transport=httpx.MockTransport(handler), base_url="http://test"
  305. )
  306. client = OrchestrationClient("http://test", client=http, max_retries=2)
  307. with pytest.raises(OrchestrationAPIError):
  308. await client.start_dispatch("root", ["task-1"])
  309. assert attempts["unsafe"] == 1
  310. result = await client.start_dispatch(
  311. "root", ["task-1"], idempotency_key="safe-on-retry"
  312. )
  313. assert result.operation_id == "operation-1"
  314. assert attempts["safe"] == 2
  315. await http.aclose()