mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:11:59 +00:00
a1d5dcd0ac
为什么:反方审稿逐句串行反查、双盲复算按项盲算,一个核查节点就能把一段执行的 600 秒用光,整次运行被中止,装着反查、盲算的 @task 没进 checkpoint,「继续运行」从头再来一遍、再次超时。核查节点要先知道还剩多少时间,才能在上限之内收尾。 deadline.allot(时长上限, grace=收尾宽限) 给核查节点在开始每一项之前问:先给下游留出余量(60 秒或这一段总时长的 10%,取大的:60 秒够成果节点复核、再做一两次普通的模型调用;时长上限调大了的,节点多半本来就慢,余量跟着放大),再扣掉收尾宽限,剩下的比配置的时长短就收紧到它;收紧之后不到 min(配置的时长, 收尾宽限)(一次模型调用的时间)就不再开始。不经 runner 执行时原样放行、时长上限还是传进来的那个值。 runner 只改一行:async with deadline, segment(deadline.when(), max_run_seconds),截止时刻就是 asyncio.timeout 的那一个、同一个钟。测试钉住 allot 的各档、余量随总时长放大、退出(含出错)时清掉,以及节点和节点里的 @task 都看到同一个截止时刻、审批恢复后的那一段重新计时。
1444 lines
75 KiB
Python
1444 lines
75 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import copy
|
||
import json
|
||
import logging
|
||
import time
|
||
from collections.abc import Callable, Sequence
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||
from langgraph.errors import GraphRecursionError
|
||
from langgraph.types import Command
|
||
from sqlalchemy import case, func, select, update
|
||
|
||
from app.core.bus import bus
|
||
from app.core.config import settings
|
||
from app.core.errors import describe_exception, raw_detail
|
||
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.approval import read_decision
|
||
from app.engine.compiler import compile_graph, initial_state
|
||
from app.engine.context import NodeError, RunContext
|
||
from app.engine.deadline import segment
|
||
from app.engine.replay import PROTOCOL, PROTOCOL_KEY, protocol_of
|
||
from app.engine.schema import GraphSpec, NodeType, loop_steps, topology_of, validate_graph
|
||
from app.tools.datasource import RUN_VERSIONS_KEY
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 流式增量(正文 token、思考 delta)量太大,只做实时推送不落库;
|
||
# 轨迹里正文靠 node.finished 的 preview,思考靠调用结束时的单条 llm.thinking 汇总
|
||
_EPHEMERAL = {EventType.LLM_TOKEN, EventType.LLM_THINKING_DELTA}
|
||
|
||
#: RunContext.extra 里放这次运行的选项(Run.options)的键。节点从这里读发起时定下的快照、不读设置的
|
||
#: 当前值:审批停下期间改了设置,恢复后的那一段也按发起时的,重放和正式运行才可复现
|
||
RUN_OPTIONS_KEY = "run_options"
|
||
|
||
|
||
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)
|
||
#: 正在关停。此时收到的取消来自关停而不是用户,运行要记成可恢复的中断
|
||
self._closing = False
|
||
#: 已经跑完、正在落状态和封存的运行。关停不去打断它们
|
||
self._finalizing: set[str] = set()
|
||
#: 谁停的:取消执行任务时记下,run.cancelled 和关掉的审批上写得出人
|
||
self._stopped_by: dict[str, str | None] = {}
|
||
|
||
# ---------------- 生命周期 ----------------
|
||
|
||
async def setup(self) -> None:
|
||
self._closing = False
|
||
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:
|
||
# 先把落后于库里实际最后一条的 last_seq 改正(为什么会落后见 _seq_floor)。内存里的序号计数刚清零,
|
||
# 接下来各处都按 last_seq 接号:本模块的几处另外兜了底,按需裁判记判定(api/evidence.py 的
|
||
# _append_judged)没有,不改正的话它重启后第一次追加就撞上已有的序号。有 (run_id, seq) 索引,
|
||
# 每次运行一次索引查找。updated_at 不动:这是补记录,不是运行有了变化
|
||
latest = select(func.max(RunEvent.seq)).where(RunEvent.run_id == Run.id).scalar_subquery()
|
||
await session.execute(
|
||
update(Run).where(func.coalesce(Run.last_seq, 0) < latest)
|
||
.values(last_seq=latest, updated_at=Run.updated_at)
|
||
.execution_options(synchronize_session=False))
|
||
stranded = list((await session.execute(
|
||
select(Run.id, Run.last_seq).where(Run.status == "running")
|
||
)).all())
|
||
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()
|
||
# 进程被强杀时连 server_shutdown 日志都来不及发,事件流停在半路。补上这一条,
|
||
# 回放它的客户端才知道它是挂起了,而不是还在跑
|
||
for run_id, last_seq in stranded:
|
||
bus.set_seq(run_id, last_seq or 0)
|
||
await self._emit(run_id, EventType.LOG, data={
|
||
"level": "warn", "code": "server_shutdown",
|
||
"message": "服务上次未正常关闭,这次运行已中断;可从断点继续运行",
|
||
})
|
||
|
||
async def shutdown(self) -> None:
|
||
self._closing = True
|
||
# 只等还没结束的:结束了的没什么可等,而且它可能属于另一个事件循环,
|
||
# gather 会直接抛 "future belongs to a different loop"
|
||
pending = [t for t in self._tasks.values() if not t.done()]
|
||
for run_id, task in list(self._tasks.items()):
|
||
# 正在收尾的不打断:状态和封存写到一半,比哪一步都没写更难收拾
|
||
if not task.done() and run_id not in self._finalizing:
|
||
task.cancel()
|
||
await asyncio.gather(*pending, return_exceptions=True)
|
||
self._tasks.clear()
|
||
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,
|
||
ts: float | None = None,
|
||
) -> None:
|
||
event = self._event(run_id, event_type, node_id=node_id, data=data, ts=ts)
|
||
# 先落库,再广播。顺序反过来会漏事件,而且是结构性的漏:
|
||
#
|
||
# 订阅者的完整性靠"读历史 + 接实时"两段拼起来。它先订阅、再读历史,
|
||
# 于是需要这条保证——**提交早于我这次 SELECT 的,在历史里;提交晚于
|
||
# 我这次 SELECT 的,广播也晚于我订阅,在实时流里**。这条保证只有在
|
||
# "提交先于广播"时才成立。
|
||
#
|
||
# 先广播的话,一条在订阅建立之前广播、却在读历史之后才提交的事件,
|
||
# 两边都拿不到。实测跑一次 25ms 的图,六条事件里的 run.started 有
|
||
# 一半几率就这么没了——界面表现为某个节点永远在转圈。
|
||
if persist and event.type not in _EPHEMERAL:
|
||
async with SessionLocal() as session:
|
||
await _stage(session, event)
|
||
await session.commit()
|
||
await bus.publish(event)
|
||
|
||
@staticmethod
|
||
def _event(
|
||
run_id: str,
|
||
event_type: EventType | str,
|
||
*,
|
||
node_id: str | None = None,
|
||
data: dict[str, Any] | None = None,
|
||
ts: float | None = None,
|
||
) -> RunEventModel:
|
||
return RunEventModel(
|
||
run_id=run_id,
|
||
seq=bus.next_seq(run_id),
|
||
type=EventType(str(event_type)),
|
||
node_id=node_id,
|
||
data=data or {},
|
||
# 节点里发出的事件带着它发生的时刻。以前一律取转发时刻:并行的成员
|
||
# 先做完的那条也要排到整轮结束才被转发,时间线上的耗时就全错了
|
||
**({"ts": ts} if ts else {}),
|
||
)
|
||
|
||
async def note(
|
||
self, run_id: str, event_type: EventType | str, *, node_id: str | None = None,
|
||
**data: Any,
|
||
) -> None:
|
||
"""给运行之外的代码往运行里记一条事件。发起时就定下的事件不走这里,交给 start 的 notes。"""
|
||
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,
|
||
approval_default: str | None = None,
|
||
options: dict[str, Any] | None = None,
|
||
schedule_id: str | None = None,
|
||
notes: Sequence[tuple[EventType | str, str | None, dict[str, Any]]] = (),
|
||
) -> Run:
|
||
"""建运行记录、交给执行任务。options 是这次运行的选项快照,schedule_id 是发起它的定时任务(可空)。
|
||
|
||
notes 是发起时就定下的事件 (类型, node_id, 数据),比如正式运行按什么级别出具、口径升版怎么处置。它们和
|
||
运行记录同一个事务提交,排在 run.started 前面:写不进去就不建运行,不会出现运行已经在执行、调用方却
|
||
只拿到一个异常(定时任务会把它记成未能发起);也不会和执行抢先后,运行结束得快时落到封存之后。
|
||
"""
|
||
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")
|
||
)
|
||
if approval_default is None:
|
||
approval_default = await _approval_default(run_class=run_class)
|
||
# 拷一份:调用方手上的 dict 之后再改,不该改到这次运行的快照
|
||
options = copy.deepcopy(options) if options else {}
|
||
|
||
async with SessionLocal() as session:
|
||
from app.data.table_versions import pin_lock
|
||
from app.tools.trust import run_snapshot
|
||
|
||
tool_trust = await run_snapshot(session, run_class=run_class)
|
||
agent_limits = await _agent_limits(session)
|
||
# 读上传表格的当前指针到运行记录提交,持着版本存储的锁:中间有人重传的话,发布后的回收
|
||
# 看不到这条还没提交的运行,会把刚固定的快照删掉(table_versions.pin_lock)
|
||
async with pin_lock():
|
||
data_versions = await _data_versions(session, spec)
|
||
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,
|
||
memory_scope=memory_scope,
|
||
collection=collection,
|
||
approval_default=approval_default,
|
||
tool_trust=tool_trust,
|
||
agent_limits=agent_limits,
|
||
data_versions=data_versions,
|
||
options=options,
|
||
schedule_id=schedule_id,
|
||
)
|
||
run.thread_id = run.id # 一个 run 一条 checkpoint 线程
|
||
session.add(run)
|
||
staged: list[RunEventModel] = []
|
||
if notes:
|
||
await session.flush() # 事件要带上运行的 id
|
||
staged = [self._event(run.id, kind, node_id=node_id, data=data)
|
||
for kind, node_id, data in notes]
|
||
for event in staged:
|
||
await _stage(session, event, run)
|
||
await session.commit()
|
||
await session.refresh(run)
|
||
# 先落库再广播,理由同 _emit;执行任务还没建,run.started 必定排在它们后面
|
||
for event in staged:
|
||
await bus.publish(event)
|
||
|
||
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,
|
||
approval_default=approval_default,
|
||
tool_trust=tool_trust,
|
||
agent_limits=agent_limits,
|
||
data_versions=data_versions,
|
||
options=options,
|
||
)
|
||
)
|
||
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("运行记录不存在")
|
||
if run.status not in ("interrupted",):
|
||
raise ValueError(_cannot_resume(run.status))
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
workflow_id = run.workflow_id
|
||
carried = _carried(run)
|
||
|
||
# 先问引擎:这条线程到底停在哪、还等着哪些 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(
|
||
"这次运行没有可恢复的断点,可能在服务重启前尚未开始执行。请重新发起运行。"
|
||
)
|
||
if stale := await _stale_replay(run_id, spec, snapshot):
|
||
raise ValueError(
|
||
f"这次运行停在「{stale}」时系统已升级,断点格式不再兼容,继续会让「{stale}」中"
|
||
f"已执行的工具重复执行。请放弃这次运行并重新发起"
|
||
)
|
||
|
||
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("这条审批已处理过,或不属于这次运行。请刷新页面查看最新状态")
|
||
elif len(pending) == 1:
|
||
target = pending[0]
|
||
elif len(pending) > 1:
|
||
raise ValueError(
|
||
f"这次运行有 {len(pending)} 条待处理的审批,"
|
||
"无法确定本次回复对应哪一条,请在对应的审批卡上操作"
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
if target is not None:
|
||
# 存全文,不能用 _safe 截:多条审批并存时,交给引擎的就是这一份
|
||
# (见下面 answers)。截过的版本会把人工改过的长稿砍掉一截再送进节点
|
||
full = json.loads(json.dumps(response, ensure_ascii=False, default=str))
|
||
# 条件更新:上面读到它还是 pending,到这里之间可能已经被放弃运行关掉
|
||
# (或者另一个人先批了)。无条件写回去,会把关掉的审批又改成已回复
|
||
marked = await session.execute(
|
||
update(Approval)
|
||
.where(Approval.id == target.id, Approval.status == "pending")
|
||
.values(status="answered", # 已回复,但还没提交给引擎
|
||
response=full if isinstance(full, dict) else {"value": full},
|
||
resolved_at=now, **({"resolved_by": actor} if actor else {}))
|
||
)
|
||
if marked.rowcount != 1:
|
||
await session.rollback()
|
||
raise ValueError("这条审批已处理过,或这次运行已被放弃。请刷新页面查看最新状态")
|
||
await session.commit()
|
||
# 审批卡上点的是「始终允许」:这个工具以后改由门控把关。只有带 trust_key 的
|
||
# 审批才有这个按钮(MCP / 自定义工具、探索运行),别的审批带了 always 也不理。
|
||
# 这次运行里本节点后面的调用由节点自己从答复里认出来(见 llm.py 的 granted)
|
||
trust_key = (target.payload or {}).get("trust_key")
|
||
if isinstance(trust_key, str) and trust_key and read_decision(response).always:
|
||
from app.tools.trust import set_trust
|
||
|
||
await set_trust(session, trust_key, "gated")
|
||
|
||
# 凑齐没有?引擎还在等的每一个 interrupt 都得有答案
|
||
answered = list((
|
||
await session.execute(
|
||
select(Approval).where(
|
||
Approval.run_id == run_id,
|
||
Approval.status.in_(["answered", "pending"]),
|
||
).execution_options(populate_existing=True)
|
||
)
|
||
).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 {}
|
||
# 只拆我们自己包的那层 {"value": x}(裸值回复)。带 approved / note / args
|
||
# 的结构体要原样交给节点——以前一律取 value,只要回复里带了内容,
|
||
# 驳回、备注、改过的参数就全在这里丢了
|
||
wrapped = isinstance(payload, dict) and set(payload) == {"value"}
|
||
answers[iid] = payload["value"] if wrapped else payload
|
||
|
||
if missing:
|
||
# 还差人没回。保持 interrupted,把已回复的那条留在 answered 上等齐
|
||
await session.commit()
|
||
run = await session.get(Run, run_id)
|
||
if run.status != "interrupted":
|
||
# 审批没了是因为运行在这期间被放弃了:不能回一个「已记下」
|
||
raise ValueError(_cannot_resume(run.status))
|
||
return run
|
||
|
||
# 齐了:正式落 resolved,然后驱动引擎。谁批的跟着交给节点,
|
||
# human.resolved 里才写得出签批人
|
||
ready = [rec for rec in answered if rec.status == "answered"]
|
||
actors: dict[str, str | None] = {rec.node_id: rec.resolved_by for rec in ready}
|
||
# 回到 running 也用条件更新占住:从上面读状态到这里隔了好几次 await,
|
||
# 放弃运行可能正好在这中间把它收成了 cancelled(已经封存、发过 run.cancelled)。
|
||
# 无条件写 running,一次取消了的运行就又被拉起来跑了
|
||
await _claim(session, run_id, ("interrupted",), _cannot_resume)
|
||
await session.execute(
|
||
update(Approval)
|
||
.where(Approval.id.in_([rec.id for rec in ready]), Approval.status == "answered")
|
||
.values(status="resolved")
|
||
)
|
||
run = await session.get(Run, run_id)
|
||
bus.set_seq(run_id, await _seq_floor(session, run_id, run.last_seq))
|
||
await session.commit()
|
||
await session.refresh(run)
|
||
|
||
# 单个 interrupt 用标量(LangGraph 两种都认),多个必须用 {interrupt_id: value} 映射。
|
||
#
|
||
# 一个都没在等:这是服务重启时停下的运行(启动扫描或关停时标成 interrupted),
|
||
# 只能从断点接着跑(输入传 None)。以前这里照样发 Command(resume=…)——那个值
|
||
# 会被后面遇到的第一个 interrupt() 当成答复,一个还没轮到的人工关卡就这么被
|
||
# 自动作答了;当前版本的 LangGraph 则直接在内部崩掉。两样都不是"接着跑"
|
||
command: Command | None
|
||
if not waiting:
|
||
command = None
|
||
elif len(waiting) == 1:
|
||
command = Command(resume=response)
|
||
else:
|
||
command = Command(resume=answers)
|
||
await self._emit(run_id, EventType.RUN_RESUMED, data=(
|
||
{"message": "服务重启时中断的运行,从断点继续运行", "actor": actor} if command is None
|
||
else {"response": _safe(response), "actor": actor}
|
||
))
|
||
self._tasks[run_id] = asyncio.create_task(
|
||
self._drive(run_id, spec, command, workflow_id=workflow_id, **carried,
|
||
resumed=sorted(_replayed(snapshot)),
|
||
actors=actors)
|
||
)
|
||
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 是按节点名存的,改了结构就对不上,续下去会拿着
|
||
错位的通道状态跑出一份似是而非的结果——那比直接报错糟得多。
|
||
|
||
服务重启挂起的运行(interrupted 且没有待审批)也走这里:它没有在等谁,
|
||
断点完好,从断点驱动和失败后接着跑是同一件事。界面上两者都叫「接着跑」。
|
||
"""
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise KeyError("运行记录不存在")
|
||
has_pending = run.status == "interrupted" and (await session.execute(
|
||
select(Approval.id).where(
|
||
Approval.run_id == run_id, Approval.status.in_(["pending", "answered"])
|
||
).limit(1)
|
||
)).first() is not None
|
||
if not (run.status == "failed" or (run.status == "interrupted" and not has_pending)):
|
||
raise ValueError(_cannot_continue(run.status, has_pending))
|
||
observed = run.status
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
workflow_id = run.workflow_id
|
||
carried = _carried(run)
|
||
|
||
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(
|
||
"这次运行没有可继续的断点,可能在执行第一个节点前就已中断。请重新发起运行。"
|
||
)
|
||
if stale := await _stale_replay(run_id, spec, snapshot):
|
||
raise ValueError(
|
||
f"这次运行停在「{stale}」时系统已升级,断点格式不再兼容,继续运行会让「{stale}」中"
|
||
f"已执行的工具重复执行。请重新发起运行"
|
||
)
|
||
|
||
done = [n for n in (snapshot.values or {}).get("nodes", {}) if n not in pending]
|
||
async with SessionLocal() as session:
|
||
# 和 resume 一样用条件更新占住:读状态之后,放弃运行、或者另一次接着跑
|
||
# 可能已经先动了它
|
||
await _claim(session, run_id, (observed,),
|
||
lambda status: _cannot_continue(status, False),
|
||
**({"graph": spec.model_dump(mode="json")} if graph is not None else {}),
|
||
**({"started_by": actor} if actor else {}))
|
||
run = await session.get(Run, run_id)
|
||
bus.set_seq(run_id, await _seq_floor(session, run_id, run.last_seq))
|
||
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 ""),
|
||
"actor": actor,
|
||
},
|
||
)
|
||
self._tasks[run_id] = asyncio.create_task(
|
||
self._drive(run_id, spec, None, workflow_id=workflow_id, **carried, resumed=pending)
|
||
)
|
||
return run
|
||
|
||
async def cancel(self, run_id: str, *, actor: str | None = None) -> str | None:
|
||
"""停止一次运行。返回 "stopping"(执行任务已取消,终态由它自己收尾)、
|
||
"cancelled"(停在断点上的,这里直接收成终态),或者 None(已经是终态、或不存在)。
|
||
|
||
停在断点上的运行——等审批的、服务重启挂起的——手上没有执行任务。以前这里只会
|
||
去取消任务,于是它们一律「不在执行中」:待审批的那张卡只能批了它、让它跑完。
|
||
"""
|
||
if run_id in self._finalizing:
|
||
# 执行任务已经跑完、正在落状态——刚发出 run.interrupted、还在写 interrupted
|
||
# 的那一刻,界面上已经给出了「放弃这次运行」。这时取消任务会把收尾从中间
|
||
# 打断:状态停在 running、审批还挂着、任务却没了,再点一次只剩 409。
|
||
# 等它落完,再按落下的状态处理
|
||
await self.wait_idle(run_id, timeout=30.0)
|
||
if run_id in self._finalizing: # 收尾卡住了(库被锁着):也不去打断它
|
||
return None
|
||
task = self._tasks.get(run_id)
|
||
if task and not task.done():
|
||
self._stopped_by[run_id] = actor
|
||
task.cancel()
|
||
return "stopping"
|
||
return "cancelled" if await self._abandon(run_id, actor) else None
|
||
|
||
async def _abandon(self, run_id: str, actor: str | None) -> bool:
|
||
"""把停在断点上的运行收成 cancelled:关掉待审批、发 run.cancelled、封存。"""
|
||
now = datetime.now(timezone.utc)
|
||
async with SessionLocal() as session:
|
||
# 条件更新占住这次收尾:同一时刻有人批了它(resume 把它改回 running),
|
||
# 或者另一个取消先到了,这里就什么都不做
|
||
claimed = await session.execute(
|
||
update(Run).where(Run.id == run_id, Run.status == "interrupted")
|
||
.values(status="cancelled", finished_at=now)
|
||
)
|
||
if claimed.rowcount != 1:
|
||
await session.rollback()
|
||
return False
|
||
closed = await _close_approvals(session, run_id, actor, now)
|
||
run = await session.get(Run, run_id)
|
||
timing = await _timing(session, run, 0, now)
|
||
usage = dict(run.usage or {})
|
||
# 停下时记的用量里还没有这次停在审批期间问的质询,这里补上(上次停下时计过的不再计)
|
||
inquiry = (await _usage_of_events(session, run_id)).get("inquiry")
|
||
if inquiry or usage.get("inquiry"):
|
||
usage = _with_inquiry(usage, inquiry)
|
||
run.usage = {**usage, "duration_ms": timing["active_ms"], **timing}
|
||
run.error = "用户取消:等待审批时放弃了这次运行" if closed else "用户取消:挂起期间放弃了这次运行"
|
||
bus.set_seq(run_id, await _seq_floor(session, run_id, run.last_seq))
|
||
# 和状态同一个事务提交,理由同 _finalize
|
||
event = self._event(run_id, EventType.RUN_CANCELLED, data={
|
||
"timing": timing, "actor": actor,
|
||
"message": f"放弃了这次运行,{closed} 条待审批一并关闭" if closed
|
||
else "放弃了这次挂起的运行",
|
||
})
|
||
await _stage(session, event, run)
|
||
await session.commit()
|
||
|
||
await bus.publish(event)
|
||
await self._seal(run_id)
|
||
# 等审批时在线的连接没有关(它还在等恢复),这里让它收到终态再结束
|
||
await bus.close(run_id)
|
||
with contextlib.suppress(Exception):
|
||
from app.sandbox.manager import sandbox_manager
|
||
|
||
await sandbox_manager.cleanup(run_id)
|
||
return True
|
||
|
||
def is_active(self, run_id: str) -> bool:
|
||
task = self._tasks.get(run_id)
|
||
return bool(task and not task.done())
|
||
|
||
async def wait_idle(self, run_id: str, timeout: float = 10.0) -> None:
|
||
"""等这次运行手上的执行任务收完尾。不取消它,也不抛它的异常。"""
|
||
task = self._tasks.get(run_id)
|
||
if task is not None and not task.done():
|
||
await asyncio.wait({task}, timeout=timeout)
|
||
|
||
# ---------------- 执行 ----------------
|
||
|
||
async def _drive(
|
||
self,
|
||
run_id: str,
|
||
spec: GraphSpec,
|
||
payload: Any,
|
||
*,
|
||
workflow_id: str | None = None,
|
||
memory_scope: str = "default",
|
||
collection: str = "default",
|
||
approval_default: str | None = None,
|
||
tool_trust: dict[str, str] | None = None,
|
||
agent_limits: dict[str, Any] | None = None,
|
||
data_versions: dict[str, Any] | None = None,
|
||
options: dict[str, Any] | None = None,
|
||
resumed: list[str] | tuple[str, ...] = (),
|
||
actors: dict[str, str | None] | None = None,
|
||
) -> None:
|
||
started = time.perf_counter()
|
||
status = "succeeded"
|
||
error: str | None = None
|
||
final_state: dict[str, Any] = {}
|
||
#: 终态事件(run.failed / run.cancelled)等 _finalize 算完耗时再发,timing 才带得上
|
||
terminal: tuple[EventType, dict[str, Any]] | None = None
|
||
error_node: str | None = None
|
||
|
||
deadline: asyncio.Timeout | None = None
|
||
# 停在审批上的那一刻:这一段的执行算到这里为止。后面写审批记录、发 run.interrupted 是收尾,
|
||
# 等待从 run.interrupted 算起——以前执行一直计到 finally,收尾那一截执行和等待两边都算,
|
||
# active + wait 比墙钟还长(机器一忙就差出几十毫秒)
|
||
paused: float | None = None
|
||
async with self._semaphore:
|
||
try:
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row is not None:
|
||
row.status = "running"
|
||
# started_at 只在第一次开始时写。以前每段 _drive 都覆盖它,
|
||
# 审批恢复、接着跑之后,运行的"开始时间"就成了最后一段的开始
|
||
if row.started_at is None:
|
||
row.started_at = datetime.now(timezone.utc)
|
||
row.finished_at = None
|
||
row.error_node_id = None
|
||
await session.commit()
|
||
await self._emit(
|
||
run_id,
|
||
EventType.RUN_STARTED,
|
||
data={"nodes": len(spec.nodes),
|
||
"resumed": payload is None or isinstance(payload, Command),
|
||
# 实际生效的运行参数进事件流,也就进了封存清单:
|
||
# 正式运行查的是哪个库、审批默认是什么,事后可复核
|
||
"memory_scope": memory_scope, "collection": collection,
|
||
"approval_default": approval_default,
|
||
# MCP / 自定义工具的信任三档(只列不是「等审批」的;正式运行记 formal)
|
||
"tool_trust": _trust_event(tool_trust),
|
||
# agent 护栏的上限(步数兜底、令牌 / 金额预算),None 是升级前的运行
|
||
"agent_limits": agent_limits,
|
||
# 这次运行固定的上传表格版本(源 id → 快照 id、源名)。续跑、恢复记的是
|
||
# 运行记录里的那份,含运行中途第一次用到时补上的;None 是升级前的运行
|
||
"data_versions": data_versions,
|
||
# 这一段是按哪一版重放协议跑的,恢复时据此认出升级前做下的工作
|
||
"replay_protocol": PROTOCOL,
|
||
# 发起时定下的运行选项(核查设置之类),每一段都记、记的是同一份。没有选项的
|
||
# 运行不带这个键,和加选项之前的 run.started 逐字相同
|
||
**({"options": options} if options else {})},
|
||
)
|
||
|
||
run_ctx = RunContext(
|
||
run_id=run_id,
|
||
thread_id=run_id,
|
||
spec=spec,
|
||
workflow_id=workflow_id,
|
||
memory_scope=memory_scope,
|
||
collection=collection,
|
||
approval_default=approval_default,
|
||
tool_trust=tool_trust,
|
||
agent_limits=agent_limits,
|
||
# 数据版本放 extra:数据源工具经 ToolContext.data_versions 拿到(tools/datasource.py
|
||
# 的 run_versions)。复制一份:运行中途补固定的源就地写进这个 dict,同一段执行里
|
||
# 后面的节点都看得到,但不该碰到别处(运行记录、事件)手上的那个对象
|
||
extra={"resumed": set(resumed), "actors": dict(actors or {}),
|
||
RUN_VERSIONS_KEY: dict(data_versions) if data_versions is not None else None,
|
||
# 同理也是一份拷贝:节点读它,不该改到运行记录那份
|
||
RUN_OPTIONS_KEY: copy.deepcopy(options or {})},
|
||
)
|
||
app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer)
|
||
# 循环按自己声明的轮数另记一份预算,全局上限只管没人把关的环
|
||
extra_steps = loop_steps(spec)
|
||
config = {
|
||
"configurable": {"thread_id": run_id},
|
||
"recursion_limit": settings.max_graph_steps + extra_steps,
|
||
# 进每个 checkpoint 的 metadata:恢复时据此认出旧版引擎停下的断点
|
||
"metadata": {PROTOCOL_KEY: PROTOCOL},
|
||
}
|
||
|
||
interrupted = False
|
||
# 单次执行的墙钟上限。以前 max_run_seconds 只存在于配置里,没有任何
|
||
# 代码执行它——一个挂住的模型或工具能把运行和它占着的并发名额一直拖着。
|
||
# 按"这一段执行"计:等人工审批时 _drive 已经结束,那段时间不算。
|
||
deadline = asyncio.timeout(settings.max_run_seconds)
|
||
# 截止时刻同时交给节点(engine/deadline.py):核查节点据此在上限之内收尾,不把整次运行拖到被中止
|
||
async with deadline, segment(deadline.when(), settings.max_run_seconds):
|
||
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":
|
||
# LangGraph 这一轮的节点都跑完了才发出中断:此刻引擎已经停下
|
||
at = time.perf_counter()
|
||
if await self._handle_updates(run_id, chunk):
|
||
interrupted = True
|
||
paused = at if paused is None else paused
|
||
|
||
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:
|
||
if self._closing:
|
||
# 服务关停(dev.sh 的 --reload 改一个文件就是一次)不是用户取消。
|
||
# checkpoint 完好,记成中断、重启后从断点接着跑——和强杀后启动
|
||
# 扫描的处理一致。以前一律记"用户取消",resume 和 continue 都拒绝它
|
||
status = "interrupted"
|
||
error = "服务重启,运行已挂起,可从断点继续运行"
|
||
await self._emit(run_id, EventType.LOG, data={
|
||
"level": "warn", "code": "server_shutdown",
|
||
"message": "服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行",
|
||
})
|
||
else:
|
||
status = "cancelled"
|
||
error = "用户取消"
|
||
terminal = (EventType.RUN_CANCELLED, {"actor": self._stopped_by.pop(run_id, None)})
|
||
raise
|
||
except TimeoutError as e:
|
||
status = "failed"
|
||
if deadline is not None and deadline.expired():
|
||
error = (
|
||
f"本次运行超过 {settings.max_run_seconds} 秒的时长上限,已中止。"
|
||
"通常是模型或工具长时间未响应;如确需更长时间,请联系管理员调大时长上限"
|
||
"(AGENTLAB_MAX_RUN_SECONDS)"
|
||
)
|
||
terminal = (EventType.RUN_FAILED, {"error": error})
|
||
else: # 别处抛的超时,不是这道上限——按普通失败说
|
||
error, terminal = _failure(e, spec)
|
||
logger.exception("run %s 失败", run_id)
|
||
except GraphRecursionError:
|
||
status = "failed"
|
||
# LangGraph 的原文是英文,还叫人去调 recursion_limit——用户碰不到那个键。
|
||
# 循环的轮数已经另算了预算,走到这里的多半是不归 loop 节点管的环
|
||
error = (
|
||
f"运行已执行 {settings.max_graph_steps + extra_steps} 步仍未结束,已中止"
|
||
f"(工作流步数上限为 {settings.max_graph_steps}"
|
||
+ (f",另为循环预留 {extra_steps} 步" if extra_steps else "") + ")。"
|
||
"可能存在没有轮数上限的环路:某个分支连回了上游,但没有用「循环」节点限制轮数。"
|
||
"请用「循环」节点包住这段环路;如确需更多步数,请联系管理员调大步数上限"
|
||
"(AGENTLAB_MAX_GRAPH_STEPS)"
|
||
)
|
||
terminal = (EventType.RUN_FAILED, {"error": error})
|
||
except Exception as e: # noqa: BLE001
|
||
status = "failed"
|
||
error, terminal = _failure(e, spec)
|
||
error_node = terminal[1].get("node_id")
|
||
logger.exception("run %s 失败", run_id)
|
||
finally:
|
||
stop = paused if status == "interrupted" and paused is not None else time.perf_counter()
|
||
elapsed = int((stop - started) * 1000)
|
||
self._finalizing.add(run_id)
|
||
try:
|
||
await self._finalize(run_id, status, error, final_state, elapsed,
|
||
terminal=terminal, error_node=error_node)
|
||
finally:
|
||
# 收尾被打断(关停时的取消、库连接出错)也得把自己摘掉。以前写在
|
||
# _finalize 后面,一被打断就漏掉,留下的任务让下一次 shutdown 的
|
||
# gather 在别的事件循环里炸开
|
||
self._finalizing.discard(run_id)
|
||
self._tasks.pop(run_id, None)
|
||
self._stopped_by.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 {},
|
||
ts=chunk.get("ts"),
|
||
)
|
||
|
||
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,
|
||
*,
|
||
terminal: tuple[EventType, dict[str, Any]] | None = None,
|
||
error_node: str | None = None,
|
||
) -> None:
|
||
usage = dict(state.get("usage") or {})
|
||
output = dict(state.get("output") or {})
|
||
now = datetime.now(timezone.utc)
|
||
timing = {"wall_ms": elapsed_ms, "active_ms": elapsed_ms, "wait_ms": 0}
|
||
|
||
# 终态事件和运行记录在同一个事务里提交,提交之后再广播。两边都有人在读:
|
||
# 读到 failed 的一方马上去翻事件,要翻得到 run.failed(报错原因和定位都在
|
||
# 那条事件里);收到 run.finished 的一方马上 GET 这次运行取完整成果(事件里
|
||
# 的 output 是截断的),要读得到 succeeded 和 output。分两次提交,总有一边
|
||
# 会读到半截——以前先提交状态,前一种偶尔翻不到;反过来先落事件,后一种
|
||
# 每次都读到 running 和空的 output
|
||
event: RunEventModel | None = None
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run:
|
||
timing = await _timing(session, run, elapsed_ms, now)
|
||
events_usage = await _usage_of_events(session, run_id)
|
||
inquiry = events_usage.pop("inquiry", None)
|
||
if status != "succeeded":
|
||
# 状态里的用量只算跑完了的节点。失败、取消、停在审批上的那个节点已经
|
||
# 花出去的调用(校验修复、agent 的前几步)只在 llm.end 里——以前这部分
|
||
# 在运行记录上凭空消失,失败的运行看着像没花钱
|
||
usage = _spent(usage, events_usage)
|
||
elif events_usage.get("unpriced_calls"):
|
||
# 金额估不出来的调用只有 llm.end 数得出(状态里的用量只有金额,0 分不出是免费还是没价)
|
||
usage = {**usage, "unpriced_calls": events_usage["unpriced_calls"]}
|
||
if inquiry:
|
||
# 审批质询不在图里执行,状态和 llm.end 两本账里都没有它:比完再加上
|
||
usage = _with_inquiry(usage, inquiry)
|
||
# duration_ms 是各段执行时长之和(= active_ms)。以前用这一段的耗时
|
||
# 覆盖它:87 秒、中间等过一次审批的运行,列表上写着 22ms
|
||
usage.update(duration_ms=timing["active_ms"], **timing)
|
||
|
||
if status == "succeeded":
|
||
# 事件里的 output 仍然截断(事件体积要控制),但截了就要说:消费方据此
|
||
# 回源 GET /api/runs/{id} 取全文。以前截得悄无声息,问数据页把 2000 字处
|
||
# 切断的半截答案当成完整结论落了库
|
||
clipped, truncated = _clip(output)
|
||
data: dict[str, Any] = {"output": clipped, "usage": usage,
|
||
"duration_ms": timing["active_ms"], "timing": timing}
|
||
if truncated:
|
||
data["output_truncated"] = True
|
||
event = self._event(run_id, EventType.RUN_FINISHED, data=data)
|
||
elif terminal is not None:
|
||
kind, data = terminal
|
||
event = self._event(run_id, kind, data={**data, "timing": timing})
|
||
|
||
if run:
|
||
run.status = status
|
||
run.error = error
|
||
run.output = output
|
||
run.usage = usage
|
||
run.error_node_id = error_node
|
||
if status != "interrupted":
|
||
run.finished_at = now
|
||
if status in ("failed", "cancelled"):
|
||
# 并行支路上已经停下来等人的那张审批卡,运行结束了它也就没有意义了;
|
||
# 留着的话它一直挂在全局待办里,批了也只会被告知运行不在断点上
|
||
actor = terminal[1].get("actor") if terminal else None
|
||
await _close_approvals(session, run_id, actor, now)
|
||
if event is not None:
|
||
await _stage(session, event, run)
|
||
await session.commit()
|
||
if event is not None:
|
||
await bus.publish(event)
|
||
|
||
if status != "interrupted":
|
||
# 终态事件落库之后再封存。以前在 run.finished 之前算,清单里恰恰少了
|
||
# 那条宣布"跑完了、成果是什么"的事件——改它的成果,清单照样对得上。
|
||
# 中断态不封:事件还会继续追加
|
||
await self._seal(run_id)
|
||
if status in ("succeeded", "failed", "cancelled") or (
|
||
status == "interrupted" and self._closing
|
||
):
|
||
# 关停时挂起的也要让在线的客户端收尾:进程马上就没了,不说一声,
|
||
# 界面会一直以为它还在跑
|
||
await bus.close(run_id)
|
||
|
||
async def _seal(self, run_id: str) -> None:
|
||
"""对到此为止的全部事件算清单哈希,记下封到了哪一条。"""
|
||
from app.core.artifact_store import manifest_hash as _manifest
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None:
|
||
return
|
||
rows = [tuple(r) for r in 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(rows)
|
||
run.manifest_seq = rows[-1][0] if rows else 0
|
||
await session.commit()
|
||
|
||
|
||
_TERMINAL = (EventType.RUN_FINISHED, EventType.RUN_FAILED, EventType.RUN_CANCELLED)
|
||
|
||
|
||
async def verify_manifest(run_id: str) -> dict[str, Any]:
|
||
"""重算封存范围内的清单哈希,和封存时记下的对一下。
|
||
|
||
以前只写不验:README 说"事后修改流水将无法对齐",可仓库里没有一行代码去对。
|
||
封存范围之后追加的事件不参与核对。没有 manifest_seq 的是加这一列之前封存的
|
||
老运行——当时的哈希是在 run.finished 落库之前算的,按那时的口径还原范围。
|
||
"""
|
||
from app.core.artifact_store import manifest_hash as _manifest
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None:
|
||
raise KeyError("运行记录不存在")
|
||
if not run.manifest_hash:
|
||
return {"sealed": False, "ok": None,
|
||
"message": "这次运行尚未封存(尚未结束,或正停在人工审批)"}
|
||
rows = [tuple(r) for r in await session.execute(
|
||
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
|
||
.where(RunEvent.run_id == run_id)
|
||
.order_by(RunEvent.seq)
|
||
)]
|
||
expected, sealed_at = run.manifest_hash, run.manifest_seq
|
||
|
||
legacy = sealed_at is None
|
||
if legacy:
|
||
last = next((r for r in reversed(rows) if r[1] in _TERMINAL), None)
|
||
if last is None:
|
||
sealed_at = rows[-1][0] if rows else 0
|
||
elif last[1] == EventType.RUN_FINISHED:
|
||
sealed_at = last[0] - 1 # 老口径:run.finished 本身不在清单里
|
||
else:
|
||
sealed_at = last[0] # run.failed / run.cancelled 在封存前就落库了
|
||
covered = [r for r in rows if r[0] <= sealed_at]
|
||
ok = _manifest(covered) == expected
|
||
return {
|
||
"sealed": True, "ok": ok, "events": len(covered), "sealed_at": sealed_at, "legacy": legacy,
|
||
"message": "事件记录与封存时一致" if ok else "事件记录与封存时不一致:封存后有事件被修改、删除或插入",
|
||
}
|
||
|
||
|
||
def _safe(value: Any, limit: int = 4000) -> Any:
|
||
"""把任意对象裁剪成能安全放进事件负载的形状。"""
|
||
return _clip(value, limit)[0]
|
||
|
||
|
||
def _clip(value: Any, limit: int = 4000) -> tuple[Any, bool]:
|
||
"""同 _safe,另外说出有没有真的截掉东西。"""
|
||
if isinstance(value, str):
|
||
return value[:limit], len(value) > limit
|
||
if isinstance(value, (dict, list)):
|
||
items = list(value.items()) if isinstance(value, dict) else list(enumerate(value))
|
||
cut = len(items) > 50
|
||
kept: list[tuple[Any, Any]] = []
|
||
for key, item in items[:50]:
|
||
clipped, lost = _clip(item, limit // 2)
|
||
kept.append((key, clipped))
|
||
cut = cut or lost
|
||
return (dict(kept) if isinstance(value, dict) else [v for _, v in kept]), cut
|
||
if isinstance(value, (int, float, bool)) or value is None:
|
||
return value, False
|
||
text = str(value)
|
||
return text[:limit], len(text) > limit
|
||
|
||
|
||
_STATUS_TEXT = {
|
||
"queued": "仍在排队", "running": "正在运行", "interrupted": "停在断点上",
|
||
"succeeded": "已完成", "failed": "已失败", "cancelled": "已取消",
|
||
}
|
||
|
||
|
||
def _cannot_resume(status: str) -> str:
|
||
hint = {
|
||
"running": "运行仍在进行,无需恢复。",
|
||
"queued": "运行尚未开始。",
|
||
"succeeded": "如需再次运行,请重新发起。",
|
||
"failed": "失败的运行可点「继续运行」从出错的节点继续。",
|
||
"cancelled": "已取消的运行请重新发起。",
|
||
}.get(status, "")
|
||
return f"这次运行{_STATUS_TEXT.get(status, status)},没有待处理的审批,无法恢复。{hint}"
|
||
|
||
|
||
def _cannot_continue(status: str, has_pending: bool) -> str:
|
||
"""接着跑被拒时说清楚为什么、下一步做什么。
|
||
|
||
以前一律套一句「在等待审批,只有失败的运行能接着跑」:服务重启挂起的也被说成
|
||
在等审批,还让人去一张不存在的审批卡上处理;已取消的只说不行,不说该怎么办。
|
||
"""
|
||
if status == "interrupted" and has_pending:
|
||
return "这次运行正在等待审批,无需继续运行。在审批卡上批准或驳回后,运行会自动继续。"
|
||
return {
|
||
"cancelled": "这次运行已取消,无法继续运行。如需继续,请重新发起运行。",
|
||
"succeeded": "这次运行已完成,无需继续运行:只有失败的运行或被服务重启打断的运行"
|
||
"可以继续运行。如需再次运行,请重新发起。",
|
||
"running": "这次运行正在进行,无需继续运行。",
|
||
"queued": "这次运行仍在排队,尚未开始,无需继续运行。",
|
||
}.get(status, f"这次运行{_STATUS_TEXT.get(status, status)},"
|
||
"只有失败的运行或被服务重启打断的运行可以继续运行。")
|
||
|
||
|
||
def _carried(run: Run) -> dict[str, Any]:
|
||
"""第一次发起时定下的运行参数。恢复、接着跑都沿用,老运行没记的按缺省。"""
|
||
return {
|
||
"memory_scope": run.memory_scope or "default",
|
||
"collection": run.collection or "default",
|
||
"approval_default": run.approval_default,
|
||
"tool_trust": run.tool_trust,
|
||
"agent_limits": run.agent_limits,
|
||
# 固定的数据版本照运行记录来,不重新读数据源的当前指针:中途重传过,续跑查的仍是发起时那一版
|
||
"data_versions": run.data_versions,
|
||
# 运行选项同理:照发起时的快照,不重新读设置
|
||
"options": run.options or {},
|
||
}
|
||
|
||
|
||
async def _data_versions(session: Any, spec: GraphSpec) -> dict[str, Any]:
|
||
"""发起时固定这次运行用到的上传表格版本:{源 id: {"snapshot": 当前快照 id, "name": 源名}}。
|
||
|
||
和 tool_trust、agent_limits 一样在发起这一刻定下、存进运行记录:续跑、审批恢复沿用它,
|
||
不再读数据源的当前指针——运行中途有人重传,这次运行查的仍是发起时那一版。数据源工具按它
|
||
解析源(tools/datasource.py),回收按它保留快照(data/table_versions.gc)。
|
||
|
||
只算这张图实际用到的源:调用工具节点、Agent、协作成员绑的 db_query__ / db_schema__,连同
|
||
子工作流里绑的。没用到的源不固定,否则它们的旧版本会因为这次运行白白留着。手工源不分版本,
|
||
不进来。工具名里有模板({{ }})时,发起时说不准它会落到哪个源,就把所有启用的上传源都固定上
|
||
(多固定几个只是多留几个旧版本;漏掉一个,这次运行就可能前后查到两版数据)。
|
||
|
||
发起时算不到的(继续运行时改了节点配置、没固定版本的子工作流中途被改过、运行中途新建的源)
|
||
由数据源工具第一次用到时补上(tools/datasource.py 的 _pin_late,记进运行记录并发一条日志事件)。
|
||
没用到任何上传源时返回空 dict 而不是 None:None 留给升级前发起的运行(照旧用当前版本,不补固定)。
|
||
"""
|
||
from app.db.models import DataSource
|
||
|
||
names, anything = await _bound_sources(session, spec)
|
||
if not names and not anything:
|
||
return {}
|
||
query = select(DataSource.id, DataSource.name, DataSource.current_snapshot_id).where(
|
||
DataSource.enabled.is_(True), DataSource.origin == "upload",
|
||
DataSource.current_snapshot_id.is_not(None),
|
||
).order_by(DataSource.name)
|
||
if not anything:
|
||
query = query.where(DataSource.name.in_(sorted(names)))
|
||
rows = (await session.execute(query)).all()
|
||
return {sid: {"snapshot": snap, "name": name} for sid, name, snap in rows}
|
||
|
||
|
||
async def _bound_sources(session: Any, spec: GraphSpec) -> tuple[set[str], bool]:
|
||
"""图里(含子工作流)绑定的数据源工具指向的源名,以及有没有带模板、发起时定不下来的工具名。
|
||
|
||
绑定的取法和发起前检查同一个(engine/launch.py 的 tool_bindings),子工作流照运行时的取法
|
||
读出来接着找:固定了版本的读那一版,没固定的读工作流现在的图。读不到、图不成形的跳过——
|
||
那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。
|
||
"""
|
||
# 在函数里导入:launch 在模块级导入了本模块(它要 run_manager)
|
||
from app.engine.launch import node_setting, tool_bindings
|
||
from app.db.models import WorkflowVersion
|
||
from app.tools.datasource import source_of_tool
|
||
|
||
names: set[str] = set()
|
||
anything = False
|
||
seen: set[tuple[str, str]] = set()
|
||
queue: list[GraphSpec] = [spec]
|
||
while queue:
|
||
graph = queue.pop(0)
|
||
for _, tool in tool_bindings(graph):
|
||
if "{{" in tool:
|
||
anything = True
|
||
elif (source := source_of_tool(tool)) is not None:
|
||
names.add(source)
|
||
for node in graph.nodes:
|
||
if node.type != NodeType.SUBGRAPH:
|
||
continue
|
||
# 和 NodeContext.cfg 一样:节点上没写就取图级 defaults(tool_bindings 读工具也是这个规则)
|
||
wid, pinned = (node_setting(graph, node, k) for k in ("workflow_id", "workflow_version"))
|
||
key = (str(wid or ""), str(pinned or ""))
|
||
if not wid or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
try:
|
||
# 只取图:Workflow 的 versions 是 selectin,整行读会把所有历史版本一起读出来
|
||
query = (select(WorkflowVersion.graph).where(
|
||
WorkflowVersion.workflow_id == wid, WorkflowVersion.version == int(pinned))
|
||
if pinned else select(Workflow.graph).where(Workflow.id == wid))
|
||
sub = (await session.execute(query)).scalar_one_or_none()
|
||
if sub:
|
||
queue.append(GraphSpec.model_validate(sub))
|
||
except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时会说清楚
|
||
continue
|
||
return names, anything
|
||
|
||
|
||
async def _agent_limits(session: Any) -> dict[str, Any]:
|
||
from app.api.settings import run_defaults
|
||
from app.engine.guards import run_limits
|
||
|
||
return run_limits(await run_defaults(session), settings.max_agent_steps)
|
||
|
||
|
||
def _trust_event(tool_trust: dict[str, str] | None) -> Any:
|
||
from app.tools.trust import FORMAL_MARK, snapshot_levels
|
||
|
||
if tool_trust is None:
|
||
return None
|
||
return "formal" if tool_trust.get(FORMAL_MARK) else snapshot_levels(tool_trust)
|
||
|
||
|
||
def _failure(exc: BaseException, spec: GraphSpec) -> tuple[str, tuple[EventType, dict[str, Any]]]:
|
||
"""失败的首行报错(给人看)和 run.failed 的负载(带上定位和原始异常)。"""
|
||
if isinstance(exc, NodeError):
|
||
# NodeError 的 message 本来就是写给人看的("人工驳回:…"),
|
||
# 再套一层类型名只会让界面上的错误更难读
|
||
message = str(exc)
|
||
detail = raw_detail(exc.__cause__) if exc.__cause__ is not None else None
|
||
node_id: str | None = exc.node_id
|
||
else:
|
||
message = (f"运行出错:{describe_exception(exc)}。技术细节已记录在服务日志中;"
|
||
"可点「继续运行」重试,如仍失败,请将运行编号反馈给维护者")
|
||
detail = raw_detail(exc)
|
||
node_id = None
|
||
data: dict[str, Any] = {"error": message}
|
||
if detail:
|
||
data["detail"] = detail
|
||
if node_id:
|
||
node = spec.node_map().get(node_id)
|
||
data.update(node_id=node_id, label=node.title if node else node_id)
|
||
return message, (EventType.RUN_FAILED, data)
|
||
|
||
|
||
async def _approval_default(*, run_class: str) -> str:
|
||
"""工具审批策略的全局默认,取自设置里的「危险工具默认需要人工确认」。
|
||
|
||
正式运行不吃"关"。它跑的是封存的发布版,审批行为只该由那一版里写明的配置
|
||
决定,不能随一个随时能改的偏好变;受管门禁要求危险工具至少人工确认,也不能被
|
||
全局开关悄悄降掉。不能按工作流的 status 判断是不是受管:画布上再存一次,status
|
||
就退回 draft,而 published_version 仍指着受管的那一版。
|
||
"""
|
||
if run_class == "formal":
|
||
return "dangerous"
|
||
from app.api.settings import run_defaults, tool_approval_default
|
||
|
||
async with SessionLocal() as session:
|
||
return tool_approval_default(await run_defaults(session))
|
||
|
||
|
||
def _replayed(snapshot: Any) -> set[str]:
|
||
"""从断点驱动时要(重新)执行的节点:还没跑的(next),加上停在 interrupt 上、或者
|
||
失败了的。恢复过一次的节点手里已经有答复的写入,不在 next 里,只能从 tasks 上认。"""
|
||
again = {str(x) for x in getattr(snapshot, "next", None) or ()}
|
||
again.update(str(t.name) for t in getattr(snapshot, "tasks", None) or ()
|
||
if getattr(t, "interrupts", None) or getattr(t, "error", None))
|
||
return again
|
||
|
||
|
||
async def _stale_replay(run_id: str, spec: GraphSpec, snapshot: Any) -> str | None:
|
||
"""旧版重放协议写下的断点,按现在的代码重放会不会把做过的工具再做一遍。会的话返回节点名。
|
||
|
||
有风险的是:断点之后,旧版代码在这个节点里已经执行过工具、或者已经批过一次。它们
|
||
的 task 缓存排在旧的位置上,新代码多插了记录 task,位置全错开了。停在第一次审批上、
|
||
之前什么都没做的,重放只会多发一条 human.requested,照常放行;人工审批节点同理。
|
||
|
||
「断点之后」按 checkpoint 的写入时刻算:同一个 superstep 里恢复不会写新的 checkpoint,
|
||
所以升级后恢复过一次的运行,断点仍是旧版写的,而那之后的工作是新版做的——
|
||
哪一段是哪一版,看那一段 run.started 里记的 replay_protocol。
|
||
"""
|
||
if protocol_of(getattr(snapshot, "metadata", None)) >= PROTOCOL:
|
||
return None
|
||
nodes = spec.node_map()
|
||
suspects = [n for n in _replayed(snapshot) if n in nodes and nodes[n].type != NodeType.HUMAN]
|
||
if not suspects:
|
||
return None
|
||
try:
|
||
since = datetime.fromisoformat(str(snapshot.created_at)).timestamp()
|
||
except (AttributeError, TypeError, ValueError):
|
||
since = 0.0
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(
|
||
select(RunEvent.type, RunEvent.node_id, RunEvent.ts, RunEvent.data)
|
||
.where(RunEvent.run_id == run_id,
|
||
RunEvent.type.in_([EventType.RUN_STARTED, EventType.TOOL_START,
|
||
EventType.HUMAN_RESOLVED]))
|
||
.order_by(RunEvent.seq)
|
||
)).all()
|
||
protocol = 1
|
||
for kind, owner, ts, data in rows:
|
||
if kind == EventType.RUN_STARTED:
|
||
protocol = protocol_of({PROTOCOL_KEY: (data or {}).get("replay_protocol")})
|
||
continue
|
||
if protocol >= PROTOCOL or (ts or 0) < since:
|
||
continue
|
||
for node_id in suspects:
|
||
if owner == node_id or (owner or "").startswith(f"{node_id}/"):
|
||
return nodes[node_id].title or node_id
|
||
return None
|
||
|
||
|
||
async def _claim(
|
||
session: Any, run_id: str, expected: tuple[str, ...], refusal: Callable[[str], str],
|
||
**values: Any,
|
||
) -> None:
|
||
"""只在状态仍是 expected 时把运行改成 running;已经被别人改过就回滚、说明原因。"""
|
||
claimed = await session.execute(
|
||
update(Run).where(Run.id == run_id, Run.status.in_(expected))
|
||
.values(status="running", error=None, **values)
|
||
)
|
||
if claimed.rowcount != 1:
|
||
await session.rollback()
|
||
status = await session.scalar(select(Run.status).where(Run.id == run_id))
|
||
raise ValueError(refusal(status or ""))
|
||
|
||
|
||
async def _stage(session: Any, event: RunEventModel, run: Run | None = None) -> None:
|
||
"""把一条事件写进这个 session,随它一起提交。run 已经在 session 里时直接改它的 last_seq。
|
||
|
||
落库的 id 用事件自己的:广播出去的、交回给调用方的和库里的是同一个 id,回头按 id 找得到。
|
||
"""
|
||
session.add(RunEvent(id=event.id, run_id=event.run_id, seq=event.seq, type=str(event.type),
|
||
node_id=event.node_id, ts=event.ts, data=event.data))
|
||
if run is not None:
|
||
run.last_seq = event.seq
|
||
else:
|
||
# 只往大里改:另开会话记的事件(note)和执行任务同时在写时,取号和提交的先后可以不一样,号小的后提交,
|
||
# 无条件写会把 last_seq 写小(后果见 _seq_floor)
|
||
await session.execute(
|
||
update(Run).where(Run.id == event.run_id)
|
||
.values(last_seq=case((Run.last_seq > event.seq, Run.last_seq), else_=event.seq))
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
|
||
|
||
async def _seq_floor(session: Any, run_id: str, last_seq: int | None) -> int:
|
||
"""接着一次运行往下编号的起点:last_seq 和库里实际最大的序号取大的。
|
||
|
||
last_seq 按说就是最后一条,但有的写入会把它写小:按需裁判记判定(api/evidence.py 的 _append_judged)
|
||
的条件更新不带 last_seq 条件,它取了号还没写、封存后追加(engine/run_append.py)抢先提交了,它再提交时
|
||
把 last_seq 写回自己那个更小的号。同一进程里序号计数只增不减,不碍事;进程重启后计数从 0 起,只按
|
||
last_seq 接号就会撞上已有的序号。清单按序号排序算哈希,撞了号封存核对就时对时不对。
|
||
"""
|
||
stored = await session.scalar(select(func.max(RunEvent.seq)).where(RunEvent.run_id == run_id))
|
||
return max(last_seq or 0, stored or 0)
|
||
|
||
|
||
async def _close_approvals(session: Any, run_id: str, actor: str | None, now: datetime) -> int:
|
||
"""运行已经结束,它还没处理完的审批一并关掉。返回关掉了几条。"""
|
||
rows = list((await session.execute(
|
||
select(Approval).where(Approval.run_id == run_id,
|
||
Approval.status.in_(["pending", "answered"]))
|
||
)).scalars())
|
||
for approval in rows:
|
||
approval.status = "cancelled"
|
||
approval.resolved_at = now
|
||
approval.resolved_by = actor
|
||
return len(rows)
|
||
|
||
|
||
_USAGE_KEYS = ("input_tokens", "output_tokens", "cost_usd", "calls")
|
||
#: 审批质询的用量里计进运行用量的键:unpriced_calls 是其中金额估不出来的调用次数(模型不在价格目录里)
|
||
_INQUIRY_KEYS = (*_USAGE_KEYS, "unpriced_calls")
|
||
|
||
|
||
async def _usage_of_events(session: Any, run_id: str) -> dict[str, Any]:
|
||
"""按事件加总的用量。
|
||
|
||
llm.end 逐次加总:每次模型调用都有一条,不管节点最后成没成。审批质询不在图里执行、不发 llm.end,
|
||
它的用量写在 approval.inquiry 事件数据的 usage 里,单独加总放在 inquiry 下(没有质询就没有这个键):
|
||
llm.end 这本账要和状态里的用量比大小(_spent),质询花的两本里都没有,混进去比会被状态那本盖掉。
|
||
|
||
封存之后追加的质询(数据带 post_seal)不算:运行用量是这次运行自己花的,结束时就定下了、写进了终态事件;
|
||
事后的批注另有自己的账,同按需裁判的判定。继续运行之后它落进新的封存范围,照样不算——不然同一条质询
|
||
计不计,全看之后有没有继续运行。
|
||
"""
|
||
rows = await session.execute(
|
||
select(RunEvent.type, RunEvent.data).where(
|
||
RunEvent.run_id == run_id, RunEvent.type.in_([EventType.LLM_END, EventType.APPROVAL_INQUIRY]))
|
||
)
|
||
from app.providers.catalog import unpriced_call
|
||
|
||
total: dict[str, Any] = dict.fromkeys(_USAGE_KEYS, 0)
|
||
unpriced = 0
|
||
inquiry: dict[str, Any] = {}
|
||
for kind, data in rows:
|
||
if kind == EventType.APPROVAL_INQUIRY:
|
||
spent = (data or {}).get("usage")
|
||
if isinstance(spent, dict) and not (data or {}).get("post_seal"):
|
||
for key in _INQUIRY_KEYS:
|
||
value = spent.get(key)
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
inquiry[key] = inquiry.get(key, 0) + value
|
||
continue
|
||
for key in _USAGE_KEYS:
|
||
value = (data or {}).get(key, 1 if key == "calls" else 0)
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
total[key] += value
|
||
unpriced += unpriced_call(data)
|
||
total["cost_usd"] = round(total["cost_usd"], 6)
|
||
if unpriced:
|
||
# 有几次调用的金额估不出来(模型不在价格目录里):cost_usd 只是估得出的那部分,界面据此不写 $0
|
||
total["unpriced_calls"] = unpriced
|
||
if inquiry:
|
||
if "cost_usd" in inquiry:
|
||
inquiry["cost_usd"] = round(inquiry["cost_usd"], 6)
|
||
total["inquiry"] = inquiry
|
||
return total
|
||
|
||
|
||
def _with_inquiry(usage: dict[str, Any], inquiry: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""把审批质询的用量计进运行用量,明细另放在 inquiry 下(界面要说清其中多少是质询花的)。
|
||
|
||
usage 里已经计过一回的(上次停下时记下的 inquiry)先减掉再加:停过两次、两次都问过的运行,
|
||
每次质询只计一遍。
|
||
"""
|
||
out = dict(usage)
|
||
counted = out.pop("inquiry", None) or {}
|
||
for key in _INQUIRY_KEYS:
|
||
delta = (inquiry or {}).get(key, 0) - counted.get(key, 0)
|
||
if delta:
|
||
out[key] = (out.get(key) or 0) + delta
|
||
if isinstance(out.get("cost_usd"), float):
|
||
out["cost_usd"] = round(out["cost_usd"], 6)
|
||
if "input_tokens" in out or "output_tokens" in out:
|
||
out["total_tokens"] = int(out.get("input_tokens") or 0) + int(out.get("output_tokens") or 0)
|
||
if inquiry:
|
||
out["inquiry"] = dict(inquiry)
|
||
return out
|
||
|
||
|
||
def _spent(state_usage: dict[str, Any], events_usage: dict[str, Any]) -> dict[str, Any]:
|
||
"""两本账取大的那本。新引擎每次调用都发 llm.end,事件那本不会更少;
|
||
加这条事件之前的老运行(agent 节点不发 llm.end)状态那本更全。"""
|
||
out = dict(state_usage)
|
||
for key in _USAGE_KEYS:
|
||
mine, theirs = out.get(key) or 0, events_usage.get(key) or 0
|
||
if isinstance(mine, (int, float)) and theirs > mine:
|
||
out[key] = theirs
|
||
if events_usage.get("unpriced_calls"):
|
||
out["unpriced_calls"] = events_usage["unpriced_calls"]
|
||
if "input_tokens" in out or "output_tokens" in out:
|
||
out["total_tokens"] = int(out.get("input_tokens") or 0) + int(out.get("output_tokens") or 0)
|
||
return out
|
||
|
||
|
||
async def _timing(session: Any, run: Run, elapsed_ms: int, now: datetime) -> dict[str, int]:
|
||
"""wall:从第一次开始到现在的墙钟;active:各段执行时长之和;wait:等人审批的总时长。
|
||
|
||
等待按事件算:每段从 run.interrupted 到下一条 run.resumed。服务重启挂起的那段
|
||
没有 run.interrupted,不算等审批;失败后到接着跑之间也不算。
|
||
"""
|
||
prev = run.usage or {}
|
||
before = prev.get("active_ms", prev.get("duration_ms", 0))
|
||
active = int(before or 0) + elapsed_ms
|
||
|
||
rows = await session.execute(
|
||
select(RunEvent.type, RunEvent.ts)
|
||
.where(RunEvent.run_id == run.id,
|
||
RunEvent.type.in_([EventType.RUN_INTERRUPTED, EventType.RUN_RESUMED]))
|
||
.order_by(RunEvent.seq)
|
||
)
|
||
waited, since = 0.0, None
|
||
for kind, ts in rows:
|
||
if kind == EventType.RUN_INTERRUPTED:
|
||
since = ts if since is None else since
|
||
elif since is not None:
|
||
waited += max(0.0, ts - since)
|
||
since = None
|
||
if since is not None:
|
||
waited += max(0.0, now.timestamp() - since)
|
||
|
||
wall = int((now - run.started_at).total_seconds() * 1000) if run.started_at else active
|
||
return {"wall_ms": max(wall, active), "active_ms": active, "wait_ms": int(waited * 1000)}
|
||
|
||
|
||
run_manager = RunManager()
|