Files
agent-desktop/src/pineagents/pawapp/task.py
T
Pine 96659759b0 feat: 智能体后端运行时(src/pineagents)
- Agent OS:agents 决策循环、runtime 编排、providers、governance/security/sandbox
- app 服务层(认证转发到 server-core)、channels 19 路频道、memory、plugins
2026-08-23 22:41:48 +08:00

208 lines
6.1 KiB
Python

# -*- coding: utf-8 -*-
"""TaskManager + SSEChannel — Long-running task infrastructure.
Enables ``ctx.ui.push()`` to send realtime events to the frontend,
and ``paw.api.task()`` to subscribe to those events via EventSource.
Backend flow:
task_id = await task_manager.create_task(app_id, handler, ctx, params)
# Inside handler: await ctx.ui.push("progress", {"step": 2})
Frontend flow:
const task = paw.api.task('/generate', { script });
task.on('progress', (data) => setProgress(data.step));
const result = await task.result;
HTTP endpoints (registered by pawapps router):
POST /api/pawapp/{app_id}/task → create task, returns {task_id}
GET /api/pawapp/{app_id}/task/{id}/stream → SSE event stream
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from typing import Any, AsyncIterator, Callable, Dict, Optional
logger = logging.getLogger(__name__)
class SSEChannel:
"""Async-safe Server-Sent Events channel.
Producers call ``send_event(data)``; consumers iterate with
``async for event in channel``.
"""
def __init__(self, max_buffer: int = 1000):
self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_buffer)
self._closed = False
async def send_event(self, data: Dict[str, Any]) -> None:
"""Send an event to the channel (non-blocking for producer)."""
if self._closed:
return
try:
self._queue.put_nowait(data)
except asyncio.QueueFull:
logger.warning("SSEChannel buffer full, dropping event")
def close(self) -> None:
"""Mark the channel as closed."""
self._closed = True
# Put a sentinel to unblock consumers
try:
self._queue.put_nowait(None)
except asyncio.QueueFull:
pass
@property
def is_closed(self) -> bool:
return self._closed
async def __aiter__(self) -> AsyncIterator[str]:
"""Yield SSE-formatted strings until channel is closed."""
while True:
# Check if channel is closed and queue is empty
if self._closed and self._queue.empty():
break
try:
event = await asyncio.wait_for(
self._queue.get(),
timeout=30.0,
)
except asyncio.TimeoutError:
# Send keepalive comment
yield ": keepalive\n\n"
continue
if event is None:
# Channel closed sentinel
break
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
class TaskRecord:
"""Internal record for a running task."""
def __init__(
self,
task_id: str,
app_id: str,
channel: SSEChannel,
):
self.task_id = task_id
self.app_id = app_id
self.channel = channel
self.result: Any = None
self.error: Optional[str] = None
self.done = False
self.created_at: float = time.monotonic()
class TaskManager:
"""Manages long-running PawApp tasks with SSE push channels."""
def __init__(self):
self._tasks: Dict[str, TaskRecord] = {}
async def create_task(
self,
app_id: str,
handler: Callable,
ctx: Any,
params: Dict[str, Any],
) -> str:
"""Create a new long-running task.
The handler receives ``ctx`` (with SSE channel injected) and
``params``. Returns ``task_id`` for the frontend to subscribe.
"""
task_id = str(uuid.uuid4())
channel = SSEChannel()
record = TaskRecord(task_id=task_id, app_id=app_id, channel=channel)
self._tasks[task_id] = record
# Inject SSE channel into ctx so ctx.ui.push() works
# pylint: disable-next=protected-access
ctx._sse_channel = channel
async def _run():
try:
result = await handler(ctx, **params)
record.result = result
await channel.send_event({"type": "done", "data": result})
except Exception as exc:
record.error = str(exc)
await channel.send_event(
{
"type": "error",
"message": str(exc),
},
)
logger.error(
"PawApp task %s failed: %s",
task_id,
exc,
exc_info=True,
)
finally:
record.done = True
channel.close()
asyncio.create_task(_run())
return task_id
def get_task(self, task_id: str) -> Optional[TaskRecord]:
"""Get a task record by ID."""
return self._tasks.get(task_id)
async def stream(self, task_id: str) -> AsyncIterator[str]:
"""Yield SSE events for a task. Used by the streaming endpoint."""
record = self._tasks.get(task_id)
if record is None:
payload = json.dumps(
{"type": "error", "message": "Task not found"},
)
yield f"data: {payload}\n\n"
return
async for event_str in record.channel:
yield event_str
def cleanup_task(self, task_id: str) -> None:
"""Remove a completed task from memory."""
self._tasks.pop(task_id, None)
def cleanup_old_tasks(
self,
max_age_seconds: int = 3600,
) -> None:
"""Remove completed tasks older than *max_age_seconds*."""
now = time.monotonic()
to_remove = [
tid
for tid, rec in self._tasks.items()
if rec.done and (now - rec.created_at) > max_age_seconds
]
for tid in to_remove:
del self._tasks[tid]
# Module-level singleton
_task_manager: Optional[TaskManager] = None
def get_task_manager() -> TaskManager:
"""Get or create the global TaskManager singleton."""
global _task_manager
if _task_manager is None:
_task_manager = TaskManager()
return _task_manager