diff --git a/.env.example b/.env.example index 22a9ac0..08f10bd 100644 --- a/.env.example +++ b/.env.example @@ -145,5 +145,16 @@ TJWATER_MCP_ALLOWED_ORIGINS='[]' TJWATER_MCP_CONTEXT_TTL_SECONDS=600 # 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。 TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30 -# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。 +# 后台计算执行上限为 120–86400 秒,实际上限通过 tools/list 发布;不延长 HTTP 超时。 TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600 + +# 独立计算队列消费者;显式项目 UUID,以逗号分隔。不会在 API lifespan 内启动。 +# 先在每个目标项目 BizDB 应用 026_calculation_jobs.sql,再启动计算服务。 +CALCULATION_WORKER_PROJECT_IDS="" +# 所有配置项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。 +CALCULATION_WORKER_MAX_CONCURRENCY=2 +CALCULATION_WORKER_POLL_SECONDS=2 +CALCULATION_WORKER_LEASE_SECONDS=30 +CALCULATION_WORKER_HEARTBEAT_SECONDS=5 +# 持久化本机恢复账本;容器模板已挂载独立 volume,不能当缓存删除。 +CALCULATION_WORKER_STATE_DIR="temp/calculation-jobs" diff --git a/Dockerfile b/Dockerfile index 8407181..d5f5495 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ COPY app ./app COPY contracts ./contracts COPY infra ./infra COPY resources ./resources -COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/ +COPY scripts/run_operational_worker.py scripts/run_calculation_worker.py scripts/sync_peripheral_work_orders.py ./scripts/ COPY samples/peripheral ./samples/peripheral RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \ rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip diff --git a/README.md b/README.md index 0bd3c61..1eb5e89 100644 --- a/README.md +++ b/README.md @@ -195,3 +195,7 @@ Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发 Server 可选启用 MCP 2026-07-28,默认关闭。当前目录包含 67 项类型化工具,其中 42 项查询、12 项计算、13 项预览式写入。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。 写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。 + +## 后台计算队列 + +长计算通过项目数据库队列和独立计算 Worker 执行,API 保持4个进程,HTTP超时不延长。接入需要新增数据库迁移和独立服务,详见[计算队列部署与MCP契约](docs/calculation-queue.md)。 diff --git a/app/algorithms/_utils.py b/app/algorithms/_utils.py index 5137943..6ad25ed 100644 --- a/app/algorithms/_utils.py +++ b/app/algorithms/_utils.py @@ -3,6 +3,14 @@ import os import pandas as pd +def calculation_temp_directory(kind: str) -> str: + """Keep native outputs inside the supervisor-owned job directory.""" + root = os.environ.get("TJWATER_CALCULATION_JOB_DIR", "temp") + path = os.path.abspath(os.path.join(root, kind)) + os.makedirs(path, exist_ok=True) + return path + + def fill_time_gaps( data: pd.DataFrame, time_col: str = "time", diff --git a/app/algorithms/burst_localization/leak_signature.py b/app/algorithms/burst_localization/leak_signature.py index de3e644..9d63e05 100644 --- a/app/algorithms/burst_localization/leak_signature.py +++ b/app/algorithms/burst_localization/leak_signature.py @@ -8,15 +8,14 @@ import sys import pandas as pd import wntr -from app.algorithms._utils import _cleanup_temp_files +from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory _PIPE2LEAKNODE = None _SIGNATURE_WORKER_DATA = {} def _make_temp_prefix(tag): - temp_dir = os.path.abspath(os.path.join("temp", "burst_location")) - os.makedirs(temp_dir, exist_ok=True) + temp_dir = calculation_temp_directory("burst_location") safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_") return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}") diff --git a/app/algorithms/dma_leakage_estimation/genetic_optimizer.py b/app/algorithms/dma_leakage_estimation/genetic_optimizer.py index fa20a58..d7bd819 100644 --- a/app/algorithms/dma_leakage_estimation/genetic_optimizer.py +++ b/app/algorithms/dma_leakage_estimation/genetic_optimizer.py @@ -15,7 +15,7 @@ from pymoo.optimize import minimize as pymoo_minimize from pymoo.termination.default import DefaultSingleObjectiveTermination -from app.algorithms._utils import _cleanup_temp_files +from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory _worker_data: dict[str, Any] = {} DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4)) @@ -92,7 +92,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float: demand_obj.base_value = original_val + per_node_leak modifications.append((demand_obj, original_val)) - temp_dir = os.path.abspath(os.path.join("temp", "leakage")) + temp_dir = calculation_temp_directory("leakage") os.makedirs(temp_dir, exist_ok=True) prefix = os.path.join(temp_dir, f"temp_{os.getpid()}") @@ -569,7 +569,7 @@ class LeakageProblem(Problem): modifications.append((demand_obj, original_val)) # 结果保存在根目录的temp/leakage文件夹中 - temp_dir = os.path.abspath(os.path.join("temp", "leakage")) + temp_dir = calculation_temp_directory("leakage") os.makedirs(temp_dir, exist_ok=True) prefix = os.path.join(temp_dir, f"temp_{os.getpid()}") diff --git a/app/infra/db/postgresql/calculation_jobs.py b/app/infra/db/postgresql/calculation_jobs.py new file mode 100644 index 0000000..74df407 --- /dev/null +++ b/app/infra/db/postgresql/calculation_jobs.py @@ -0,0 +1,103 @@ +"""Durable project calculation queue. Every worker update is generation fenced.""" +from uuid import uuid4 + +from fastapi import HTTPException +from psycopg.types.json import Jsonb + + +class CalculationJobRepository: + def __init__(self, connection, project_id): + self.connection = connection + self.project_id = str(project_id) + + async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds): + cursor = await self.connection.execute( + """INSERT INTO workbench.calculation_jobs + (job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT(project_id,owner_user_id,request_key) DO NOTHING RETURNING *""", + (uuid4(), self.project_id, owner_user_id, request_key, tool, + Jsonb(parameters), Jsonb(binding), timeout_seconds), + ) + row = await cursor.fetchone() + if row: + return dict(row) + cursor = await self.connection.execute( + "SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s", + (self.project_id, owner_user_id, request_key), + ) + row = dict(await cursor.fetchone()) + if row['tool'] != tool or row['parameters'] != parameters or row['binding'] != binding: + raise HTTPException(409, 'Calculation request_key was used with different inputs') + return row + + async def get(self, job_id, owner_user_id=None): + cursor = await self.connection.execute( + """SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s + AND (%s::uuid IS NULL OR owner_user_id=%s)""", + (self.project_id, job_id, owner_user_id, owner_user_id), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + async def list(self, *, owner_user_id, binding, limit, offset, status=None): + cursor = await self.connection.execute( + """SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s + AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""", + (self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset), + ) + return [dict(row) for row in await cursor.fetchall()] + + async def cancel(self, job_id, owner_user_id): + await self.connection.execute( + """UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled' + ELSE 'cancel_requested' END,updated_at=clock_timestamp() + WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""", + (self.project_id, job_id, owner_user_id), + ) + return await self.get(job_id, owner_user_id) + + async def recover(self): + # A crashed write is never automatically replayed. + await self.connection.execute( + """UPDATE workbench.calculation_jobs SET status='interrupted', + error='{"code":"WORKER_INTERRUPTED"}'::jsonb,updated_at=clock_timestamp() + WHERE project_id=%s AND status IN ('running','cancel_requested') + AND lease_expires_at < clock_timestamp()""", (self.project_id,), + ) + + async def claim(self, owner, lease_seconds): + cursor = await self.connection.execute( + """UPDATE workbench.calculation_jobs j SET status='running',lease_owner=%s, + lease_generation=j.lease_generation+1, + lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp() + WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs + WHERE project_id=%s AND status='queued' ORDER BY created_at,job_id + FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""", + (owner, lease_seconds, self.project_id), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + async def heartbeat(self, row, owner, lease_seconds): + cursor = await self.connection.execute( + """UPDATE workbench.calculation_jobs SET lease_expires_at=clock_timestamp()+%s*interval '1 second', + updated_at=clock_timestamp() WHERE project_id=%s AND job_id=%s AND lease_owner=%s + AND lease_generation=%s AND lease_expires_at>clock_timestamp() + AND status IN ('running','cancel_requested') RETURNING status""", + (lease_seconds, self.project_id, row['job_id'], owner, row['lease_generation']), + ) + result = await cursor.fetchone() + return result['status'] if result else None + + async def finish(self, row, owner, status, *, result=None, error=None): + cursor = await self.connection.execute( + """UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,updated_at=clock_timestamp() + WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s + AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested') + AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted') + OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""", + (status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, + self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')), + ) + return await cursor.fetchone() is not None diff --git a/app/infra/db/postgresql/operational_tasks.py b/app/infra/db/postgresql/operational_tasks.py index 539b00b..9773251 100644 --- a/app/infra/db/postgresql/operational_tasks.py +++ b/app/infra/db/postgresql/operational_tasks.py @@ -75,6 +75,20 @@ class OperationalTaskRepository: status=409, ) + async def pending_calculation_jobs( + self, task_id: str, run_id: str, execution_request_id: str, + ) -> list[str]: + """Only the exact original run blocks final artifacts and completion.""" + cursor = await self.connection.execute( + """SELECT job_id FROM workbench.calculation_jobs + WHERE project_id=%s AND binding->>'task_id'=%s + AND binding->>'run_id'=%s AND binding->>'execution_request_id'=%s + AND status IN ('queued','running','cancel_requested') + ORDER BY created_at,job_id LIMIT 100""", + (self.project_id, task_id, run_id, execution_request_id), + ) + return [str(row["job_id"]) for row in await cursor.fetchall()] + async def get_task(self, task_id: str) -> dict | None: cursor = await self.connection.execute( """SELECT document FROM workbench.operational_tasks diff --git a/app/mcp/application.py b/app/mcp/application.py index 6401ffe..6a33d11 100644 --- a/app/mcp/application.py +++ b/app/mcp/application.py @@ -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", }: diff --git a/app/mcp/calculation_jobs.py b/app/mcp/calculation_jobs.py new file mode 100644 index 0000000..79558f3 --- /dev/null +++ b/app/mcp/calculation_jobs.py @@ -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 diff --git a/app/mcp/calculation_models.py b/app/mcp/calculation_models.py new file mode 100644 index 0000000..58e13ad --- /dev/null +++ b/app/mcp/calculation_models.py @@ -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 diff --git a/app/mcp/catalog.py b/app/mcp/catalog.py index 0865e87..f6f8565 100644 --- a/app/mcp/catalog.py +++ b/app/mcp/catalog.py @@ -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} diff --git a/app/mcp/contexts.py b/app/mcp/contexts.py index e83c33c..8f95a41 100644 --- a/app/mcp/contexts.py +++ b/app/mcp/contexts.py @@ -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, diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py index 0996a9d..a4c5a71 100644 --- a/app/mcp/query_service.py +++ b/app/mcp/query_service.py @@ -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) diff --git a/app/native/wndb/core/projects.py b/app/native/wndb/core/projects.py index 0b0ffcb..20b103f 100644 --- a/app/native/wndb/core/projects.py +++ b/app/native/wndb/core/projects.py @@ -201,6 +201,8 @@ def temporary_project_name(project: str, purpose: str) -> str: def temporary_project_database(project: str, purpose: str): """Clone one project's runnable model into an isolated temporary database.""" temporary_name = temporary_project_name(project, purpose) + from app.services.calculation_cleanup import record_calculation_database + record_calculation_database(temporary_name) try: copy_project( get_project_template_database_name(project), @@ -211,12 +213,15 @@ def temporary_project_database(project: str, purpose: str): finally: if have_project(temporary_name): delete_project(temporary_name) + record_calculation_database(temporary_name, removed=True) @contextmanager def temporary_template_database(name_hint: str, purpose: str): """Create an empty schema-only temporary database from the fixed template.""" temporary_name = temporary_project_name(name_hint, purpose) + from app.services.calculation_cleanup import record_calculation_database + record_calculation_database(temporary_name) try: copy_project( get_schema_template_database_name(), @@ -227,6 +232,7 @@ def temporary_template_database(name_hint: str, purpose: str): finally: if have_project(temporary_name): delete_project(temporary_name) + record_calculation_database(temporary_name, removed=True) def have_project(name: str) -> bool: diff --git a/app/services/calculation_cleanup.py b/app/services/calculation_cleanup.py new file mode 100644 index 0000000..39ef81c --- /dev/null +++ b/app/services/calculation_cleanup.py @@ -0,0 +1,74 @@ +"""Record only job-owned native databases so abrupt cancellation is recoverable.""" +import asyncio +import json +import os +from pathlib import Path +import re +from uuid import UUID + + +def _names(directory: Path) -> list[str]: + path = directory / "databases.json" + if not path.exists(): + return [] + if path.is_symlink() or path.stat().st_size > 65536: + raise ValueError("Invalid calculation database ledger") + names = json.loads(path.read_text()) + if not isinstance(names, list) or len(names) > 100 or any( + not isinstance(name, str) or not re.fullmatch(r"tjw_tmp_[a-z0-9_]+_[a-f0-9]{32}", name) + or len(name) > 63 for name in names + ): + raise ValueError("Invalid calculation database ledger") + return names + + +def _write(directory: Path, names: list[str]) -> None: + path = directory / "databases.json" + staging = directory / "databases.json.new" + with staging.open("w") as output: + json.dump(names, output) + output.flush() + os.fsync(output.fileno()) + staging.replace(path) + + +def record_calculation_database(name: str, *, removed: bool = False) -> None: + """Called before clone creation and after successful normal cleanup.""" + root = os.environ.get("TJWATER_CALCULATION_JOB_DIR") + if not root: + return + directory = Path(root) + names = _names(directory) + if removed: + names = [value for value in names if value != name] + elif name not in names: + names.append(name) + _write(directory, names) + + +async def cleanup_calculation_databases(project_id: str, job_directory: str) -> None: + directory = Path(job_directory) + names = _names(directory) + if not names: + return + from app.infra.db.metadb.database import SessionLocal + from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing + from app.native.wndb.core.projects import have_project, delete_project + async with SessionLocal() as session: + metadata = MetadataRepository(session) + project = await metadata.get_project_by_id(UUID(str(project_id))) + route = await metadata.get_project_db_routing(UUID(str(project_id)), "biz_data") + if not project or not route or route.db_type != "postgresql": + raise ValueError("Calculation cleanup routing unavailable") + routing = ActiveProjectRouting(project.code, route.dsn, project_id=project.id) + await session.commit() + + def cleanup(): + with activate_project_routing(routing): + for name in names[:]: + if have_project(name): + delete_project(name) + names.remove(name) + _write(directory, names) + await asyncio.to_thread(cleanup) diff --git a/app/services/calculation_worker.py b/app/services/calculation_worker.py new file mode 100644 index 0000000..622dbbc --- /dev/null +++ b/app/services/calculation_worker.py @@ -0,0 +1,318 @@ +"""Independent, singleton calculation supervisor and isolated job subprocesses. + +Never imported by API lifespan. Child output contains the original QueryResult; +algorithm stdout/stderr is discarded rather than interpreted as a protocol. +""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import signal +import shutil +import sys +import tempfile +import time +from uuid import UUID, uuid4 + + +@dataclass(frozen=True) +class WorkerSettings: + project_ids: tuple[str, ...] + max_concurrency: int = 2 + poll_seconds: float = 2 + lease_seconds: int = 30 + heartbeat_seconds: float = 5 + + @classmethod + def from_environment(cls): + projects = tuple(dict.fromkeys(str(UUID(p.strip())) for p in + os.environ['CALCULATION_WORKER_PROJECT_IDS'].split(','))) + result = cls(projects, int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')), + float(os.getenv('CALCULATION_WORKER_POLL_SECONDS', '2')), + int(os.getenv('CALCULATION_WORKER_LEASE_SECONDS', '30')), + float(os.getenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '5'))) + if not projects or not 1 <= result.max_concurrency <= 32: + raise ValueError('Configure explicit project IDs and concurrency between 1 and 32') + if not 0 < result.heartbeat_seconds < result.lease_seconds / 3 or result.poll_seconds <= 0: + raise ValueError('Heartbeat must be positive and below one third of the lease') + return result + + +@asynccontextmanager +async def queue_repository(project_id): + """Resolve current routing from metadata, never from a job payload.""" + from psycopg import AsyncConnection + from psycopg.rows import dict_row + from app.infra.db.metadb.database import SessionLocal + from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository + async with SessionLocal() as session: + metadata = MetadataRepository(session) + project = await metadata.get_project_by_id(UUID(str(project_id))) + if not project or project.status != 'active': + raise ValueError('Calculation project is not active') + routing = await metadata.get_project_db_routing(UUID(str(project_id)), 'biz_data') + if not routing or routing.db_type != 'postgresql': + raise ValueError('Project business PostgreSQL routing is unavailable') + await session.commit() + async with await AsyncConnection.connect(routing.dsn, row_factory=dict_row) as conn: + yield CalculationJobRepository(conn, project_id) + + +@asynccontextmanager +async def execution_scope(row): + from fastapi import HTTPException + from app.mcp.scope import QueryMetadataRepository, QueryScope + from app.auth.project_dependencies import (resolve_project_context, + resolve_project_business_routing, get_project_pg_connection) + from app.infra.db.metadb.database import SessionLocal + from app.infra.db.project_routing import activate_project_routing + async with SessionLocal() as session: + metadata = QueryMetadataRepository(session) + user = await metadata.get_user_by_id(UUID(str(row['owner_user_id']))) + if not user or not user.is_active: + raise HTTPException(403, 'Calculation owner is inactive') + project = await resolve_project_context(str(row['project_id']), user, metadata) + routing = await resolve_project_business_routing(project, metadata) + await session.commit() + async with asynccontextmanager(get_project_pg_connection)(project, metadata) as conn: + await session.commit() + with activate_project_routing(routing): + yield QueryScope(project, user, conn, routing, metadata) + + +async def execute_child(input_path: str, output_path: str): + from app.mcp.calculation_jobs import execute_calculation_job + row = json.loads(Path(input_path).read_text()) + try: + async with execution_scope(row) as scope: + result = await execute_calculation_job(scope, row) + payload = {'status': 'succeeded', 'result': result.model_dump(mode='json')} + except Exception as exc: + # Do not persist exception text: DSNs, SQL, credentials or model data can occur there. + category = 'CALCULATION_FAILED' + if getattr(exc, 'status_code', None) in (401, 403): + category = 'AUTHORIZATION_DENIED' + elif getattr(exc, 'status_code', None) == 409: + category = 'EXECUTION_CONFLICT' + elif getattr(exc, 'status_code', None) in (404, 410): + category = 'EXECUTION_CONTEXT_UNAVAILABLE' + payload = {'status': 'failed', 'error': {'code': category}} + encoded = json.dumps(payload, allow_nan=False) + if len(encoded.encode()) > 8 * 1024 * 1024: + encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}}) + Path(output_path).write_text(encoded) + + +async def stop_process_group(process): + """Always kill the group, even if its original leader already exited.""" + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + await asyncio.wait_for(process.wait(), 2) + except TimeoutError: + pass + # Native pools can ignore SIGTERM or survive the Python leader. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await process.wait() + if process.stdin is not None: + process.stdin.close() + + +@contextmanager +def job_directory(row): + root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs')) + root.mkdir(parents=True, exist_ok=True) + directory = Path(tempfile.mkdtemp(prefix=f"{row.get('job_id', 'job')}-", dir=root)) + directory.chmod(0o700) + (directory / 'scope.json').write_text(json.dumps({'project_id': str(row['project_id']), 'job_id': str(row.get('job_id', 'job')), + 'lease_generation': row.get('lease_generation')})) + try: + yield str(directory.resolve()) + finally: + # Failed external resource cleanup must remain operator-recoverable. + if not (directory / 'cleanup-failed').exists(): + shutil.rmtree(directory) + + +async def cleanup_job_resources(row, directory): + from app.services.calculation_cleanup import cleanup_calculation_databases + try: + await cleanup_calculation_databases(row['project_id'], directory) + except BaseException: + (Path(directory) / 'cleanup-failed').touch() + raise + + +async def recover_job_resources(project_ids): + """Reconcile only this worker's explicit scopes after obtaining the singleton.""" + root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs')) + if not root.exists(): + return + for directory in root.iterdir(): + if directory.is_symlink() or not directory.is_dir(): + continue + scope_file = directory / 'scope.json' + if not scope_file.exists(): + continue + row = json.loads(scope_file.read_text()) + if str(row.get('project_id')) not in project_ids: + continue + job_id = str(UUID(row['job_id'])) + if not directory.name.startswith(job_id + '-'): + raise RuntimeError('Calculation state directory identity is invalid') + async with queue_repository(row['project_id']) as repository: + stored = await repository.get(job_id) + if not stored or stored['lease_generation'] != row['lease_generation']: + raise RuntimeError('Calculation state ownership cannot be verified') + expiry = stored.get('lease_expires_at') + if expiry and expiry > datetime.now(timezone.utc) and stored['status'] in ('running', 'cancel_requested'): + raise RuntimeError('Previous calculation lease has not expired') + await cleanup_job_resources(row, str(directory.resolve())) + async with queue_repository(row['project_id']) as repository: + await repository.recover() + shutil.rmtree(directory) + + +class CalculationWorker: + def __init__(self, settings, *, repository=queue_repository): + self.settings = settings + self.repository = repository + self.owner = str(uuid4()) + + async def run_job(self, row): + from fastapi.encoders import jsonable_encoder + with job_directory(row) as directory: + input_path = Path(directory) / 'input.json' + output_path = Path(directory) / 'result.json' + input_path.write_text(json.dumps(jsonable_encoder(row))) + process = await asyncio.create_subprocess_exec( + sys.executable, '-m', 'app.services.calculation_worker', + '--guard', str(input_path), str(output_path), start_new_session=True, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ, 'TMPDIR': directory, 'TJWATER_CALCULATION_JOB_DIR': directory}, + ) + started = time.monotonic() + cleaned = False + try: + while True: + async with asyncio.timeout(self.settings.heartbeat_seconds): + async with self.repository(row['project_id']) as repository: + status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds) + if status is None: + return # Finally stops all owned computation on a lost lease. + terminal = None + if status == 'cancel_requested': + terminal = {'status': 'cancelled'} + elif time.monotonic() - started >= row['timeout_seconds']: + terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}} + elif process.returncode is not None: + try: + if output_path.stat().st_size > 8 * 1024 * 1024: + raise ValueError('Result exceeds persistence limit') + terminal = json.loads(output_path.read_text()) + except (OSError, ValueError): + terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}} + if terminal: + await stop_process_group(process) + try: + await cleanup_job_resources(row, directory) + cleaned = True + except Exception: + terminal = {'status': 'failed', 'error': {'code': 'CLEANUP_FAILED'}} + cleaned = True + async with self.repository(row['project_id']) as repository: + finished = await repository.finish(row, self.owner, **terminal) + if not finished: + status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds) + if status == 'cancel_requested': + await repository.finish(row, self.owner, 'cancelled') + return + await asyncio.sleep(self.settings.heartbeat_seconds) + finally: + await stop_process_group(process) + if not cleaned: + await cleanup_job_resources(row, directory) + + async def run(self): + from sqlalchemy import text + from app.infra.db.metadb.database import engine + # Dedicated session-level metadata lock: independent API workers never + # participate; duplicate supervisors exit before claiming any jobs. + async with engine.connect() as lock: + acquired = await lock.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)")) + await lock.commit() + if not acquired: + raise RuntimeError('Another calculation supervisor is already active') + tasks = set() + project_cursor = 0 + try: + # Allow any old supervisor to detect a broken lock connection and + # stop its children before this replacement claims capacity. + await asyncio.sleep(self.settings.lease_seconds) + await recover_job_resources(self.settings.project_ids) + while True: + async with asyncio.timeout(self.settings.heartbeat_seconds): + await lock.execute(text('SELECT 1')) + await lock.commit() + for task in tuple(tasks): + if task.done(): + tasks.remove(task) + task.result() # Fail closed if heartbeat/persistence failed. + projects = self.settings.project_ids + ordered = projects[project_cursor:] + projects[:project_cursor] + project_cursor = (project_cursor + 1) % len(projects) + for project_id in ordered: + if len(tasks) >= self.settings.max_concurrency: + break + async with self.repository(project_id) as repository: + await repository.recover() + row = await repository.claim(self.owner, self.settings.lease_seconds) + if row: + tasks.add(asyncio.create_task(self.run_job(row))) + await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds)) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await lock.execute(text('SELECT pg_advisory_unlock(1748531781, 2)')) + await lock.commit() + + +async def guard_child(input_path, output_path): + """A parent-death pipe kills the whole group even when the supervisor is SIGKILLed.""" + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + transport, _ = await asyncio.get_running_loop().connect_read_pipe(lambda: protocol, sys.stdin) + process = await asyncio.create_subprocess_exec( + sys.executable, '-m', 'app.services.calculation_worker', input_path, output_path, + stdin=asyncio.subprocess.DEVNULL, + ) + completed = asyncio.create_task(process.wait()) + parent_gone = asyncio.create_task(reader.read(1)) + try: + done, _ = await asyncio.wait((completed, parent_gone), return_when=asyncio.FIRST_COMPLETED) + if parent_gone in done: + os.killpg(os.getpgrp(), signal.SIGKILL) + await completed + finally: + parent_gone.cancel() + transport.close() + + +if __name__ == '__main__': + if sys.argv[1] == '--guard': + asyncio.run(guard_child(sys.argv[2], sys.argv[3])) + else: + asyncio.run(execute_child(sys.argv[1], sys.argv[2])) diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index bbe8806..c99c337 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -778,6 +778,10 @@ class OperationalTaskService: "Only a running run accepts Agent artifacts", status=409) now = self._now() self._require_active_run_lease(active_run, now) + if command.report and ( + command.report.stage == "final" or command.report.outcome == "insufficient_data" + ): + await self._require_no_pending_calculations(task_id, active_run) previous_report = await self.repo.get_latest_report(str(task_id)) require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"] or previous_report["report"].get("outcome") != "insufficient_data", @@ -1866,6 +1870,15 @@ class OperationalTaskService: task_ids.append(task_id) return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now) + async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None: + if hasattr(self.repo, "pending_calculation_jobs"): + pending = await self.repo.pending_calculation_jobs( + str(task_id), str(run["run_id"]), str(run["execution_request_id"]), + ) + require(not pending, "PENDING_CALCULATIONS", + "Wait for submitted calculations before publishing a final report or completing the run", + refs=pending, status=409, recovery="retry_same_request") + async def _finish_run( self, task_id: UUID, run_id: UUID, command: AgentRunCommand, *, outcome: str, failure_code: str | None = None, @@ -1890,6 +1903,7 @@ class OperationalTaskService: task = await self._task(task_id) final_outcome = outcome if outcome == "succeeded": + await self._require_no_pending_calculations(task_id, run) if hasattr(self.repo, "pending_run_messages"): pending_messages = await self.repo.pending_run_messages(str(run_id)) require(not pending_messages, "PENDING_AGENT_MESSAGES", diff --git a/app/services/project_inp.py b/app/services/project_inp.py index 78fb5fd..e3dd54c 100644 --- a/app/services/project_inp.py +++ b/app/services/project_inp.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from tempfile import NamedTemporaryFile +import os from app.native.wndb.inp.exporter import dump_inp @@ -17,9 +18,11 @@ def temporary_project_inp( version: str = "2", ) -> Iterator[Path]: """Export one project model to an isolated INP and remove it afterwards.""" - PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True) + job_directory = os.environ.get("TJWATER_CALCULATION_JOB_DIR") + directory = Path(job_directory) / "inp" if job_directory else PROJECT_INP_DIRECTORY + directory.mkdir(parents=True, exist_ok=True) with NamedTemporaryFile( - dir=PROJECT_INP_DIRECTORY, + dir=directory, prefix=f"{purpose}_", suffix=".inp", delete=False, diff --git a/docs/calculation-queue.md b/docs/calculation-queue.md new file mode 100644 index 0000000..4db6616 --- /dev/null +++ b/docs/calculation-queue.md @@ -0,0 +1,86 @@ +# MCP 后台计算队列 + +API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,由独立计算进程领取;所有 API 进程读取相同任务状态,不依赖粘性会话。HTTP 的 5 分钟空闲超时保留,Agent 的提交和状态查询采用 30 秒调用预算、35 秒宿主预算。`timeout_seconds` 对入队工具表示 Worker 的执行预算,不是 HTTP 等待时间,也不是模拟时间 `duration`。 + +```text +前端 / Agent → API × 4 → 项目 BizDB calculation_jobs + ↑ + 独立计算 supervisor + ↓ + 受管理的计算子进程组 +``` + +## 工具契约 + +原工具名继续使用。以下工具提交后返回 `QueryResult`,其中 `semantics=task`,`data` 包含 `job_id`、`status`、`tool` 和 `poll_after_ms`;这只证明提交状态,不是分析结果或调查证据: + +- `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` 的 `dry_run=false` 执行阶段 + +后两项保留预览、摘要校验和原有审批;提交与执行时均校验权限及预览摘要,已有写入回执继续约束重复请求。`analyze_valve_isolation`、`calculate_demand_allocation` 保持同步执行。普通查询和其他写入不进入此队列。 + +| MCP 工具 | 输入 | 返回 | +| --- | --- | --- | +| `get_calculation_job` | `job_id` | 状态、工具、时间和安全错误码 | +| `get_calculation_result` | `job_id`,可选 `data_path/limit/offset` | 成功后读取原分析结果;未成功返回任务状态 | +| `list_calculation_jobs` | 可选 `status/limit/offset` | 当前授权会话及调查轮次的任务列表 | +| `cancel_calculation_job` | `job_id` | 排队任务可直接取消;运行任务先进入 `cancel_requested` | + +状态为 `queued/running/cancel_requested/succeeded/failed/cancelled/timed_out/interrupted`。任务取消不等于在线模型写入回滚;发生失败、超时或中断时,需要核查已有写入,不能自动重算整个窗口。 + +当前使用普通 MCP 业务工具承载队列契约,没有宣称已实现协议级 Tasks 扩展。Server 和 Agent 目录必须配套更新,旧 Agent 不应接收新的队列返回契约。 + +## 身份、幂等和结果 + +任务记录保存提交人的元数据用户 ID、项目、原工具参数和受限授权快照,不保存 Bearer、数据库 DSN 或短期 `context_handle`。执行和读取结果时重新查询当前用户、成员关系、项目路由、权限及调查运行租约。任务不会因为短期 MCP 句柄失效而丢失;也不会借持久化任务绕过撤销的权限。 + +普通计算使用宿主从原生工具调用记录解析出的稳定调用 ID;同一次传输重试复用任务,不将新的业务计算自动按参数合并。重写工具同时保留原审批 `request_id` 幂等语义。不同参数复用同一操作编号会被拒绝。 + +调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。 + +Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳,计算进入终态后再触发模型读取结果。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。 + +## 并发、取消和恢复 + +一个 supervisor 默认全项目合计并发 2 项,独立于 API 的 4 个进程。它持有元数据库的会话级 advisory lock,第二个 supervisor 会拒绝启动;当前不是多台计算节点各自扩容的模式。各项目轮转领取,使用 PostgreSQL 短事务 `SKIP LOCKED`,租约代次约束心跳和结果写回。 + +每项任务有独立子进程组;取消、执行超时或失去租约时先停止进程组,再写终态。守护管道在 supervisor 被强杀后关闭,负责终止仍在运行的算法进程。API 重启不影响计算 supervisor;计算进程崩溃后,旧任务标为中断,不自动重放。 + +原生临时文件写入独立任务目录。临时模型数据库创建前登记到该目录的账本,正常结束或取消后只清理明确登记的临时数据库。清理失败返回 `CLEANUP_FAILED` 并保留恢复目录;启动时检查项目、任务和执行代次,确认旧租约过期后恢复清理。无法验证归属或清理失败时停止领取新工作。 + +## 部署 + +1. 先对每个目标项目的 BizDB 应用 `resources/sql/026_calculation_jobs.sql`,包括未来项目使用的相应业务模板。不要应用到 TimescaleDB。迁移为新增表及索引,不删除业务数据。 +2. 配置 `CALCULATION_WORKER_PROJECT_IDS` 为明确的项目 UUID 列表,配置现有元数据库连接及数据库路由解密密钥。Worker 不需要新 OAuth 密钥;仅部署侧工作进程读取这些设置。 +3. 同步发布 Server 和 Agent,在接入长计算前启动独立 Worker。不要从 API `lifespan` 启动,不要把调查 Worker 当作计算 Worker。 + +从 Server 仓库启动: + +```bash +conda run -n server python scripts/run_calculation_worker.py +``` + +systemd 模板:`infra/systemd/tjwater-calculation-worker.service`。Docker 使用同一 Server 镜像的独立服务: + +```bash +docker compose -f infra/docker/docker-compose.yml --profile calculations up -d calculation-worker +``` + +Compose 使用 `../../.env` 注入配置,需确保其中数据库地址在容器内可达。`calculation-state` volume 保存任务恢复账本,不能当作临时缓存删除。非容器启动可用 `CALCULATION_WORKER_STATE_DIR` 指定持久目录。 + +并发默认 2、轮询默认 2 秒、租约 30 秒、心跳 5 秒。算法内部并行度仍消耗额外 CPU/内存;调整并发必须依据实际资源测量。 + +回滚前暂停接收新的队列计算并等待或明确取消在途任务,核对有副作用任务的写入。保留任务表、结果和恢复目录;旧同步版本不支持继续处理这些作业,不能直接删除记录后重新提交。 + +## 验证 + +```bash +conda run -n server python -m pytest tests/unit/test_mcp_calculation_jobs.py tests/unit/test_calculation_worker.py tests/unit/test_calculation_cleanup.py tests/unit/test_mcp_query.py tests/unit/test_mcp_analysis.py tests/unit/test_operational_tasks.py -q +``` + +真实 PostgreSQL 并发测试为 `tests/integration/test_calculation_queue_storage.py`。它要求 `CALCULATION_QUEUE_TEST_DSN` 指向名称以 `tjwater_calc_test_` 开头的专用可丢弃数据库,不能指向业务库。覆盖四连接重复提交、参数冲突、并发领取、项目和用户隔离、取消竞争、失效代次拒绝和过期租约恢复。 + +这些验证不代替真实项目算法运行、浏览器断线重连或部署验收。生产接入还应观察超过 5 分钟的真实计算、恢复后结果读取和原生临时资源清理。 diff --git a/docs/mcp-analysis-tools.md b/docs/mcp-analysis-tools.md index 038c082..bfca91e 100644 --- a/docs/mcp-analysis-tools.md +++ b/docs/mcp-analysis-tools.md @@ -54,15 +54,15 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42 ## 资源边界与发布 -12项计算和13项预览式写入工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。 +长计算采用[后台计算队列](calculation-queue.md)。10项分析计算与2项重计算写入的正式执行阶段快速返回任务编号,再使用 `get_calculation_job`、`get_calculation_result`、`list_calculation_jobs`、`cancel_calculation_job` 管理。原有预览与审批仍有效;排队回执不作为分析证据。 -例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。 +`timeout_seconds` 对队列工具表示计算 Worker 的执行预算,与模拟时间 `duration` 和单次 HTTP 请求时限独立。各工具默认值与管理员上限继续由目录公布。Agent的提交/查询采用30秒调用预算和35秒宿主预算,不延长Bun的5分钟HTTP空闲超时。 -计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。 +`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。 在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。 -无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。 +队列接入需要在目标项目BizDB应用 `resources/sql/026_calculation_jobs.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。 本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。 diff --git a/docs/mcp-query-implementation.md b/docs/mcp-query-implementation.md index 162bd5c..46b6122 100644 --- a/docs/mcp-query-implementation.md +++ b/docs/mcp-query-implementation.md @@ -1,10 +1,10 @@ # 产品 MCP 查询:本地实施与启用 -实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和67项工具(42项查询、12项计算、13项预览式写入);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。 +实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和71项工具(42项原查询、12项计算、13项预览式写入、4项计算任务管理);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。 `run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。 -计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。 +长计算使用持久化队列,原工具快速返回任务编号,新增状态、结果、列表和取消工具。`timeout_seconds` 对队列工具表示执行预算;HTTP保持短调用,不延长5分钟空闲超时。独立计算Worker、迁移与权限边界见[后台计算队列](calculation-queue.md)。 ## 认证与数据边界 diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 3389e72..2de9f19 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -21,6 +21,26 @@ services: - timescaledb - postgis + # Enable after provisioning calculation storage and explicit project scopes. + calculation-worker: + profiles: [calculations] + image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local} + init: true + restart: on-failure + command: ["python", "scripts/run_calculation_worker.py"] + env_file: + - ../../.env + environment: + - PYTHONPATH=/app + volumes: + - ../../app:/app/app + - ../../resources:/app/resources + - calculation-state:/app/temp/calculation-jobs + stop_grace_period: 30s + depends_on: + - timescaledb + - postgis + # ========================================== # Infrastructure Services # ========================================== @@ -127,6 +147,9 @@ services: volumes: - ./mapservice/geoserver_data:/opt/geoserver_data +volumes: + calculation-state: + networks: default: driver: bridge diff --git a/infra/systemd/tjwater-calculation-worker.service b/infra/systemd/tjwater-calculation-worker.service new file mode 100644 index 0000000..3a13942 --- /dev/null +++ b/infra/systemd/tjwater-calculation-worker.service @@ -0,0 +1,17 @@ +[Unit] +Description=TJWater queued calculation worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/NextTJWater/next-tjwater-server +ExecStart=%h/miniconda3/envs/server/bin/python scripts/run_calculation_worker.py +Restart=on-failure +RestartSec=5 +KillMode=control-group +TimeoutStopSec=30 +UMask=0077 + +[Install] +WantedBy=default.target diff --git a/resources/sql/026_calculation_jobs.sql b/resources/sql/026_calculation_jobs.sql new file mode 100644 index 0000000..0fe4c38 --- /dev/null +++ b/resources/sql/026_calculation_jobs.sql @@ -0,0 +1,23 @@ +-- Apply independently to each project's business database. +CREATE SCHEMA IF NOT EXISTS workbench; +CREATE TABLE IF NOT EXISTS workbench.calculation_jobs ( + job_id uuid PRIMARY KEY, + project_id uuid NOT NULL, + owner_user_id uuid NOT NULL, + request_key text NOT NULL, + tool text NOT NULL, + parameters jsonb NOT NULL, + binding jsonb NOT NULL, + timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 86400), + status text NOT NULL DEFAULT 'queued' CHECK (status IN + ('queued','running','cancel_requested','succeeded','failed','cancelled','timed_out','interrupted')), + result jsonb, + error jsonb, + lease_generation bigint NOT NULL DEFAULT 0, + lease_owner text, + lease_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE(project_id,owner_user_id,request_key) +); +CREATE INDEX IF NOT EXISTS calculation_jobs_queue ON workbench.calculation_jobs(project_id,status,created_at); diff --git a/scripts/run_calculation_worker.py b/scripts/run_calculation_worker.py new file mode 100644 index 0000000..0ca46dd --- /dev/null +++ b/scripts/run_calculation_worker.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Run the independent calculation supervisor (never an API lifespan task).""" +import argparse +import asyncio +from pathlib import Path +import signal +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dotenv import load_dotenv + +from app.services.calculation_worker import CalculationWorker, WorkerSettings + + +async def main(): + load_dotenv(Path(__file__).resolve().parents[1] / '.env', override=False) + task = asyncio.create_task(CalculationWorker(WorkerSettings.from_environment()).run()) + loop = asyncio.get_running_loop() + for signum in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(signum, task.cancel) + try: + await task + except asyncio.CancelledError: + pass + + +if __name__ == '__main__': + argparse.ArgumentParser(description=__doc__).parse_args() + asyncio.run(main()) diff --git a/tests/integration/test_calculation_queue_storage.py b/tests/integration/test_calculation_queue_storage.py new file mode 100644 index 0000000..e5f853f --- /dev/null +++ b/tests/integration/test_calculation_queue_storage.py @@ -0,0 +1,70 @@ +"""Opt-in real PostgreSQL queue races, using an explicitly disposable database.""" +import asyncio +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from psycopg import AsyncConnection +from psycopg.conninfo import conninfo_to_dict +from psycopg.rows import dict_row + +from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository + + +def test_concurrent_submission_claims_fencing_and_project_isolation(): + dsn = os.getenv("CALCULATION_QUEUE_TEST_DSN") + if not dsn: + pytest.skip("Set CALCULATION_QUEUE_TEST_DSN to a disposable PostgreSQL database") + assert conninfo_to_dict(dsn)["dbname"].startswith("tjwater_calc_test_") + + async def run(): + project, other, owner = uuid4(), uuid4(), uuid4() + async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + await conn.execute((Path(__file__).parents[2] / "resources/sql/026_calculation_jobs.sql").read_text()) + + async def submit(key, parameters=None, selected_project=project): + async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + return await CalculationJobRepository(conn, selected_project).enqueue( + owner_user_id=owner, request_key=key, tool="run_burst_location", + parameters=parameters or {"x": 1}, binding={"native_session_id": "session"}, timeout_seconds=600) + + # Four independent API connections, same logical submission. + submissions = await asyncio.gather(*(submit("same") for _ in range(4))) + assert len({str(row["job_id"]) for row in submissions}) == 1 + with pytest.raises(HTTPException) as conflict: + await submit("same", {"x": 2}) + assert conflict.value.status_code == 409 + second = await submit("second") + foreign = await submit("foreign", selected_project=other) + + async def claim(worker): + async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + return await CalculationJobRepository(conn, project).claim(worker, 60) + + claimed = await asyncio.gather(*(claim(f"worker-{index}") for index in range(4))) + rows = [row for row in claimed if row] + assert len(rows) == 2 + assert len({str(row["job_id"]) for row in rows}) == 2 + assert str(foreign["job_id"]) not in {str(row["job_id"]) for row in rows} + + async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + repo = CalculationJobRepository(conn, project) + assert await repo.get(foreign["job_id"], owner) is None + assert await repo.get(second["job_id"], uuid4()) is None + row = rows[0] + stale = {**row, "lease_generation": row["lease_generation"] - 1} + assert await repo.heartbeat(stale, row["lease_owner"], 60) is None + assert not await repo.finish(stale, row["lease_owner"], "succeeded", result={}) + cancelled = await repo.cancel(row["job_id"], owner) + assert cancelled["status"] == "cancel_requested" + assert not await repo.finish(row, row["lease_owner"], "succeeded", result={}) + assert await repo.finish(row, row["lease_owner"], "cancelled") + expired = rows[1] + await conn.execute("UPDATE workbench.calculation_jobs SET lease_expires_at=now()-interval '1 second' WHERE job_id=%s", (expired["job_id"],)) + await repo.recover() + assert (await repo.get(expired["job_id"], owner))["status"] == "interrupted" + assert not await repo.finish(expired, expired["lease_owner"], "succeeded", result={}) + assert await repo.claim("replacement", 60) is None + asyncio.run(run()) diff --git a/tests/unit/test_calculation_cleanup.py b/tests/unit/test_calculation_cleanup.py new file mode 100644 index 0000000..9983fb1 --- /dev/null +++ b/tests/unit/test_calculation_cleanup.py @@ -0,0 +1,25 @@ +import json + +import pytest + +from app.services.calculation_cleanup import _names, record_calculation_database +from app.algorithms._utils import calculation_temp_directory + + +def test_job_owned_database_ledger_survives_until_successful_cleanup(tmp_path, monkeypatch): + monkeypatch.setenv("TJWATER_CALCULATION_JOB_DIR", str(tmp_path)) + name = "tjw_tmp_run_project_" + "a" * 32 + record_calculation_database(name) + record_calculation_database(name) + assert _names(tmp_path) == [name] + assert json.loads((tmp_path / "databases.json").read_text()) == [name] + record_calculation_database(name, removed=True) + assert _names(tmp_path) == [] + assert calculation_temp_directory("leakage") == str(tmp_path / "leakage") + + +@pytest.mark.parametrize("names", [["production"], ["tjw_tmp_not_random"], {"database": "production"}]) +def test_cleanup_rejects_non_job_database_names(tmp_path, names): + (tmp_path / "databases.json").write_text(json.dumps(names)) + with pytest.raises(ValueError): + _names(tmp_path) diff --git a/tests/unit/test_calculation_worker.py b/tests/unit/test_calculation_worker.py new file mode 100644 index 0000000..90e3e29 --- /dev/null +++ b/tests/unit/test_calculation_worker.py @@ -0,0 +1,241 @@ +"""Worker boundaries use real managed subprocesses, without external databases.""" +import asyncio +from functools import wraps +from types import SimpleNamespace +from contextlib import asynccontextmanager +import os +from pathlib import Path +import signal +import sys + +import pytest + +from app.services.calculation_worker import CalculationWorker, WorkerSettings, stop_process_group + + +@pytest.fixture(autouse=True) +def no_external_cleanup(monkeypatch): + async def cleanup(*args): + pass + monkeypatch.setattr('app.services.calculation_worker.cleanup_job_resources', cleanup) + + +def run_async(function): + @wraps(function) + def wrapper(*args, **kwargs): + return asyncio.run(function(*args, **kwargs)) + return wrapper + + +def test_worker_settings_require_explicit_valid_scope(monkeypatch): + monkeypatch.setenv('CALCULATION_WORKER_PROJECT_IDS', '6b39b128-2ba8-454f-9bbe-e9553902ab53') + settings = WorkerSettings.from_environment() + assert settings.max_concurrency == 2 + monkeypatch.setenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '30') + with pytest.raises(ValueError): + WorkerSettings.from_environment() + + +@run_async +async def test_process_group_cleanup_kills_native_style_descendant(tmp_path): + pid_file = tmp_path / 'pid' + code = ('import subprocess,time,pathlib,sys; ' + 'p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(60)"]); ' + 'pathlib.Path(sys.argv[1]).write_text(str(p.pid)); time.sleep(60)') + process = await asyncio.create_subprocess_exec(sys.executable, '-c', code, + str(pid_file), start_new_session=True) + try: + for _ in range(100): + if pid_file.exists(): + break + await asyncio.sleep(.02) + child_pid = int(pid_file.read_text()) + await stop_process_group(process) + assert process.returncode is not None + # Linux may retain a dead grandchild as a zombie until PID 1 reaps it. + stat_path = Path(f'/proc/{child_pid}/stat') + for _ in range(100): + if not stat_path.exists() or stat_path.read_text().split()[2] == 'Z': + break + await asyncio.sleep(.01) + assert not stat_path.exists() or stat_path.read_text().split()[2] == 'Z' + finally: + await stop_process_group(process) + + +@run_async +@pytest.mark.parametrize('lease_status, expected', [('cancel_requested', 'cancelled'), (None, None)]) +async def test_cancel_and_lost_lease_stop_process_before_persisting(monkeypatch, lease_status, expected): + processes = [] + original = asyncio.create_subprocess_exec + async def spawn(*args, **kwargs): + process = await original(sys.executable, '-c', 'import time; time.sleep(60)', + start_new_session=True) + processes.append(process) + return process + monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) + finishes = [] + class Repository: + async def heartbeat(self, *args): + return lease_status + async def finish(self, row, owner, status, **kwargs): + assert processes[0].returncode is not None + finishes.append(status) + return True + @asynccontextmanager + async def repository(project): + yield Repository() + worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.05), repository=repository) + await worker.run_job({'project_id': 'project', 'job_id': 'job', 'timeout_seconds': 60}) + assert finishes == ([expected] if expected else []) + assert processes[0].returncode is not None + + +@run_async +async def test_timeout_stops_process_before_recording(monkeypatch): + original = asyncio.create_subprocess_exec + processes = [] + async def spawn(*args, **kwargs): + p = await original(sys.executable, '-c', 'import time; time.sleep(60)', start_new_session=True) + processes.append(p) + return p + monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) + statuses = [] + class Repository: + async def heartbeat(self, *args): + return 'running' + async def finish(self, row, owner, status, **kwargs): + assert processes[0].returncode is not None + statuses.append(status) + return True + @asynccontextmanager + async def repository(project): + yield Repository() + worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.02), repository=repository) + await worker.run_job({'project_id': 'project', 'timeout_seconds': 0}) + assert statuses == ['timed_out'] + + +@run_async +async def test_duplicate_supervisor_never_claims(monkeypatch): + from app.infra.db.metadb import database + class Lock: + async def scalar(self, statement): + assert 'pg_try_advisory_lock' in str(statement) + return False + async def commit(self): + pass + @asynccontextmanager + async def connection(): + yield Lock() + monkeypatch.setattr(database, 'engine', SimpleNamespace(connect=connection)) + @asynccontextmanager + async def repository(project): + pytest.fail('A duplicate supervisor must never open the queue') + yield + with pytest.raises(RuntimeError, match='already active'): + await CalculationWorker(WorkerSettings(('project',)), repository=repository).run() + + +@run_async +async def test_repository_terminal_updates_are_fenced(): + from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository + statements = [] + class Cursor: + async def fetchone(self): + return None + class Connection: + async def execute(self, sql, parameters): + statements.append((sql, parameters)) + return Cursor() + repository = CalculationJobRepository(Connection(), 'project') + row = {'job_id': 'job', 'lease_generation': 7} + assert await repository.finish(row, 'owner', 'succeeded', result={'original': True}) is False + sql, parameters = statements[-1] + assert 'lease_generation=%s' in sql and 'lease_owner=%s' in sql + assert 'lease_expires_at>clock_timestamp()' in sql + assert "status!='cancel_requested'" in sql + assert 7 in parameters + + +@run_async +async def test_guard_parent_death_pipe_kills_entire_group(tmp_path): + pid_file = tmp_path / 'guard-child' + child = 'import os,time,pathlib,sys; pathlib.Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(60)' + launcher = '''import asyncio,sys +from app.services.calculation_worker import guard_child +original = asyncio.create_subprocess_exec +async def spawn(*args, **kwargs): + return await original(sys.executable, '-c', sys.argv[2], sys.argv[1]) +asyncio.create_subprocess_exec = spawn +asyncio.run(guard_child('unused','unused')) +''' + guard = await asyncio.create_subprocess_exec(sys.executable, '-c', launcher, + str(pid_file), child, stdin=asyncio.subprocess.PIPE, start_new_session=True) + try: + for _ in range(100): + if pid_file.exists(): + break + await asyncio.sleep(.02) + child_pid = int(pid_file.read_text()) + guard.stdin.close() + await asyncio.wait_for(guard.wait(), 3) + assert guard.returncode == -signal.SIGKILL + stat = Path(f'/proc/{child_pid}/stat') + for _ in range(100): + if not stat.exists() or stat.read_text().split()[2] == 'Z': + break + await asyncio.sleep(.01) + assert not stat.exists() or stat.read_text().split()[2] == 'Z' + finally: + await stop_process_group(guard) + + +@run_async +async def test_cleanup_failure_is_not_reported_cancelled(monkeypatch, tmp_path): + monkeypatch.setenv('CALCULATION_WORKER_STATE_DIR', str(tmp_path)) + async def cleanup(row, directory): + (Path(directory) / 'cleanup-failed').touch() + raise RuntimeError('external resource unavailable') + monkeypatch.setattr('app.services.calculation_worker.cleanup_job_resources', cleanup) + original = asyncio.create_subprocess_exec + async def spawn(*args, **kwargs): + return await original(sys.executable, '-c', 'import time; time.sleep(60)', start_new_session=True) + monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) + results = [] + class Repository: + async def heartbeat(self, *args): + return 'cancel_requested' + async def finish(self, row, owner, status, **kwargs): + results.append((status, kwargs['error']['code'])) + return True + @asynccontextmanager + async def repository(project): + yield Repository() + worker = CalculationWorker(WorkerSettings(('project',)), repository=repository) + await worker.run_job({'project_id': 'project', 'timeout_seconds': 60}) + assert results == [('failed', 'CLEANUP_FAILED')] + assert len(list(tmp_path.glob('*/cleanup-failed'))) == 1 + + +@run_async +async def test_startup_recovery_checks_persisted_generation(monkeypatch, tmp_path): + import json + from app.services.calculation_worker import recover_job_resources + project = '6b39b128-2ba8-454f-9bbe-e9553902ab53' + job = '2d3a6231-a8ee-4acb-bb13-58328d29b88f' + directory = tmp_path / (job + '-owned') + directory.mkdir() + (directory / 'scope.json').write_text(json.dumps({ + 'project_id': project, 'job_id': job, 'lease_generation': 1})) + monkeypatch.setenv('CALCULATION_WORKER_STATE_DIR', str(tmp_path)) + class Repository: + async def get(self, job_id): + return {'lease_generation': 2} + @asynccontextmanager + async def repository(project): + yield Repository() + monkeypatch.setattr('app.services.calculation_worker.queue_repository', repository) + with pytest.raises(RuntimeError, match='ownership'): + await recover_job_resources((project,)) + assert directory.exists() diff --git a/tests/unit/test_mcp_calculation_jobs.py b/tests/unit/test_mcp_calculation_jobs.py new file mode 100644 index 0000000..4326e1f --- /dev/null +++ b/tests/unit/test_mcp_calculation_jobs.py @@ -0,0 +1,139 @@ +import asyncio +from datetime import datetime, timezone +from uuid import uuid4 +from types import SimpleNamespace +import pytest +from fastapi import HTTPException +from app.mcp.models import BoundContext +from app.mcp.calculation_jobs import durable_binding, restore_binding, check_binding, should_queue, QUEUED_TOOLS + + +def binding(): + return BoundContext(context_handle='secret-handle', project_id=uuid4(), subject='user', client_id='host', native_session_id='session', purpose='ordinary_chat', expires_at=datetime.now(timezone.utc)) + + +def test_durable_binding_never_stores_expiring_secret_handle(): + original = binding() + safe = durable_binding(original) + assert 'context_handle' not in safe + assert 'expires_at' not in safe + assert restore_binding(safe).subject == original.subject + + +def test_original_session_boundary_is_enforced(): + original = binding() + row = {'binding': durable_binding(original)} + check_binding(original, row) + original.native_session_id = 'other' + with pytest.raises(HTTPException) as error: + check_binding(original, row) + assert error.value.status_code == 403 + + +def test_only_executed_expensive_mutations_are_queued(): + tool = SimpleNamespace(name='save_simulation_scenario', mutating=True) + assert not should_queue(tool, SimpleNamespace(dry_run=True)) + assert should_queue(tool, SimpleNamespace(dry_run=False)) + assert 'analyze_valve_isolation' not in QUEUED_TOOLS + assert 'calculate_demand_allocation' not in QUEUED_TOOLS + assert len(QUEUED_TOOLS) == 12 + + +def test_result_pages_keep_original_semantics_and_provenance(monkeypatch): + from unittest.mock import AsyncMock + from app.mcp import calculation_jobs as jobs + from app.mcp.calculation_models import CalculationResultInput + from app.mcp.models import QueryResult + original = binding() + result = QueryResult(source='run_burst_location', source_ref='calculation:one', queried_at=datetime.now(timezone.utc), semantics='analysis', data={'result': {'items': list(range(10))}}, evidence_id='evidence-one') + row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')} + repo = SimpleNamespace(get=AsyncMock(return_value=row)) + monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo) + monkeypatch.setattr(jobs, 'authorize_original', AsyncMock()) + monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result)) + scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4())) + args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'], data_path=['result', 'items'], limit=3, offset=2) + page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args)) + assert page.data == [2, 3, 4] + assert page.semantics == 'analysis' + assert page.source == 'run_burst_location' + assert page.evidence_id == 'evidence-one' + assert page.truncated and page.page.has_more and page.page.total == 10 + + +def test_pending_result_is_task_receipt_without_evidence(monkeypatch): + from unittest.mock import AsyncMock + from app.mcp import calculation_jobs as jobs + from app.mcp.calculation_models import CalculationResultInput + original = binding() + row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'running', 'tool': 'run_simulation'} + monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row))) + monkeypatch.setattr(jobs, 'authorize_original', AsyncMock()) + attach = AsyncMock() + monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach) + scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4())) + result = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), CalculationResultInput(context_handle='x' * 32, job_id=row['job_id']))) + assert result.semantics == 'task' and result.evidence_id is None + assert result.data['status'] == 'running' + attach.assert_not_awaited() + + +def test_mutation_submission_cannot_bypass_existing_approval_receipt(monkeypatch): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock + from app.mcp import calculation_jobs as jobs, query_service + from app.mcp.catalog import TOOLS + from app.mcp.extended_models import SensorCreateInput + from app.mcp.mutation_service import mutation_arguments + from app.infra.db.postgresql import mcp_queries + args = SensorCreateInput(context_handle='x' * 32, request_id=uuid4(), dry_run=False, expected_digest='a' * 64, name='approved', method='kmeans', sensor_count=2) + @asynccontextmanager + async def transaction(): + yield + previous = {'parameters': {'tool': 'create_sensor_placement', 'arguments': mutation_arguments(args, preview=False)}, 'result': {'status': 'started'}} + receipt = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=previous)) + monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt) + monkeypatch.setattr(query_service, 'authorize_query', AsyncMock()) + queue = AsyncMock() + monkeypatch.setattr(jobs, 'CalculationJobRepository', queue) + scope = SimpleNamespace(connection=SimpleNamespace(transaction=transaction), project=SimpleNamespace(project_id=uuid4(), user_id=uuid4())) + with pytest.raises(HTTPException) as error: + asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], args, 'host-call')) + assert error.value.status_code == 409 + assert 'already started' in str(error.value.detail) + queue.assert_not_called() + changed = args.model_copy(update={'name': 'changed'}) + with pytest.raises(HTTPException) as conflict: + asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], changed, 'host-call')) + assert conflict.value.detail == 'request_id already has different parameters' + queue.assert_not_called() + + +def test_evidence_receipt_commits_in_root_operational_transaction(monkeypatch): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock + from app.mcp import calculation_jobs as jobs, query_service, contexts + from app.infra.db.postgresql import mcp_queries + from app.mcp.models import QueryResult + original = binding().model_copy(update={'task_id': uuid4(), 'run_id': uuid4()}) + result = QueryResult(source='run_simulation', source_ref='original', queried_at=datetime.now(timezone.utc), semantics='analysis', data={}) + events = [] + async def commit(): + events.append('commit-reads') + @asynccontextmanager + async def transaction(): + events.append('begin-root-operational') + yield + events.append('commit-evidence') + task_repo = SimpleNamespace(transaction=transaction, lock=AsyncMock()) + receipt_repo = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=None), save_receipt=AsyncMock()) + service = SimpleNamespace(repo=task_repo, record_data_snapshot=AsyncMock(return_value=(SimpleNamespace(evidence_id='saved'), None))) + monkeypatch.setattr(jobs, 'authorize_original', AsyncMock(return_value=(original, SimpleNamespace(name='run_simulation'), None))) + monkeypatch.setattr(contexts, 'verify_task', AsyncMock()) + monkeypatch.setattr(query_service, 'task_service', lambda scope: service) + monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt_repo) + scope = SimpleNamespace(connection=SimpleNamespace(commit=commit), project=SimpleNamespace(project_id=original.project_id)) + attached = asyncio.run(jobs.attach_calculation_evidence(scope, {'job_id': uuid4()}, result)) + assert attached.evidence_id == 'saved' + assert events == ['commit-reads', 'begin-root-operational', 'commit-evidence'] + receipt_repo.save_receipt.assert_awaited_once() diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py index bed4462..d8d4347 100644 --- a/tests/unit/test_mcp_query.py +++ b/tests/unit/test_mcp_query.py @@ -37,31 +37,22 @@ PERMISSIONS = { } -def test_calculation_budget_outlives_query_default_and_still_times_out(runtime, monkeypatch): +def test_calculation_submission_returns_receipt_without_running_native_code(runtime, monkeypatch): + from app.mcp import calculation_jobs app, client, _, _ = runtime - app.settings.query_timeout_seconds = 1 monkeypatch.setattr(application, "authorize_query", AsyncMock()) - - async def execute(scope, bound, tool, args): - await asyncio.sleep(1.05) - return QueryResult( - source=tool.name, source_ref="test:calculation", semantics="analysis", - queried_at=datetime.now(timezone.utc), data={"completed": True}, - ) - - app.executor = execute - handle = binding().context_handle - arguments = { - "context_handle": handle, "start_time": "2026-10-08T12:00:00+08:00", + receipt = calculation_jobs.envelope("run_simulation", {"job_id": str(uuid4()), "status": "queued"}) + submit = AsyncMock(return_value=receipt) + monkeypatch.setattr(calculation_jobs, "submit_calculation", submit) + app.executor = AsyncMock(side_effect=AssertionError("native work entered API process")) + response = request(client, "tools/call", {"name": "run_simulation", "arguments": { + "context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00", "duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 2, - } - response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json() - assert response["result"].get("isError") is not True - assert response["result"]["structuredContent"]["data"] == {"completed": True} - arguments["timeout_seconds"] = 1 - response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json() - assert response["result"]["isError"] is True - assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_TIMEOUT" + }}).json() + assert response["result"]["structuredContent"]["data"]["status"] == "queued" + assert response["result"]["structuredContent"]["semantics"] == "task" + app.executor.assert_not_awaited() + assert submit.await_args.args[3].timeout_seconds == 2 def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime): @@ -80,34 +71,23 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime): assert calls == [] -def test_calculation_capacity_has_an_explicit_mcp_error(runtime, monkeypatch): - from threading import BoundedSemaphore - from app.mcp import analysis_service - - app, client, _, _ = runtime +def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch): + from app.mcp import calculation_jobs + _, client, _, _ = runtime monkeypatch.setattr(application, "authorize_query", AsyncMock()) - slots = BoundedSemaphore(2) - monkeypatch.setattr(analysis_service, "_slots", slots) - async def execute(*unused): - return await analysis_service.bounded_calculation(lambda: pytest.fail("occupied calculation entered")) - app.executor = execute - assert slots.acquire(blocking=False) and slots.acquire(blocking=False) - try: - response = request(client, "tools/call", {"name": "run_simulation", "arguments": { - "context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00", - "duration": 3600, "output_node_ids": ["J1"], - }}).json() - finally: - slots.release() - slots.release() - error = response["result"]["structuredContent"]["error"] - assert response["result"]["isError"] is True - assert error["code"] == "CALCULATION_CAPACITY_EXCEEDED" - assert "not started" in error["message"] + monkeypatch.setattr(calculation_jobs, "submit_calculation", AsyncMock(side_effect=HTTPException(409, "Calculation request_key was used with different inputs"))) + response = request(client, "tools/call", {"name": "run_simulation", "arguments": { + "context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00", + "duration": 3600, "output_node_ids": ["J1"], + }}).json() + assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_CONTEXT_STALE" def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch): app, client, calls, _ = runtime + from app.mcp import calculation_jobs + executor = AsyncMock(return_value=calculation_jobs.envelope("run_burst_location", {"job_id": str(uuid4()), "status": "queued"})) + monkeypatch.setattr(calculation_jobs, "submit_calculation", executor) monkeypatch.setattr(application, "authorize_query", AsyncMock()) schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"] if tool["name"] == "run_burst_location") @@ -118,7 +98,7 @@ def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeyp "scada_burst_end": "2026-10-08T13:00:00+08:00", }}).json() assert response["result"].get("isError") is not True - assert calls == [("session-1", "run_burst_location")] + assert calls == [] def settings(): diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index a63c7c0..c09a77d 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -1842,3 +1842,95 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e request_id=uuid4(), source_session_id="session-1", report=report, )) assert error.value.code == "EVIDENCE_NOT_FOUND" + + +@pytest.mark.anyio +@pytest.mark.parametrize("report_factory", [_current_report, _insufficient_report]) +async def test_final_report_rejected_while_original_run_has_pending_calculations(report_factory): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + pending_job = str(uuid4()) + repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job]) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + with pytest.raises(DomainError) as error: + await service.publish_artifacts(task['task_id'], AgentArtifactsCommand( + request_id=uuid4(), source_session_id='session-1', run_id=repository.run['run_id'], report=report_factory(), + )) + assert error.value.code == 'PENDING_CALCULATIONS' + assert error.value.status == 409 + assert error.value.recovery == 'retry_same_request' + assert error.value.refs == [pending_job] + repository.pending_calculation_jobs.assert_awaited_once_with( + str(task['task_id']), str(repository.run['run_id']), str(repository.run['execution_request_id']), + ) + assert not repository.reports + assert repository.run['status'] in {'claimed', 'running'} + + +@pytest.mark.anyio +async def test_completion_rejected_until_calculations_reach_terminal_state(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())]) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1') + with pytest.raises(DomainError) as error: + await service.complete_run(task['task_id'], repository.run['run_id'], command) + assert error.value.code == 'PENDING_CALCULATIONS' + assert repository.run['status'] in {'claimed', 'running'} + repository.pending_calculation_jobs.return_value = [] + # Once terminal, the normal final-artifact requirement runs again. + completed = await service.complete_run(task['task_id'], repository.run['run_id'], command) + assert repository.run['failure_code'] == 'ARTIFACT_INCOMPLETE' + assert completed.lifecycle_status == 'failed' + + +@pytest.mark.anyio +async def test_pending_calculation_query_matches_exact_original_execution_binding(): + job_id = uuid4() + connection = SimpleNamespace(execute=AsyncMock()) + connection.execute.return_value.fetchall = AsyncMock(return_value=[{'job_id': job_id}]) + project_id, task_id, run_id, request_id = (uuid4() for _ in range(4)) + repo = OperationalTaskRepository(connection, project_id) + assert await repo.pending_calculation_jobs(str(task_id), str(run_id), str(request_id)) == [str(job_id)] + sql, parameters = connection.execute.await_args.args + assert parameters == (str(project_id), str(task_id), str(run_id), str(request_id)) + assert "status IN ('queued','running','cancel_requested')" in sql + assert "binding->>'execution_request_id'=%s" in sql + + +@pytest.mark.anyio +async def test_interim_report_can_explain_progress_while_calculation_is_pending(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())]) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand( + request_id=uuid4(), source_session_id='session-1', run_id=repository.run['run_id'], + report={**_current_report(), 'stage': 'interim'}, + )) + assert result.report_revision == 1 + repository.pending_calculation_jobs.assert_not_awaited() + + +@pytest.mark.anyio +async def test_missing_calculation_migration_fails_finalization_closed(monkeypatch): + from psycopg.errors import UndefinedTable + from app.infra.db.postgresql import operational_tasks as storage + @asynccontextmanager + async def transaction(): + yield + connection = SimpleNamespace(transaction=transaction, execute=AsyncMock(side_effect=UndefinedTable('calculation_jobs missing'))) + monkeypatch.setattr(storage, 'lock_event_transaction', AsyncMock()) + repo = OperationalTaskRepository(connection, uuid4()) + with pytest.raises(DomainError) as error: + async with repo.transaction(): + await repo.pending_calculation_jobs(str(uuid4()), str(uuid4()), str(uuid4())) + assert error.value.code == 'DEPENDENCY_UNAVAILABLE' + assert error.value.status == 503