F4: verify task recovery and document Prompt background jobs

This commit is contained in:
Xin Wang
2026-10-23 16:27:00 +08:00
parent 9a4d48a86e
commit 8ac75a1873
14 changed files with 654 additions and 46 deletions
+2
View File
@@ -61,3 +61,5 @@ make contract-check
```
开发计划中的生产可观测性、并发保护和可靠性工作尚未全部完成,见[单租户生产化计划](../docs-developer/development-plan.mdx)。
提示词 Pipeline 的远程编程任务接入与迁移步骤见 [后台任务说明](../docs-developer/prompt-background-tasks.mdx)。使用 Pipecat 原生 Jobs,需先迁移数据库并配置已有的远程 OpenCode 服务。
+401 -3
View File
@@ -5082,6 +5082,148 @@
"title": "StateUpdateRule",
"type": "object"
},
"SubmitTask": {
"properties": {
"assistantId": {
"title": "Assistantid",
"type": "string"
},
"instruction": {
"maxLength": 12000,
"minLength": 1,
"title": "Instruction",
"type": "string"
},
"requestId": {
"maxLength": 80,
"minLength": 1,
"title": "Requestid",
"type": "string"
},
"toolId": {
"title": "Toolid",
"type": "string"
}
},
"required": [
"assistantId",
"toolId",
"instruction",
"requestId"
],
"title": "SubmitTask",
"type": "object"
},
"TaskInput": {
"properties": {
"answers": {
"anyOf": [
{
"items": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Answers"
},
"reply": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reply"
}
},
"title": "TaskInput",
"type": "object"
},
"TaskToolConfig": {
"properties": {
"allowInterruptions": {
"const": true,
"default": true,
"title": "Allowinterruptions",
"type": "boolean"
},
"directory": {
"maxLength": 2048,
"minLength": 1,
"title": "Directory",
"type": "string"
},
"executionMode": {
"const": "immediate",
"default": "immediate",
"title": "Executionmode",
"type": "string"
},
"provider": {
"const": "opencode",
"default": "opencode",
"title": "Provider",
"type": "string"
},
"timeoutSeconds": {
"default": 600,
"maximum": 7200.0,
"minimum": 30.0,
"title": "Timeoutseconds",
"type": "integer"
},
"url": {
"maxLength": 2048,
"minLength": 1,
"title": "Url",
"type": "string"
},
"username": {
"default": "opencode",
"maxLength": 128,
"title": "Username",
"type": "string"
}
},
"required": [
"url",
"directory"
],
"title": "TaskToolConfig",
"type": "object"
},
"TaskToolDefinition": {
"properties": {
"config": {
"$ref": "#/components/schemas/TaskToolConfig"
},
"schemaVersion": {
"default": 2,
"title": "Schemaversion",
"type": "integer"
},
"type": {
"const": "task",
"default": "task",
"title": "Type",
"type": "string"
}
},
"required": [
"config"
],
"title": "TaskToolDefinition",
"type": "object"
},
"TemplateValueSource": {
"properties": {
"source": {
@@ -5648,7 +5790,8 @@
"mapping": {
"client": "#/components/schemas/ClientToolDefinition",
"http": "#/components/schemas/HttpToolDefinition",
"mcp": "#/components/schemas/McpToolDefinition"
"mcp": "#/components/schemas/McpToolDefinition",
"task": "#/components/schemas/TaskToolDefinition"
},
"propertyName": "type"
},
@@ -5661,6 +5804,9 @@
},
{
"$ref": "#/components/schemas/McpToolDefinition"
},
{
"$ref": "#/components/schemas/TaskToolDefinition"
}
],
"title": "Definition"
@@ -5727,7 +5873,8 @@
"enum": [
"http",
"mcp",
"client"
"client",
"task"
],
"title": "Type",
"type": "string"
@@ -5927,7 +6074,8 @@
"mapping": {
"client": "#/components/schemas/ClientToolDefinition",
"http": "#/components/schemas/HttpToolDefinition",
"mcp": "#/components/schemas/McpToolDefinition"
"mcp": "#/components/schemas/McpToolDefinition",
"task": "#/components/schemas/TaskToolDefinition"
},
"propertyName": "type"
},
@@ -5940,6 +6088,9 @@
},
{
"$ref": "#/components/schemas/McpToolDefinition"
},
{
"$ref": "#/components/schemas/TaskToolDefinition"
}
],
"title": "Definition"
@@ -6541,6 +6692,253 @@
]
}
},
"/api/background-tasks": {
"get": {
"operationId": "list_tasks_api_background_tasks_get",
"parameters": [
{
"in": "query",
"name": "assistant_id",
"required": true,
"schema": {
"title": "Assistant Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "List Tasks",
"tags": [
"background-tasks"
]
},
"post": {
"operationId": "submit_task_api_background_tasks_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmitTask"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Submit Task",
"tags": [
"background-tasks"
]
}
},
"/api/background-tasks/{task_id}": {
"get": {
"operationId": "get_task_api_background_tasks__task_id__get",
"parameters": [
{
"in": "path",
"name": "task_id",
"required": true,
"schema": {
"title": "Task Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Task",
"tags": [
"background-tasks"
]
}
},
"/api/background-tasks/{task_id}/cancel": {
"post": {
"operationId": "cancel_task_api_background_tasks__task_id__cancel_post",
"parameters": [
{
"in": "path",
"name": "task_id",
"required": true,
"schema": {
"title": "Task Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Cancel Task",
"tags": [
"background-tasks"
]
}
},
"/api/background-tasks/{task_id}/input": {
"post": {
"operationId": "send_input_api_background_tasks__task_id__input_post",
"parameters": [
{
"in": "path",
"name": "task_id",
"required": true,
"schema": {
"title": "Task Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskInput"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Send Input",
"tags": [
"background-tasks"
]
}
},
"/api/background-tasks/{task_id}/refresh": {
"post": {
"operationId": "refresh_task_api_background_tasks__task_id__refresh_post",
"parameters": [
{
"in": "path",
"name": "task_id",
"required": true,
"schema": {
"title": "Task Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Refresh Task",
"tags": [
"background-tasks"
]
}
},
"/api/conversations": {
"get": {
"operationId": "list_conversations_api_conversations_get",
+10
View File
@@ -66,3 +66,13 @@ async def send_input(task_id: str, body: TaskInput):
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
raise HTTPException(502, "远端暂未接受回复,请稍后重试") from exc
@router.post("/{task_id}/refresh")
async def refresh_task(task_id: str):
try:
return await task_service.reconcile(task_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
except Exception as exc:
raise HTTPException(502, "暂时无法核查远端状态") from exc
+1 -1
View File
@@ -31,7 +31,7 @@ class RemoteAgentWorker(BaseWorker):
self._execute = execute
self._cancel = cancel
@job(name="execute")
@job(name="execute", sequential=True)
async def execute(self, message):
try:
result = await self._execute(message, self)
+41 -7
View File
@@ -25,7 +25,12 @@ class TaskService:
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):
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:
@@ -37,10 +42,13 @@ class TaskService:
await self.runtime.close()
self.runtime = None
self._listeners.clear()
self._jobs.clear()
async def submit(self, *, assistant_id, conversation_id, tool, instruction, invocation_id):
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 个字符")
@@ -77,13 +85,16 @@ class TaskService:
self._listeners.pop(conversation_id, None)
return detach
async def _save(self, task_id, **changes):
record = await self.repository.patch(task_id, **changes)
async def _publish(self, record):
for listener in tuple(self._listeners.get(record.conversation_id, ())):
try:
await listener(record.public())
except Exception:
logger.warning("任务通知未送达,结果仍可查询: {}", task_id)
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):
@@ -96,7 +107,12 @@ class TaskService:
if not resume:
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.
@@ -123,7 +139,7 @@ class TaskService:
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)
await self._publish(record)
async def _on_result(self, message):
if self._closing:
@@ -135,7 +151,7 @@ class TaskService:
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)
await self._publish(record)
async def cancel(self, task_id, assistant_id=None):
record = await self.get(task_id, assistant_id)
@@ -157,7 +173,7 @@ class TaskService:
if record.status in TERMINAL:
return
if not record.remote_handle:
await self._save(record.id, status="unknown", summary="尚无远端句柄,无法确认是否已停止")
await self._save(record.id, status="cancelled", summary="已取消,尚未向远端提交指令")
return
try:
stopped = await self.executor_factory(record.connection, record.secrets).cancel(record.remote_handle)
@@ -165,6 +181,24 @@ class TaskService:
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:
+32 -29
View File
@@ -847,40 +847,43 @@ async def run_pipeline(
messages.insert(0, {"role": "system", "content": content})
set_visible_tools([])
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_visible_tools,
call_end=call_end,
session_id=cfg.conversation_id,
client_tools=client_tools,
worker=worker,
context_aggregator=WorkflowAggregatorPair(
user_aggregator,
assistant_aggregator,
),
transport=transport,
switch_services=service_controller.switch,
set_knowledge_scope=knowledge_retrieval.set_scope,
set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=workflow_vision_function,
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
apply_turn_config=apply_workflow_turn_config,
flow_global_functions=flow_global_functions,
),
)
try:
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_visible_tools,
call_end=call_end,
session_id=cfg.conversation_id,
client_tools=client_tools,
worker=worker,
context_aggregator=WorkflowAggregatorPair(
user_aggregator,
assistant_aggregator,
),
transport=transport,
switch_services=service_controller.switch,
set_knowledge_scope=knowledge_retrieval.set_scope,
set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=workflow_vision_function,
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
apply_turn_config=apply_workflow_turn_config,
flow_global_functions=flow_global_functions,
),
)
await brain.run_preflight()
except Exception:
# Preflight tools may assign variables used by the opening speech.
greeting = await brain.greeting(cfg)
except BaseException:
close = getattr(brain, "close", None)
if close:
await close()
if recorder:
await recorder.finish(status="failed")
raise
# Preflight tools may assign variables used by the opening speech.
greeting = await brain.greeting(cfg)
async def submit_user_input(value: UserInput) -> None:
if not value.has_image:
@@ -18,6 +18,26 @@ class Passthrough(FrameProcessor):
class NativeJobTest(unittest.IsolatedAsyncioTestCase):
async def test_native_timeout_calls_remote_cleanup(self):
cancelled = asyncio.Event()
async def execute(message, worker):
await asyncio.Event().wait()
async def cleanup(message):
cancelled.set()
async def ignore(message):
pass
runtime = NativeTaskRuntime(execute, cleanup, ignore, ignore)
await runtime.start()
try:
await runtime.submit("timeout-test", 0.05)
await asyncio.wait_for(cancelled.wait(), 2)
finally:
await runtime.close()
async def test_progress_completion_and_media_disconnect(self):
progress, finished, release = asyncio.Event(), asyncio.Event(), asyncio.Event()
results = []
+83 -1
View File
@@ -35,6 +35,7 @@ class MemoryRepository:
class SimulatedExecutor:
release = None
calls = 0
confirms_stop = True
def __init__(self, config, secrets):
pass
async def prepare(self):
@@ -45,13 +46,16 @@ class SimulatedExecutor:
await type(self).release.wait()
return {"status": "succeeded", "summary": "完成", "result": "真实Job,模拟远端结果"}
async def cancel(self, handle):
return True
return type(self).confirms_stop
async def snapshot(self, handle):
return {"status": "running", "summary": "远端仍在执行", "result": ""}
class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
SimulatedExecutor.release = asyncio.Event()
SimulatedExecutor.calls = 0
SimulatedExecutor.confirms_stop = True
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"})
@@ -84,7 +88,85 @@ class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
await self.wait_status(task["id"], "running")
await self.service.close()
self.assertEqual((await self.service.get(task["id"])).status, "running")
async def test_unconfirmed_remote_stop_is_not_reported_cancelled(self):
SimulatedExecutor.confirms_stop = False
task = await self.submit()
await self.wait_status(task["id"], "running")
await self.service.cancel(task["id"])
async with asyncio.timeout(2):
while self.service.runtime.executor.active_jobs:
await asyncio.sleep(.005)
self.assertEqual((await self.service.get(task["id"])).status, "cancel_requested")
async def test_queued_task_can_be_cancelled_without_remote_submission(self):
first = await self.submit("call_first")
await self.wait_status(first["id"], "running")
second = await self.submit("call_second")
await self.service.cancel(second["id"])
await self.wait_status(second["id"], "cancelled")
self.assertEqual(SimulatedExecutor.calls, 1)
async def test_restart_observes_existing_remote_handle(self):
task = await self.submit()
await self.wait_status(task["id"], "running")
handle = (await self.service.get(task["id"])).remote_handle
await self.service.close()
await self.service.start()
async with asyncio.timeout(2):
while SimulatedExecutor.calls < 2:
await asyncio.sleep(.005)
self.assertEqual((await self.service.get(task["id"])).remote_handle, handle)
SimulatedExecutor.release.set()
await self.wait_status(task["id"], "succeeded")
async def test_reconcile_preserves_a_pending_question(self):
task = await self.submit()
await self.wait_status(task["id"], "running")
question = {"kind": "question", "id": "que_1", "questions": [{"question": "使用哪个分支?"}]}
await self.service.repository.patch(task["id"], status="waiting_input", input_request=question)
result = await self.service.reconcile(task["id"])
self.assertEqual(result["status"], "waiting_input")
self.assertEqual(result["inputRequest"], question)
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")
class TaskApiTest(unittest.IsolatedAsyncioTestCase):
async def test_admin_auth_and_real_native_job_through_http_api(self):
import httpx
from fastapi import FastAPI
from unittest.mock import AsyncMock, patch
from routes import background_tasks as routes
from services.auth import require_admin
from db.session import get_session
SimulatedExecutor.release = asyncio.Event()
service = TaskService(MemoryRepository(), SimulatedExecutor)
await service.start()
app = FastAPI()
app.include_router(routes.router)
async def database():
yield None
app.dependency_overrides[get_session] = database
cfg = SimpleNamespace(type="prompt", runtimeMode="pipeline", tool_bindings=[SimpleNamespace(tool_id="tool_task")], tools=[SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={})])
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://platform.test") as client:
unauthenticated = await client.get("/api/background-tasks?assistant_id=asst_1")
self.assertEqual(unauthenticated.status_code, 401)
app.dependency_overrides[require_admin] = lambda: {"username": "test"}
with patch.object(routes, "task_service", service), patch.object(routes, "resolve_runtime_config", AsyncMock(return_value=cfg)):
response = await client.post("/api/background-tasks", json={"assistantId": "asst_1", "toolId": "tool_task", "instruction": "测试请求", "requestId": "request_1"})
self.assertEqual(response.status_code, 200)
task_id = response.json()["id"]
self.assertEqual(response.json()["assistantId"], "asst_1")
SimulatedExecutor.release.set()
async with asyncio.timeout(2):
while (await service.get(task_id)).status != "succeeded":
await asyncio.sleep(.005)
task = await client.get(f"/api/background-tasks/{task_id}")
self.assertEqual(task.json()["status"], "succeeded")
self.assertNotIn("secrets", task.json())
finally:
await service.close()
@@ -0,0 +1,50 @@
---
title: 提示词后台任务
description: 使用 Pipecat 原生 Jobs 委派远程 OpenCode 任务。
---
# 提示词后台任务 MVP
提示词 Pipeline 可以绑定一个「后台任务」工具。对话模型提交任务后立即获得任务编号,继续对话;应用级 Pipecat Worker 拥有长任务,语音断线不取消远端执行。OpenCode 是当前唯一实现的执行器。
## 使用
1. 更新后端依赖,并执行数据库迁移:`cd backend && .venv/bin/python -m alembic upgrade head`。迁移新增任务记录/事件表,并允许 task 工具类型。
2. 重启后端。第一版要求单个应用进程(一个 Uvicorn worker),使用原生 AsyncQueueBus。
3. 在远端准备好仓库、OpenCode 模型与服务。此功能连接已有 `opencode serve`,不会在远端安装软件或启动进程。服务应通过后端可访问的地址提供 HTTP(S),如开启 Basic 鉴权,请配置 `OPENCODE_SERVER_PASSWORD`。
4. 在「组件库 / 工具」新建「后台任务 · OpenCode」,填写服务地址、远端工作目录、用户名、服务密码和任务时限。服务地址/目录固定于配置,不由 LLM 参数决定。
5. 在提示词 Pipeline 助手的「外部工具」中选择该工具。默认 instruction 参数由模型提供,可配置固定值或助手变量。任务工具不支持最终结果映射到会话变量。
6. 保存助手,在手动语音调试中提出任务。展开「后台任务」查看进展、结果,处理远端问题/权限,或停止任务。网页刷新后仍可按助手查询任务。
前台新增三个控制函数:`get_background_task`、`cancel_background_task`、`send_background_task_message`。主提交函数使用工具资源的函数名。控制函数名由平台保留。单个远端问题可由对话工具回答;权限和多个问题请在网页卡片处理。不会自动批准权限。
## 运行与恢复
- NativeTaskRuntime 只组装 `WorkerRunner`、常驻 CoordinatorWorker 和 RemoteAgentWorker。派发、超时、取消和 FIFO 串行执行由 Pipecat 原生 Job 系统负责。
- 任务时限包含原生队列等待时间;本版所有 coding 任务串行执行,避免同时修改同一仓库。
- 提交回执表示平台接受任务。OpenCode 的 204 只表示远端接收;必须取得关联到本次 messageID 的完成消息才能显示成功。
- OpenCodeAdapter 负责创建远端 session、订阅 SSE、异步提交、结果查询和 abort。每个任务使用独立远端 session,内部步骤仍由 OpenCode 完成。
- 原生 Job 取消不代表远端已停止。适配器调用 abort 并查询远端状态;未确认时保留「停止待确认」。
- 连接中断会尝试重新观察,不重新发送原指令。无法核查时显示「状态待核查」,可显式核查或请求停止。
- 应用退出仅停止本地监听,不主动 abort 远端任务;重启后使用已保存的远端句柄重新观察。没有远端句柄的中断任务不会自动重新提交。
- 任务和远端连接配置保存在数据库;公开 API 不返回密码或连接快照。资源后续编辑不会改变已运行任务的地址/目录。
- 在线进度通过现有自定义 app message 传送,网页任务卡片另按助手轮询快照以支持刷新。完成/失败/等待输入通过 Pipecat 原生上下文消息触发前台模型回复;用户/助手正在说话时由原生聚合器安排。离线结果只保留在任务卡片,不自动在新语音连接中播报旧结果。
## 第一版边界
仅提示词 Pipeline 支持任务工具。Workflow、Realtime、启动 Action 不支持。自动测试/Responses 测试的模拟工具执行路径不会启动真实任务;请使用手动语音调试验收。运行中的任意实时 steer 尚未支持;需要修改目标时,先停止,再提交新的任务。
后端使用现有管理员鉴权;第一版沿用整个管理台的单管理员访问范围,尚无多租户隔离。持久化记录是业务历史和恢复依据,不是另一套任务调度器。
## 管理接口
- `GET /api/background-tasks?assistant_id=...`:最近100项任务。
- `GET /api/background-tasks/{id}`:任务快照。
- `POST /api/background-tasks`:提交已绑定工具的任务,携带 assistantId/toolId/instruction/requestId;重复 requestId 返回原任务。
- `POST /api/background-tasks/{id}/cancel`:请求停止。
- `POST /api/background-tasks/{id}/refresh`:核查远端并重新观察,不重发原任务。
- `POST /api/background-tasks/{id}/input`:回复待处理的 permission/question。
## 验证边界
测试覆盖真实 Pipecat Jobs 的派发/进度/断线独立/取消,以及显式模拟的 OpenCode HTTP/SSE 协议。不把模拟执行结果当作真实代码修改结果。当前开发环境没有可连接的数据库或远端 OpenCode 服务,因此数据库迁移生成检查与真实服务联调应在部署环境执行。
@@ -45,6 +45,7 @@ function TaskCard({ task, onChanged }: { task: BackgroundTask; onChanged: () =>
<Button size="sm" disabled={busy || !request.questions?.every((_, index) => answers[index]?.trim())} onClick={() => void act(() => backgroundTasksApi.input(task.id, { answers: request.questions?.map((question, index) => question.multiple ? answers[index].split(/[,,]/).map(value => value.trim()).filter(Boolean) : [answers[index].trim()]) }))}>发送回复</Button>
</div>}
{task.result && <details><summary className="cursor-pointer text-muted-foreground">查看结果</summary><pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-sans">{task.result}</pre></details>}
{task.status === "unknown" && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.refresh(task.id))}>核查远端状态</Button>}
{!terminal.has(task.status) && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.cancel(task.id))}>{task.status === "cancel_requested" ? "再次核查停止" : "停止任务"}</Button>}
{error && <p role="alert" className="text-destructive">{error}</p>}
</article>;
@@ -556,9 +556,10 @@ export function ToolBindingEditor({
() => tools.filter((tool) => tool.status === "active"),
[tools],
);
const selectedTaskId = value.find(binding => tools.find(tool => tool.id === binding.toolId)?.type === "task")?.toolId;
const pickerTools = useMemo(
() => (selectableTools ?? tools).filter((tool) => tool.status === "active" && (allowTask || tool.type !== "task")),
[selectableTools, tools, allowTask],
() => (selectableTools ?? tools).filter((tool) => tool.status === "active" && (allowTask || tool.type !== "task") && (tool.type !== "task" || !selectedTaskId || tool.id === selectedTaskId)),
[selectableTools, tools, allowTask, selectedTaskId],
);
const selectedIds = new Set(value.map((binding) => binding.toolId));
const variableNames = [...Object.keys(variables), ...SYSTEM_VARIABLE_NAMES];
+1
View File
@@ -1004,6 +1004,7 @@ export type BackgroundTask = {
};
};
export const backgroundTasksApi = {
refresh: (id: string) => request<BackgroundTask>(`/api/background-tasks/${encodeURIComponent(id)}/refresh`, { method: "POST" }),
list: (assistantId: string) => request<BackgroundTask[]>(`/api/background-tasks?assistant_id=${encodeURIComponent(assistantId)}`),
cancel: (id: string) => request<BackgroundTask>(`/api/background-tasks/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
input: (id: string, value: { reply?: string; answers?: string[][] }) => request<BackgroundTask>(`/api/background-tasks/${encodeURIComponent(id)}/input`, { method: "POST", body: JSON.stringify(value) }),
+4 -2
View File
@@ -12,9 +12,11 @@
| A-007 | 通知 | 进度仅网页;完成/失败在线时触发前台总结;离线结果保留供查询 | semantic | default |
| A-008 | 恢复 | 单进程;重启恢复观察有远端句柄的任务,不自动重新发送未知提交 | semantic | default |
| A-009 | 远端配置 | 地址/工作目录/Basic鉴权由管理员工具配置,任务冻结连接信息;LLM不能改地址和目录 | interface | default |
| A-010 | 补充输入 | 同一运行任务可发送文本;权限/问题交互显式等待管理员处理,不自动批准 | semantic | default |
| 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 |
| A-015 | 同一工作区并发修改 | 本版原生execute Job采用全局FIFO串行;排队也计入任务时限,避免同时改同一仓库 | semantic | default |
| A-016 | 状态未知后恢复 | 网页提供显式核查/重新观察;只重连已有远端句柄,绝不重复发送原任务 | semantic | default |
| A-017 | 自动测试边界 | task工具不出现在普通工具模拟列表;测试运行器中调用task只返回不支持,真实执行需手动语音调试 | semantic | default |
+5 -1
View File
@@ -6,13 +6,17 @@
| 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 | passed |
| F3 | 提示词绑定、任务通知、网页 | cd frontend && npx tsc --noEmit | MUST | passed |
| F4 | 回归、文档与假设审阅 | make test | MUST | pending |
| F4 | 回归、文档与假设审阅 | make test | MUST | checks passed; sweep 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、协议、服务生命周期与密钥保留测试通过。
- F3: 累计9项后端任务测试通过,TypeScript检查通过,前端lint无错误(BatchTestPage已有1条warning)。
- F4: `make test` → exit 0;360 项后端测试、4 项前端契约测试、5 项公开契约检查与生产构建通过。前端已有 BatchTestPage useEffect warning 1 条,无错误。
- F4: 初次回归发现公开契约快照需更新;随后构建受沙箱 Google Fonts 网络限制,联网重试通过;未修改字体设计。
- F4: 补充待回答问题的状态核查保留与原生超时清理检查;任务生命周期专项测试通过。假设审阅待执行。
- 新增迁移区间 SQL:`cd backend && .venv/bin/python -m alembic upgrade 20260907_0025:head --sql` → exit 0。旧历史迁移含数据库读取,未声称全历史离线生成通过。
## Deferred
- 真实远程 OpenCode + 模型联调:未提供服务地址/凭证。
- 多进程分布式总线及通用插件发现:超出单进程 MVP。