feat(skills): publish objectstack-pm-dispatch — the project-agnostic PM dispatch loop (#4607) #31
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 `<this repo>#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', | |
| ); | |
| // 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 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)); | |
| } | |
| 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-<n>-<slug>` is discoverable by the next session | |
| // with one `git ls-remote | grep issue-<n>`. #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-<n>-<slug> (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.`, | |
| ); | |
| } |