Files
AgentLab/backend/tests/test_shutdown.py
T
ThearchyHelios 03830d4815 服务重启不再把运行记成"用户取消";接着跑也不再替人答掉下一个审批
dev.sh 开着 --reload,改一个后端文件就是一次关停。三件事以前都是错的:

1. 关停时在跑的运行一律记成 cancelled、原因"用户取消"——之后 resume 和
   continue 都拒绝它,尽管 checkpoint 完好。现在关停时收到的取消记成 interrupted
   (和强杀后启动扫描的处理一致),留一条 server_shutdown 日志说明原委,
   不发 run.cancelled。

2. 关停会打断正在收尾的运行:状态已落、封存和 run.finished 还没写完就被取消。
   现在已进入收尾的运行不取消,等它写完。

3. 因重启而中断的运行没有在等人的 interrupt,resume 却照样发 Command(resume=…):
   那个值会被后面遇到的第一个 interrupt() 当成答复——一个还没轮到的人工关卡被
   自动作答(审查时的实测);当前版本的 LangGraph 则直接在内部崩掉
   (UnboundLocalError: resume_is_map),运行判 failed。现在没有待答的 interrupt
   时从断点接着跑(输入 None)。

运行详情页:中断了却没有待审批的运行,以前显示"等待人工介入"却没有任何可点的
东西,现在说明原因并给「接着跑」。用真实库里的中断运行看过:无待审批的出横幅,
有待审批的仍由审批卡片处理。README 加了对应的常见问题。

test_shutdown.py 3 例,修前全部失败:关停不是用户取消;重启后接着跑,前面跑完的
节点不重跑、后面的人工关卡真的停下来等人;关停不打断收尾(封存照样写完)。
分两步修、中间跑了一次,确认第二例在关停修好之后确实撞上了 resume 的崩溃。
422 → 425 passed,连跑 8 轮全绿;check-guards / decode / stream / canvas-layout 不回归。
2026-09-25 16:05:14 +08:00

157 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""服务关停不是用户取消;重启后接着跑,也不能替人把后面的审批答了。
dev.sh 开着 --reload,改任何一个后端文件就是一次关停。以前关停时在跑的运行
一律记成 cancelled、原因写"用户取消"——之后 resume 和 continue 都拒绝它,尽管
checkpoint 完好无损。
更要紧的是接着跑那一步:这类运行没有在等人的 interrupt,resume 却照样发一个
Command(resume=…)。LangGraph 会把这个值交给后面遇到的第一个 interrupt()——一个
还没轮到的人工关卡就这么被自动作答了,审批记录从没建过,运行直接跑完。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Approval, Run, RunEvent
from app.engine import compiler
from app.engine.runner import run_manager
from app.engine.schema import NodeType
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes),
"edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def _status(run_id: str) -> str:
async with SessionLocal() as session:
return (await session.get(Run, run_id)).status
async def _until(check, timeout: float = 15.0):
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
if await check():
return
await asyncio.sleep(0.05)
raise AssertionError("等超时了")
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 _started(run_id: str, node_id: str) -> int:
return sum(1 for e in await _events(run_id) if e.type == "node.started" and e.node_id == node_id)
@pytest.fixture
def slow_once(monkeypatch):
"""transform 节点第一次跑时卡住(好让关停撞上它),之后立刻返回。"""
calls = {"n": 0}
original = compiler.RUNNERS[NodeType.TRANSFORM]
async def runner(state, ctx):
if ctx.node.id == "slow":
calls["n"] += 1
if calls["n"] == 1:
await asyncio.sleep(30)
return await original(state, ctx)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, runner)
return calls
GRAPH = chain(
node("start", "input", fields=[{"name": "question"}]),
node("a", "transform", mode="template", template="前面这步", assign_to="x"),
node("slow", "transform", mode="template", template="慢的这步"),
node("gate", "human", mode="approve", title="发出去之前看一眼"),
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.x }}"}]),
)
async def _run_into_shutdown() -> str:
run = await run_manager.start(graph=GRAPH, input_payload={"question": "随便"})
await _until(lambda: _started_is(run.id, "slow"))
await run_manager.shutdown()
return run.id
async def _started_is(run_id: str, node_id: str) -> bool:
return await _started(run_id, node_id) > 0
async def test_shutdown_is_not_a_user_cancel(slow_once):
run_id = await _run_into_shutdown()
async with SessionLocal() as session:
run = await session.get(Run, run_id)
assert run.status == "interrupted", f"关停被记成了 {run.status}:{run.error}"
assert "用户取消" not in (run.error or "")
events = await _events(run_id)
assert not [e for e in events if e.type == "run.cancelled"]
assert "server_shutdown" in [e.data.get("code") for e in events if e.type == "log"]
async def test_resuming_after_a_restart_does_not_answer_the_next_human_gate(slow_once):
run_id = await _run_into_shutdown()
await run_manager.setup() # 重启
await run_manager.resume(run_id, None)
async def reached_gate() -> bool:
return await _status(run_id) in ("interrupted", "succeeded", "failed")
await _until(reached_gate)
await asyncio.sleep(0.2)
status = await _status(run_id)
assert status == "interrupted", f"人工关卡被跳过了,运行直接 {status}"
async with SessionLocal() as session:
pending = list((await session.execute(
select(Approval).where(Approval.run_id == run_id, Approval.status == "pending")
)).scalars())
assert [a.node_id for a in pending] == ["gate"], "关卡在等人,审批记录却没建"
assert await _started(run_id, "a") == 1, "接着跑把前面跑完的节点又跑了一遍"
async def test_shutdown_lets_a_finishing_run_finish(monkeypatch):
"""正在收尾的运行不被关停打断:状态、封存都得落完。"""
original = run_manager._seal
async def slow_seal(run_id):
await asyncio.sleep(0.5)
await original(run_id)
monkeypatch.setattr(run_manager, "_seal", slow_seal)
run = await run_manager.start(graph=chain(
node("start", "input", fields=[{"name": "question"}]),
node("out", "output", fields=[]),
), input_payload={"question": "随便"})
async def done() -> bool:
return await _status(run.id) == "succeeded"
await _until(done)
await run_manager.shutdown() # 撞在封存的半路上
async with SessionLocal() as session:
sealed = await session.get(Run, run.id)
assert sealed.manifest_hash, "关停打断了封存"