Prepare asynchronous test handoffs with budget-preserving resume
This commit is contained in:
1 parent
af18f36c24
commit
798925d25d
11 files changed
+655
-2
No files matched your search
@@ -0,0 +1,117 @@
|
||||
import copy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from viano_bug_analysis.budget import BudgetConfig, BudgetManager
|
||||
from viano_bug_analysis.lifecycle import CheckpointStore, InboxProcessor, LifecycleError, State, Workspace
|
||||
from viano_bug_analysis.lifecycle.async_tests import AsyncTestCoordinator
|
||||
from viano_bug_analysis.package import PackageReader, PackageValidator
|
||||
from test_budget_manager import hypotheses
|
||||
from test_case_builder import case_setup
|
||||
from test_case_interpreter import built_case
|
||||
from test_hypothesis_builder import interpreted_case
|
||||
|
||||
CASE = ("case-A", "stable-package")
|
||||
|
||||
|
||||
def setup(hypotheses, executor="human", deadline="2099-01-01T00:00:00Z"):
|
||||
store = hypotheses[0]
|
||||
budget = BudgetManager(store, BudgetConfig(action_costs={"testing.request_human":40,"testing.request_automated":20}))
|
||||
budget.initialize(*CASE)
|
||||
action = "testing.request_human" if executor == "human" else "testing.request_automated"
|
||||
budget.reserve(*CASE, "H1", "test-action", action)
|
||||
store.advance(*CASE, State.INVESTIGATING_HYPOTHESES, step_id="begin-investigation")
|
||||
before = budget.snapshot(*CASE)
|
||||
request = {"format":"viano-async-test-request","format_version":1,"case_id":CASE[0],"package_id":CASE[1],
|
||||
"analysis_revision":1,"request_id":"test-1","hypothesis_id":"H1","action_id":"test-action",
|
||||
"executor":executor,"question":"What happens after interacting?","steps":["Interact once"],
|
||||
"prerequisites":["Use the specified test environment"],"requested_observations":["Record the observed transition"],
|
||||
"environment":"test-build-A","deadline":deadline,"budget_ledger_ref":before.event_ref}
|
||||
feedback = {name:request[name] for name in ("format_version","case_id","package_id","analysis_revision","request_id","hypothesis_id","action_id","executor")}
|
||||
feedback.update(format="viano-async-test-feedback",outcome="completed",observations=["The transition was absent"],
|
||||
environment="test-build-A",tester_id="tester-1",attachments=[],
|
||||
confirmation={"confirmed":True,"confirmed_by":"reviewer-1","confirmed_at":"2026-10-14T12:00:00Z"})
|
||||
return store,budget,AsyncTestCoordinator(store),before,request,feedback
|
||||
|
||||
|
||||
@pytest.mark.parametrize("executor", ["human","automated"])
|
||||
def test_wait_confirmation_preserves_budget_revision_and_payloads(hypotheses, executor):
|
||||
store,budget,coordinator,before,request,feedback = setup(hypotheses,executor)
|
||||
blocked = coordinator.submit(request)
|
||||
assert coordinator.submit(request).reused
|
||||
assert budget.snapshot(*CASE).state == before.state
|
||||
with PackageReader(blocked.package) as reader:
|
||||
assert reader.manifest["state"] == State.BLOCKED
|
||||
assert reader.manifest["routing"]["reason"] == "AWAITING_TEST_FEEDBACK"
|
||||
with pytest.raises(LifecycleError, match="Confirmed test feedback"):
|
||||
store.advance(*CASE,State.INVESTIGATING_HYPOTHESES,step_id="timer-resume")
|
||||
resumed = coordinator.confirm_feedback(feedback)
|
||||
assert coordinator.confirm_feedback(feedback).reused
|
||||
assert budget.snapshot(*CASE).state == before.state
|
||||
assert PackageValidator().validate(resumed.package).valid
|
||||
with PackageReader(before.package) as old, PackageReader(resumed.package) as new:
|
||||
assert new.manifest["state"] == State.INVESTIGATING_HYPOTHESES
|
||||
assert new.manifest["analysis_revision"] == 1
|
||||
assert new.manifest["package_generation"] == old.manifest["package_generation"] + 2
|
||||
assert new.manifest["routing"]["retry_not_before"] is None
|
||||
for name in old.files:
|
||||
if name != "manifest.json":
|
||||
assert old.read_bytes(name) == new.read_bytes(name)
|
||||
# Feedback is an investigation result, not a reason to regenerate the case.
|
||||
assert hypotheses[1].build(*CASE).reused
|
||||
budget.record_result(*CASE,"H1","test-action",{"observations":feedback["observations"]},source_refs=[resumed.feedback_ref])
|
||||
assessed = budget.settle(*CASE,"H1","test-action",semantic_score=50,reason="Useful observation")
|
||||
cost = 40 if executor == "human" else 20
|
||||
assert assessed.state["accounts"]["H1"]["remaining"] == 100-cost+cost*4//10
|
||||
|
||||
|
||||
def test_deadline_does_not_resume_or_charge_idle_case(hypotheses):
|
||||
store,budget,coordinator,before,request,feedback = setup(hypotheses,deadline="2000-01-01T00:00:00Z")
|
||||
coordinator.submit(request)
|
||||
outcomes = InboxProcessor(store.workspace).poll()
|
||||
assert not any(outcome.status == "RESUMED" for outcome in outcomes)
|
||||
assert budget.snapshot(*CASE).state == before.state
|
||||
coordinator.confirm_feedback(feedback)
|
||||
assert budget.snapshot(*CASE).state == before.state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
lambda f: f.update(analysis_revision=2),
|
||||
lambda f: f.update(hypothesis_id="H2"),
|
||||
lambda f: f.update(request_id="other"),
|
||||
lambda f: f["confirmation"].update(confirmed=False),
|
||||
lambda f: f.update(attachments=[{"source_ref":"../escape","sha256":"0"*64}]),
|
||||
])
|
||||
def test_invalid_feedback_never_wakes_or_changes_budget(hypotheses, change):
|
||||
store,budget,coordinator,before,request,feedback = setup(hypotheses)
|
||||
blocked = coordinator.submit(request)
|
||||
change(feedback)
|
||||
with pytest.raises(LifecycleError):
|
||||
coordinator.confirm_feedback(feedback)
|
||||
assert budget.snapshot(*CASE).package == blocked.package
|
||||
assert budget.snapshot(*CASE).state == before.state
|
||||
|
||||
|
||||
def test_portable_wait_feedback_and_conflicting_replay(hypotheses,tmp_path):
|
||||
store,budget,coordinator,before,request,feedback = setup(hypotheses)
|
||||
blocked = coordinator.submit(request)
|
||||
portable = CheckpointStore(Workspace(tmp_path / "other-service"))
|
||||
status, managed = portable.intake(blocked.package)
|
||||
assert status == "ACCEPTED"
|
||||
assert BudgetManager(portable).snapshot(*CASE).state == before.state
|
||||
remote_resumed = AsyncTestCoordinator(portable).confirm_feedback(feedback)
|
||||
with PackageReader(remote_resumed.package) as reader:
|
||||
assert reader.manifest["state"] == State.INVESTIGATING_HYPOTHESES
|
||||
with PackageReader(blocked.package) as reader:
|
||||
assert reader.manifest["artifacts"]["async_test_request"] == blocked.request_ref
|
||||
changed = copy.deepcopy(request)
|
||||
changed["question"] = "A different test"
|
||||
with pytest.raises(LifecycleError):
|
||||
coordinator.submit(changed)
|
||||
resumed = coordinator.confirm_feedback(feedback)
|
||||
changed = copy.deepcopy(feedback)
|
||||
changed["observations"] = ["Different feedback"]
|
||||
with pytest.raises(LifecycleError):
|
||||
coordinator.confirm_feedback(changed)
|
||||
assert budget.snapshot(*CASE).package == resumed.package
|
||||
Reference in new issue
Block a user