Files
AgentLab/backend/tests/test_runs_cancel_waiting.py
T
ThearchyHelios 587bf7e27c 第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」,
校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮,
轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。

平台可信度(先写失败测试)
- Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则,
  工具集合变小时自查报警、改图回执列出变化
- 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修
- 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量
- 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因
- 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员
- 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行

后端
- 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、
  SQLite progress handler),可按数据源配置;超时报错与实际耗时一致
- 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存;
  自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存;
  检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors

前端
- 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时,
  一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子
- 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚;
  回放用量取那一刻的值
- 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口
- 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对
- 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条
- 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问

验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿;
check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
2026-09-27 10:16:32 +08:00

246 lines
9.8 KiB
Python

"""停在审批上的运行要能放弃;记录页要能只看「没保存的工作流」跑出来的运行。
放弃:等审批的运行此时没有执行任务,以前 POST /cancel 只会去取消任务,于是一律
回 409「已经不在执行中」。可它明明还挂着一条待审批——界面上右栏的「去审批」旁边
给不出「放弃这次运行」,审批卡留在全局待办里,只能批了它、让它跑完。服务重启
挂起的运行(interrupted、没有待审批)同样没法收掉。
真正的取消:状态置为 cancelled,待审批一并关掉(不再出现在待办里,也不能再批),
发 run.cancelled 并封存;在线的客户端收到结束标记。
"""
from __future__ import annotations
import asyncio
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Approval, Run, RunEvent, Workflow
from app.engine.runner import run_manager
from app.main import app
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
GATED = {
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
node("gate", "human", mode="approve", title="放行吗"),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.gate.approved }}"}]),
],
"edges": [{"source": "start", "target": "gate"}, {"source": "gate", "target": "out"}],
}
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 15.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"run {run_id} 没有进入 {statuses}")
async def _waiting_run() -> str:
run = await run_manager.start(graph=GATED, input_payload={"question": "x"})
await _wait(run.id, ("interrupted",))
await run_manager.wait_idle(run.id)
return run.id
async def _events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
)).scalars())
async def test_a_run_waiting_for_approval_can_be_abandoned(client):
run_id = await _waiting_run()
r = await client.post(f"/api/runs/{run_id}/cancel", headers={"X-Actor": quote("审核员甲")})
assert r.status_code == 200, r.text
assert r.json()["status"] == "cancelled"
row = (await client.get(f"/api/runs/{run_id}")).json()
assert row["status"] == "cancelled"
assert row["finished_at"] and row["manifest_seq"], "取消是终态:要收尾时间,也要封存"
assert row["usage"]["wait_ms"] >= 0
# 待审批一并关掉:不再出现在待办里,谁关的记下来
assert (await client.get("/api/approvals", params={"status": "pending",
"run_id": run_id})).json() == []
async with SessionLocal() as session:
approval = (await session.execute(
select(Approval).where(Approval.run_id == run_id))).scalar_one()
assert approval.status == "cancelled"
assert approval.resolved_by == "审核员甲" and approval.resolved_at is not None
events = await _events(run_id)
last = events[-1]
assert last.type == "run.cancelled", [e.type for e in events[-3:]]
assert set(last.data["timing"]) == {"wall_ms", "active_ms", "wait_ms"}
assert last.data.get("actor") == "审核员甲"
verified = (await client.get(f"/api/runs/{run_id}/verify")).json()
assert verified["sealed"] and verified["ok"], verified
async def test_an_abandoned_run_cannot_be_approved_or_continued(client):
run_id = await _waiting_run()
assert (await client.post(f"/api/runs/{run_id}/cancel")).status_code == 200
async with SessionLocal() as session:
approval = (await session.execute(
select(Approval).where(Approval.run_id == run_id))).scalar_one()
r = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True})
assert r.status_code == 409
r = await client.post(f"/api/runs/{run_id}/resume", json={"response": True})
assert r.status_code == 409
r = await client.post(f"/api/runs/{run_id}/continue", json={})
assert r.status_code == 409 and "已取消" in r.json()["detail"]
# 再取消一次是 409:已经是终态了
r = await client.post(f"/api/runs/{run_id}/cancel")
assert r.status_code == 409
async def test_a_run_suspended_by_a_restart_can_be_abandoned_too(client):
async with SessionLocal() as session:
run = Run(workflow_name="挂起", status="interrupted", graph=GATED, input={},
error="服务重启,运行已挂起,可从断点恢复")
session.add(run)
await session.commit()
run_id = run.id
r = await client.post(f"/api/runs/{run_id}/cancel")
assert r.status_code == 200, r.text
row = (await client.get(f"/api/runs/{run_id}")).json()
assert row["status"] == "cancelled"
assert (await _events(run_id))[-1].type == "run.cancelled"
async def test_finished_runs_still_refuse_to_be_cancelled(client):
async with SessionLocal() as session:
run = Run(workflow_name="完了", status="succeeded", graph=GATED, input={})
session.add(run)
await session.commit()
run_id = run.id
r = await client.post(f"/api/runs/{run_id}/cancel")
assert r.status_code == 409
assert "已完成" in r.json()["detail"]
assert (await client.post("/api/runs/nope/cancel")).status_code == 404
class _Socket:
"""够 stream_run 用的最小 WebSocket:记下发出去的帧。"""
def __init__(self) -> None:
self.query_params: dict[str, str] = {}
self.sent: list[dict] = []
self.closed = asyncio.Event()
async def accept(self) -> None:
pass
async def send_json(self, frame: dict) -> None:
self.sent.append(frame)
async def receive_text(self) -> str:
await self.closed.wait()
from starlette.websockets import WebSocketDisconnect
raise WebSocketDisconnect()
async def close(self) -> None:
self.closed.set()
async def test_a_live_stream_ends_when_the_waiting_run_is_abandoned():
"""停在审批上时在线的连接不关(它在等恢复)。放弃之后它要收到 run.cancelled 和结束标记。"""
from app.api.runs import stream_run
run_id = await _waiting_run()
async with SessionLocal() as session:
# 让连接以为运行还在进行:审批停下时在线的客户端就是这个处境
(await session.get(Run, run_id)).status = "running"
await session.commit()
socket = _Socket()
listening = asyncio.create_task(stream_run(socket, run_id))
await asyncio.sleep(0.2)
async with SessionLocal() as session:
(await session.get(Run, run_id)).status = "interrupted"
await session.commit()
assert await run_manager.cancel(run_id) == "cancelled"
await asyncio.wait_for(listening, 5)
types = [f.get("type") for f in socket.sent]
assert "run.cancelled" in types, types
assert socket.sent[-1]["type"] == "stream.end"
assert socket.sent[-1]["data"]["status"] == "cancelled"
# --------------------------------------------------------------------------
# 记录页:只看没保存的工作流跑出来的运行
# --------------------------------------------------------------------------
async def test_runs_can_be_filtered_to_unsaved_workflows(client):
async with SessionLocal() as session:
workflow = Workflow(name="已保存的", graph=GATED)
session.add(workflow)
await session.flush()
saved = Run(workflow_id=workflow.id, workflow_name="已保存的", graph=GATED, input={},
status="succeeded")
loose = Run(workflow_id=None, workflow_name="临时图", graph=GATED, input={},
status="succeeded")
session.add_all([saved, loose])
await session.commit()
saved_id, loose_id = saved.id, loose.id
ids = [r["id"] for r in (await client.get(
"/api/runs", params={"workflow_id": "__none__", "limit": 200})).json()]
assert loose_id in ids and saved_id not in ids
assert all(r["workflow_id"] is None for r in (await client.get(
"/api/runs", params={"workflow_id": "__none__"})).json())
async def test_stopping_a_running_run_records_who_stopped_it(client, monkeypatch):
from app.engine import compiler
from app.engine.schema import NodeType
async def slow(state, ctx):
await asyncio.sleep(5)
return {"nodes": {ctx.node.id: "x"}}
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, slow)
graph = {"nodes": [node("start", "input"), node("work", "transform", mode="template", template="x")],
"edges": [{"source": "start", "target": "work"}]}
run = await run_manager.start(graph=graph, input_payload={})
await _wait(run.id, ("running",))
await asyncio.sleep(0.2)
r = await client.post(f"/api/runs/{run.id}/cancel", headers={"X-Actor": quote("值班员")})
assert r.json()["status"] == "stopping"
await _wait(run.id, ("cancelled",))
await run_manager.wait_idle(run.id)
last = (await _events(run.id))[-1]
assert last.type == "run.cancelled" and last.data["actor"] == "值班员", last.data