mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 09:38:41 +00:00
* feat(mcp): add durable task runtime foundation * fix(chart): sync embedded config version * fix(mcp): isolate task polls during shutdown * feat(mcp): track consecutive poll errors on mcp_tasks poll_attempt_count grows on every claim (successful polls included), so it cannot drive a failure backoff without misjudging normal long tasks. Add consecutive_poll_error_count: incremented when a claim is released after a poll error, reset to zero by any applied snapshot. The backoff/terminal policy that consumes it lands with the first concrete driver. * fix(mcp): harden durable task lifecycle * feat(mcp): add ordinary durable task driver * test(mcp): address durable task review feedback * fix(mcp): preserve submit tool descriptions * fix(mcp): bound remote task calls * fix(mcp): bound persisted task payloads * fix(mcp): preserve task tool error details * fix(mcp): enforce durable task boundaries * test(mcp): cover task config snapshot lifecycle --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import socket
|
|
import uuid
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from deerflow.constants import (
|
|
MCP_TASK_POLL_AFTER_MAX_SECONDS,
|
|
MCP_TASK_REMOTE_ID_MAX_LENGTH,
|
|
MCP_TASK_RESULT_ARTIFACT_MAX_BYTES,
|
|
)
|
|
from deerflow.mcp.tasks import (
|
|
McpTaskDriverRegistry,
|
|
McpTaskProtocolError,
|
|
TaskReference,
|
|
TaskSnapshot,
|
|
TaskStatus,
|
|
TaskSubmitRequest,
|
|
)
|
|
from deerflow.persistence.mcp_tasks import DuplicateMcpRemoteTaskError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_PERSISTED_ERROR_CHARS = 4_000
|
|
_MAX_INPUT_REQUIRED_BYTES = 65_536
|
|
|
|
|
|
def _bound_error(error: str | None) -> str | None:
|
|
if error is None:
|
|
return None
|
|
return error[:_MAX_PERSISTED_ERROR_CHARS]
|
|
|
|
|
|
class McpTaskService:
|
|
"""Persist and poll long-running MCP tasks outside the Agent loop."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
repository,
|
|
drivers: McpTaskDriverRegistry,
|
|
poll_interval_seconds: int,
|
|
lease_seconds: int,
|
|
max_concurrent_polls: int,
|
|
max_poll_backoff_seconds: int = 300,
|
|
input_required_poll_interval_seconds: int = 60,
|
|
tracking_degraded_after_errors: int = 3,
|
|
max_result_bytes: int = 65_536,
|
|
result_preview_max_chars: int = 2_000,
|
|
) -> None:
|
|
self._repository = repository
|
|
self._drivers = drivers
|
|
self._poll_interval_seconds = poll_interval_seconds
|
|
self._lease_seconds = lease_seconds
|
|
self._max_concurrent_polls = max_concurrent_polls
|
|
self._max_poll_backoff_seconds = max_poll_backoff_seconds
|
|
self._input_required_poll_interval_seconds = input_required_poll_interval_seconds
|
|
self._tracking_degraded_after_errors = tracking_degraded_after_errors
|
|
self._max_result_bytes = max_result_bytes
|
|
self._result_preview_max_chars = result_preview_max_chars
|
|
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
|
|
self._task: asyncio.Task[None] | None = None
|
|
self._stop = asyncio.Event()
|
|
|
|
@property
|
|
def drivers(self) -> McpTaskDriverRegistry:
|
|
return self._drivers
|
|
|
|
@property
|
|
def tracking_degraded_after_errors(self) -> int:
|
|
return self._tracking_degraded_after_errors
|
|
|
|
async def submit(
|
|
self,
|
|
*,
|
|
driver_name: str,
|
|
request: TaskSubmitRequest,
|
|
now: datetime | None = None,
|
|
) -> dict:
|
|
"""Submit through one driver and persist the remote handle before returning."""
|
|
driver = self._drivers.get(driver_name)
|
|
if driver is None:
|
|
raise LookupError(f"No MCP task driver registered as {driver_name!r}")
|
|
|
|
submitted_at = now or datetime.now(UTC)
|
|
local_task_id = request.local_task_id or f"mcp-task-{uuid.uuid4().hex}"
|
|
driver_request = replace(request, local_task_id=local_task_id)
|
|
submission = await driver.submit(driver_request)
|
|
driver_data = {**request.driver_data, **submission.driver_data}
|
|
task_reference = TaskReference(
|
|
local_task_id=local_task_id,
|
|
user_id=request.user_id,
|
|
thread_id=request.thread_id,
|
|
server_name=request.server_name,
|
|
remote_task_id=submission.remote_task_id,
|
|
driver_data=driver_data,
|
|
)
|
|
try:
|
|
if len(submission.remote_task_id) > MCP_TASK_REMOTE_ID_MAX_LENGTH:
|
|
raise McpTaskProtocolError(f"MCP task remote_task_id must not exceed {MCP_TASK_REMOTE_ID_MAX_LENGTH} characters")
|
|
snapshot = self._normalize_snapshot(submission.snapshot)
|
|
next_poll_at = self._next_poll_at(snapshot, now=submitted_at)
|
|
return await self._repository.create(
|
|
task_id=local_task_id,
|
|
user_id=request.user_id,
|
|
thread_id=request.thread_id,
|
|
run_id=request.run_id,
|
|
tool_call_id=request.tool_call_id,
|
|
server_name=request.server_name,
|
|
driver_name=driver_name,
|
|
remote_task_id=submission.remote_task_id,
|
|
task_name=request.task_name,
|
|
status=snapshot.status.value,
|
|
result=snapshot.result,
|
|
result_preview=snapshot.result_preview,
|
|
result_truncated=snapshot.result_truncated,
|
|
result_artifact=snapshot.result_artifact,
|
|
error=snapshot.error,
|
|
input_required=snapshot.input_required,
|
|
next_poll_at=next_poll_at,
|
|
driver_data=driver_data,
|
|
)
|
|
except DuplicateMcpRemoteTaskError:
|
|
# This handle already has a durable owner. Cancelling it as
|
|
# compensation would terminate the pre-existing tracked task.
|
|
raise
|
|
except Exception:
|
|
try:
|
|
await driver.cancel(task_reference)
|
|
except Exception: # noqa: BLE001 - preserve the original persistence failure
|
|
logger.exception(
|
|
"Failed to cancel untracked MCP task after persistence failure (task_id=%s, driver=%s, remote_task_id=%s)",
|
|
local_task_id,
|
|
driver_name,
|
|
submission.remote_task_id,
|
|
)
|
|
raise
|
|
|
|
async def run_once(self, *, now: datetime) -> None:
|
|
claimed = await self._repository.claim_due_tasks(
|
|
now=now,
|
|
lease_owner=self._lease_owner,
|
|
lease_seconds=self._lease_seconds,
|
|
limit=self._max_concurrent_polls,
|
|
)
|
|
if not claimed:
|
|
return
|
|
results = await asyncio.gather(
|
|
*(self._poll_one(task, now=now) for task in claimed),
|
|
return_exceptions=True,
|
|
)
|
|
for record, result in zip(claimed, results, strict=True):
|
|
if isinstance(result, BaseException):
|
|
logger.error(
|
|
"Unexpected MCP task poll failure (task_id=%s); the lease will expire for recovery",
|
|
record.get("id"),
|
|
exc_info=(type(result), result, result.__traceback__),
|
|
)
|
|
|
|
async def _poll_one(self, record: dict, *, now: datetime) -> None:
|
|
driver_name = str(record.get("driver_name") or "")
|
|
driver = self._drivers.get(driver_name)
|
|
if driver is None:
|
|
await self._release_after_error(
|
|
record,
|
|
now=now,
|
|
error=f"No MCP task driver registered as {driver_name!r}",
|
|
)
|
|
return
|
|
|
|
try:
|
|
snapshot = self._normalize_snapshot(await driver.get_status(TaskReference.from_record(record)))
|
|
except McpTaskProtocolError as exc:
|
|
logger.error(
|
|
"MCP task status contract failed permanently (task_id=%s, driver=%s): %s",
|
|
record.get("id"),
|
|
driver_name,
|
|
exc,
|
|
)
|
|
await self._apply_snapshot(
|
|
record,
|
|
TaskSnapshot(status=TaskStatus.FAILED, error=_bound_error(str(exc))),
|
|
polled_at=datetime.now(UTC),
|
|
)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 - driver boundary; retry on the next poll
|
|
polled_at = datetime.now(UTC)
|
|
logger.warning(
|
|
"MCP task status poll failed (task_id=%s, driver=%s); retrying",
|
|
record.get("id"),
|
|
driver_name,
|
|
exc_info=True,
|
|
)
|
|
await self._release_after_error(record, now=polled_at, error=str(exc) or type(exc).__name__)
|
|
return
|
|
|
|
polled_at = datetime.now(UTC)
|
|
await self._apply_snapshot(record, snapshot, polled_at=polled_at)
|
|
|
|
async def _apply_snapshot(
|
|
self,
|
|
record: dict,
|
|
snapshot: TaskSnapshot,
|
|
*,
|
|
polled_at: datetime,
|
|
) -> None:
|
|
applied = await self._repository.apply_snapshot(
|
|
record["id"],
|
|
lease_owner=self._lease_owner,
|
|
status=snapshot.status.value,
|
|
result=snapshot.result,
|
|
result_preview=snapshot.result_preview,
|
|
result_truncated=snapshot.result_truncated,
|
|
result_artifact=snapshot.result_artifact,
|
|
error=snapshot.error,
|
|
input_required=snapshot.input_required,
|
|
next_poll_at=self._next_poll_at(snapshot, now=polled_at),
|
|
polled_at=polled_at,
|
|
)
|
|
if not applied:
|
|
logger.info(
|
|
"Discarded MCP task poll result after lease ownership changed or expired (task_id=%s)",
|
|
record.get("id"),
|
|
)
|
|
|
|
def _next_poll_at(self, snapshot: TaskSnapshot, *, now: datetime) -> datetime | None:
|
|
if not snapshot.is_pollable:
|
|
return None
|
|
interval = snapshot.poll_after_seconds or self._poll_interval_seconds
|
|
if snapshot.status == TaskStatus.INPUT_REQUIRED:
|
|
interval = max(interval, self._input_required_poll_interval_seconds)
|
|
interval = min(interval, MCP_TASK_POLL_AFTER_MAX_SECONDS)
|
|
return now + timedelta(seconds=interval)
|
|
|
|
async def _release_after_error(self, record: dict, *, now: datetime, error: str) -> None:
|
|
consecutive_errors = max(0, int(record.get("consecutive_poll_error_count") or 0))
|
|
retry_seconds = min(
|
|
self._poll_interval_seconds * (2 ** min(consecutive_errors, 16)),
|
|
self._max_poll_backoff_seconds,
|
|
)
|
|
bounded_error = _bound_error(error)
|
|
assert bounded_error is not None
|
|
await self._repository.release_claim(
|
|
record["id"],
|
|
lease_owner=self._lease_owner,
|
|
next_poll_at=now + timedelta(seconds=retry_seconds),
|
|
error=bounded_error,
|
|
)
|
|
|
|
def _normalize_snapshot(self, snapshot: TaskSnapshot) -> TaskSnapshot:
|
|
"""Bound remote payloads without ever storing truncated JSON."""
|
|
snapshot = replace(snapshot, error=_bound_error(snapshot.error))
|
|
if snapshot.result_artifact is not None:
|
|
encoded_artifact = self._encode_json_payload(
|
|
snapshot.result_artifact,
|
|
field_name="result_artifact",
|
|
)
|
|
if len(encoded_artifact) > MCP_TASK_RESULT_ARTIFACT_MAX_BYTES:
|
|
raise McpTaskProtocolError(f"MCP task result_artifact payload exceeds the {MCP_TASK_RESULT_ARTIFACT_MAX_BYTES}-byte limit")
|
|
if snapshot.input_required is not None:
|
|
encoded_input = self._encode_json_payload(
|
|
snapshot.input_required,
|
|
field_name="input_required",
|
|
)
|
|
if len(encoded_input) > _MAX_INPUT_REQUIRED_BYTES:
|
|
raise McpTaskProtocolError(f"MCP task input_required payload exceeds the {_MAX_INPUT_REQUIRED_BYTES}-byte limit")
|
|
if snapshot.result is None:
|
|
return snapshot
|
|
encoded = self._encode_json_payload(snapshot.result, field_name="result")
|
|
if len(encoded) <= self._max_result_bytes:
|
|
return snapshot
|
|
|
|
if isinstance(snapshot.result, str):
|
|
preview_source = snapshot.result
|
|
else:
|
|
preview_source = encoded.decode("utf-8", errors="replace")
|
|
return replace(
|
|
snapshot,
|
|
result=None,
|
|
result_preview=preview_source[: self._result_preview_max_chars],
|
|
result_truncated=True,
|
|
)
|
|
|
|
@staticmethod
|
|
def _encode_json_payload(value, *, field_name: str) -> bytes:
|
|
try:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
except (TypeError, ValueError) as exc:
|
|
raise McpTaskProtocolError(f"MCP task {field_name} is not valid JSON: {exc}") from exc
|
|
|
|
async def start(self) -> None:
|
|
if self._task is not None:
|
|
return
|
|
self._stop.clear()
|
|
self._task = asyncio.create_task(self._run_loop(), name="deerflow-mcp-task-poller")
|
|
|
|
async def stop(self) -> None:
|
|
task = self._task
|
|
if task is None:
|
|
return
|
|
self._stop.set()
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
self._task = None
|
|
|
|
async def _run_loop(self) -> None:
|
|
while not self._stop.is_set():
|
|
try:
|
|
# The first pass runs immediately. Expired leases therefore
|
|
# recover at startup without a separate destructive sweep.
|
|
await self.run_once(now=datetime.now(UTC))
|
|
except Exception:
|
|
logger.exception("MCP task poll failed; retrying next interval")
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._stop.wait(),
|
|
timeout=self._poll_interval_seconds,
|
|
)
|
|
except TimeoutError:
|
|
continue
|