From b05b537d7a99d2e3b0f4da242d2a13a0b7e04d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 09:13:01 +0000 Subject: [PATCH 1/2] ci: fail a PR at open time when an earlier open PR already fixes the same issue (#4588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub lets any number of open PRs declare Fixes #N for the same issue; #4555 and #4559 proved the cost this morning. Three changes, one per hole: - duplicate-fix-guard.yml: same-repo closing keywords are checked against every other open PR; the LATER PR goes red with a pointer to the earlier one (first come, first served, matching the pm-dispatch claim convention). Re-runs on 'edited'/'synchronize' so resolving the conflict either way clears the red. Branch names that carry no declared issue number get a warning, never a failure. - Claim comments now require a session ID (pm-dispatch template, AGENTS.md, CLAUDE.md): under the shared GitHub identity the assignee field cannot answer "is this claim mine?" — the comment's session line is what can. - Branch naming claude/issue-- documented in AGENTS.md so git ls-remote | grep issue- is a one-command pre-check. Script validated with node --check under the full injected-identifier wrapper; keyword regex exercised against 10 samples including the prefixes-#N false-positive and cross-repo exclusions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --- .changeset/duplicate-fix-guard.md | 32 ++++++ .claude/skills/pm-dispatch/SKILL.md | 12 +- .github/workflows/duplicate-fix-guard.yml | 129 ++++++++++++++++++++++ AGENTS.md | 20 +++- CLAUDE.md | 6 + 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 .changeset/duplicate-fix-guard.md create mode 100644 .github/workflows/duplicate-fix-guard.yml diff --git a/.changeset/duplicate-fix-guard.md b/.changeset/duplicate-fix-guard.md new file mode 100644 index 0000000000..8a6a4c6a58 --- /dev/null +++ b/.changeset/duplicate-fix-guard.md @@ -0,0 +1,32 @@ +--- +--- + +ci: fail a PR at open time when an earlier open PR already declares a fix for +the same issue (#4588) + +Release-nothing: adds `.github/workflows/duplicate-fix-guard.yml` and updates +agent process docs (AGENTS.md, CLAUDE.md, pm-dispatch claim template) — no +package code. + +GitHub lets any number of open PRs declare `Fixes #N` for the same issue. +On 2026-08-02, #4555 and #4559 both declared `Fixes #4551` and both were +implemented in full — 834 duplicate lines through the whole gate suite — with +the duplication machine-detectable from the second PR's open (03:08) yet +unnoticed by any human until 08:52. The shared GitHub identity made the +issue's assignee useless as a warning: "assigned to os-zhuang" reads the same +whether the claimant is you or another session. + +Three changes, one per hole: + +- **Duplicate Fix Guard workflow**: on PR opened/edited/reopened/synchronize, + parse same-repo closing keywords and fail the PR if an EARLIER open PR + (lower number) declares the same issue, naming it. First come, first + served — matching the pm-dispatch "first claim comment wins" convention. + The check is body-driven and re-runs on `edited`, so a red PR goes green + the moment the conflict is resolved either way. +- **Claim comments must carry a session ID** (pm-dispatch template, AGENTS.md, + CLAUDE.md): under a shared identity, the comment's session line is the only + thing that makes "is this claim mine?" answerable. +- **Branch naming `claude/issue--`** (AGENTS.md): puts the issue + number where `git ls-remote | grep issue-` can find it; the workflow + warns (never fails) when a fix PR's branch names no declared issue. diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 61005e5729..ec4e059a41 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -189,14 +189,20 @@ execute atomically, in order: 1. **Assign** to yourself (`@me`) and add `pm:dispatched`. Skip — and drop from the batch — any issue that acquired an assignee since step 1. 2. **Claim comment** (Chinese), fixed shape — the branch name is the key, - every later artifact (worktree, push, PR) hangs off it: + every later artifact (worktree, push, PR) hangs off it. The session ID is + NOT optional: under the shared identity it is the only line that lets a + later reader — including your own future self after a context reset — + answer "is this claim mine?". A claim without it caused the #4555/#4559 + duplicate (#4588): the second session saw its own shared name as assignee + and could not tell the claim was someone else's. > 认领:PM 循环第 N 轮 + > 会话:`session_` > 分支:`claude/issue--` > Worktree:`-issue-` 3. **Race check**: assignment is idempotent, so two agents can both "succeed". Re-read the comments; if an earlier claim comment with a - *different* branch name exists, you lost — touch nothing of theirs, - reply 「已有认领,让行」, and pick another issue. First comment wins. + *different* session ID or branch name exists, you lost — touch nothing of + theirs, reply 「已有认领,让行」, and pick another issue. First comment wins. Dev agents push their branch early — a remote branch is the hardest evidence of work in flight, closing the gap between "claimed" and "PR exists". diff --git a/.github/workflows/duplicate-fix-guard.yml b/.github/workflows/duplicate-fix-guard.yml new file mode 100644 index 0000000000..0a657ae4cb --- /dev/null +++ b/.github/workflows/duplicate-fix-guard.yml @@ -0,0 +1,129 @@ +# GitHub happily lets any number of open PRs declare `Fixes #N` for the same +# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and +# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and +# the duplication sat machine-detectable from the moment the second PR opened +# (03:08) until a human noticed it (08:52). All agents here share one GitHub +# identity, so the issue's assignee could not warn the second author either — +# "assigned to os-zhuang" reads the same whether it is you or another session. +# Full post-mortem: #4588. +# +# This gate makes the second PR red at open time. First come, first served — +# the EARLIER open PR (lower number) keeps its claim and stays green; the +# later one fails with a pointer to it. That matches the pm-dispatch claim +# convention ("first claim comment wins") already in .claude/skills/. +# +# Scope: same-repo references only (bare `#N` and qualified `#N`). +# Cross-repo references are the cross-repo-issue-closer's territory, and a +# duplicate across repos cannot be resolved by failing one side's CI anyway. +name: Duplicate Fix Guard + +# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body +# after the fact must re-run this check, and one that drops the line must be +# able to go green again. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: read + +jobs: + duplicate-fix-guard: + name: No other open PR may claim the same issue + runs-on: ubuntu-latest + steps: + - name: Check declared issues against other open PRs + uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + const thisRepo = `${context.repo.owner}/${context.repo.repo}`; + + // GitHub's own closing-keyword set. The optional colon is part of + // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms: + // bare `#N` and qualified `owner/repo#N` naming THIS repo. + const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; + const pattern = new RegExp( + `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`, + 'gi', + ); + + const declaredIssues = (body) => { + const found = new Set(); + for (const [, owner, repo, number] of (body || '').matchAll(pattern)) { + // A qualified reference to ANOTHER repo is not ours to judge. + if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue; + found.add(Number(number)); + } + return found; + }; + + const mine = declaredIssues(pr.body); + if (mine.size === 0) { + core.info('This PR declares no same-repo closing keywords — nothing to guard.'); + return; + } + core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`); + + // Branch-name convention (advisory, never red): a fix branch named + // `claude/issue--` is discoverable by the next session + // with one `git ls-remote | grep issue-`. #4555 vs #4559 + // happened partly because the branches shared no token to grep. + // Warning only — existing branches must not go red retroactively. + const branch = pr.head.ref; + if (![...mine].some((n) => branch.includes(`issue-${n}`))) { + core.warning( + `Branch \`${branch}\` does not name any declared issue. ` + + `Convention: claude/issue-- (e.g. claude/issue-${[...mine][0]}-short-slug) ` + + `so parallel sessions can discover in-flight work with git ls-remote.`, + ); + } + + // Drafts count: a draft PR is work in flight, which is exactly + // what the second session needs to see. + const openPrs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const conflicts = []; + for (const other of openPrs) { + if (other.number === pr.number) continue; + const theirs = declaredIssues(other.body); + const shared = [...mine].filter((n) => theirs.has(n)); + if (shared.length > 0) conflicts.push({ other, shared }); + } + + if (conflicts.length === 0) { + core.info('No other open PR declares these issues.'); + return; + } + + // First come, first served: only the LATER PR goes red. Failing + // both would leave the original author red through no action of + // their own; failing the earlier one would reward racing. + const older = conflicts.filter((c) => c.other.number < pr.number); + const newer = conflicts.filter((c) => c.other.number > pr.number); + + for (const { other, shared } of newer) { + core.info( + `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` + + `it will fail its own run of this guard; this PR keeps its claim.`, + ); + } + + if (older.length > 0) { + const lines = older.map(({ other, shared }) => + ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` + + `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`, + ); + core.setFailed( + `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` + + `If this PR is the duplicate, close it and add anything it uniquely covers to the ` + + `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` + + `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` + + `and 'synchronize', and goes green once the conflict is gone.`, + ); + } diff --git a/AGENTS.md b/AGENTS.md index 57307ca3a5..770b098aae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,18 @@ then race to land conflicting shapes for the same problem, which is worse than either one alone. If it is already assigned to someone else it is taken — pick another, or say so and ask; never reassign it to yourself. +Because every agent here shares one GitHub identity, the assignee field alone +cannot answer "is this claim *mine*?" — seeing your own shared name on an issue +is exactly what another session's claim looks like. So a claim is two acts, not +one: assign, **and leave a claim comment carrying your session ID and branch +name** (`claude/issue--`). Before writing code, re-read the issue's +comments; an earlier claim comment with a different session ID or branch means +the issue is taken no matter what the assignee field seems to say. Skipping +this read is how #4551 got implemented twice in one morning (#4555 and #4559 — +post-mortem in #4588), and misreading shared-identity state is also how a +maintainer's manual ready-flip got reverted by an agent that assumed its own +write had failed. + The claim is also what makes the *finding* rule (Prime Directive #10) safe to follow. Once out-of-scope discoveries become issues, the issue list is a real queue other agents read, and a claim is the only thing separating "someone is on @@ -129,7 +141,13 @@ Even inside your own worktree, operate defensively: reverts, or other agents' in-flight edits, and don't try to manage the whole working tree. If a file you didn't change shows as modified, leave it. 2. **One feature branch + one PR per task.** Branch off `main`. **Never commit - task work straight to `main`.** + task work straight to `main`.** Name the branch after the issue it fixes: + `claude/issue--`. The issue number in the name is what makes + in-flight work *discoverable* — `git ls-remote --heads origin | grep + issue-` is a one-command pre-check, and the Duplicate Fix Guard workflow + warns on fix PRs whose branch names no declared issue. The #4555/#4559 + duplicate (#4588) stayed invisible partly because one branch carried the + issue number and the other didn't. 3. **Never `git push --force` / `--force-with-lease`, and never push `main`.** A force-push can clobber a parallel agent's work; `main` is shared — land everything via PR. diff --git a/CLAUDE.md b/CLAUDE.md index 060059c78f..a07127445e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,12 @@ conflicting shapes for one problem. Already assigned to someone else? It is take another or ask; never reassign it to yourself. File findings unassigned when you are only recording them; assign at the moment you start. +All agents share one GitHub identity, so the assignee field can't tell you whether a claim +is **yours** — a claim is assign **plus a claim comment with your session ID and branch** +(`claude/issue--`), and before writing code you must re-read the comments: an +earlier claim with a different session ID means it's taken, whatever the assignee says. +(#4551 was implemented twice in one morning because this read was skipped — see #4588.) + ## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11) This repo — **and every sibling repo you touch (`objectui`, `cloud`)** — is edited by From e1e83cfc0ab49ae02fcacfb4981c44749b85a931 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 09:15:09 +0000 Subject: [PATCH 2/2] fix(ci): duplicate-fix-guard ignores closing keywords inside code spans and fenced blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's first real run — on its own PR — counted the PR body's backticked DISCUSSION of 'Fixes #4551' as a declaration. GitHub's own closing-keyword parser ignores code formatting; a guard stricter than the linker it protects turns prose mentions of other PRs' fix lines into spurious reds. Strip ```fences``` and `spans` before matching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --- .github/workflows/duplicate-fix-guard.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/duplicate-fix-guard.yml b/.github/workflows/duplicate-fix-guard.yml index 0a657ae4cb..d65954ee24 100644 --- a/.github/workflows/duplicate-fix-guard.yml +++ b/.github/workflows/duplicate-fix-guard.yml @@ -48,9 +48,20 @@ jobs: 'gi', ); + // Strip fenced blocks and inline code spans before matching. + // GitHub's own closing-keyword parser ignores code formatting, and + // this guard must not be stricter than the linker it protects: + // this very PR's first run counted its own DISCUSSION of + // `Fixes #4551` (in backticks, describing the incident) as a + // declaration. Benign there — #4551 is closed — but a prose + // mention of an issue some other open PR really fixes would have + // been a spurious red. const declaredIssues = (body) => { + const prose = (body || '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`\n]*`/g, ' '); const found = new Set(); - for (const [, owner, repo, number] of (body || '').matchAll(pattern)) { + for (const [, owner, repo, number] of prose.matchAll(pattern)) { // A qualified reference to ANOTHER repo is not ours to judge. if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue; found.add(Number(number));