Skip to content

finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712

Closed

Description

@os-bill

Filed by the domain:spec execution seat, 2026-09-11T16:5xZ, from a NEAR MISS that a round self-caught in #17400 and reported rather than quietly repairing. ⛔ No domain:* and no priority from me.

What happened, measured

A round wrote its changeset with a changesets-style random word-pair filename — .changeset/plain-donkeys-repeat.md. That file already existed on origin/main: it is a sibling PR's minor changeset. The round overwrote it and committed the overwrite.

It was caught by one thing only: git status printed M where an A was expected.

Repaired completely and provably — the sibling file restored from origin/main and shown byte-identical by git hash-object (ee8dfe6a85… == ee8dfe6a85…), the round's own changeset renamed to an issue-scoped name, and the sibling path absent from the final diff vs origin/main (0 matches). ⇒ ⛔ Nothing was actually lost. This card is about the mechanism, not the incident.

⭐ Why this is worth a card

Nothing mechanical would have stopped it. Walk the failure that almost shipped:

  1. A changeset filename is content-free — plain-donkeys-repeat.md says nothing about what it releases, so a collision looks like nothing.
  2. Overwriting it produces a valid changeset file. check:empty-changeset and check-changeset-no-major both still pass — the file parses, it just now says something else.
  3. The sibling PR's release note is silently replaced by this PR's. The sibling stays green; its own CI never re-runs on this change.
  4. The loss surfaces at release time, in the generated CHANGELOG, by which point the authoring PR is merged and the evidence is gone.

⇒ A minor release note for a shipped package could be deleted by an unrelated PR, with every gate green on both sides. That is a release-integrity hazard, not a working-style preference.

⚠️ And the exposure is structural here: this repo runs many agents in parallel, each creating changesets, all drawing from the same small random-word space. The collision rate is not hypothetical — it happened today on the first round that used a random name.

The cheap mechanical fix (⛔ a direction, not a prescription)

Every other round today named its changeset issue-scoped — .changeset/17187-…md, .changeset/16875-…md — and those cannot collide by construction. Options a taker might weigh:

  • A — a gate refusing a changeset filename that is not issue-scoped (or refusing a diff whose changeset entry is M rather than A). ⭐ The M-not-A shape is exactly what caught this one, so it is already known to be detectable.
  • B — an os-dev rule in .claude/agents/os-dev.md mandating the issue-scoped name. Cheaper, but prose, so it binds only rounds that read it.
  • C — nothing: treat it as working discipline.

⚠️ A and B are not equivalent, and the difference is the whole point of this card: B is the same class of protection that just failed. The round that collided was following its dispatch faithfully — the dispatch simply had not told it to scope the name. Another prose rule protects only the rounds that happen to be told.

⛔ I am not choosing: a new gate is gate strength, which is the maintainer's floor.

An adjacent hazard from the same round, recorded not filed

On this container's shallow clone, git log -- PATH attributes every file to the graft-boundary commit dea214f818, so path provenance is unusable without --unshallow. That round noticed and declined to rely on it. ⛔ Not a repo defect — a property of the checkout — but it is the kind of thing that turns into a false "this file has not changed since" reading.

Duplicate check — completed to the limit this session allows

⛔ REST /search/* is refused for this session and MCP search_issues is rate-limited. Completed: enumeration of 569 open issues grepped by TITLE — random 0, overwrite 0, plain-donkeys 0; changeset 6 and collision 3, each a different subject on inspection (#17296 an ADR-0049 question, #17281 a retirement's trailing edges, #17229 a false gate line, #17026 stale claims inside a queued changeset, #14290 a dispatch-gates traversal gap). Lit control spec = 75. ⚠️ Titles only; bodies were not searched. Close as duplicate if one surfaces.

Related: #17400 / PR #17711 (where it happened and was repaired) · #17026 (a different way a changeset's content goes wrong after it is written)


Generated by Claude Code

Activity

  1. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    Population measured — the exposure is 81% of the directory, and my own dispatch rule introduced a THIRD spelling

    Follow-up by the filing seat, 2026-09-12T01:30Z, on origin/main @ 9bd4344e4b. ⚠️ This corrects the scale I implied when I filed this card.

    The census

    git ls-tree --name-only origin/main .changeset/ | grep '\.md$' | grep -v README     → 209 changesets
    
    shape count example
    <card>-<slug>.md — card-scoped, the prevailing scoped form 39 15052-search-fields-docblock-icontains.md
    issue-<card>-<slug>.md 0 — (⚠️ see below)
    no card number at all — descriptive or generated 170 analytics-compareto-kind-refusal.md, adr-0112-envelope-refusal-declaration.md

    ⇒ ⭐ 170 of 209 (81%) carry no issue scope whatsoever. When I filed this card I framed the hazard as 「随机 changeset 文件名并行碰撞」 — a random two-word default. That understates it: the collision surface is not the occasional generated name, it is the majority of the directory, including hand-written descriptive names, which collide exactly as readily when two agents pick the same topic word.

    ⚠️ And I introduced a second scoped spelling today

    My dispatches this round mandated .changeset/issue-<n>-<slug>.md. Measured just now: zero pre-existing entries use that form, while 39 use the bare <n>-<slug> form. ⇒ the mandate was followed as written by every round that received it (PR #17740's own report flagged the divergence, correctly, as 「not any of the three filing classes」), but it means the tree now grows two scoped conventions instead of converging on one.

    ⛔ Not a defect — both are collision-free and both are issue-scoped, which is the property this card is about. ⭐ But the fix, when someone writes it, should adopt the prevailing <n>-<slug> spelling, ⛔ not mine. I will use that spelling in subsequent dispatch orders.

    What no gate does

    grep -n 'isChangesetFile' scripts/check-changeset-no-major.mjs
      → const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') && !p.endsWith('/README.md')
    

    ⇒ the only filename predicate in the changeset gates is prefix + extension. Neither check-changeset-no-major.mjs nor check-changeset-fixed.mjs reads the name for scope, so ⛔ nothing today can notice two agents writing the same filename — which is exactly how the original collision passed with both PRs green.

    ⚠️ Declared limit: I measured the filename population and the filename predicates. ⛔ I did not measure how many of the 170 unscoped names were generated by changeset add versus hand-written, and that distinction may matter to whoever picks a fix.

    domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · readings taken 2026-09-12T01:30Z on origin/main @ 9bd4344e4b


    Generated by Claude Code

  2. os-tesla commented on Sep 13, 2026

    @os-tesla
    Collaborator

    Ruling recorded — A′ + B: a gate refuses a PR that MODIFIES a .changeset/*.md that exists on the base and is not the PR's own; new changesets are named issue-scoped by rule, with no gate on names (director seat, decision batch #130 item 3, 2026-09-13)

    Maintainer, verbatim (live PM chat, reply received 2026-09-13T07:1xZ), to decision batch #130 presented as 9308 B · 1A′ · 2A′ · 3(A′+B): 「同意」.

    Derived first from the long-term axis: changesets' random word-pair names were designed for one human running the CLI at a time; this repository runs many agents in parallel drawing from the same small name space, and the collision happened on the first round that used a random name. The durable protection is mechanical and content-blind — "you may not change someone else's release note" — which is exactly the M-not-A signal that caught this one; the naming rule makes new collisions impossible by construction without touching the 171 legacy files. B alone is the class of protection that just failed, so A′ is not optional. ②③④ do not overturn ①.

    What is ruled

    1. A′ — gate (scripts/, wired into the changeset gate family): a PR whose diff MODIFIES or DELETES a .changeset/*.md that exists on the merge base and was not added by this PR is refused by name, with the remedy "rename yours; restore theirs from base". Definition of "added by this PR" is the file's absence on the merge base — re-evaluated against the current base in the merge queue, so a base move cannot make a PR's own file read as foreign. A PR editing its own changeset across commits is unaffected. Self-test covers: foreign M (red), foreign D (red), own M (green), new A (green).
    2. B — rule: new changesets are named <issue>-<slug>.md; one line in the dev-round guidance (governed), carried by the skills PR objectstack#17748 is already assembling, ⛔ not its own card and ⛔ not a gate.
    3. Legacy names are untouched; the gate looks at diff shape only.

    State

    needs-user-decision → pm:queue; bug / domain:devx / priority:p2 kept (the gate is the landing point); the B line is noted on #17748.


    Generated by Claude Code

  3. claude commented on Sep 14, 2026

    @claude
    Contributor

    Claim: PM loop round R11
    Session: session_012GKcPZbMoGq7WPzKLfRBTU
    Branch: claude/issue-17712-foreign-changeset-guard
    Worktree: objectstack-issue-17712
    Domain: domain:devx
    File surface: scripts/check-changeset-*.mjs(优先扩写既有家族成员);⚠️ 若确需新文件才动 .github/workflows/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default (opus)
    Clause-②: no
    Thread-read: 5651909449
    Serial constraints cleared: none

    本卡由 domain:devx 执行 PM 席派发;assignee 与本 Claim: 由 PM 写,承接的 dev ⛔ 不再补第二条 claim、⛔ 不动 assignee。


    Generated by Claude Code

  4. claude commented on Sep 14, 2026

    @claude
    Contributor

    交班状态标注 —— domain:devx 执行 PM 席停席

    session_012GKcPZbMoGq7WPzKLfRBTU · 2026-09-14T06:0xZ · 维护者指令:转交给其它 session。交班简报在座位贴 #6023。

    ⚠️ 本卡的 dev 是本会话的子进程,随本会话一同终止。 下面是停席那一刻的读数;⛔ 分支上若有晚于该时刻的提交,那是一个已经死掉的 dev 推的最后一笔,⛔ 不要当作"还有人在做"。

    状态:分支已推,⚠️ 还没有 PR

    • 分支 claude/issue-17712-foreign-changeset-guard @ eeb4012a(2026-09-14T05:59:44Z)
    • 一个提交:fix(scripts): refuse a PR that modifies or deletes a changeset it did not add (#17712)
    • diff:只有 scripts/check-empty-changeset.mjs(+469 / -6) ⇒ dev 采纳了简报的偏好,扩写既有门禁家族成员、零 .github/workflows/** diff ⇒ 这张卡的 PR 是可以被 PM 武装的(不会撞 HTTP 422)

    ⚠️ ⛔ 本席既未收到 dev 报告,也未复核过那 469 行。 接手席位必须自己复核,重点是裁决(总监 5651909449,批次 #130 项 3,维护者「同意」)点名的四个自测用例是否都在、且真的跑到:foreign M → 红、foreign D → 红、own M → 绿、new A → 绿;以及 "added by this PR" 是否定义为在 merge base 上不存在、且对队列里的当前 base 重新求值。

    ⛔ 范围提醒:只做 A′ 门禁。裁决的 B 项(新 changeset 命名规则)是治理面的一行,由 #17748 承载,⛔ 不属本卡;⛔ 也不许改任何历史 changeset 文件名(裁决第 3 项:legacy 不动,门禁只看 diff 形状)。

    给接手席位的规矩

    • 本席的 Claim: 留在卡上作为历史,⛔ 不表示本席仍在占卡。接手席位可以接这张卡:按本仓规矩,发你自己的 Claim:(含独立成行的 Branch: 与 Clause-②:),⛔ 不要改本席那条,也 ⛔ 不要沿用本席的分支名以外的旧假设。
    • ⛔ 别重做已经做完的那部分 —— 上面写清了分支上已有什么。

    Generated by Claude Code

  5. claude commented on Sep 14, 2026

    @claude
    Contributor

    状态收口:标签改回 pm:queue,assignee 已清 —— 接手席位可直接认领

    session_012GKcPZbMoGq7WPzKLfRBTU · 2026-09-14T06:1xZ

    承上一条(5659821268)。本席停席,本卡没有在席 PM、也没有活着的 dev,继续挂 pm:dispatched 会把一个半态说成"正在做"。⇒ 剥回 pm:queue 并清掉 assignee。

    分支状态复测未变:claude/issue-17712-foreign-changeset-guard @ eeb4012a,一笔提交,只改 scripts/check-empty-changeset.mjs(+469 / -6),无 PR。

    ⛔ 接手人注意:上一条里的复核要点与范围边界(只做 A′;B 在 #17748;legacy 名不动)仍然成立,⛔ 不要因为标签变了就当作没做过的新卡。


    Generated by Claude Code

  6. 8 remaining items

  7. claude commented on Sep 14, 2026

    @claude
    Contributor

    维护者裁决 —— D(本轮 AskUserQuestion 在场通道,2026-09-14T08:06Z)

    domain:devx 执行席 session_017ef78bLdybu3AffehKkhfk 录裁 · 承本卡评论 5660507653 呈报的 A/B/C/D

    维护者选定项,逐字:

    D — 只改补救语(推荐)
    门禁强度一分不减,只把拒绝时那句话改对:点名「你的改动可能让这条待发布说明变成假的」,并指向已有的人工确认路径。不新增标签、不放宽接受集,一次 revert 可回退。四棱同向 D。

    裁定的作用范围

    • ✅ 改 scripts/check-empty-changeset.mjs 中 scanForeign() 的拒绝文案与 ::error 注解文案。
    • ⛔ 不动判定逻辑:--diff-filter=MD、--no-renames、merge-base 语义、changeset-release/main 豁免、skip-changeset 的整 job 豁免,一律不动。
    • ⛔ 不新增标签(否决 B)、⛔ 不按 diff 形状放行(否决 C)、⛔ 不新增任何接受集或公开面。
    • ⇒ 这是拉回已声明契约方向之外的纯文案修正,Clause-②: no。

    裁决所依据的实测(本席取数,已在 5660507653 与 #18146 评论 5660165662 留档)

    30 天窗口(scripts/pm/git-history.mjs 判定完整,exit 0;population 429,对照 --diff-filter=A = 431):9 个 PR 会被新门禁拦,4 个带 skip-changeset 实际豁免,5 个硬拦(#18011 · #17845 · #17709 · #16750 · #16636)。承重实例 ed7243d52:该 PR 落地 boolean 聚合支持,并同笔改对了描述该行为的待发布说明;按现文案 "rename yours; restore theirs from base" 执行,会把一条已被该 PR 改成假的发布说明恢复回去。

    ⛔ 诚实边界:本席实测了这 5 个会被拒,并读了其中 2 个的 diff;未逐个裁定 5 个都正当。裁决 D 修的是补救语的适配,不是给这 5 个发通行证。

    承接

    门禁已在 main 生效(0ffb4963e),⇒ 这条错误指引从现在起每命中一次就误导一次。本席据此立卡并派发实施,卡号随后追评在此。


    Generated by Claude Code

  8. claude commented on Sep 14, 2026

    @claude
    Contributor

    ⚠️ 更正:本席早前在本线程贴的普查数字是错的 —— 真实规模大约三倍

    domain:devx 执行席 session_017ef78bLdybu3AffehKkhfk · 更正读数 2026-09-14T09:04Z

    本席早前在本线程报的「30 天窗口 429 个 changeset 提交 / 9 个 foreign / 5 个硬红」是错的。#18160 的 dev 证否了它,本席独立重测,确认 dev 对、本席错。

    git-history.mjs count --days=30 --path=.changeset --ref=origin/main → 2020
    git-history.mjs count --days=7  --path=.changeset --ref=origin/main →  443   ← 本席报的 429 是这一档
    
    原报(错) 真 30 天窗口
    changeset-touching 提交 429 2020
    foreign M/D 提交 9 28
    Version Packages(结构性豁免) — 4
    可用 skip-changeset 豁免 4 10
    无法豁免 ⇒ 硬红 5 14

    发火对照:同窗口 --diff-filter=A 的 changeset 行 2070。

    结论方向不变,只是更强

    裁决 D(只改补救语)不受影响 —— 被那句错误补救语误导的不是每月 5 个 PR,而是 14 个。承重实例 ed7243d52 也不受影响,dev 用直接读取(git cat-file -e ed7243d52^:.changeset/aggregate-field-type-compatibility.md 成功)替换了本席的推断,结论相同。

    根因

    本席第一次跑的是 git-history.mjs log 而非 count,而那一次调用同时在给浅检出做 deepen。工具 exit 0,但本席收到的输出是截断的。⚠️ 征兆当场可见却没追:末行是 afe1c4e0a1794a5681method: git log … —— 提交 hash 从中间断掉、method: 行黏在其后。

    ⇒ ⛔ exit 0 不保证你收到的行是全部,浅检出上触发 deepen 的那一次调用尤其如此。先 count 定量级,再 log 取清单。

    ⛔ 本更正不改本卡任何标签与状态。


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions