test_attempt_input_binding.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import pytest
  2. from agent.orchestration.coordinator import OrchestrationError
  3. from agent.orchestration.models import (
  4. DecisionAction,
  5. FailureCode,
  6. TaskStatus,
  7. ValidationVerdict,
  8. )
  9. from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
  10. async def _accept_child(coordinator, task_id, key):
  11. cycle = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  12. await coordinator.decide_task(
  13. "root",
  14. task_id,
  15. cycle.validation_id,
  16. DecisionAction.ACCEPT,
  17. {"reason": "accepted"},
  18. key,
  19. )
  20. return cycle
  21. @pytest.mark.asyncio
  22. async def test_new_attempt_distinguishes_empty_and_ordered_child_bindings(tmp_path):
  23. executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
  24. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  25. first = await create_task(coordinator, "first")
  26. second = await create_task(coordinator, "second")
  27. await _accept_child(coordinator, second, "accept-second")
  28. await _accept_child(coordinator, first, "accept-first")
  29. ledger = await store.load("root")
  30. root = ledger.tasks[ledger.root_task_id]
  31. reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
  32. frozen = (await store.load("root")).attempts[reserved["attempt_id"]]
  33. assert frozen.accepted_child_decision_ids == (
  34. ledger.tasks[first].decision_ids[-1],
  35. ledger.tasks[second].decision_ids[-1],
  36. )
  37. first_attempt = ledger.attempts[ledger.tasks[first].attempt_ids[-1]]
  38. assert first_attempt.accepted_child_decision_ids == ()
  39. @pytest.mark.asyncio
  40. @pytest.mark.parametrize(
  41. "corruption", ["duplicate", "reverse", "missing", "cross", "non_accept"]
  42. )
  43. async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corruption):
  44. executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
  45. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  46. first = await create_task(coordinator, "first")
  47. second = await create_task(coordinator, "second")
  48. await _accept_child(coordinator, first, "accept-first")
  49. await _accept_child(coordinator, second, "accept-second")
  50. ledger = await store.load("root")
  51. root = ledger.tasks[ledger.root_task_id]
  52. reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
  53. ledger = await store.load("root")
  54. attempt = ledger.attempts[reserved["attempt_id"]]
  55. ids = list(attempt.accepted_child_decision_ids)
  56. if corruption == "duplicate":
  57. attempt.accepted_child_decision_ids = (ids[0], ids[0])
  58. elif corruption == "reverse":
  59. attempt.accepted_child_decision_ids = tuple(reversed(ids))
  60. elif corruption == "missing":
  61. attempt.accepted_child_decision_ids = ("missing-decision",)
  62. elif corruption == "cross":
  63. decision = ledger.decisions[ids[0]]
  64. object.__setattr__(decision, "task_id", root.task_id)
  65. else:
  66. decision = ledger.decisions[ids[0]]
  67. object.__setattr__(decision, "action", DecisionAction.RETRY)
  68. with pytest.raises(OrchestrationError):
  69. coordinator._accepted_child_results(ledger, root, attempt)
  70. @pytest.mark.asyncio
  71. @pytest.mark.parametrize("missing", ["attempt", "validation", "snapshot"])
  72. async def test_child_accept_binding_requires_complete_references(tmp_path, missing):
  73. executor = FakeExecutor([ValidationVerdict.PASSED])
  74. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  75. child = await create_task(coordinator, "child")
  76. await _accept_child(coordinator, child, "accept-child")
  77. ledger = await store.load("root")
  78. root = ledger.tasks[ledger.root_task_id]
  79. reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
  80. ledger = await store.load("root")
  81. parent_attempt = ledger.attempts[reserved["attempt_id"]]
  82. decision = ledger.decisions[parent_attempt.accepted_child_decision_ids[0]]
  83. child_attempt = ledger.attempts[decision.attempt_id]
  84. if missing == "attempt":
  85. del ledger.attempts[decision.attempt_id]
  86. elif missing == "validation":
  87. del ledger.validations[decision.validation_id]
  88. else:
  89. child_attempt.snapshot_id = None
  90. with pytest.raises(OrchestrationError):
  91. coordinator._accepted_child_results(ledger, root, parent_attempt)
  92. @pytest.mark.asyncio
  93. async def test_missing_artifact_snapshot_fails_parent_as_protocol_violation(tmp_path):
  94. executor = FakeExecutor([ValidationVerdict.PASSED])
  95. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  96. child = await create_task(coordinator, "child")
  97. await _accept_child(coordinator, child, "accept-child")
  98. ledger = await store.load("root")
  99. root = ledger.tasks[ledger.root_task_id]
  100. reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
  101. decision = ledger.decisions[ledger.tasks[child].decision_ids[-1]]
  102. child_attempt = ledger.attempts[decision.attempt_id]
  103. artifact_path = (
  104. tmp_path
  105. / "root"
  106. / "orchestration"
  107. / "artifacts"
  108. / f"{child_attempt.snapshot_id}.json"
  109. )
  110. artifact_path.unlink()
  111. result = await coordinator.advance_cycle("root", root.task_id, reserved["attempt_id"])
  112. failed = (await store.load("root")).attempts[reserved["attempt_id"]]
  113. assert "Invalid frozen child input binding" in result.error
  114. assert failed.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
  115. assert executor.worker_calls == 1 # child only; the parent Worker was never invoked
  116. @pytest.mark.asyncio
  117. async def test_legacy_running_attempt_never_guesses_child_inputs(tmp_path):
  118. executor = FakeExecutor([])
  119. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  120. root_id = (await store.load("root")).root_task_id
  121. reserved = await coordinator._create_attempt("root", root_id, "worker", None)
  122. ledger = await store.load("root")
  123. ledger.attempts[reserved["attempt_id"]].accepted_child_decision_ids = None
  124. await store.commit(ledger, expected_revision=ledger.revision)
  125. result = await coordinator.advance_cycle("root", root_id, reserved["attempt_id"])
  126. recovered = await store.load("root")
  127. attempt = recovered.attempts[reserved["attempt_id"]]
  128. assert result.error == "Legacy running attempt has no frozen child input binding"
  129. assert executor.worker_calls == 0
  130. assert recovered.tasks[root_id].status == TaskStatus.NEEDS_REPLAN
  131. assert attempt.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION