claim-sweep: make the door a reusable, and emit the claim-activity dispatch (#290) - #291
Merged
Merged
Conversation
`claim-ticket.yml`'s header already named this file as the fix: "Nothing expires stale claims yet; if that starts to hurt, it is a scheduled sweep in the board-parity family, not more logic here." Measured 2026-08-29 against the live API: 114 claims org-wide sit on finished work, 92 of them in this repo and `.github-private`. The claim doors write the `claimed` label and nothing removes it, so the count only grows -- claims.bounded.tools renders it as "114 finished, still labelled" and says outright that it only grows until someone drains it. Ten further claims in `.github-private` are open with no linked PR and no session behind them, several dating to 08-07; those are the ones with real exclusion cost, because a claimed issue reads as taken to every session that looks. Built as the pr-sweep sibling: weekly cron (a real run, per the #713 standing decision) plus dispatch with dry-run default. Per claimed issue: exempt a standing claim; release if the issue is closed, whatever closed it; release if open, PR-less and untouched for stale_days (default 14 -- claims are leases on attention, shorter than the 30-day PR window); keep everything else with the reason printed. Release is label removal, an unassign and one comment naming the run -- exactly what every claim record already tells its reader to do, made mechanical. THE EXEMPTION IS LOAD-BEARING. The six machine-lane standing claims are open, PR-less and never refresh, which is precisely the shape the staleness rule deletes. `.github-private`#731 predicts this sweep by name: "a standing claim never refreshes, so any wall-clock expiry (#717) would expire it first." Without the exemption the first cron breaks every lane in the org, and for the three fan-out lanes in ~86 repos at once. So two guards, because one was not enough. The exemption keys on a label rather than on parsing a `machine/*` claimant out of hand-written comment prose, which is not a checkable interface. And the staleness rule refuses to run at all in a repo where that label does not exist, saying why: if no standing claim there can be marked, rule 3 is not safe to apply. The closed-issue rule is unaffected either way, since a standing claim is open by construction -- so the 92-issue drain works before the label rollout and the dangerous half stays off until it is done. Unassign failure is reported, never fatal: the label is the half pr-claim reads, so its removal is what ends the claim; the assignee is a human-facing projection and must not be load-bearing. The release comment is built with `jq -nc` rather than inline prose -- this repo's workflow ratchet refuses content at column 0 in a run: block, and caught exactly that defect in the first draft. Rollout steps that are not in this diff are checkboxes on #286: create the `standing-claim` label in both repos and apply it to the six standing claims, and register the `claim-sweep` broker entry. Until the broker entry exists, cross-repo runs fail at mint naming the gap -- the intended arrival state; same-repo runs work on the workflow's own token. Tests: 254 root, 207 .claude, 93 workflow ratchets, all passing. Closes #286 Co-Authored-By: Claude <noreply@anthropic.com>
Run 2 -- the first real run -- died on its first release with:
unknown shorthand flag: 'd' in -d
`gh api` has no `-d` flag. That is curl's. `_claim.yml` writes
`-d "$(jq -nc ...)"` and is correct, because `_claim.yml` calls curl;
I read that line as a house idiom and carried it to `gh api`, where it
is simply not a flag. The dry run could not catch it: the release
branch is the one path a dry run never enters, so the defect was
structurally invisible until DRY_RUN=false.
Nothing was mutated. `set -euo pipefail` aborted on the comment POST,
which runs before the label removal, so no claim was half-released and
the count stayed at 23. That ordering was already deliberate -- the
`.github-private`#652 shape is a label removed with no record of why --
and it is what turned a wrong flag into a clean no-op instead of a
partial sweep.
Both body-carrying calls now pipe JSON to `--input -`. The unassign
additionally sends one request for all assignees rather than one per
login, and keeps its `|| warning`: under `set -e` a non-assignable
login after the label came off would abort mid-release, which is the
same #652 half-applied shape. The label is the half `pr-claim` reads,
so once it is gone the claim is released; the assignee is a projection.
Verified before pushing: no `gh api ... -d` remains anywhere in the
file, and 254 root + 207 .claude + 93 workflow ratchets pass.
Reopens the ticket, because the merged lane did not work: the sweep
is not delivered until a real run releases something.
Closes #286
Co-Authored-By: Claude <noreply@anthropic.com>
#287 was squash-merged, so main carries one squashed commit while this branch still had the pre-squash original plus the fix on top. The histories diverged and GitHub reported the PR as `dirty` -- an add/add conflict on claim-sweep.yml, both sides having "created" the file. Resolved toward this branch: main's copy is the pre-fix version whose release path uses `gh api -d`, which is not a flag and is exactly what run 2 died on. Taking main's side would have re-landed the defect. Verified after resolving: no conflict markers, no `gh api ... -d` anywhere in the file, four `--input -` call sites, and 254 root + 207 .claude + 93 workflow ratchets all passing. Co-Authored-By: Claude <noreply@anthropic.com>
) The lane shipped with a cross-repo fan-out: a `repo` input (or `*`) and a token minted through the OIDC broker under a `claim-sweep` entry. That entry does not exist and could not be created from here -- the binding plane lives in `infra` -- so every cross-repo run would have failed at mint, and `.github-private`'s 70 claims sat unreachable behind an infra change in a third repository. The lane worked on exactly one repo: the one it lives in. A reusable needs no credential at all. `_claim-sweep.yml` runs against the CALLER's `github.repository` with the CALLER's `GITHUB_TOKEN`, and `issues: write` is exactly what releasing a claim uses -- the same argument `_claim.yml`'s header makes for why that door, alone of the three, travels. Adopting the sweep becomes a short caller rather than a broker grant, and no fan-in App grows `issues: write` because one window needed it. It is also the shape this org has converged on twice already. `_claim.yml` records what the alternative cost: the claim convention was delivered as "copy this file", the copies drifted, and one of them left issues labelled `claimed` with no comment naming anyone -- unheld, unclaimable, clearable only by hand (#652). `_pr-claim.yml` is the same pattern. A third copy of sweep logic was the wrong direction. Behaviour is unchanged and deliberately so: the four rules in order, the standing-claim exemption, both fail-closed guards, comment-before-label, and the one-request unassign with its non-fatal warning all move across verbatim. The `-d`/`--input` correction from #288 moves with them, with the reason recorded at the call site so it cannot be reintroduced by someone reading `_claim.yml`'s correct `curl -d` as a house idiom. The reusable also emits `released` as an output, so a caller can act on whether anything actually changed -- which is what a claim-activity dispatch will need. Verified: 254 root, 207 .claude, 95 workflow ratchets (up from 93; the new file picks up its own checks). Evidence the behaviour is right is run 33264394589 on the pre-refactor file -- `released 23, kept 0, exempted 5` against .github, with all five standing claims printing `exempt`, and claims.bounded.tools moving 114 -> 92 once the feed republished. Closes #286 Co-Authored-By: Claude <noreply@anthropic.com>
`front-desk-feed`'s publish lane already ACCEPTS `repository_dispatch: types: [pr-activity, claim-activity]`. The claim-activity type was added in front-desk-feed#6 for exactly this, and that header names what is missing: "No sender emits `claim-activity` yet; the claim doors are the natural one, since they are the only things that know a claim changed." This is a claim door, and after a real sweep it is the only thing in the org that knows a batch of claims just stopped being claims. WHY IT MATTERS. The feed's `35 * * * *` cron is best-effort and GitHub drops slots under load -- a denser cron is skipped MORE often, not less. Measured 2026-08-28: seven stale claims released between ~21:20Z and ~21:35Z, and claims.bounded.tools went on listing all seven as live work -- one already closed and merged -- from an 18:55:58Z snapshot until a hand dispatch at 22:38Z. Measured again today: the .github sweep landed at 16:59Z and the page still read 114 at 17:04Z, moving to 92 only after this session dispatched the publish lane by hand. Fires only when something actually changed: never on a dry run, never when a real run released nothing. Otherwise the weekly no-op cron would spend a publish run republishing an identical feed. INERT UNTIL PROVISIONED, the same posture the receiving half shipped with. repository_dispatch needs `contents: write` on the target, which a caller's GITHUB_TOKEN does not carry for another repository, so it wants a broker binding-plane entry bounded by an installation on that repo alone. Absent `feed_dispatch_token` the step prints a notice and exits 0. A stale page is not a reason to call a successful release failed, so the step is also `continue-on-error`. THE TOKEN IS CHECKED IN THE SHELL, NOT IN THE `if`. The secrets context's availability in a step conditional is the kind of thing that evaluates to something surprising rather than erroring, and #289 is the standing record of shipping a branch no dry run reaches. An empty env var is unambiguous on every runner. The payload carries no issue-derived data and no repo name: every event produces the same whole-board reprojection so there is nothing to branch on, and a caller here may be private while the receiver's logs are world-readable. front-desk-feed's header refuses to print its payload for the same reason. Verified: 254 root, 207 .claude, 95 workflow ratchets; no `gh api ... -d` anywhere in the file. Closes #290 Co-Authored-By: Claude <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #290
Two changes, both consequences of the lane shipping in #286/#288 and then meeting reality.
1. The door becomes a reusable
claim-sweep.ymlshipped with a cross-repo fan-out: arepoinput (or*) and a token minted through the OIDC broker under aclaim-sweepentry.That entry does not exist. Measured, not inferred — dispatching the merged lane at
.github-privatereturns:So the lane worked on exactly one repository: the one it lives in. I had asserted this from reading the workflow header and then tested it, because a gate's own claim about itself is not evidence (
docs/agentic-code-hygiene.mdrule 3)._claim-sweep.ymlruns against the caller'sgithub.repositorywith the caller'sGITHUB_TOKEN.issues: writeis exactly what releasing a claim uses — the same argument_claim.yml's header makes for why that door alone travels. No broker entry, no fan-in App growingissues: write, and adoption becomes a short caller instead of an infra grant.It is also the shape this org has converged on twice.
_claim.ymlrecords what the alternative cost: "copy this file" produced copies that drifted, one of which left issues labelledclaimedwith no comment naming anyone — unheld, unclaimable, clearable only by hand (.github-private#652)._pr-claim.ymlis the same pattern.Behaviour moves across verbatim: the four rules in order, the standing-claim exemption, both fail-closed guards, comment-before-label ordering, and the one-request unassign with its non-fatal warning. The
-d→--inputcorrection from #288 travels with the reason recorded at the call site, so nobody reintroduces it by reading_claim.yml's correctcurl -das a house idiom.Caller drops 297 lines → 72.
2. The
claim-activitysender that nothing was emittingfront-desk-feed's publish lane already acceptsrepository_dispatch: types: [pr-activity, claim-activity]. The type was added infront-desk-feed#6 for exactly this, and that header names the gap:This is a claim door. After a real sweep it is the only thing in the org that knows a batch of claims stopped being claims.
Why it matters, measured three times. The feed's
35 * * * *cron is best-effort and GitHub drops slots under load — a denser cron is skipped more often:3 openfor 6.5h after the last PR merged..githubsweep released 23 at 16:59Z; the page still read114at 17:04Z and moved to92only after this session dispatchedpublish.ymlby hand.Fires only when something changed: never on a dry run, never when a real run released nothing — otherwise the weekly no-op cron spends a publish run republishing an identical feed.
continue-on-error, because the release already happened and is already correct; a failed notification costs freshness, not truth.Inert until provisioned, the same posture the receiving half shipped with.
repository_dispatchneedscontents: writeon the target, which a caller'sGITHUB_TOKENdoes not carry cross-repo, so it wants a broker binding-plane entry bounded by an installation on that repo alone. Absentfeed_dispatch_tokenthe step prints a notice and exits 0.The token is checked in the shell, not in the
if. Thesecretscontext's availability in a step conditional is the kind of thing that evaluates to something surprising rather than erroring, and #289 is the standing record of shipping a branch no dry run reaches. An empty env var is unambiguous on every runner.The payload carries no issue-derived data and no repo name — every event produces the same whole-board reprojection so there is nothing to branch on, and a caller may be private while the receiver's logs are world-readable.
Evidence the behaviour is right
Run 33264394589 on the pre-refactor file, against
.github:All five standing claims printed
exempt (standing claim).claims.bounded.toolsmoved 114 → 92 once the feed republished. Only the plumbing changed here.Verification
node --test *.test.mjs— 256 pass (up from 254; two tests auto-cover the new workflow)node --test .claude/*.test.mjs— 207 passnode --test workflows.test.mjs— 95 pass (up from 93)grepconfirms nogh api … -danywhere in either fileRollout
standing-claimlabel created in both repos and applied to all six standing claims.[settings]broker binding-plane entry that can mintcontents: writeonfront-desk-feed, to activate the sender..github-private(.github-private#765) — its 69 finished claims were drained by hand in the meantime, via the REST API rather than 69 workflow runs, because that repo is ~1 day from a projected Actions budget stop (.github-private#705, #467).Related: #286, #288, #289,
.github-private#717, #765,front-desk-feed#5,desk#22.Generated by Claude Code