test_coordinator_integration.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import asyncio
  2. from collections import deque
  3. import pytest
  4. from agent.orchestration.config import OrchestrationConfig
  5. from agent.orchestration.coordinator import TaskConflict, TaskCoordinator
  6. from agent.orchestration.models import (
  7. AgentRole,
  8. ArtifactRef,
  9. AttemptSubmission,
  10. CriterionResult,
  11. DecisionAction,
  12. TaskStatus,
  13. ValidationVerdict,
  14. )
  15. from agent.orchestration.protocols import ValidatorRunResult, WorkerRunResult
  16. from agent.orchestration.store import FileSystemArtifactStore, FileSystemTaskStore, TraceEventSink
  17. from agent.trace.goal_models import GoalTree
  18. from agent.trace.models import Trace
  19. from agent.trace.store import FileSystemTraceStore
  20. from agent.core.runner import AgentRunner
  21. from agent.orchestration.wiring import wire_orchestration
  22. class FakeExecutor:
  23. def __init__(self, verdicts, submit_worker=True, submit_validator=True, delay=0):
  24. self.verdicts = deque(verdicts)
  25. self.submit_worker = submit_worker
  26. self.submit_validator = submit_validator
  27. self.delay = delay
  28. self.coordinator = None
  29. self.active = 0
  30. self.max_active = 0
  31. async def run_worker(self, context):
  32. self.active += 1
  33. self.max_active = max(self.max_active, self.active)
  34. if self.delay:
  35. await asyncio.sleep(self.delay)
  36. try:
  37. if self.submit_worker:
  38. await self.coordinator.submit_attempt(
  39. {
  40. **context,
  41. "role": AgentRole.WORKER.value,
  42. "trace_id": context["worker_trace_id"],
  43. "tool_call_id": f"submit-{context['attempt_id']}",
  44. },
  45. AttemptSubmission(
  46. summary="done",
  47. artifact_refs=[ArtifactRef(uri=f"memory://{context['attempt_id']}", version="1")],
  48. ),
  49. )
  50. return WorkerRunResult(context["worker_trace_id"], "completed")
  51. finally:
  52. self.active -= 1
  53. async def run_validator(self, context):
  54. verdict = self.verdicts.popleft() if self.verdicts else ValidationVerdict.PASSED
  55. if self.submit_validator:
  56. criteria = [
  57. CriterionResult(
  58. criterion_id=item["criterion_id"], verdict=verdict,
  59. reason="checked",
  60. )
  61. for item in context["task_spec"]["acceptance_criteria"]
  62. ]
  63. await self.coordinator.submit_validation(
  64. {
  65. **context,
  66. "role": AgentRole.VALIDATOR.value,
  67. "trace_id": context["validator_trace_id"],
  68. "tool_call_id": f"validate-{context['validation_id']}",
  69. },
  70. verdict,
  71. criteria,
  72. "independent report",
  73. [], [], [], "accept" if verdict == ValidationVerdict.PASSED else "replan",
  74. )
  75. return ValidatorRunResult(context["validator_trace_id"], "completed")
  76. async def make_coordinator(
  77. tmp_path,
  78. executor,
  79. artifact_store=None,
  80. trace_store=None,
  81. task_store=None,
  82. ):
  83. trace_store = trace_store or FileSystemTraceStore(str(tmp_path))
  84. await trace_store.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
  85. await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
  86. task_store = task_store or FileSystemTaskStore(str(tmp_path))
  87. coordinator = TaskCoordinator(
  88. task_store,
  89. artifact_store or FileSystemArtifactStore(str(tmp_path)),
  90. trace_store,
  91. OrchestrationConfig(max_parallel_tasks=4),
  92. TraceEventSink(str(tmp_path)),
  93. executor,
  94. )
  95. executor.coordinator = coordinator
  96. await coordinator.ensure_ledger("root", "mission")
  97. return coordinator, task_store, trace_store
  98. async def create_task(coordinator, objective="task", parent_task_id=None):
  99. result = await coordinator.create_tasks(
  100. "root",
  101. [{
  102. "objective": objective,
  103. "acceptance_criteria": [{"criterion_id": "c1", "description": "must pass", "hard": True}],
  104. }],
  105. parent_task_id=parent_task_id,
  106. )
  107. return result["tasks"][0]["task_id"]
  108. @pytest.mark.asyncio
  109. async def test_passed_validation_requires_planner_accept(tmp_path):
  110. executor = FakeExecutor([ValidationVerdict.PASSED])
  111. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  112. task_id = await create_task(coordinator)
  113. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  114. assert cycle.task_status == TaskStatus.AWAITING_DECISION
  115. ledger = await store.load("root")
  116. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  117. await coordinator.decide_task(
  118. "root", task_id, cycle.validation_id, DecisionAction.ACCEPT,
  119. {"reason": "all hard criteria passed"}, "decision-1",
  120. )
  121. assert (await store.load("root")).tasks[task_id].status == TaskStatus.COMPLETED
  122. @pytest.mark.asyncio
  123. async def test_failed_or_inconclusive_cannot_be_accepted(tmp_path):
  124. executor = FakeExecutor([ValidationVerdict.FAILED])
  125. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  126. task_id = await create_task(coordinator)
  127. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  128. with pytest.raises(TaskConflict, match="passed"):
  129. await coordinator.decide_task(
  130. "root", task_id, cycle.validation_id, DecisionAction.ACCEPT,
  131. {"reason": "override"}, "bad-accept",
  132. )
  133. assert (await store.load("root")).tasks[task_id].status == TaskStatus.AWAITING_DECISION
  134. @pytest.mark.asyncio
  135. async def test_repair_is_limited_to_once_per_spec_version(tmp_path):
  136. executor = FakeExecutor([ValidationVerdict.FAILED, ValidationVerdict.FAILED])
  137. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  138. task_id = await create_task(coordinator)
  139. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  140. await coordinator.decide_task(
  141. "root", task_id, first.validation_id, DecisionAction.REPAIR,
  142. {"reason": "small local correction"}, "repair-1",
  143. )
  144. second = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  145. ledger = await store.load("root")
  146. attempts = [ledger.attempts[x] for x in ledger.tasks[task_id].attempt_ids]
  147. assert attempts[0].worker_trace_id == attempts[1].worker_trace_id
  148. with pytest.raises(TaskConflict, match="limit"):
  149. await coordinator.decide_task(
  150. "root", task_id, second.validation_id, DecisionAction.REPAIR,
  151. {"reason": "try again"}, "repair-2",
  152. )
  153. @pytest.mark.asyncio
  154. async def test_worker_without_submit_attempt_needs_replan(tmp_path):
  155. executor = FakeExecutor([], submit_worker=False)
  156. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  157. task_id = await create_task(coordinator)
  158. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  159. ledger = await store.load("root")
  160. assert result.task_status == TaskStatus.NEEDS_REPLAN
  161. assert ledger.tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  162. assert ledger.attempts[result.attempt_id].status.value == "failed"
  163. @pytest.mark.asyncio
  164. async def test_validator_without_submit_is_error_then_revalidates_with_new_trace(tmp_path):
  165. executor = FakeExecutor([ValidationVerdict.PASSED], submit_validator=False)
  166. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  167. task_id = await create_task(coordinator)
  168. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  169. ledger = await store.load("root")
  170. first_validation = ledger.validations[first.validation_id]
  171. assert first.task_status == TaskStatus.NEEDS_REPLAN
  172. assert first_validation.status.value == "error"
  173. assert first_validation.verdict is None
  174. executor.submit_validator = True
  175. executor.verdicts.append(ValidationVerdict.PASSED)
  176. second = await coordinator.revalidate_attempt(
  177. "root", task_id, first.attempt_id, "revalidate-1"
  178. )
  179. assert second.task_status == TaskStatus.AWAITING_DECISION
  180. assert second.validation_id != first.validation_id
  181. assert second.validation.validator_trace_id != first_validation.validator_trace_id
  182. @pytest.mark.asyncio
  183. async def test_retry_uses_new_trace_and_revise_invalidates_old_validation(tmp_path):
  184. executor = FakeExecutor([
  185. ValidationVerdict.FAILED,
  186. ValidationVerdict.INCONCLUSIVE,
  187. ValidationVerdict.PASSED,
  188. ])
  189. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  190. task_id = await create_task(coordinator)
  191. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  192. await coordinator.decide_task(
  193. "root", task_id, first.validation_id, DecisionAction.RETRY,
  194. {"reason": "use a fresh worker"}, "retry-1",
  195. )
  196. second = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  197. ledger = await store.load("root")
  198. first_attempt = ledger.attempts[first.attempt_id]
  199. second_attempt = ledger.attempts[second.attempt_id]
  200. assert first_attempt.worker_trace_id != second_attempt.worker_trace_id
  201. await coordinator.decide_task(
  202. "root", task_id, second.validation_id, DecisionAction.REVISE,
  203. {
  204. "reason": "clarify criterion",
  205. "objective": "revised task",
  206. "acceptance_criteria": [{"criterion_id": "c1", "description": "revised", "hard": True}],
  207. },
  208. "revise-1",
  209. )
  210. ledger = await store.load("root")
  211. assert ledger.tasks[task_id].current_spec_version == 2
  212. third = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  213. with pytest.raises(TaskConflict, match="obsolete"):
  214. await coordinator.decide_task(
  215. "root", task_id, first.validation_id, DecisionAction.ACCEPT,
  216. {"reason": "stale"}, "stale-accept",
  217. )
  218. await coordinator.decide_task(
  219. "root", task_id, third.validation_id, DecisionAction.ACCEPT,
  220. {"reason": "current validation passed"}, "current-accept",
  221. )
  222. @pytest.mark.asyncio
  223. async def test_block_unblock_cancel_and_supersede(tmp_path):
  224. executor = FakeExecutor([])
  225. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  226. task_id = await create_task(coordinator, "blocked")
  227. await coordinator.decide_task(
  228. "root", task_id, None, DecisionAction.BLOCK,
  229. {"reason": "external dependency"}, "block-1",
  230. )
  231. assert (await store.load("root")).tasks[task_id].blocked_reason == "external dependency"
  232. await coordinator.decide_task(
  233. "root", task_id, None, DecisionAction.UNBLOCK, {}, "unblock-1"
  234. )
  235. assert (await store.load("root")).tasks[task_id].status == TaskStatus.NEEDS_REPLAN
  236. await coordinator.decide_task(
  237. "root", task_id, None, DecisionAction.CANCEL,
  238. {"reason": "no longer needed"}, "cancel-1",
  239. )
  240. assert (await store.load("root")).tasks[task_id].status == TaskStatus.CANCELLED
  241. old_id = await create_task(coordinator, "old")
  242. result = await coordinator.decide_task(
  243. "root", old_id, None, DecisionAction.SUPERSEDE,
  244. {"reason": "replace spec", "replacement": {"objective": "replacement"}},
  245. "supersede-1",
  246. )
  247. replacement_id = result["payload"]["replacement_task_id"]
  248. ledger = await store.load("root")
  249. assert ledger.tasks[old_id].status == TaskStatus.SUPERSEDED
  250. assert ledger.tasks[replacement_id].status == TaskStatus.PENDING
  251. @pytest.mark.asyncio
  252. async def test_insert_after_keeps_stable_ids_and_updates_display_order(tmp_path):
  253. executor = FakeExecutor([])
  254. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  255. first = await create_task(coordinator, "first")
  256. third = await create_task(coordinator, "third")
  257. inserted = await coordinator.create_tasks(
  258. "root",
  259. [{"objective": "second"}],
  260. placement={"after_task_id": first},
  261. idempotency_key="insert",
  262. )
  263. second = inserted["tasks"][0]["task_id"]
  264. ledger = await store.load("root")
  265. assert ledger.tasks[first].display_path == "1"
  266. assert ledger.tasks[second].display_path == "2"
  267. assert ledger.tasks[third].display_path == "3"
  268. assert len({first, second, third}) == 3
  269. repeated = await coordinator.create_tasks(
  270. "root",
  271. [{"objective": "second"}],
  272. placement={"after_task_id": first},
  273. idempotency_key="insert",
  274. )
  275. assert repeated["tasks"][0]["task_id"] == second
  276. @pytest.mark.asyncio
  277. async def test_split_children_do_not_auto_complete_parent(tmp_path):
  278. executor = FakeExecutor([ValidationVerdict.FAILED, ValidationVerdict.PASSED, ValidationVerdict.PASSED])
  279. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  280. parent_id = await create_task(coordinator, "parent")
  281. first = (await coordinator.dispatch_tasks("root", [parent_id]))[0]
  282. decision = await coordinator.decide_task(
  283. "root", parent_id, first.validation_id, DecisionAction.SPLIT,
  284. {
  285. "reason": "split work",
  286. "tasks": [
  287. {"objective": "child one", "acceptance_criteria": [{"criterion_id": "c1", "description": "pass"}]},
  288. {"objective": "child two", "acceptance_criteria": [{"criterion_id": "c1", "description": "pass"}]},
  289. ],
  290. },
  291. "split-1",
  292. )
  293. child_ids = decision["payload"]["child_task_ids"]
  294. cycles = await coordinator.dispatch_tasks("root", child_ids)
  295. for child_id, cycle in zip(child_ids, cycles):
  296. await coordinator.decide_task(
  297. "root", child_id, cycle.validation_id, DecisionAction.ACCEPT,
  298. {"reason": "passed"}, f"accept-{child_id}",
  299. )
  300. ledger = await store.load("root")
  301. assert all(ledger.tasks[x].status == TaskStatus.COMPLETED for x in child_ids)
  302. assert ledger.tasks[parent_id].status == TaskStatus.NEEDS_REPLAN
  303. @pytest.mark.asyncio
  304. async def test_parallel_tasks_are_isolated_and_bounded(tmp_path):
  305. executor = FakeExecutor([ValidationVerdict.PASSED] * 4, delay=0.02)
  306. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  307. task_ids = [await create_task(coordinator, f"task-{i}") for i in range(4)]
  308. results = await coordinator.dispatch_tasks("root", task_ids, idempotency_key="batch")
  309. assert [x.task_id for x in results] == task_ids
  310. assert executor.max_active <= 4
  311. assert all(x.task_status == TaskStatus.AWAITING_DECISION for x in results)
  312. ledger = await store.load("root")
  313. assert len({ledger.attempts[ledger.tasks[x].attempt_ids[-1]].worker_trace_id for x in task_ids}) == 4
  314. repeated = await coordinator.dispatch_tasks("root", task_ids, idempotency_key="batch")
  315. assert [x.attempt_id for x in repeated] == [x.attempt_id for x in results]
  316. @pytest.mark.asyncio
  317. async def test_real_runner_local_executor_creates_independent_terminal_traces(tmp_path):
  318. import json
  319. async def fake_llm(messages, tools, **kwargs):
  320. names = {item["function"]["name"] for item in tools or []}
  321. if "submit_attempt" in names:
  322. arguments = {
  323. "summary": "implemented",
  324. "artifact_refs": [{"uri": "memory://result", "version": "1"}],
  325. "evidence_refs": [],
  326. }
  327. tool_name = "submit_attempt"
  328. elif "submit_validation" in names:
  329. arguments = {
  330. "verdict": "passed",
  331. "criterion_results": [{"criterion_id": "c1", "verdict": "passed", "reason": "verified"}],
  332. "summary": "independent pass",
  333. "evidence_refs": [],
  334. "unverified_claims": [],
  335. "risks": [],
  336. "recommendation": "accept",
  337. }
  338. tool_name = "submit_validation"
  339. else:
  340. raise AssertionError(f"unexpected tools: {names}")
  341. return {
  342. "content": "",
  343. "tool_calls": [{
  344. "id": f"call-{tool_name}",
  345. "type": "function",
  346. "function": {"name": tool_name, "arguments": json.dumps(arguments)},
  347. }],
  348. "finish_reason": "tool_calls",
  349. }
  350. trace_store = FileSystemTraceStore(str(tmp_path))
  351. await trace_store.create_trace(Trace(trace_id="root", mode="agent", task="mission", agent_role="planner"))
  352. await trace_store.update_goal_tree("root", GoalTree(mission="mission"))
  353. runner = AgentRunner(trace_store=trace_store, llm_call=fake_llm)
  354. coordinator = wire_orchestration(
  355. runner,
  356. FileSystemTaskStore(str(tmp_path)),
  357. FileSystemArtifactStore(str(tmp_path)),
  358. )
  359. await coordinator.ensure_ledger("root", "mission")
  360. task_id = await create_task(coordinator)
  361. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  362. ledger = await coordinator.task_store.load("root")
  363. worker = await trace_store.get_trace(ledger.attempts[cycle.attempt_id].worker_trace_id)
  364. validator = await trace_store.get_trace(cycle.validation.validator_trace_id)
  365. assert cycle.task_status == TaskStatus.AWAITING_DECISION
  366. assert worker.agent_role == "worker" and worker.result_summary
  367. assert validator.agent_role == "validator" and validator.result_summary
  368. assert worker.trace_id != validator.trace_id
  369. class OneShotGetFailureArtifactStore(FileSystemArtifactStore):
  370. def __init__(self, base_path):
  371. super().__init__(base_path)
  372. self.get_calls = 0
  373. def for_root(self, root_trace_id):
  374. self.root_trace_id = root_trace_id
  375. return self
  376. async def get(self, snapshot_id):
  377. self.get_calls += 1
  378. if self.get_calls == 2:
  379. raise RuntimeError("injected artifact read failure")
  380. return await super().get(snapshot_id)
  381. class OneShotGoalProjectionFailureStore(FileSystemTraceStore):
  382. def __init__(self, base_path):
  383. super().__init__(base_path)
  384. self.fail_next_goal_update = True
  385. async def update_goal(self, trace_id, goal_id, cascade_completion=True, **updates):
  386. if self.fail_next_goal_update:
  387. self.fail_next_goal_update = False
  388. raise RuntimeError("injected goal projection failure")
  389. return await super().update_goal(
  390. trace_id,
  391. goal_id,
  392. cascade_completion=cascade_completion,
  393. **updates,
  394. )
  395. class OneShotLoadFailureTaskStore(FileSystemTaskStore):
  396. def __init__(self, base_path):
  397. super().__init__(base_path)
  398. self.fail_next_load = False
  399. async def load(self, root_trace_id):
  400. if self.fail_next_load:
  401. self.fail_next_load = False
  402. raise RuntimeError("injected reservation load failure")
  403. return await super().load(root_trace_id)
  404. class FailingEventSink:
  405. async def emit(self, root_trace_id, event_type, payload):
  406. raise RuntimeError("injected event sink failure")
  407. @pytest.mark.asyncio
  408. async def test_parallel_unexpected_branch_error_does_not_cancel_siblings(tmp_path):
  409. executor = FakeExecutor([ValidationVerdict.PASSED] * 4, delay=0.01)
  410. artifact_store = OneShotGetFailureArtifactStore(str(tmp_path))
  411. coordinator, store, _ = await make_coordinator(
  412. tmp_path, executor, artifact_store=artifact_store
  413. )
  414. task_ids = [await create_task(coordinator, f"isolated-{index}") for index in range(4)]
  415. results = await coordinator.dispatch_tasks("root", task_ids)
  416. assert [result.task_id for result in results] == task_ids
  417. failures = [result for result in results if result.error]
  418. assert len(failures) == 1
  419. assert "artifact read failure" in failures[0].error
  420. assert failures[0].task_status == TaskStatus.NEEDS_REPLAN
  421. successes = [result for result in results if not result.error]
  422. assert len(successes) == 3
  423. assert all(result.task_status == TaskStatus.AWAITING_DECISION for result in successes)
  424. ledger = await store.load("root")
  425. assert all(
  426. ledger.tasks[task_id].status not in {TaskStatus.RUNNING, TaskStatus.VALIDATING}
  427. for task_id in task_ids
  428. )
  429. @pytest.mark.asyncio
  430. async def test_batch_reservation_conflict_does_not_strand_other_tasks(tmp_path):
  431. executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
  432. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  433. task_ids = [await create_task(coordinator, f"reserve-{index}") for index in range(3)]
  434. existing = await coordinator._create_attempt(
  435. "root", task_ids[1], "worker", "existing-reservation"
  436. )
  437. results = await coordinator.dispatch_tasks("root", task_ids)
  438. assert [result.task_id for result in results] == task_ids
  439. assert results[0].task_status == TaskStatus.AWAITING_DECISION
  440. assert results[1].attempt_id is None
  441. assert "Dispatch conflict" in results[1].error
  442. assert results[2].task_status == TaskStatus.AWAITING_DECISION
  443. ledger = await store.load("root")
  444. assert ledger.tasks[task_ids[0]].status == TaskStatus.AWAITING_DECISION
  445. assert ledger.tasks[task_ids[2]].status == TaskStatus.AWAITING_DECISION
  446. assert ledger.attempts[existing["attempt_id"]].status.value == "running"
  447. @pytest.mark.asyncio
  448. async def test_goal_projection_failure_does_not_abort_committed_attempt(tmp_path):
  449. executor = FakeExecutor([ValidationVerdict.PASSED])
  450. trace_store = OneShotGoalProjectionFailureStore(str(tmp_path))
  451. coordinator, store, _ = await make_coordinator(
  452. tmp_path,
  453. executor,
  454. trace_store=trace_store,
  455. )
  456. task_id = await create_task(coordinator, "reservation-projection-failure")
  457. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  458. assert result.task_id == task_id
  459. assert result.task_status == TaskStatus.AWAITING_DECISION
  460. assert result.attempt_id is not None
  461. assert result.error is None
  462. ledger = await store.load("root")
  463. assert ledger.tasks[task_id].status == TaskStatus.AWAITING_DECISION
  464. assert ledger.attempts[result.attempt_id].status.value == "submitted"
  465. @pytest.mark.asyncio
  466. async def test_reservation_load_failure_does_not_corrupt_existing_attempt(tmp_path):
  467. executor = FakeExecutor([ValidationVerdict.PASSED])
  468. task_store = OneShotLoadFailureTaskStore(str(tmp_path))
  469. coordinator, store, _ = await make_coordinator(
  470. tmp_path,
  471. executor,
  472. task_store=task_store,
  473. )
  474. running_task = await create_task(coordinator, "already-running")
  475. existing = await coordinator._create_attempt(
  476. "root", running_task, "worker", "existing-running-attempt"
  477. )
  478. sibling_task = await create_task(coordinator, "unrelated-sibling")
  479. task_store.fail_next_load = True
  480. results = await coordinator.dispatch_tasks(
  481. "root",
  482. [running_task, sibling_task],
  483. )
  484. assert [result.task_id for result in results] == [running_task, sibling_task]
  485. assert results[0].attempt_id is None
  486. assert results[0].task_status == TaskStatus.RUNNING
  487. assert "reservation load failure" in results[0].error
  488. assert results[1].task_status == TaskStatus.AWAITING_DECISION
  489. ledger = await store.load("root")
  490. assert ledger.tasks[running_task].status == TaskStatus.RUNNING
  491. assert ledger.attempts[existing["attempt_id"]].status.value == "running"
  492. @pytest.mark.asyncio
  493. async def test_event_sink_failure_does_not_rollback_committed_ledger(tmp_path):
  494. executor = FakeExecutor([])
  495. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  496. coordinator.event_sink = FailingEventSink()
  497. task_id = await create_task(coordinator, "event-outage")
  498. ledger = await store.load("root")
  499. assert ledger.tasks[task_id].status == TaskStatus.PENDING