Repository navigation
π fix(holdguard): one branch update per head and base; stop and say why - #14401
Conversation
The hold guard's keep-mergeable update called UpdateBranch, whose mutation-journal identity is the PR alone. After the first update, every later one replayed as "already applied" without reaching GitHub, so the branch stayed behind and the guard warned every pass. - Call UpdateBranchExpectedHead with the head and base, so each pair is its own journaled attempt; held PRs now carry BaseSHA. - An accepted (202) update whose head is not visible yet is waited on; one unmoved after 30 minutes stops as update-not-landed. A request settles when the head moves, without advancing the baseline. - Refusals are classified with the automerge classifier, now exported from pkg/github: workflows permission, fork not editable (also up front when maintainer edits are off), conflict and up-to-date stop for that head and base until either moves. Transient failures still retry. - One note per head and reason tells a person to update the branch; a conflict keeps its existing notice. - A PR returned to its contributor (072) is not updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQYnPGFJx81c8JXR3o1sg1 Signed-off-by: Danathar <Danathar@users.noreply.github.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
There was a problem hiding this comment.
The diagnosis holds and the fix looks correct to me; one open question for the author, no defects found.
Verified on this head (d059167):
- Root cause is real.
UpdateBranchcallsupdateBranch(ctx, repo, number, "", "")(src/pkg/github/pullrequest.go:559-561), which leavesinputs == nil(pullrequest.go:580-583), and the mutation journal's logical ID is derived from the claim'sInputs(src/pkg/convergence/mutation/boundary.go:299-316,journal.go:175). So the identity really was repo +branch_update+ PR number, and every later update for that PR replayed as applied.UpdateBranchExpectedHeadaddsexpected_head_sha/observed_base_shato the inputs, which gives one journal entry per (head, base) as the body describes. UpdateBranchExpectedHeadandPullRequest.BaseSHAalready exist on v6 (pullrequest.go:568,client.go:986); this PR only starts populatingBaseSHAinfetchPRsWithDetails.go build ./cmd/hive/ ./pkg/github/... ./pkg/holdguard/passes on this head.SettleUpdateRequestruns before thebehind|dirtyfilter (main_helpers.go:305-308), so a 202 that lands between ticks is cleared even when the PR is no longer behind;UpdatePendingcannot then re-match because the head differs.ClassifyBranchUpdateErroris a verbatim move out ofautomerge_sweep.go(type alias + const aliases keep the sweep's call sites unchanged), so the sweep's behaviour is not altered.WorkflowRefusedHeadSHAwritten by older builds is still honoured byUpdateStoppedFor(holdguard.go:376-378), so no retry storm on upgrade.
Open question (not a defect): when an accepted update lands between ticks, SettleUpdateRequest clears the request but deliberately does not AdvanceHygiene (holdguard.go:441-447), so the hive's own merge commit is never added to the baseline CommitSHAs. The lift check is covered by ObservedHeldHeadSHA as the comment says β but does the snapshot/drift step treat that App-authored merge commit as new commits on a held PR and post the drift notice? If drift is already keyed off ObservedHeldHeadSHA rather than CommitSHAs, ignore this; if not, the 202 race turns into a spurious drift comment instead of a spurious WARN. I did not trace the snapshot step far enough to settle it.
β hive: agent=reviewer-2 backend=copilot model=claude-fable-5.1 copilot=1.0.88
|
@Danathar this PR is back in your court ( Failing checks:
Reply here or push a new head to this branch (same sign-off); CI and the review swarm re-run automatically and the PR returns to the normal path. Your relay session received this as a |
|
The only red required check, relative links and anchors resolve, is PR-local: this PR adds lines to On head - (`RankActionableIssues`, `src/pkg/github/client.go:4707-4717`, descending `AgeMinutes`).
+ (`RankActionableIssues`, `src/pkg/github/client.go:4712-4722`, descending `AgeMinutes`).(The branch lives in your fork, so the hive cannot push this for you.) π Hive Agent: β hive: agent=scanner backend=copilot model=claude-fable-5.1 copilot=1.0.88 |
Open PR overlap checkBase branch: Conflicts are checked with every PR merged onto the current tip of
|
|
hive/ci-report (head d059167): the Cause: the PR adds ~10 exported/unexported funcs to Fix: add table tests in π Hive Agent: β hive: agent=scanner backend=copilot model=claude-fable-5.1 copilot=1.0.88 |
|
hive/check-report: The one-line insertion in π Hive Agent: β hive: agent=scanner backend=copilot model=claude-fable-5.1 copilot=1.0.88 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQYnPGFJx81c8JXR3o1sg1 Signed-off-by: Danathar <Danathar@users.noreply.github.com>
β¦ifted citation The coverage gate scores pkg/holdguard on its own tests, and the new update-request and update-stop methods were only exercised from cmd/hive (75.3% against the 90% floor). Test them in the package: a request is awaited for its own head and base, expires after UpdateConfirmWindow and settles once the head moves without moving the baseline; a stop holds for one head and base and survives a reload; the note is per head and reason; the comment names each reason. Answer the review's open question with an end-to-end test: an update that lands between ticks is not drift at lift time when the hive saw the merge while the PR was held, because the snapshot step records that head before the update settles (hivecommons#13603). A lift before the hive sees it still re-holds. The BaseSHA line in client.go moved RankActionableIssues down one line, so the scale-envelope citation now names 4712-4722. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQYnPGFJx81c8JXR3o1sg1 Signed-off-by: Danathar <Danathar@users.noreply.github.com>
|
Pushed a13a0c6, on top of a merge of current
|
|
Hive filed review findings that do not block this PR as backlog items:
Cap: 10 backlog item(s) per PR per day. |
There was a problem hiding this comment.
Correctness review (read at a13a0c6, plus pkg/github/pullrequest.go, client.go enrichment, and the lift path in cmd/hive/main_helpers.go). No blocker; two small in-scope notes.
- Stale
expected_head_sha422 is recorded as a "conflict". The guard now pins the update to the enumerated head (src/cmd/hive/main_helpers.go:328, viaUpdateBranchExpectedHeadβPullRequestBranchUpdateOptions{ExpectedHeadSHA}atsrc/pkg/github/pullrequest.go:594-596). When the author pushes between enumeration and the call, GitHub answers 422 "expected head sha didn't match current head ref";ClassifyBranchUpdateErrorhas no case for that text and its 422 fallthrough returnsBranchUpdateConflict(src/pkg/github/branch_update_refusal.go:55). The guard thenMarkUpdateStopped(..., "conflict"), logs a WARN "stopped keeping held PR mergeable" withreason=conflict(main_helpers.go:374-380), and on adirtyPR postsConflictCommentBody(main_helpers.go:398-407). It self-heals next tick because the head moved, but the stop/log/comment are for a race, not a conflict. The old call had no expected head, so this path is new. Suggested fix in the shared classifier (the sweep also passes a head, so it benefits too):
case http.StatusUnprocessableEntity:
switch {
case strings.Contains(msg, "expected head sha"):
// The head moved under us; the next pass sees the new head.
return BranchUpdateOther
case strings.Contains(msg, "maintainer") || strings.Contains(msg, "permission") || strings.Contains(msg, "not authorized"):
(src/pkg/github/branch_update_refusal.go:47-48; BranchUpdateOther lands in the guard's transient branch at main_helpers.go:339-345, which only logs.)
- Legacy records now get a comment.
UpdateStoppedFormaps an oldWorkflowRefusedHeadSHAtoUpdateStopWorkflowPermission(src/pkg/holdguard/holdguard.go:383-385), and the stopped path callsnoteHeldPRUpdateStoppedevery pass (main_helpers.go:314-316), so every pre-upgrade refused PR still behind will receive oneUpdateStoppedCommentBodyon the first pass after deploy. Reasonable, but the PR body's "Legacy records" section says only that they still stop β worth a line there so nobody reads the burst of notes as a bug.
Checked and fine: actionable.PRs.Held is enriched through EnrichCIStatus β enrichPRCI (client.go:2477-2492), so the up-front FromFork && !MaintainerCanModify stop reads the full-PR value, not the list endpoint's absent field. The settled-update/no-baseline-advance behaviour matches what the old code already did in the 202 race (warn, never advance), so lifts by a bot/hive actor behave as before.
β hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88
There was a problem hiding this comment.
Reviewed (style/maintainability) β no blockers; one advisory.
ClassifyBranchUpdateError now lives in src/pkg/github/branch_update_refusal.go:32-58, but its only table test is still TestClassifyUpdateBranchError in src/pkg/github/automerge/automerge_update_branch_error_test.go:21, which exercises the one-line forwarder classifyUpdateBranchError (automerge_sweep.go:2473-2475) and the const aliases. Tests normally sit beside the code they cover, and the pkg/github floor in .github/workflows/v2-tests.yml:1632 is scored without -coverpkg, so the moved body counts as uncovered there. Suggest moving (or copying) the table into src/pkg/github/branch_update_refusal_test.go against BranchUpdate*, and then either dropping the automerge aliases (single call site at automerge_sweep.go:2438) or leaving them β your call; the alias layer is fine as-is if you prefer the smaller diff.
β hive: agent=reviewer-2 backend=copilot model=claude-fable-5.1 copilot=1.0.88
β¦om-base (#14535) * π fix(holdguard): advance the held baseline only on a proven merge-from-base After an accepted update-branch, maintainHeldPRMergeability refetched the PR's commits and advanced the hold baseline to whatever head it found. A contributor push landing between the 202 and the refetch was therefore adopted as hive hygiene, moving the reviewed baseline past unreviewed commits. The advance now requires the new head to be a two-parent commit whose parents are the head the hive asked to update and the base SHA (holdguard.IsHygieneMerge). Any other head leaves the baseline pinned, so the lift check reads the move as drift and the PR goes back to a human. ListPRCommits now carries the parent SHAs to make that proof possible. Hive-Run: #14534 Hive-Plan: review-backlog security finding (PR #14401 out-of-scope) Hive-Spec: holdguard#hygiene-baseline-advance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRH6kjKSaVf8mEsXFx1uAd Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> * π fix(holdguard): prove the hygiene merge's base parent against the live base Review of #14535: the second-parent check compared against pr.BaseSHA, which is empty for every held PR on the polling path (fetchPRsWithDetails never set it) and, when set, is the enumeration-time tip rather than the tip update-branch actually merges. Either way the hive's own merge failed the proof and read as drift at lift, regressing #10437. - Populate BaseSHA for held PRs and refresh it from the enrichPRCI detail. - IsHygieneMerge keeps the strict first-parent proof but takes an onBase membership predicate for the second parent: BaseSHA is the fast path, otherwise Client.CommitOnBranch asks GitHub's compare whether the base branch contains the merged parent. Errors fail closed. - Document that BaseSHA now feeds the hold guard (fail-closed). - Test the base-moved and BaseSHA-unknown cases. Closes #14534 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> * π docs(scale-envelope): re-anchor RankActionableIssues citation after client.go shift Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> --------- Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> Co-authored-by: Karibhou <Karibhou@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
The hold guard keeps held PRs mergeable by merging their base in (#10437). On every hive running the default
convergence.mode(shadow), it could make that update once per PR and then never again. After that, it loggedhold guard: could not confirm new head after keeping held PR mergeableevery pass, and the branch stayed behind.Cause
maintainHeldPRMergeabilitycalledUpdateBranch, whose mutation-journal identity is only repo +branch_update+ PR number: no head, no base. Once the first update was journaled as applied, every later update for that PR replayed as "external mutation already applied; treating as idempotent success". GitHub was never called, so the head never moved, and the guard warned and tried again the next pass.Live evidence (hive on a08007f, then fcf0e78):
fc68f8a, committed at 11:10:05Z. It still warned at 11:10:07Z, because it read the commit list before the merge showed up. This is the race described below.external mutation already applied; treating as idempotent success β¦ kind=branch_update target=474. There was no new commit, and the PR merged at 17:56Z.Causes ruled out for these PRs
Both still get a stop here, because they are refusals the same token would get again.
Change
Journal identity
UpdateBranchExpectedHeadwith the PR's head and base SHA. Each (head, base) is its own journaled attempt, the same way PR follow-up and supersession already call it. The update is also pinned to the head the guard saw.BaseSHAfrom enumeration, as actionable PRs already did.Accepted (202) but not visible yet
holdguard.UpdateConfirmWindow(30 minutes), the guard stops with reasonupdate-not-landed.Refusals
pkg/github(ClassifyBranchUpdateError). The automerge sweep keeps using it through aliases, unchanged.workflow-permissionfork-not-editable: also decided up front, with no call, when "allow edits by maintainers" is offconflictup-to-dateUpdateStopped*) and logged with the head and base.Person-facing note
workflow-permission,fork-not-editableorupdate-not-landed, it posts one comment per head and reason. The comment names the reason and says to use Update branch; for a fork, it says to ask the author.behindthat GitHub calls up to date gets no note.Returned to contributor (072)
stage/072-returned-to-contributor. That stage means the author's hive is expected to push to the branch. A merge commit pushed under them only makes their next push fail until they pull, and the PR cannot merge before they act anyway.Docs citation
client.gogains one line (BaseSHA), which movesRankActionableIssuesdown a line, sosrc/docs/scale-envelope.md:37now citesclient.go:4712-4722.Legacy records
WorkflowRefusedHeadSHAstill stop on that head. The field is no longer written.Testing
Tests run from
src/under/var/tmp, with-count=1:go test ./pkg/holdguard/ ./pkg/github/... ./pkg/convergence/... ./cmd/hive/: allok(holdguard, github, automerge, requestwatch, the convergence packages and cmd/hive).go vet ./cmd/hive/is clean, andgofmtis clean on the changed files.src/scripts/check-changelog-fragment.sh v6,check-docs-citations.pyandcheck-docs-links.pypass.pkg/holdguardcoverage is 99.6% on its own tests (the gate's floor is 90%; it was 75.3% when the new methods were only tested fromcmd/hive).New tests in
cmd/hive/hold_guard_update_retry_test.go:β¦AsksGitHubAgainAfterAJournaledUpdateβ¦WaitsOnAnAcceptedUpdateβ¦SettlesAnUpdateThatLandsLaterβ¦StopsOnAForkItCannotPushToβ¦RetriesRefusedHeadOnlyAfterHeadOrBaseMovesβ¦HonoursLegacyWorkflowRefusalβ¦LeavesAReturnedPRAloneTestEnforceHoldGuardSettledUpdateIsNotDriftAtLiftNew tests in
pkg/holdguard/update_state_test.go: an awaited update is matched on its own head and base, expires afterUpdateConfirmWindow, and settles once the head moves without moving the baseline.AdvanceHygieneclears it. A stop holds for one head and base and survives a reload, and the legacy workflows record still stops. The note is per head and reason, and the comment names each reason and what to do.Changed existing tests in
hold_guard_mergeability_test.go:Mutation checks
Each part of the fix was broken in turn and restored from a backup, and each break failed the expected tests:
Old no-input
UpdateBranch: the journal test fails.No wait on an accepted update: the 202 test fails.
No up-front fork check: the fork test fails.
No 072 skip: the 072 test fails.
No stop check: five tests fail.
No note dedupe: three tests fail.
pkg/holdguard, one break at a time: pending ignores the base; settle on the same head; a stop keeps the request; a stop ignores the base; the note ignores the reason. Each fails one of the new package tests.No observed-held-head step (
store.Touchin the snapshot step): the settled-update lift test fails.Review question: is a settled update drift at lift time?
The hive's review asked whether the merge commit from an update that lands between ticks, which
SettleUpdateRequestleaves out of the baseline's commit set, reads as new commits when a person lifts the hold. It does not, as long as the hive saw it while the PR was held. Each tick, the snapshot step records the held head (store.Touch,ObservedHeldHeadSHA) before the mergeability step settles the request. At lift,LiftCoversObservedHeadaccepts that head (#13603), and the commit set is only used to describe drift that has already been found. If the lift lands before the hive sees the new head, the PR is re-held with a drift note. This PR does not change that case, and it errs toward a fresh review.TestEnforceHoldGuardSettledUpdateIsNotDriftAtLiftcovers both orders.Not verified
base.shafreshness. The retry-on-main-moved rule relies onbase.shain the pulls list changing when main moves. PR follow-up already relies on the same field for the same purpose. I have not confirmed how quickly GitHub refreshes it in the list response.pkg/github/merge_request_watcher.gostill calls the no-inputUpdateBranchfor merge requests withupdate_branch, so a second request on the same PR would replay the first update the same way. Left out of this PR, because pinning it to the requested head changes that path's behaviour.Contributor checklist
changelog.d/fixed-hold-guard-branch-update-retries.md.v6.π€ Generated with Claude Code
https://claude.ai/code/session_01BQYnPGFJx81c8JXR3o1sg1