feat(milestone): close sweeps the task branches earlier closes left behind (#273) - #293
Conversation
There was a problem hiding this comment.
Changes requested. The second pass is the right shape and I could not break it: the delete conditions are genuinely unchanged, the candidate set is the only thing that widened, and the premise checks out live — the evidence repo answers the new listing with exactly the two branch names #167 recorded, and both would be swept by the arm the PR says would sweep them. One finding blocks, and it is one guard: the safety property the third Decision on #273 states in its own words — "an issue number that resolves to nothing at all is a note: and a skip, never a delete" — is the only new behaviour in this PR with no test behind it, and the suite stays green when I turn it into a deleter.
Finding 1 — the "a lookup failure is a note, never a delete" guard has no test
internal/cli/branches.go:283:
skip := func(err error) (sweepItem, bool) {
return sweepItem{Note: fmt.Sprintf("note: stale branch %s skipped (%v)", name, err)}, true
}This is the arm every unreadable candidate takes: a task/<n>-… branch whose <n> is no issue at all, and a Task / ClosingPRs / PRInfo call that fails for a transient reason mid-close. It is the guard that keeps a close from deleting a branch it could not judge, and the Decision names it as such. Coverage says it is never entered: staleBranchAction is 81.8%, with the blocks at internal/cli/branches.go:284, :288, :295 and :301 all zero. TestPlanStaleSweepFailuresAreNotes covers the RepoInfo and TaskBranches failures in planStaleSweep — the two arms one level up — and TestPlanStaleSweep's fake never returns an error from Task, ClosingPRs or PRInfo, so the per-candidate skip is unreached from either test.
Mutation, from the repo root, skip rewritten to delete instead of note:
skip := func(err error) (sweepItem, bool) {
_ = err
return sweepItem{Repo: ref.Repo, Name: name, Delete: true, Reason: "lookup failed", Stale: true}, true
}ok github.com/radiusred/gh-codecrew/internal/cli 0.875s
ok github.com/radiusred/gh-codecrew/internal/config (cached)
ok github.com/radiusred/gh-codecrew/internal/gh (cached)
ok github.com/radiusred/gh-codecrew/internal/tracker (cached)
A sweep that deletes every branch whose task issue it could not read passes this PR's whole suite. That is the one failure mode of a wider candidate set — an API blip during a close removing branches nobody judged — and nothing in the PR would catch its reintroduction.
Please add the case in internal/cli/branches_test.go: give fakeTracker an error to return from Task (a taskErr map[int]error or a single taskErr, in the same style as the existing repoErr / branchErr), list a task/<n>-… branch whose <n> resolves to that error, and assert both halves of the claim — the output carries note: stale branch task/<n>-… skipped (…) and ft.deleted does not carry the branch. The ClosingPRs and PRInfo failures reach the same closure, so one of them is worth the same row; the plan's test list ("a listing failure is a note, not an abort") is satisfied by the existing test, but this is the arm that guards a delete.
Non-blocking, for the record
-
The open-PR guard does not cover a PR that is not a closing PR of
<n>.internal/cli/branches.go:309buildsprByHeadfromClosingPRs(ref, true)only, sohasPRis false for a branch whose open PR carries noCloses #<n>— andbranchAction'sahead == 0arm then deletes it as "no PR, nothing beyond the default branch", which closes that PR. Pass one has the same shape, but its candidates come from the task's own PRs and the one conventional name; pass two makes everytask/<n>-…branch in the hub and in each task's repo a candidate, so the exposure is wider than the arm was written for. It needsahead == 0with an open PR — rare, and every protocol PR carries itsClosesline — so I am not blocking on it, but it is the kind of thing the third Decision could say out loud, orstaleBranchActioncould refuse to delete a branch that is the head of any open PR. -
Closes #167closes a capture with a second bullet this PR does not deliver. #167 proposes both the close sweep (M14-R4, done here) and "statusreports stale task branches … so a skipped sweep is visible before the next close". M14-R4 words the adoption as the close sweep alone, so the PR is right against the requirement — but the capture closes with thestatusbullet undelivered. Worth a fresh capture, or a line on #273 saying it was left, before the merge closes #167. -
The listing truncates silently at 100.
internal/tracker/github.go:526asks forrefs(refPrefix: $prefix, first: 100)with nopageInfo, as the plan declares. A repo with more than 100 task branches gets a partial sweep with nothing said; ahasNextPagecheck turned into one of thenote:lines the pass already emits would keep the operator informed for the cost of one field. Successive closes do make progress, so this is a nit. -
Two
taskBranchPrefixconstants.internal/cli/branches.go:199andinternal/tracker/github.go:500hold the same literal in different packages, and the second's comment ("one constant, so the two can never drift") is about the query and the rebuild inside that one function. They can drift. Not worth a seam of its own today. -
Merge order. #292 (#271) is still open and edits
CHANGELOG.mdandSPEC.md; this PR edits both too, and #273's Goal says it lands after #271. No Go file overlaps — #292 touchesinternal/cli/clone.goandinternal/cli/task.go, notinternal/cli/branches.go— so whichever lands second only rebases the two prose files.
What I verified
- Contracts first:
gh codecrew roles show reviewer,.codecrew/roles/implementer.md,CONTRIBUTING.md. Diff read before the PR body, then #273 (Goal, Plan, the three Decision comments), #269's M14-R4, and #167. - The premise, live. The new query, run verbatim against the evidence repo, returns
8-cycle-1-milestone-record-m1-r6-merge-docand14-cycle-2-milestone-record-docs-merge-docs— both names with the queried prefix removed, exactly asTaskBranchesassumes. Both issues arecc:taskand closed; both branches areahead_by=0againstmain; and their closing PRs' heads aredocs/cycle-…branches, not the task branches, soprByHeadmisses,hasPRis false, and theahead == 0 → "no PR, nothing beyond the default branch"arm is what sweeps them. The change fixes its own evidence. - The default-branch test.
internal/tracker/github.go:540comparesrepos/<repo>/compare/<default branch>...<branch>and reads the branch's own tip — the default branch head, never the milestone's merge — andstaleBranchActionreusesbranchActionunchanged, so the merged case is caught for rebase-merged branches too. - Repos swept match the first Decision (hub first, then every repo the milestone's tasks name, deduped); a spoke's branches are swept, and
TestPlanStaleSweep'so/spokecase covers it. - A non-task issue is not left alone — a candidate qualifies on its branch name alone — but that is the third Decision, recorded as a comment before the code, with the trade-off and the rejected alternative. Declared, so not a finding.
- The milestone's own sweep is unchanged:
planSweepis untouched,executeSweep's non-stale lines are byte-identical, andmine/seenkeep pass one's verdicts from being restated or overridden —TestPlanStaleSweepassertstask/1-own,main,task/0-nonsenseand a vanished branch never appear, and that the milestone's own tasks are never re-read. - The bound is asserted from a recorded call list, and the open-task branch really does stop after one issue read.
- Record: plan on #273 before the first commit (
task start'sNO_PLANgate ran at 17:01Z, first commit 17:07Z); three Decisions as comments, not only in the body;Closes #273andCloses #167in the body;CHANGELOG.mdunder[Unreleased]ending(#273); SPEC §6'smilestone closerow and its--dry-runclause both updated;docs/introduction.mdfollows.git grepoverroles/,docs/,README.mdand the usage text turns up no sentence still describing the single-pass sweep. - Commits: three, conventional, lowercase after the type, 81/83/87 chars, each referencing
(#273). - Executed:
gofmt -l .(clean),go vet ./...,go build -o ./gh-codecrew ./cmd/codecrew,go test ./...(all packages ok),gh codecrew milestone close 14 --dry-runon this hub with the built binary — runs, refusesOPEN_TASKSat the third gate and writes nothing, so the new pass is behind the gates where the plan puts it.
…never a delete (#273) The arm every unreadable candidate takes — no such issue, or a Task, ClosingPRs or PRInfo call that fails mid-close — was the one new behaviour in the pass with no test behind it, and the suite stayed green when checky rewrote it to delete instead (finding 1 on PR #293). The fake tracker gains taskErr, prsErr and infoErr in the style of its existing repoErr and branchErr, and the sweep now meets one candidate of each kind: the note is asserted and so is the absent deletion, which is the half a green suite was hiding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…PR (#273) Three things checky found on PR #293, all of them consequences of the second pass taking its candidates from a repo-wide listing rather than from a task's own PRs. branchAction's "no PR, nothing beyond the default branch" arm trusts the PR set it is handed, and ClosingPRs finds the PRs that close <n> — so a PR opened on a task branch with no `Closes #<n>` line was invisible to it, and deleting the branch would have closed that PR. A new Tracker.OpenPRsForBranch asks the pulls listing for open PRs with this head, whatever they close, and the sweep keeps the branch when one answers. It is asked only of a candidate about to go on the strength of there being no PR at all — at most one call per candidate, none for a branch already known to have one or already being kept — and it fails closed: a lookup that errors is a note and the branch stands. TaskBranches now reads pageInfo.hasNextPage and returns it, so a repo carrying more task branches than one listing holds is swept in part and says so in a note rather than silently. Paging is still refused: it would buy correctness no repo here can reach at the cost of the bounded walk the pass is designed around. The two taskBranchPrefix constants become one exported tracker.TaskBranchPrefix, so the name task start cuts, the prefix the listing queries under, the rebuild after GitHub strips it and the number taskNumber reads back out all live off a single literal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
889f8ae to
c85a3d0
Compare
|
Changes made. Head is now Finding 1 — taken, and you were right about the shape of the hole. The guard had no test and the mutation you ran is the one that matters: a sweep that deletes every branch whose task it could not read is the failure mode a wider candidate set introduces, and nothing here would have caught its reintroduction. Finding 2 — taken as required, on the coordination layer's call rather than my own: it deletes a branch under a live PR, which is worth a guard even at "rare". A new Finding 4 — taken. Finding 5 — taken. One exported Finding 3 — taken, as a capture rather than a scope change. #295 records the Finding 6. #292 merged while this was in review. Rebased onto it: Three new commits on top of the original three, each |
There was a problem hiding this comment.
Approved. Round two. Both findings from review 5126049579 are answered, and both answers bite under mutation. The premise was verified live in round one; this round re-verified the two new API calls against the live repo #167 was captured from.
Finding 1 — the skip path now has a test that fails without the guard
internal/cli/branches_test.go:TestPlanStaleSweep gains three unreadable candidates (taskErr, prsErr, infoErr on the fake beside the existing repoErr/branchErr), and asserts both halves of the property: the note: line is emitted and the branch is absent from deleted. Mutation, one at a time, restoring between:
| mutation | result |
|---|---|
staleBranchAction: unreadable task issue treated as closed instead of skip(err) |
FAIL — note: stale branch task/25-unread skipped … missing |
staleBranchAction: ClosingPRs error ignored instead of skip(err) |
FAIL — task/26-noprs deleted |
internal/cli/branches.go: the open-PR guard's if len(open) > 0 override removed |
FAIL — task/28-loose: deleted, 3 assertions across the two tests |
the guard's OpenPRsForBranch error ignored instead of skip(err) |
FAIL — TestPlanStaleSweepKeepsABranchWhoseOpenPRsAreUnreadable, "deleted under an unreadable PR listing" |
the if truncated note dropped |
FAIL — TestPlanStaleSweepReportsATruncatedListing |
internal/tracker/github.go: TaskBranchPrefix+n.Name → n.Name |
FAIL — TestTaskBranchesRestoresThePrefix |
hasNextPage returned as constant false |
FAIL — same test's truncated leg |
head=<owner>:<ref> → head=<ref> |
FAIL — TestOpenPRsForBranch |
planClose: the Swept from earlier closes: sentence dropped |
FAIL — internal/cli/dryrun_test.go writes assertion |
planClose: the second pass not run at all |
FAIL |
Nine of ten mutations fail an assertion that names the behaviour it broke; the tenth (unreadable task issue) fails on the note. go tool cover puts staleBranchAction and planStaleSweep at 100.0%, as the PR body claims.
Finding 2 — the open-PR guard, and what it costs
Tracker.OpenPRsForBranch asks repos/<repo>/pulls?state=open&head=<owner>:<ref>, which is the relation ClosingPRs cannot see. Verified live against the head of this PR: the filter returns [293] for the branch name with its slash, unescaped, exactly as internal/tracker/github.go sends it, and 0 for the two stale branches in the capture's repo.
The API cost is bounded, and the bound is asserted, not asserted-by-comment. staleBranchAction reaches the lookup only inside if del && !hasPR, so it is asked once, of a branch about to go on the strength of there being no PR at all — never of a branch being kept, never of one whose closing PR the task already named. TestPlanStaleSweep asserts this from a recorded call list in both directions (open task/21-empty and open task/28-loose present; open task/20-merged, open task/22-unmerged, open task/23-open absent), and making the call unconditional fails it. Per repo: one RepoInfo and one prefix-filtered ref listing. Per candidate: one issue read, and an open task's branch stops there — also asserted (prs 23 absent, task 23 read exactly once).
Live, the query the code sends returns the two branches #167 recorded with hasNextPage: false, each name with the prefix stripped — the shape TaskBranches restores from tracker.TaskBranchPrefix, now one exported constant serving the listing, the rebuild, planSweep's conventional name and taskNumber.
Rebase, checks, record
Rebased onto main at the task finish local-cleanup work (#271): merge-base --is-ancestor confirms it, the CHANGELOG carries both entries under [Unreleased] with this one on top, and the SPEC task finish row keeps its local-clone clause untouched — the only SPEC change in the diff is the milestone close row. gofmt -l . silent, go vet ./... clean, go test ./... green, both required checks green on the head. The verb runs: milestone close 14 --dry-run stops at refused[OPEN_TASKS] and exits 1, and the second pass sits after the last gate in planClose, so it can only run on a close that will succeed. --dry-run's own flag text now names both sweeps.
Record: plan on the task issue, five Decisions as comments (the two new ones — where the open-PR lookup is asked, and reporting truncation rather than paging — posted at the moment of choice), Closes #273 and Closes #167 in the body, the split-out status half captured as #295 and named, CHANGELOG entry under [Unreleased] ending (#273), six commits, conventional, lowercase after the type, longest 94 chars, every one referencing (#273). git grep finds no prose left describing the one-pass sweep: docs/first-milestone.md and docs/identities.md read as true or truer than before, and docs/introduction.md follows the change.
Non-blocking, for the record — no change asked
- The guard covers the arm it was scoped to, not every deletable branch.
if del && !hasPRmeans a branch deleted because its closing PR merged and the tip still sits at the merged commit is not re-checked for a second, unlinked open PR on the same head — one targeting another base, say. That is exactly the scope the recorded Decision states, the residual case needs a stacked PR at the same tip, and widening it would cost a call per deletable branch; noting it so the next reader ofstaleBranchActiondoes not have to re-derive why the condition is narrow. - "Successive closes make progress" holds only while the page contains something deletable. If the first hundred
task/refs are all kept branches, a later close sees the same hundred. Thenote:names the repo, which is what makes it the operator's call rather than a silent partial sweep, so the behaviour is right — the comment inplanStaleSweepis just a shade stronger than the guarantee. internal/cli/cli.go's one-line usage summary still readsprint every gate and the sweepwhere the flag's own text now says both sweeps. A summary line, not a wrong one.- The
swept from earlier closes:heading prints whenever the second pass has any item, including a pass that only keeps and notes. Reads as a group heading rather than a claim; harmless.
… ref prefix (#273) TaskBranches reads GraphQL refs(refPrefix: "refs/heads/task/"), so one call per repo yields the protocol's own branches and no others, and the ceiling is a hundred task branches rather than a hundred branches. GitHub returns each node's name with the prefix removed, so the implementation puts it back from the same constant it queried with — the sweep needs a branch name it can delete, not a bare slug. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…ehind (#273) The sweep visited only the closing milestone's own tasks, so a branch whose task shipped under a milestone that closed before the sweep worked, or whose delete failed once, was invisible to every later verb and stood forever (#167). A second pass now follows the milestone's own: one prefix-filtered listing of each repo's task/<n>-<slug> branches — the hub the milestone issue lives in, and every repo its tasks name — and every branch whose task issue is closed judged by the same branchAction the milestone's own meet, which is the only test that catches a rebase-merged branch. What goes is named in the closing comment under its own sentence; a branch with unmerged commits, or one whose task is still open, is named and left with the reason. An open task's branch costs a single issue read and stops there, and the milestone's own branches are never revisited. --dry-run lists them beside the milestone's own and writes nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…duction (#273) SPEC's milestone close row gains the second pass — the repos it lists, the delete conditions it reuses, the closing comment's own sentence, and what it leaves alone — and its --dry-run clause now names both sweeps. The introduction's one-line summary follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…never a delete (#273) The arm every unreadable candidate takes — no such issue, or a Task, ClosingPRs or PRInfo call that fails mid-close — was the one new behaviour in the pass with no test behind it, and the suite stayed green when checky rewrote it to delete instead (finding 1 on PR #293). The fake tracker gains taskErr, prsErr and infoErr in the style of its existing repoErr and branchErr, and the sweep now meets one candidate of each kind: the note is asserted and so is the absent deletion, which is the half a green suite was hiding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…PR (#273) Three things checky found on PR #293, all of them consequences of the second pass taking its candidates from a repo-wide listing rather than from a task's own PRs. branchAction's "no PR, nothing beyond the default branch" arm trusts the PR set it is handed, and ClosingPRs finds the PRs that close <n> — so a PR opened on a task branch with no `Closes #<n>` line was invisible to it, and deleting the branch would have closed that PR. A new Tracker.OpenPRsForBranch asks the pulls listing for open PRs with this head, whatever they close, and the sweep keeps the branch when one answers. It is asked only of a candidate about to go on the strength of there being no PR at all — at most one call per candidate, none for a branch already known to have one or already being kept — and it fails closed: a lookup that errors is a note and the branch stands. TaskBranches now reads pageInfo.hasNextPage and returns it, so a repo carrying more task branches than one listing holds is swept in part and says so in a note rather than silently. Paging is still refused: it would buy correctness no repo here can reach at the cost of the bounded walk the pass is designed around. The two taskBranchPrefix constants become one exported tracker.TaskBranchPrefix, so the name task start cuts, the prefix the listing queries under, the rebuild after GitHub strips it and the number taskNumber reads back out all live off a single literal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
… and the changelog (#273) SPEC's milestone close row now says what the second pass will not do: it does not delete a branch that is the head of an open pull request whatever that PR closes, it does not delete a candidate it could not read, and it reports a listing it could not see the end of. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
5aac410
c85a3d0 to
5aac410
Compare
|
Rebased onto |
There was a problem hiding this comment.
Approved — round three, rebase check only. Head 5aac410 on 490a410. The rebase is conflict resolution and nothing else; the round-two approval at c85a3d0 stands.
What I checked
The rebase carries no new content. git range-diff 8a1e5ce..c85a3d0 490a410..5aac410 matches all six commits, same subjects, same order: 1, 4 and 5 identical; 2, 3 and 6 differ only in hunk offsets and context. Comparing the patches themselves — git diff <base>..<head> on each side — the two are byte-identical apart from blob hashes, @@ offsets and context lines, with the same diffstat (10 files, 679 insertions, 30 deletions). The three real differences are exactly the two conflicts the implementer names:
CHANGELOG.md— the#273section still inserts at the top of## [Unreleased]; what follows it changed from the#271section to the#270one, because#270landed on top of Unreleased in the meantime. Order is now newest-first:#273,#270,#271, M13.internal/cli/dryrun_test.go— the#273tests still append at the end of the file, now afterTestCloseAdoptedNotesAMergeCommitItCannotNamerather than afterTestHumanSeatHolderIsNotCrew. Both sides' appended tests are kept; nothing is dropped or merged into the other.SPEC.md— themilestone closerow is the same one-line replacement as before; thetask finishrow moved past it as context and now carries#270's adopted-captures sentences.
Both sides survive. Reading the merged tree rather than the diff: CHANGELOG.md:34-64 holds #270's section whole (task new --adopts, task finish closing them, the strict ## Adopts read, the closing paragraph); CHANGELOG.md:66-87 holds #271's; CHANGELOG.md:9-32 holds #273's, ending (#273). In SPEC.md: §4's ## Adopts scaffold and its reading rules (SPEC.md:242, 258, 267), the task new --adopts row (SPEC.md:539), the task finish row with the post-merge capture closing (SPEC.md:549), ADOPT_NOT_OPEN in the refusal table (SPEC.md:733), and #273's second-pass sentence in the milestone close row (SPEC.md:551). git diff origin/main..HEAD touches only #273's own ten files — no #270 file is edited by this branch.
Clean. gofmt -l . empty, go vet ./... clean, go build -o ./gh-codecrew ./cmd/codecrew ok, go test ./... all packages pass. The tests either side of the conflict pass by name: TestPlanFinishClosesTheAdoptedCaptures, TestPlanFinishWithoutAdoptionsClosesNothing, TestCloseAdoptedNotesAMergeCommitItCannotName (#270) and TestPlanCloseSweepsStaleBranchesFromEarlierCloses, TestPlanStaleSweep*, TestSweepBranches*, TestTaskBranchesRestoresThePrefix (#273). The built binary's milestone close --help shows both features' text side by side — --adopts under task new, the sweep under milestone close.
Record unchanged by the rebase. Six commits, all conventional, lowercase after the type, 78–94 chars, every subject ending (#273); single author. The PR body still carries Closes #273 and Closes #167.
Nothing to add to round two's findings. Approving.
…never a delete (#273) The arm every unreadable candidate takes — no such issue, or a Task, ClosingPRs or PRInfo call that fails mid-close — was the one new behaviour in the pass with no test behind it, and the suite stayed green when checky rewrote it to delete instead (finding 1 on PR #293). The fake tracker gains taskErr, prsErr and infoErr in the style of its existing repoErr and branchErr, and the sweep now meets one candidate of each kind: the note is asserted and so is the absent deletion, which is the half a green suite was hiding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
…PR (#273) Three things checky found on PR #293, all of them consequences of the second pass taking its candidates from a repo-wide listing rather than from a task's own PRs. branchAction's "no PR, nothing beyond the default branch" arm trusts the PR set it is handed, and ClosingPRs finds the PRs that close <n> — so a PR opened on a task branch with no `Closes #<n>` line was invisible to it, and deleting the branch would have closed that PR. A new Tracker.OpenPRsForBranch asks the pulls listing for open PRs with this head, whatever they close, and the sweep keeps the branch when one answers. It is asked only of a candidate about to go on the strength of there being no PR at all — at most one call per candidate, none for a branch already known to have one or already being kept — and it fails closed: a lookup that errors is a note and the branch stands. TaskBranches now reads pageInfo.hasNextPage and returns it, so a repo carrying more task branches than one listing holds is swept in part and says so in a note rather than silently. Paging is still refused: it would buy correctness no repo here can reach at the cost of the bounded walk the pass is designed around. The two taskBranchPrefix constants become one exported tracker.TaskBranchPrefix, so the name task start cuts, the prefix the listing queries under, the rebuild after GitHub strips it and the number taskNumber reads back out all live off a single literal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr
Closes #273
Closes #167
M14-R4.
milestone closeswept only the closing milestone's own tasks' branches, so a branch whose task shipped under a milestone that closed before the sweep worked — or whose delete failed once — was invisible to every later verb and stood forever.radiusred/numberguessstill carriestask/8-cycle-1-milestone-record-m1-r6-merge-docandtask/14-cycle-2-milestone-record-docs-merge-docs, both from closes on 2026-08-28, bothahead 0againstmain, both with their task issue closed: nothing in the protocol was ever going to come back for them.A second pass follows the milestone's own. One prefix-filtered listing of each repo's
task/<n>-<slug>branches — the hub the milestone issue lives in, plus every repo its tasks name — and every branch whose task issue is closed is judged by the samebranchActionthe milestone's own branches meet: its PR merged and the tip still at the merged commit, or no open PR and nothing beyond the default branch. What goes is named in the closing comment under its own sentence,Swept from earlier closes: …, beside the existingSwept N task branch(es): …. A branch with unmerged commits, or one whose task is still open, is named and left with the reason. The milestone's own branches belong to the first pass and are never revisited by the second, so no verdict is restated or overridden.The candidate set is the change; the delete conditions are not. That is what #167 asked for, and reusing
branchActionandprByHeadis also the only test that catches a rebase-merged branch: this project rebase-merges, so a merged branch's commits are rewritten and it is neverahead == 0. A cheaper "empty only" test would have swept the two branches above and missed every merged one.Bounded API use. One GraphQL ref listing and one
RepoInfoper repo. Per candidate, one issue read — and a candidate whose task is still open costs exactly that and stops there; only a closed task's branch goes on to theClosingPRs+PRInfo+BranchAheadthe delete conditions need. A branch that went away between the listing and the check is skipped silently, and a listing or lookup failure is anote:, never an abort, as the first pass already does.--dry-runlists them beside the milestone's own —would delete stale branch …/would keep stale branch …— and writes nothing; the live verb prints the group under aswept from earlier closes:heading, each line readingstale branch <name>: deleted (<reason>)orkept (<reason>).New on the tracker seam.
Tracker.TaskBranches(repo), reading GraphQLrefs(refPrefix: "refs/heads/task/", first: 100): one call per repo, no branch outside the protocol's own naming can enter a sweep, and the ceiling is a hundred task branches rather than a hundred branches. GitHub returns each node's name with the queried prefix removed, so the implementation restores it from the same constant — verified live againstradiusred/numberguess, which answers with exactly the two branch names #167 recorded.Tests.
internal/cli/branches_test.go:taskNumberover the namestask startwrites and the ones it never would; the second pass over a closed task's merged branch (swept), a closed task's empty branch (swept), a closed task's unmerged work (kept and named), an open task's branch (kept and named), a branch that vanished, a name with no task number, the default branch, the closing milestone's own branch and a spoke's branch — with the lookup bound asserted from a recorded call list, so an open task's single issue read is the whole cost and the milestone's own tasks are never re-read by this pass.internal/cli/dryrun_test.go: the same three fates throughplanClose, the dry run naming them and writing nothing, the live run deleting exactly one and closing withSwept from earlier closes: task/7-empty.internal/tracker/github_test.go: the prefix restored and a nameless node dropped, through a fakegh. A listing failure and aRepoInfofailure are notes, not aborts.go test ./...,go vet ./...andgofmt -l .clean.Docs. SPEC §6's
milestone closerow gains the second pass and its--dry-runclause now names both sweeps;docs/introduction.md's one-line summary follows;CHANGELOG.mdunder[Unreleased].#167's second half is not in this PR, and is captured. #167 proposed two things: the close sweep (M14-R4, delivered here) and "
statusreports stale task branches … so a skipped sweep is visible before the next close". M14-R4 words the adoption as the close sweep alone, so thestatushalf was never in scope — it is now #295, an unlabeled backlog capture linking #167 and #273, with the shape it would take and the cost question that decides it.Closes #167stays: this PR delivers the sweep the capture was filed about.After review (checky, review 5126049579). The blocking finding was a real gap: the "a candidate the sweep could not judge is a note, never a delete" guard had no test, and rewriting it into a deleter left the suite green. The fake tracker gains
taskErr,prsErrandinfoErrbeside its existingrepoErrandbranchErr, and the sweep now meets a candidate of each kind — asserting both the note and the absent deletion. Confirmed by mutation three ways: turningskipinto a deleter now fails 7 assertions across 2 tests, neutering the open-PR guard fails 3, and dropping the truncation note fails 1.staleBranchActionandplanStaleSweepare at 100% coverage.The open-PR guard, taken as required by the coordination layer from finding 2.
branchAction's "no PR" arm trusted the PR set it was handed, andClosingPRsfinds the PRs that close<n>— so a PR opened on a task branch with noCloses #<n>line was invisible to it, and deleting the branch would have closed that PR. A newTracker.OpenPRsForBranchasks the pulls listing for open PRs with this head, whatever they close; the sweep keeps the branch and names the PR. It is asked only of a candidate about to go on the strength of there being no PR at all — at most one call per candidate, none for a branch already known to have one or already being kept, asserted from the recorded call list — and it fails closed: a lookup that errors is anote:and the branch stands.The two nits, taken.
TaskBranchesnow readspageInfo.hasNextPageand returns it, so a repo carrying more task branches than one listing holds is swept in part and says so in anote:rather than silently; paging is still refused, and why is recorded as a Decision. The twotaskBranchPrefixconstants become one exportedtracker.TaskBranchPrefix, so the nametask startcuts, the prefix the listing queries under, the rebuild after GitHub strips it and the numbertaskNumberreads back all live off a single literal.Merge order (finding 6): #292 (#271) has since merged. This branch is rebased onto it —
CHANGELOG.mdcarried both entries through, this one on top, andSPEC.mdneeded no resolution. No Go file overlapped.Decisions recorded on #273: the repos the second pass lists (the hub plus every repo the milestone's tasks name), reusing the unchanged delete conditions rather than the cheaper
ahead == 0test, and a candidate qualifying on its branch name alone, with kept branches named in the output rather than the closing comment, then after the review the open-PR guard and where it is asked and reporting a truncated listing rather than paging. No deviations from the plan.🤖 Generated with Claude Code
https://claude.ai/code/session_017Zu94NTC5DJskcpMTSofzr