F0: establish native Pipecat background job spine

This commit is contained in:
Xin Wang
2026-10-23 15:55:08 +08:00
parent d45be1e686
commit 82d479f9e1
7 changed files with 186 additions and 0 deletions
@@ -0,0 +1 @@
"""Prompt background tasks built on Pipecat's native Worker/Jobs."""
@@ -0,0 +1,83 @@
"""A long-lived owner for native Pipecat jobs, independent of media calls."""
import asyncio
from collections.abc import Awaitable, Callable
from pipecat.pipeline.job_context import JobParams, JobStatus
from pipecat.pipeline.job_decorator import job
from pipecat.workers.base_worker import BaseWorker
from pipecat.workers.runner import WorkerRunner
JobCallback = Callable[[object], Awaitable[None]]
Execute = Callable[[object, BaseWorker], Awaitable[dict]]
class CoordinatorWorker(BaseWorker):
def __init__(self, on_update: JobCallback, on_result: JobCallback):
super().__init__("background-task-coordinator")
self._update = on_update
self._result = on_result
async def on_job_update(self, message):
await self._update(message)
async def on_job_response(self, message):
await self._result(message)
class RemoteAgentWorker(BaseWorker):
def __init__(self, execute: Execute, cancel: JobCallback):
super().__init__("background-task-executor")
self._execute = execute
self._cancel = cancel
@job(name="execute")
async def execute(self, message):
try:
result = await self._execute(message, self)
except asyncio.CancelledError:
raise
except Exception:
# Error details belong in the repository; don't publish credentials.
await self.send_job_response(message.job_id, status=JobStatus.ERROR)
else:
await self.send_job_response(message.job_id, result)
async def on_job_cancelled(self, message):
await self._cancel(message)
class NativeTaskRuntime:
"""Only wiring: scheduling, deadlines and cancellation belong to Pipecat."""
def __init__(self, execute: Execute, cancel: JobCallback,
on_update: JobCallback, on_result: JobCallback):
self.coordinator = CoordinatorWorker(on_update, on_result)
self.executor = RemoteAgentWorker(execute, cancel)
self.runner: WorkerRunner | None = None
self._run: asyncio.Task | None = None
async def start(self):
self.runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
ready = asyncio.Event()
async def on_ready(_runner):
ready.set()
self.runner.add_event_handler("on_ready", on_ready)
await self.runner.add_workers(self.coordinator, self.executor)
self._run = asyncio.create_task(self.runner.run(auto_end=False), name="background-jobs")
await asyncio.wait_for(ready.wait(), 10)
async def submit(self, task_id: str, timeout: float) -> str:
return await self.coordinator.request_job(
self.executor.name,
params=JobParams(name="execute", payload={"task_id": task_id}, timeout=timeout),
)
async def cancel(self, job_id: str):
return await self.coordinator.request_cancel_job_group(job_id, reason="user requested stop")
async def close(self):
if self.runner:
await self.runner.cancel(reason="application shutdown")
if self._run:
await self._run
@@ -0,0 +1,59 @@
"""Real Pipecat scheduling with an explicitly simulated remote executor."""
import asyncio
import json
import unittest
from pathlib import Path
from pipecat.frames.frames import CancelFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineWorker
from pipecat.processors.frame_processor import FrameProcessor
from services.background_tasks.native import NativeTaskRuntime
class Passthrough(FrameProcessor):
async def process_frame(self, frame, direction):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
class NativeJobTest(unittest.IsolatedAsyncioTestCase):
async def test_progress_completion_and_media_disconnect(self):
progress, finished, release = asyncio.Event(), asyncio.Event(), asyncio.Event()
results = []
async def execute(message, worker):
# SIMULATED executor; no remote OpenCode or model is called here.
await worker.send_job_update(message.job_id, {"step": "working"})
await release.wait()
return {"result": "done"}
async def cancelled(message):
pass
async def update(message):
progress.set()
async def result(message):
results.append(message)
finished.set()
runtime = NativeTaskRuntime(execute, cancelled, update, result)
await runtime.start()
try:
job_id = await runtime.submit("test-task", 5)
await asyncio.wait_for(progress.wait(), 2)
self.assertFalse(finished.is_set())
media = PipelineWorker(Pipeline([Passthrough()]), enable_rtvi=False)
await runtime.runner.add_workers(media)
await media.queue_frame(CancelFrame(reason="client_disconnected"))
await asyncio.wait_for(media.wait(), 2)
self.assertIn(job_id, runtime.executor.active_jobs)
release.set()
await asyncio.wait_for(finished.wait(), 2)
self.assertEqual(results[0].status, "completed")
self.assertEqual(results[0].response, {"result": "done"})
artifact = Path(__file__).resolve().parents[2] / "implement-stage/native-jobs-smoke.json"
artifact.write_text(json.dumps({"simulated_executor": True, "native_job": "completed", "survives_media_disconnect": True}) + "\n")
finally:
await runtime.close()