fix(tasks): persist work-order waits without restarting runs
Generic Container CI/CD / test-build-publish (push) Successful in 4m20s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m20s

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:
2026-11-12 13:45:27 +08:00
parent 1892b1d1f0
commit 471ad92a47
7 changed files with 345 additions and 24 deletions
@@ -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"