Prhub

#3197 ci(lark): collapse jobs that failed identically into one card row

原始 PR 作者 yueming-yuan 合并时间 2026-09-11 03:14 文件变更 2 提交数 1 评论 0 代码增减 +53 / -3

执行摘要

Lark CI 卡片按相同失败分析合并 job 行,消除重复刷屏

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.” —— 真正的第二处故障会被挤出可视区域,所以要按“同一缺陷只占一行”来重排。

值得快速精读:源码改动不到 30 行,但“分组键 + 非空护栏 + 保留每 job 链接”是一套通用的告警去重取舍,可直接迁移到其他通知渠道。阅读时建议重点确认三点:分组键选择(整段分析元组是否过严/过松)、空分析必须独立成行的护栏、以及截断(jobs[:limit])在分组前后顺序上的语义。后续可考虑补一个“多个 job 逐条 UNAVAILABLE_REASON”的用例,或在分组键中显式过滤哨兵值,以对齐 PR 描述中的承诺。

讨论亮点

本 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 追问或反驳,也没有留下未解决的公开疑虑。

实现拆解

  1. 引入分组函数(.github/workflows/scripts/lark_notify.py):新增 group_identical_failures(jobs, reasons),为每个 job 计算分组键 analyses = tuple((reasons or {}).get(job.get("id")) or ()),即该 job 的完整分析元组;键已存在则把 job 追加进组,否则新建组。分组只改变展示聚合,不改变 job 顺序与失败判定。
  2. 设置“非空”护栏:只有 analyses 为真值时才把组写入 members_by_analyses 索引,因此无分析、分析为空列表、reasons=None(分析功能关闭)三种情况都会退化为“一 job 一行”。这是防止“整张卡片被折叠成一行”的关键条件,作者也把理由写进了函数的 docstring。
  3. 重写行渲染(list_jobs_md):从“逐 job 追加一行 + 逐 job 渲染分析”改为“逐组追加一行”,行内用 ", " 连接组内所有 job 的 Markdown 链接,随后只调用一次 analysis_md(analysis, repo) 渲染共享分析块。jobs[:limit] 截断与 ... and N more 尾部逻辑保持不变,保证长列表仍受 MAX_LISTED_JOBS 约束。
  4. 测试配套(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 时三行保持不变。
  5. 无配置/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 通知 modified 5.48
tests/ci/test/test_lark_notify.py CI 测试 modified 6.05

关键符号

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 core-logic

本 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 test-coverage

新增 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("\n- ") == 0 # 仅一条列表项,且位于字符串开头故不匹配 "\n- "
​
​
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("\n- ") == 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("\n- ") == 2, content

评论区精华

为什么合并必须要求「分析非空且完全相同」 设计

没有 reviewer 在评论区提出该问题;作者在 PR body 与 group_identical_failures 的 docstring 中主动说明:若不加非空条件,一张关闭了分析功能的卡片(所有 job 都没有分析、也就无从比对)会把所有失败 job 折叠成一行。

结论:以「非空 + 完全一致」作为分组键的准入条件,无分析的行行为与改动前完全一致;由 test_rows_stay_separate_when_no_analysis_is_available 用例锁定。 · 作者自答,无评审异议

「分析不可用」的 job 是否真的保持独立 正确性

PR body 声明 “Jobs whose analysis came back unavailable stay separate for the same reason.”,但实现的分组键是整段分析元组,并未剔除哨兵值 UNAVAILABLE_REASON;若多个 job 逐条写入同一 unavailable 文本,理论上会因元组相同而被合并。评论区无人讨论此点。

结论:未在 PR 内解决,也没有对应测试覆盖(仅覆盖整体 unavailable=True 与 reasons=None);属于描述与实现的潜在偏差。 · 未讨论

评审只做了形式化通过,缺实质技术讨论 other

claude[bot] 仅留下仓库「需手动触发 review」的配置提示,并非技术评审;guapisolo 给出 APPROVED 但正文为空。设计取舍完全依赖作者在 PR body 中的自述与新增测试来兜底。

结论:变更以作者自测(pytest tests/ci/test/ 907 passed, 1 skipped)与 pre-commit 通过作为唯一验证手段。 · 已合并,无后续动作

风险与影响

  1. 合并语义依赖 LLM 生成文本的“完全相同”:分组键是整段分析元组,若两个根因不同的失败恰好被分析模型产出同样(或同样笼统)的文字,就会被折进同一行,读者可能误以为只有一处故障。缓解点在于每个 job 链接都被保留,可点开逐个确认,且真实分析通常带 test_name 等具体信息,完全相同的概率较低。
  2. “分析不可用”路径与描述存在潜在偏差:PR 描述称分析不可用的 job 会保持独立,但实现的分组键并未剔除哨兵值 UNAVAILABLE_REASON。若多个 job 逐条写入同一个 unavailable 常量文本(test_per_job_missing_log_reason_and_omitted_footer_render_compactly 展示的正是这种单 job 形态),它们的元组会完全相同而被合并,与描述不符。本次测试只覆盖了整卡 unavailable=True 与 reasons=None,没有覆盖“多个 job 逐条 unavailable”的组合。
  3. 截断与分组的交互:group_identical_failures(jobs[:limit], reasons) 先截断再分组,若一个故障横跨的 shard 数超过 MAX_LISTED_JOBS,行内只列出前 limit 个 job 链接,其余由 ... and N more 概括;信息不丢失,但行内链接数与计数提示的对应关系会变模糊。
  4. 测试与渲染格式强耦合:用例通过统计 "↳" 出现次数和 " - " 计数来断言结果,任何对前缀符号或行首格式的调整都会连带让这些断言失败,属于可维护性成本而非功能风险。
  5. 无回归外溢:改动只发生在 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 通知渲染层

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论