feat(mcp): queue long-running calculations
This commit is contained in:
+12
-7
@@ -347,20 +347,25 @@ class McpQueryApplication:
|
||||
)
|
||||
result = QueryResult.model_validate(previous["result"])
|
||||
else:
|
||||
result = (
|
||||
await self.executor(
|
||||
scope, binding, tool, args, full_snapshot=True
|
||||
from app.mcp.calculation_jobs import should_queue, submit_calculation
|
||||
if should_queue(tool, args):
|
||||
result = await submit_calculation(scope, binding, tool, args, call_id)
|
||||
else:
|
||||
result = (
|
||||
await self.executor(
|
||||
scope, binding, tool, args, full_snapshot=True
|
||||
)
|
||||
if include_full
|
||||
else await self.executor(scope, binding, tool, args)
|
||||
)
|
||||
if include_full
|
||||
else await self.executor(scope, binding, tool, args)
|
||||
)
|
||||
# Never serialize an unbounded response into a model message or database receipt.
|
||||
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
|
||||
return tool_error(
|
||||
"RESULT_TOO_LARGE",
|
||||
"Narrow the query or request a smaller page",
|
||||
)
|
||||
if binding.task_id and name not in {
|
||||
if binding.task_id and not should_queue(tool, args) and name not in {
|
||||
"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs",
|
||||
"get_task_context",
|
||||
"get_task_evidence",
|
||||
}:
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Durable MCP calculation submission and owner-scoped result retrieval.
|
||||
|
||||
Only safe server-owned binding fields are persisted. OAuth tokens and context
|
||||
handles are never used to resume a calculation.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid5, NAMESPACE_URL
|
||||
from fastapi import HTTPException
|
||||
from app.mcp.models import BoundContext, QueryResult, PageInfo
|
||||
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||
|
||||
QUEUED_TOOLS = frozenset({
|
||||
"run_burst_location", "run_leakage_identification", "run_burst_detection",
|
||||
"run_simulation", "run_burst_simulation", "run_contaminant_simulation",
|
||||
"analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation",
|
||||
"optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement",
|
||||
})
|
||||
CONTROL_TOOLS = frozenset({"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"})
|
||||
BINDING_FIELDS = {"project_id", "subject", "client_id", "native_session_id", "purpose", "parent_session_id", "task_id", "run_id", "execution_request_id", "tool_cap", "permission_cap", "task_scope_cap"}
|
||||
|
||||
|
||||
def durable_binding(binding: BoundContext) -> dict:
|
||||
return binding.model_dump(mode="json", include=BINDING_FIELDS)
|
||||
|
||||
|
||||
def restore_binding(document: dict) -> BoundContext:
|
||||
# Expiration governs admission via a context handle; durable work has its own
|
||||
# lifecycle and must revalidate identity, membership and run on every use.
|
||||
return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)})
|
||||
|
||||
|
||||
def should_queue(tool, args) -> bool:
|
||||
return tool.name in QUEUED_TOOLS and (not tool.mutating or not args.dry_run)
|
||||
|
||||
|
||||
def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
|
||||
return QueryResult(source=source, source_ref="calculation:" + str(job_id), queried_at=datetime.now(timezone.utc), semantics="task", data=data, page=page, truncated=bool(page and (page.offset or page.has_more)),
|
||||
data_gaps=["Calculation status and queue receipts are not analytical evidence."])
|
||||
|
||||
|
||||
def public_job(row: dict) -> dict:
|
||||
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error"}
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
return {**jsonable_encoder({key: row[key] for key in fields if key in row}), "poll_after_ms": 1500}
|
||||
|
||||
|
||||
async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryResult:
|
||||
from app.mcp.query_service import authorize_query
|
||||
await authorize_query(scope, binding, tool, args)
|
||||
if binding.purpose == "task_reader":
|
||||
raise HTTPException(403, "Read-only task agents cannot start calculations")
|
||||
if tool.mutating:
|
||||
from app.mcp.mutation_service import (
|
||||
preview_change, check_digest, mutation_arguments, retryable_capacity_rejection,
|
||||
)
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
mutation_repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||
mutation_key = "mutation:" + binding.subject + ":" + binding.native_session_id
|
||||
approved_parameters = {"tool": tool.name, "arguments": mutation_arguments(args, preview=False)}
|
||||
async with scope.connection.transaction():
|
||||
await mutation_repo.lock_receipt(mutation_key, str(args.request_id))
|
||||
previous = await mutation_repo.receipt(mutation_key, str(args.request_id))
|
||||
if previous:
|
||||
if previous["parameters"] != approved_parameters:
|
||||
raise HTTPException(409, "request_id already has different parameters")
|
||||
if previous["result"].get("status") == "completed":
|
||||
return QueryResult.model_validate(previous["result"]["output"])
|
||||
if not retryable_capacity_rejection(previous["result"]):
|
||||
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
|
||||
check_digest(args, await preview_change(scope, binding, tool.name, args))
|
||||
parameters = args.model_dump(mode="json", exclude={"context_handle"})
|
||||
request_id = str(args.request_id) if tool.mutating else call_id
|
||||
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
|
||||
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||
if binding.task_id:
|
||||
from app.mcp.query_service import task_service
|
||||
from app.mcp.contexts import verify_task
|
||||
task_repo = task_service(scope).repo
|
||||
# Share the operational transaction lock with finalization. Admission
|
||||
# cannot race a pending-job check and then publish work into a closed run.
|
||||
async with task_repo.transaction():
|
||||
await task_repo.lock(f"task:{binding.task_id}")
|
||||
await task_repo.lock(f"run:{binding.run_id}")
|
||||
await verify_task(scope, binding)
|
||||
row = await repo.enqueue(
|
||||
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
else:
|
||||
row = await repo.enqueue(
|
||||
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
await scope.connection.commit()
|
||||
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||
|
||||
|
||||
def check_binding(binding, row):
|
||||
original = row["binding"]
|
||||
if any(str(original.get(key)) != str(getattr(binding, key)) for key in ("subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id")):
|
||||
raise HTTPException(403, "Calculation belongs to a different session or task run")
|
||||
|
||||
|
||||
async def authorize_original(scope, row):
|
||||
from app.mcp.catalog import TOOLS
|
||||
from app.mcp.query_service import authorize_query
|
||||
from app.mcp.contexts import verify_task
|
||||
original = restore_binding(row["binding"])
|
||||
tool = TOOLS[row["tool"]]
|
||||
args = tool.model.model_validate({**row["parameters"], "context_handle": "durable-calculation-context-handle"})
|
||||
if (str(scope.project.user_id) != str(row["owner_user_id"])
|
||||
or str(scope.project.project_id) != str(original.project_id)):
|
||||
raise HTTPException(403, "Calculation owner is no longer authorized")
|
||||
if original.task_id:
|
||||
await verify_task(scope, original)
|
||||
await authorize_query(scope, original, tool, args)
|
||||
return original, tool, args
|
||||
|
||||
|
||||
async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||
if tool.name == "list_calculation_jobs":
|
||||
identity_scope = binding.model_dump(mode="json", include={
|
||||
"subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id",
|
||||
})
|
||||
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
|
||||
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
|
||||
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Calculation was not found")
|
||||
check_binding(binding, row)
|
||||
await authorize_original(scope, row)
|
||||
if tool.name == "cancel_calculation_job":
|
||||
if binding.purpose == "task_reader":
|
||||
raise HTTPException(403, "Read-only task agents cannot cancel calculations")
|
||||
row = await repo.cancel(args.job_id, owner_user_id=scope.project.user_id)
|
||||
await scope.connection.commit()
|
||||
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
|
||||
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||
result = QueryResult.model_validate(row["result"])
|
||||
result = await attach_calculation_evidence(scope, row, result)
|
||||
value = result.data
|
||||
for part in args.data_path:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
raise HTTPException(422, "data_path must select an existing object field")
|
||||
if isinstance(value, list):
|
||||
result.data = value[args.offset:args.offset + args.limit]
|
||||
result.page = PageInfo(limit=args.limit, offset=args.offset, total=len(value), has_more=args.offset + args.limit < len(value))
|
||||
result.truncated = result.truncated or args.offset > 0 or result.page.has_more
|
||||
elif args.offset:
|
||||
raise HTTPException(422, "offset requires a collection data_path")
|
||||
else:
|
||||
result.data = value
|
||||
if len(result.model_dump_json().encode()) > 11000:
|
||||
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||
return result
|
||||
|
||||
|
||||
async def execute_calculation_job(scope, row) -> QueryResult:
|
||||
"""Worker entry point; success is persisted by the fenced supervisor."""
|
||||
from app.mcp.query_service import execute_query
|
||||
original, tool, args = await authorize_original(scope, row)
|
||||
result = await execute_query(scope, original, tool, args)
|
||||
return result
|
||||
|
||||
|
||||
async def attach_calculation_evidence(scope, row, result):
|
||||
from app.mcp.query_service import task_service
|
||||
from app.mcp.contexts import verify_task
|
||||
original, tool, _ = await authorize_original(scope, row)
|
||||
if original.task_id:
|
||||
from app.domain.schemas.operational_task import TaskDataSnapshot
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||
key, call_id = "calculation:" + str(row["job_id"]), "result"
|
||||
# Result lookup can leave an implicit SELECT transaction open. End it so
|
||||
# evidence and its receipt commit together before returning a response.
|
||||
await scope.connection.commit()
|
||||
task_repo = task_service(scope).repo
|
||||
# Match operational finalization's lock order: event lock, then task.
|
||||
async with task_repo.transaction():
|
||||
await repo.lock_receipt(key, call_id)
|
||||
previous = await repo.receipt(key, call_id)
|
||||
if previous:
|
||||
return QueryResult.model_validate(previous["result"])
|
||||
await task_repo.lock(f"task:{original.task_id}")
|
||||
await verify_task(scope, original)
|
||||
result.source_ref = key
|
||||
evidence, _ = await task_service(scope).record_data_snapshot(original.task_id, TaskDataSnapshot(
|
||||
source=tool.name, source_ref=key, queried_at=result.queried_at, origin=result.origin,
|
||||
data=result.model_dump(mode="json"), truncated=result.truncated))
|
||||
result.evidence_id = evidence.evidence_id
|
||||
await repo.save_receipt(key, call_id, {"job_id": str(row["job_id"])}, result.model_dump(mode="json"))
|
||||
return result
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Bounded inputs for durable computation control."""
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from pydantic import Field
|
||||
from app.mcp.models import StrictInput, PageInput, Id
|
||||
|
||||
CalculationStatus = Literal["queued", "running", "cancel_requested", "succeeded", "failed", "cancelled", "interrupted", "timed_out"]
|
||||
|
||||
|
||||
class CalculationJobInput(StrictInput):
|
||||
job_id: UUID
|
||||
|
||||
|
||||
class CalculationResultInput(CalculationJobInput):
|
||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||
limit: int = Field(default=20, ge=1, le=500)
|
||||
offset: int = Field(default=0, ge=0, le=1000000)
|
||||
|
||||
|
||||
class CalculationJobsInput(PageInput):
|
||||
status: CalculationStatus | None = None
|
||||
+13
-2
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from pydantic import BaseModel
|
||||
from mcp import types
|
||||
from app.mcp import models as m
|
||||
from app.mcp import calculation_models as jobs
|
||||
from app.mcp import extended_models as e
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
|
||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||
@@ -36,11 +37,15 @@ class QueryTool:
|
||||
)
|
||||
return types.Tool(
|
||||
name=self.name,
|
||||
description=self.description + suffix,
|
||||
description=self.description + suffix + (
|
||||
"。执行返回持久job_id;排队回执不是分析证据,使用get_calculation_job查询状态、get_calculation_result分页读取成功结果,不重复提交。"
|
||||
if self.name in {"run_burst_location", "run_leakage_identification", "run_burst_detection", "run_simulation", "run_burst_simulation", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement"}
|
||||
else ""
|
||||
),
|
||||
inputSchema=self.model.model_json_schema(),
|
||||
outputSchema=m.QueryResult.model_json_schema(),
|
||||
annotations=types.ToolAnnotations(
|
||||
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
||||
readOnlyHint=not (self.computation or self.mutating or self.name == "cancel_calculation_job"), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -256,4 +261,10 @@ _specs += [
|
||||
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
|
||||
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
|
||||
]
|
||||
_specs += [
|
||||
("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执不是分析证据", jobs.CalculationJobInput, "webgis.view", "task"),
|
||||
("get_calculation_result", "读取成功计算的原始结果;data_path选择嵌套集合,limit/offset按需分页", jobs.CalculationResultInput, "webgis.view", "task"),
|
||||
("cancel_calculation_job", "请求取消本人当前会话的计算;运行中取消由独立计算进程处理,须继续核实最终状态", jobs.CalculationJobInput, "webgis.view", "task"),
|
||||
("list_calculation_jobs", "分页读取本人当前会话及任务范围的计算状态", jobs.CalculationJobsInput, "webgis.view", "task"),
|
||||
]
|
||||
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ async def bind_context(
|
||||
subject=principal.subject,
|
||||
client_id=principal.client_id,
|
||||
tool_cap=sorted(name for name, tool in TOOLS.items()
|
||||
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
|
||||
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating or name == "cancel_calculation_job"))
|
||||
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
||||
permission_cap=sorted(permissions_for_context(scope.project)),
|
||||
native_session_id=command.native_session_id,
|
||||
|
||||
@@ -131,6 +131,9 @@ async def execute_query(
|
||||
full_snapshot: bool = False,
|
||||
) -> QueryResult:
|
||||
await authorize_query(scope, binding, tool, args)
|
||||
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
|
||||
from app.mcp.calculation_jobs import read_calculation_job
|
||||
return await read_calculation_job(scope, binding, tool, args)
|
||||
if tool.mutating:
|
||||
from app.mcp.mutation_service import execute_mutation
|
||||
return await execute_mutation(scope, binding, tool, args)
|
||||
|
||||
Reference in New Issue
Block a user