|
|
|
@@ -1,3 +1,4 @@
|
|
|
|
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
@@ -114,11 +115,6 @@ async def test_access_request_read_uses_table_after_migration():
|
|
|
|
|
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
|
|
|
|
@@ -232,208 +228,12 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
|
|
|
|
|
assert exc.value.code == "PERMISSION_DENIED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task_document(context, now, *, lease=None):
|
|
|
|
|
task_id = uuid4()
|
|
|
|
|
return OperationalTaskSummary(
|
|
|
|
|
execution_scope="production",
|
|
|
|
|
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 require_execution_storage(self):
|
|
|
|
|
return 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)
|
|
|
|
|
|
|
|
|
|
async def list_presentation_events(self, task_id, after, through, limit):
|
|
|
|
|
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
|
|
|
|
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
|
|
|
|
and event["aggregate_id"] == task_id][:limit]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
@@ -457,7 +257,7 @@ async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_sc
|
|
|
|
|
run = _active_run(task, now)
|
|
|
|
|
run.update(status="queued", revision=1, agent_session_id=None,
|
|
|
|
|
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(task, run)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
|
|
|
|
execution_scope=worker_scope)
|
|
|
|
|
|
|
|
|
@@ -488,7 +288,7 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(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"],
|
|
|
|
@@ -535,7 +335,7 @@ async def test_reading_publications_reject_stale_execution_ownership(kind, inval
|
|
|
|
|
run["lease_expires_at"] = now.isoformat()
|
|
|
|
|
elif invalid == "finished":
|
|
|
|
|
run["status"] = "succeeded"
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(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"}
|
|
|
|
@@ -556,7 +356,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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(
|
|
|
|
@@ -582,7 +382,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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"]
|
|
|
|
@@ -608,7 +408,7 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
|
|
|
|
task["source"]["replay_context"] = {
|
|
|
|
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
|
|
|
|
}
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
|
|
|
|
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
|
|
|
@@ -645,7 +445,7 @@ async def test_comparison_chart_publication_is_validated_at_both_write_boundarie
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
|
|
|
|
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
|
|
|
@@ -715,7 +515,7 @@ async def test_replay_cannot_propose_approve_or_submit_device_commands(operation
|
|
|
|
|
required_execution_permission="command.execute", approval_status="pending",
|
|
|
|
|
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
|
|
|
|
)
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.action = action.model_dump(mode="json")
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
|
|
|
@@ -743,7 +543,7 @@ async def test_unified_commands_record_canonical_events_without_mutating_task(pa
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
command = TaskPresentationEventCommand(
|
|
|
|
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
|
|
|
@@ -762,7 +562,7 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
coverage = build_node_coverage(
|
|
|
|
|
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
|
|
|
@@ -795,7 +595,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(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,
|
|
|
|
@@ -844,7 +644,7 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(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(
|
|
|
|
@@ -891,7 +691,7 @@ async def test_completed_investigation_rejects_unlinked_or_initial_child_feedbac
|
|
|
|
|
task.update(status="completed", lifecycle_status="completed")
|
|
|
|
|
run = _active_run(task, now)
|
|
|
|
|
run["status"] = "succeeded"
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(task, run)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
with pytest.raises(DomainError) as unlinked:
|
|
|
|
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
|
|
|
@@ -923,7 +723,7 @@ async def test_original_complaint_feedback_advances_its_source_revision():
|
|
|
|
|
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
|
|
|
|
run = _active_run(task, now)
|
|
|
|
|
run["status"] = "succeeded"
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(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="新投诉信息")
|
|
|
|
@@ -940,7 +740,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
|
updated = await service.heartbeat_run(
|
|
|
|
@@ -962,7 +762,7 @@ async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_sessio
|
|
|
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
|
|
|
run = _active_run(task, now)
|
|
|
|
|
run["agent_instance_id"] = "agent-a"
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(task, run)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(DomainError) as mismatch:
|
|
|
|
@@ -982,7 +782,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
|
completed = await service.complete_run(
|
|
|
|
@@ -1014,7 +814,7 @@ async def test_external_feedback_coalesces_into_an_already_queued_run():
|
|
|
|
|
status="queued", revision=1, agent_session_id=None,
|
|
|
|
|
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
|
|
|
|
)
|
|
|
|
|
repository = RunTaskRepository(task, run)
|
|
|
|
|
repository = TaskRepository(task, run)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
|
result = await service.external_feedback(
|
|
|
|
@@ -1069,7 +869,7 @@ async def test_scada_final_report_requires_complete_current_input_not_selected_d
|
|
|
|
|
context = _context()
|
|
|
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
|
|
|
task["workflow"] = "scada_anomaly"
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
|
|
|
|
"mode": "all_scada", "query_complete": True,
|
|
|
|
@@ -1124,7 +924,7 @@ async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
command = AgentArtifactsCommand(
|
|
|
|
|
request_id=uuid4(), source_session_id="session-1",
|
|
|
|
@@ -1179,7 +979,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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": "关键数据不足"}
|
|
|
|
@@ -1229,7 +1029,7 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|
|
|
|
task["source"]["replay_context"] = {
|
|
|
|
|
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
|
|
|
|
}
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.connection = object()
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
|
|
|
@@ -1308,8 +1108,10 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|
|
|
|
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
|
|
|
|
evidence_id = revised_id
|
|
|
|
|
|
|
|
|
|
repository.task.update(status="running", agent_session_id="session-1")
|
|
|
|
|
repository.run = _active_run(repository.task, now)
|
|
|
|
|
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
|
|
|
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"],
|
|
|
|
|
source_session_id="session-1",
|
|
|
|
|
))
|
|
|
|
|
_seed_concluded_report(repository, now)
|
|
|
|
|
with pytest.raises(DomainError) as rejected:
|
|
|
|
|
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
|
|
|
@@ -1334,7 +1136,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
|
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
|
|
@@ -1369,7 +1171,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
plan = _waiting_plan()
|
|
|
|
|
plan["nodes"][0]["status"] = "completed"
|
|
|
|
@@ -1410,7 +1212,11 @@ async def test_report_is_not_blocked_by_a_historical_plan():
|
|
|
|
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
|
|
|
|
))
|
|
|
|
|
assert result.task.latest_report.execution_generation == 2
|
|
|
|
|
assert result.task.status == "completed"
|
|
|
|
|
assert result.task.status == "running"
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.anyio
|
|
|
|
@@ -1418,7 +1224,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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(),
|
|
|
|
@@ -1441,7 +1247,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
report = _insufficient_report()
|
|
|
|
|
report["data_gaps"][0].pop("node_id")
|
|
|
|
@@ -1463,7 +1269,7 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
for conclusion in ["第一次判断", "更新后的判断"]:
|
|
|
|
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
|
|
|
@@ -1499,7 +1305,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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"}]}
|
|
|
|
@@ -1526,7 +1332,7 @@ async def test_legacy_report_diagram_reads_only_matching_prepublication_progress
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(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",
|
|
|
|
@@ -1559,7 +1365,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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(
|
|
|
|
@@ -1575,7 +1381,7 @@ async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_r
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(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(),
|
|
|
|
@@ -1624,7 +1430,7 @@ 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())
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now).copy())
|
|
|
|
|
service = OperationalTaskService(
|
|
|
|
|
repository,
|
|
|
|
|
context,
|
|
|
|
@@ -1673,7 +1479,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(
|
|
|
|
|
repository,
|
|
|
|
|
context,
|
|
|
|
@@ -1737,7 +1543,7 @@ async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
|
|
|
|
|
context = _context()
|
|
|
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
|
|
|
initial_revision = task["revision"]
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(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",
|
|
|
|
@@ -1771,7 +1577,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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",
|
|
|
|
@@ -1794,7 +1600,7 @@ 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 = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.access_requests_available = AsyncMock(return_value=False)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
@@ -1813,7 +1619,7 @@ 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))
|
|
|
|
|
repository = TaskRepository(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",
|
|
|
|
@@ -1850,7 +1656,7 @@ async def test_work_order_draft_edit_creates_new_review_version(replay):
|
|
|
|
|
task["source"]["replay_context"] = {
|
|
|
|
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
|
|
|
|
}
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(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(
|
|
|
|
@@ -1881,7 +1687,7 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
|
|
|
|
task["source"]["replay_context"] = {
|
|
|
|
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
|
|
|
|
}
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, {
|
|
|
|
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
|
|
|
|
}, clock=lambda: now)
|
|
|
|
@@ -2125,7 +1931,7 @@ 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 = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
|
|
|
|
@@ -2253,7 +2059,7 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
|
|
|
|
|
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
|
|
|
|
context = _context()
|
|
|
|
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
report = {**rich_report(), "stage": "final"}
|
|
|
|
|
command = AgentArtifactsCommand(
|
|
|
|
@@ -2290,7 +2096,7 @@ async def test_presentation_history_is_task_scoped_paginated_and_readable_after_
|
|
|
|
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
|
|
|
context = _context()
|
|
|
|
|
task = _task_document(context, now)
|
|
|
|
|
repository = RunTaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.run["status"] = "succeeded"
|
|
|
|
|
for index in range(4):
|
|
|
|
|
await repository.append_event(
|
|
|
|
@@ -2322,7 +2128,7 @@ async def test_final_report_rejected_while_original_run_has_pending_calculations
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
pending_job = str(uuid4())
|
|
|
|
|
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
@@ -2346,7 +2152,7 @@ async def test_completion_rejected_until_calculations_reach_terminal_state():
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
|
|
|
|
@@ -2380,7 +2186,7 @@ async def test_interim_report_can_explain_progress_while_calculation_is_pending(
|
|
|
|
|
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))
|
|
|
|
|
repository = TaskRepository(task, _active_run(task, now))
|
|
|
|
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
|
|
|
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
|
|
|
|
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
|
|
|
|
|