fix(tasks): persist work-order waits without restarting runs
Dispatch receipts previously requeued active investigations and the final-only completion gate then failed waiting generations. Preserve the run, accept current interim wait reports, and wake only on actionable feedback or recovery evidence. Regression tests cover dispatch, same-generation reports, receipt revisions, cancellation and replacement ordering.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""A field-work wait ends the run without closing or restarting the incident."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionExecuteCommand, ActionReconcileCommand, ActionReceipt, AgentRunCommand,
|
||||
ExternalFeedbackCommand, HumanApprovalAction,
|
||||
)
|
||||
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
||||
from app.services.operational_recovery import track_work_order
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
|
||||
from tests.unit.test_operational_tasks import _seed_concluded_report
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def setup_wait(*, active=True, phase="waiting_work_order"):
|
||||
now = datetime(2026, 11, 12, 5, 14, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task.update(workflow="scada_anomaly", lifecycle_status="open")
|
||||
run = _active_run(task, now)
|
||||
if not active:
|
||||
run.update(status="succeeded", lease_expires_at=None, finished_at=now.isoformat())
|
||||
task.update(status="waiting_human", lifecycle_status="waiting_human",
|
||||
agent_session_id=None, agent_lease_expires_at=None)
|
||||
order_id = str(uuid4())
|
||||
action = HumanApprovalAction(
|
||||
action_id=uuid4(), task_id=task["task_id"], version=3, kind="create_work_order",
|
||||
reason="现场检查", payload={"title": "检查", "description": "核验候选点", "work_order_type": "general"},
|
||||
evidence_refs=[], required_approval_permission="work-order.approve",
|
||||
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
approval_status="approved", execution_status="succeeded",
|
||||
proposed_by="principal:test", proposed_at=now,
|
||||
receipt=ActionReceipt(adapter="peripheral_sample", receipt_id=order_id,
|
||||
submitted_at=now, completed_at=now, external_status="open", detail="创建成功"),
|
||||
)
|
||||
repository = TaskRepository(task, run, action=action.model_dump(mode="json"))
|
||||
track_work_order(task, repository.action)
|
||||
task["handling_context"]["phase"] = phase
|
||||
if phase == "waiting_scada":
|
||||
task["handling_context"]["returned_at"] = now.isoformat()
|
||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
|
||||
return service, repository, now, action
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||
@pytest.mark.parametrize("active", [True, False])
|
||||
async def test_successful_dispatch_preserves_run_and_parks_only_idle_task(method, active):
|
||||
service, repository, now, action = setup_wait(active=active)
|
||||
repository.task["handling_context"] = {}
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": AsyncMock(
|
||||
return_value=ActionDispatchOutcome("succeeded", "创建成功", action.receipt))})
|
||||
run_before = repository.run.copy()
|
||||
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||
command = command_type(request_id=uuid4(), expected_version=action.version)
|
||||
await getattr(service, f"{method}_action")(action.task_id, action.action_id, command, dispatcher, None)
|
||||
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
|
||||
assert repository.task["execution_generation"] == 1
|
||||
assert repository.run == run_before
|
||||
assert repository.task["status"] == ("running" if active else "waiting_human")
|
||||
if active:
|
||||
await service.heartbeat_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
else:
|
||||
assert await repository.get_active_run(str(action.task_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "unsupported"])
|
||||
async def test_late_dispatch_receipt_is_saved_without_reviving_cancelled_parent(method, outcome):
|
||||
service, repository, now, action = setup_wait()
|
||||
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||
async def cancelled_while_dispatching(**kwargs):
|
||||
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||
repository.run.update(status="cancelled", lease_expires_at=None)
|
||||
return ActionDispatchOutcome(outcome, "外部回执", action.receipt if outcome == "succeeded" else None)
|
||||
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": cancelled_while_dispatching})
|
||||
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||
result = await getattr(service, f"{method}_action")(action.task_id, action.action_id,
|
||||
command_type(request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||
assert result.execution_status == outcome
|
||||
assert repository.task["status"] == "cancelled"
|
||||
assert repository.task["execution_generation"] == 1
|
||||
assert await repository.get_active_run(str(action.task_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("phase", ["waiting_work_order", "waiting_scada", "pending_approval"])
|
||||
async def test_current_interim_report_finishes_run_as_waiting_without_closing_incident(phase):
|
||||
service, repository, now, action = setup_wait()
|
||||
if phase == "pending_approval":
|
||||
repository.task["handling_context"] = {}
|
||||
repository.action.update(approval_status="pending", execution_status="not_submitted", receipt=None)
|
||||
else:
|
||||
repository.task["handling_context"]["phase"] = phase
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.reports[-1]["report"]["stage"] = "interim"
|
||||
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == result.lifecycle_status == "waiting_human"
|
||||
assert repository.run["status"] == "succeeded"
|
||||
assert repository.run["lease_expires_at"] is None
|
||||
assert result.active_run is None
|
||||
assert result.execution_generation == 1
|
||||
assert not any(n["kind"] == "task_failed" for n in repository.notifications)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("defect", ["missing", "old_generation", "other_session", "verification_ready"])
|
||||
async def test_wait_does_not_bypass_current_report_or_final_recovery_requirements(defect):
|
||||
service, repository, now, action = setup_wait()
|
||||
if defect != "missing":
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.reports[-1]["report"]["stage"] = "interim"
|
||||
if defect == "old_generation":
|
||||
repository.task["execution_generation"] = repository.run["generation"] = 2
|
||||
repository.reports[-1]["execution_generation"] = 1
|
||||
elif defect == "other_session":
|
||||
repository.reports[-1]["source_session_id"] = "another-session"
|
||||
else:
|
||||
repository.task["handling_context"]["phase"] = "verification_ready"
|
||||
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == "failed"
|
||||
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("status", ["assigned", "in_progress", "awaiting_feedback", "completed"])
|
||||
@pytest.mark.parametrize("active", [True, False])
|
||||
async def test_ordinary_child_feedback_waits_without_starting_or_cancelling_run(status, active):
|
||||
service, repository, now, action = setup_wait(active=active)
|
||||
if not active:
|
||||
# Recover the persisted shape left by the reported ARTIFACT_INCOMPLETE failure.
|
||||
repository.task.update(status="failed", lifecycle_status="failed")
|
||||
repository.run.update(status="failed", failure_code="ARTIFACT_INCOMPLETE")
|
||||
run_before = repository.run.copy()
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=2, status=status, occurred_at=now, detail="工单进度更新"))
|
||||
assert result.task.execution_generation == 1
|
||||
assert repository.run == run_before
|
||||
assert result.task.status == ("running" if active else "waiting_human")
|
||||
assert result.task.handling_context["phase"] == ("waiting_scada" if status == "completed" else "waiting_work_order")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("status", ["failed", "cancelled"])
|
||||
async def test_failed_or_cancelled_child_wakes_same_task_for_replanning(status):
|
||||
service, repository, now, action = setup_wait(active=False)
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=2, status=status, occurred_at=now, detail="现场处置无法继续"))
|
||||
assert str(result.task.task_id) == str(action.task_id)
|
||||
assert result.task.status == "queued" and result.task.execution_generation == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("phase", ["verification_ready", "completed"])
|
||||
async def test_revised_receipt_with_ready_recovery_evidence_does_not_strand_task(phase):
|
||||
service, repository, now, action = setup_wait(active=False, phase="waiting_scada")
|
||||
repository.task["handling_context"].update(phase=phase, verification_evidence_id="recovery:saved")
|
||||
if phase == "completed":
|
||||
repository.task.update(status="completed", lifecycle_status="completed")
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=3, status="completed", occurred_at=now, detail="补充完工说明,实际完工时间不变"))
|
||||
assert result.task.status == ("completed" if phase == "completed" else "queued")
|
||||
assert result.task.execution_generation == (1 if phase == "completed" else 2)
|
||||
assert result.task.handling_context["verification_evidence_id"] == "recovery:saved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_same_completion_time_revision_does_not_interrupt_active_recovery_review():
|
||||
service, repository, now, action = setup_wait(phase="waiting_scada")
|
||||
repository.task["handling_context"].update(phase="verification_ready", verification_evidence_id="recovery:saved")
|
||||
run_before = repository.run.copy()
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=3, status="completed", occurred_at=now, detail="补充备注,完工时间不变"))
|
||||
assert result.task.status == "running" and result.task.execution_generation == 1
|
||||
assert repository.run == run_before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("new_decision", [True, False])
|
||||
@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
|
||||
async def test_only_new_dispatch_decisions_replace_terminated_work_orders(new_decision, terminal_status):
|
||||
service, repository, now, action = setup_wait(active=False)
|
||||
old_id = action.receipt.receipt_id
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=2,
|
||||
status=terminal_status, occurred_at=now - timedelta(hours=1), detail="需重新安排"))
|
||||
replacement_id = str(uuid4())
|
||||
new_action = action.model_dump(mode="json")
|
||||
new_action.update(action_id=str(uuid4()), proposed_at=(now + timedelta(seconds=1 if new_decision else -1)).isoformat())
|
||||
new_action["receipt"]["receipt_id"] = replacement_id
|
||||
track_work_order(repository.task, new_action)
|
||||
repository.action = new_action
|
||||
# Finish the replanning run before the replacement's completion arrives.
|
||||
repository.run.update(status="succeeded", lease_expires_at=None)
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{replacement_id}", source_revision=2,
|
||||
status="completed", occurred_at=now + timedelta(hours=1), detail="替代处置完成"))
|
||||
phase = repository.task["handling_context"]["phase"]
|
||||
assert phase == ("waiting_scada" if new_decision else "waiting_work_order")
|
||||
if new_decision:
|
||||
orders = repository.task["handling_context"]["work_orders"]
|
||||
assert orders[old_id]["superseded_by"] == replacement_id
|
||||
# A late historical edit cannot interrupt the replacement or change T.
|
||||
context_before = {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"}
|
||||
generation = repository.task["execution_generation"]
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=3,
|
||||
status=terminal_status, occurred_at=now, detail="旧工单备注更新"))
|
||||
assert repository.task["execution_generation"] == generation
|
||||
assert {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"} == context_before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_superseded_run_heartbeat_has_distinct_problem_code():
|
||||
service, repository, now, action = setup_wait()
|
||||
old_run = repository.run.copy()
|
||||
await service._requeue_task(repository.task, now)
|
||||
with pytest.raises(DomainError) as rejected:
|
||||
await service.heartbeat_run(action.task_id, old_run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=old_run["revision"], source_session_id="session-1"))
|
||||
assert rejected.value.code == "RUN_SUPERSEDED"
|
||||
Reference in New Issue
Block a user