test_orchestration_operations.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. import asyncio
  2. import pytest
  3. from agent.orchestration.config import OrchestrationConfig
  4. from agent.orchestration.coordinator import TaskConflict, TaskCoordinator
  5. from agent.orchestration.models import (
  6. AgentRole,
  7. ArtifactRef,
  8. AttemptStatus,
  9. AttemptSubmission,
  10. BackgroundOperation,
  11. CriterionResult,
  12. OperationKind,
  13. OperationStatus,
  14. TaskStatus,
  15. ValidationVerdict,
  16. )
  17. from agent.orchestration.protocols import WorkerRunResult
  18. from agent.orchestration.store import FileSystemArtifactStore, TraceEventSink
  19. from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
  20. class BlockingWorkerExecutor:
  21. def __init__(self):
  22. self.coordinator = None
  23. self.started = asyncio.Event()
  24. self.context = None
  25. self.stop_calls = []
  26. async def run_worker(self, context):
  27. self.context = context
  28. self.started.set()
  29. await asyncio.Event().wait()
  30. async def run_validator(self, context):
  31. raise AssertionError("validator must not start")
  32. async def stop(self, trace_id):
  33. self.stop_calls.append(trace_id)
  34. return True
  35. class SlowWorkerExecutor:
  36. def __init__(self):
  37. self.coordinator = None
  38. async def run_worker(self, context):
  39. await asyncio.sleep(1)
  40. return WorkerRunResult(context["worker_trace_id"], "completed")
  41. async def run_validator(self, context):
  42. raise AssertionError("validator must not start")
  43. async def stop(self, trace_id):
  44. return True
  45. class HangingStopExecutor(BlockingWorkerExecutor):
  46. async def stop(self, trace_id):
  47. self.stop_calls.append(trace_id)
  48. await asyncio.Event().wait()
  49. class CancellationResistantExecutor(BlockingWorkerExecutor):
  50. def __init__(self):
  51. super().__init__()
  52. self.release = asyncio.Event()
  53. async def run_worker(self, context):
  54. self.context = context
  55. self.started.set()
  56. try:
  57. await asyncio.Event().wait()
  58. except asyncio.CancelledError:
  59. await self.release.wait()
  60. return WorkerRunResult(context["worker_trace_id"], "completed")
  61. class BlockingValidatorExecutor(FakeExecutor):
  62. def __init__(self):
  63. super().__init__([])
  64. self.validator_started = asyncio.Event()
  65. self.validator_context = None
  66. async def run_validator(self, context):
  67. self.validator_context = context
  68. self.validator_started.set()
  69. await asyncio.Event().wait()
  70. class SubmittedBlockingExecutor(FakeExecutor):
  71. def __init__(self):
  72. super().__init__([])
  73. self.submitted = asyncio.Event()
  74. self.release = asyncio.Event()
  75. async def run_worker(self, context):
  76. self.worker_calls += 1
  77. await self.coordinator.submit_attempt(
  78. {
  79. **context,
  80. "role": AgentRole.WORKER.value,
  81. "trace_id": context["worker_trace_id"],
  82. "tool_call_id": f"submit-{context['attempt_id']}",
  83. },
  84. AttemptSubmission(
  85. summary="durable before worker return",
  86. artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
  87. ),
  88. )
  89. self.submitted.set()
  90. await self.release.wait()
  91. return WorkerRunResult(context["worker_trace_id"], "completed")
  92. def second_coordinator(tmp_path, store, trace_store, executor):
  93. coordinator = TaskCoordinator(
  94. store,
  95. FileSystemArtifactStore(str(tmp_path)),
  96. trace_store,
  97. OrchestrationConfig(stop_grace_seconds=0),
  98. TraceEventSink(str(tmp_path)),
  99. executor,
  100. )
  101. executor.coordinator = coordinator
  102. return coordinator
  103. async def persist_operation(store, operation):
  104. ledger = await store.load(operation.root_trace_id)
  105. ledger.operations[operation.operation_id] = operation
  106. await store.commit(ledger, ledger.revision)
  107. @pytest.mark.asyncio
  108. async def test_advance_normalizes_stop_requested_stages_atomically(tmp_path):
  109. executor = FakeExecutor([])
  110. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  111. task_id = await create_task(coordinator, "normalize requested stop")
  112. operation = BackgroundOperation(
  113. operation_id="stop-requested-operation",
  114. root_trace_id="root",
  115. kind=OperationKind.DISPATCH,
  116. status=OperationStatus.RUNNING,
  117. task_ids=[task_id],
  118. execution_epoch=1,
  119. )
  120. await persist_operation(store, operation)
  121. reserved = await coordinator._create_attempt(
  122. "root",
  123. task_id,
  124. "worker",
  125. None,
  126. operation_id=operation.operation_id,
  127. execution_epoch=1,
  128. )
  129. await coordinator._mark_stage_started(
  130. "root",
  131. operation.operation_id,
  132. 1,
  133. attempt_id=reserved["attempt_id"],
  134. )
  135. ledger = await store.load("root")
  136. ledger.operations[operation.operation_id].status = OperationStatus.STOP_REQUESTED
  137. ledger.operations[operation.operation_id].execution_epoch = 2
  138. await store.commit(ledger, ledger.revision)
  139. stopped = await coordinator.advance_operation("root", operation.operation_id)
  140. ledger = await store.load("root")
  141. assert stopped.status == OperationStatus.STOPPED
  142. assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.STOPPED
  143. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  144. @pytest.mark.asyncio
  145. async def test_stop_marks_started_attempt_and_rejects_late_epoch_submission(tmp_path):
  146. executor = BlockingWorkerExecutor()
  147. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  148. coordinator.config = OrchestrationConfig(stop_grace_seconds=0)
  149. task_id = await create_task(coordinator, "stop running worker")
  150. operation = await coordinator.start_operation(
  151. "root", OperationKind.DISPATCH, task_ids=[task_id]
  152. )
  153. await asyncio.wait_for(executor.started.wait(), timeout=1)
  154. stopped = await coordinator.stop_operation(
  155. "root", operation.operation_id, idempotency_key="stop-once"
  156. )
  157. ledger = await store.load("root")
  158. attempt = ledger.attempts[ledger.tasks[task_id].attempt_ids[-1]]
  159. assert stopped.status == OperationStatus.STOPPED
  160. assert attempt.status == AttemptStatus.STOPPED
  161. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  162. assert executor.stop_calls == [attempt.worker_trace_id]
  163. with pytest.raises(TaskConflict, match="epoch|stopping"):
  164. await coordinator.submit_attempt(
  165. {
  166. **executor.context,
  167. "role": AgentRole.WORKER.value,
  168. "trace_id": attempt.worker_trace_id,
  169. "tool_call_id": "late-submit",
  170. },
  171. AttemptSubmission(
  172. summary="late",
  173. artifact_refs=[ArtifactRef(uri="memory://late", version="1")],
  174. ),
  175. )
  176. revision = ledger.revision
  177. replay = await coordinator.stop_operation(
  178. "root", operation.operation_id, idempotency_key="stop-once"
  179. )
  180. assert replay.status == OperationStatus.STOPPED
  181. assert (await store.load("root")).revision == revision
  182. with pytest.raises(TaskConflict, match="resume_not_safe"):
  183. await coordinator.resume_operation("root", operation.operation_id)
  184. @pytest.mark.asyncio
  185. async def test_hanging_executor_stop_is_bounded_by_grace_period(tmp_path):
  186. executor = HangingStopExecutor()
  187. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  188. coordinator.config = OrchestrationConfig(stop_grace_seconds=0.01)
  189. task_id = await create_task(coordinator, "bounded stop")
  190. operation = await coordinator.start_operation(
  191. "root", OperationKind.DISPATCH, task_ids=[task_id]
  192. )
  193. await asyncio.wait_for(executor.started.wait(), timeout=1)
  194. stopped = await asyncio.wait_for(
  195. coordinator.stop_operation("root", operation.operation_id), timeout=0.5
  196. )
  197. assert stopped.status == OperationStatus.STOPPED
  198. assert executor.stop_calls
  199. assert (await store.load("root")).operations[operation.operation_id].status == OperationStatus.STOPPED
  200. @pytest.mark.asyncio
  201. async def test_cancellation_resistant_runtime_cannot_block_stop_or_commit_late(tmp_path):
  202. executor = CancellationResistantExecutor()
  203. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  204. coordinator.config = OrchestrationConfig(stop_grace_seconds=0.01)
  205. task_id = await create_task(coordinator, "resists cancellation")
  206. operation = await coordinator.start_operation(
  207. "root", OperationKind.DISPATCH, task_ids=[task_id]
  208. )
  209. await asyncio.wait_for(executor.started.wait(), timeout=1)
  210. stopped = await asyncio.wait_for(
  211. coordinator.stop_operation("root", operation.operation_id), timeout=0.5
  212. )
  213. assert stopped.status == OperationStatus.STOPPED
  214. executor.release.set()
  215. await asyncio.wait_for(
  216. coordinator.runtime.wait("root", operation.operation_id), timeout=0.5
  217. )
  218. ledger = await store.load("root")
  219. assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
  220. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  221. @pytest.mark.asyncio
  222. async def test_worker_timeout_expires_attempt_without_starting_validation(tmp_path):
  223. executor = SlowWorkerExecutor()
  224. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  225. coordinator.config = OrchestrationConfig(worker_timeout_seconds=0.001)
  226. task_id = await create_task(coordinator, "timeout")
  227. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  228. ledger = await store.load("root")
  229. assert result.task_status == TaskStatus.NEEDS_REPLAN
  230. assert ledger.attempts[result.attempt_id].status == AttemptStatus.EXPIRED
  231. assert ledger.tasks[task_id].validation_ids == []
  232. @pytest.mark.asyncio
  233. async def test_recover_then_resume_operation_before_any_attempt_is_safe(tmp_path):
  234. executor = FakeExecutor([])
  235. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  236. task_id = await create_task(coordinator, "resume before reservation")
  237. operation = BackgroundOperation(
  238. operation_id="crashed-before-reservation",
  239. root_trace_id="root",
  240. kind=OperationKind.DISPATCH,
  241. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  242. request_fingerprint="fingerprint",
  243. status=OperationStatus.RUNNING,
  244. task_ids=[task_id],
  245. execution_epoch=1,
  246. started_at="2026-07-18T00:00:00+00:00",
  247. )
  248. await persist_operation(store, operation)
  249. recovered = await coordinator.recover_operations("root")
  250. assert recovered[0].status == OperationStatus.STOPPED
  251. resumed = await coordinator.resume_operation(
  252. "root", operation.operation_id, idempotency_key="resume-safe"
  253. )
  254. completed = await coordinator.await_operation("root", resumed.operation_id)
  255. ledger = await store.load("root")
  256. assert completed.status == OperationStatus.COMPLETED
  257. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  258. assert executor.worker_calls == executor.validator_calls == 1
  259. @pytest.mark.asyncio
  260. async def test_recover_submitted_attempt_resumes_at_validation_only(tmp_path):
  261. executor = FakeExecutor([])
  262. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  263. task_id = await create_task(coordinator, "resume submitted")
  264. operation = BackgroundOperation(
  265. operation_id="crashed-after-submit",
  266. root_trace_id="root",
  267. kind=OperationKind.DISPATCH,
  268. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  269. request_fingerprint="fingerprint",
  270. status=OperationStatus.RUNNING,
  271. task_ids=[task_id],
  272. execution_epoch=1,
  273. started_at="2026-07-18T00:00:00+00:00",
  274. )
  275. await persist_operation(store, operation)
  276. reserved = await coordinator._create_attempt(
  277. "root",
  278. task_id,
  279. "worker",
  280. None,
  281. operation_id=operation.operation_id,
  282. execution_epoch=1,
  283. )
  284. await coordinator.submit_attempt(
  285. {
  286. "role": AgentRole.WORKER.value,
  287. "root_trace_id": "root",
  288. "task_id": task_id,
  289. "attempt_id": reserved["attempt_id"],
  290. "trace_id": reserved["worker_trace_id"],
  291. "spec_version": 1,
  292. "operation_id": operation.operation_id,
  293. "execution_epoch": 1,
  294. "tool_call_id": "submitted-before-crash",
  295. },
  296. AttemptSubmission(
  297. summary="submitted",
  298. artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
  299. ),
  300. )
  301. await coordinator.recover_operations("root")
  302. resumed = await coordinator.resume_operation("root", operation.operation_id)
  303. await coordinator.await_operation("root", resumed.operation_id)
  304. ledger = await store.load("root")
  305. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  306. assert executor.worker_calls == 0
  307. assert executor.validator_calls == 1
  308. assert len(ledger.tasks[task_id].attempt_ids) == 1
  309. @pytest.mark.asyncio
  310. async def test_recover_started_attempt_is_not_resumable(tmp_path):
  311. executor = FakeExecutor([])
  312. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  313. task_id = await create_task(coordinator, "unsafe crash")
  314. operation = BackgroundOperation(
  315. operation_id="crashed-during-worker",
  316. root_trace_id="root",
  317. kind=OperationKind.DISPATCH,
  318. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  319. request_fingerprint="fingerprint",
  320. status=OperationStatus.RUNNING,
  321. task_ids=[task_id],
  322. execution_epoch=1,
  323. started_at="2026-07-18T00:00:00+00:00",
  324. )
  325. await persist_operation(store, operation)
  326. reserved = await coordinator._create_attempt(
  327. "root",
  328. task_id,
  329. "worker",
  330. None,
  331. operation_id=operation.operation_id,
  332. execution_epoch=1,
  333. )
  334. ledger = await store.load("root")
  335. ledger.attempts[reserved["attempt_id"]].started_at = "2026-07-18T00:00:01+00:00"
  336. await store.commit(ledger, ledger.revision)
  337. await coordinator.recover_operations("root")
  338. ledger = await store.load("root")
  339. assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.STOPPED
  340. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  341. with pytest.raises(TaskConflict, match="resume_not_safe"):
  342. await coordinator.resume_operation("root", operation.operation_id)
  343. @pytest.mark.asyncio
  344. async def test_remote_stop_cannot_be_overwritten_and_submitted_attempt_resumes_validation(
  345. tmp_path,
  346. ):
  347. running_executor = SubmittedBlockingExecutor()
  348. coordinator_a, store, trace_store = await make_coordinator(
  349. tmp_path, running_executor
  350. )
  351. task_id = await create_task(coordinator_a, "remote stop after submit")
  352. operation = await coordinator_a.start_operation(
  353. "root", OperationKind.DISPATCH, task_ids=[task_id]
  354. )
  355. await asyncio.wait_for(running_executor.submitted.wait(), timeout=1)
  356. resume_executor = FakeExecutor([])
  357. coordinator_b = second_coordinator(
  358. tmp_path, store, trace_store, resume_executor
  359. )
  360. stopped = await coordinator_b.stop_operation("root", operation.operation_id)
  361. running_executor.release.set()
  362. await coordinator_a.runtime.wait("root", operation.operation_id)
  363. ledger = await store.load("root")
  364. assert stopped.status == OperationStatus.STOPPED
  365. assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
  366. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_VALIDATION
  367. assert ledger.tasks[task_id].validation_ids == []
  368. resumed = await coordinator_b.resume_operation("root", operation.operation_id)
  369. completed = await coordinator_b.await_operation("root", resumed.operation_id)
  370. ledger = await store.load("root")
  371. assert completed.status == OperationStatus.COMPLETED
  372. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  373. assert resume_executor.worker_calls == 0
  374. assert resume_executor.validator_calls == 1
  375. @pytest.mark.asyncio
  376. async def test_past_deadline_never_starts_agents_and_is_part_of_idempotency(tmp_path):
  377. executor = FakeExecutor([])
  378. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  379. task_id = await create_task(coordinator, "already expired")
  380. operation = await coordinator.start_operation(
  381. "root",
  382. OperationKind.DISPATCH,
  383. task_ids=[task_id],
  384. deadline_at="2000-01-01T00:00:00Z",
  385. idempotency_key="deadline-key",
  386. )
  387. completed = await coordinator.await_operation("root", operation.operation_id)
  388. assert completed.status == OperationStatus.FAILED
  389. assert "deadline" in (completed.error or "").lower()
  390. assert executor.worker_calls == executor.validator_calls == 0
  391. with pytest.raises(TaskConflict, match="different operation or request"):
  392. await coordinator.start_operation(
  393. "root",
  394. OperationKind.DISPATCH,
  395. task_ids=[task_id],
  396. deadline_at="2040-01-01T00:00:00+00:00",
  397. idempotency_key="deadline-key",
  398. )
  399. ledger = await store.load("root")
  400. command = ledger.command_records["root:operation.dispatch:deadline-key"]
  401. assert command.operation_id == operation.operation_id
  402. events = await store.list_events("root", limit=1000)
  403. started = [item for item in events.events if item.event_type == "operation_started"]
  404. assert started[-1].operation_id == operation.operation_id
  405. @pytest.mark.asyncio
  406. async def test_await_operation_polls_shared_store_when_other_runtime_owns_task(tmp_path):
  407. executor = FakeExecutor([], delay=0.05)
  408. coordinator_a, store, trace_store = await make_coordinator(tmp_path, executor)
  409. task_id = await create_task(coordinator_a, "cross coordinator await")
  410. operation = await coordinator_a.start_operation(
  411. "root", OperationKind.DISPATCH, task_ids=[task_id]
  412. )
  413. while (await coordinator_a.get_operation("root", operation.operation_id)).status != OperationStatus.RUNNING:
  414. await asyncio.sleep(0)
  415. observer = second_coordinator(tmp_path, store, trace_store, FakeExecutor([]))
  416. completed = await asyncio.wait_for(
  417. observer.await_operation("root", operation.operation_id), timeout=2
  418. )
  419. assert completed.status == OperationStatus.COMPLETED
  420. @pytest.mark.asyncio
  421. async def test_unstarted_revalidation_is_reused_after_remote_stop_and_resume(tmp_path):
  422. initial_executor = FakeExecutor([], submit_validator=False)
  423. coordinator_a, store, trace_store = await make_coordinator(
  424. tmp_path, initial_executor
  425. )
  426. task_id = await create_task(coordinator_a, "resume revalidation")
  427. first = (await coordinator_a.dispatch_tasks("root", [task_id]))[0]
  428. assert first.validation_id
  429. assert (await store.load("root")).tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  430. entered = asyncio.Event()
  431. release = asyncio.Event()
  432. original_advance = coordinator_a.advance_validation
  433. async def pause_before_validation(*args, **kwargs):
  434. entered.set()
  435. await release.wait()
  436. return await original_advance(*args, **kwargs)
  437. coordinator_a.advance_validation = pause_before_validation
  438. operation = await coordinator_a.start_operation(
  439. "root",
  440. OperationKind.REVALIDATE,
  441. task_id=task_id,
  442. attempt_id=first.attempt_id,
  443. )
  444. await asyncio.wait_for(entered.wait(), timeout=1)
  445. resume_executor = FakeExecutor([])
  446. coordinator_b = second_coordinator(
  447. tmp_path, store, trace_store, resume_executor
  448. )
  449. await coordinator_b.stop_operation("root", operation.operation_id)
  450. release.set()
  451. await coordinator_a.runtime.wait("root", operation.operation_id)
  452. ledger = await store.load("root")
  453. validation_id = ledger.operations[operation.operation_id].validation_ids[-1]
  454. assert ledger.validations[validation_id].started_at is None
  455. assert ledger.operations[operation.operation_id].status == OperationStatus.STOPPED
  456. resumed = await coordinator_b.resume_operation("root", operation.operation_id)
  457. completed = await coordinator_b.await_operation("root", resumed.operation_id)
  458. ledger = await store.load("root")
  459. assert completed.status == OperationStatus.COMPLETED
  460. assert ledger.operations[operation.operation_id].validation_ids == [validation_id]
  461. assert ledger.validations[validation_id].status.value == "completed"
  462. assert resume_executor.validator_calls == 1
  463. @pytest.mark.asyncio
  464. async def test_recovery_expires_reserved_unstarted_attempt_after_deadline(tmp_path):
  465. executor = FakeExecutor([])
  466. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  467. task_id = await create_task(coordinator, "expired reserved attempt")
  468. operation = BackgroundOperation(
  469. operation_id="expired-attempt-operation",
  470. root_trace_id="root",
  471. kind=OperationKind.DISPATCH,
  472. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  473. request_fingerprint="fingerprint",
  474. status=OperationStatus.RUNNING,
  475. task_ids=[task_id],
  476. execution_epoch=1,
  477. deadline_at="2000-01-01T00:00:00+00:00",
  478. )
  479. await persist_operation(store, operation)
  480. reserved = await coordinator._create_attempt(
  481. "root",
  482. task_id,
  483. "worker",
  484. None,
  485. operation_id=operation.operation_id,
  486. execution_epoch=1,
  487. )
  488. await coordinator.recover_operations("root")
  489. ledger = await store.load("root")
  490. assert ledger.operations[operation.operation_id].status == OperationStatus.FAILED
  491. assert ledger.attempts[reserved["attempt_id"]].status == AttemptStatus.EXPIRED
  492. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  493. @pytest.mark.asyncio
  494. async def test_recovery_expires_reserved_unstarted_validation_after_deadline(tmp_path):
  495. executor = FakeExecutor([])
  496. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  497. task_id = await create_task(coordinator, "expired reserved validation")
  498. operation = BackgroundOperation(
  499. operation_id="expired-validation-operation",
  500. root_trace_id="root",
  501. kind=OperationKind.DISPATCH,
  502. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  503. request_fingerprint="fingerprint",
  504. status=OperationStatus.RUNNING,
  505. task_ids=[task_id],
  506. execution_epoch=1,
  507. deadline_at="2000-01-01T00:00:00+00:00",
  508. )
  509. await persist_operation(store, operation)
  510. reserved = await coordinator._create_attempt(
  511. "root",
  512. task_id,
  513. "worker",
  514. None,
  515. operation_id=operation.operation_id,
  516. execution_epoch=1,
  517. )
  518. await coordinator.submit_attempt(
  519. {
  520. "role": AgentRole.WORKER.value,
  521. "root_trace_id": "root",
  522. "task_id": task_id,
  523. "attempt_id": reserved["attempt_id"],
  524. "trace_id": reserved["worker_trace_id"],
  525. "spec_version": 1,
  526. "operation_id": operation.operation_id,
  527. "execution_epoch": 1,
  528. "tool_call_id": "submit-before-expiry",
  529. },
  530. AttemptSubmission(
  531. summary="submitted",
  532. artifact_refs=[ArtifactRef(uri="memory://submitted", version="1")],
  533. ),
  534. )
  535. validation_id = await coordinator._start_validation(
  536. "root",
  537. task_id,
  538. reserved["attempt_id"],
  539. operation_id=operation.operation_id,
  540. execution_epoch=1,
  541. )
  542. await coordinator.recover_operations("root")
  543. ledger = await store.load("root")
  544. assert ledger.operations[operation.operation_id].status == OperationStatus.FAILED
  545. assert ledger.validations[validation_id].status.value == "expired"
  546. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  547. @pytest.mark.asyncio
  548. async def test_terminal_operation_cannot_reserve_a_late_attempt(tmp_path):
  549. executor = FakeExecutor([])
  550. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  551. task_id = await create_task(coordinator, "reject late reservation")
  552. operation = BackgroundOperation(
  553. operation_id="stopped-operation",
  554. root_trace_id="root",
  555. kind=OperationKind.DISPATCH,
  556. request={"kind": "dispatch", "task_ids": [task_id], "worker_presets": ["worker"]},
  557. request_fingerprint="fingerprint",
  558. status=OperationStatus.STOPPED,
  559. task_ids=[task_id],
  560. execution_epoch=4,
  561. )
  562. await persist_operation(store, operation)
  563. with pytest.raises(TaskConflict, match="stale|stopping"):
  564. await coordinator._create_attempt(
  565. "root",
  566. task_id,
  567. "worker",
  568. None,
  569. operation_id=operation.operation_id,
  570. execution_epoch=4,
  571. )
  572. ledger = await store.load("root")
  573. assert ledger.tasks[task_id].attempt_ids == []
  574. assert ledger.tasks[task_id].status == TaskStatus.PENDING
  575. @pytest.mark.asyncio
  576. @pytest.mark.parametrize(
  577. "criterion_ids",
  578. [["c1", "c1"], ["c1", "unknown"]],
  579. )
  580. async def test_validation_report_rejects_duplicate_or_unknown_criteria(
  581. tmp_path,
  582. criterion_ids,
  583. ):
  584. executor = BlockingValidatorExecutor()
  585. coordinator, _, _ = await make_coordinator(tmp_path, executor)
  586. task_id = await create_task(coordinator, "strict report schema")
  587. operation = await coordinator.start_operation(
  588. "root", OperationKind.DISPATCH, task_ids=[task_id]
  589. )
  590. await asyncio.wait_for(executor.validator_started.wait(), timeout=1)
  591. context = executor.validator_context
  592. with pytest.raises(ValueError, match="duplicate|unknown"):
  593. await coordinator.submit_validation(
  594. {
  595. **context,
  596. "role": AgentRole.VALIDATOR.value,
  597. "trace_id": context["validator_trace_id"],
  598. "tool_call_id": f"bad-report-{criterion_ids[-1]}",
  599. },
  600. ValidationVerdict.PASSED,
  601. [
  602. CriterionResult(item, ValidationVerdict.PASSED, "checked")
  603. for item in criterion_ids
  604. ],
  605. "bad report",
  606. [],
  607. [],
  608. [],
  609. "reject",
  610. )
  611. await coordinator.stop_operation("root", operation.operation_id)