Files
ai-video-fullstack/backend/services/background_tasks/service.py
T

229 lines
10 KiB
Python

"""Business entry point backed by native Pipecat Jobs."""
import asyncio
from collections import defaultdict
from copy import deepcopy
from uuid import uuid4
from loguru import logger
from services.background_tasks.native import NativeTaskRuntime
from services.background_tasks.opencode import OpenCodeExecutor
from services.background_tasks.records import TaskRecord, TaskRepository, TERMINAL
class TaskService:
def __init__(self, repository=None, executor_factory=OpenCodeExecutor):
self.repository = repository or TaskRepository()
self.executor_factory = executor_factory
self.runtime = None
self._listeners = defaultdict(set)
self._jobs = {}
self._submit_lock = asyncio.Lock()
self._closing = False
async def start(self):
self._closing = False
self.runtime = NativeTaskRuntime(self._execute, self._cancel_remote, self._on_update, self._on_result)
await self.runtime.start()
try:
records = await self.repository.list(active=True)
except Exception:
await self.close()
raise
for record in records:
if record.remote_handle:
await self._dispatch(record)
else:
await self.repository.patch(record.id, status="unknown", summary="服务曾中断,未自动重新提交;请核查远端后再创建任务")
async def close(self):
self._closing = True
if self.runtime:
await self.runtime.close()
self.runtime = None
self._listeners.clear()
self._jobs.clear()
async def submit(self, *, assistant_id, conversation_id, instruction, invocation_id, backend=None, tool=None):
if not self.runtime:
raise ValueError("后台任务服务尚未启动")
if not assistant_id or not conversation_id:
raise ValueError("后台任务需要已保存的助手和会话标识")
text = instruction.strip()
if not text or len(text) > 12000:
raise ValueError("任务指令必须为 1-12000 个字符")
if backend is not None:
if backend.type != "opencode":
raise ValueError("该任务后端尚未接通")
backend_id = backend.id
connection = deepcopy(backend.config)
connection["_backend_type"] = backend.type
secrets = deepcopy(backend.secrets)
else:
# F0 transition: retired when saved Tool bindings migrate in F1.
if tool is None or tool.type != "task":
raise ValueError("请选择任务后端")
backend_id, connection, secrets = tool.id, deepcopy(tool.definition["config"]), deepcopy(tool.secrets)
async with self._submit_lock:
record = await self.repository.create(TaskRecord(
id="task_" + uuid4().hex,
assistant_id=assistant_id, conversation_id=conversation_id,
tool_id=backend_id, instruction=text,
origin_key=f"{conversation_id}:{invocation_id}"[:256],
connection=connection, secrets=secrets,
))
if not record.job_id and record.status == "queued":
await self._dispatch(record)
return record.public()
async def _dispatch(self, record):
job_id = await self.runtime.submit(record.id, record.connection.get("timeout_seconds", 600))
self._jobs[job_id] = record.id
await self.repository.patch(record.id, job_id=job_id)
async def get(self, task_id, assistant_id=None):
record = await self.repository.get(task_id)
if not record or (assistant_id and record.assistant_id != assistant_id):
raise ValueError("任务不存在或不属于当前助手")
return record
def subscribe(self, conversation_id, listener):
self._listeners[conversation_id].add(listener)
def detach():
self._listeners[conversation_id].discard(listener)
if not self._listeners[conversation_id]:
self._listeners.pop(conversation_id, None)
return detach
async def _publish(self, record):
for listener in tuple(self._listeners.get(record.conversation_id, ())):
try:
await listener(record.public())
except Exception:
logger.warning("任务通知未送达,结果仍可查询: {}", record.id)
async def _save(self, task_id, **changes):
record = await self.repository.patch(task_id, **changes)
await self._publish(record)
return record
async def _execute(self, message, worker):
task_id = message.payload["task_id"]
self._jobs[message.job_id] = task_id
record = await self.get(task_id)
if record.status == "cancel_requested":
await self._stop_record(record)
return {"task_id": task_id}
executor = self.executor_factory(record.connection, record.secrets)
resume = bool(record.remote_handle)
try:
if not resume:
outstanding = await self.repository.list(active=True)
if any(other.id != task_id and other.remote_handle for other in outstanding):
await self.repository.patch(task_id, status="failed", summary="另一远端任务尚未结束,请先核查或停止该任务;本任务未提交到远端")
return {"task_id": task_id}
handle = await executor.prepare()
record = await self.repository.patch(task_id, remote_handle=handle)
last_event = None
async def emit(event):
nonlocal last_event
if event == last_event:
return
last_event = deepcopy(event)
await self.repository.patch(task_id, **event)
await worker.send_job_update(message.job_id, {"task_id": task_id})
# Reconnect only observations; never repeat a mutating prompt.
for attempt in range(5):
try:
result = await executor.run(record.remote_handle, record.instruction, emit, resume=resume)
await self.repository.patch(task_id, **result, input_request={})
return {"task_id": task_id}
except asyncio.CancelledError:
raise
except Exception:
resume = True
await emit({"summary": "远端连接中断,正在核查任务状态"})
if attempt == 4:
raise
await asyncio.sleep(min(2 ** attempt, 10))
except asyncio.CancelledError:
raise
except Exception:
await self.repository.patch(task_id, status="unknown", summary="无法确认远端执行状态,请检查远端服务;不会自动重复提交")
return {"task_id": task_id}
async def _on_update(self, message):
task_id = (message.update or {}).get("task_id")
if task_id:
record = await self.get(task_id)
await self._publish(record)
async def _on_result(self, message):
if self._closing:
return
task_id = self._jobs.pop(message.job_id, None)
if not task_id:
return
record = await self.get(task_id)
if record.status not in TERMINAL and str(message.status) in {"error", "failed"}:
await self._save(task_id, status="unknown", summary="任务运行中断,请核查远端状态")
else:
await self._publish(record)
async def cancel(self, task_id, assistant_id=None):
record = await self.get(task_id, assistant_id)
if record.status in TERMINAL:
return record.public()
await self._save(task_id, status="cancel_requested", summary="正在请求远端停止")
accepted = bool(self.runtime and record.job_id and await self.runtime.cancel(record.job_id))
if not accepted:
await self._stop_record(record)
return (await self.get(task_id)).public()
async def _cancel_remote(self, message):
task_id = self._jobs.get(message.job_id)
if not task_id or self._closing:
return
await self._stop_record(await self.get(task_id))
async def _stop_record(self, record):
if record.status in TERMINAL:
return
if not record.remote_handle:
await self._save(record.id, status="cancelled", summary="已取消,尚未向远端提交指令")
return
try:
stopped = await self.executor_factory(record.connection, record.secrets).cancel(record.remote_handle)
except Exception:
stopped = False
await self._save(record.id, status="cancelled" if stopped else "cancel_requested", summary="远端已停止" if stopped else "停止尚未确认,请检查远端服务")
async def reconcile(self, task_id):
record = await self.get(task_id)
if not record.remote_handle or record.status in TERMINAL:
return record.public()
executor = self.executor_factory(record.connection, record.secrets)
if record.status == "cancel_requested":
await self._stop_record(record)
else:
snapshot = await executor.snapshot(record.remote_handle)
# Session status does not describe pending questions/permissions.
# Keep the saved request visible until a reply or terminal result.
if snapshot["status"] == "running" and record.input_request:
snapshot.update(status="waiting_input", summary="远端等待确认或补充信息")
await self._save(task_id, **snapshot)
if snapshot["status"] not in TERMINAL and self.runtime and record.job_id not in self.runtime.coordinator.job_groups:
await self._dispatch(await self.get(task_id))
return (await self.get(task_id)).public()
async def send_input(self, task_id, value, assistant_id=None):
record = await self.get(task_id, assistant_id)
if record.status != "waiting_input" or not record.input_request:
raise ValueError("任务当前没有待处理的确认或问题")
await self.executor_factory(record.connection, record.secrets).send_input(record.input_request, value)
await self._save(task_id, status="running", summary="已发送回复,等待远端继续", input_request={})
return (await self.get(task_id)).public()
task_service = TaskService()