|
@@ -46,7 +46,9 @@ async def test_new_attempt_distinguishes_empty_and_ordered_child_bindings(tmp_pa
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.asyncio
|
|
|
-@pytest.mark.parametrize("corruption", ["duplicate", "reverse", "cross", "non_accept"])
|
|
|
|
|
|
|
+@pytest.mark.parametrize(
|
|
|
|
|
+ "corruption", ["duplicate", "reverse", "missing", "cross", "non_accept"]
|
|
|
|
|
+)
|
|
|
async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corruption):
|
|
async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corruption):
|
|
|
executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
|
|
executor = FakeExecutor([ValidationVerdict.PASSED, ValidationVerdict.PASSED])
|
|
|
coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
@@ -64,8 +66,11 @@ async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corrup
|
|
|
attempt.accepted_child_decision_ids = (ids[0], ids[0])
|
|
attempt.accepted_child_decision_ids = (ids[0], ids[0])
|
|
|
elif corruption == "reverse":
|
|
elif corruption == "reverse":
|
|
|
attempt.accepted_child_decision_ids = tuple(reversed(ids))
|
|
attempt.accepted_child_decision_ids = tuple(reversed(ids))
|
|
|
- elif corruption == "cross":
|
|
|
|
|
|
|
+ elif corruption == "missing":
|
|
|
attempt.accepted_child_decision_ids = ("missing-decision",)
|
|
attempt.accepted_child_decision_ids = ("missing-decision",)
|
|
|
|
|
+ elif corruption == "cross":
|
|
|
|
|
+ decision = ledger.decisions[ids[0]]
|
|
|
|
|
+ object.__setattr__(decision, "task_id", root.task_id)
|
|
|
else:
|
|
else:
|
|
|
decision = ledger.decisions[ids[0]]
|
|
decision = ledger.decisions[ids[0]]
|
|
|
object.__setattr__(decision, "action", DecisionAction.RETRY)
|
|
object.__setattr__(decision, "action", DecisionAction.RETRY)
|
|
@@ -73,6 +78,57 @@ async def test_frozen_child_decision_binding_rejects_corruption(tmp_path, corrup
|
|
|
coordinator._accepted_child_results(ledger, root, attempt)
|
|
coordinator._accepted_child_results(ledger, root, attempt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.parametrize("missing", ["attempt", "validation", "snapshot"])
|
|
|
|
|
+async def test_child_accept_binding_requires_complete_references(tmp_path, missing):
|
|
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED])
|
|
|
|
|
+ coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
|
|
|
+ child = await create_task(coordinator, "child")
|
|
|
|
|
+ await _accept_child(coordinator, child, "accept-child")
|
|
|
|
|
+ ledger = await store.load("root")
|
|
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
|
|
+ reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
|
|
|
|
|
+ ledger = await store.load("root")
|
|
|
|
|
+ parent_attempt = ledger.attempts[reserved["attempt_id"]]
|
|
|
|
|
+ decision = ledger.decisions[parent_attempt.accepted_child_decision_ids[0]]
|
|
|
|
|
+ child_attempt = ledger.attempts[decision.attempt_id]
|
|
|
|
|
+ if missing == "attempt":
|
|
|
|
|
+ del ledger.attempts[decision.attempt_id]
|
|
|
|
|
+ elif missing == "validation":
|
|
|
|
|
+ del ledger.validations[decision.validation_id]
|
|
|
|
|
+ else:
|
|
|
|
|
+ child_attempt.snapshot_id = None
|
|
|
|
|
+ with pytest.raises(OrchestrationError):
|
|
|
|
|
+ coordinator._accepted_child_results(ledger, root, parent_attempt)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_missing_artifact_snapshot_fails_parent_as_protocol_violation(tmp_path):
|
|
|
|
|
+ executor = FakeExecutor([ValidationVerdict.PASSED])
|
|
|
|
|
+ coordinator, store, _ = await make_coordinator(tmp_path, executor)
|
|
|
|
|
+ child = await create_task(coordinator, "child")
|
|
|
|
|
+ await _accept_child(coordinator, child, "accept-child")
|
|
|
|
|
+ ledger = await store.load("root")
|
|
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
|
|
+ reserved = await coordinator._create_attempt("root", root.task_id, "worker", None)
|
|
|
|
|
+ decision = ledger.decisions[ledger.tasks[child].decision_ids[-1]]
|
|
|
|
|
+ child_attempt = ledger.attempts[decision.attempt_id]
|
|
|
|
|
+ artifact_path = (
|
|
|
|
|
+ tmp_path
|
|
|
|
|
+ / "root"
|
|
|
|
|
+ / "orchestration"
|
|
|
|
|
+ / "artifacts"
|
|
|
|
|
+ / f"{child_attempt.snapshot_id}.json"
|
|
|
|
|
+ )
|
|
|
|
|
+ artifact_path.unlink()
|
|
|
|
|
+
|
|
|
|
|
+ result = await coordinator.advance_cycle("root", root.task_id, reserved["attempt_id"])
|
|
|
|
|
+ failed = (await store.load("root")).attempts[reserved["attempt_id"]]
|
|
|
|
|
+ assert "Invalid frozen child input binding" in result.error
|
|
|
|
|
+ assert failed.execution_stats.failure_code == FailureCode.PROTOCOL_VIOLATION
|
|
|
|
|
+ assert executor.worker_calls == 1 # child only; the parent Worker was never invoked
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.asyncio
|
|
|
async def test_legacy_running_attempt_never_guesses_child_inputs(tmp_path):
|
|
async def test_legacy_running_attempt_never_guesses_child_inputs(tmp_path):
|
|
|
executor = FakeExecutor([])
|
|
executor = FakeExecutor([])
|