Files
next-tjwater-server/tests/unit/test_operational_tasks.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

1806 lines
82 KiB
Python

from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.auth.permissions import (
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_ACCESS_APPROVE,
SCADA_VIEW,
)
from app.auth.project_dependencies import ProjectContext
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
ActionProposalCommand,
AgentArtifactsCommand,
AgentRunCommand,
ExternalFeedbackCommand,
ExternalSourceWithdrawalCommand,
HumanApprovalAction,
NotificationAcknowledgeCommand,
OperationalTaskNotification,
OperationalTaskRun,
OperationalTaskSummary,
ExecutionPlanInput,
TaskAccessRequestCreate,
TaskAccessRequestDecision,
TaskAccessRequest,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
TaskPresentationEventCommand,
StoredEvidenceRecord,
)
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import ActionDispatchOutcome
from app.services.operational_tasks import OperationalTaskService
@pytest.fixture
def anyio_backend():
return "asyncio"
class NotificationRepository:
def __init__(self, notification):
self.notification = notification
self.reads = {}
self.requests = {}
self.events = []
@asynccontextmanager
async def transaction(self):
yield self
async def lock(self, _key):
return None
async def get_request(self, scope, request_id):
return self.requests.get((scope, request_id))
async def save_request(self, scope, request_id, request_digest, response):
self.requests[(scope, request_id)] = {
"request_digest": request_digest,
"response": response,
}
async def get_notification(self, notification_id, user_id):
if notification_id != str(self.notification.notification_id):
return None
read = self.reads.get(user_id)
return self.notification.model_copy(update={
"status": "read" if read else "unread",
"version": 2 if read else 1,
"read_at": read,
"read_by": f"principal:{user_id}" if read else None,
}).model_dump(mode="json")
async def mark_notification_read(self, notification_id, user_id, version, read_at):
assert version == 2
self.reads[user_id] = read_at
async def append_event(self, **event):
self.events.append(event)
return len(self.events)
@pytest.mark.anyio
async def test_access_request_read_keeps_existing_tasks_available_before_migration():
connection = SimpleNamespace(execute=AsyncMock())
connection.execute.return_value.fetchone = AsyncMock(return_value={"relation": None})
repository = OperationalTaskRepository(connection, uuid4())
assert await repository.list_access_requests(str(uuid4())) == []
assert connection.execute.await_count == 1
@pytest.mark.anyio
async def test_access_request_read_uses_table_after_migration():
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"relation": "workbench.operational_task_access_requests"}))
records = [{"document": {"access_request_id": str(uuid4())}}]
result = SimpleNamespace(fetchall=AsyncMock(return_value=records))
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, result]))
repository = OperationalTaskRepository(connection, uuid4())
assert await repository.list_access_requests(str(uuid4())) == [records[0]["document"]]
assert connection.execute.await_count == 2
def _context(user_id=None, *, system_role="user"):
return ProjectContext(
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
project_role="member", system_role=system_role,
)
@pytest.mark.anyio
async def test_notification_read_state_is_isolated_per_user():
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=uuid4(), kind="task_created",
title="任务", description="已创建", status="unread", version=1,
created_at=now,
)
repository = NotificationRepository(notification)
first = _context()
second = _context()
first_service = OperationalTaskService(
repository, first, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
)
second_service = OperationalTaskService(
repository, second, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
)
request_id = uuid4()
read = await first_service.acknowledge_notification(
notification.notification_id,
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
)
assert read.status == "read"
assert (await repository.get_notification(
str(notification.notification_id), str(second.user_id)
))["status"] == "unread"
second_read = await second_service.acknowledge_notification(
notification.notification_id,
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
)
assert second_read.status == "read"
assert second_read.read_by == f"principal:{second.user_id}"
assert [event["aggregate_type"] for event in repository.events] == [
"operational_notification_read",
"operational_notification_read",
]
assert repository.events[0]["aggregate_id"] != repository.events[1]["aggregate_id"]
def test_running_plan_state_wins_over_future_human_waiting_node():
nodes = [
type("Node", (), {"status": "running", "owner": "agent"})(),
type("Node", (), {"status": "waiting", "owner": "human"})(),
]
assert OperationalTaskService._status_from_plan(nodes, []) == "running"
def test_pending_approval_prevents_plan_from_marking_task_completed():
action = HumanApprovalAction(
action_id=uuid4(), task_id=uuid4(), version=1,
kind="create_work_order", reason="现场采样",
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
evidence_refs=[], required_approval_permission="work-order.approve",
required_execution_permission="work-order.execute",
approval_status="pending", execution_status="not_submitted",
proposed_by="principal:test", proposed_at=datetime.now(timezone.utc),
)
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human"
def test_pending_access_request_prevents_plan_from_marking_task_completed():
request = TaskAccessRequest(
access_request_id=uuid4(), task_id=uuid4(), node_id="read-scada",
scopes=["scada_devices"], reason="核对监测点", status="pending",
approved_scopes=[], version=1, requested_by="principal:agent",
requested_at=datetime.now(timezone.utc),
)
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
assert OperationalTaskService._status_from_plan(
nodes, [], [request]
) == "waiting_human"
def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work():
nodes = [
type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(),
type("Node", (), {"id": "AGENT_RUN_FAILURE", "status": "failed", "owner": "agent"})(),
]
assert OperationalTaskService._status_from_plan(nodes, []) == "failed"
@pytest.mark.anyio
async def test_targeted_notification_cannot_be_acknowledged_without_permission():
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=uuid4(), kind="action_pending",
title="待确认", description="命令", required_permission="command.approve",
status="unread", version=1, created_at=now,
)
service = OperationalTaskService(
NotificationRepository(notification), _context(), {OPERATIONAL_TASK_VIEW},
clock=lambda: now,
)
with pytest.raises(DomainError) as exc:
await service.acknowledge_notification(
notification.notification_id,
NotificationAcknowledgeCommand(request_id=uuid4(), expected_version=1),
)
assert exc.value.code == "PERMISSION_DENIED"
def _task_document(context, now, *, lease=None):
task_id = uuid4()
return OperationalTaskSummary(
project_id=context.project_id,
task_id=task_id,
task_type="event_investigation",
workflow="water_quality_complaint",
status="running",
priority="normal",
revision=2,
execution_generation=1,
execution_request_id=uuid4(),
title="调查",
summary="验证状态机",
source={"kind": "operator", "event_kind": "water_quality_complaint"},
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
scheduled_for=None,
agent_session_id="session-1",
agent_lease_expires_at=lease,
current_plan_revision=None,
current_report_revision=None,
created_at=now,
updated_at=now,
).model_dump(mode="json")
class TaskRepository:
def __init__(self, task, *, action=None):
self.task = task
self.action = action
self.requests = {}
self.evidence = {}
self.feedback = {}
self.plans = []
self.reports = []
self.progress = {}
self.notifications = []
self.locks = []
self.access_requests = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock(self, key):
self.locks.append(key)
async def get_request(self, scope, request_id):
return self.requests.get((scope, request_id))
async def save_request(self, scope, request_id, request_digest, response):
self.requests[(scope, request_id)] = {
"request_digest": request_digest,
"response": response,
}
async def get_task(self, task_id):
return self.task if task_id == self.task["task_id"] else None
async def update_task(self, task):
self.task = task
async def get_latest_plan(self, _task_id):
return self.plans[-1] if self.plans else None
async def get_latest_report(self, _task_id):
return self.reports[-1] if self.reports else None
async def get_latest_progress(self, task_id, generation):
return self.progress.get((task_id, generation))
async def save_progress(self, task_id, snapshot):
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
async def get_report(self, _task_id, revision):
return next((item for item in self.reports if item["revision"] == revision), None)
async def list_reports(self, _task_id, limit, offset):
return list(reversed(self.reports))[offset:offset + limit]
async def insert_plan(self, _task_id, snapshot):
self.plans.append(snapshot)
async def insert_report(self, _task_id, snapshot):
self.reports.append(snapshot)
async def list_evidence(self, _task_id):
return list(self.evidence.values())
async def get_evidence(self, _task_id, evidence_id):
return self.evidence.get(evidence_id)
async def insert_evidence(self, _task_id, evidence):
self.evidence[evidence["evidence_id"]] = evidence
async def list_access_requests(self, _task_id):
return list(self.access_requests.values())
async def get_access_request(self, _task_id, access_request_id):
return self.access_requests.get(access_request_id)
async def insert_access_request(self, _task_id, document):
self.access_requests[document["access_request_id"]] = document
async def update_access_request(self, _task_id, document, expected_version):
assert document["version"] == expected_version + 1
self.access_requests[document["access_request_id"]] = document
async def list_actions(self, _task_id):
return [self.action] if self.action else []
async def get_action(self, _task_id, action_id):
if self.action and self.action["action_id"] == action_id:
return self.action
return None
async def update_action(self, _task_id, action):
self.action = action
async def insert_action(self, _task_id, action):
self.action = action
async def insert_notification(self, notification):
self.notifications.append(notification)
async def resolve_notifications(
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
):
resolved = []
for notification in self.notifications:
if (
notification["task_id"] == task_id
and notification["kind"] in kinds
and notification.get("lifecycle_status", "active") == "active"
and notification.get("subject_type") == subject_type
and notification.get("subject_id") == subject_id
):
notification.update(
lifecycle_status="resolved",
resolved_at=resolved_at.isoformat(),
resolution_reason=resolution_reason,
)
resolved.append(notification.copy())
return resolved
async def get_feedback(self, task_id, source_event_id):
return self.feedback.get((task_id, source_event_id))
async def upsert_feedback(self, task_id, feedback, accepted_at):
self.feedback[(task_id, feedback["source_event_id"])] = {
"source_revision": feedback["source_revision"],
"document": feedback,
"accepted_at": accepted_at,
}
class RunTaskRepository(TaskRepository):
def __init__(self, task, run):
super().__init__(task)
self.run = run
self.events = []
async def get_active_run(self, task_id):
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
return self.run
return None
async def get_run(self, run_id):
return self.run if run_id == self.run["run_id"] else None
async def update_run(self, run, expected_revision):
assert run["revision"] == expected_revision + 1
self.run = run
async def insert_run(self, run):
self.run = run
async def append_event(self, **event):
self.events.append(event)
return len(self.events)
async def current_event_cursor(self):
return len(self.events)
def _active_run(task, now):
return OperationalTaskRun(
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
execution_request_id=task["execution_request_id"], status="running", revision=2,
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
heartbeat_at=now, started_at=now, dispatch_attempts=0,
next_dispatch_at=now, created_at=now, updated_at=now,
).model_dump(mode="json")
@pytest.mark.anyio
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
first = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "正在核对输入,尚未形成结论。", "diagram": {
"title": "执行进展", "mermaid": "flowchart LR\nfirst[开始查询]",
"node_refs": [{"node_id": "first", "label": "开始查询", "status": "running",
"map_targets": [{"source_id": "pipes", "feature_id": "P-1"}]}],
}},
})
initial = await service.publish_artifacts(task["task_id"], first)
assert initial.progress_revision == 1
assert initial.task.latest_report is None and initial.task.latest_plan is None
assert initial.task.active_run.status == "running"
assert (await service.publish_artifacts(task["task_id"], first)).progress_revision == 1
assert len([event for event in repository.events if event["topic"] == "operational.progress.changed"]) == 1
replacement = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "重新核查另一条路径。", "diagram": {
"title": "新脉络", "mermaid": "flowchart TD\nother[核查新路径]",
"node_refs": [{"node_id": "other", "label": "核查新路径", "status": "running"}],
}},
})
updated = await service.publish_artifacts(task["task_id"], replacement)
assert updated.progress_revision == 2
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "当前不需要图示。", "diagram": None},
))
assert cleared.progress_revision == 3
assert cleared.task.latest_progress.progress.diagram is None
assert not repository.notifications
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["progress", "presentation"])
@pytest.mark.parametrize("invalid", ["run", "session", "lease", "finished"])
async def test_reading_publications_reject_stale_execution_ownership(kind, invalid):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
if invalid == "lease":
run["lease_expires_at"] = now.isoformat()
elif invalid == "finished":
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
"source_session_id": "other-session" if invalid == "session" else "session-1"}
with pytest.raises(DomainError):
if kind == "progress":
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
**identity, progress={"summary": "执行中"},
))
else:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
**identity, command={"action": "clear_highlights"},
))
assert repository.progress == {} and repository.events == []
@pytest.mark.anyio
async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "执行中", "diagram": {"title": "核查", "mermaid": "graph LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["unknown"]}]}},
))
assert not repository.progress
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "查询完成,正在判断"},
))
with pytest.raises(DomainError) as error:
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="不能用进展授权动作",
payload={"title": "现场核查", "description": "核查", "work_order_type": "sampling"},
))
assert error.value.code == "ACTION_CONCLUSION_REQUIRED"
@pytest.mark.anyio
async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
baseline = await service.presentation_cursor(task["task_id"])
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"], "effect": "pulse"},
)
result = await service.publish_presentation_event(task["task_id"], command)
assert (await service.publish_presentation_event(task["task_id"], command)) == result
assert (await service.presentation_cursor(task["task_id"])).event_cursor == result.event_seq
assert repository.task["revision"] == task["revision"]
assert len(repository.events) == 1
assert repository.events[0]["metadata"]["command"]["effect"] == "pulse"
with pytest.raises(DomainError):
await service.presentation_cursor(uuid4())
@pytest.mark.anyio
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
origin="system_data", truncated=False,
data={"items": [{"device_id": "D-1", "longitude": 121.4, "latitude": 31.1}]})
evidence = StoredEvidenceRecord(evidence_id="verified-points", kind="system_data", summary="实际坐标",
source_label="SCADA", observed_at=now, recorded_at=now, recorded_by="server",
has_snapshot=True, data_snapshot=snapshot)
repository.evidence[evidence.evidence_id] = evidence.model_dump(mode="json")
identity = {"run_id": repository.run["run_id"], "source_session_id": "session-1"}
overlay = {"action": "set_overlay", "overlay_id": "points", "title": "监测点分布", "mode": "heatmap"}
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
emitted = repository.events[-1]["metadata"]["command"]["data"]
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
assert emitted["features"][0]["properties"]["weight"] == 1
with pytest.raises(DomainError) as missing:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
))
assert missing.value.code == "EVIDENCE_NOT_FOUND"
repository.evidence[evidence.evidence_id]["data_snapshot"]["data"] = {
"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [121, 31]},
"properties": {"label": "x" * (512 * 1024)}}],
}
with pytest.raises(DomainError) as oversized:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
assert len(repository.events) == 1
@pytest.mark.anyio
async def test_completed_investigation_resumes_once_for_linked_new_field_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场检测",
payload={"title": "现场检测", "description": "回填复测结果", "work_order_type": "sampling"},
))
child_id = str(uuid4())
repository.action = action.model_copy(update={
"approval_status": "approved", "execution_status": "succeeded",
}).model_dump(mode="json")
repository.action["receipt"] = {
"adapter": "peripheral_sample", "receipt_id": child_id, "submitted_at": now.isoformat(),
"completed_at": now.isoformat(), "external_status": "open", "detail": "工单已创建,现场未完成",
}
repository.run["status"] = "succeeded"
repository.task.update(status="completed", lifecycle_status="completed", agent_session_id=None,
agent_lease_expires_at=None)
identity = {"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
"status": "completed", "occurred_at": now, "detail": "现场复测通过"}
report_before = repository.reports[-1].copy()
command = ExternalFeedbackCommand(request_id=uuid4(), **identity)
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.execution_generation == 2 and accepted.task.active_run.status == "queued"
assert repository.reports[-1] == report_before
assert (await service.external_feedback(task["task_id"], command)).task.execution_generation == 2
# Source-version deduplication also survives a caller changing the request ID.
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
assert again.task.execution_generation == 2
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
repository.task.update(status="cancelled", lifecycle_status="cancelled")
with pytest.raises(DomainError) as cancelled:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), **{**identity, "source_revision": 3},
))
assert cancelled.value.code == "TASK_NOT_ACTIONABLE"
assert repository.task["execution_generation"] == 2
@pytest.mark.anyio
async def test_completed_investigation_rejects_unlinked_or_initial_child_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed")
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as unlinked:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}", source_revision=2,
status="completed", occurred_at=now, detail="无关联的工单",
))
assert unlinked.value.code == "EXTERNAL_SOURCE_MISMATCH"
assert repository.task["execution_generation"] == 1 and repository.feedback == {}
child_id = str(uuid4())
repository.action = {"kind": "create_work_order", "receipt": {
"adapter": "peripheral_sample", "receipt_id": child_id,
}}
with pytest.raises(DomainError) as initial:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=1,
status="assigned", occurred_at=now, detail="工单创建,尚未回填",
))
assert initial.value.code == "INITIAL_CHILD_FEEDBACK"
assert repository.feedback == {}
@pytest.mark.anyio
async def test_original_complaint_feedback_advances_its_source_revision():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
source_id = str(uuid4())
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed",
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.source.external_revision == 2
assert accepted.task.execution_generation == 2
with pytest.raises(DomainError) as stale:
await service.external_feedback(task["task_id"], command.model_copy(update={"request_id": uuid4(), "source_revision": 1}))
assert stale.value.code == "STALE_EXTERNAL_REVISION"
@pytest.mark.anyio
async def test_run_heartbeat_does_not_change_task_business_revision():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
updated = await service.heartbeat_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
),
)
assert updated.revision == 3
assert repository.task["revision"] == 2
assert repository.events == []
@pytest.mark.anyio
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
completed = await service.complete_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
),
)
assert repository.run["status"] == "failed"
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
assert completed.lifecycle_status == "failed"
assert completed.active_run is None
assert [item["kind"] for item in repository.notifications] == ["task_failed"]
@pytest.mark.anyio
async def test_external_feedback_coalesces_into_an_already_queued_run():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(
status="queued", lifecycle_status="open", execution_generation=2,
execution_request_id=str(uuid4()), agent_session_id=None,
)
run = _active_run(task, now)
run.update(
generation=2, execution_request_id=task["execution_request_id"],
status="queued", revision=1, agent_session_id=None,
lease_expires_at=None, heartbeat_at=None, started_at=None,
)
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
result = await service.external_feedback(
task["task_id"],
ExternalFeedbackCommand(
request_id=uuid4(), source_event_id="sample-result", source_revision=1,
status="completed", occurred_at=now, detail="新的现场结果",
),
)
assert result.task.execution_generation == 2
assert str(result.task.execution_request_id) == task["execution_request_id"]
assert result.task.active_run is not None
assert str(result.task.active_run.run_id) == run["run_id"]
def _waiting_plan(action_id=None):
node = {
"id": "field-work",
"title": "现场处置",
"owner": "human",
"status": "waiting",
"detail": "等待审批与执行",
"evidence_refs": [],
"action_refs": [str(action_id)] if action_id else [],
}
return {
"schema": "execution-plan@1",
"title": "调查计划",
"reason": "等待现场处置",
"nodes": [node],
"edges": [],
}
def _current_report():
return {
"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [],
"title": "本代调查报告",
"current_conclusion": "等待现场处置",
"conclusion_boundary": "尚无现场回执",
"evidence_refs": [],
"recommendations": ["完成现场处置"],
"pending_items": ["现场处置"],
}
def _insufficient_report():
return {
**_current_report(), "outcome": "insufficient_data",
"current_conclusion": "无法确定投诉原因", "stop_reason": "缺少投诉时段的末梢采样",
"recommendations": [], "pending_items": [], "evidence_refs": ["empty-query"],
"data_gaps": [{"gap_id": "sample-gap", "node_id": "read", "missing_data": "末梢采样",
"scope": "投诉小区,2026-09-23 08:00 至 09:00", "impact": "无法判断异常来源",
"required_data": "补齐该时段采样结果", "evidence_refs": ["empty-query"], "blocking": True}],
}
def test_report_contract_separates_legacy_reads_from_new_writes():
from app.domain.schemas.operational_task import InvestigationReport, InvestigationReportInput
legacy = {k: v for k, v in _current_report().items()
if k not in {"stage", "outcome", "stop_reason", "data_gaps"}}
stored = InvestigationReport.model_validate(legacy)
assert stored.outcome is None and stored.data_gaps is None
with pytest.raises(ValueError):
InvestigationReportInput.model_validate(legacy)
assert InvestigationReportInput.model_validate(_insufficient_report()).outcome == "insufficient_data"
for changes in [{"stage": "interim"}, {"stop_reason": None}, {"data_gaps": []},
{"recommendations": ["自动派采样工单"]}, {"pending_items": ["等待人工"]},
{"outcome": "concluded"}, {"outcome": "other"}]:
with pytest.raises(ValueError):
InvestigationReportInput.model_validate({**_insufficient_report(), **changes})
@pytest.mark.anyio
async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new_run():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询成功,该范围无采样记录",
"source_label": "合成查询", "observed_at": now.isoformat()}],
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
"nodes": [{"id": "read", "title": "查询采样", "owner": "agent", "status": "completed",
"detail": "已核对查询范围,无记录", "evidence_refs": ["empty-query"]},
{"id": "action", "title": "处置建议", "owner": "agent", "status": "skipped",
"detail": "缺少关键数据,未进入"}],
"edges": [{"from": "read", "to": "action"}]},
report=_insufficient_report(),
)
published = await service.publish_artifacts(task["task_id"], command)
replay = await service.publish_artifacts(task["task_id"], command)
assert published.report_revision == replay.report_revision == 1
with pytest.raises(DomainError) as ended:
await service.publish_artifacts(task["task_id"], command.model_copy(update={"request_id": uuid4()}))
assert ended.value.code == "INVESTIGATION_ENDED"
with pytest.raises(DomainError) as exc:
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="缺数后不应派单",
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"},
))
assert exc.value.code == "ACTION_CONCLUSION_REQUIRED"
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert completed.lifecycle_status == "completed"
assert repository.run["status"] == "succeeded"
assert completed.latest_report.report.outcome == "insufficient_data"
assert repository.action is None
assert not any(item["kind"] in {"task_failed", "human_input_required", "action_approval_pending"}
for item in repository.notifications)
from app.domain.schemas.operational_task import OperationalTaskRetryCommand
restarted = await service.retry_task(task["task_id"], OperationalTaskRetryCommand(
request_id=uuid4(), reason="已补充新的采样记录"))
assert restarted.execution_generation == 2
assert restarted.latest_report.execution_generation == 1
assert restarted.latest_report.report.outcome == "insufficient_data"
with pytest.raises(DomainError) as stale:
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="新轮次尚未形成结论",
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
assert stale.value.code == "ACTION_CONCLUSION_REQUIRED"
@pytest.mark.anyio
@pytest.mark.parametrize("defect,expected", [
("unlisted_evidence", "REPORT_GAP_EVIDENCE_INVALID"), ("pending_action", "REPORT_ACTION_PENDING"),
])
async def test_insufficient_report_rejects_inconsistent_context(defect, expected):
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
report = _insufficient_report()
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
if defect == "unknown_node": report["data_gaps"][0]["node_id"] = "unknown"
if defect == "unlisted_evidence": report["data_gaps"][0]["evidence_refs"] = ["unlisted"]
if defect == "waiting_node": node["status"] = "waiting"
if defect == "pending_action":
_seed_concluded_report(repository, now)
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="已产生待审动作",
payload={"title": "采样", "description": "采样", "work_order_type": "sampling"}))
with pytest.raises(DomainError) as exc:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "查询空结果",
"source_label": "合成查询", "observed_at": now.isoformat()}],
plan={"schema": "execution-plan@1", "title": "调查", "reason": "无数据", "nodes": [node]},
report=report))
assert exc.value.code == expected
def _seed_concluded_report(repository, now):
if repository.reports and repository.reports[-1].get("execution_generation") == repository.task["execution_generation"]:
return
repository.reports.append({
"revision": 1, "execution_generation": repository.task["execution_generation"],
"source_session_id": "session-1", "published_at": now.isoformat(),
"published_by": "agent", "report": _current_report(),
})
@pytest.mark.anyio
async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
))
assert planned.task.status == "running"
assert planned.task.agent_lease_expires_at == now + timedelta(minutes=1)
assert planned.task.latest_plan.execution_generation == 1
reported = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
))
assert reported.task.status == "running"
assert reported.task.latest_report.execution_generation == 1
completed = await service.complete_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"],
source_session_id="session-1",
),
)
assert completed.status == "completed"
assert completed.agent_lease_expires_at is None
assert [item["kind"] for item in repository.notifications] == [
"report_updated"
]
@pytest.mark.anyio
async def test_interim_report_cannot_finish_even_with_a_completed_plan():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
plan = _waiting_plan()
plan["nodes"][0]["status"] = "completed"
plan["nodes"][0]["evidence_refs"] = ["field-result"]
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", plan=plan,
evidence=[{"evidence_id": "field-result", "kind": "synthetic", "summary": "模拟现场结果",
"source_label": "测试采样", "observed_at": now.isoformat()}],
report={**_current_report(), "stage": "interim"},
))
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert completed.lifecycle_status == "failed"
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
@pytest.mark.anyio
async def test_report_is_not_blocked_by_a_historical_plan():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task["execution_generation"] = 2
repository = TaskRepository(task)
repository.plans.append({
"revision": 1,
"execution_generation": None,
"source_session_id": "session-1",
"published_at": now.isoformat(),
"published_by": "principal:test",
"plan": _waiting_plan(),
})
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
))
assert result.task.latest_report.execution_generation == 2
assert result.task.status == "completed"
@pytest.mark.anyio
async def test_report_and_action_without_plan_finish_with_real_approval_pending():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
))
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", node_id="display-only",
reason="已形成判断,需要现场核查", payload={"title": "现场核查", "description": "核验", "work_order_type": "general"},
))
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert completed.latest_plan is None
assert completed.lifecycle_status == "waiting_human"
assert completed.actions[0].action_id == action.action_id
assert repository.run["status"] == "succeeded"
@pytest.mark.anyio
async def test_insufficient_data_without_graph_or_node_ends_the_round():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
report = _insufficient_report()
report["data_gaps"][0].pop("node_id")
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
evidence=[{"evidence_id": "empty-query", "kind": "synthetic", "summary": "成功查询,范围内没有记录",
"source_label": "测试输入", "observed_at": now.isoformat()}],
))
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert completed.lifecycle_status == "completed"
assert completed.latest_report.report.data_gaps[0].node_id is None
assert repository.run["status"] == "succeeded"
@pytest.mark.anyio
async def test_report_history_and_notifications_keep_selected_and_current_references_separate():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
for conclusion in ["第一次判断", "更新后的判断"]:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
report={**_current_report(), "current_conclusion": conclusion},
))
page, offset = await service.list_reports(task["task_id"], 1, 0)
assert [item.revision for item in page] == [2] and offset == 1
page, offset = await service.list_reports(task["task_id"], 1, offset)
assert [item.revision for item in page] == [1] and offset is None
selected = await service.agent_context(task["task_id"], report_revision=1)
assert selected.selected_report.revision == 1
assert selected.task.latest_report.revision == 2
assert selected.selected_report.report.current_conclusion == "第一次判断"
assert repository.notifications[0]["lifecycle_status"] == "resolved"
assert repository.notifications[-1]["report_reference"] == {"execution_generation": 1, "revision": 2}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
report={**_current_report(), "current_conclusion": "更新后的判断",
"diagram": {"title": "说明", "mermaid": 'flowchart TD\nA["核查"] --> B["判断"]'}},
))
assert len(repository.notifications) == 2
with pytest.raises(DomainError) as missing:
await service.report(task["task_id"], 999)
assert missing.value.code == "REPORT_NOT_FOUND"
with pytest.raises(DomainError) as other_task:
await service.list_reports(uuid4(), 20, 0)
assert other_task.value.status == 404
@pytest.mark.anyio
async def test_final_report_archives_its_progress_diagram_across_new_rounds():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "正在整理判断", "diagram": diagram},
))
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**_current_report(), "diagram": None},
)
await service.publish_artifacts(task["task_id"], command)
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
assert command.report.diagram is None # Do not change the idempotency request.
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
assert detail.latest_progress is None
assert detail.latest_report.report.diagram.title == "调查过程"
@pytest.mark.anyio
@pytest.mark.parametrize("mismatch", [None, "session", "future", "evidence", "cleared"])
async def test_legacy_report_diagram_reads_only_matching_prepublication_progress(mismatch):
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
repository.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
"published_at": now.isoformat(), "published_by": "agent", "report": _current_report(),
})
repository.progress[(task["task_id"], 1)] = {
"revision": 1, "execution_generation": 1, "run_id": repository.run["run_id"],
"source_session_id": "another-session" if mismatch == "session" else "session-1",
"published_at": (now + timedelta(seconds=1) if mismatch == "future" else now - timedelta(seconds=1)).isoformat(),
"progress": {"summary": "报告发布前的核查", "diagram": None if mismatch == "cleared" else {
"title": "已保存的过程图", "mermaid": "flowchart LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["not-in-report"] if mismatch == "evidence" else []}],
}},
}
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
selected = await service.report(task["task_id"], 1)
history, _ = await service.list_reports(task["task_id"], 20, 0)
for report in (detail.latest_report, selected, history[0]):
assert (report.report.diagram is not None) is (mismatch is None)
assert repository.reports[0]["report"].get("diagram") is None # Read compatibility never rewrites history.
@pytest.mark.anyio
@pytest.mark.parametrize("node_ref,code", [
({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"),
({"node_id": "A", "label": "动作", "action_refs": [str(uuid4())]}, "ACTION_NOT_FOUND"),
])
async def test_optional_diagram_cannot_forge_business_references(node_ref, code):
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as invalid:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
report={**_current_report(), "diagram": {"title": "调查脉络", "mermaid": 'flowchart TD\nA["核查"]', "node_refs": [node_ref]}},
))
assert invalid.value.code == code
assert repository.reports == []
@pytest.mark.anyio
async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_run():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
))
revision_before_proposal = repository.run["revision"]
lease_before_proposal = repository.run["lease_expires_at"]
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
node_id="field-work", evidence_refs=[],
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
))
assert repository.task["status"] == "running"
assert repository.run["revision"] == revision_before_proposal
assert repository.run["lease_expires_at"] == lease_before_proposal
heartbeat = await service.heartbeat_run(
task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=revision_before_proposal,
source_session_id="session-1",
),
)
assert heartbeat.status == "running"
published = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
run_id=repository.run["run_id"],
plan=_waiting_plan(action.action_id), report=_current_report(),
))
closed = await service.complete_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=published.task.active_run.revision,
source_session_id="session-1",
),
)
assert closed.status == "waiting_human"
assert closed.actions[0].action_id == action.action_id
assert [item["kind"] for item in repository.notifications] == [
"action_approval_pending"
]
@pytest.mark.anyio
async def test_rejected_action_requeues_task_in_a_new_execution_generation():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now).copy())
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
clock=lambda: now,
)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
evidence_refs=[],
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
))
original_request_id = repository.task["execution_request_id"]
rejected = await service.decide_action(
task["task_id"],
action.action_id,
ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version,
decision="rejected", comment="当前方案不执行",
),
)
assert rejected.approval_status == "rejected"
assert rejected.version == action.version + 1
assert repository.task["status"] == "queued"
assert repository.task["execution_generation"] == 2
assert repository.task["execution_request_id"] != original_request_id
assert repository.task["agent_session_id"] is None
assert repository.task["agent_lease_expires_at"] is None
approval_notice = next(
item for item in repository.notifications
if item["kind"] == "action_approval_pending"
)
assert approval_notice["lifecycle_status"] == "resolved"
assert approval_notice["resolution_reason"] == "action_rejected"
assert any(
event["topic"] == "operational.notification.changed"
and event["metadata"].get("change") == "resolved"
for event in repository.events
)
@pytest.mark.anyio
async def test_action_decision_rejects_a_stale_expected_version():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
clock=lambda: now,
)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
evidence_refs=[],
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
))
with pytest.raises(DomainError) as exc:
await service.decide_action(
task["task_id"],
action.action_id,
ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version + 1,
decision="approved",
),
)
assert exc.value.code == "VERSION_CONFLICT"
assert repository.action["approval_status"] == "pending"
assert repository.action["version"] == action.version
def test_v2_plan_requires_explicit_decision_route_and_rejects_cycles():
plan = {
"schema": "execution-plan@2", "title": "动态调查", "reason": "核对投诉与风险",
"nodes": [
{"id": "risk", "title": "判断风险", "owner": "agent", "status": "completed",
"detail": "按已核实事件选择", "kind": "decision", "selected_edge_id": "urgent",
"evidence_refs": ["risk-evidence"]},
{"id": "urgent-check", "title": "紧急采样", "owner": "human", "status": "waiting",
"detail": "等待现场人员", "kind": "activity"},
{"id": "routine-check", "title": "常规核查", "owner": "human", "status": "skipped",
"detail": "紧急路线已选", "kind": "activity"},
],
"edges": [
{"id": "urgent", "from": "risk", "to": "urgent-check", "condition": "有紧急风险"},
{"id": "routine", "from": "risk", "to": "routine-check", "condition": "无紧急风险"},
],
}
assert ExecutionPlanInput.model_validate(plan).nodes[0].selected_edge_id == "urgent"
with pytest.raises(ValidationError, match="outgoing route"):
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "selected_edge_id": "missing"}, *plan["nodes"][1:]]})
with pytest.raises(ValidationError, match="acyclic"):
ExecutionPlanInput.model_validate({**plan, "edges": [*plan["edges"], {"id": "loop", "from": "urgent-check", "to": "risk"}]})
with pytest.raises(ValidationError, match="unchosen route"):
ExecutionPlanInput.model_validate({**plan, "nodes": [plan["nodes"][0], plan["nodes"][1],
{**plan["nodes"][2], "status": "waiting"}]})
with pytest.raises(ValidationError, match="only decision or parallel split"):
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "kind": "activity", "selected_edge_id": None}, *plan["nodes"][1:]]})
@pytest.mark.anyio
async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
initial_revision = task["revision"]
repository = RunTaskRepository(task, _active_run(task, now))
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
assert repository.task["revision"] == initial_revision + 1
assert any(event["topic"] == "operational.task.changed" for event in repository.events)
assert not await agent.can_read_source(task["task_id"], "scada_devices")
assert repository.notifications[0]["kind"] == "access_approval_pending"
reviewer = OperationalTaskService(repository, context, {OPERATIONAL_TASK_ACCESS_APPROVE, SCADA_VIEW}, clock=lambda: now)
approved = await reviewer.decide_access(task["task_id"], request.access_request_id,
TaskAccessRequestDecision(request_id=uuid4(), expected_version=1,
decision="approved", approved_scopes=["scada_devices"]))
assert approved.version == 2
assert not await agent.can_read_source(task["task_id"], "scada_devices")
repository.run.update(status="running", agent_session_id="session-2",
lease_expires_at=now + timedelta(minutes=1))
assert await agent.can_read_source(task["task_id"], "scada_devices")
assert not await agent.can_read_source(task["task_id"], "scada_readings")
assert repository.task["status"] == "queued"
access_notice = next(
item for item in repository.notifications
if item["kind"] == "access_approval_pending"
)
assert access_notice["lifecycle_status"] == "resolved"
assert access_notice["resolution_reason"] == "access_request_approved"
@pytest.mark.anyio
async def test_task_access_request_rejects_a_second_pending_request():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
with pytest.raises(DomainError) as exc:
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="重复申请",
))
assert exc.value.code == "ACCESS_REQUEST_PENDING"
assert len(repository.access_requests) == 1
assert len(repository.notifications) == 1
@pytest.mark.anyio
async def test_task_access_request_fails_closed_before_project_migration():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository.access_requests_available = AsyncMock(return_value=False)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as exc:
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
assert exc.value.code == "DEPENDENCY_UNAVAILABLE"
assert exc.value.status == 503
@pytest.mark.anyio
async def test_completed_gate_requires_access_request_decision():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
with pytest.raises(DomainError) as exc:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
plan={
"schema": "execution-plan@2",
"title": "读取监测数据",
"reason": "等待授权后读取",
"nodes": [{
"id": "read-scada", "title": "数据访问审核",
"owner": "human", "status": "completed",
"detail": "尚未审核", "kind": "gate",
"gate_ref": str(request.access_request_id),
}],
"edges": [],
},
))
assert exc.value.code == "GATE_NOT_RESOLVED"
@pytest.mark.anyio
async def test_work_order_draft_edit_creates_new_review_version():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
payload={"title": "旧草稿", "description": "采样", "work_order_type": "general"},
))
updated = await service.update_work_order_draft(task["task_id"], action.action_id,
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
payload={"title": "人工修订草稿", "description": "增加复测", "work_order_type": "general"}))
assert updated.version == 2
assert updated.payload.title == "人工修订草稿"
assert updated.approval_status == "pending"
with pytest.raises(DomainError, match="Action version changed"):
await service.update_work_order_draft(task["task_id"], action.action_id,
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
payload={"title": "过期草稿", "description": "采样", "work_order_type": "general"}))
assert repository.action["approval_status"] == "pending"
assert repository.action["version"] == updated.version
@pytest.mark.anyio
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
}, clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
payload={"title": "采样", "description": "现场核验", "work_order_type": "general"},
))
class Dispatcher:
calls = 0
async def dispatch(self, **_kwargs):
self.calls += 1
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
dispatcher = Dispatcher()
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
command, dispatcher, None)
replay = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
command, dispatcher, None)
assert first.approval_status == "approved"
assert first.execution_status == "unsupported"
assert replay.version == first.version
assert dispatcher.calls == 1
assert [item["kind"] for item in repository.notifications] == [
"action_approval_pending",
"external_coordination_failed",
]
@pytest.mark.anyio
async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = TaskRepository(task)
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
clock=lambda: now,
)
_seed_concluded_report(repository, now)
original = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要首次核验",
evidence_refs=[],
payload={"title": "首次核验", "description": "原始内容", "work_order_type": "sampling"},
))
approved = await service.decide_action(
task["task_id"],
original.action_id,
ActionDecisionCommand(
request_id=uuid4(), expected_version=original.version, decision="approved",
),
)
_seed_concluded_report(repository, now)
revised = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="内容已修改",
evidence_refs=[],
payload={"title": "修改后核验", "description": "新的内容", "work_order_type": "sampling"},
))
assert approved.approval_status == "approved"
assert revised.action_id != approved.action_id
assert revised.payload != approved.payload
assert revised.approval_status == "pending"
assert revised.execution_status == "not_submitted"
assert revised.version == 1
assert revised.decided_by is None
assert revised.decided_at is None
def _work_order_snapshot(work_order_id, now):
return {
"work_order_id": str(work_order_id),
"revision": 2,
"title": "复测预约",
"summary": "等待现场复测回令",
"status": "awaiting_reply",
"priority": "high",
"assignee": "水质班组",
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
"work_order_type": "water_quality_complaint",
"location": "联调小区",
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
"feedback_requirements": ["上传检测结果"],
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
"origin": "synthetic",
"updated_at": now.isoformat(),
}
@pytest.mark.anyio
async def test_external_feedback_exposes_validated_work_order_snapshot_and_refreshes_summary():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
work_order_id = uuid4()
task = _task_document(context, now, lease=None)
task.update(
task_type="external_work_order",
workflow="water_quality_complaint",
source={
"kind": "external_system",
"external_id": f"peripheral_sample:{work_order_id}",
"external_revision": 1,
"event_kind": "water_quality_complaint",
},
)
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
result = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(),
source_event_id=f"peripheral_sample:{work_order_id}",
source_revision=2,
status="awaiting_feedback",
occurred_at=now,
detail="等待现场复测回令",
work_order_snapshot=_work_order_snapshot(work_order_id, now),
))
assert result.task.status == "waiting_human"
assert result.task.title == "复测预约"
assert result.task.summary == "等待现场复测回令"
assert result.task.scheduled_for == now + timedelta(hours=1)
assert result.task.priority == "urgent"
assert result.task.work_order_snapshot is not None
assert result.task.work_order_snapshot.activity[0].title == "现场反馈"
assert result.task.work_order_snapshot.feedback_requirements == ["上传检测结果"]
@pytest.mark.anyio
async def test_withdraw_external_source_keeps_history_and_is_idempotent():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
work_order_id = uuid4()
source_id = f"peripheral_sample:{work_order_id}"
task = _task_document(context, now, lease=None)
task.update(
task_type="external_work_order",
source={"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general"},
)
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalSourceWithdrawalCommand(
request_id=uuid4(), source_event_id=source_id
)
withdrawn = await service.withdraw_external_source(task["task_id"], command)
replayed = await service.withdraw_external_source(task["task_id"], command)
assert withdrawn.source.deleted_at == now
assert withdrawn.status == "cancelled"
assert withdrawn.lifecycle_status == "cancelled"
assert withdrawn.revision == 3
assert replayed.revision == withdrawn.revision
assert repository.task["source"]["external_revision"] == 1
with pytest.raises(DomainError) as exc:
await service.withdraw_external_source(task["task_id"], ExternalSourceWithdrawalCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}"
))
assert exc.value.code == "EXTERNAL_SOURCE_MISMATCH"
@pytest.mark.anyio
async def test_withdrawn_external_task_rejects_action_changes_and_resolves_approval():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
work_order_id = uuid4()
source_id = f"peripheral_sample:{work_order_id}"
task = _task_document(context, now, lease=None)
task.update(
task_type="external_work_order",
status="scheduled",
source={"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general"},
)
repository = TaskRepository(task)
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
clock=lambda: now,
)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
evidence_refs=[],
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
))
await service.withdraw_external_source(
task["task_id"],
ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id),
)
with pytest.raises(DomainError) as exc:
await service.decide_action(
task["task_id"],
action.action_id,
ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version, decision="approved",
),
)
assert exc.value.code == "TASK_NOT_ACTIONABLE"
assert repository.notifications[0]["lifecycle_status"] == "resolved"
assert repository.notifications[0]["resolution_reason"] == "external_source_withdrawn"
@pytest.mark.anyio
async def test_event_investigation_rejects_work_order_snapshot():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
work_order_id = uuid4()
with pytest.raises(DomainError) as exc:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=str(work_order_id), source_revision=2,
status="awaiting_feedback", occurred_at=now, detail="不应接受",
work_order_snapshot=_work_order_snapshot(work_order_id, now),
))
assert exc.value.code == "INVALID_FEEDBACK_SNAPSHOT"
@pytest.mark.anyio
async def test_expired_owned_lease_is_rejected_with_stable_code():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now - timedelta(seconds=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as exc:
await service.heartbeat_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
),
)
assert exc.value.code == "LEASE_EXPIRED"
assert repository.run["revision"] == 2
@pytest.mark.anyio
async def test_interrupted_submit_replays_unknown_without_dispatching_twice():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task.update(status="waiting_human", agent_lease_expires_at=None)
action = HumanApprovalAction(
action_id=uuid4(),
task_id=task["task_id"],
version=2,
kind="create_work_order",
reason="现场核验",
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
evidence_refs=[],
required_approval_permission="work-order.approve",
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
approval_status="approved",
execution_status="not_submitted",
proposed_by="principal:agent",
proposed_at=now,
decided_by="principal:approver",
decided_at=now,
).model_dump(mode="json")
repository = TaskRepository(task, action=action)
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_EXECUTE},
clock=lambda: now,
)
command = ActionExecuteCommand(request_id=uuid4(), expected_version=2)
class LostResponseDispatcher:
def __init__(self):
self.calls = 0
async def dispatch(self, **_kwargs):
self.calls += 1
raise RuntimeError("connection lost after submission")
dispatcher = LostResponseDispatcher()
with pytest.raises(RuntimeError):
await service.submit_action(
task["task_id"], action["action_id"], command, dispatcher, None
)
replay = await service.submit_action(
task["task_id"], action["action_id"], command, dispatcher, None
)
assert dispatcher.calls == 1
assert replay.execution_status == "unknown"
assert replay.execution_request_id is not None
assert [item["kind"] for item in repository.notifications] == [
"external_coordination_failed"
]
@pytest.mark.anyio
async def test_snapshot_reuse_returns_the_original_provenance_and_rejects_collision():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
original = TaskDataSnapshot(
source="peripheral_points",
source_ref="peripheral_points:sha256:same",
queried_at=now,
origin="synthetic",
data={"items": [{"id": "p-1"}]},
truncated=False,
)
evidence, stored = await service.record_data_snapshot(task["task_id"], original)
replayed = original.model_copy(update={"queried_at": now + timedelta(minutes=1)})
replay_evidence, replay_snapshot = await service.record_data_snapshot(
task["task_id"], replayed
)
assert replay_evidence == evidence
assert replay_snapshot == stored == original
collision = original.model_copy(update={"data": {"items": [{"id": "p-2"}]}})
with pytest.raises(DomainError) as exc:
await service.record_data_snapshot(task["task_id"], collision)
assert exc.value.code == "EVIDENCE_CONFLICT"
@pytest.mark.anyio
async def test_repository_task_update_rejects_a_stale_revision():
cursor = SimpleNamespace(rowcount=0)
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
repository = OperationalTaskRepository(connection, uuid4())
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task["revision"] = 3
with pytest.raises(DomainError) as exc:
await repository.update_task(task)
assert exc.value.code == "VERSION_CONFLICT"