"""异步后台任务注册表(T1:后端异步化核心)。 设计原则(对齐 v3 方案 D1-D6): - asyncio 原生,无 Celery/Redis/外部队列 - 每个 request_id -> TaskInfo(状态/开始时间/结果或错误) - 任务写 runs/{id}/workspace.json,SSE 生成器只读该文件(D3:不改 pipeline) - 定期清理已完成任务(防内存泄漏) """ from __future__ import annotations import asyncio import json import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, AsyncGenerator, Dict, Optional # runs/ 目录(与 pipeline.py 默认一致) RUNS_DIR = Path("runs") # 任务状态 STATE_PENDING = "pending" STATE_RUNNING = "running" STATE_DONE = "done" STATE_FAILED = "failed" @dataclass class TaskInfo: """一个后台任务的状态快照。""" request_id: str state: str = STATE_PENDING # pending | running | done | failed started_at: float = 0.0 # time.time() finished_at: float = 0.0 # time.time()(done/failed 时) error: Optional[str] = None # failed 时错误信息 # PipelineResult 字段(done 时填充) response: Optional[str] = None status: Optional[str] = None # done | fast_path | escalated | failed fast_path: bool = False rounds_used: int = 0 api_input_tokens: int = 0 api_output_tokens: int = 0 cost_est: float = 0.0 model_used: Optional[str] = None latency_ms: float = 0.0 workspace_path: Optional[str] = None error_detail: Optional[str] = None route: list = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { "request_id": self.request_id, "state": self.state, "started_at": self.started_at, "finished_at": self.finished_at, "error": self.error, "response": self.response, "status": self.status, "fast_path": self.fast_path, "rounds_used": self.rounds_used, "api_input_tokens": self.api_input_tokens, "api_output_tokens": self.api_output_tokens, "cost_est": self.cost_est, "model_used": self.model_used, "latency_ms": round(self.latency_ms, 2), "workspace_path": self.workspace_path, "route": self.route, "error_detail": self.error_detail, } def to_result_event(self) -> Dict[str, Any]: """终态 SSE result 事件 payload。""" return { "type": "result", "request_id": self.request_id, "response": self.response or "", "status": self.status, "fast_path": self.fast_path, "rounds_used": self.rounds_used, "api_input_tokens": self.api_input_tokens, "api_output_tokens": self.api_output_tokens, "cost_est": self.cost_est, "model_used": self.model_used, "latency_ms": round(self.latency_ms, 2), "route": self.route, "error": self.error, } class JobStore: """asyncio 后台任务注册表。 线程安全(asyncio 事件循环单线程,不需要额外锁)。 任务由 register() 注册、由 _task_done() 填充结果。 SSE 生成器调用 watch_file() 轮询 workspace.json 变化。 """ def __init__(self, max_age_seconds: float = 3600.0, max_running: int = 50): """ Args: max_age_seconds: 已完成任务在内存中保留时间(秒),超时后自动清理 max_running: 最大同时运行任务数,超出后拒绝新任务 """ self._tasks: Dict[str, TaskInfo] = {} self._asyncio_tasks: Dict[str, "asyncio.Task[None]"] = {} # request_id -> asyncio.Task self._cancel_events: Dict[str, asyncio.Event] = {} # request_id -> cancel Event self.max_age_seconds = max_age_seconds self.max_running = max_running self._poll_interval = 0.3 # workspace.json 轮询间隔(秒) # ───────────────────────────────────────────────────────────────── # 公共 API # ───────────────────────────────────────────────────────────────── def register(self, request_id: str) -> tuple[bool, str]: """注册一个 pending 任务。返回 (True, "") 成功,(False, reason) 容量满。""" running = [t for t in self._tasks.values() if t.state == STATE_RUNNING] if len(running) >= self.max_running: return False, f"同时运行任务已达上限 {self.max_running},请稍后重试" if request_id in self._tasks: return False, f"任务 {request_id} 已存在" self._tasks[request_id] = TaskInfo( request_id=request_id, state=STATE_PENDING, started_at=time.time(), ) return True, "" def get(self, request_id: str) -> Optional[TaskInfo]: """查询任务状态。""" return self._tasks.get(request_id) def list_all(self) -> Dict[str, TaskInfo]: """列出所有任务(含已完成)。""" return dict(self._tasks) def list_running(self) -> Dict[str, TaskInfo]: """只列出运行中任务。""" return {k: v for k, v in self._tasks.items() if v.state == STATE_RUNNING} def submit( self, request_id: str, coro, # type: asyncio.coroutine ) -> "asyncio.Task[None]": """提交协程到后台运行;内部注册 asyncio.Task。""" task = asyncio.create_task(self._run_wrapper(request_id, coro)) self._asyncio_tasks[request_id] = task return task def new_cancel(self, request_id: str) -> asyncio.Event: """为 SSE 连接创建一个取消事件。""" evt = asyncio.Event() self._cancel_events[request_id] = evt return evt def get_cancel(self, request_id: str) -> Optional[asyncio.Event]: """获取已有取消事件。""" return self._cancel_events.get(request_id) # ───────────────────────────────────────────────────────────────── # 内部:任务运行包装 # ───────────────────────────────────────────────────────────────── async def _run_wrapper(self, request_id: str, coro): """把用户协程包装成可追踪的任务:更新状态、捕获异常、清理。""" info = self._tasks.get(request_id) if info is None: return info.state = STATE_RUNNING try: await coro except Exception as exc: # pragma: no cover if info: info.state = STATE_FAILED info.finished_at = time.time() info.error = f"unhandled:{type(exc).__name__}:{exc}" finally: # 清理 asyncio task 引用 self._asyncio_tasks.pop(request_id, None) # 定期 GC 已完成任务 self._cleanup_aged() def _task_done( self, request_id: str, result: "PipelineResult", # from pipeline.PipelineResult ) -> None: """任务正常完成时由调用方调用,写入结果。""" info = self._tasks.get(request_id) if info is None: return info.state = STATE_DONE info.finished_at = time.time() info.response = result.response info.status = result.status info.fast_path = result.fast_path info.rounds_used = result.rounds_used info.api_input_tokens = result.api_input_tokens info.api_output_tokens = result.api_output_tokens info.cost_est = result.cost_est info.model_used = result.model_used info.latency_ms = result.latency_ms info.workspace_path = result.workspace_path info.route = list(result.route) if result.route else [] if result.error: info.error_detail = result.error def _task_failed( self, request_id: str, error: str, detail: Optional[str] = None, ) -> None: """任务异常结束时调用。""" info = self._tasks.get(request_id) if info is None: return info.state = STATE_FAILED info.finished_at = time.time() info.error = error info.error_detail = detail # ───────────────────────────────────────────────────────────────── # SSE 专用:workspace.json 文件轮询 # ───────────────────────────────────────────────────────────────── async def watch_workspace( self, request_id: str, cancel_event: asyncio.Event, ) -> AsyncGenerator[Dict[str, Any], None]: """监视 runs/{id}/workspace.json,yield 单条事件。 事件类型: - {"type": "status", "value": , "request_id": } - {"type": "workspace", "version": , "workspace": } - {"type": "error", "detail": } 当任务进入 done/failed 状态或 cancel_event 被 set 时停止。 """ ws_path = RUNS_DIR / request_id / "workspace.json" seen_mtime: float = 0.0 seen_size: int = 0 while not cancel_event.is_set(): info = self.get(request_id) # 检查终态 if info and info.state in (STATE_DONE, STATE_FAILED): if info.state == STATE_FAILED: yield {"type": "error", "detail": info.error or "任务失败"} # result 由 /runs/{id}/status 提供,此处只推送终态 status yield {"type": "status", "value": info.state, "request_id": request_id} break # 读文件变化(mtime + size 双检) if ws_path.exists(): try: stat = ws_path.stat() if stat.st_mtime != seen_mtime or stat.st_size != seen_size: raw = ws_path.read_text(encoding="utf-8") data = json.loads(raw) version = int(data.get("meta", {}).get("round", 0)) seen_mtime = stat.st_mtime seen_size = stat.st_size yield { "type": "workspace", "version": version, "state": info.state if info else STATE_RUNNING, "request_id": request_id, "workspace": data, } except (json.JSONDecodeError, OSError): pass # 文件正在写入,忽略 await asyncio.sleep(self._poll_interval) # 连接关闭前最后推一次终态 info = self.get(request_id) if info: yield {"type": "status", "value": info.state, "request_id": request_id} # ───────────────────────────────────────────────────────────────── # 内存清理 # ───────────────────────────────────────────────────────────────── def _cleanup_aged(self) -> None: """删除超过 max_age_seconds 的已完成任务引用。""" now = time.time() to_remove = [ rid for rid, info in self._tasks.items() if info.state in (STATE_DONE, STATE_FAILED) and (now - info.finished_at) > self.max_age_seconds ] for rid in to_remove: self._tasks.pop(rid, None) def cancel(self, request_id: str) -> bool: """取消运行中的任务。返回 True 找到并取消,False 未找到。""" task = self._asyncio_tasks.get(request_id) if task is None: return False task.cancel() info = self.get(request_id) if info: info.state = STATE_FAILED info.finished_at = time.time() info.error = "cancelled_by_user" return True # ───────────────────────────────────────────────────────────────────────────── # 全局单例(gateway 进程内共享) # ───────────────────────────────────────────────────────────────────────────── _store: Optional[JobStore] = None def get_job_store() -> JobStore: global _store if _store is None: _store = JobStore(max_age_seconds=3600.0, max_running=50) return _store def reset_job_store() -> None: """测试用:重置全局注册表。""" global _store _store = None