836 lines
31 KiB
Python
836 lines
31 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 app.auth.permissions import (
|
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
|
OPERATIONAL_TASK_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,
|
|
HumanApprovalAction,
|
|
NotificationAcknowledgeCommand,
|
|
OperationalTaskNotification,
|
|
OperationalTaskRun,
|
|
OperationalTaskSummary,
|
|
)
|
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
|
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 = {}
|
|
|
|
@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
|
|
|
|
|
|
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}"
|
|
|
|
|
|
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_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.notifications = []
|
|
self.locks = []
|
|
|
|
@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 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_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 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)
|
|
|
|
|
|
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_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
|
|
|
|
|
|
@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 {
|
|
"title": "本代调查报告",
|
|
"current_conclusion": "等待现场处置",
|
|
"conclusion_boundary": "尚无现场回执",
|
|
"evidence_refs": [],
|
|
"recommendations": ["完成现场处置"],
|
|
"pending_items": ["现场处置"],
|
|
}
|
|
|
|
|
|
@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 == "waiting_human"
|
|
assert completed.agent_lease_expires_at is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_report_does_not_close_from_legacy_or_prior_generation_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.status == "running"
|
|
assert result.task.latest_report.execution_generation == 2
|
|
|
|
|
|
@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"]
|
|
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|
|
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",
|
|
),
|
|
)
|
|
|
|
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_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
|
|
|
|
|
|
@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"
|