ForcePilot/server/services/tasker.py
Wenjie Zhang 43ec5a4a71 feat: 实现异步任务管理系统和任务中心UI
## 后端变更

  ### 任务管理核心
  - 新增 tasker 服务,支持异步任务队列和工作线程池
  - 添加任务执行时间追踪(started_at, completed_at)
  - 实现任务状态持久化(JSON文件存储)
  - 支持任务取消和进度更新

  ### 知识库导入优化
  - 将知识库文档导入改为异步任务处理
  - 逐个处理文档并实时更新进度(5% → 95%)
  - 支持任务取消检测

  ### API接口
  - 新增任务列表、详情、取消等REST API
  - 任务API需要管理员权限

  ### 测试
  - 添加任务路由集成测试

  ## 前端变更

  ### 任务中心UI
  - 新增任务中心抽屉组件,采用简洁的卡片式设计
  - 支持任务状态筛选(全部/进行中/已完成/失败)
  - 进行中任务显示实时进度条
  - 已完成任务显示执行时长和完成状态徽章
  - 支持相对时间显示(如:5分钟前)

  ### 状态管理
  - 新增 tasker store,管理任务列表和轮询
  - 支持任务注册、刷新、取消操作

  ### 交互优化
  - 在状态栏和侧边栏添加任务中心入口
  - 显示活跃任务数量徽章
  - 知识库导入自动注册到任务中心
2025-10-11 15:02:24 +08:00

302 lines
11 KiB
Python

import asyncio
import json
import os
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
from src.config import config
from src.utils.logging_config import logger
TaskCoroutine = Callable[["TaskContext"], Awaitable[Any]]
TERMINAL_STATUSES = {"success", "failed", "cancelled"}
def _utc_timestamp() -> str:
return datetime.utcnow().isoformat() + "Z"
@dataclass
class Task:
id: str
name: str
type: str
status: str = "pending"
progress: float = 0.0
message: str = ""
created_at: str = field(default_factory=_utc_timestamp)
updated_at: str = field(default_factory=_utc_timestamp)
started_at: Optional[str] = None
completed_at: Optional[str] = None
payload: Dict[str, Any] = field(default_factory=dict)
result: Optional[Any] = None
error: Optional[str] = None
cancel_requested: bool = False
def to_dict(self) -> Dict[str, Any]:
data = asdict(self)
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Task":
return cls(
id=data["id"],
name=data.get("name", "Unnamed Task"),
type=data.get("type", "general"),
status=data.get("status", "pending"),
progress=data.get("progress", 0.0),
message=data.get("message", ""),
created_at=data.get("created_at", _utc_timestamp()),
updated_at=data.get("updated_at", _utc_timestamp()),
started_at=data.get("started_at"),
completed_at=data.get("completed_at"),
payload=data.get("payload", {}),
result=data.get("result"),
error=data.get("error"),
cancel_requested=data.get("cancel_requested", False),
)
class TaskContext:
def __init__(self, tasker: "Tasker", task_id: str):
self._tasker = tasker
self.task_id = task_id
async def set_progress(self, progress: float, message: Optional[str] = None) -> None:
await self._tasker._update_task(
self.task_id,
progress=max(0.0, min(progress, 100.0)),
message=message,
)
async def set_message(self, message: str) -> None:
await self._tasker._update_task(self.task_id, message=message)
async def set_result(self, result: Any) -> None:
await self._tasker._update_task(self.task_id, result=result)
def is_cancel_requested(self) -> bool:
return self._tasker._is_cancel_requested(self.task_id)
async def raise_if_cancelled(self) -> None:
if self.is_cancel_requested():
raise asyncio.CancelledError("Task was cancelled")
class Tasker:
def __init__(self, worker_count: int = 2):
self.worker_count = max(1, worker_count)
self._queue: "asyncio.Queue[tuple[str, TaskCoroutine]]" = asyncio.Queue()
self._tasks: Dict[str, Task] = {}
self._lock = asyncio.Lock()
self._workers: List[asyncio.Task[Any]] = []
self._storage_path = Path(config.save_dir) / "tasks" / "tasks.json"
os.makedirs(self._storage_path.parent, exist_ok=True)
self._started = False
async def start(self) -> None:
async with self._lock:
if self._started:
return
await self._load_state()
for _ in range(self.worker_count):
worker = asyncio.create_task(self._worker_loop(), name="tasker-worker")
self._workers.append(worker)
self._started = True
logger.info("Tasker started with %s workers", self.worker_count)
async def shutdown(self) -> None:
async with self._lock:
if not self._started:
return
for worker in self._workers:
worker.cancel()
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers.clear()
await self._persist_state()
self._started = False
logger.info("Tasker shutdown complete")
async def enqueue(
self,
*,
name: str,
task_type: str,
payload: Optional[Dict[str, Any]] = None,
coroutine: TaskCoroutine,
) -> Task:
task_id = uuid.uuid4().hex
task = Task(id=task_id, name=name, type=task_type, payload=payload or {})
async with self._lock:
self._tasks[task_id] = task
await self._persist_state()
await self._queue.put((task_id, coroutine))
logger.info("Enqueued task %s (%s)", task_id, name)
return task
async def list_tasks(self, status: Optional[str] = None) -> List[Dict[str, Any]]:
async with self._lock:
tasks = list(self._tasks.values())
if status:
tasks = [task for task in tasks if task.status == status]
tasks.sort(key=lambda item: item.created_at, reverse=True)
return [task.to_dict() for task in tasks]
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
task = self._tasks.get(task_id)
return task.to_dict() if task else None
async def cancel_task(self, task_id: str) -> bool:
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return False
if task.status in {"success", "failed", "cancelled"}:
return False
task.cancel_requested = True
task.updated_at = _utc_timestamp()
await self._persist_state()
logger.info("Cancellation requested for task %s", task_id)
return True
async def _worker_loop(self) -> None:
while True:
try:
task_id, coroutine = await self._queue.get()
try:
task = await self._get_task_instance(task_id)
if not task:
continue
if task.cancel_requested:
await self._mark_cancelled(task_id, "Task was cancelled before execution")
continue
await self._update_task(task_id, status="running", progress=0.0, message="任务开始执行", started_at=_utc_timestamp())
context = TaskContext(self, task_id)
try:
result = await coroutine(context)
if task.cancel_requested:
await self._mark_cancelled(task_id, "Task cancelled during execution")
continue
await self._update_task(
task_id,
status="success",
progress=100.0,
message="任务已完成",
result=result,
completed_at=_utc_timestamp(),
)
except asyncio.CancelledError:
await self._mark_cancelled(task_id, "任务被取消")
except Exception as exc: # noqa: BLE001
logger.error("Task %s failed: %s", task_id, exc, exc_info=True)
await self._update_task(
task_id,
status="failed",
progress=100.0,
message="任务执行失败",
error=str(exc),
completed_at=_utc_timestamp(),
)
finally:
self._queue.task_done()
except asyncio.CancelledError:
break
except Exception as exc: # noqa: BLE001
logger.error("Tasker worker error: %s", exc, exc_info=True)
async def _get_task_instance(self, task_id: str) -> Optional[Task]:
async with self._lock:
return self._tasks.get(task_id)
async def _mark_cancelled(self, task_id: str, message: str) -> None:
await self._update_task(
task_id,
status="cancelled",
progress=100.0,
message=message,
completed_at=_utc_timestamp(),
)
async def _update_task(
self,
task_id: str,
*,
status: Optional[str] = None,
progress: Optional[float] = None,
message: Optional[str] = None,
result: Any = None,
error: Optional[str] = None,
started_at: Optional[str] = None,
completed_at: Optional[str] = None,
) -> None:
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return
if status:
task.status = status
if progress is not None:
task.progress = max(0.0, min(progress, 100.0))
if message is not None:
task.message = message
if result is not None:
task.result = result
if error is not None:
task.error = error
if started_at is not None:
task.started_at = started_at
if completed_at is not None:
task.completed_at = completed_at
task.updated_at = _utc_timestamp()
await self._persist_state()
def _is_cancel_requested(self, task_id: str) -> bool:
task = self._tasks.get(task_id)
return bool(task and task.cancel_requested)
async def _load_state(self) -> None:
if not self._storage_path.exists():
return
try:
content = await asyncio.to_thread(self._storage_path.read_text, encoding="utf-8")
if not content.strip():
return
data = json.loads(content)
tasks = data.get("tasks", [])
for item in tasks:
task = Task.from_dict(item)
if task.status == "running":
task.status = "failed"
task.message = "服务重启时任务中断"
task.updated_at = _utc_timestamp()
elif task.status not in TERMINAL_STATUSES:
task.status = "failed"
task.message = "服务重启时任务未继续执行"
task.updated_at = _utc_timestamp()
self._tasks[task.id] = task
logger.info("Loaded %s task records from storage", len(tasks))
except Exception as exc: # noqa: BLE001
logger.error("Failed to load task state: %s", exc, exc_info=True)
async def _persist_state(self) -> None:
tasks = [task.to_dict() for task in self._tasks.values()]
payload = {"tasks": tasks, "updated_at": _utc_timestamp()}
async def _write() -> None:
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self._storage_path.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, self._storage_path)
await asyncio.to_thread(_write)
tasker = Tasker()
__all__ = ["tasker", "TaskContext", "Tasker"]