F0: establish native Pipecat background job spine
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user