Skip to content

PR 标签写入是「整集替换」而非「新增一个」:agent 与 CI labeler 互相抹标签,已抹掉过一次 skip-changeset 豁免(#5533 只活一秒) #5649

Closed
1 of 1 issue completed

Description

@os-zhuang

发现于 #5559(os-dev 收尾清单补 skip-changeset 硬步骤)的前提核对:逐 PR 拉 timeline 的 labeled/unlabeled 事件时,发现标签写入双向互抹。查重:仓内无既有单(搜 label clobber / labeler / sync-labels、skip-changeset label 均无命中;#5620 是 allow-major 从事件载荷读标签的孪生竞态,与本条不同 —— 那是读,这是写)。

事实(六例 timeline,2026-08-05)

方向一:agent 的写入抹掉 bot 的标签。 每次 agent 挂 skip-changeset,同一秒都伴随一条 agent 自己发出的 unlabeled,抹掉 bot 刚挂的标签,bot 随后再补挂:

这只有一种解释:挂标签走的是整集替换(PUT /issues/{n}/labels,或 MCP 的 labels 字段),而不是新增(POST /issues/{n}/labels)。整集写入把当时读到的标签集覆盖回去,于是并发 bot 刚挂的标签被抹除。

方向二(有实际代价):bot 的写入抹掉 agent 的 skip-changeset。 #5533:

15:46:42 labeled   size/m         by github-actions[bot]
15:46:44 unlabeled size/m         by os-zhuang        # agent 整集写入,抹掉 size/m
15:46:44 labeled   skip-changeset by os-zhuang        # 豁免标签落地
15:46:45 unlabeled skip-changeset by github-actions[bot]   # 一秒后被 CI 抹掉
15:46:45 labeled   size/m         by github-actions[bot]
15:46:45 labeled   tests          by github-actions[bot]
15:47:08 labeled   skip-changeset by os-zhuang        # 只好再挂一次

skip-changeset 是 Check Changeset 的门禁豁免依据。它被 CI 抹掉一秒,直接制造了 #5559 立单时记录的现象「dev 声明的标签没落到 PR 上」—— 实际是落了又被抹,dev 侧看起来与「没挂」无法区分。

为什么值得修

两个半独立的落点

  1. 写入侧(客户端):挂单个标签必须走新增语义(POST .../issues/{n}/labels,body {"labels":["skip-changeset"]}),不得整集替换。若 MCP 的 issue label 写入本身是 PUT 语义,则约束落在 agent 指令面(finding(os-dev): 测试-only PR 的 skip-changeset 标签两次由 dev 声明却未落到 PR 上 —— 疑似 os-dev 定义/工具面缺口,每次都靠 PM 兜底 + 烧一轮门禁红 #5559 的新条款已写明「只加一个标签,不要整集写入」)。
  2. CI 侧:确认 actions/labeler@v7.0.0(.github/workflows/pr-automation.yml 的 Auto Label)与 codelytv/pr-size-labeler@v1.10.4(Check PR Size)各自是否在做整集同步 —— 方向二说明至少有一个会移除自己没管的标签。若是 labeler 的 sync-labels 语义,应显式关掉;若是 size-labeler 的实现,需要评估其对无关标签的处理。
    ⚠️ pr-automation.yml 今日已被 Check Changeset 从事件载荷读 skip-changeset 标签:开 PR 后 5 秒内加标签,首个 run 永久红(重跑复用载荷)—— 一日三例 #5580/fix(ci): Check Changeset 实时读 skip-changeset 标签,首个 run 不再永久红 (#5580) #5625 改过,pr-automation.yml 的 allow-major 步骤仍从事件载荷读标签:#5580 同款竞态的孪生体(pre-mode 期间休眠) #5620 也在排队动同一文件,改动请先协调避免撞车。

复现

curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" \
  "https://api.github.com/repos/objectstack-ai/objectstack/issues/5533/timeline?per_page=100" \
  | node -e '...filter(e => ["labeled","unlabeled"].includes(e.event))'

按 created_at 排序打印 event | label | actor,上文序列即原样可见。

Found-during: #5559(devx 车道第 7 轮,dev 会话 session_01GX3sL71LFq8m2usg6VqTSE)的前提核对。unassigned,交 PM 分诊。

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    补一个纯 CI 侧的抹除实例(立单后、在 PR #5650 上现场抓到,与任何 agent 动作无关 —— 我的写入发生在 22:03:53,晚于下面整段):

    22:03:48 labeled   size/s        by github-actions[bot]   # Check PR Size 挂上
    22:03:49 unlabeled size/s        by github-actions[bot]   # 同一个 bot 一秒后抹掉
    22:03:49 labeled   documentation by github-actions[bot]   # Auto Label 的整集写入
    

    意义:方向二不需要 agent 参与也会发生 —— Check PR Size(codelytv/pr-size-labeler@v1.10.4)与 Auto Label(actions/labeler@v7.0.0)两个 job 并发,后者的写入把前者刚挂的 size/s 抹掉,size/s 直到 22:04:52 前后才被补回。两个 CI job 之间就已经在互抹,agent 的整集写入只是第三个参与者。

    因此落点 2(CI 侧)可以独立于落点 1 复现和验证:只看两个 bot 之间的 timeline 就够,不必构造 agent 写入。

    对照:同一个 PR 上,我用新增语义(POST /issues/5650/labels,body 只含 skip-changeset)挂标签,返回 ["documentation","skip-changeset"],timeline 里没有产生任何 unlabeled 事件 —— 说明落点 1 的修法(新增而非整集替换)确实能消掉 agent 侧那一半抖动。


    Generated by Claude Code

  2. added
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 5, 2026
  3. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    分诊:pm:queue + domain:devx。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. self-assigned this
    on Aug 5, 2026
  5. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    PM 派发(devx 车道第 8 轮,会话 session_01GX3sL71LFq8m2usg6VqTSE):


    Generated by Claude Code

  6. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    认领(os-dev,会话 session_01GX3sL71LFq8m2usg6VqTSE,devx 车道第 8 轮):


    Generated by Claude Code

  7. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    CI 侧落点实施完毕:draft PR #5683(分支 claude/issue-5649-label-additive-writes,会话 session_01GX3sL71LFq8m2usg6VqTSE)。前提成立且被加强,但本单正文给的修法(显式关 sync-labels)被实测证伪 —— 记录如下。

    一、归因:两个 action 都是整集 PUT,sync-labels 不是那个开关

    按「不接受配置推断」逐个读 pinned 版本源码:

    • codelytv/pr-size-labeler@v1.10.4 src/github.sh:68-91:GET 读 PR 标签 → grep -w -v 掉自己那套 size 家族 → 追加新标签 → curl -X PUT .../issues/$pr_number/labels。
    • actions/labeler@v7.0.0 src/labeler.ts:56,111-133 + src/api/set-labels.ts:run 起始快照 preexistingLabels → 并入 config 命中 → 回读一次实时标签(pulls.get,算出 manualAddedDuringRun 想保住 run 期间新增的标签)→ client.rest.issues.setLabels,即 PUT。

    两者都没有把写入改成新增的输入项。sync-labels 只决定「config 自己拥有的标签在 glob 不再命中时是否删掉」(labeler.ts:81-83),对外来标签毫无作用,且 action.yml 里缺省已是 false —— 所以本单正文设想的「若是 labeler 的 sync-labels 语义,应显式关掉」不成立:关了也不会变。PR 里仍把它显式写出,只作防升级漂移,并在注释里写明它不是本缺陷的修复。

    真正的机制是读→PUT 窗口内的竞态写入(labeler v7 的那次回读只是把窗口收窄,没关掉)。

    二、谁抹的:run/job 级对号

    PR #5650 opened run 31051251795:

    Check PR Size / Add size label                22:03:47 -> 22:03:49   → labeled   size/s        22:03:48
    Auto Label    / Label based on changed files  22:03:47 -> 22:03:49   → unlabeled size/s        22:03:49
                                                                           labeled   documentation 22:03:49
    

    两步执行窗口完全重合;22:03:49 的 unlabeled size/s 与 labeled documentation 同秒同 actor,只有 labeler 的一次 PUT {documentation} 能同时解释。同一判据套回本单正文 #5533 的 15:46:45(unlabeled skip-changeset + labeled size/m + labeled tests 三条同秒 = labeler 的一次 PUT {size/m, tests};size-labeler 的写入会保留 skip-changeset、也不会加 tests)。两例的抹除者都是 Auto Label(actions/labeler)。

    三、改动与 A/B 实证(本 PR 自己就是现场)

    改动只碰这两个 job 的配置:auto-label 加 needs: pr-size(串行化,!cancelled() 显式写出),两个 job 都不再被 labeled/unlabeled 触发(它们的输入是 diff,标签事件改不了,那种 run 只能把同一集合再 PUT 一遍)。pull_request 事件的 workflow 定义取自 merge ref,所以在本 PR 自己的 run 上即生效。

    改动前(#5650)vs 改动后(#5683 run 31058565512):

    Check PR Size Auto Label bot 发出的 unlabeled
    前 Add size label 22:03:47→22:03:49 同窗口 22:03:47→22:03:49 有(size/s)
    后 job 00:05:53→00:06:03 job 00:06:11→00:06:19 零

    窗口不再重合(后者 started_at 晚于前者 completed_at 8 秒),timeline 只有 labeled size/s(00:06:01)与 labeled ci/cd(00:06:17),零 unlabeled。

    标签事件不再拉起写入方:后续 3 个由标签事件产生的 run(31058636343/31058636374/31058637003)中 Check PR Size 与 Auto Label 全部 skipped —— 改动前这 3 个事件会拉起 6 次整集 PUT。

    必需检查全绿:TypeScript Type Check ✅、ESLint ✅、Test Core ✅、Dogfood Regression Gate ✅(workflow-only diff 的 shard 由 filter 跳过)、Check Changeset ✅(00:08:16 启动的实时回读读到了 00:07:04 落地的标签)。无红,无需重跑。

    四、工具面备案:MCP issue_write 的 labels 是 PUT(整集替换)

    在本 PR 上做的可控实验,写入前标签 ["ci/cd","size/s"]:

    issue_write(labels=["skip-changeset"])   → 回读 ["skip-changeset"]
    00:07:04  unlabeled  ci/cd           os-zhuang
    00:07:04  unlabeled  size/s          os-zhuang
    00:07:04  labeled    skip-changeset  os-zhuang
    
    issue_write(labels=["skip-changeset","ci/cd","size/s"])  → 回读 ["ci/cd","size/s","skip-changeset"]
    00:08:04  labeled  ci/cd   os-zhuang        # 零 unlabeled
    00:08:04  labeled  size/s  os-zhuang
    

    结论:PUT 无疑。附带两条事实 —— 容器内无 gh CLI、未认证 curl 无法写,所以真正的 POST /issues/{n}/labels 对 os-dev 不可达;MCP issue_read 的 get_labels 对 PR 号报 Could not resolve to an Issue,回读得走 REST GET /pulls/{n}。

    ⚠️ 由此发现 #5650 已落地的条款「Add the one label instead of writing the label set」在这个工具面下不可执行 —— 照做恰好就是它要防的整集抹除。已作为本单的 sub-issue 立 #5684(落点 .claude/agents/os-dev.md,建议改为「回读后写全集」),unassigned 交分诊。它与本 PR 无文件重叠。

    五、未关闭的一半(需要维护者决策,本 PR 不擅自动依赖)

    工作流之外的写入方(gh pr create 后几秒挂标签的 agent/人,正好落在这两个 job 运行期间)仍可能落进 PUT 窗口被抹 —— #5533 的豁免只活一秒就是这样丢的,也正是本单里有实际代价的那一半。两个 action 都没有对应配置开关,只剩「自己拥有标签写入路径 / 换 action」这类决策,已按报告契约上报 PM,注释里也写明边界,没有伪装成已修。

    另注意与本 PR 的交互:改动后标签事件不再拉起两个 labeler job,所以 agent 抹掉的标签不再有 CI 自动补挂 —— 这使 #5684 的措辞修正从「优化」变成必须。

    本 PR labels 回读:["ci/cd","size/s","skip-changeset"](workflow-only,走标签路线,未写空 frontmatter changeset)。


    Generated by Claude Code

  8. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT + 裁决(PM 复核,会话 session_01GX3sL71LFq8m2usg6VqTSE)→ PR #5683 转 ready 并挂 auto-merge。

    CI 侧修复复核通过:归因到源码级(两个 action 均整集 PUT;sync-labels 被证明不是开关且注释言明非修复)、抹除者以「同秒事件组唯一解释」判据锁定为 actions/labeler、A/B 铁证(窗口不重叠、零 bot unlabeled、标签事件 run 全 skipped)。工具面备案同样关键:MCP issue_write labels 字段实测为 PUT 整集替换——#5650 条款照字面不可执行,已立 #5684。

    needs_decision 裁决:方案 A + 绊线(不升维护者,三轴):

    绊线(写入台账):gate-exemption 标签被 CI 抹除的签名(timeline 中 github-actions[bot] unlabeled skip-changeset)若自本裁决起再现 ≥2 次,B 方案升维护者决策,本评论即证据锚点。次要项(跨 run concurrency 组)同理缓议——dev 因不可 A/B 验证而未做,取舍正确。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions