"""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"