Files
AgentLab/backend/app/engine/runner.py
T
ThearchyHelios c980da65d0 失败的运行能从断点接着跑了,前面跑过的节点不重来
库里 28 条 failed 的构成说明了为什么值得单独开这条路:

    模型 id 写错(404)        7
    循环条件语法错             3
    401                        3
    工具参数名不对             3
    缺必填输入                 2

大半是**配置错了**,改一下就能跑。而"改一下再跑"原来意味着整张图从头来
一遍——前面花两分钟查完的表结构、跑完的 SQL 全部重跑,尽管那些结果一直
躺在 checkpoint 里。checkpointer 本来就一直开着(thread_id = run_id),
挡住这条路的只是 resume 里的一行状态判定:它只认 interrupted,failed 进不去。

**带着改过的图续。** 不让改配置的话这功能基本没用:上面那几类续下去会
立刻再撞同一个错。所以 /runs/{id}/continue 接受一张改过的图,画布上点
「接着跑」带的就是当前编辑中的那张——用户正是改完它才点的。

**但只准改配置。** checkpoint 是按节点名存的(编译时 add_node(node.id)),
而节点配置是运行时从 spec 现读的(NodeContext.cfg)。所以只改配置对得上,
增删节点或改连线就对不上——续下去会拿着错位的通道状态跑出一份似是而非的
结果,比直接报错糟得多。加了 schema.topology_of() 做这道判据:节点 id +
类型 + 边,不含任何配置;对不上就 409,让用户重新发起。

这正是 resume 那边踩过的坑的同一类:不看有没有 checkpoint 就发 Command,
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。所以这里也先问
一次 aget_state,没有待跑节点就拒绝,而不是硬发一个 None 下去。

时间线上要说清楚是接着跑、保了几步("从「lp」接着跑,前面 2 个节点的结果
保留")——否则用户看到时间线又动起来,分不清这次是续的还是整张图又跑了一遍。

验证:后端 249 passed(新增 test_continue_run.py 7 条,核心那条断言的是
node.started 事件——前面的节点确实没有再出现)。端到端真跑了一遍:
第一遍 start → prep → lp 挂在 lp,改好条件后第二遍只跑 lp → out,
成果里带着 prep 那步的结果;改了结构的图被 409 拒绝;没有失败的运行时
画布上不显示这个按钮。

顺带:_drive 里 resumed 的判定漏了 payload is None 这种(从 checkpoint
直接往下走),run.started 事件会说成不是续跑。
2026-09-20 11:54:16 +08:00

599 lines
25 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.
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from datetime import datetime, timezone
from typing import Any
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.types import Command
from sqlalchemy import select, update
from app.core.bus import bus
from app.core.config import settings
from app.core.events import EventType, RunEventModel
from app.db.base import SessionLocal
from app.db.models import Approval, Run, RunEvent, Workflow
from app.engine.compiler import compile_graph, initial_state
from app.engine.context import NodeError, RunContext
from app.engine.schema import GraphSpec, topology_of, validate_graph
logger = logging.getLogger(__name__)
# 流式增量(正文 token、思考 delta)量太大,只做实时推送不落库;
# 轨迹里正文靠 node.finished 的 preview,思考靠调用结束时的单条 llm.thinking 汇总
_EPHEMERAL = {EventType.LLM_TOKEN, EventType.LLM_THINKING_DELTA}
class RunManager:
"""负责一次运行的完整生命周期:启动、串流、中断、恢复、取消。"""
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task[None]] = {}
self._checkpointer: AsyncSqliteSaver | None = None
self._cm: Any = None
self._semaphore = asyncio.Semaphore(settings.max_concurrent_runs)
# ---------------- 生命周期 ----------------
async def setup(self) -> None:
settings.ensure_dirs()
self._cm = AsyncSqliteSaver.from_conn_string(str(settings.checkpoint_path))
self._checkpointer = await self._cm.__aenter__()
# 上次进程被强杀时留下的记录。running 和 queued 要分开处理:
# running 至少跑过一步、有 checkpoint,可以续;queued 还在排队,
# 一个 checkpoint 都没写过,"恢复"它只会从 START 用默认值重跑一遍,
# 还丢掉 run.input——那不是恢复,是伪造一份结果。
async with SessionLocal() as session:
await session.execute(
update(Run)
.where(Run.status == "running")
.values(status="interrupted", error="服务重启,运行已挂起,可从断点恢复")
)
await session.execute(
update(Run)
.where(Run.status == "queued")
.values(status="failed", error="服务重启时这次运行还在排队,没有可恢复的进度,请重新发起")
)
await session.commit()
async def shutdown(self) -> None:
for task in list(self._tasks.values()):
task.cancel()
await asyncio.gather(*self._tasks.values(), return_exceptions=True)
if self._cm is not None:
with contextlib.suppress(Exception):
await self._cm.__aexit__(None, None, None)
@property
def checkpointer(self) -> AsyncSqliteSaver:
if self._checkpointer is None:
raise RuntimeError("RunManager 还没初始化")
return self._checkpointer
# ---------------- 事件 ----------------
async def _emit(
self,
run_id: str,
event_type: EventType | str,
*,
node_id: str | None = None,
data: dict[str, Any] | None = None,
persist: bool = True,
) -> None:
event = RunEventModel(
run_id=run_id,
seq=bus.next_seq(run_id),
type=EventType(str(event_type)),
node_id=node_id,
data=data or {},
)
# 先落库,再广播。顺序反过来会漏事件,而且是结构性的漏:
#
# 订阅者的完整性靠"读历史 + 接实时"两段拼起来。它先订阅、再读历史,
# 于是需要这条保证——**提交早于我这次 SELECT 的,在历史里;提交晚于
# 我这次 SELECT 的,广播也晚于我订阅,在实时流里**。这条保证只有在
# "提交先于广播"时才成立。
#
# 先广播的话,一条在订阅建立之前广播、却在读历史之后才提交的事件,
# 两边都拿不到。实测跑一次 25ms 的图,六条事件里的 run.started 有
# 一半几率就这么没了——界面表现为某个节点永远在转圈。
if persist and event.type not in _EPHEMERAL:
async with SessionLocal() as session:
session.add(
RunEvent(
run_id=run_id,
seq=event.seq,
type=str(event.type),
node_id=event.node_id,
ts=event.ts,
data=event.data,
)
)
await session.execute(
update(Run).where(Run.id == run_id).values(last_seq=event.seq)
)
await session.commit()
await bus.publish(event)
async def note(
self, run_id: str, event_type: EventType | str, *, node_id: str | None = None,
**data: Any,
) -> None:
"""给 API 层用的事件入口(比如 formal 启动时记录口径升版处置)。"""
await self._emit(run_id, event_type, node_id=node_id, data=data)
# ---------------- 启动 ----------------
async def start(
self,
*,
graph: dict[str, Any],
input_payload: dict[str, Any],
workflow_id: str | None = None,
workflow_name: str = "",
memory_scope: str = "default",
collection: str = "default",
run_class: str = "exploratory",
version: int | None = None,
version_hash: str | None = None,
started_by: str | None = None,
) -> Run:
spec = GraphSpec.model_validate(graph)
report = validate_graph(spec)
if not report.ok:
raise ValueError(
"图校验未通过:" + ";".join(i.message for i in report.issues if i.level == "error")
)
async with SessionLocal() as session:
run = Run(
workflow_id=workflow_id,
workflow_name=workflow_name,
status="queued",
graph=graph,
input=input_payload,
run_class=run_class,
version=version,
version_hash=version_hash,
started_by=started_by,
)
run.thread_id = run.id # 一个 run 一条 checkpoint 线程
session.add(run)
await session.commit()
await session.refresh(run)
self._tasks[run.id] = asyncio.create_task(
self._drive(
run.id,
spec,
initial_state(input_payload),
workflow_id=workflow_id,
memory_scope=memory_scope,
collection=collection,
)
)
return run
async def resume(
self, run_id: str, response: Any, *, approval_id: str | None = None,
actor: str | None = None,
) -> Run:
"""从人工介入的断点继续。
两件事以前是错的:
一是不看有没有 checkpoint 就发 Command。重启扫描会把 queued(从没跑过、
没有任何 checkpoint)也标成 interrupted,对这种 run 发 Command,LangGraph
会拿一个空 state 从 START 重跑整张图——run.input 完全没被注入,最后还落成
succeeded。跑出来的是一份用默认值算的假结果。
二是同一 superstep 里有多个 interrupt 时,只发标量 Command 会让 LangGraph
直接抛 RuntimeError 打爆整个 run,而所有 pending 审批还都被写成同一个回复。
现在按 interrupt_id 逐条收集,凑齐了才继续,没凑齐就继续等下一个人回复。
"""
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if not run:
raise KeyError(f"找不到运行 {run_id}")
if run.status not in ("interrupted",):
raise ValueError(f"当前状态是 {run.status},无法恢复")
spec = GraphSpec.model_validate(run.graph)
workflow_id = run.workflow_id
# 先问引擎:这条线程到底停在哪、还等着哪些 interrupt
run_ctx = RunContext(run_id=run_id, thread_id=run_id, spec=spec, workflow_id=workflow_id)
app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer)
config = {"configurable": {"thread_id": run_id}}
snapshot = await app.aget_state(config)
waiting = list(getattr(snapshot, "interrupts", None) or [])
if not waiting and not getattr(snapshot, "next", None):
# 没有断点可续。多半是重启时被标成 interrupted 的 queued 运行。
raise ValueError(
"这次运行没有可恢复的断点(很可能重启前还没真正开始跑)。"
"请重新发起一次运行,而不是恢复——继续下去只会用默认值跑出一份假结果。"
)
answers: dict[str, Any] = {}
async with SessionLocal() as session:
pending = list((
await session.execute(
select(Approval).where(
Approval.run_id == run_id, Approval.status == "pending"
)
)
).scalars())
# 定位这次回复的是哪一条:显式指定优先,否则只在唯一 pending 时才敢猜
target = None
if approval_id:
target = next((a for a in pending if a.id == approval_id), None)
if target is None:
raise ValueError(f"审批 {approval_id} 不在待处理列表里")
elif len(pending) == 1:
target = pending[0]
elif len(pending) > 1:
raise ValueError(
f"这次运行有 {len(pending)} 条待处理的人工介入,"
"恢复时必须指定 approval_id 说明回复的是哪一条"
)
now = datetime.now(timezone.utc)
if target is not None:
target.status = "answered" # 已回复,但还没提交给引擎
target.response = (
_safe(response) if isinstance(response, dict) else {"value": _safe(response)}
)
target.resolved_at = now
if actor:
target.resolved_by = actor
await session.commit()
# 凑齐没有?引擎还在等的每一个 interrupt 都得有答案
answered = list((
await session.execute(
select(Approval).where(
Approval.run_id == run_id,
Approval.status.in_(["answered", "pending"]),
)
)
).scalars())
by_iid = {a.interrupt_id: a for a in answered if a.interrupt_id}
missing = []
for item in waiting:
iid = getattr(item, "id", None)
rec = by_iid.get(iid)
if rec is None or rec.status != "answered":
missing.append(iid)
continue
payload = rec.response or {}
answers[iid] = payload.get("value", payload) if isinstance(payload, dict) else payload
if missing:
# 还差人没回。保持 interrupted,把已回复的那条留在 answered 上等齐
await session.commit()
run = await session.get(Run, run_id)
return run
# 齐了:正式落 resolved,然后驱动引擎
for rec in answered:
if rec.status == "answered":
rec.status = "resolved"
run = await session.get(Run, run_id)
bus.set_seq(run_id, run.last_seq)
run.status = "running"
run.error = None
await session.commit()
await session.refresh(run)
# 单个 interrupt 用标量(LangGraph 两种都认),多个必须用 {interrupt_id: value} 映射
command = (
Command(resume=response)
if len(waiting) <= 1
else Command(resume=answers)
)
await self._emit(run_id, EventType.RUN_RESUMED, data={"response": _safe(response)})
self._tasks[run_id] = asyncio.create_task(
self._drive(run_id, spec, command, workflow_id=workflow_id)
)
return run
async def continue_failed(
self, run_id: str, *, graph: dict[str, Any] | None = None, actor: str | None = None
) -> Run:
"""从失败的地方接着跑,而不是整张图从头来一遍。
为什么值得单独开这条路:库里 28 条 failed 里,模型 id 写错、401、循环
条件语法错、缺必填输入加起来占了大半——全是**配置错了**,改一下就能跑。
而现在"改一下再跑"意味着前面花两分钟查完的表结构、跑完的 SQL 全部重来。
checkpointer 一直开着(thread_id = run_id),那些结果本来就在库里躺着。
graph 可以带一张改过的图,但只准改配置:节点 id、类型、连线必须和原来
一模一样。checkpoint 是按节点名存的,改了结构就对不上,续下去会拿着
错位的通道状态跑出一份似是而非的结果——那比直接报错糟得多。
"""
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if not run:
raise KeyError(f"找不到运行 {run_id}")
if run.status != "failed":
raise ValueError(
f"当前状态是 {run.status},只有失败的运行能接着跑。"
"(等人工介入的用恢复,已完成的重新发起一次。)"
)
spec = GraphSpec.model_validate(run.graph)
workflow_id = run.workflow_id
if graph is not None:
revised = GraphSpec.model_validate(graph)
if topology_of(revised) != topology_of(spec):
raise ValueError(
"接着跑只能改节点配置,不能增删节点或改连线——"
"断点是按节点名存的,结构变了就对不上了。"
"要改结构请重新发起一次运行。"
)
report = validate_graph(revised)
if not report.ok:
first = next((i.message for i in report.issues if i.level == "error"), "")
raise ValueError(f"改过的图没有通过校验:{first}")
spec = revised
# 先问引擎到底停在哪。没有待跑的节点就不是"能接着跑"的情形——
# 硬发一个 None 下去,LangGraph 会从 START 重跑整张图
run_ctx = RunContext(run_id=run_id, thread_id=run_id, spec=spec, workflow_id=workflow_id)
app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer)
config = {"configurable": {"thread_id": run_id}}
snapshot = await app.aget_state(config)
pending = [str(n) for n in (getattr(snapshot, "next", None) or [])]
if not pending:
raise ValueError(
"这次运行没有留下可以接着跑的断点(多半是还没跑到第一个节点就挂了)。"
"请重新发起一次运行——继续下去只会从头跑一遍,还看不出来。"
)
done = [n for n in (snapshot.values or {}).get("nodes", {}) if n not in pending]
async with SessionLocal() as session:
run = await session.get(Run, run_id)
bus.set_seq(run_id, run.last_seq)
run.status = "running"
run.error = None
if graph is not None:
run.graph = spec.model_dump(mode="json")
if actor:
run.started_by = actor
await session.commit()
await session.refresh(run)
labels = {n.id: n.title for n in spec.nodes}
await self._emit(
run_id, EventType.RUN_RESUMED,
data={
"from": pending,
# 说清楚"哪些没重来"——否则用户看到时间线又动起来,
# 分不清这次是接着跑还是整张图又跑了一遍
"message": f"从「{labels.get(pending[0], pending[0])}」接着跑"
+ (f",前面 {len(done)} 个节点的结果保留" if done else ""),
},
)
self._tasks[run_id] = asyncio.create_task(
self._drive(run_id, spec, None, workflow_id=workflow_id)
)
return run
async def cancel(self, run_id: str) -> bool:
task = self._tasks.get(run_id)
if task and not task.done():
task.cancel()
return True
return False
def is_active(self, run_id: str) -> bool:
task = self._tasks.get(run_id)
return bool(task and not task.done())
# ---------------- 执行 ----------------
async def _drive(
self,
run_id: str,
spec: GraphSpec,
payload: Any,
*,
workflow_id: str | None = None,
memory_scope: str = "default",
collection: str = "default",
) -> None:
started = time.perf_counter()
status = "succeeded"
error: str | None = None
final_state: dict[str, Any] = {}
async with self._semaphore:
try:
async with SessionLocal() as session:
await session.execute(
update(Run)
.where(Run.id == run_id)
.values(status="running", started_at=datetime.now(timezone.utc))
)
await session.commit()
await self._emit(
run_id,
EventType.RUN_STARTED,
data={"nodes": len(spec.nodes),
"resumed": payload is None or isinstance(payload, Command)},
)
run_ctx = RunContext(
run_id=run_id,
thread_id=run_id,
spec=spec,
workflow_id=workflow_id,
memory_scope=memory_scope,
collection=collection,
)
app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer)
config = {
"configurable": {"thread_id": run_id},
"recursion_limit": settings.max_graph_steps,
}
interrupted = False
async for mode, chunk in app.astream(
payload, config=config, stream_mode=["custom", "updates"]
):
if mode == "custom":
await self._handle_custom(run_id, chunk)
elif mode == "updates":
if await self._handle_updates(run_id, chunk):
interrupted = True
snapshot = await app.aget_state(config)
final_state = dict(snapshot.values or {})
if interrupted or snapshot.interrupts:
status = "interrupted"
elif snapshot.next:
status = "interrupted"
except asyncio.CancelledError:
status = "cancelled"
error = "用户取消"
await self._emit(run_id, EventType.RUN_CANCELLED, data={})
raise
except Exception as e: # noqa: BLE001
status = "failed"
# NodeError 的 message 本来就是写给人看的("人工驳回:…"),
# 再套一层类型名只会让界面上的错误更难读
error = str(e) if isinstance(e, NodeError) else f"{type(e).__name__}: {e}"
logger.exception("run %s 失败", run_id)
await self._emit(run_id, EventType.RUN_FAILED, data={"error": error})
finally:
elapsed = int((time.perf_counter() - started) * 1000)
await self._finalize(run_id, status, error, final_state, elapsed)
self._tasks.pop(run_id, None)
# 运行到终态就把沙箱会话收掉。thread_id 每个 run 都是新的,
# 不收的话每跑一次带代码节点的图就多一台常驻 microVM(几百 MB),
# 而且没有任何自动回收路径会碰它——闲置回收只在"下次有人执行
# 代码"时才触发。interrupted 要留着,人回来还得接着跑。
if status in ("succeeded", "failed", "cancelled"):
with contextlib.suppress(Exception):
from app.sandbox.manager import sandbox_manager
await sandbox_manager.cleanup(run_id)
async def _handle_custom(self, run_id: str, chunk: Any) -> None:
"""节点通过 get_stream_writer 发来的事件。"""
if not isinstance(chunk, dict) or not chunk.get("__agentlab__"):
return
await self._emit(
run_id,
chunk.get("type", EventType.LOG),
node_id=chunk.get("node_id"),
data=chunk.get("data") or {},
)
async def _handle_updates(self, run_id: str, chunk: Any) -> bool:
"""LangGraph 的状态更新。这里只关心中断信号 —— 节点级事件已经由 custom 流覆盖。"""
if not isinstance(chunk, dict):
return False
interrupts = chunk.get("__interrupt__")
if not interrupts:
return False
for item in interrupts:
value = getattr(item, "value", item)
interrupt_id = getattr(item, "id", None)
payload = value if isinstance(value, dict) else {"value": value}
async with SessionLocal() as session:
existing = (
await session.execute(
select(Approval).where(
Approval.run_id == run_id,
Approval.interrupt_id == interrupt_id,
Approval.status == "pending",
)
)
).scalar_one_or_none()
if existing is None:
session.add(
Approval(
run_id=run_id,
node_id=str(payload.get("node_id") or ""),
interrupt_id=interrupt_id,
mode=str(payload.get("mode") or "approve"),
title=str(payload.get("title") or "需要你确认"),
payload=payload,
schema_=payload.get("schema") or {},
)
)
await session.commit()
await self._emit(
run_id,
EventType.RUN_INTERRUPTED,
node_id=payload.get("node_id"),
data={"interrupt_id": interrupt_id, "payload": _safe(payload)},
)
return True
async def _finalize(
self,
run_id: str,
status: str,
error: str | None,
state: dict[str, Any],
elapsed_ms: int,
) -> None:
usage = dict(state.get("usage") or {})
usage["duration_ms"] = elapsed_ms
output = dict(state.get("output") or {})
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run:
run.status = status
run.error = error
run.output = output
run.usage = usage
if status != "interrupted":
run.finished_at = datetime.now(timezone.utc)
# 终态时对全部已落库事件计算清单哈希:事后任何对事件流的
# 增删改都会与这个值对不上。中断态不算——事件还会继续追加。
from app.core.artifact_store import manifest_hash as _manifest
rows = await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id)
.order_by(RunEvent.seq)
)
run.manifest_hash = _manifest([tuple(r) for r in rows])
await session.commit()
if status == "succeeded":
await self._emit(
run_id,
EventType.RUN_FINISHED,
data={"output": _safe(output), "usage": usage, "duration_ms": elapsed_ms},
)
if status in ("succeeded", "failed", "cancelled"):
await bus.close(run_id)
def _safe(value: Any, limit: int = 4000) -> Any:
"""把任意对象裁剪成能安全放进事件负载的形状。"""
if isinstance(value, str):
return value[:limit]
if isinstance(value, dict):
return {k: _safe(v, limit // 2) for k, v in list(value.items())[:50]}
if isinstance(value, list):
return [_safe(v, limit // 2) for v in value[:50]]
if isinstance(value, (int, float, bool)) or value is None:
return value
return str(value)[:limit]
run_manager = RunManager()