test_orchestration_v2_control.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import asyncio
  2. from dataclasses import replace
  3. from types import SimpleNamespace
  4. import pytest
  5. from agent import AgentExecutor
  6. from agent.orchestration.config import OrchestrationConfig
  7. from agent.orchestration.coordinator import TaskCoordinator
  8. from agent.orchestration.executor import LocalAgentExecutor
  9. from agent.orchestration.models import (
  10. AttemptStatus,
  11. AttemptSubmission,
  12. ExecutionStats,
  13. FailureCode,
  14. TaskAttempt,
  15. TaskStatus,
  16. ValidationVerdict,
  17. utc_now,
  18. )
  19. from agent.orchestration.protocols import AgentExecutor as ProtocolAgentExecutor
  20. from agent.orchestration.protocols import WorkerRunResult
  21. from agent.orchestration.store import (
  22. FileSystemArtifactStore,
  23. FileSystemTaskStore,
  24. TraceEventSink,
  25. )
  26. from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
  27. def test_v2_control_config_defaults_preserve_unbounded_v1_execution():
  28. config = OrchestrationConfig()
  29. assert config.worker_timeout_seconds is None
  30. assert config.validator_timeout_seconds is None
  31. assert config.stop_grace_seconds == 5.0
  32. def test_agent_executor_port_is_available_from_public_package():
  33. assert AgentExecutor is ProtocolAgentExecutor
  34. @pytest.mark.parametrize(
  35. ("field", "value"),
  36. [
  37. ("worker_timeout_seconds", 0),
  38. ("worker_timeout_seconds", -0.1),
  39. ("validator_timeout_seconds", 0),
  40. ("validator_timeout_seconds", -1),
  41. ("validator_timeout_seconds", float("nan")),
  42. ("worker_timeout_seconds", float("inf")),
  43. ("worker_timeout_seconds", True),
  44. ("stop_grace_seconds", -0.1),
  45. ("stop_grace_seconds", float("nan")),
  46. ("stop_grace_seconds", float("inf")),
  47. ("stop_grace_seconds", False),
  48. ],
  49. )
  50. def test_v2_control_config_rejects_invalid_durations(field, value):
  51. with pytest.raises(ValueError, match=field):
  52. OrchestrationConfig(**{field: value})
  53. @pytest.mark.parametrize("field", ["max_parallel_tasks", "max_repair_continuations"])
  54. def test_v2_control_config_rejects_boolean_integer_limits(field):
  55. with pytest.raises(ValueError, match=field):
  56. OrchestrationConfig(**{field: True})
  57. def test_v2_control_config_allows_immediate_stop_and_explicit_timeouts():
  58. config = OrchestrationConfig(
  59. worker_timeout_seconds=1.5,
  60. validator_timeout_seconds=2,
  61. stop_grace_seconds=0,
  62. )
  63. assert config.worker_timeout_seconds == 1.5
  64. assert config.validator_timeout_seconds == 2
  65. assert config.stop_grace_seconds == 0
  66. @pytest.mark.asyncio
  67. async def test_local_executor_stop_delegates_every_request():
  68. class Runner:
  69. def __init__(self):
  70. self.calls = []
  71. async def stop(self, trace_id):
  72. self.calls.append(trace_id)
  73. return len(self.calls) == 1
  74. runner = Runner()
  75. executor = LocalAgentExecutor(runner)
  76. assert await executor.stop("worker-trace") is True
  77. assert await executor.stop("worker-trace") is False
  78. assert runner.calls == ["worker-trace", "worker-trace"]
  79. @pytest.mark.asyncio
  80. async def test_local_executor_stop_does_not_hide_runner_failure():
  81. class Runner:
  82. async def stop(self, trace_id):
  83. raise RuntimeError(f"cannot stop {trace_id}")
  84. with pytest.raises(RuntimeError, match="cannot stop validator-trace"):
  85. await LocalAgentExecutor(Runner()).stop("validator-trace")
  86. @pytest.mark.asyncio
  87. async def test_hard_cancellation_is_not_normalized_as_agent_failure():
  88. class Runner:
  89. async def run_result(self, **kwargs):
  90. raise asyncio.CancelledError
  91. with pytest.raises(asyncio.CancelledError):
  92. await LocalAgentExecutor(Runner()).run_worker({
  93. "worker_preset": "worker",
  94. "worker_trace_id": "worker-trace",
  95. "root_trace_id": "root",
  96. "task_id": "task",
  97. "spec_version": 1,
  98. "attempt_id": "attempt",
  99. "task_spec": {},
  100. "continue_trace_id": None,
  101. })
  102. @pytest.mark.asyncio
  103. async def test_failed_stats_lookup_does_not_mask_original_runner_error():
  104. class Store:
  105. async def get_trace(self, trace_id):
  106. raise RuntimeError("stats store unavailable")
  107. class Runner:
  108. trace_store = Store()
  109. async def run_result(self, **kwargs):
  110. raise RuntimeError("original runner failure")
  111. result = await LocalAgentExecutor(Runner()).run_worker({
  112. "worker_preset": "worker",
  113. "worker_trace_id": "worker-trace",
  114. "root_trace_id": "root",
  115. "task_id": "task",
  116. "spec_version": 1,
  117. "attempt_id": "attempt",
  118. "task_spec": {},
  119. "continue_trace_id": None,
  120. })
  121. assert result.error == "original runner failure"
  122. assert result.execution_stats.failure_code == FailureCode.EXECUTOR_ERROR
  123. @pytest.mark.asyncio
  124. async def test_worker_protects_operation_epoch_and_deadline():
  125. class Runner:
  126. def __init__(self):
  127. self.config = None
  128. async def run_result(self, *, messages, config):
  129. self.config = config
  130. return {"status": "completed", "summary": "done"}
  131. runner = Runner()
  132. executor = LocalAgentExecutor(runner)
  133. result = await executor.run_worker({
  134. "worker_preset": "worker",
  135. "worker_trace_id": "worker-trace",
  136. "root_trace_id": "root",
  137. "task_id": "task",
  138. "spec_version": 2,
  139. "attempt_id": "attempt",
  140. "task_spec": {"objective": "test"},
  141. "continue_trace_id": None,
  142. "operation_id": "operation",
  143. "execution_epoch": 7,
  144. "deadline": "2030-01-01T00:00:00+00:00",
  145. "untrusted": "must-not-leak",
  146. })
  147. assert result.trace_id == "worker-trace"
  148. assert result.status == "completed"
  149. assert runner.config.trace_id is None
  150. assert runner.config.new_trace_id == "worker-trace"
  151. assert runner.config.context == {
  152. "root_trace_id": "root",
  153. "task_id": "task",
  154. "spec_version": 2,
  155. "attempt_id": "attempt",
  156. "completion_policy": "explicit_validation",
  157. "operation_id": "operation",
  158. "execution_epoch": 7,
  159. "deadline": "2030-01-01T00:00:00+00:00",
  160. }
  161. @pytest.mark.asyncio
  162. async def test_validator_omits_absent_v2_context_and_normalizes_empty_result():
  163. class Runner:
  164. async def run_result(self, *, messages, config):
  165. self.config = config
  166. return {}
  167. runner = Runner()
  168. result = await LocalAgentExecutor(runner).run_validator({
  169. "validator_preset": "validator",
  170. "validator_trace_id": "validator-trace",
  171. "root_trace_id": "root",
  172. "task_id": "task",
  173. "spec_version": 1,
  174. "attempt_id": "attempt",
  175. "snapshot_id": "snapshot",
  176. "validation_id": "validation",
  177. "task_spec": {},
  178. "artifact_snapshot": {},
  179. })
  180. assert result.trace_id == "validator-trace"
  181. assert result.status == "failed"
  182. assert result.summary == ""
  183. assert result.error is None
  184. assert runner.config.trace_id is None
  185. assert runner.config.new_trace_id == "validator-trace"
  186. assert "operation_id" not in runner.config.context
  187. assert "execution_epoch" not in runner.config.context
  188. assert "deadline" not in runner.config.context
  189. @pytest.mark.parametrize(
  190. "kwargs",
  191. [
  192. {"primary_model": " "},
  193. {"primary_model": 123},
  194. {"total_tokens": -1},
  195. {"total_tokens": True},
  196. {"total_cost": -0.1},
  197. {"total_cost": float("nan")},
  198. {"total_cost": float("inf")},
  199. {"total_cost": True},
  200. {"failure_code": "unknown"},
  201. {"failure_code": 123},
  202. ],
  203. )
  204. def test_execution_stats_reject_invalid_values(kwargs):
  205. with pytest.raises(ValueError, match="ExecutionStats"):
  206. ExecutionStats(**kwargs)
  207. def test_execution_stats_normalizes_known_failure_code_string():
  208. assert ExecutionStats(failure_code="timeout").failure_code == FailureCode.TIMEOUT
  209. def test_old_stage_without_stats_roundtrips_and_duration_is_derived():
  210. attempt = TaskAttempt.from_dict({
  211. "attempt_id": "attempt",
  212. "task_id": "task",
  213. "spec_version": 1,
  214. "worker_trace_id": "trace",
  215. "worker_preset": "worker",
  216. "execution_mode": "new",
  217. "started_at": "2026-07-18T00:00:00Z",
  218. "completed_at": "2026-07-18T00:00:01.250+00:00",
  219. })
  220. assert attempt.execution_stats is None
  221. assert attempt.duration_ms == 1250
  222. assert replace(attempt, completed_at="2026-07-17T23:59:59Z").duration_ms == 0
  223. assert replace(attempt, completed_at=None).duration_ms is None
  224. @pytest.mark.asyncio
  225. async def test_repair_continuation_records_only_trace_usage_delta():
  226. trace = SimpleNamespace(
  227. context={}, total_tokens=100, total_cost=1.0, model="repair-model"
  228. )
  229. class Coordinator:
  230. async def validate_continue_from(self, *args):
  231. return "trace"
  232. class Store:
  233. async def get_trace(self, trace_id):
  234. return trace
  235. async def update_trace(self, trace_id, **updates):
  236. trace.context = updates["context"]
  237. class Runner:
  238. task_coordinator = Coordinator()
  239. trace_store = Store()
  240. async def run_result(self, **kwargs):
  241. trace.total_tokens = 130
  242. trace.total_cost = 1.4
  243. return {
  244. "trace_id": "trace",
  245. "status": "completed",
  246. "stats": {"total_tokens": 130, "total_cost": 1.4},
  247. }
  248. result = await LocalAgentExecutor(Runner()).run_worker({
  249. "worker_preset": "worker",
  250. "worker_trace_id": "trace",
  251. "root_trace_id": "root",
  252. "task_id": "task",
  253. "spec_version": 1,
  254. "attempt_id": "new-attempt",
  255. "prior_attempt_id": "old-attempt",
  256. "task_spec": {},
  257. "continue_trace_id": "trace",
  258. })
  259. assert result.execution_stats.primary_model == "repair-model"
  260. assert result.execution_stats.total_tokens == 30
  261. assert result.execution_stats.total_cost == pytest.approx(0.4)
  262. class StatsExecutor(FakeExecutor):
  263. async def run_worker(self, context):
  264. result = await super().run_worker(context)
  265. return replace(
  266. result,
  267. execution_stats=ExecutionStats(
  268. primary_model="worker-model", total_tokens=12, total_cost=0.12
  269. ),
  270. )
  271. async def run_validator(self, context):
  272. result = await super().run_validator(context)
  273. return replace(
  274. result,
  275. execution_stats=ExecutionStats(
  276. primary_model="validator-model", total_tokens=7, total_cost=0.07
  277. ),
  278. )
  279. @pytest.mark.asyncio
  280. async def test_successful_stage_stats_persist_once_and_failed_verdict_is_not_run_failure(
  281. tmp_path,
  282. ):
  283. executor = StatsExecutor([ValidationVerdict.FAILED])
  284. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  285. task_id = await create_task(coordinator, "persist execution stats")
  286. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  287. ledger = await store.load("root")
  288. attempt = ledger.attempts[result.attempt_id]
  289. validation = ledger.validations[result.validation_id]
  290. assert attempt.execution_stats.primary_model == "worker-model"
  291. assert attempt.execution_stats.total_tokens == 12
  292. assert validation.verdict == ValidationVerdict.FAILED
  293. assert validation.execution_stats.primary_model == "validator-model"
  294. assert validation.execution_stats.failure_code is None
  295. assert attempt.duration_ms is not None
  296. assert validation.duration_ms is not None
  297. @pytest.mark.asyncio
  298. async def test_completed_worker_without_submit_is_protocol_failure(tmp_path):
  299. executor = StatsExecutor([], submit_worker=False)
  300. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  301. task_id = await create_task(coordinator, "protocol failure")
  302. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  303. attempt = (await store.load("root")).attempts[result.attempt_id]
  304. assert attempt.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
  305. assert attempt.execution_stats.total_tokens == 12
  306. @pytest.mark.asyncio
  307. async def test_protocol_failure_overrides_executor_supplied_failure_code(tmp_path):
  308. class MisclassifiedExecutor(StatsExecutor):
  309. async def run_worker(self, context):
  310. result = await super().run_worker(context)
  311. return replace(
  312. result,
  313. execution_stats=replace(
  314. result.execution_stats,
  315. failure_code=FailureCode.AGENT_FAILED,
  316. ),
  317. )
  318. executor = MisclassifiedExecutor([], submit_worker=False)
  319. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  320. task_id = await create_task(coordinator, "protocol code wins")
  321. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  322. attempt = (await store.load("root")).attempts[result.attempt_id]
  323. assert attempt.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
  324. @pytest.mark.asyncio
  325. async def test_late_failure_cannot_overwrite_concurrent_submission(tmp_path):
  326. executor = FakeExecutor([])
  327. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  328. task_id = await create_task(coordinator, "concurrent submission")
  329. operation = await coordinator.start_operation(
  330. "root", "dispatch", task_ids=[task_id]
  331. )
  332. completed = await coordinator.await_operation("root", operation.operation_id)
  333. ledger = await store.load("root")
  334. attempt_id = completed.attempt_ids[0]
  335. original = ledger.attempts[attempt_id]
  336. await coordinator._mark_worker_failure(
  337. "root",
  338. task_id,
  339. attempt_id,
  340. "failed",
  341. "late failure",
  342. ExecutionStats(failure_code=FailureCode.AGENT_FAILED),
  343. operation.operation_id,
  344. completed.execution_epoch,
  345. )
  346. after = (await store.load("root")).attempts[attempt_id]
  347. assert after.status == original.status
  348. assert after.error == original.error
  349. assert after.execution_stats == original.execution_stats
  350. @pytest.mark.asyncio
  351. async def test_timeout_persists_machine_failure_code(tmp_path):
  352. class SlowExecutor(FakeExecutor):
  353. async def run_worker(self, context):
  354. await asyncio.sleep(1)
  355. return WorkerRunResult(context["worker_trace_id"], "completed")
  356. executor = SlowExecutor([])
  357. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  358. coordinator.config = OrchestrationConfig(worker_timeout_seconds=0.001)
  359. task_id = await create_task(coordinator, "timeout stats")
  360. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  361. attempt = (await store.load("root")).attempts[result.attempt_id]
  362. assert attempt.status == AttemptStatus.EXPIRED
  363. assert attempt.execution_stats.failure_code == FailureCode.TIMEOUT
  364. @pytest.mark.asyncio
  365. async def test_remote_stop_wins_over_late_worker_failure(tmp_path):
  366. class LateFailureExecutor:
  367. def __init__(self):
  368. self.started = asyncio.Event()
  369. self.release = asyncio.Event()
  370. async def run_worker(self, context):
  371. self.started.set()
  372. await self.release.wait()
  373. return WorkerRunResult(
  374. context["worker_trace_id"],
  375. "failed",
  376. error="late failure",
  377. execution_stats=ExecutionStats(
  378. total_tokens=3, failure_code=FailureCode.AGENT_FAILED
  379. ),
  380. )
  381. async def run_validator(self, context):
  382. raise AssertionError("validator must not run")
  383. async def stop(self, trace_id):
  384. return True
  385. executor = LateFailureExecutor()
  386. coordinator_a, store, trace_store = await make_coordinator(tmp_path, executor)
  387. task_id = await create_task(coordinator_a, "remote stop stats")
  388. operation = await coordinator_a.start_operation("root", "dispatch", task_ids=[task_id])
  389. await asyncio.wait_for(executor.started.wait(), timeout=1)
  390. observer_executor = FakeExecutor([])
  391. coordinator_b = TaskCoordinator(
  392. store,
  393. FileSystemArtifactStore(str(tmp_path)),
  394. trace_store,
  395. OrchestrationConfig(stop_grace_seconds=0),
  396. TraceEventSink(str(tmp_path)),
  397. observer_executor,
  398. )
  399. observer_executor.coordinator = coordinator_b
  400. await coordinator_b.stop_operation("root", operation.operation_id)
  401. executor.release.set()
  402. await coordinator_a.runtime.wait("root", operation.operation_id)
  403. ledger = await store.load("root")
  404. attempt = ledger.attempts[ledger.operations[operation.operation_id].attempt_ids[0]]
  405. assert attempt.status == AttemptStatus.STOPPED
  406. assert attempt.execution_stats.failure_code == FailureCode.STOPPED
  407. assert attempt.error == "Operation stopped"
  408. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  409. @pytest.mark.asyncio
  410. async def test_cas_retry_does_not_overwrite_concurrent_attempt_submission(tmp_path):
  411. class ConcurrentSubmissionStore:
  412. def __init__(self, inner):
  413. self.inner = inner
  414. self.base_path = inner.base_path
  415. self.injected = False
  416. async def load(self, root_trace_id):
  417. return await self.inner.load(root_trace_id)
  418. async def commit(
  419. self,
  420. ledger,
  421. expected_revision,
  422. idempotency_key=None,
  423. event=None,
  424. ):
  425. if (
  426. event is not None
  427. and event.event_type == "worker_failed"
  428. and not self.injected
  429. ):
  430. self.injected = True
  431. winner = await self.inner.load(ledger.root_trace_id)
  432. attempt = next(
  433. item
  434. for item in winner.attempts.values()
  435. if item.status == AttemptStatus.RUNNING
  436. )
  437. attempt.status = AttemptStatus.SUBMITTED
  438. attempt.submission = AttemptSubmission(summary="concurrent winner")
  439. attempt.completed_at = utc_now()
  440. winner.tasks[attempt.task_id].status = TaskStatus.AWAITING_VALIDATION
  441. await self.inner.commit(winner, winner.revision)
  442. return await self.inner.commit(
  443. ledger,
  444. expected_revision,
  445. idempotency_key=idempotency_key,
  446. event=event,
  447. )
  448. async def list_events(self, *args, **kwargs):
  449. return await self.inner.list_events(*args, **kwargs)
  450. async def list_recoverable(self, *args, **kwargs):
  451. return await self.inner.list_recoverable(*args, **kwargs)
  452. task_store = ConcurrentSubmissionStore(FileSystemTaskStore(str(tmp_path)))
  453. executor = StatsExecutor([], submit_worker=False)
  454. coordinator, store, _ = await make_coordinator(
  455. tmp_path, executor, task_store=task_store
  456. )
  457. task_id = await create_task(coordinator, "CAS submit winner")
  458. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  459. ledger = await store.load("root")
  460. attempt = ledger.attempts[result.attempt_id]
  461. assert task_store.injected is True
  462. assert attempt.status == AttemptStatus.SUBMITTED
  463. assert attempt.submission.summary == "concurrent winner"
  464. assert attempt.error is None
  465. assert attempt.execution_stats is None
  466. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_VALIDATION