F2: persist native job projections and expose task controls
This commit is contained in:
@@ -49,6 +49,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from routes import (
|
||||
assistants,
|
||||
background_tasks,
|
||||
auth,
|
||||
conversations,
|
||||
health,
|
||||
@@ -80,6 +81,8 @@ async def lifespan(_app: FastAPI):
|
||||
await recover_interrupted_webhooks()
|
||||
await recover_interrupted_test_runs()
|
||||
await recover_interrupted_responses()
|
||||
from services.background_tasks.service import task_service
|
||||
await task_service.start()
|
||||
analysis_worker = asyncio.create_task(
|
||||
run_analysis_worker(), name="post-call-analysis-worker"
|
||||
)
|
||||
@@ -89,6 +92,7 @@ async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await task_service.close()
|
||||
analysis_worker.cancel()
|
||||
webhook_worker.cancel()
|
||||
await asyncio.gather(
|
||||
@@ -193,6 +197,7 @@ app.include_router(test_cases.router)
|
||||
app.include_router(test_assets.router)
|
||||
app.include_router(test_runs.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(background_tasks.router)
|
||||
app.include_router(voice_webrtc.router)
|
||||
app.include_router(voice_ws.router)
|
||||
|
||||
|
||||
+32
-1
@@ -248,7 +248,7 @@ class Tool(Base):
|
||||
name="uq_tools_mcp_server_remote_name",
|
||||
),
|
||||
CheckConstraint(
|
||||
"type IN ('http', 'mcp', 'client')",
|
||||
"type IN ('http', 'mcp', 'client', 'task')",
|
||||
name="ck_tools_external_type",
|
||||
),
|
||||
)
|
||||
@@ -647,3 +647,34 @@ class TestRunCase(Base):
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class BackgroundTask(Base):
|
||||
"""A durable view of a Pipecat Job and its external execution handle."""
|
||||
__tablename__ = "background_tasks"
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
assistant_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
conversation_id: Mapped[str] = mapped_column(String(80), index=True)
|
||||
tool_id: Mapped[str] = mapped_column(String(40))
|
||||
instruction: Mapped[str] = mapped_column(Text)
|
||||
origin_key: Mapped[str] = mapped_column(String(256), unique=True)
|
||||
connection: Mapped[dict] = mapped_column(JSONB)
|
||||
secrets: Mapped[dict] = mapped_column(JSONB)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
result: Mapped[str] = mapped_column(Text)
|
||||
remote_handle: Mapped[dict] = mapped_column(JSONB)
|
||||
input_request: Mapped[dict] = mapped_column(JSONB)
|
||||
job_id: Mapped[str] = mapped_column(String(80))
|
||||
sequence: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[str] = mapped_column(String(40))
|
||||
updated_at: Mapped[str] = mapped_column(String(40))
|
||||
|
||||
|
||||
class BackgroundTaskEvent(Base):
|
||||
__tablename__ = "background_task_events"
|
||||
__table_args__ = (UniqueConstraint("task_id", "sequence"),)
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(40), ForeignKey("background_tasks.id", ondelete="CASCADE"), index=True)
|
||||
sequence: Mapped[int] = mapped_column(Integer)
|
||||
data: Mapped[dict] = mapped_column(JSONB)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add durable projections for native Pipecat background Jobs."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "20261023_0026"
|
||||
down_revision = "20260907_0025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.drop_constraint("ck_tools_external_type", "tools", type_="check")
|
||||
op.create_check_constraint("ck_tools_external_type", "tools", "type IN ('http', 'mcp', 'client', 'task')")
|
||||
op.create_table(
|
||||
"background_tasks",
|
||||
sa.Column("id", sa.String(40), primary_key=True),
|
||||
sa.Column("assistant_id", sa.String(40), nullable=False),
|
||||
sa.Column("conversation_id", sa.String(80), nullable=False),
|
||||
sa.Column("tool_id", sa.String(40), nullable=False),
|
||||
sa.Column("instruction", sa.Text(), nullable=False),
|
||||
sa.Column("origin_key", sa.String(256), nullable=False, unique=True),
|
||||
*[sa.Column(name, postgresql.JSONB(), nullable=False) for name in ("connection", "secrets", "remote_handle", "input_request")],
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("result", sa.Text(), nullable=False),
|
||||
sa.Column("job_id", sa.String(80), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.String(40), nullable=False),
|
||||
sa.Column("updated_at", sa.String(40), nullable=False),
|
||||
)
|
||||
for name in ("assistant_id", "conversation_id", "status"):
|
||||
op.create_index(f"ix_background_tasks_{name}", "background_tasks", [name])
|
||||
op.create_table(
|
||||
"background_task_events",
|
||||
sa.Column("id", sa.String(40), primary_key=True),
|
||||
sa.Column("task_id", sa.String(40), sa.ForeignKey("background_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("data", postgresql.JSONB(), nullable=False),
|
||||
sa.UniqueConstraint("task_id", "sequence"),
|
||||
)
|
||||
op.create_index("ix_background_task_events_task_id", "background_task_events", ["task_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint("ck_tools_external_type", "tools", type_="check")
|
||||
op.create_check_constraint("ck_tools_external_type", "tools", "type IN ('http', 'mcp', 'client')")
|
||||
op.drop_table("background_task_events")
|
||||
op.drop_table("background_tasks")
|
||||
@@ -242,6 +242,11 @@ async def _validate_tool_bindings(
|
||||
missing = sorted(tool_ids - set(tools))
|
||||
if missing:
|
||||
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
|
||||
task_tools = [tool for tool in tools.values() if tool.type == "task"]
|
||||
if task_tools and (body.type != "prompt" or body.runtime_mode != "pipeline"):
|
||||
raise HTTPException(400, "后台任务工具仅支持提示词 Pipeline")
|
||||
if len(task_tools) > 1:
|
||||
raise HTTPException(400, "每个助手最多绑定一个后台任务工具")
|
||||
reserved = sorted(
|
||||
tool.function_name
|
||||
for tool in tools.values()
|
||||
@@ -282,6 +287,8 @@ async def _validate_tool_bindings(
|
||||
type=tool.type,
|
||||
definition=tool.definition or {},
|
||||
)
|
||||
if tool.type == "task" and binding.result_assignments:
|
||||
raise HTTPException(400, "后台任务结果通过任务通知返回,不支持结果变量映射")
|
||||
properties, required = ToolExecutor.input_schema(runtime_tool)
|
||||
unknown = sorted(set(binding.argument_bindings) - set(properties))
|
||||
if unknown:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Administrator task projections and controls, backed by native Jobs."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import Field
|
||||
from schemas import CamelModel
|
||||
from db.session import get_session
|
||||
from services.auth import require_admin
|
||||
from services.background_tasks.service import task_service
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
|
||||
router = APIRouter(prefix="/api/background-tasks", tags=["background-tasks"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class SubmitTask(CamelModel):
|
||||
assistant_id: str
|
||||
tool_id: str
|
||||
instruction: str = Field(min_length=1, max_length=12000)
|
||||
request_id: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class TaskInput(CamelModel):
|
||||
reply: str | None = None
|
||||
answers: list[list[str]] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(assistant_id: str):
|
||||
return [record.public() for record in await task_service.repository.list(assistant_id)]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_task(body: SubmitTask, db=Depends(get_session)):
|
||||
cfg = await resolve_runtime_config(db, body.assistant_id)
|
||||
if cfg.type != "prompt" or cfg.runtimeMode != "pipeline":
|
||||
raise HTTPException(400, "后台任务仅支持提示词 Pipeline")
|
||||
bound = {binding.tool_id for binding in cfg.tool_bindings}
|
||||
tool = next((tool for tool in cfg.tools if tool.id == body.tool_id and tool.id in bound and tool.type == "task"), None)
|
||||
if not tool:
|
||||
raise HTTPException(400, "助手未绑定该任务工具")
|
||||
try:
|
||||
return await task_service.submit(assistant_id=body.assistant_id, conversation_id="web_" + body.assistant_id, tool=tool, instruction=body.instruction, invocation_id=body.request_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
try:
|
||||
return (await task_service.get(task_id)).public()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str):
|
||||
try:
|
||||
return await task_service.cancel(task_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{task_id}/input")
|
||||
async def send_input(task_id: str, body: TaskInput):
|
||||
try:
|
||||
return await task_service.send_input(task_id, body.model_dump(exclude_none=True))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, "远端暂未接受回复,请稍后重试") from exc
|
||||
@@ -100,7 +100,7 @@ def _payload(
|
||||
_normalize_http_template_inputs(definition)
|
||||
secrets = (
|
||||
merge_secrets(body.secrets, stored_secrets or {})
|
||||
if definition["type"] == "http"
|
||||
if definition["type"] in {"http", "task"}
|
||||
else {}
|
||||
)
|
||||
secret_variables = (secrets.get("dynamic_variables") or {}) if secrets else {}
|
||||
@@ -154,7 +154,7 @@ async def list_tools(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Tool)
|
||||
.where(Tool.type.in_(("http", "mcp", "client")))
|
||||
.where(Tool.type.in_(("http", "mcp", "client", "task")))
|
||||
.order_by(Tool.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
+27
-1
@@ -21,7 +21,7 @@ AssistantType = Literal["prompt", "workflow", "dify", "fastgpt"]
|
||||
SUPPORTED_ASSISTANT_TYPES = get_args(AssistantType)
|
||||
TurnEndStrategy = Literal["silence", "smart_turn"]
|
||||
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
||||
ToolType = Literal["http", "mcp", "client"]
|
||||
ToolType = Literal["http", "mcp", "client", "task"]
|
||||
ToolStatus = Literal["active", "archived", "draft"]
|
||||
McpServerStatus = Literal["active", "archived", "draft"]
|
||||
McpTransport = Literal["streamable_http", "sse"]
|
||||
@@ -474,11 +474,37 @@ class McpToolDefinition(CamelModel):
|
||||
config: McpToolConfig
|
||||
|
||||
|
||||
class TaskToolConfig(CamelModel):
|
||||
provider: Literal["opencode"] = "opencode"
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
directory: str = Field(min_length=1, max_length=2048)
|
||||
username: str = Field(default="opencode", max_length=128)
|
||||
timeout_seconds: int = Field(default=600, ge=30, le=7200)
|
||||
allow_interruptions: Literal[True] = True
|
||||
execution_mode: Literal["immediate"] = "immediate"
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_task_url(cls, value: str) -> str:
|
||||
from urllib.parse import urlsplit
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
class TaskToolDefinition(CamelModel):
|
||||
schema_version: int = 2
|
||||
type: Literal["task"] = "task"
|
||||
config: TaskToolConfig
|
||||
|
||||
|
||||
ToolDefinition = Annotated[
|
||||
Union[
|
||||
HttpToolDefinition,
|
||||
ClientToolDefinition,
|
||||
McpToolDefinition,
|
||||
TaskToolDefinition,
|
||||
],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Persistent projections of native jobs; this is not another scheduler."""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from pydantic.alias_generators import to_camel
|
||||
from db.models import BackgroundTask, BackgroundTaskEvent
|
||||
from db.session import SessionLocal
|
||||
|
||||
TERMINAL = {"succeeded", "failed", "cancelled"}
|
||||
ACTIVE = {"queued", "submitted", "running", "waiting_input", "cancel_requested", "unknown"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRecord:
|
||||
id: str
|
||||
assistant_id: str
|
||||
conversation_id: str
|
||||
tool_id: str
|
||||
instruction: str
|
||||
origin_key: str
|
||||
connection: dict
|
||||
secrets: dict
|
||||
status: str = "queued"
|
||||
summary: str = "任务已排队"
|
||||
result: str = ""
|
||||
remote_handle: dict = field(default_factory=dict)
|
||||
input_request: dict = field(default_factory=dict)
|
||||
job_id: str = ""
|
||||
sequence: int = 0
|
||||
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
def public(self):
|
||||
hidden = {"connection", "secrets", "origin_key", "remote_handle"}
|
||||
return {to_camel(key): value for key, value in asdict(self).items() if key not in hidden}
|
||||
|
||||
|
||||
def from_row(row) -> TaskRecord:
|
||||
return TaskRecord(**{key: getattr(row, key) for key in TaskRecord.__dataclass_fields__})
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
async def create(self, record: TaskRecord) -> TaskRecord:
|
||||
async with SessionLocal() as db:
|
||||
# All calls are serialized by the application entry point; unique
|
||||
# origin_key also protects against duplicate tool delivery.
|
||||
existing = (await db.execute(select(BackgroundTask).where(BackgroundTask.origin_key == record.origin_key))).scalar_one_or_none()
|
||||
if existing:
|
||||
return from_row(existing)
|
||||
db.add(BackgroundTask(**asdict(record)))
|
||||
await db.commit()
|
||||
return record
|
||||
|
||||
async def get(self, task_id: str) -> TaskRecord | None:
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(BackgroundTask, task_id)
|
||||
return from_row(row) if row else None
|
||||
|
||||
async def list(self, assistant_id: str | None = None, *, active=False) -> list[TaskRecord]:
|
||||
statement = select(BackgroundTask).order_by(BackgroundTask.created_at.desc())
|
||||
if not active:
|
||||
statement = statement.limit(100)
|
||||
if assistant_id:
|
||||
statement = statement.where(BackgroundTask.assistant_id == assistant_id)
|
||||
if active:
|
||||
statement = statement.where(BackgroundTask.status.in_(ACTIVE))
|
||||
async with SessionLocal() as db:
|
||||
return [from_row(row) for row in (await db.execute(statement)).scalars().all()]
|
||||
|
||||
async def patch(self, task_id: str, **changes) -> TaskRecord:
|
||||
async with SessionLocal() as db:
|
||||
row = (await db.execute(select(BackgroundTask).where(BackgroundTask.id == task_id).with_for_update())).scalar_one()
|
||||
if row.status in TERMINAL:
|
||||
return from_row(row)
|
||||
for key, value in changes.items():
|
||||
setattr(row, key, value)
|
||||
row.sequence += 1
|
||||
row.updated_at = datetime.now(UTC).isoformat()
|
||||
record = from_row(row)
|
||||
db.add(BackgroundTaskEvent(id="te_" + uuid4().hex, task_id=task_id, sequence=row.sequence, data=record.public()))
|
||||
await db.commit()
|
||||
return record
|
||||
@@ -0,0 +1,177 @@
|
||||
"""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()
|
||||
for record in await self.repository.list(active=True):
|
||||
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()
|
||||
|
||||
async def submit(self, *, assistant_id, conversation_id, tool, instruction, invocation_id):
|
||||
if not self.runtime:
|
||||
raise ValueError("后台任务服务尚未启动")
|
||||
text = instruction.strip()
|
||||
if not text or len(text) > 12000:
|
||||
raise ValueError("任务指令必须为 1-12000 个字符")
|
||||
if tool.type != "task":
|
||||
raise ValueError("请选择后台任务工具")
|
||||
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=tool.id, instruction=text,
|
||||
origin_key=f"{conversation_id}:{invocation_id}"[:256],
|
||||
connection=deepcopy(tool.definition["config"]), secrets=deepcopy(tool.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 _save(self, task_id, **changes):
|
||||
record = await self.repository.patch(task_id, **changes)
|
||||
for listener in tuple(self._listeners.get(record.conversation_id, ())):
|
||||
try:
|
||||
await listener(record.public())
|
||||
except Exception:
|
||||
logger.warning("任务通知未送达,结果仍可查询: {}", task_id)
|
||||
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)
|
||||
executor = self.executor_factory(record.connection, record.secrets)
|
||||
resume = bool(record.remote_handle)
|
||||
try:
|
||||
if not resume:
|
||||
handle = await executor.prepare()
|
||||
record = await self.repository.patch(task_id, remote_handle=handle)
|
||||
async def emit(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._save(task_id, summary=record.summary)
|
||||
|
||||
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._save(task_id, summary=record.summary)
|
||||
|
||||
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="unknown", 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 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()
|
||||
@@ -43,6 +43,8 @@ class ToolExecutor:
|
||||
@staticmethod
|
||||
def input_schema(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
|
||||
config = (tool.definition or {}).get("config") or {}
|
||||
if tool.type == "task":
|
||||
return {"instruction": {"type": "string", "description": "需要远程执行的完整任务要求"}}, ["instruction"]
|
||||
if tool.type == "mcp":
|
||||
schema = config.get("input_schema") or {}
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Native jobs + service lifecycle, with an explicit in-memory repository."""
|
||||
import asyncio
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.background_tasks.service import TaskService
|
||||
from services.background_tasks.records import TERMINAL
|
||||
from schemas import ToolUpsert
|
||||
from routes.tools import _payload
|
||||
|
||||
|
||||
class MemoryRepository:
|
||||
def __init__(self):
|
||||
self.records = {}
|
||||
async def create(self, record):
|
||||
old = next((r for r in self.records.values() if r.origin_key == record.origin_key), None)
|
||||
if old:
|
||||
return deepcopy(old)
|
||||
self.records[record.id] = deepcopy(record)
|
||||
return record
|
||||
async def get(self, task_id):
|
||||
return deepcopy(self.records.get(task_id))
|
||||
async def list(self, assistant_id=None, active=False):
|
||||
return [deepcopy(r) for r in self.records.values() if (not assistant_id or r.assistant_id == assistant_id) and (not active or r.status not in TERMINAL)]
|
||||
async def patch(self, task_id, **changes):
|
||||
record = self.records[task_id]
|
||||
if record.status not in TERMINAL:
|
||||
for key, value in changes.items():
|
||||
setattr(record, key, value)
|
||||
record.sequence += 1
|
||||
return deepcopy(record)
|
||||
|
||||
|
||||
class SimulatedExecutor:
|
||||
release = None
|
||||
calls = 0
|
||||
def __init__(self, config, secrets):
|
||||
pass
|
||||
async def prepare(self):
|
||||
return {"session_id": "ses_test", "message_id": "msg_test"}
|
||||
async def run(self, handle, instruction, emit, resume=False):
|
||||
type(self).calls += 1
|
||||
await emit({"status": "running", "summary": "正在执行测试"})
|
||||
await type(self).release.wait()
|
||||
return {"status": "succeeded", "summary": "完成", "result": "真实Job,模拟远端结果"}
|
||||
async def cancel(self, handle):
|
||||
return True
|
||||
|
||||
|
||||
class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
SimulatedExecutor.release = asyncio.Event()
|
||||
SimulatedExecutor.calls = 0
|
||||
self.service = TaskService(MemoryRepository(), SimulatedExecutor)
|
||||
await self.service.start()
|
||||
self.tool = SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={"password": "never-public"})
|
||||
async def asyncTearDown(self):
|
||||
await self.service.close()
|
||||
async def wait_status(self, task_id, expected):
|
||||
async with asyncio.timeout(3):
|
||||
while (await self.service.get(task_id)).status != expected:
|
||||
await asyncio.sleep(.005)
|
||||
async def submit(self, key="call_1"):
|
||||
return await self.service.submit(assistant_id="asst_1", conversation_id="conv_1", tool=self.tool, instruction="执行测试", invocation_id=key)
|
||||
async def test_idempotence_public_projection_and_completion(self):
|
||||
task = await self.submit()
|
||||
again = await self.submit()
|
||||
self.assertEqual(task["id"], again["id"])
|
||||
await self.wait_status(task["id"], "running")
|
||||
self.assertEqual(SimulatedExecutor.calls, 1)
|
||||
self.assertNotIn("never-public", str(task))
|
||||
SimulatedExecutor.release.set()
|
||||
await self.wait_status(task["id"], "succeeded")
|
||||
with self.assertRaises(ValueError):
|
||||
await self.service.get(task["id"], "another-assistant")
|
||||
async def test_cancel_requires_remote_confirmation(self):
|
||||
task = await self.submit()
|
||||
await self.wait_status(task["id"], "running")
|
||||
await self.service.cancel(task["id"])
|
||||
await self.wait_status(task["id"], "cancelled")
|
||||
async def test_shutdown_does_not_abort_remote_work(self):
|
||||
task = await self.submit()
|
||||
await self.wait_status(task["id"], "running")
|
||||
await self.service.close()
|
||||
self.assertEqual((await self.service.get(task["id"])).status, "running")
|
||||
def test_task_tool_preserves_masked_password(self):
|
||||
body = ToolUpsert(name="后台编程", functionName="coding_task", definition={"type": "task", "config": {"url": "https://remote.test", "directory": "/demo"}}, secrets={"password": "****"})
|
||||
data = _payload(body, {"password": "old-password"})
|
||||
self.assertEqual(data["secrets"]["password"], "old-password")
|
||||
@@ -15,3 +15,6 @@
|
||||
| A-010 | 补充输入 | 同一运行任务可发送文本;权限/问题交互显式等待管理员处理,不自动批准 | semantic | default |
|
||||
| A-011 | 验证边界 | 未提供真实远端服务,使用httpx MockTransport验证协议和真实Pipecat验证调度;真实联调待部署配置 | semantic | default |
|
||||
| A-012 | 外部编码变更 | 仅明确调用任务工具时会启动远端执行,不因查看页面自动执行 | semantic | default |
|
||||
|
||||
| A-013 | 任意运行中修改任务 | 本版支持远端待回答的问题/权限;任意实时 steer 延后,新增要求通过停止后重新提交 | semantic | default |
|
||||
| A-014 | 任务API字段 | 与现有API一致使用camelCase;执行器内部保持Python snake_case | interface | default |
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
|------|---------|------------------|------|--------|
|
||||
| F0 | 原生Job主线、断线独立与取消 | backend/.venv/bin/python -m unittest discover -s backend/tests -p test_background_task_native.py | MUST | passed |
|
||||
| F1 | OpenCode协议适配与状态关联 | cd backend && .venv/bin/python -m unittest tests.test_background_task_native tests.test_opencode_tasks | MUST | passed |
|
||||
| F2 | 持久记录、管理API、应用宿主 | cd backend && .venv/bin/python -m unittest tests.test_background_tasks | MUST | pending |
|
||||
| F2 | 持久记录、管理API、应用宿主 | cd backend && .venv/bin/python -m unittest tests.test_background_tasks | MUST | passed |
|
||||
| F3 | 提示词绑定、任务通知、网页 | cd frontend && npx tsc --noEmit | MUST | pending |
|
||||
| F4 | 回归、文档与假设审阅 | make test | MUST | pending |
|
||||
|
||||
## Run record
|
||||
- F0: PYTHONPATH=backend backend/.venv/bin/python -m unittest discover -s backend/tests -p test_background_task_native.py → exit 0; native-jobs-smoke.json。
|
||||
- F1: 原生 Job + httpx 模拟远端协议共 3 项测试通过;未调用真实模型。
|
||||
- F2: 累计 7 项原生Job、协议、服务生命周期与密钥保留测试通过。
|
||||
## Deferred
|
||||
- 真实远程 OpenCode + 模型联调:未提供服务地址/凭证。
|
||||
- 多进程分布式总线及通用插件发现:超出单进程 MVP。
|
||||
## Blockers
|
||||
- 开发数据库连接不可用(提升权限后仍 OSError);本次新增迁移区间 SQL 生成通过,未执行线上/本地数据库迁移。
|
||||
|
||||
Reference in New Issue
Block a user