Files
AgentLab/backend/tests/test_notify_run.py
T
ThearchyHelios 845809486a 驳回不再记成失败(后端):人的驳回收成 cancelled + end_reason=rejected,消费方都读这一个字段
为什么:动作确认、工具 / 代码审批、审批驳回即终止、填写 / 编辑被驳回、双盲裁决的驳回以前抛普通 NodeError,运行记成 failed。
通知推「实例失败」,定时推「运行失败」,发布单把驳回过的试运行说成「试运行失败了」并挡住发布,记录页还给「继续运行」
(接着跑只会读到同一个驳回答复、再停一次)。人的决定被说成故障,每个消费方各写一份映射,已经漏了四处(05-已知遗留 (a)、总方案 X9)。

做了什么:
- engine/context 新增 NodeRejected(NodeError),带 node_id、kind、decision(reject / expired_reject)、actor、note;六处驳回改抛它,
  原话一字不变。待办超时驳回的答复带 expired,Decision.expired 据此记成 expired_reject。
- runner._drive 在通用 except 之前单独接住:status=cancelled、end_reason=rejected、error 写原话、error_node 写那一步,
  run.cancelled 带 reason/node_id/label/kind/decision/actor/note;关审批、回收沙箱、关流、封存走取消的那一套。手动停止
  (执行中取消、放弃断点上的运行)写 end_reason=stopped,run.cancelled 带 reason=stopped。
- 继续运行:被驳回的 409 code=run_rejected「被驳回的运行不能接着跑:改好内容后重新发起」;存量 failed+驳回(失败那一步上
  最近一条处理完的待办是驳回,和实例页同一份判断 resolve.legacy_rejected)也拒。RunOut 带 end_reason。
- apps/resolve.instance_state:cancelled+rejected 和存量 failed+驳回说同一句(「「付款」未确认(被驳回)」「「x」被驳回」)。
- notify_run:状态那一句取自 instance_state,被驳回的走「实例结束」、ok=False,走到「已退回」成果的不再写「已完成」;
  手动停止照旧不推。顺带修 04-对外集成与触达 B1:发起人是认证身份(run.origin.actor_verified)时 users 传 [started_by],
  个人渠道订阅了实例完成 / 失败终于收得到;自报署名不算。
- 定时 webhook:cancelled+rejected 按「运行结束」推「未出具(被驳回)」、写驳回的那一步,不写备注和原话、不画成功色;
  手动停止仍不推。
- 发布单:试运行被驳回不算 plan.trial_failed,记 warning plan.trial_rejected「最近一次试运行在「x」被驳回、没有走完」(带白话)。
- (h) 重启时排队中的运行改成 failed 时补发 run.failed(同一句原因),时间线里有了失败原因这一行;不写结束时间、不封存,同以前。
- (f) tasks.tick / actions.tick 交回这一拍处理过的 id 列表(调度循环只记条数),测试改成只断言自己造的待办;先加了预置
  一条无关到期待办的 fixture,旧的按条数断言当场失败(3 == 1),改完通过。

已封存的老运行不改写。测试:新增 test_run_rejected(五种关卡参数化、超时驳回、继续运行拒绝含存量、手动停止、补发 run.failed、
instance_state 新老形状同一句)、test_notify_run(驳回走实例结束、手动停止不推、已退回不写已完成、个人渠道 B1 两条);
webhook、发布单、动作确认、双盲、审批的老断言跟上。去掉 runner 里接 NodeRejected 的分支回跑,新测试 8 条失败。
2026-10-16 10:14:27 +08:00

200 lines
9.1 KiB
Python

"""应用实例结束的通知(notify/service.notify_run):说法取自实例页,个人渠道按「我发起的实例」推。
- 被驳回的实例(cancelled + end_reason=rejected)走「实例结束」,状态写实例页那一句(「「review」被驳回」),不推
「实例失败」(05-已知遗留 (a)、总方案 X6);
- 走到「已退回」那种结局的运行,实例页写成果节点的标题、记成人的决定,推送以前却写「已完成」:现在两边一致;
- 个人渠道勾了「实例完成 / 失败」以前永远收不到(_channels 拿到的 users 是空的):发起人是认证过的身份时,他自己的
个人渠道收得到;自报的署名不算(04-对外集成与触达 B1)。
"""
from __future__ import annotations
import asyncio
import json
import uuid
import httpx
import pytest
from sqlalchemy import select
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, User
from app.engine.runner import run_manager
from app.notify import core, service
URL = "https://hooks.example.com/agentlab/run-3b8e1f"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await service.drain()
await run_manager.shutdown()
@pytest.fixture
def received(monkeypatch):
got: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
got.append(request)
return httpx.Response(200, json={"ok": True})
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
# 个人渠道发送前要解析域名、核对不是内网:测试里不连外网
async def public(url: str) -> None:
return None
monkeypatch.setattr(service, "personal_url_problem", public)
return got
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def flow(**human):
nodes = [node("start", "input", fields=[{"name": "q"}]),
node("review", "human", mode="approve", title="审一下", **human),
node("ok", "output", fields=[{"name": "结论", "value": "通过"}]),
node("no", "output", fields=[{"name": "结论", "value": "退回"}])]
edges = [{"source": "start", "target": "review"},
{"source": "review", "target": "ok", "sourceHandle": "approved"},
{"source": "review", "target": "no", "sourceHandle": "rejected"}]
return {"nodes": nodes, "edges": edges}
async def _app(events: list[str]) -> tuple[str, str]:
async with SessionLocal() as session:
channel = NotifyChannel(name=f"群-{uuid.uuid4().hex[:6]}", format="generic", url=encrypt(URL), events=[])
session.add(channel)
app = App(name=f"报销-{uuid.uuid4().hex[:6]}", published_version=1)
session.add(app)
await session.flush()
session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={},
spec={"notify": [{"channel": channel.id, "events": events}]}))
await session.commit()
return app.id, channel.id
async def _wait(run_id: str, statuses: tuple[str, ...]) -> Run:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row is not None and row.status in statuses:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def _decide(run_id: str, approved: bool) -> Run:
"""经待办服务回复(同界面上点同意 / 驳回):实例页认结局靠待办的结论。"""
from app.api.deps import Principal
from app.db.models import Task
from app.process import tasks
await _wait(run_id, ("interrupted",))
await run_manager.wait_idle(run_id)
for _ in range(100):
async with SessionLocal() as session:
task = (await session.execute(select(Task).where(Task.run_id == run_id, Task.status == "pending")))\
.scalars().first()
if task is not None:
break
await asyncio.sleep(0.05)
await tasks.respond(task.id, Principal(username=None, role="admin", mode="off"),
decision="approve" if approved else "reject", note="金额 45678 不对")
final = await _wait(run_id, ("succeeded", "failed", "cancelled"))
await run_manager.wait_idle(run_id)
await service.drain()
return final
async def _deliveries(channel_id: str) -> list[tuple[str, str]]:
async with SessionLocal() as session:
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.channel_id == channel_id))).scalars()
return [(r.event, r.status) for r in rows]
async def test_a_rejected_instance_is_pushed_as_finished_in_the_instance_page_words(received):
app_id, channel_id = await _app(["run.finished", "run.failed"])
run = await run_manager.start(graph=flow(stop_on_reject=True), input_payload={"q": "x"}, app_id=app_id,
app_version=1)
final = await _decide(run.id, approved=False)
assert (final.status, final.end_reason) == ("cancelled", "rejected")
assert await _deliveries(channel_id) == [("run.finished", "ok")], "驳回不进「实例失败」"
[request] = received
body = json.loads(request.content)
assert body["event"] == "run.finished" and body["title"].endswith("的实例已结束")
assert "状态:「review」被驳回" in body["text"] and "失败" not in body["text"]
assert "45678" not in request.content.decode(), "驳回的备注不进消息(可能带着数)"
async def test_a_manual_stop_is_not_pushed(received):
app_id, channel_id = await _app(["run.finished", "run.failed"])
run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1)
await _wait(run.id, ("interrupted",))
await run_manager.wait_idle(run.id)
assert await run_manager.cancel(run.id) == "cancelled"
await service.drain()
assert await _deliveries(channel_id) == [] and received == []
async def test_reaching_the_returned_outcome_no_longer_says_completed(received):
app_id, channel_id = await _app(["run.finished"])
run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1)
final = await _decide(run.id, approved=False)
assert final.status == "succeeded" and final.output.get("结论") == "退回"
[request] = received
body = json.loads(request.content)
# 实例页写走到的成果节点(no),记成人的决定:推送也这么写,不再说「已完成」
assert "状态:no" in body["text"] and "已完成" not in body["title"] and body["title"].endswith("的实例已结束")
async def _personal(username: str, events: list[str]) -> str:
async with SessionLocal() as session:
user = (await session.execute(select(User).where(User.username == username))).scalar_one_or_none()
if user is None:
user = User(username=username, role="user")
session.add(user)
await session.flush()
channel = NotifyChannel(name="我的", scope="user", owner_id=user.id, format="generic", url=encrypt(URL),
events=events)
session.add(channel)
await session.commit()
return channel.id
async def _finished_instance(app_id: str, *, started_by: str, verified: bool) -> str:
async with SessionLocal() as session:
run = Run(workflow_name="报销", status="succeeded", graph=flow(), input={}, app_id=app_id, app_version=1,
started_by=started_by, origin_kind="app_entry",
origin={"kind": "app_entry", "actor": started_by, "actor_verified": verified})
session.add(run)
await session.commit()
return run.id
async def test_the_initiator_s_personal_channel_receives_my_instance_finished(received):
"""B1:个人渠道订阅了 run.finished,发起人自己发起的实例结束了收得到;应用自己没订阅也一样。"""
app_id, _ = await _app([])
name = f"uma{uuid.uuid4().hex[:6]}"
mine = await _personal(name, ["run.finished"])
run_id = await _finished_instance(app_id, started_by=name, verified=True)
assert await service.notify_run(run_id, "run.finished") == 1
assert await _deliveries(mine) == [("run.finished", "ok")]
body = json.loads(received[0].content)
assert body["title"].endswith("的实例已完成") and "状态:已完成" in body["text"]
async def test_a_self_declared_initiator_does_not_reach_a_personal_channel(received):
"""关登录时署名是自己填的,谁都能填别人的名字:不按它推给那个人的个人渠道。"""
app_id, _ = await _app([])
name = f"vic{uuid.uuid4().hex[:6]}"
mine = await _personal(name, ["run.finished"])
run_id = await _finished_instance(app_id, started_by=name, verified=False)
assert await service.notify_run(run_id, "run.finished") == 0
assert await _deliveries(mine) == [] and received == []