mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
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 更新。
246 lines
9.8 KiB
Python
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
|