refactor: simplify task and history services

Remove retired query paths and repository compatibility branches, and share task test fixtures.
Cancel pending questions and resolve their notifications when recovering expired runs.
This commit is contained in:
2026-11-09 17:55:00 +08:00
parent 5197a93163
commit 6a4478bfe4
10 changed files with 464 additions and 816 deletions
+31 -19
View File
@@ -1,6 +1,7 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from unittest.mock import AsyncMock
import pytest
@@ -10,7 +11,7 @@ from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailure
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
from app.services.operational_tasks import OperationalTaskService
from app.services.task_questions import create_question, answer_question
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
@pytest.fixture
@@ -18,28 +19,11 @@ def anyio_backend():
return "asyncio"
class QuestionRepository(RunTaskRepository):
def __init__(self, task, run):
super().__init__(task, run)
self.questions = {}
async def list_questions(self, task_id):
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
async def get_question(self, task_id, question_id):
q = self.questions.get(question_id)
return deepcopy(q) if q and q["task_id"] == task_id else None
async def save_question(self, task_id, document):
assert document["task_id"] == task_id
self.questions[document["question_request_id"]] = deepcopy(document)
def setup():
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repo = QuestionRepository(task, _active_run(task, now))
repo = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
@@ -162,6 +146,34 @@ async def test_real_execution_failure_cancels_question_instead_of_disguising_fai
assert repo.notifications[0]["lifecycle_status"] == "resolved"
@pytest.mark.anyio
@pytest.mark.parametrize("timeout_count", [1, 3])
async def test_lease_timeout_cancels_old_question_and_unblocks_the_next_generation(timeout_count):
service, repo, task_id, command = setup()
question = await create_question(service, task_id, command)
service.clock = lambda: question.created_at + timedelta(minutes=2)
repo.claim_expired_runs = AsyncMock(return_value=[deepcopy(repo.run)])
repo.consecutive_timed_out_runs = AsyncMock(return_value=timeout_count)
await service.recover_expired_leases(1)
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
assert repo.notifications[0]["lifecycle_status"] == "resolved"
with pytest.raises(DomainError):
await answer_question(service, task_id, question.question_request_id, reply())
if timeout_count == 3:
assert repo.task["lifecycle_status"] == "failed"
return
await service.claim_run(task_id, UUID(repo.run["run_id"]), AgentRunCommand(
request_id=UUID(repo.run["execution_request_id"]), expected_revision=1,
source_session_id="session-2"))
next_question = await create_question(service, task_id, command.model_copy(update={
"request_id": uuid4(), "run_id": UUID(repo.run["run_id"]), "source_session_id": "session-2"}))
assert next_question.status == "pending"
assert next_question.execution_generation == 2
@pytest.mark.anyio
async def test_multiple_selection_and_optional_question_are_preserved():
service, repo, task_id, command = setup()