mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:01:58 +00:00
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 不回归。
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""服务关停不是用户取消;重启后接着跑,也不能替人把后面的审批答了。
|
||
|
||
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, "关停打断了封存"
|