mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
定时任务自动对比复核整改(后端):只比成功的运行、按发起先后挑上一次成功的基准、和两边的封存绑定,对比挪到认领之前在锁外做并限时限并发;失败通知的处理行不再重复原因
- 只比成功结束的运行,基准也只取成功的:失败、取消的运行指标不全,和上一次比只会得出「移除 N 个」,拿失败的那次当 基准每个指标都成了「新增」。基准改成自己查同一定时任务、同一工作流、比这次先发起的运行里成功并封存的最近一次, 不再用对比接口的 default_base(按结束时间挑:老的失败运行事后被接着跑完,会拿后一次触发当它的「上一次」)。 中间隔着失败、取消的运行时,通知写「与上一次成功的运行(#短编号,日期)相比」,Fire.compare 多一个 previous - 和封存绑定:运行的封存要盖住最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后 接着跑完的运行在两步之间还带着失败时的清单哈希,以前这一拍会直接认领、按旧封存比、通知里写旧的清单哈希;现在 等新的封存,等不来就照常推送但不比、不写哈希。停在审批上的通知也不再写失败时留下的哈希。对比工件连同两边的清单 哈希一起存,触发历史发现任何一边不在终态或清单哈希变了就给 null;对比途中封存变了不记(条件写) - 时机和时限:锁里只挑出状态有变化的触发,认领挪到对比之后、锁外按条件 UPDATE 做。对比最多等 20 秒(含排队), 同一时刻最多 2 个;超时只记警告,通知不带对比照常推送。停机时扫描在对比途中被取消,这次触发还没认领,下次照常 比、照常推(以前先认领、后比,这条通知会永远丢掉)。同一份封存已经比过的直接用记下的结果 - 失败通知:从没开始执行的,原因刚写过,处理行不再让人「查看原因」,任务在排期上写「下一次触发会照常发起」,停用 的写怎么重新发起;开始执行过的改成「请在 AgentLab 的「记录」中查看失败原因」,和挂起那句指向同一个导航入口 - 新增 test_scheduler_compare_guards.py(封存绑定、时限、取消、并发、复用),test_scheduler_compare.py 补真实缺 指标的失败运行、上一次失败、补跑老运行几种用例;去掉实现逐项验证过会变红
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""定时任务:按周期自动发起工作流运行,结束、失败、停在审批时推送 Webhook,运行结束后和同一任务的上一次自动对比。
|
||||
"""定时任务:按周期自动发起工作流运行,结束、失败、停在审批时推送 Webhook,运行成功结束后和同一任务上一次成功的运行
|
||||
自动对比。
|
||||
|
||||
形状按前后端约定(Schedule、Fire 两种对象,见 _schedule_out、_fire_outs)。Webhook 地址和签名密钥加密存放,接口
|
||||
只给主机和末 4 位(url_hint)、有没有密钥(has_secret)。请求体自己解析、自己校验:错误一律回中文 detail,表单
|
||||
@@ -279,13 +280,30 @@ def _webhook_out(row: Schedule) -> dict[str, Any] | None:
|
||||
|
||||
async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[dict[str, Any]]:
|
||||
"""Fire 对象:run_status 是运行现在的状态,notified 是这次触发的推送记录 [{event, status, at}](按时间先后),
|
||||
compare 是运行结束后和同一任务上一次的自动对比 {base_run_id, summary}(summary 同对比接口的计数),没有为 null。
|
||||
compare 是运行成功结束后和同一任务上一次成功的运行的自动对比 {base_run_id, summary, previous}(summary 同对比接口
|
||||
的计数,previous 说基准是不是紧挨着的上一次),没有为 null。
|
||||
|
||||
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
|
||||
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
|
||||
被改过的同样给 null。"""
|
||||
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
|
||||
compares = await auto_compare.briefs(list(compared.values()))
|
||||
run_ids = {f.run_id for f in fires if f.run_id} | {brief["base_run_id"] for brief in compares.values()}
|
||||
# 运行现在的状态和清单哈希:run_status 照写,对比按它核对两边的封存
|
||||
current = {run_id: (status, seal) for run_id, status, seal in (await session.execute(
|
||||
select(Run.id, Run.status, Run.manifest_hash).where(Run.id.in_(run_ids)))).all()} if run_ids else {}
|
||||
|
||||
def compare_of(f: ScheduleFire) -> dict[str, Any] | None:
|
||||
brief = compares.get(compared.get(f.id) or "")
|
||||
if brief is None or brief["target_run_id"] != f.run_id:
|
||||
return None
|
||||
for run_id, seal in ((brief["target_run_id"], brief["target_manifest"]),
|
||||
(brief["base_run_id"], brief["base_manifest"])):
|
||||
status, now_seal = current.get(run_id, (None, None))
|
||||
if status not in auto_compare.TERMINAL or now_seal != seal:
|
||||
return None
|
||||
return auto_compare.public(brief)
|
||||
|
||||
运行又被继续执行、现在不在终态时 compare 也给 null:对比页只认结束并封存的运行,链过去只会被拒;再结束时
|
||||
通知扫描会按新的封存重新比。基准那一次被删掉、被继续执行的,工件读不出来、被改过的,同样给 null。"""
|
||||
run_ids = [f.run_id for f in fires if f.run_id]
|
||||
statuses = dict((await session.execute(select(Run.id, Run.status).where(Run.id.in_(run_ids)))).all()) \
|
||||
if run_ids else {}
|
||||
notified: dict[str, list[dict[str, Any]]] = {}
|
||||
if fires:
|
||||
rows = (await session.execute(
|
||||
@@ -296,25 +314,16 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
|
||||
notified.setdefault(d.fire_id, []).append({"event": d.event, "status": d.status, "at": _iso(d.created_at)})
|
||||
# 正在发起的触发:库里是占位的「未能发起」(为了进程被强杀时照实说),进程还在、只是还没发起完,按已发起给
|
||||
launching = {f.id for f in fires if service.launching(f.id)}
|
||||
compared = {f.id: f.compare_artifact for f in fires
|
||||
if f.compare_artifact and statuses.get(f.run_id) in auto_compare.TERMINAL}
|
||||
compares = await auto_compare.briefs(list(compared.values()))
|
||||
# 基准一侧同样要在终态:被删掉(强制删除封存的正式运行)、失败后被继续执行的,对比页比不了
|
||||
base_ids = list({brief["base_run_id"] for brief in compares.values()})
|
||||
if base_ids:
|
||||
bases = dict((await session.execute(select(Run.id, Run.status).where(Run.id.in_(base_ids)))).all())
|
||||
compares = {artifact: brief for artifact, brief in compares.items()
|
||||
if bases.get(brief["base_run_id"]) in auto_compare.TERMINAL}
|
||||
return [{
|
||||
"id": f.id,
|
||||
"fire_at": _iso(f.fire_at),
|
||||
"kind": f.kind,
|
||||
"status": "started" if f.id in launching else f.status,
|
||||
"run_id": f.run_id,
|
||||
"run_status": statuses.get(f.run_id) if f.run_id else None,
|
||||
"run_status": current.get(f.run_id, (None, None))[0] if f.run_id else None,
|
||||
"reason": None if f.id in launching else f.reason,
|
||||
"notified": notified.get(f.id, []),
|
||||
"compare": compares.get(compared[f.id]) if f.id in compared else None,
|
||||
"compare": compare_of(f),
|
||||
"created_at": _iso(f.created_at),
|
||||
} for f in fires]
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
- service.py:输入模板、和工作流入口字段的对照、发起一次触发(调度循环和「立即运行」共用)
|
||||
- loop.py:调度循环,条件 UPDATE 认领到点的任务
|
||||
- webhook.py:通知推送(通用 / 飞书 / 企业微信 / 钉钉)
|
||||
- auto_compare.py:运行结束后和同一定时任务的上一次自动对比,结果记在触发记录上,通知里只带计数摘要和对比页链接
|
||||
- auto_compare.py:运行成功结束后和同一定时任务上一次成功的运行自动对比,结果记在触发记录上,通知里只带计数摘要和
|
||||
对比页链接
|
||||
|
||||
服务启动时(main.py 的 lifespan)只在 scheduler_enabled() 时调 start,关闭时总是调 stop——stop 要能在没 start
|
||||
过时安全返回。start 自己也看一次开关:拷来的数据目录(检查脚本的 --stacks、验收副本)不管从哪条路径起服务,
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"""定时运行结束后的自动对比:和同一定时任务的上一次运行比一次,结果存成工件,工件 id 记在触发记录上
|
||||
"""定时运行结束后的自动对比:和同一定时任务上一次成功的运行比一次,结果存成工件,工件 id 记在触发记录上
|
||||
(ScheduleFire.compare_artifact)。
|
||||
|
||||
对比本身调运行对比接口的现成函数(api/compare.py 的 default_base、compare_runs),这里只管什么时候比、跟谁比、结果
|
||||
放哪、怎么说给触发历史和通知听:
|
||||
对比本身调运行对比接口的现成函数(api/compare.py 的 compare_runs,不改它),这里只管比哪些、跟谁比、什么时候比、
|
||||
结果放哪、怎么说给触发历史和通知听:
|
||||
|
||||
- 什么时候:通知扫描(webhook.notify_due)认领到运行进入终态(结束、失败、取消)的那一次之后、推送之前。和推送一样,
|
||||
同一个终态只处理一次;失败后被继续执行、又结束的,按新的封存重新比,覆盖旧的结果
|
||||
- 跟谁比:default_base 的「同一定时任务」那一档,即这个任务里更早结束、封存过的终态运行中最近的一次。它退回「同一
|
||||
工作流、同一运行类别」时不比:那不是这个定时任务的上一次,别人手动改的输入、另一个任务的参数都会被算成变化
|
||||
- 结果:整份对比结果存成工件(kind=run_compare)。不挂在任何运行名下:它不是运行产出的,不该出现在运行的工件列表里。
|
||||
没封存的不比(服务重启时还在排队、被改成失败的,永远不会封存);比不了、出错只记日志,通知照常推送
|
||||
- 比哪些:成功结束、封存完的运行。失败、取消的运行指标不全(口径卡常常没执行),和上一次比只会得出「移除 N 个」,
|
||||
收到通知的人会以为报告删了指标
|
||||
- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次(_previous)。失败的那次不当基准:
|
||||
拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比后一次晚,
|
||||
它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和触发历史
|
||||
写明比的是哪一次
|
||||
- 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后
|
||||
接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希:
|
||||
哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记
|
||||
- 什么时候:通知扫描(webhook.notify_due)挑出这次触发之后、认领之前,在锁外比,比完(或等过时限)再认领、推送。
|
||||
比到一半服务停了,这次触发还没认领,下次启动照常比、照常推;同一份封存已经比过的,直接用记下的结果
|
||||
- 时限:一次对比最多等 WAIT_S 秒,同一时刻最多 SLOTS 个在算(读快照、重算都在线程里,不能一起占满接口请求也要用的
|
||||
线程池)。超时、比不了(对比接口拒绝,如基准的封存核对不一致)、出错只记日志,通知照常推送、不带对比
|
||||
- 结果:整份对比结果连同两边的清单哈希存成工件(kind=run_compare)。不挂在任何运行名下:它不是运行产出的,不该出现
|
||||
在运行的工件列表里
|
||||
- 对外:触发历史(Fire.compare)和通知只给基准是哪一次、各类指标的个数,不给具体数字
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -17,21 +26,29 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
from app.core import artifact_store
|
||||
from app.core.events import EventType
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Run, ScheduleFire
|
||||
from app.db.models import Run, RunEvent, ScheduleFire
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KIND = "run_compare"
|
||||
#: 终态,同对比接口(api/evidence._TERMINAL):只有这几种状态、封存过的运行能参与对比
|
||||
#: 终态,同对比接口(api/evidence._TERMINAL):触发历史里两边都要在终态,对比页才比得了
|
||||
TERMINAL = ("succeeded", "failed", "cancelled")
|
||||
#: 结束事件:封存要盖住这次运行的最后一条,才是这一次结束时封的
|
||||
_ENDS = (EventType.RUN_FINISHED.value, EventType.RUN_FAILED.value, EventType.RUN_CANCELLED.value)
|
||||
#: 一次对比最多等多久(秒),排队等空位的时间也算在里面。等不到就不等了:通知不带对比照常推送
|
||||
WAIT_S = 20.0
|
||||
#: 同一时刻最多几个对比在算
|
||||
SLOTS = 2
|
||||
#: 各类指标的叫法,和对比页一致(frontend/src/lib/terms.ts 的 RUN_COMPARE_TEXT.classLabel)
|
||||
CLASS_LABEL = {
|
||||
"data_only": "只数据变",
|
||||
@@ -56,6 +73,8 @@ NO_METRICS = "两次运行都没有指标"
|
||||
_BRIEFS: dict[str, dict[str, Any]] = {}
|
||||
_BRIEFS_MAX = 512
|
||||
_BRIEFS_LOCK = threading.Lock()
|
||||
#: 对比的空位,按事件循环分开(同 service.launch_lock):测试每个用例一个新循环,跨循环用同一个信号量会报错
|
||||
_SLOTS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def summary_text(summary: dict[str, Any]) -> str:
|
||||
@@ -73,68 +92,148 @@ def page_url(base_url: str, target: str, base: str) -> str:
|
||||
return f"{base_url}/runs/compare?{urlencode({'target': target, 'base': base})}"
|
||||
|
||||
|
||||
async def compare_fire(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
"""这次触发发起的运行刚进入终态:和同一定时任务的上一次比一次,结果记进触发记录。返回给通知用的摘要
|
||||
{base_run_id, summary};没有可比的、比不了时返回 None,触发记录上之前的结果一并清掉(那是上一次封存的)。
|
||||
async def sealed_now(session: Any, run: Run) -> bool:
|
||||
"""这次运行的封存是不是这一次结束时封的:有清单哈希,而且封存范围盖住了最后一条结束事件。
|
||||
|
||||
不抛异常:比不了(对比接口拒绝,如基准的封存核对不一致)记一条警告,意外的异常记堆栈,都不影响通知。"""
|
||||
artifact: str | None = None
|
||||
brief: dict[str, Any] | None = None
|
||||
runner 先在一个事务里写终态和结束事件,再另起会话封存。失败后接着跑完(或接着跑之后停在审批上被放弃)的运行,在
|
||||
两步之间还带着失败时的清单哈希,只看「有没有哈希」会把旧的封存当成这一次的。没有 manifest_seq 的是加这一列之前
|
||||
封存的老运行,只能看有没有哈希。"""
|
||||
if not run.manifest_hash:
|
||||
return False
|
||||
if run.manifest_seq is None:
|
||||
return True
|
||||
last = await session.scalar(select(func.max(RunEvent.seq)).where(
|
||||
RunEvent.run_id == run.id, RunEvent.type.in_(_ENDS)))
|
||||
return last is None or run.manifest_seq >= last
|
||||
|
||||
|
||||
async def compare_fire(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
"""这次触发发起的运行成功结束、封存完了:和同一定时任务上一次成功的运行比一次,结果记进触发记录。返回给通知用的
|
||||
摘要 {base_run_id, summary, previous, base_started_at, …};不该比、没有可比的、比不了时返回 None。
|
||||
|
||||
最多等 WAIT_S 秒(含排队)。超时、对比接口拒绝(基准的封存核对不一致等)记一条警告,意外的异常记堆栈,都不影响
|
||||
通知。取消不吞:扫描被取消(停机)时这次触发还没认领,下次照常比。"""
|
||||
try:
|
||||
artifact, brief = await _compare(run_id)
|
||||
return await asyncio.wait_for(_compare(fire_id, run_id), timeout=WAIT_S)
|
||||
except TimeoutError:
|
||||
logger.warning("定时任务的运行 %s 自动对比超过 %g 秒仍未完成,通知不带对比照常推送", run_id, WAIT_S)
|
||||
except HTTPException as e:
|
||||
logger.warning("定时任务的运行 %s 自动对比未完成:%s", run_id, e.detail)
|
||||
except Exception: # noqa: BLE001 - 对比出错不能拖住通知,也不能让通知扫描停下
|
||||
logger.exception("定时任务的运行 %s 自动对比出错", run_id)
|
||||
await _store(fire_id, artifact)
|
||||
return brief
|
||||
return None
|
||||
|
||||
|
||||
async def _compare(run_id: str) -> tuple[str | None, dict[str, Any] | None]:
|
||||
async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
# 现成的对比函数在用到时才导入:api/compare.py 连带证据、推演接口,调度模块被导入时不必一起加载
|
||||
from app.api import compare as compare_api
|
||||
|
||||
# 挑基准、核对封存、看有没有比过都只是读库,不占对比的空位:前面排着慢的对比时,第一次运行这类没什么可比的
|
||||
# 不必跟着排队、等到超时
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None or run.status not in TERMINAL or not run.manifest_hash:
|
||||
return None, None
|
||||
found = await compare_api.default_base(run_id)
|
||||
if found is None or found[1] != "same_schedule":
|
||||
return None, None
|
||||
base = found[0]
|
||||
result = await compare_api.compare_runs(run_id, base.id)
|
||||
brief = _brief(result)
|
||||
if brief is None:
|
||||
return None, None
|
||||
artifact = await artifact_store.put_json(result, kind=KIND, meta={"target_run_id": run_id, "base_run_id": base.id})
|
||||
target = await session.get(Run, run_id)
|
||||
stored = await session.scalar(select(ScheduleFire.compare_artifact).where(ScheduleFire.id == fire_id))
|
||||
if target is None or target.status != "succeeded" or not target.schedule_id or not target.workflow_id \
|
||||
or not await sealed_now(session, target):
|
||||
return None
|
||||
found = await _previous(session, target)
|
||||
if found is None:
|
||||
return None
|
||||
base, previous = found
|
||||
if not await sealed_now(session, base):
|
||||
# 基准正好处在写完终态、还没封存的那一刻,或者封存没补上就停了机:拿它比只比得到旧封存里的事件
|
||||
logger.warning("作为基准的运行 %s 还没有封存到它的结束事件,运行 %s 这次不比", base.id, run_id)
|
||||
return None
|
||||
sides = ((target.id, target.manifest_hash), (base.id, base.manifest_hash))
|
||||
(target_id, target_seal), (base_id, base_seal) = sides
|
||||
if stored:
|
||||
# 同一份封存已经比过(比完还没认领就停了机):直接用
|
||||
known = await asyncio.to_thread(brief_of, stored)
|
||||
if known is not None and _sides_of(known) == sides:
|
||||
return _copy(known)
|
||||
async with _slots():
|
||||
result = await compare_api.compare_runs(target_id, base_id)
|
||||
payload = {"target_manifest": target_seal, "base_manifest": base_seal, "previous": previous, "result": result}
|
||||
brief = _brief(payload)
|
||||
if brief is None or _sides_of(brief) != sides:
|
||||
return None
|
||||
artifact = await artifact_store.put_json(payload, kind=KIND,
|
||||
meta={"target_run_id": target_id, "base_run_id": base_id})
|
||||
if not await _store(fire_id, artifact, sides):
|
||||
logger.warning("运行 %s 或作为基准的运行 %s 在对比途中重新封存(或触发记录已删除),这次的对比结果不记",
|
||||
target_id, base_id)
|
||||
return None
|
||||
_remember(artifact, brief)
|
||||
return artifact, _copy(brief)
|
||||
return _copy(brief)
|
||||
|
||||
|
||||
async def _store(fire_id: str, artifact: str | None) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id)
|
||||
.values(compare_artifact=artifact))
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 - 任务刚被删掉之类:记不下不影响通知
|
||||
logger.warning("触发记录 %s 的自动对比结果没能保存", fire_id)
|
||||
async def _previous(session: Any, target: Run) -> tuple[Run, bool] | None:
|
||||
"""(基准, 是不是紧挨着的上一次):同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。
|
||||
|
||||
|
||||
def _brief(result: Any) -> dict[str, Any] | None:
|
||||
"""对比结果里给触发历史、通知的那一点:基准是哪一次、各类指标的个数(同对比接口的 summary)。"""
|
||||
if not isinstance(result, dict):
|
||||
按发起先后挑(created_at),不按结束时间;同一工作流才比得了(任务改过工作流的,之前那个工作流的运行不算)。
|
||||
中间隔着别的运行(失败、取消、还没结束的)时第二项为 False。"""
|
||||
earlier = (Run.schedule_id == target.schedule_id, Run.workflow_id == target.workflow_id, Run.id != target.id,
|
||||
Run.created_at < target.created_at)
|
||||
base = (await session.execute(
|
||||
select(Run).where(*earlier, Run.status == "succeeded", Run.manifest_hash.is_not(None))
|
||||
.order_by(Run.created_at.desc()).limit(1))).scalars().first()
|
||||
if base is None:
|
||||
return None
|
||||
base, summary = result.get("base"), result.get("summary")
|
||||
if not isinstance(base, dict) or not isinstance(base.get("id"), str) or not isinstance(summary, dict):
|
||||
between = await session.scalar(select(func.count()).select_from(Run).where(
|
||||
*earlier, Run.created_at > base.created_at))
|
||||
return base, not between
|
||||
|
||||
|
||||
def _slots() -> asyncio.Semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
slots = _SLOTS.get(loop)
|
||||
if slots is None:
|
||||
slots = _SLOTS[loop] = asyncio.Semaphore(SLOTS)
|
||||
return slots
|
||||
|
||||
|
||||
async def _store(fire_id: str, artifact: str, sides: tuple[tuple[str, str | None], ...]) -> bool:
|
||||
"""把工件 id 记进触发记录。条件写:两边都还是成功结束、清单哈希还是比的时候那一份,才记。"""
|
||||
same = [select(Run.id).where(Run.id == run_id, Run.status == "succeeded", Run.manifest_hash == seal).exists()
|
||||
for run_id, seal in sides]
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id, *same)
|
||||
.values(compare_artifact=artifact))
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def _sides_of(brief: dict[str, Any]) -> tuple[tuple[str, str | None], ...]:
|
||||
return (brief["target_run_id"], brief["target_manifest"]), (brief["base_run_id"], brief["base_manifest"])
|
||||
|
||||
|
||||
def _brief(payload: Any) -> dict[str, Any] | None:
|
||||
"""对比工件里给触发历史、通知的那一点:两边是哪一次、比的时候两边的清单哈希、基准是不是紧挨着的上一次、基准
|
||||
什么时候开始的、各类指标的个数(同对比接口的 summary)。形状不对的(这一版之前的工件、被改过的)返回 None。"""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("result"), dict):
|
||||
return None
|
||||
return {"base_run_id": base["id"],
|
||||
result = payload["result"]
|
||||
target, base, summary = result.get("target"), result.get("base"), result.get("summary")
|
||||
if not isinstance(target, dict) or not isinstance(base, dict) or not isinstance(summary, dict):
|
||||
return None
|
||||
keys = (target.get("id"), base.get("id"), payload.get("target_manifest"), payload.get("base_manifest"))
|
||||
if not all(isinstance(key, str) and key for key in keys):
|
||||
return None
|
||||
started = base.get("started_at")
|
||||
return {"target_run_id": keys[0], "base_run_id": keys[1], "target_manifest": keys[2], "base_manifest": keys[3],
|
||||
"previous": payload.get("previous") is not False,
|
||||
"base_started_at": started if isinstance(started, str) else None,
|
||||
"summary": {key: value for key, value in summary.items()
|
||||
if isinstance(value, int) and not isinstance(value, bool)}}
|
||||
|
||||
|
||||
def public(brief: dict[str, Any]) -> dict[str, Any]:
|
||||
"""接口给前端的那一份(Fire.compare):基准是哪一次、各类指标的个数、基准是不是紧挨着的上一次。"""
|
||||
return {"base_run_id": brief["base_run_id"], "summary": dict(brief["summary"]), "previous": brief["previous"]}
|
||||
|
||||
|
||||
def _copy(brief: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"base_run_id": brief["base_run_id"], "summary": dict(brief["summary"])}
|
||||
return {**brief, "summary": dict(brief["summary"])}
|
||||
|
||||
|
||||
def _remember(artifact: str, brief: dict[str, Any]) -> None:
|
||||
|
||||
@@ -9,8 +9,8 @@ Webhook 地址本身就是凭证(企业微信的 key、钉钉的 access_token
|
||||
一个过滤器把推送期间的那几行换成主机名。
|
||||
|
||||
通知里只有任务名、状态、出具档位、缺口条数、运行链接、封存哈希(界面上叫清单哈希)前 12 位和起止时间,失败时
|
||||
另写失败节点的名字和去哪里查看(不许诺能继续运行);运行结束后和同一任务上一次的对比(auto_compare)只写各类
|
||||
指标的个数和对比页链接。不放报告里的具体数字,也不放失败时的原始报错——报错里可能带着查询到的数据,Webhook
|
||||
另写失败节点的名字和下一步(不许诺能继续运行);运行成功结束后和同一任务上一次成功的运行的对比(auto_compare)只写
|
||||
各类指标的个数和对比页链接。不放报告里的具体数字,也不放失败时的原始报错——报错里可能带着查询到的数据,Webhook
|
||||
推去的群不一定有权看。
|
||||
|
||||
三个平台出错时都回 HTTP 200、错误码放在响应体里,所以送没送达要看响应体:按地址的主机认平台,不看选的格式——
|
||||
@@ -67,14 +67,17 @@ _STATUS_LABEL = {"succeeded": "已完成", "failed": "失败", "cancelled": "已
|
||||
#: 挂起的运行要人去处理:不处理,这个任务之后的触发都按「上一次还没结束」跳过
|
||||
SUSPENDED_REASON = "运行停在断点上、没有待审批,通常是服务重启所致"
|
||||
SUSPENDED_ACTION = "请在 AgentLab 的「记录」中继续运行或放弃这次运行;它结束之前,这个定时任务之后的触发都会跳过"
|
||||
#: 失败的运行怎么处理:不许诺「修正后可继续运行」。没开始执行的没有断点;开始执行过的也可能没有(执行第一个节点前就
|
||||
#: 失败)或已不兼容(系统升级),能不能继续以记录页的提示为准
|
||||
FAILED_ACTION = "请在 AgentLab 的运行记录中查看原因"
|
||||
#: 失败的运行怎么处理:不许诺「修正后可继续运行」。开始执行过的也可能没有断点(执行第一个节点前就失败)或已不兼容
|
||||
#: (系统升级),能不能继续以记录页的提示为准。「记录」是导航里那一页的名字,和挂起那句一致
|
||||
FAILED_ACTION = "请在 AgentLab 的「记录」中查看失败原因"
|
||||
#: 从没开始执行就失败的(服务重启时还在排队,被改成失败、没有开始时间):没有可继续的进度。不照抄运行记录上的报错——
|
||||
#: 通知一律不放原始报错,这一句是按「没有开始时间」认出来的,自己写
|
||||
NOT_STARTED_REASON = "运行在开始执行前就已中止(通常是服务重启时仍在排队),没有可继续的进度"
|
||||
#: 失败的运行不挡之后的触发(挂起的才挡):任务还在排期上时补这一句,免得人以为这个定时任务就此停了
|
||||
NEXT_AS_USUAL = "下一次触发会照常发起"
|
||||
#: 从没开始执行、任务又不在排期上(停用了):原因刚写过,记录页上也没有更多可看的,下一步是重新发起。「立即运行」
|
||||
#: 是定时任务页上那个按钮的名字,停用的任务也能点
|
||||
RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点击「立即运行」"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -228,7 +231,8 @@ class Notice:
|
||||
|
||||
suspended:运行停在断点上、没有待审批(通常是服务重启挂起的),按 failed 事件推。error_node:失败节点的名字。
|
||||
next_fire:任务启用着、往后还会触发(失败通知据此补「下一次触发会照常发起」)。
|
||||
compare:和同一定时任务上一次的对比摘要 {base_run_id, summary}(只有个数),compare_url:对比页的链接。
|
||||
compare:和同一定时任务上一次成功的运行的对比摘要(auto_compare.compare_fire 的返回:基准是哪一次、只有个数的
|
||||
summary、基准是不是紧挨着的上一次、基准开始的时间),compare_url:对比页的链接。
|
||||
fmt、signed 只给测试消息用:说明里只说这条消息证明得了的事。"""
|
||||
|
||||
event: str
|
||||
@@ -293,7 +297,7 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
|
||||
lines.append(("出具档位", f"{ISSUANCE_LABEL.get(notice.tier, notice.tier)}{gaps}"))
|
||||
if notice.compare:
|
||||
# 只给各类指标的个数:具体的数在对比页上,跟着运行的权限走,不随消息推进群里
|
||||
lines.append(("与上一次相比", auto_compare.summary_text(notice.compare.get("summary") or {})))
|
||||
lines.append((_compare_head(notice), auto_compare.summary_text(notice.compare.get("summary") or {})))
|
||||
if notice.pending_approvals:
|
||||
lines.append(("待审批", f"{notice.pending_approvals} 项,请到 AgentLab 处理;运行结束之前,"
|
||||
"这个定时任务之后的触发都会跳过"))
|
||||
@@ -302,12 +306,14 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
|
||||
elif notice.suspended:
|
||||
lines += [("原因", SUSPENDED_REASON), ("处理", SUSPENDED_ACTION)]
|
||||
elif notice.status == "failed":
|
||||
# 不写原始报错(可能带着查询到的数据),写失败节点的名字和去哪里处理
|
||||
# 不写原始报错(可能带着查询到的数据),写失败节点的名字和下一步
|
||||
if notice.error_node:
|
||||
lines.append(("失败节点", f"「{notice.error_node}」"))
|
||||
if notice.never_started:
|
||||
lines.append(("原因", NOT_STARTED_REASON))
|
||||
lines.append(("处理", f"{FAILED_ACTION};{NEXT_AS_USUAL}" if notice.next_fire else FAILED_ACTION))
|
||||
# 原因刚写过,也没有断点可续:不再让人去查看原因,说下一次照常,不在排期上的说怎么重新发起
|
||||
lines += [("原因", NOT_STARTED_REASON), ("处理", NEXT_AS_USUAL if notice.next_fire else RERUN_ACTION)]
|
||||
else:
|
||||
lines.append(("处理", f"{FAILED_ACTION};{NEXT_AS_USUAL}" if notice.next_fire else FAILED_ACTION))
|
||||
if notice.fire_at is not None:
|
||||
when = f"{_local(notice.fire_at, notice.timezone, seconds=False)}({notice.timezone})"
|
||||
# 立即运行不写「计划触发」:群里的人会以为报告按时出了
|
||||
@@ -329,6 +335,27 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
|
||||
return title, lines, notice.run_url
|
||||
|
||||
|
||||
def _compare_head(notice: Notice) -> str:
|
||||
"""对比那一行的项名。基准是紧挨着的上一次时写「与上一次相比」;不是(上一次失败了、取消了)时写明比的是哪一次:
|
||||
短编号(同界面,#前 6 位)和开始的日期(按定时任务的时区)。"""
|
||||
compare = notice.compare or {}
|
||||
if compare.get("previous", True):
|
||||
return "与上一次相比"
|
||||
when = _date_of(compare.get("base_started_at"), notice.timezone)
|
||||
return f"与上一次成功的运行(#{str(compare.get('base_run_id') or '')[:6]}{f',{when}' if when else ''})相比"
|
||||
|
||||
|
||||
def _date_of(text: Any, tz: str) -> str | None:
|
||||
"""ISO 时间(对比结果里运行的开始时间)按定时任务的时区写成日期,读不出来的返回 None。"""
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(text))
|
||||
except ValueError:
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return (_local(moment, tz, seconds=False) or "")[:10] or None
|
||||
|
||||
|
||||
def _status_label(notice: Notice) -> str:
|
||||
if notice.status == "interrupted":
|
||||
return "等待审批" if notice.pending_approvals else "已挂起,可继续运行"
|
||||
@@ -406,9 +433,10 @@ def _generic(notice: Notice, now: datetime) -> dict[str, Any]:
|
||||
else f"{SUSPENDED_REASON}。{SUSPENDED_ACTION}" if notice.suspended
|
||||
else NOT_STARTED_REASON if notice.never_started else None),
|
||||
"error_node": notice.error_node,
|
||||
# 对比只给基准是哪一次、各类指标的个数和对比页链接
|
||||
# 对比只给基准是哪一次(是不是紧挨着的上一次)、各类指标的个数和对比页链接
|
||||
"compare": ({"base_run_id": notice.compare.get("base_run_id"),
|
||||
"summary": dict(notice.compare.get("summary") or {}), "url": notice.compare_url}
|
||||
"summary": dict(notice.compare.get("summary") or {}),
|
||||
"previous": notice.compare.get("previous", True) is not False, "url": notice.compare_url}
|
||||
if notice.compare else None),
|
||||
"sent_at": _iso(now),
|
||||
}
|
||||
@@ -592,12 +620,16 @@ class _Job:
|
||||
|
||||
@dataclass
|
||||
class _Due:
|
||||
"""认领到的一次状态变化接下来要做的事:运行进入终态时和上一次比一次(compare_run),订阅了这个事件时推一条
|
||||
(job)。两样都在锁外做:对比要读快照、重算,推送要等接收端,都不能挡着发起。"""
|
||||
"""挑出来的一次状态变化接下来要做的事:运行成功结束时和上一次成功的运行比一次(compare_run),按条件 UPDATE
|
||||
认领(把 notified_status 从 old 改成 state),认领到了、订阅了这个事件时推一条(job)。都在锁外做:对比要读快照、
|
||||
重算,推送要等接收端,都不能挡着发起。pushed:认领到了、发出了推送(含失败的)。"""
|
||||
|
||||
fire_id: str
|
||||
old: str | None
|
||||
state: str
|
||||
compare_run: str | None
|
||||
job: _Job | None
|
||||
pushed: bool = False
|
||||
|
||||
|
||||
async def notify_due(now: datetime | None = None) -> int:
|
||||
@@ -607,8 +639,11 @@ async def notify_due(now: datetime | None = None) -> int:
|
||||
推一次。没订阅这个事件、任务没配 Webhook 的也记下已处理——之后再配上 Webhook,不会把旧的补推一遍。
|
||||
停在断点上的运行每一拍都要重新看(见 _state_of):它的已处理状态带着第几段执行,和运行状态本身对不上。
|
||||
|
||||
运行进入终态的那一次,推送之前先和同一定时任务的上一次比一次(auto_compare),结束、失败的通知带上计数摘要和
|
||||
对比页链接。没配 Webhook 的也比:触发历史里要用。比不了、出错不影响推送。
|
||||
运行成功结束、封存完的那一次,认领之前先和同一定时任务上一次成功的运行比一次(auto_compare),结束的通知带上计数
|
||||
摘要和对比页链接。没配 Webhook 的也比:触发历史里要用。对比在锁外、认领之前做,最多等 auto_compare.WAIT_S 秒,
|
||||
过了就不带对比推送:一次慢的对比最多让这一拍晚这么久,不会把之后所有的通知一直压着,也不拖着发起用的锁。比到一半
|
||||
扫描被取消(停机只等通知扫描一个宽限),这次触发还没认领,下次照常比、照常推——以前先认领、后比,取消之后这条
|
||||
通知再也不会推。比不了、出错不影响推送。
|
||||
"""
|
||||
now = now or utcnow()
|
||||
dues: list[_Due] = []
|
||||
@@ -617,7 +652,9 @@ async def notify_due(now: datetime | None = None) -> int:
|
||||
# 扫描窗口按触发记录的创建时间算,两种例外:运行记录窗口内有过变化(运行状态一变 updated_at 就跟着变),以及
|
||||
# 停在审批上、有待审批的——以前只看触发记录,创建 30 天以后才停到审批上的运行收不到「等待审批」
|
||||
waiting = select(Approval.id).where(Approval.run_id == Run.id, Approval.status == "pending").exists()
|
||||
# 和发起共用一把锁:发起时先记一条「未能发起」再发起运行、成功了才改成 started,扫描不能撞在这中间
|
||||
# 和发起共用一把锁:发起时先记一条「未能发起」再发起运行、成功了才改成 started,扫描不能撞在这中间。锁里只挑出
|
||||
# 状态有变化的、拼好要推的消息;认领在锁外按条件 UPDATE 做,挑出来的是锁里看到的状态,和别的扫描撞上也只有一个
|
||||
# 认领得到
|
||||
async with service.launch_lock():
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
@@ -636,39 +673,52 @@ async def notify_due(now: datetime | None = None) -> int:
|
||||
|
||||
base_url = (await notify_settings(session))["public_base_url"]
|
||||
for fire, run in rows:
|
||||
state, event, pending = await _state_of(session, fire, run, now)
|
||||
if state is None or state == fire.notified_status \
|
||||
or not await _mark(fire.id, fire.notified_status, state):
|
||||
seen = await _state_of(session, fire, run, now)
|
||||
if seen.state is None or seen.state == fire.notified_status:
|
||||
continue
|
||||
compare_run = run.id if run is not None and run.status in auto_compare.TERMINAL else None
|
||||
schedule = await session.get(Schedule, fire.schedule_id)
|
||||
url = decrypt(schedule.webhook_url) if schedule and schedule.webhook_url else None
|
||||
job = None
|
||||
if event and url and schedule.webhook_format in FORMATS and event in (schedule.webhook_events or []):
|
||||
notice = await _notice(session, schedule, fire, run, event, base_url, pending)
|
||||
if seen.event and url and schedule.webhook_format in FORMATS \
|
||||
and seen.event in (schedule.webhook_events or []):
|
||||
notice = await _notice(session, schedule, fire, run, seen, base_url)
|
||||
job = _Job(schedule.webhook_format, url, decrypt(schedule.webhook_secret), notice)
|
||||
if compare_run or job:
|
||||
dues.append(_Due(fire.id, compare_run, job))
|
||||
# 只比成功结束、封存完的(见 auto_compare):失败、取消的指标不全,等不来新封存的比了也是旧的
|
||||
compare_run = run.id if run is not None and run.status == "succeeded" and seen.sealed else None
|
||||
dues.append(_Due(fire.id, fire.notified_status, seen.state, compare_run, job))
|
||||
if dues:
|
||||
await asyncio.gather(*(_follow_up(due, base_url) for due in dues))
|
||||
return sum(1 for due in dues if due.job is not None)
|
||||
return sum(1 for due in dues if due.pushed)
|
||||
|
||||
|
||||
async def _follow_up(due: _Due, base_url: str | None) -> None:
|
||||
"""先比(需要的话),再推:对比摘要要写进这条通知。对比自己兜住了异常,出什么事都不挡推送。"""
|
||||
if due.compare_run:
|
||||
brief = await auto_compare.compare_fire(due.fire_id, due.compare_run)
|
||||
if brief is not None and due.job is not None:
|
||||
due.job.notice.compare = brief
|
||||
if base_url:
|
||||
due.job.notice.compare_url = auto_compare.page_url(base_url, due.compare_run, brief["base_run_id"])
|
||||
if due.job is not None:
|
||||
await _deliver(due.job)
|
||||
"""先比(需要的话),再认领,认领到了再推:对比摘要要写进这条通知;认领放在对比之后,比到一半被取消时这次触发
|
||||
还没认领。对比自己兜住了超时和异常,出什么事都不挡推送。"""
|
||||
brief = await auto_compare.compare_fire(due.fire_id, due.compare_run) if due.compare_run else None
|
||||
if not await _mark(due.fire_id, due.old, due.state) or due.job is None:
|
||||
return
|
||||
if brief is not None:
|
||||
due.job.notice.compare = brief
|
||||
if base_url:
|
||||
due.job.notice.compare_url = auto_compare.page_url(base_url, due.compare_run, brief["base_run_id"])
|
||||
due.pushed = True
|
||||
await _deliver(due.job)
|
||||
|
||||
|
||||
async def _state_of(session: Any, fire: ScheduleFire, run: Run | None,
|
||||
now: datetime) -> tuple[str | None, str | None, int]:
|
||||
"""(要记下的已处理状态, 要推送的事件, 待审批数)。还不能下结论(没封存、审批刚批完还没接着执行)时状态为 None。
|
||||
@dataclass
|
||||
class _Seen:
|
||||
"""_state_of 的结论。state:要记下的已处理状态,还不能下结论时为 None;event:要推送的事件;pending:待审批数;
|
||||
sealed:终态的运行封存完了(封存盖住了这一次的结束事件,见 auto_compare.sealed_now),只有这时才比、通知里才写
|
||||
清单哈希。"""
|
||||
|
||||
state: str | None
|
||||
event: str | None = None
|
||||
pending: int = 0
|
||||
sealed: bool = False
|
||||
|
||||
|
||||
async def _state_of(session: Any, fire: ScheduleFire, run: Run | None, now: datetime) -> _Seen:
|
||||
"""这次触发现在该记成什么状态、推什么。还不能下结论(没封存、审批刚批完还没接着执行)时状态为 None。
|
||||
|
||||
停在断点上(interrupted)的分两种,各推一次:
|
||||
- 有待审批:停在审批,推 awaiting_approval。批完接着执行、又停在下一个审批点时要再推,所以已处理状态里带着这是
|
||||
@@ -678,18 +728,21 @@ async def _state_of(session: Any, fire: ScheduleFire, run: Run | None,
|
||||
审批批完到运行改回 running 之间,审批是 answered、还没有 resolved:那不是挂起,等下一拍。
|
||||
"""
|
||||
if fire.status == "failed_to_start":
|
||||
return "failed_to_start", "failed", 0
|
||||
return _Seen("failed_to_start", "failed")
|
||||
if run is None:
|
||||
return None, None, 0
|
||||
return _Seen(None)
|
||||
if run.status in ("succeeded", "failed", "cancelled"):
|
||||
# runner 先写终态和结束时间、再封存,两步之间被这一拍撞上就等。没有结束时间的(服务重启时还在排队、被改成
|
||||
# failed 的)永远不会封存:按它最后一次更新的时间等,以前按「现在」等,每一拍都等、永远不推
|
||||
# runner 先写终态和结束时间、再另起会话封存,两步之间被这一拍撞上就等。失败后接着跑完的运行这时还带着失败时
|
||||
# 的清单哈希,不能只看有没有哈希,要看封存盖没盖住这一次的结束事件。没有结束时间的(服务重启时还在排队、被改
|
||||
# 成 failed 的)永远不会封存:按它最后一次更新的时间等,以前按「现在」等,每一拍都等、永远不推。等过时限仍没
|
||||
# 封存完的照常推,只是不比、不写清单哈希
|
||||
sealed = await auto_compare.sealed_now(session, run)
|
||||
since = run.finished_at or run.updated_at or fire.created_at
|
||||
if not run.manifest_hash and (now - service.as_utc(since)).total_seconds() < SEAL_WAIT_S:
|
||||
return None, None, 0
|
||||
return run.status, _EVENT_OF[run.status], 0
|
||||
if not sealed and (now - service.as_utc(since)).total_seconds() < SEAL_WAIT_S:
|
||||
return _Seen(None)
|
||||
return _Seen(run.status, _EVENT_OF[run.status], sealed=sealed)
|
||||
if run.status != "interrupted":
|
||||
return None, None, 0
|
||||
return _Seen(None)
|
||||
counts = dict((await session.execute(
|
||||
select(Approval.status, func.count()).where(
|
||||
Approval.run_id == run.id, Approval.status.in_(("pending", "answered")))
|
||||
@@ -697,10 +750,10 @@ async def _state_of(session: Any, fire: ScheduleFire, run: Run | None,
|
||||
segment = await session.scalar(select(func.count()).select_from(RunEvent).where(
|
||||
RunEvent.run_id == run.id, RunEvent.type == EventType.RUN_STARTED.value)) or 0
|
||||
if counts.get("pending"):
|
||||
return f"interrupted:approval:{segment}", "awaiting_approval", counts["pending"]
|
||||
return _Seen(f"interrupted:approval:{segment}", "awaiting_approval", counts["pending"])
|
||||
if counts.get("answered"):
|
||||
return None, None, 0
|
||||
return f"interrupted:held:{segment}", "failed", 0
|
||||
return _Seen(None)
|
||||
return _Seen(f"interrupted:held:{segment}", "failed")
|
||||
|
||||
|
||||
async def _mark(fire_id: str, old: str | None, state: str) -> bool:
|
||||
@@ -712,17 +765,19 @@ async def _mark(fire_id: str, old: str | None, state: str) -> bool:
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run | None, event: str,
|
||||
base_url: str | None, pending: int) -> Notice:
|
||||
async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run | None, seen: _Seen,
|
||||
base_url: str | None) -> Notice:
|
||||
tier, gaps = await _issuance_of(session, run) if run is not None else (None, None)
|
||||
pending = seen.pending
|
||||
return Notice(
|
||||
event=event, schedule_id=schedule.id, schedule_name=schedule.name, timezone=schedule.timezone,
|
||||
event=seen.event or "", schedule_id=schedule.id, schedule_name=schedule.name, timezone=schedule.timezone,
|
||||
fire_id=fire.id, fire_at=fire.fire_at, fire_kind=fire.kind,
|
||||
run_id=run.id if run else None,
|
||||
status=run.status if run else fire.status,
|
||||
tier=tier, gaps=gaps,
|
||||
run_url=f"{base_url}/runs/{run.id}" if base_url and run else None,
|
||||
manifest_hash=run.manifest_hash if run else None,
|
||||
# 只写这一次结束时封的:停在审批上、封存还没补上的运行,身上可能还留着上一次失败时的清单哈希
|
||||
manifest_hash=run.manifest_hash if run is not None and seen.sealed else None,
|
||||
started_at=run.started_at if run else None,
|
||||
finished_at=run.finished_at if run else None,
|
||||
reason=fire.reason if fire.status == "failed_to_start" else None,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""定时运行结束后的自动对比(app/scheduler/auto_compare.py,由通知扫描 webhook.notify_due 在推送之前触发):
|
||||
"""定时运行结束后的自动对比(app/scheduler/auto_compare.py,由通知扫描 webhook.notify_due 在认领、推送之前触发):
|
||||
|
||||
- 运行进入终态且已封存、同一定时任务有更早的已封存运行时,用运行对比的现成函数(api/compare.py 的 default_base、
|
||||
compare_runs)和它比一次,整份结果存成工件(kind=run_compare,不挂在运行名下、不进运行的工件列表),工件 id 记进
|
||||
触发记录的 compare_artifact。基准是同一定时任务的上一次:同一工作流更近的手动运行、别的定时任务的运行都不算
|
||||
- 这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对不一致、意外的异常)只记
|
||||
日志,通知照常推送、不带对比
|
||||
- 接口:Fire 多一个 compare: {base_run_id, summary} 或 null,summary 同对比接口的计数;运行又被继续执行、不在终态
|
||||
时为 null(对比页只认封存的运行)
|
||||
- 通知:结束、失败的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问
|
||||
地址时附对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现
|
||||
- 只比成功结束并封存的运行:失败、取消的指标不全,和上一次比只会得出「移除 N 个」,误导收到通知的人
|
||||
- 基准是同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次:同一工作流更近的手动运行、别的
|
||||
定时任务的运行都不算;失败的那一次不当基准(拿它当基准每个指标都成了「新增」);按发起先后挑,不按结束时间——
|
||||
老的失败运行事后被接着跑完,结束得比后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的时,通知和触发
|
||||
历史写明比的是哪一次
|
||||
- 整份结果连同两边的清单哈希存成工件(kind=run_compare,不挂在运行名下、不进运行的工件列表),工件 id 记进触发
|
||||
记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对
|
||||
不一致、意外的异常)只记日志,通知照常推送、不带对比
|
||||
- 接口:Fire 多一个 compare: {base_run_id, summary, previous} 或 null,summary 同对比接口的计数,previous 说基准是不是
|
||||
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行)
|
||||
- 通知:结束的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问地址时附
|
||||
对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现
|
||||
|
||||
两次周报运行用 tests/fixtures/compare_runs.py 的虚构运行(区域、金额、日期都是编的),按 runner 的算法封存,不真的
|
||||
执行;Webhook 用 httpx.MockTransport 接住,不连外网。
|
||||
封存绑定、时限、取消这几道保护另见 test_scheduler_compare_guards.py。两次周报运行用 tests/fixtures/compare_runs.py 的
|
||||
虚构运行(区域、金额、日期都是编的),按 runner 的算法封存,不真的执行;Webhook 用 httpx.MockTransport 接住,不连外网。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,7 +35,7 @@ from app.db.base import SessionLocal
|
||||
from app.db.models import Artifact, Run, RunEvent, Schedule, ScheduleFire, Setting, Workflow
|
||||
from app.main import app
|
||||
from app.scheduler import auto_compare, webhook
|
||||
from tests.fixtures.compare_runs import NEXT_ROWS, Fake, week
|
||||
from tests.fixtures.compare_runs import NEXT_ROWS, Fake, region_sql, week
|
||||
|
||||
UTC = timezone.utc
|
||||
T0 = datetime(2026, 9, 7, 1, 0, tzinfo=UTC)
|
||||
@@ -111,8 +114,9 @@ async def new_schedule(workflow_id: str, *, fmt: str = "generic", url: str | Non
|
||||
|
||||
|
||||
async def persist(fake: Fake, workflow_id: str, *, finished: datetime, schedule_id: str | None = None,
|
||||
status: str = "succeeded", seal: bool = True) -> str:
|
||||
"""把一次虚构运行落库:工件进工件库,事件进运行记录,按 runner 的算法封存(同 test_compare_api.py)。"""
|
||||
status: str = "succeeded", seal: bool = True, seal_upto: int | None = None) -> str:
|
||||
"""把一次虚构运行落库:工件进工件库,事件进运行记录,按 runner 的算法封存(同 test_compare_api.py)。
|
||||
seal_upto 只封到第几条事件:失败时封存过、接着跑完又写下了终态,新的封存还没补上的那一刻。"""
|
||||
for content in fake.store.values():
|
||||
await put_json(content, kind="compare_fixture")
|
||||
async with SessionLocal() as session:
|
||||
@@ -128,11 +132,39 @@ async def persist(fake: Fake, workflow_id: str, *, finished: datetime, schedule_
|
||||
rows.append((seq, etype, node_id, data))
|
||||
run.last_seq = len(rows)
|
||||
if seal:
|
||||
run.manifest_hash, run.manifest_seq = manifest_hash(rows), rows[-1][0]
|
||||
covered = [r for r in rows if seal_upto is None or r[0] <= seal_upto]
|
||||
run.manifest_hash, run.manifest_seq = manifest_hash(covered), covered[-1][0]
|
||||
await session.commit()
|
||||
return run.id
|
||||
|
||||
|
||||
async def reseal(run_id: str, fake: Fake, *, status: str, finished: datetime) -> None:
|
||||
"""一次运行被接着跑完(或者被重新封存):事件换成 fake 的那一份,按新的事件封存,状态、结束时间一并改。"""
|
||||
for content in fake.store.values():
|
||||
await put_json(content, kind="compare_fixture")
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(RunEvent).where(RunEvent.run_id == run_id))
|
||||
rows = []
|
||||
for seq, etype, node_id, data in fake.events:
|
||||
session.add(RunEvent(run_id=run_id, seq=seq, type=etype, node_id=node_id, ts=float(seq), data=data))
|
||||
rows.append((seq, etype, node_id, data))
|
||||
run = await session.get(Run, run_id)
|
||||
run.last_seq = len(rows)
|
||||
run.manifest_hash, run.manifest_seq = manifest_hash(rows), rows[-1][0]
|
||||
run.status, run.finished_at = status, finished
|
||||
await session.commit()
|
||||
|
||||
|
||||
def failed_week(run_id: str, rows=NEXT_ROWS) -> Fake:
|
||||
"""在 Agent 节点失败的一次周报:取数做完了,Agent 没交回结果,口径卡没有执行,一个指标都没有。"""
|
||||
run = Fake(run_id)
|
||||
run.tool("fetch", region_sql(), rows=rows)
|
||||
run.add("node.started", "bot", {"node_type": "agent"})
|
||||
run.add("node.failed", "bot", {"error": "模型接入超时"})
|
||||
run.add("run.failed", None, {"error": "节点「区域分析」失败"})
|
||||
return run
|
||||
|
||||
|
||||
async def seal(run_id: str) -> None:
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
@@ -206,8 +238,14 @@ async def test_finished_run_is_compared_with_the_previous_run_of_its_schedule(re
|
||||
from app.api.compare import compare_runs
|
||||
|
||||
stored = load(artifact)
|
||||
assert stored == json.loads(canonical_json(await compare_runs(target_id, base_id)))
|
||||
assert stored["base"]["id"] == base_id and stored["summary"] == SUMMARY
|
||||
# 整份对比结果,连同比的时候两边的清单哈希(封存变了,这份结果就不再作数)
|
||||
assert stored["result"] == json.loads(canonical_json(await compare_runs(target_id, base_id)))
|
||||
assert stored["result"]["base"]["id"] == base_id and stored["result"]["summary"] == SUMMARY
|
||||
async with SessionLocal() as session:
|
||||
runs = await session.scalars(select(Run).where(Run.id.in_((target_id, base_id))))
|
||||
seals = {r.id: r.manifest_hash for r in runs}
|
||||
assert (stored["target_manifest"], stored["base_manifest"], stored["previous"]) == (
|
||||
seals[target_id], seals[base_id], True)
|
||||
async with SessionLocal() as session:
|
||||
rows = list((await session.execute(select(Artifact).where(Artifact.id == artifact))).scalars())
|
||||
# 对比结果不是运行产出的:不挂在运行名下,不进这次运行的工件列表
|
||||
@@ -215,7 +253,7 @@ async def test_finished_run_is_compared_with_the_previous_run_of_its_schedule(re
|
||||
assert artifact not in [a["id"] for a in (await client.get(f"/api/runs/{target_id}/artifacts")).json()]
|
||||
|
||||
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
|
||||
assert items[fire_id]["compare"] == {"base_run_id": base_id, "summary": SUMMARY}
|
||||
assert items[fire_id]["compare"] == {"base_run_id": base_id, "summary": SUMMARY, "previous": True}
|
||||
assert items[base_fire]["compare"] is None
|
||||
[listed] = [s for s in (await client.get("/api/schedules")).json()["items"] if s["id"] == sid]
|
||||
assert listed["last_fire"]["id"] == fire_id and listed["last_fire"]["compare"]["base_run_id"] == base_id
|
||||
@@ -223,7 +261,7 @@ async def test_finished_run_is_compared_with_the_previous_run_of_its_schedule(re
|
||||
[body] = recorder.bodies()
|
||||
link = f"{BASE_URL}/runs/compare?target={target_id}&base={base_id}"
|
||||
assert body["event"] == "finished" and LINE in body["text"] and f"查看对比:{link}" in body["text"]
|
||||
assert body["compare"] == {"base_run_id": base_id, "summary": SUMMARY, "url": link}
|
||||
assert body["compare"] == {"base_run_id": base_id, "summary": SUMMARY, "previous": True, "url": link}
|
||||
query = parse_qs(urlsplit(body["compare"]["url"]).query)
|
||||
assert query == {"target": [target_id], "base": [base_id]}
|
||||
assert no_numbers(recorder.requests[0].content.decode()) == []
|
||||
@@ -231,25 +269,79 @@ async def test_finished_run_is_compared_with_the_previous_run_of_its_schedule(re
|
||||
assert await webhook.notify_due() == 0 and (await fire_row(fire_id)).compare_artifact == artifact
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt, url", [("feishu", FEISHU), ("generic", GENERIC)])
|
||||
async def test_failed_run_notice_carries_the_counts_too(recorder, fmt, url):
|
||||
"""失败的运行也封存:和上一次比一次,失败通知同样带计数摘要"""
|
||||
@pytest.mark.parametrize("status, fmt, url", [("failed", "feishu", FEISHU), ("failed", "generic", GENERIC),
|
||||
("cancelled", "generic", GENERIC)])
|
||||
async def test_failed_or_cancelled_run_is_not_compared(recorder, client, status, fmt, url):
|
||||
"""失败、取消的运行指标不全(这次在 Agent 节点失败,口径卡没有执行):和上一次比只会得出「移除 2 个」,收到通知
|
||||
的人会以为报告删了两个指标。不比:触发记录上没有对比,失败通知不带计数那一行和对比页链接"""
|
||||
await public_address()
|
||||
_, base_id, target_id, fire_id = await weekly_pair(target_status="failed", fmt=fmt, url=url)
|
||||
assert await webhook.notify_due() == 1
|
||||
assert (await fire_row(fire_id)).compare_artifact
|
||||
raw = recorder.requests[0].content.decode()
|
||||
text = json.loads(raw)["content"]["text"] if fmt == "feishu" else json.loads(raw)["text"]
|
||||
assert "运行失败" in text and LINE in text and f"base={base_id}" in text and f"target={target_id}" in text
|
||||
assert no_numbers(raw) == []
|
||||
|
||||
|
||||
async def test_cancelled_run_is_compared_but_not_pushed(recorder, client):
|
||||
"""取消的运行不推送(有人手动停的),对比照样算:触发历史里一样能点「对比上一次」"""
|
||||
sid, base_id, _, fire_id = await weekly_pair(target_status="cancelled")
|
||||
assert await webhook.notify_due() == 0 and recorder.requests == []
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, fmt=fmt, url=url)
|
||||
base_id = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, base_id, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
target_id = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status=status)
|
||||
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
# 取消的不推送(有人手动停的)
|
||||
assert await webhook.notify_due() == (1 if status == "failed" else 0)
|
||||
assert (await fire_row(fire_id)).compare_artifact is None
|
||||
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
|
||||
assert items[fire_id]["compare"]["base_run_id"] == base_id
|
||||
assert items[fire_id]["compare"] is None
|
||||
if status == "failed":
|
||||
raw = recorder.requests[0].content.decode()
|
||||
text = json.loads(raw)["content"]["text"] if fmt == "feishu" else json.loads(raw)["text"]
|
||||
assert "运行失败" in text and f"{BASE_URL}/runs/{target_id}" in text
|
||||
for absent in ("相比", "移除", "查看对比", "/runs/compare"):
|
||||
assert absent not in text, text
|
||||
if fmt == "generic":
|
||||
assert json.loads(raw)["compare"] is None
|
||||
|
||||
|
||||
async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder, client):
|
||||
"""上一次失败了:基准取再往前、成功的那一次——拿失败的那次当基准,每个指标都成了「新增」。紧挨着的上一次不是
|
||||
它,通知和触发历史写明比的是哪一次(短编号和发起的日期,按定时任务的时区)"""
|
||||
await public_address()
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow)
|
||||
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, first, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
failed = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="failed")
|
||||
await fire_for(sid, failed, T0 + WEEK - timedelta(minutes=2), notified="failed")
|
||||
target_id = await persist(week("r3", NEXT_ROWS), workflow, finished=T0 + 2 * WEEK, schedule_id=sid)
|
||||
fire_id = await fire_for(sid, target_id, T0 + 2 * WEEK - timedelta(minutes=2))
|
||||
|
||||
assert await webhook.notify_due() == 1
|
||||
stored = load((await fire_row(fire_id)).compare_artifact)
|
||||
assert stored["result"]["base"]["id"] == first and stored["previous"] is False
|
||||
[body] = recorder.bodies()
|
||||
# 第一次发起于北京时间 2026-09-07 08:58
|
||||
line = f"与上一次成功的运行(#{first[:6]},2026-09-07)相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个"
|
||||
assert line in body["text"] and "新增" not in body["text"], body["text"]
|
||||
assert f"base={first}" in body["text"] and failed not in body["text"]
|
||||
assert (body["compare"]["base_run_id"], body["compare"]["previous"]) == (first, False)
|
||||
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
|
||||
assert items[fire_id]["compare"] == {"base_run_id": first, "summary": SUMMARY, "previous": False}
|
||||
|
||||
|
||||
async def test_base_follows_launch_order_not_finish_time(recorder):
|
||||
"""第 2 周失败、第 3 周照常成功之后,有人把第 2 周接着跑完:它结束得比第 3 周晚,但它的上一次仍是第 1 周。以前
|
||||
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。第 3 周已经比过的那一份不受影响"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=None)
|
||||
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, first, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
second = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="failed")
|
||||
second_fire = await fire_for(sid, second, T0 + WEEK - timedelta(minutes=2))
|
||||
third = await persist(week("r3", NEXT_ROWS), workflow, finished=T0 + 2 * WEEK, schedule_id=sid)
|
||||
third_fire = await fire_for(sid, third, T0 + 2 * WEEK - timedelta(minutes=2))
|
||||
await webhook.notify_due()
|
||||
third_artifact = (await fire_row(third_fire)).compare_artifact
|
||||
assert load(third_artifact)["result"]["base"]["id"] == first
|
||||
|
||||
await reseal(second, week("r2", NEXT_ROWS), status="succeeded", finished=T0 + 2 * WEEK + timedelta(days=1))
|
||||
await webhook.notify_due()
|
||||
stored = load((await fire_row(second_fire)).compare_artifact)
|
||||
assert stored["result"]["base"]["id"] == first and stored["previous"] is True
|
||||
assert (await fire_row(third_fire)).compare_artifact == third_artifact
|
||||
|
||||
|
||||
async def test_first_run_of_a_schedule_has_no_compare(recorder, client):
|
||||
@@ -276,7 +368,7 @@ async def test_waits_for_the_seal_before_comparing(recorder):
|
||||
assert (await fire_row(fire_id)).compare_artifact is None and recorder.requests == []
|
||||
await seal(target_id)
|
||||
assert await webhook.notify_due() == 1
|
||||
assert load((await fire_row(fire_id)).compare_artifact)["base"]["id"] == base_id
|
||||
assert load((await fire_row(fire_id)).compare_artifact)["result"]["base"]["id"] == base_id
|
||||
assert LINE in recorder.bodies()[0]["text"]
|
||||
|
||||
|
||||
@@ -327,18 +419,28 @@ async def test_compare_failure_only_logs_and_the_notice_still_goes_out(recorder,
|
||||
assert "封存核对不一致" in caplog.text
|
||||
|
||||
|
||||
async def test_compare_is_hidden_once_the_run_is_running_again(client):
|
||||
"""失败后被继续执行(不在终态):对比页只认封存的运行,接口不给 compare;再次结束时重新比"""
|
||||
sid, base_id, target_id, fire_id = await weekly_pair(target_status="failed", url=None)
|
||||
async def test_a_failed_run_is_compared_once_it_is_continued_to_success(client):
|
||||
"""失败时不比;接着跑(不在终态)时也没有;跑完、成功并重新封存之后按新的封存比一次"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=None)
|
||||
base_id = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, base_id, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
target_id = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="failed")
|
||||
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
|
||||
async def shown():
|
||||
items = (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]
|
||||
return {f["id"]: f for f in items}[fire_id]
|
||||
|
||||
await webhook.notify_due()
|
||||
assert (await fire_row(fire_id)).compare_artifact
|
||||
assert (await fire_row(fire_id)).compare_artifact is None and (await shown())["compare"] is None
|
||||
await set_run(target_id, status="running")
|
||||
[item] = [f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"] if f["id"] == fire_id]
|
||||
assert item["run_status"] == "running" and item["compare"] is None
|
||||
await set_run(target_id, status="succeeded", finished_at=T0 + WEEK + timedelta(hours=1))
|
||||
await webhook.notify_due()
|
||||
[item] = [f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"] if f["id"] == fire_id]
|
||||
assert item["compare"] == {"base_run_id": base_id, "summary": SUMMARY}
|
||||
item = await shown()
|
||||
assert item["run_status"] == "running" and item["compare"] is None
|
||||
await reseal(target_id, week("r2", NEXT_ROWS), status="succeeded", finished=T0 + WEEK + timedelta(hours=1))
|
||||
await webhook.notify_due()
|
||||
assert (await shown())["compare"] == {"base_run_id": base_id, "summary": SUMMARY, "previous": True}
|
||||
|
||||
|
||||
async def test_compare_is_hidden_once_the_base_run_is_gone_or_running(client):
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""定时运行自动对比的几道保护(app/scheduler/auto_compare.py、webhook.notify_due):
|
||||
|
||||
- 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完。runner 先写终态、再另起会话封存,失败后接着跑完的运行
|
||||
在两步之间还带着失败时的清单哈希——这时去比,只比得到旧封存范围里的事件,通知里的清单哈希也是旧的。等不来新的
|
||||
封存(两步之间进程没了)时照常推送,不带对比、不写旧的哈希
|
||||
- 对比结果记下两边的清单哈希:任何一边后来变了,触发历史就不再给这份对比(悬停的计数和点进去的对比页对不上);
|
||||
对比途中封存变了,结果不记
|
||||
- 时限:对比放在认领之前、锁外做,最多等 WAIT_S 秒,同一时刻最多 SLOTS 个。慢的对比不压住这条通知,也不压住别的
|
||||
定时任务的通知;扫描在对比途中被取消(停机),这次触发还没认领,下次照常比、照常推。同一份封存已经比过的(比完
|
||||
还没推送就停机了),下次直接用记下的结果
|
||||
|
||||
运行、任务名、金额都是虚构的(tests/fixtures/compare_runs.py),不真的执行;Webhook 用 httpx.MockTransport 接住。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import scheduler
|
||||
from app.api import compare as compare_api
|
||||
from app.core.artifact_store import load
|
||||
from app.core.config import Settings
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Run, RunEvent
|
||||
from app.main import app
|
||||
from app.scheduler import auto_compare, webhook
|
||||
from tests.fixtures.compare_runs import BASE_ROWS, NEXT_ROWS, Fake, default_metrics, region_sql, week
|
||||
from tests.test_scheduler_compare import ( # noqa: F401 - clean、recorder 是夹具,按名字引入
|
||||
BASE_URL, LINE, T0, WEEK, Recorder, clean, fire_for, fire_row, new_schedule, new_workflow, persist,
|
||||
public_address, recorder, reseal, seal, set_run, weekly_pair,
|
||||
)
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slow_compare(monkeypatch):
|
||||
"""对比要 5 秒:真的对比之前先等着。返回原来的函数,用例里可以换回去"""
|
||||
real = compare_api.compare_runs
|
||||
|
||||
async def slow(*args, **kwargs):
|
||||
await asyncio.sleep(5)
|
||||
return await real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(compare_api, "compare_runs", slow)
|
||||
return real
|
||||
|
||||
|
||||
def failed_then_continued(run_id: str, rows=NEXT_ROWS) -> tuple[Fake, int]:
|
||||
"""在 Agent 节点失败、之后从断点接着跑完的一次周报。返回 (运行, 失败时封存到第几条事件)。"""
|
||||
run = Fake(run_id)
|
||||
sql = region_sql()
|
||||
fetch = run.tool("fetch", sql, rows=rows)
|
||||
run.add("node.started", "bot", {"node_type": "agent"})
|
||||
run.add("node.failed", "bot", {"error": "模型接入超时"})
|
||||
run.add("run.failed", None, {"error": "节点「区域分析」失败"})
|
||||
failed_at = len(run.events)
|
||||
run.add("run.resumed", None, {"from": ["bot"], "message": "从「区域分析」继续运行"})
|
||||
bot = run.agent("bot", sql, rows=rows)
|
||||
run.card(default_metrics(fetch, bot, rows, rows))
|
||||
run.add("run.finished", None, {"output": {}})
|
||||
return run, failed_at
|
||||
|
||||
|
||||
async def previous_week(*, url: str | None = None):
|
||||
"""一个定时任务和它上一次成功、已推送过的周报。返回 (工作流, 任务, 上一次)。"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=url) if url else await new_schedule(workflow)
|
||||
base_id = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, base_id, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
return workflow, sid, base_id
|
||||
|
||||
|
||||
async def seal_of(run_id: str) -> str | None:
|
||||
async with SessionLocal() as session:
|
||||
return (await session.get(Run, run_id)).manifest_hash
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 和封存绑定
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_waits_for_the_seal_of_this_ending_not_the_failed_one(recorder):
|
||||
"""失败后接着跑完:终态和结束时间已经写下,封存还是失败时那一份(只盖到 run.failed)。这一拍不比、不推,新的
|
||||
封存补上之后再比,比的是完整的一次,通知里的清单哈希是新的"""
|
||||
await public_address()
|
||||
workflow, sid, base_id = await previous_week()
|
||||
fake, failed_at = failed_then_continued("r2")
|
||||
target_id = await persist(fake, workflow, finished=datetime.now(UTC), schedule_id=sid, seal_upto=failed_at)
|
||||
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
stale = await seal_of(target_id)
|
||||
|
||||
assert await webhook.notify_due() == 0
|
||||
assert recorder.requests == [] and (await fire_row(fire_id)).compare_artifact is None
|
||||
await seal(target_id)
|
||||
fresh = await seal_of(target_id)
|
||||
assert fresh != stale
|
||||
assert await webhook.notify_due() == 1
|
||||
stored = load((await fire_row(fire_id)).compare_artifact)
|
||||
assert (stored["target_manifest"], stored["result"]["base"]["id"]) == (fresh, base_id)
|
||||
[body] = recorder.bodies()
|
||||
assert LINE in body["text"] and body["manifest_hash"] == fresh[:12]
|
||||
|
||||
|
||||
async def test_a_seal_that_never_catches_up_is_not_used(recorder):
|
||||
"""新的封存一直没补上(写完终态、封存之前进程没了):等过时限照常推送,但不比,也不写失败时那份旧的清单哈希"""
|
||||
await public_address()
|
||||
workflow, sid, _ = await previous_week()
|
||||
fake, failed_at = failed_then_continued("r2")
|
||||
target_id = await persist(fake, workflow, finished=datetime.now(UTC), schedule_id=sid, seal_upto=failed_at)
|
||||
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
later = datetime.now(UTC) + timedelta(seconds=webhook.SEAL_WAIT_S + 5)
|
||||
assert await webhook.notify_due(later) == 1
|
||||
[body] = recorder.bodies()
|
||||
assert (body["event"], body["manifest_hash"], body["compare"]) == ("finished", None, None)
|
||||
assert "清单哈希" not in body["text"] and "相比" not in body["text"]
|
||||
assert (await fire_row(fire_id)).compare_artifact is None
|
||||
|
||||
|
||||
async def test_approval_notice_does_not_carry_the_seal_of_an_earlier_failure(recorder):
|
||||
"""失败后接着跑、停在审批上:运行上还留着失败时的清单哈希,那不是这一次的封存,「等待审批」里不写"""
|
||||
workflow, sid, _ = await previous_week()
|
||||
fake, failed_at = failed_then_continued("r2")
|
||||
target_id = await persist(fake, workflow, finished=T0 + WEEK, schedule_id=sid, status="interrupted",
|
||||
seal_upto=failed_at)
|
||||
await set_run(target_id, finished_at=None)
|
||||
async with SessionLocal() as session:
|
||||
from app.db.models import Approval
|
||||
|
||||
session.add(Approval(run_id=target_id, node_id="card", title="审核周报口径", status="pending"))
|
||||
await session.commit()
|
||||
await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
assert await webhook.notify_due() == 1
|
||||
[body] = recorder.bodies()
|
||||
assert (body["event"], body["manifest_hash"]) == ("awaiting_approval", None)
|
||||
assert "清单哈希" not in body["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["target", "base"])
|
||||
async def test_fire_history_drops_a_compare_whose_seal_changed(client, side):
|
||||
"""比过之后哪一边被重新封存(内容变了):记下的计数已经不是对比页现场算出来的那样,触发历史不再给这份对比"""
|
||||
sid, base_id, target_id, fire_id = await weekly_pair(url=None)
|
||||
await webhook.notify_due()
|
||||
|
||||
async def shown():
|
||||
items = (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]
|
||||
return {f["id"]: f for f in items}[fire_id]["compare"]
|
||||
|
||||
assert (await shown())["base_run_id"] == base_id
|
||||
# 换成和另一边一样的数:现场再比就是「未变」
|
||||
run_id, rows = (target_id, BASE_ROWS) if side == "target" else (base_id, NEXT_ROWS)
|
||||
async with SessionLocal() as session:
|
||||
finished = (await session.get(Run, run_id)).finished_at
|
||||
await reseal(run_id, week("rx", rows), status="succeeded", finished=finished)
|
||||
assert await shown() is None
|
||||
# 对比页现场算出来的和记下的确实不一样
|
||||
assert (await compare_api.compare_runs(target_id, base_id))["summary"]["unchanged"] == 2
|
||||
|
||||
|
||||
async def test_result_is_dropped_when_a_seal_changes_mid_compare(recorder, monkeypatch):
|
||||
"""对比途中基准被重新封存:比出来的是旧封存的结果,不记进触发记录、不写进通知"""
|
||||
await public_address()
|
||||
_, base_id, _, fire_id = await weekly_pair()
|
||||
real = compare_api.compare_runs
|
||||
|
||||
async def resealing(run_id, other):
|
||||
out = await real(run_id, other)
|
||||
await set_run(base_id, manifest_hash="ab" * 32)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(compare_api, "compare_runs", resealing)
|
||||
assert await webhook.notify_due() == 1
|
||||
assert (await fire_row(fire_id)).compare_artifact is None
|
||||
[body] = recorder.bodies()
|
||||
assert body["compare"] is None and "相比" not in body["text"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 时限、取消、并发
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_slow_compare_is_cut_off_and_the_notice_goes_out(recorder, monkeypatch, caplog, slow_compare):
|
||||
"""对比超过时限:通知不等它,照常推送、不带对比,记一条警告"""
|
||||
monkeypatch.setattr(auto_compare, "WAIT_S", 0.3, raising=False)
|
||||
await public_address()
|
||||
_, _, target_id, fire_id = await weekly_pair()
|
||||
began = time.monotonic()
|
||||
with caplog.at_level(logging.WARNING, logger="app.scheduler"):
|
||||
assert await webhook.notify_due() == 1
|
||||
assert time.monotonic() - began < 2
|
||||
[body] = recorder.bodies()
|
||||
assert (body["event"], body["compare"]) == ("finished", None) and "相比" not in body["text"]
|
||||
assert f"{BASE_URL}/runs/{target_id}" in body["text"]
|
||||
assert (await fire_row(fire_id)).compare_artifact is None
|
||||
assert "超过" in caplog.text and target_id in caplog.text
|
||||
|
||||
|
||||
async def test_scan_cancelled_mid_compare_pushes_next_time(recorder, monkeypatch, slow_compare):
|
||||
"""停机时通知扫描只等 1.5 秒就被取消:撞在对比途中的那次触发还没认领,下次启动照常比、照常推。以前先认领、后比,
|
||||
取消之后这条「运行结束」再也不会推"""
|
||||
await public_address()
|
||||
_, _, _, fire_id = await weekly_pair()
|
||||
with pytest.raises(TimeoutError):
|
||||
await asyncio.wait_for(webhook.notify_due(), timeout=0.5)
|
||||
assert (await fire_row(fire_id)).notified_status is None and recorder.requests == []
|
||||
monkeypatch.setattr(compare_api, "compare_runs", slow_compare)
|
||||
assert await webhook.notify_due() == 1
|
||||
[body] = recorder.bodies()
|
||||
assert LINE in body["text"]
|
||||
assert (await fire_row(fire_id)).notified_status == "succeeded"
|
||||
|
||||
|
||||
async def test_a_slow_compare_does_not_hold_other_schedules(monkeypatch, slow_compare):
|
||||
"""任务 A 的对比慢:调度循环里通知扫描同一时刻只跑一份,以前 A 比多久,之后才结束的任务 B(第一次,不用比)
|
||||
的通知就要等多久。现在 A 的对比等过时限就不等了,B 的通知在下一拍推出"""
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(webhook, "_transport", httpx.MockTransport(rec))
|
||||
monkeypatch.setattr(webhook, "RETRY_DELAYS", (0, 0, 0))
|
||||
monkeypatch.setattr(auto_compare, "WAIT_S", 0.5, raising=False)
|
||||
await weekly_pair()
|
||||
on = Settings(_env_file=None, scheduler=True)
|
||||
assert await scheduler.start(interval=0.05, config=on) is True
|
||||
try:
|
||||
await asyncio.sleep(0.2)
|
||||
workflow = await new_workflow("仓库日报")
|
||||
sid = await new_schedule(workflow, name="仓库日报 · 每天")
|
||||
run_id = await persist(week("b1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, run_id, T0 - timedelta(minutes=2))
|
||||
began = time.monotonic()
|
||||
while not any(b.get("run_id") == run_id for b in rec.bodies()) and time.monotonic() - began < 8:
|
||||
await asyncio.sleep(0.02)
|
||||
waited = time.monotonic() - began
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert any(b.get("run_id") == run_id for b in rec.bodies())
|
||||
assert waited < 2, f"任务 B 的通知等了 {waited:.2f} 秒"
|
||||
|
||||
|
||||
async def test_compares_take_turns(monkeypatch):
|
||||
"""同一拍里好几个任务都要比:同一时刻最多 SLOTS 个,不一起占满线程池(接口请求也要用)"""
|
||||
monkeypatch.setattr(auto_compare, "SLOTS", 2, raising=False)
|
||||
real = compare_api.compare_runs
|
||||
active = peak = 0
|
||||
|
||||
async def counted(*args, **kwargs):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
try:
|
||||
await asyncio.sleep(0.2)
|
||||
return await real(*args, **kwargs)
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(compare_api, "compare_runs", counted)
|
||||
fires = [(await weekly_pair(url=None))[3] for _ in range(4)]
|
||||
await webhook.notify_due()
|
||||
assert peak == 2
|
||||
assert all([(await fire_row(f)).compare_artifact for f in fires])
|
||||
|
||||
|
||||
async def test_a_compare_already_made_for_this_seal_is_reused(recorder, monkeypatch):
|
||||
"""比完、还没认领就停机了,结果已经记在触发记录上:下次直接用,不再算一遍"""
|
||||
await public_address()
|
||||
_, _, target_id, fire_id = await weekly_pair()
|
||||
assert await auto_compare.compare_fire(fire_id, target_id) is not None
|
||||
artifact = (await fire_row(fire_id)).compare_artifact
|
||||
assert artifact
|
||||
calls = 0
|
||||
|
||||
async def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise RuntimeError("不该再算一遍")
|
||||
|
||||
monkeypatch.setattr(compare_api, "compare_runs", counted)
|
||||
auto_compare._BRIEFS.clear()
|
||||
assert await webhook.notify_due() == 1
|
||||
assert calls == 0 and LINE in recorder.bodies()[0]["text"]
|
||||
assert (await fire_row(fire_id)).compare_artifact == artifact
|
||||
|
||||
|
||||
async def test_seal_covers_the_last_ending():
|
||||
"""封存盖没盖住最后一条结束事件:老运行(没有 manifest_seq)只看有没有哈希"""
|
||||
workflow = await new_workflow()
|
||||
fake, failed_at = failed_then_continued("r9")
|
||||
partial = await persist(fake, workflow, finished=T0, seal_upto=failed_at)
|
||||
full = await persist(failed_then_continued("r8")[0], workflow, finished=T0)
|
||||
legacy = await persist(week("r7"), workflow, finished=T0)
|
||||
await set_run(legacy, manifest_seq=None)
|
||||
unsealed = await persist(week("r6"), workflow, finished=T0, seal=False)
|
||||
async with SessionLocal() as session:
|
||||
got = {rid: await auto_compare.sealed_now(session, await session.get(Run, rid))
|
||||
for rid in (partial, full, legacy, unsealed)}
|
||||
assert await session.scalar(select(RunEvent.seq).where(
|
||||
RunEvent.run_id == partial, RunEvent.type == "run.finished")) > failed_at
|
||||
assert got == {partial: False, full: True, legacy: True, unsealed: False}
|
||||
@@ -1,9 +1,10 @@
|
||||
"""定时任务通知(app/scheduler/webhook.py)的两条规则:
|
||||
|
||||
- 失败通知的「处理」行不许诺能继续运行:服务重启时还在排队、从没开始执行的运行(被改成失败、没有开始时间)没有
|
||||
断点,「修正后可继续运行」是错的。按有没有开始执行分开写——没开始的另写一行原因(没有可继续的进度),处理一律是
|
||||
中性的「请在运行记录中查看原因」;任务还在排期上时补一句「下一次触发会照常发起」:失败的运行不挡之后的触发,
|
||||
和挂起的那种(之后的触发都会跳过)说清区别
|
||||
断点,「修正后可继续运行」是错的。按有没有开始执行分开写——没开始的另写一行原因(没有可继续的进度),处理行不再
|
||||
让人去「查看原因」(原因刚写过),任务还在排期上时只写「下一次触发会照常发起」,不在排期上时写怎么重新发起;开始
|
||||
执行过的写「请在 AgentLab 的「记录」中查看失败原因」(和挂起那句一样指到导航里的「记录」),任务还在排期上时补
|
||||
「下一次触发会照常发起」:失败的运行不挡之后的触发,和挂起的那种(之后的触发都会跳过)说清区别
|
||||
- 扫描窗口(SCAN_DAYS)之外的触发:停在审批、有待审批的运行不受窗口限制——创建 30 天以后才停到审批上的照样推送
|
||||
「等待审批」;运行记录在窗口内有变化的(等了一个多月的审批批完、跑完)照样推送结束。早就结束、记录多日没有变化
|
||||
的旧触发仍不扫
|
||||
@@ -27,6 +28,8 @@ from app.scheduler import webhook
|
||||
UTC = timezone.utc
|
||||
GENERIC = "https://hooks.example.com/agentlab/inbox-3e9d"
|
||||
NOT_STARTED = "运行在开始执行前就已中止(通常是服务重启时仍在排队),没有可继续的进度"
|
||||
LOOK = "请在 AgentLab 的「记录」中查看失败原因"
|
||||
RERUN = "如需重新发起,请在 AgentLab 的「定时任务」中点击「立即运行」"
|
||||
|
||||
|
||||
class Recorder:
|
||||
@@ -106,12 +109,15 @@ async def notified(fire_id: str) -> str | None:
|
||||
|
||||
|
||||
def test_a_run_that_never_started_is_not_promised_a_continue():
|
||||
"""服务重启时还在排队的运行没有断点:不写「修正后可继续运行」,另写一行原因说没有可继续的进度"""
|
||||
"""服务重启时还在排队的运行没有断点:不写「修正后可继续运行」,另写一行原因说没有可继续的进度。处理行不再让人
|
||||
去「查看原因」(原因刚写过):还在排期上的只说下一次照常,不在排期上的说怎么重新发起"""
|
||||
_, lines, _ = webhook.message(notice(started_at=None, next_fire=True))
|
||||
shown = dict(lines)
|
||||
assert shown["原因"] == NOT_STARTED
|
||||
assert shown["处理"] == "请在 AgentLab 的运行记录中查看原因;下一次触发会照常发起"
|
||||
assert not any("继续运行" in value for _, value in lines), lines
|
||||
assert shown["处理"] == "下一次触发会照常发起"
|
||||
assert not any("继续运行" in value or "查看" in value for _, value in lines), lines
|
||||
_, lines, _ = webhook.message(notice(started_at=None, next_fire=False))
|
||||
assert dict(lines)["处理"] == RERUN
|
||||
|
||||
|
||||
def test_a_failed_run_gets_a_neutral_action_without_promising_a_continue():
|
||||
@@ -120,14 +126,14 @@ def test_a_failed_run_gets_a_neutral_action_without_promising_a_continue():
|
||||
_, lines, _ = webhook.message(notice(error_node="汇总库存", next_fire=True))
|
||||
shown = dict(lines)
|
||||
assert shown["失败节点"] == "「汇总库存」" and "原因" not in shown
|
||||
assert shown["处理"] == "请在 AgentLab 的运行记录中查看原因;下一次触发会照常发起"
|
||||
assert shown["处理"] == f"{LOOK};下一次触发会照常发起"
|
||||
assert "修正后可继续运行" not in webhook._text(notice(error_node="汇总库存"))
|
||||
|
||||
|
||||
def test_next_fire_is_only_promised_while_the_schedule_is_on():
|
||||
"""任务已暂停(或往后不再触发)时不说「下一次触发会照常发起」"""
|
||||
_, lines, _ = webhook.message(notice(next_fire=False))
|
||||
assert dict(lines)["处理"] == "请在 AgentLab 的运行记录中查看原因"
|
||||
assert dict(lines)["处理"] == LOOK
|
||||
|
||||
|
||||
async def test_restart_failure_notice_end_to_end(recorder):
|
||||
@@ -140,12 +146,12 @@ async def test_restart_failure_notice_end_to_end(recorder):
|
||||
by_fire = {b["fire_id"]: b for b in recorder.bodies()}
|
||||
on, off = by_fire[on_fire], by_fire[off_fire]
|
||||
assert (on["event"], on["status"], on["reason"]) == ("failed", "failed", NOT_STARTED)
|
||||
assert "处理:请在 AgentLab 的运行记录中查看原因;下一次触发会照常发起" in on["text"]
|
||||
assert "处理:请在 AgentLab 的运行记录中查看原因\n" in off["text"] + "\n" and "照常发起" not in off["text"]
|
||||
assert "处理:下一次触发会照常发起" in on["text"]
|
||||
assert f"处理:{RERUN}" in off["text"] and "照常发起" not in off["text"]
|
||||
for body in (on, off):
|
||||
assert "继续运行" not in body["text"], body["text"]
|
||||
# 运行记录上的原始报错不进通知
|
||||
assert "请重新发起运行" not in body["text"]
|
||||
assert "没有可恢复的进度" not in body["text"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -179,7 +179,7 @@ def test_failed_run_text_points_to_the_node_without_repeating_itself():
|
||||
shown = dict(lines)
|
||||
assert title.endswith("运行失败") and shown["状态"] == "失败"
|
||||
# 处理只说去哪里看,不许诺能继续运行(没有断点的运行续不了,见 test_scheduler_notice_rules.py)
|
||||
assert shown["失败节点"] == "「查询门店销售」" and shown["处理"] == "请在 AgentLab 的运行记录中查看原因"
|
||||
assert shown["失败节点"] == "「查询门店销售」" and shown["处理"] == "请在 AgentLab 的「记录」中查看失败原因"
|
||||
# 不再写「原因:运行失败,详情请查看运行记录」这种同义反复
|
||||
assert "原因" not in shown
|
||||
|
||||
|
||||
Reference in New Issue
Block a user