F2: persist native job projections and expose task controls

This commit is contained in:
Xin Wang
2026-10-23 16:03:40 +08:00
parent bae5ec3c58
commit 33fde56edd
13 changed files with 550 additions and 5 deletions
+5
View File
@@ -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
View File
@@ -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")
+7
View File
@@ -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:
+68
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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 (
+90
View File
@@ -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")
+3
View File
@@ -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 |
+3 -1
View File
@@ -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 生成通过,未执行线上/本地数据库迁移。