相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
163 lines
6.9 KiB
Python
163 lines
6.9 KiB
Python
"""取消语义的 MySQL 集成测试(文档 §6.4)。
|
||
|
||
覆盖三条真实链路:
|
||
1. 取消 `queued` 运行 → `202`,`agent_run.status=cancel_requested`,
|
||
且原请求在 `request_idempotency` 落 `failed + RUN_CANCELLED`;
|
||
2. **重复取消**(换幂等键、真实 HTTP)→ 返回同一状态,**不报错**;
|
||
3. 已成功 / 已失败的运行取消 → `409 RUN_NOT_CANCELLABLE`。
|
||
|
||
依赖真实 MySQL;所有写入在 finally 中按 session_id 清理。
|
||
"""
|
||
|
||
from uuid import uuid4
|
||
|
||
import httpx
|
||
import pytest
|
||
from sqlalchemy import delete, select
|
||
|
||
from app.api.dependencies.auth import build_request_context
|
||
from app.core.contracts import AgentRequest, RequestContext
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.main import create_app
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
|
||
from app.service.agent_run_application_service import AgentRunApplicationService
|
||
from app.worker.runtime import WorkerRuntime
|
||
|
||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("acceptance_registry")]
|
||
|
||
|
||
async def state_of(run_id: str) -> tuple[str, str | None, str]:
|
||
async with SessionFactory() as session:
|
||
run = await session.scalar(select(AgentRun).where(AgentRun.run_id == run_id))
|
||
assert run is not None
|
||
idem = await session.get(RequestIdempotency, run.idempotency_id)
|
||
assert idem is not None
|
||
return run.status, idem.error_code, idem.status
|
||
|
||
|
||
async def cleanup(session_id: str, run_id: str) -> None:
|
||
async with SessionFactory() as session, session.begin():
|
||
event_ids = select(DomainEventOutbox.event_id).where(
|
||
DomainEventOutbox.aggregate_id == run_id)
|
||
await session.execute(delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids)))
|
||
await session.execute(delete(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == run_id))
|
||
await session.execute(delete(InteractionAudit).where(
|
||
InteractionAudit.session_id == session_id))
|
||
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
|
||
await session.execute(delete(RequestIdempotency).where(
|
||
RequestIdempotency.session_id == session_id))
|
||
await session.execute(delete(ConversationMessage).where(
|
||
ConversationMessage.session_id == session_id))
|
||
|
||
|
||
def context() -> RequestContext:
|
||
return RequestContext(user_id="1", trace_id=str(uuid4()), roles=("customer",),
|
||
permissions=("agent:run", "agent:cancel"))
|
||
|
||
|
||
def client_for(ctx: RequestContext) -> httpx.AsyncClient:
|
||
application = create_app()
|
||
application.dependency_overrides[build_request_context] = lambda: ctx
|
||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=application),
|
||
base_url="http://test")
|
||
|
||
|
||
async def submit(ctx: RequestContext, session_id: str) -> str:
|
||
async with SessionFactory() as session:
|
||
accepted = await AgentRunApplicationService(session).accept(
|
||
AgentRequest(agent_type="customer_service", message="cancel integration",
|
||
session_id=session_id, idempotency_key=str(uuid4())),
|
||
ctx,
|
||
)
|
||
return accepted.run_id
|
||
|
||
|
||
async def test_cancel_is_idempotent_and_terminates_original_request() -> None:
|
||
ctx = context()
|
||
session_id = f"cancel-{uuid4()}"
|
||
run_id = ""
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
async with client_for(ctx) as client:
|
||
first = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
# 换一个幂等键重复取消:必须幂等返回同一状态,而不是 409。
|
||
second = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
|
||
assert first.status_code == 202
|
||
assert second.status_code == 202
|
||
assert first.json()["data"] == second.json()["data"]
|
||
assert first.json()["data"]["status"] == "cancel_requested"
|
||
assert first.json()["data"]["run_id"] == run_id
|
||
|
||
status, error_code, idem_status = await state_of(run_id)
|
||
assert status == "cancel_requested"
|
||
# 文档 §6.4:原请求以 failed + RUN_CANCELLED 结束,不扩展状态枚举。
|
||
assert (idem_status, error_code) == ("failed", "RUN_CANCELLED")
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def test_cancel_after_worker_completion_is_not_cancellable(
|
||
acceptance_registry,
|
||
) -> None:
|
||
ctx = context()
|
||
session_id = f"cancel-done-{uuid4()}"
|
||
run_id = ""
|
||
runtime = WorkerRuntime(
|
||
acceptance_registry, resolve_identity=lambda value: _identity(value, ctx)
|
||
)
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
await runtime.execute(run_id)
|
||
status, _error_code, _idem = await state_of(run_id)
|
||
assert status == "succeeded"
|
||
|
||
async with client_for(ctx) as client:
|
||
response = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
|
||
assert response.status_code == 409
|
||
assert response.json()["error"]["code"] == "RUN_NOT_CANCELLABLE"
|
||
assert response.json()["error"]["retryable"] is False
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def test_worker_advances_cancel_requested_to_cancelled_with_idempotency_marker() -> None:
|
||
"""worker 真正落 `cancelled` 时,幂等记录仍必须是 `failed + RUN_CANCELLED`。"""
|
||
ctx = context()
|
||
session_id = f"cancel-worker-{uuid4()}"
|
||
run_id = ""
|
||
try:
|
||
run_id = await submit(ctx, session_id)
|
||
async with client_for(ctx) as client:
|
||
response = await client.post(f"/api/v1/agent-runs/{run_id}/cancellations",
|
||
json={"reason": "user_cancelled"},
|
||
headers={"Idempotency-Key": str(uuid4())})
|
||
assert response.status_code == 202
|
||
|
||
runtime = WorkerRuntime(resolve_identity=lambda value: _identity(value, ctx))
|
||
await runtime.execute(run_id)
|
||
|
||
status, error_code, idem_status = await state_of(run_id)
|
||
assert status == "cancelled"
|
||
assert (idem_status, error_code) == ("failed", "RUN_CANCELLED")
|
||
finally:
|
||
if run_id:
|
||
await cleanup(session_id, run_id)
|
||
|
||
|
||
async def _identity(value: RequestContext, fallback: RequestContext) -> RequestContext:
|
||
del value
|
||
return fallback
|