test_orchestration_v2_api.py 12 KB

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