test_orchestration_v2_api.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  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. ArtifactRef,
  11. ArtifactSnapshot,
  12. BackgroundOperation,
  13. DecisionAction,
  14. EventPage,
  15. OperationKind,
  16. OperationStatus,
  17. OrchestrationEvent,
  18. PlannerDecision,
  19. TaskAttempt,
  20. TaskLedger,
  21. TaskRecord,
  22. TaskSpec,
  23. TaskStatus,
  24. ValidationReport,
  25. )
  26. from agent.orchestration.store import TaskStoreError, TaskStoreNotFound
  27. FastAPI = pytest.importorskip("fastapi").FastAPI
  28. class StubStore:
  29. def __init__(self, ledger: TaskLedger, event: OrchestrationEvent) -> None:
  30. self.ledger = ledger
  31. self.event = event
  32. self.load_calls = 0
  33. async def load(self, root_trace_id):
  34. self.load_calls += 1
  35. if root_trace_id != self.ledger.root_trace_id:
  36. raise TaskStoreNotFound(f"No task ledger for root trace {root_trace_id}")
  37. return self.ledger
  38. async def list_events(self, root_trace_id, cursor=None, limit=100):
  39. await self.load(root_trace_id)
  40. if cursor == "bad":
  41. raise ValueError("Invalid event cursor")
  42. events = [] if cursor == "end" else [self.event]
  43. return EventPage(events=events, next_cursor="end", has_more=False)
  44. class StubCoordinator:
  45. def __init__(self, store, operation, artifact_store):
  46. self.task_store = store
  47. self.artifact_store = artifact_store
  48. self.operation = operation
  49. self.calls = []
  50. async def start_operation(self, root_trace_id, kind, **kwargs):
  51. self.calls.append(("start", root_trace_id, kind, kwargs))
  52. return replace(
  53. self.operation,
  54. root_trace_id=root_trace_id,
  55. kind=OperationKind(kind),
  56. )
  57. async def get_operation(self, root_trace_id, operation_id):
  58. if operation_id == "conflict":
  59. raise TaskConflict("operation conflict")
  60. if operation_id == "unavailable":
  61. raise TaskStoreError("storage unavailable")
  62. if operation_id != self.operation.operation_id:
  63. raise ValueError(f"Operation not found: {operation_id}")
  64. return self.operation
  65. async def stop_operation(self, root_trace_id, operation_id, idempotency_key=None):
  66. self.calls.append(("stop", idempotency_key))
  67. return replace(self.operation, status=OperationStatus.STOPPED)
  68. async def resume_operation(self, root_trace_id, operation_id, idempotency_key=None):
  69. self.calls.append(("resume", idempotency_key))
  70. return replace(self.operation, status=OperationStatus.PENDING)
  71. async def root_completion(self, root_trace_id):
  72. ledger = await self.task_store.load(root_trace_id)
  73. root = ledger.tasks[ledger.root_task_id]
  74. return {
  75. "root_task_id": root.task_id,
  76. "root_objective": ledger.root_objective,
  77. "status": root.status.value,
  78. "blocked_reason": root.blocked_reason,
  79. "result_summary": None,
  80. "pending_tasks": ["must not leak"],
  81. }
  82. class StubArtifactStore:
  83. def __init__(self, snapshot):
  84. self.snapshot = snapshot
  85. async def get(self, root_trace_id, snapshot_id):
  86. if root_trace_id != "root" or snapshot_id != self.snapshot.snapshot_id:
  87. raise FileNotFoundError(snapshot_id)
  88. return self.snapshot
  89. @pytest.fixture
  90. def api_fixture():
  91. task = TaskRecord(
  92. task_id="task-1",
  93. goal_id=None,
  94. parent_task_id=None,
  95. display_path="1",
  96. specs=[
  97. TaskSpec(
  98. version=1,
  99. objective="test",
  100. acceptance_criteria=[
  101. AcceptanceCriterion(
  102. criterion_id="done",
  103. description="The task is complete",
  104. )
  105. ],
  106. )
  107. ],
  108. status=TaskStatus.PENDING,
  109. )
  110. attempt = TaskAttempt(
  111. attempt_id="attempt-1",
  112. task_id=task.task_id,
  113. spec_version=1,
  114. worker_trace_id="worker-1",
  115. worker_preset="worker",
  116. execution_mode="new",
  117. accepted_child_decision_ids=(),
  118. )
  119. validation = ValidationReport(
  120. validation_id="validation-1",
  121. task_id=task.task_id,
  122. attempt_id=attempt.attempt_id,
  123. spec_version=1,
  124. snapshot_id="snapshot-1",
  125. validator_trace_id="validator-1",
  126. )
  127. operation = BackgroundOperation(
  128. operation_id="operation-1",
  129. root_trace_id="root",
  130. kind=OperationKind.DISPATCH,
  131. request={"kind": "dispatch", "task_ids": [task.task_id], "secret": "hidden"},
  132. request_fingerprint="private-fingerprint",
  133. task_ids=[task.task_id],
  134. )
  135. ledger = TaskLedger(
  136. root_trace_id="root",
  137. mission="test",
  138. root_task_id=task.task_id,
  139. revision=3,
  140. )
  141. ledger.tasks[task.task_id] = task
  142. ledger.attempts[attempt.attempt_id] = attempt
  143. ledger.validations[validation.validation_id] = validation
  144. decision = PlannerDecision(
  145. decision_id="decision-1",
  146. task_id=task.task_id,
  147. action=DecisionAction.RETRY,
  148. reason="retry",
  149. from_status=TaskStatus.NEEDS_REPLAN,
  150. to_status=TaskStatus.PENDING,
  151. created_at=task.created_at,
  152. )
  153. ledger.decisions[decision.decision_id] = decision
  154. ledger.operations[operation.operation_id] = operation
  155. event = OrchestrationEvent(
  156. schema_version=1,
  157. event_id="event-1",
  158. sequence=1,
  159. root_trace_id="root",
  160. ledger_revision=3,
  161. event_type="task_created",
  162. occurred_at="2026-07-18T00:00:00+00:00",
  163. )
  164. store = StubStore(ledger, event)
  165. snapshot = ArtifactSnapshot(
  166. snapshot_id="snapshot-1",
  167. attempt_id=attempt.attempt_id,
  168. normalized_content={"summary": "must not leak"},
  169. sha256="sha256",
  170. artifact_refs=[ArtifactRef(uri="memory://artifact", version="1")],
  171. evidence_refs=[],
  172. )
  173. coordinator = StubCoordinator(store, operation, StubArtifactStore(snapshot))
  174. app = FastAPI()
  175. app.include_router(create_orchestration_router(coordinator))
  176. transport = httpx.ASGITransport(app=app)
  177. http = httpx.AsyncClient(transport=transport, base_url="http://test")
  178. return coordinator, http
  179. @pytest.mark.asyncio
  180. async def test_router_start_queries_and_problem_details(api_fixture):
  181. coordinator, http = api_fixture
  182. response = await http.post(
  183. "/api/v2/roots/root/operations",
  184. headers={"Idempotency-Key": "dispatch-once"},
  185. json={"kind": "dispatch", "task_ids": ["task-1"]},
  186. )
  187. assert response.status_code == 202
  188. assert response.json()["operation_id"] == "operation-1"
  189. assert "request" not in response.json()
  190. assert "request_fingerprint" not in response.json()
  191. assert coordinator.calls[0][-1]["idempotency_key"] == "dispatch-once"
  192. task_list = (await http.get("/api/v2/roots/root/tasks")).json()
  193. assert task_list["revision"] == 3
  194. assert task_list["root_task_id"] == "task-1"
  195. assert (await http.get("/api/v2/roots/root/tasks/task-1")).json()["task_id"] == "task-1"
  196. assert (await http.get("/api/v2/roots/root/attempts")).json()["items"][0]["attempt_id"] == "attempt-1"
  197. assert (await http.get("/api/v2/roots/root/attempts/attempt-1")).json()["attempt_id"] == "attempt-1"
  198. assert (await http.get("/api/v2/roots/root/validations")).json()["items"][0]["validation_id"] == "validation-1"
  199. assert (
  200. await http.get("/api/v2/roots/root/validations/validation-1")
  201. ).json()["validation_id"] == "validation-1"
  202. assert (await http.get("/api/v2/roots/root/events")).json()["events"][0]["sequence"] == 1
  203. missing = await http.get("/api/v2/roots/root/tasks/missing")
  204. assert missing.status_code == 404
  205. assert missing.json()["code"] == "not_found"
  206. invalid = await http.post(
  207. "/api/v2/roots/root/operations", json={"kind": "unknown"}
  208. )
  209. assert invalid.status_code == 422
  210. assert invalid.json()["code"] == "invalid_request"
  211. invalid_shape = await http.post(
  212. "/api/v2/roots/root/operations", json=["not", "an", "object"]
  213. )
  214. assert invalid_shape.status_code == 422
  215. assert invalid_shape.json()["code"] == "invalid_request"
  216. missing_body = await http.post("/api/v2/roots/root/operations")
  217. assert missing_body.status_code == 422
  218. assert missing_body.json()["code"] == "invalid_request"
  219. for path in (
  220. "/api/v2/roots/root/attempts/missing",
  221. "/api/v2/roots/root/validations/missing",
  222. "/api/v2/roots/missing/tasks",
  223. ):
  224. response = await http.get(path)
  225. assert response.status_code == 404
  226. assert response.json()["code"] == "not_found"
  227. for query in ("limit=nope", "limit=0", "wait_seconds=31"):
  228. response = await http.get(f"/api/v2/roots/root/events?{query}")
  229. assert response.status_code == 422
  230. assert response.json()["code"] == "invalid_request"
  231. empty = await http.get(
  232. "/api/v2/roots/root/events?cursor=end&wait_seconds=0.001"
  233. )
  234. assert empty.status_code == 200
  235. assert empty.json()["events"] == []
  236. bad_cursor = await http.get("/api/v2/roots/root/events?cursor=bad")
  237. assert bad_cursor.status_code == 422
  238. for operation_id, status, code in (
  239. ("conflict", 409, "conflict"),
  240. ("unavailable", 503, "storage_unavailable"),
  241. ):
  242. response = await http.get(
  243. f"/api/v2/roots/root/operations/{operation_id}"
  244. )
  245. assert response.status_code == status
  246. assert response.json()["code"] == code
  247. for key in ("", "x" * 201, "contains space"):
  248. response = await http.post(
  249. "/api/v2/roots/root/operations",
  250. headers={"Idempotency-Key": key},
  251. json={"kind": "dispatch", "task_ids": ["task-1"]},
  252. )
  253. assert response.status_code == 422
  254. assert response.json()["code"] == "invalid_request"
  255. schema = (await http.get("/openapi.json")).json()
  256. operation_schema = schema["paths"]["/api/v2/roots/{root_trace_id}/operations"]["post"]
  257. assert "discriminator" in operation_schema["requestBody"]["content"]["application/json"]["schema"]
  258. assert operation_schema["responses"]["202"]["content"]["application/json"]["schema"]
  259. assert operation_schema["responses"]["409"]["content"]["application/json"]["schema"]
  260. await http.aclose()
  261. @pytest.mark.asyncio
  262. async def test_async_client_round_trip_and_watch(api_fixture):
  263. _, http = api_fixture
  264. client = OrchestrationClient("http://test", client=http)
  265. operation = await client.start_revalidation(
  266. "root",
  267. "task-1",
  268. "attempt-1",
  269. deadline_at="2026-07-19T00:00:00+00:00",
  270. idempotency_key="revalidate-once",
  271. )
  272. assert operation.kind == "revalidate"
  273. assert (await client.poll("root", "operation-1")).operation_id == "operation-1"
  274. assert (await client.stop("root", "operation-1")).status == "stopped"
  275. assert (await client.resume("root", "operation-1")).status == "pending"
  276. task_list = await client.list_tasks("root")
  277. assert task_list.root_task_id == "task-1"
  278. assert task_list.items[0].task_id == "task-1"
  279. assert (await client.get_task("root", "task-1")).task_id == "task-1"
  280. assert (await client.list_attempts("root")).items[0].attempt_id == "attempt-1"
  281. assert (await client.get_attempt("root", "attempt-1")).attempt_id == "attempt-1"
  282. assert (
  283. await client.list_validations("root")
  284. ).items[0].validation_id == "validation-1"
  285. assert (
  286. await client.get_validation("root", "validation-1")
  287. ).validation_id == "validation-1"
  288. assert (await client.list_events("root", cursor=None)).next_cursor == "end"
  289. snapshot = await client.get_mission_snapshot("root")
  290. assert snapshot.revision == 3
  291. assert snapshot.root_objective == "test"
  292. assert snapshot.attempts[0].accepted_child_decision_ids == []
  293. completion = await client.get_root_completion("root")
  294. assert completion.root_task_id == "task-1"
  295. artifact = await client.get_artifact_snapshot("root", "snapshot-1")
  296. assert artifact.sha256 == "sha256"
  297. stream = client.watch_events("root", wait_seconds=0)
  298. assert (await anext(stream)).event_id == "event-1"
  299. await stream.aclose()
  300. await http.aclose()
  301. @pytest.mark.asyncio
  302. async def test_atomic_mission_snapshot_completion_and_artifact_schema(api_fixture):
  303. coordinator, http = api_fixture
  304. coordinator.task_store.load_calls = 0
  305. response = await http.get("/api/v2/roots/root/snapshot")
  306. assert response.status_code == 200
  307. assert coordinator.task_store.load_calls == 1
  308. body = response.json()
  309. assert set(body) == {
  310. "schema_version",
  311. "revision",
  312. "root_trace_id",
  313. "root_task_id",
  314. "root_objective",
  315. "focused_task_id",
  316. "tasks",
  317. "attempts",
  318. "validations",
  319. "decisions",
  320. "operations",
  321. }
  322. assert body["schema_version"] == 1
  323. assert "request" not in body["operations"][0]
  324. assert "request_fingerprint" not in body["operations"][0]
  325. assert body["attempts"][0]["accepted_child_decision_ids"] == []
  326. completion = await http.get("/api/v2/roots/root/completion")
  327. assert set(completion.json()) == {
  328. "root_task_id",
  329. "root_objective",
  330. "status",
  331. "blocked_reason",
  332. "result_summary",
  333. }
  334. artifact = await http.get("/api/v2/roots/root/snapshots/snapshot-1")
  335. assert artifact.status_code == 200
  336. assert "normalized_content" not in artifact.json()
  337. missing = await http.get("/api/v2/roots/root/snapshots/missing")
  338. assert missing.status_code == 404
  339. assert missing.json()["code"] == "not_found"
  340. assert (await http.get("/api/v2/capabilities")).json() == {
  341. "api_version": "v2",
  342. "mission_snapshot_schema_version": 1,
  343. "event_schema_version": 2,
  344. "capabilities": [
  345. "mission_snapshot",
  346. "root_completion",
  347. "artifact_snapshot",
  348. "orchestration_event_cursor",
  349. "trace_correlation_filters",
  350. ],
  351. }
  352. await http.aclose()
  353. @pytest.mark.asyncio
  354. async def test_client_transport_retry_and_non_problem_error():
  355. calls = 0
  356. async def flaky(request):
  357. nonlocal calls
  358. calls += 1
  359. if calls == 1:
  360. raise httpx.ConnectError("temporary", request=request)
  361. return httpx.Response(418, text="not problem details")
  362. http = httpx.AsyncClient(
  363. transport=httpx.MockTransport(flaky), base_url="http://test"
  364. )
  365. client = OrchestrationClient("http://test", client=http, max_retries=1)
  366. with pytest.raises(OrchestrationAPIError) as caught:
  367. await client.poll("root", "operation-1")
  368. assert calls == 2
  369. assert caught.value.code == "http_error"
  370. assert caught.value.status_code == 418
  371. await http.aclose()
  372. @pytest.mark.asyncio
  373. async def test_client_retries_mutation_only_with_idempotency_key():
  374. attempts = {"unsafe": 0, "safe": 0}
  375. async def handler(request):
  376. key = "safe" if request.headers.get("Idempotency-Key") else "unsafe"
  377. attempts[key] += 1
  378. if attempts[key] < 2:
  379. return httpx.Response(
  380. 503,
  381. json={
  382. "title": "Unavailable",
  383. "status": 503,
  384. "detail": "retry",
  385. "code": "service_unavailable",
  386. },
  387. )
  388. return httpx.Response(
  389. 200,
  390. json={
  391. "operation_id": "operation-1",
  392. "root_trace_id": "root",
  393. "kind": "dispatch",
  394. "status": "pending",
  395. "task_ids": ["task-1"],
  396. "attempt_ids": [],
  397. "validation_ids": [],
  398. "result_ref": None,
  399. "execution_epoch": 0,
  400. "deadline_at": None,
  401. "error": None,
  402. "started_at": None,
  403. "completed_at": None,
  404. "created_at": "now",
  405. "updated_at": "now",
  406. },
  407. )
  408. http = httpx.AsyncClient(
  409. transport=httpx.MockTransport(handler), base_url="http://test"
  410. )
  411. client = OrchestrationClient("http://test", client=http, max_retries=2)
  412. with pytest.raises(OrchestrationAPIError):
  413. await client.start_dispatch("root", ["task-1"])
  414. assert attempts["unsafe"] == 1
  415. result = await client.start_dispatch(
  416. "root", ["task-1"], idempotency_key="safe-on-retry"
  417. )
  418. assert result.operation_id == "operation-1"
  419. assert attempts["safe"] == 2
  420. await http.aclose()