执行摘要
- 一句话:Lark CI 卡片按相同失败分析合并 job 行,消除重复刷屏
- 推荐动作:值得快速精读:源码改动不到 30 行,但“分组键 + 非空护栏 + 保留每 job 链接”是一套通用的告警去重取舍,可直接迁移到其他通知渠道。阅读时建议重点确认三点:分组键选择(整段分析元组是否过严/过松)、空分析必须独立成行的护栏、以及截断(
jobs[:limit])在分组前后顺序上的语义。后续可考虑补一个“多个 job 逐条 UNAVAILABLE_REASON”的用例,或在分组键中显式过滤哨兵值,以对齐 PR 描述中的承诺。
功能与动机
PR body 开门见山描述了触发场景:2026-09-10 的 nightly 卡片把同一条失败打印了五次(stage-b-cpu / stage-a-cpu 各 shard 都报 “[rollout] tests/e2e/agentic/test_harbor_e2b_rollout.py ↳ Test collection failed because register_cuda_ci() rejected the file's hardware list”)。作者强调 “Not five failures — one.”:collect_tests(sanity_check=True) 会在每次 run_suite.py 启动时对仓库内每个注册文件做 AST 检查,任何一个文件缺 hardware= 参数都会让所有 CPU shard 在 “Resolve suite plan” 阶段直接抛错,测试一个都没跑。而卡片是 job 粒度,于是一个缺陷按 shard 数复制。作者进一步指出这五行不是无关痛痒的噪音:“they were the whole card. Anything else that broke would have been pushed out of view.” —— 真正的第二处故障会被挤出可视区域,所以要按“同一缺陷只占一行”来重排。
实现拆解
- 引入分组函数(
.github/workflows/scripts/lark_notify.py):新增 group_identical_failures(jobs, reasons),为每个 job 计算分组键 analyses = tuple((reasons or {}).get(job.get("id")) or ()),即该 job 的完整分析元组;键已存在则把 job 追加进组,否则新建组。分组只改变展示聚合,不改变 job 顺序与失败判定。
- 设置“非空”护栏:只有
analyses 为真值时才把组写入 members_by_analyses 索引,因此无分析、分析为空列表、reasons=None(分析功能关闭)三种情况都会退化为“一 job 一行”。这是防止“整张卡片被折叠成一行”的关键条件,作者也把理由写进了函数的 docstring。
- 重写行渲染(
list_jobs_md):从“逐 job 追加一行 + 逐 job 渲染分析”改为“逐组追加一行”,行内用 ", " 连接组内所有 job 的 Markdown 链接,随后只调用一次 analysis_md(analysis, repo) 渲染共享分析块。jobs[:limit] 截断与 ... and N more 尾部逻辑保持不变,保证长列表仍受 MAX_LISTED_JOBS 约束。
- 测试配套(
tests/ci/test/test_lark_notify.py):新增 shard(index) 工厂构造与真实 nightly 同形的作业字典(name 形如 stage-a-cpu (i) / run-cpu),并用三个用例锁定行为:五个 shard 共享同一分析折叠为一行(断言 "↳" 恰好 2 次、stage-a-cpu 仍出现 5 次、无第二个 "
- ");两个相同 + 一个不同的分析产生两行且不同的那行内容完整;reasons=None 时三行保持不变。
- 无配置/schema/工作流改动:本次未触碰
.github/workflows/ci-lark-notify.yml、policies 或失败分析 prompt,变更被严格限制在渲染函数与其单测,配套验证为 pytest tests/ci/test/(907 passed, 1 skipped)与 pre-commit run --all-files。
关键文件:
.github/workflows/scripts/lark_notify.py(模块 CI 通知;类别 infra;类型 core-logic;符号 group_identical_failures, list_jobs_md, analysis_md): 本 PR 唯一的源码改动:新增 group_identical_failures 重写 list_jobs_md 的行输出,决定同一缺陷的多个 shard 如何折叠为一行并保留每个 job 的独立链接。
tests/ci/test/test_lark_notify.py(模块 CI 测试;类别 test;类型 test-coverage;符号 shard, test_one_defect_across_every_shard_collapses_to_a_single_row, test_a_different_failure_keeps_its_own_row, test_rows_stay_separate_when_no_analysis_is_available): 新增 shard 工厂与三个用例,锁定折叠行为、不同故障保留独立行、以及 reasons=None 时不折叠这三条边界。
关键符号:group_identical_failures, list_jobs_md, shard, test_one_defect_across_every_shard_collapses_to_a_single_row, test_a_different_failure_keeps_its_own_row, test_rows_stay_separate_when_no_analysis_is_available
关键源码片段
.github/workflows/scripts/lark_notify.py
本 PR 唯一的源码改动:新增 group_identical_failures 重写 list_jobs_md 的行输出,决定同一缺陷的多个 shard 如何折叠为一行并保留每个 job 的独立链接。
# 新增的分组函数:把「分析结果非空且完全一致」的 job 归入同一行
# 返回 [(analyses, [job, ...]), ...],analyses 为空元组表示该行只含一个无可比对的 job
def group_identical_failures(jobs: list, reasons: dict | None) -> list[tuple[tuple, list]]:
"""一个缺陷可能让每个 shard 以完全相同的方式失败,逐 job 重复展示会把其他故障挤出卡片。
仅在分析结果非空且完全一致时才合并:分析缺失时没有可比对的内容,
这些行必须保持独立,否则「分析功能已关闭」的卡片会把所有失败 job 挤成一行。
"""
groups: list[tuple[tuple, list]] = []
members_by_analyses: dict[tuple, list] = {}
for job in jobs:
# 分组键 = 该 job 的全部分析构成的元组;无分析(None 或空列表)时退化为空元组
analyses = tuple((reasons or {}).get(job.get("id")) or ())
if analyses and analyses in members_by_analyses:
# 命中已有分组:只把 job 追加为组员,不再新开一行
members_by_analyses[analyses].append(job)
continue
members = [job]
groups.append((analyses, members))
if analyses:
# 空分析不进入索引,保证「无分析」的 job 各自成行
members_by_analyses[analyses] = members
return groups
# 渲染侧改造:由「逐 job 一行」变为「逐分组一行」
def list_jobs_md(
jobs: list,
limit: int = MAX_LISTED_JOBS,
reasons: dict | None = None,
repo: str = DEFAULT_REPO,
) -> str:
lines = []
# 先按 limit 截断再分组:超出 limit 的组员不会出现在行内,由尾部 "and N more" 兜底
for analyses, members in group_identical_failures(jobs[:limit], reasons):
# 同一行内用 ", " 连接组内所有 job 链接,每个 job 仍是可单独点开的入口
lines.append("- " + ", ".join(f"[{job['name']}]({job['html_url']})" for job in members))
# 共享分析块只渲染一次(标签、test_name、原因、关联 PR 都由 analysis_md 统一拼装)
for analysis in analyses:
lines.extend(analysis_md(analysis, repo))
if len(jobs) > limit:
lines.append(f"- ... and {len(jobs) - limit} more")
return "\n".join(lines)
tests/ci/test/test_lark_notify.py
新增 shard 工厂与三个用例,锁定折叠行为、不同故障保留独立行、以及 reasons=None 时不折叠这三条边界。
def shard(index):
# 构造与真实 nightly 同形的 shard 作业:名字带 stage 与序号,conclusion 固定为 failure
return {
"id": index,
"name": f"stage-a-cpu ({index}) / run-cpu",
"html_url": f"https://example/jobs/{index}",
"conclusion": "failure",
}
def test_one_defect_across_every_shard_collapses_to_a_single_row():
# 五个 shard 共享同一条分析:期望只剩 1 个列表项,"↳" 出现 2 次(标签行 + 原因行)
same = ANALYSIS(reason="Collection failed because the hardware list was rejected.", tags=("rollout",))
content = HANDLER.list_jobs_md([shard(i) for i in range(5)], reasons={i: [same] for i in range(5)})
assert content.count("↳") == 2, content
assert content.count("stage-a-cpu") == 5 # 五个 job 链接都还在,只是合并到同一行
assert content.count("
- ") == 0 # 仅一条列表项,且位于字符串开头故不匹配 "
- "
def test_a_different_failure_keeps_its_own_row():
# 两个相同 + 一个不同:不同的分析必须完整保留在自己的行里
same = ANALYSIS(reason="Collection failed because the hardware list was rejected.")
other = ANALYSIS(reason="The deterministic test exited with code 1.")
content = HANDLER.list_jobs_md([shard(0), shard(1), shard(2)], reasons={0: [same], 1: [same], 2: [other]})
assert content.count("
- ") == 1, content
assert "The deterministic test exited with code 1." in content
def test_rows_stay_separate_when_no_analysis_is_available():
# reasons=None(分析功能关闭)必须退化为「一 job 一行」,否则卡片会误导为单一故障
content = HANDLER.list_jobs_md([shard(0), shard(1), shard(2)], reasons=None)
assert content.count("
- ") == 2, content
评论区精华
本 PR 没有任何实质性的评审讨论:claude[bot] 只留下仓库“需手动触发 review”的配置提示(Comment @claude review ...),并非技术评审;guapisolo 直接 APPROVED 且正文为空。因此设计权衡全部由作者在 PR body 与函数 docstring 中预先说明,最值得记录的是两条自我设限:一是“分组要求分析非空且完全相同”,作者明确写出“Without that condition a card with the analysis switched off — where no job has an analysis and there is nothing to compare — would collapse every failed job into a single row.”;二是“分析不可用的 job 保持独立”,理由是无可比对的内容。这两点没有 reviewer 追问或反驳,也没有留下未解决的公开疑虑。
- 为什么合并必须要求「分析非空且完全相同」 (design): 以「非空 + 完全一致」作为分组键的准入条件,无分析的行行为与改动前完全一致;由 test_rows_stay_separate_when_no_analysis_is_available 用例锁定。
- 「分析不可用」的 job 是否真的保持独立 (correctness): 未在 PR 内解决,也没有对应测试覆盖(仅覆盖整体 unavailable=True 与 reasons=None);属于描述与实现的潜在偏差。
- 评审只做了形式化通过,缺实质技术讨论 (other): 变更以作者自测(pytest tests/ci/test/ 907 passed, 1 skipped)与 pre-commit 通过作为唯一验证手段。
风险与影响
- 风险:
- 合并语义依赖 LLM 生成文本的“完全相同”:分组键是整段分析元组,若两个根因不同的失败恰好被分析模型产出同样(或同样笼统)的文字,就会被折进同一行,读者可能误以为只有一处故障。缓解点在于每个 job 链接都被保留,可点开逐个确认,且真实分析通常带 test_name 等具体信息,完全相同的概率较低。
- “分析不可用”路径与描述存在潜在偏差:PR 描述称分析不可用的 job 会保持独立,但实现的分组键并未剔除哨兵值
UNAVAILABLE_REASON。若多个 job 逐条写入同一个 unavailable 常量文本(test_per_job_missing_log_reason_and_omitted_footer_render_compactly 展示的正是这种单 job 形态),它们的元组会完全相同而被合并,与描述不符。本次测试只覆盖了整卡 unavailable=True 与 reasons=None,没有覆盖“多个 job 逐条 unavailable”的组合。
- 截断与分组的交互:
group_identical_failures(jobs[:limit], reasons) 先截断再分组,若一个故障横跨的 shard 数超过 MAX_LISTED_JOBS,行内只列出前 limit 个 job 链接,其余由 ... and N more 概括;信息不丢失,但行内链接数与计数提示的对应关系会变模糊。
- 测试与渲染格式强耦合:用例通过统计
"↳" 出现次数和 "
- " 计数来断言结果,任何对前缀符号或行首格式的调整都会连带让这些断言失败,属于可维护性成本而非功能风险。
- 无回归外溢:改动只发生在 Lark 通知脚本的渲染函数,不参与失败采集、分析调用、CI 调度或训练/rollout 主路径;即使渲染异常,也只影响通知卡片可读性。
- 影响:对团队而言,收益直接体现在 nightly 值班体验:一个跨 shard 的收集期缺陷从占满整张卡片变成一行,其他独立故障重新回到视野;每个 job 仍保留独立链接,排障路径不变。对系统而言影响面极窄——只改变 .github/workflows/scripts/lark_notify.py 生成的 Markdown 行结构,job 抓取、失败分析调用、policies 校验与 FastFail 行为均未改动,因此不存在对训练、rollout、权重同步等主链路的运行时影响。对协作者而言,新增的 group_identical_failures 成为一个可复用的“通知去重”抽象,后续若要按 root cause 或标签聚合失败,可直接在此基础上扩展分组键。
- 风险标记:分组键未过滤 unavailable 哨兵, 同文本不同根因可能被合并, unavailable 组合缺少测试覆盖, 仅影响 CI 通知渲染层
关联脉络
- PR #3152 ci(lark): explain each failed job on the CI status card: 引入 Lark 卡片中每个失败 job 下方的 AI 分析块(analysis_md 及 AnalysisOutcome 体系),本 PR 折叠的正是这套按 job 展开的分析行。
- PR #3168 ci(lark): report every failing test in a job: 同一脚本 .github/workflows/scripts/lark_notify.py 的上一轮演进,把失败展示从 job 粒度细化到测试粒度;本 PR 从另一个方向(合并重复)继续优化同一张卡片的信噪比。
- PR #2701 feat(ci): declare supported GPU generations on every CUDA test: 建立 register_cuda_ci(hardware=...) 契约并要求每个 CUDA 测试声明硬件;本次 nightly 刷屏的直接诱因就是某个文件缺 hardware= 参数,使每个 shard 在收集阶段抛错。
- PR #3184 ci: declare hardware on the two CUDA tests that landed after #2701: 补上 #2701 之后遗漏的硬件声明,正是它修掉了让五个 CPU shard 同时失败的同一类缺陷;与本 PR 属于同一晚 nightly 问题的上下游。
- PR #2876 ci(e2e): Harbor trials on real e2b sandboxes through the full rollout path (manual for now): 新增了 tests/e2e/agentic/test_harbor_e2b_rollout.py——PR body 中报错文件正是它,其 hardware= 声明缺失导致跨 shard 的重复失败。
参与讨论