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
@@ -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()