Measure auto-rebase behind-ness with git, not mergeStateStatus (CROW-944) - #950
Conversation
…944) The auto-rebase watcher decided "is this branch behind its base?" from one GitHub field, `mergeStateStatus == "BEHIND"`. That field is single-valued — it reports the highest-priority reason the merge button isn't green, not a set of flags — so a PR behind base *and* anything else reports the other value. BLOCKED, DIRTY, DRAFT and UNKNOWN all mask BEHIND. BLOCKED is the expensive one: it is the normal state of a PR waiting on its reviewer, which is exactly the window in which a busy base drifts ahead. On a repo with strict required-status-checks the PR then stalled short of merge until a human pressed "Update branch", and the rebase Crow could have done for free during review was serialized behind approval instead. `shouldAttemptAutoRebase` is now a candidate filter (OPEN and not CLEAN) and git answers the real question. `GitManager.behindBase` runs one fetch plus `rev-list --count origin/<branch>..origin/<base>` — comparing the two remote refs, not HEAD, so a locally-rebased-but-unpushed branch still surfaces its out-of-sync deferral instead of reading as up to date. It runs before the Crow-authorship call, so the widened candidate set costs a fetch per head state rather than a provider API call. New `RebaseOutcome.alreadyUpToDate` (checked after the fast-forward reconcile) keeps a no-op from returning `.rebasedAndPushed` and firing a phantom "Branch rebased" notification. Widening plus the per-head latch created a new trap: a PR probed while merely BLOCKED-and-not-yet-behind would burn its one attempt, and the base moving afterwards is invisible in `headRefOid`. `autoRebaseUpToDateHeads` re-arms the latch when GitHub's view of that head *changes*, and only then, so a persistent git/GitHub disagreement can't hot-loop. Also fixes three things around it: - A worktree stuck in `out-of-sync-*` backed off at a 15-minute cap forever in silence. After five consecutive deferrals — where the backoff saturates — it publishes `AutoRebaseState(.blocked)` and fires a new `autoRebaseStuck` notification. Escalating is not giving up: Crow keeps retrying, so a human fix lands on the next cycle. It deliberately does not self-heal a diverged worktree (that would destroy the local commits) and does not hand off to the agent the way the conflict path does, for the same reason. - `attemptUpdateBranch`'s no-backend and no-trailer guards returned above the `defer`, latching the PR in `autoMergeInFlight` for the process lifetime. Nothing recorded a reason, so `evaluateAutoMerge` fell back to `.inFlight` and the UI claimed Crow was mid-attempt on a PR it had abandoned. The defer now covers every path; suppression is `autoUpdateBranchAttempted`'s job, which returns before any dispatch. A failed `gh pr update-branch` gets bounded retries instead of latching until a head commit that a failed update is precisely what didn't change, and both maps are now pruned. - Auto-rebase yielded to auto-merge for any crow:merge BEHIND PR forever, even after auto-merge spent its one-shot attempt. It now yields only while auto-merge can still act — in-flight, or not yet attempted. Auto-rebase publishes an `AutoRebaseState` on `list-sessions-live`, mirroring `auto_merge_state`, rendered as a ⟲ chip. The justification is sharper than auto-merge's: `prStatusJSON` never ships `mergeStateStatus`, so a wedged branch rendered as a fully green pill. Drafts stay eligible, as they have been since CROW-318 — a rebase only rewrites the session's own branch and can never merge, and DRAFT masked behind-ness permanently. Every open GitLab MR also becomes a candidate, since that backend never populates `mergeStateStatus`; bounded to one probe per head. Both decisions are stated in the code. `docs/cli-reference.md` and `docs/configuration.md` were already stale — they said "the ten events" and omitted `autoMergeBlocked` from #888; fixed here. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: C4A7A6E2-918C-4910-8172-ADE308E3B4E5
dgershman
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues (if any)
None.
Security Review
Strengths:
- Git operations use
Processargument arrays (no shell interpolation), so branch/base ref names from the provider are not a command-injection vector (GitManager.swift:335-341). - Auto-rebase only force-pushes after
prHasCrowAuthoredCommitand excludes review/manager sessions (IssueTracker.swift:3836-3838,4035-4038). rebaseOntoBaserefuses dirty worktrees, verifies HEAD branch, fast-forwards only when strictly behind remote, and pushes with--force-with-lease(GitManager.swift:171-289).- UI surfaces daemon
messageviatextContent/titleattributes, notinnerHTML(app.js:1192-1196,2748-2756); git stderr in verdicts is truncated to 200 chars (IssueTracker.swift:358).
Concerns:
- No material security issues identified. Residual risk is operational: force-push on Crow-authored branches can dismiss approvals on repos with “dismiss stale reviews on push” — documented in the PR and an accepted tradeoff vs.
shouldUpdateBranchBeforeMergestayingBEHIND-only.
Code Quality
- Yellow — re-arm signal may miss pure base drift:
autoRebaseUpToDateHeadsre-arms the per-head latch only whenmergeStateStatuschanges (IssueTracker.swift:3912-3915). GitHub keepsmergeStateStatusatBLOCKEDwhile a review is pending even when the base moves ahead, so a PR probed whileBLOCKEDand git-up-to-date can burn its one latch, then miss a later base drift with noheadRefOidchange. The test atIssueTrackerBehindPRHandoffTests.swift:204-219coversBLOCKED → BEHINDflips but not stable-BLOCKEDbase drift. Consider a secondary re-arm keyed on fetchedorigin/<base>SHA or a bounded periodic re-probe. - Green:
behindBasecorrectly compares remote refs (notHEAD), returns.unknownon failure rather than.upToDate, andalreadyUpToDateprevents phantom notifications (GitManager.swift:100-135,232-252). - Green:
attemptUpdateBranchin-flight leak fix and bounded retry mirror the rebase path (IssueTracker.swift:3663-3718); precedence hand-off tests exercise real dispatch bookkeeping (IssueTrackerBehindPRHandoffTests.swift). - Green:
AutoRebaseStateUI chip, RPC payload, andautoRebaseStucknotification close the visibility gap for wedged branches (app.js:1982-2006,RPCHandlers.swift:977-983,CrowDaemon.swift:685-688). - Green:
swift buildsucceeds;CrowGit(27 tests) andIssueTrackerAutoRebaseTests(33 tests) pass locally.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 1 Yellow, 4 Green] findings.
…950) Review #950 caught that the re-arm added for the per-head latch could not fire for the case CROW-944 is actually about — and tracing it, it never could, by construction. `autoRebaseUpToDateHeads` re-armed the latch only when `mergeStateStatus` changed. But `mergeStateStatus` is single-valued, which is the whole premise of #944: BLOCKED outranks BEHIND, so when the base drifts under a review-pending PR the field simply *stays* BLOCKED. `headRefOid` doesn't move either. So a PR first probed while up-to-date-and-BLOCKED stayed latched until the status changed — i.e. until approval landed, which is exactly the serialization the ticket set out to remove. The fix only ever helped PRs that were already behind the first time they were seen. `AutoRebaseUpToDateHead` now records a `recheckAt` alongside the status, and `shouldRecheckUpToDateHead` re-arms on either signal. The interval is 900s, matching `autoRebaseDeferralMaxDelay`: four local `git fetch`es an hour per open PR with a worktree, well inside how long CI takes, and still bounded enough that a persistent git/GitHub disagreement re-probes on the interval rather than every poll. Tests: the stable-BLOCKED base-drift case the review named, plus the pure policy gate. Both verified to fail with the clock condition removed. Crow-Session: C4A7A6E2-918C-4910-8172-ADE308E3B4E5 Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks — the Yellow finding is correct, and tracing it, it's worse than "may miss": that re-arm could never fire for the case CROW-944 is about.
Fixed in 25cb27b. I went with your second suggestion (bounded periodic re-probe) rather than the
nonisolated static func shouldRecheckUpToDateHead(
_ seen: AutoRebaseUpToDateHead, currentStatus: String, now: Date
) -> Bool {
seen.mergeStateStatus != currentStatus || now >= seen.recheckAt
}Interval is 900s, matching Two tests:
|
dgershman
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues (if any)
None.
Security Review
Strengths:
GitManager.runpasses argv arrays toProcess(no shell interpolation), andbehindBase/rebaseOntoBaseinherit the existing--force-with-lease+ dirty-tree guards.BaseBehindState.unknowncorrectly falls through to a full attempt instead of silently skipping work — avoids a class of "optimization drops real rebases" bugs.AutoRebaseVerdict.gaveUptruncates git stderr to 200 chars before it reaches RPC/UI;prStatusPartrenders viatextContent/title, notinnerHTML(app.js:2748-2756,IssueTracker.swift:387-396).- Crow-authorship gate still applies before any rewrite; review/manager sessions remain excluded.
Concerns:
- None blocking. Operational note (not a vulnerability):
crow:mergePRs that areBLOCKEDbut git-behind now route through auto-rebase (force-push) rather thangh pr update-branch, which can dismiss approvals on repos with "dismiss stale reviews on push." This is documented and intentional — worth awareness for operators.
Code Quality
- Root-cause fix is sound: widen
shouldAttemptAutoRebaseto a cheap candidate filter, then letGitManager.behindBaseanswer the real question using remote refs (notHEAD), withRebaseOutcome.alreadyUpToDatepreventing phantom notifications. - The
autoRebaseUpToDateHeads+ 900s recheck interval correctly handles base drift invisible in bothheadRefOidandmergeStateStatus(IssueTracker.swift:3938-3953) — without this, widening the candidate set would be worse than the original bug. attemptUpdateBranchdefer fix (IssueTracker.swift:3706-3751) closes a realautoMergeInFlightleak; bounded retry on failedupdate-branchmirrors the rebase path.- Precedence hand-off only yields while auto-merge can still act (
IssueTracker.swift:3929-3935), with thorough coverage inIssueTrackerBehindPRHandoffTests. - UI/RPC wiring (
AutoRebaseState,list-sessions-live, ⟲ chip) mirrorsAutoMergeStateconventions; stuck escalation +autoRebaseStucknotification address the prior silent 15-minute log loop. - Tests are extensive:
CrowGitbehindBase/alreadyUpToDate (13/13),IssueTrackerAutoRebaseTests(33/33),IssueTrackerBehindPRHandoffTests(14/14),AutoRebaseStatePayloadTests(5/5).swift buildclean.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
| # | Finding | Color |
|---|---|---|
| 1 | Remote-ref behindBase probe + three-valued result prevents both false skips and false positives |
Green |
| 2 | autoRebaseUpToDateHeads time-based re-arm closes the widened-candidate latch trap |
Green |
| 3 | attemptUpdateBranch in-flight leak + bounded failure retries |
Green |
| 4 | crow:merge + BLOCKED-but-behind may force-push (dismiss reviews on some repos) — documented trade-off |
Green |
| 5 | New CrowEngine/web tests won't run in Linux CI (pre-existing CROW-802); verified locally |
Green |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 5 Green] findings.
Closes #944
The bug
Auto-rebase decided "is this branch behind its base?" from one GitHub field,
mergeStateStatus == "BEHIND"(IssueTracker.swift:3599). That field is single-valued — it reports the highest-priority reason the merge button isn't green, not a set of flags — so a PR that is behind base and anything else reports the other value:mergeStateStatusBLOCKEDDIRTYCONFLICTINGclause, by luck)DRAFTUNKNOWNBLOCKEDis the expensive one: it is the normal state of a PR waiting on its reviewer — exactly the window in which a busy base drifts ahead. On a repo withstrict_required_status_checks_policythe PR then stalled short of merge until a human pressed Update branch, and the rebase Crow could have done for free during review was serialized behind approval instead.The fix
shouldAttemptAutoRebasebecomes a candidate filter (OPEN and notCLEAN); git answers the real question.GitManager.behindBase— one fetch +rev-list --count origin/<branch>..origin/<base>. It compares the two remote refs, notHEAD: behind-ness is a property of the PR head, and aHEAD-based probe would read a locally-rebased-but-unpushed branch as up to date and swallow theout-of-sync-aheaddeferral that tells you to push. Three-valued (.upToDate/.behind(n)/.unknown) so a fetch flake falls through to a full attempt rather than silently skipping.prHasCrowAuthoredCommit, so the widened candidate set costs agit fetchper head state rather than a provider API call.RebaseOutcome.alreadyUpToDate, checked after the fast-forward reconcile. Without it a no-op rebase (exit 0) plus a no-op force-push ("Everything up-to-date", exit 0) returned.rebasedAndPushedand fired a phantom "Branch rebased" notification.The trap this created, and how it's handled
Widening + the per-head latch means a PR probed while merely
BLOCKED-and-not-yet-behind burns its one attempt — and the base moving afterwards is invisible inheadRefOid, so it would never be looked at again. Worse than the original bug.autoRebaseUpToDateHeadsre-arms the latch when GitHub's view of that same head changes, and only then, so a persistent git/GitHub disagreement can't hot-loop.Three fixes around it
A wedged branch now says so.
out-of-sync-divergedbacked off at a 15-minute cap forever, in silence — 64 such lines in mycrowd-automation.log. After 5 consecutive deferrals (whereautoRebaseDeferralBackoffsaturates; a test pins the two together) it publishesAutoRebaseState(.blocked)and fires a newautoRebaseStucknotification. Escalating is not giving up — Crow keeps retrying at the cap, so a human fix lands next cycle. It deliberately does not self-heal a diverged worktree (by definition that holds commitsoriginlacks; a reset would destroy them) and does not hand off to the agent the way the conflict path does, for the same reason.attemptUpdateBranchstops latching and lying. Its no-backend and no-trailer guards returned above thedefer, so the PR stayed inautoMergeInFlightfor the process lifetime. Worse than "silent": nothing wroteautoMergePermanentSkips, soevaluateAutoMerge's guard fell back to.inFlight— the UI said "Crow is working on this PR's auto-merge right now." Forever. The defer now covers every path; suppression isautoUpdateBranchAttempted's job, which returns before any dispatch, so this costs no extra backend calls. A failedgh pr update-branchgets bounded retries rather than latching until a head commit that a failed update is precisely what didn't change, and both maps are now pruned (autoUpdateBranchAttemptednever was).The precedence
continueyields only while auto-merge can still act. It was unconditional, so once auto-merge spent its one-shot per-head attempt and gave up, nobody fixed the branch. TheautoMergeInFlightdisjunct is load-bearing: the key is inserted before the async attempt andapplyAutoMergeruns synchronously right beforeapplyAutoRebasein the same poll, so!containsalone is already false in the very poll auto-merge dispatched.Visibility
auto_rebase_staterideslist-sessions-livebesideauto_merge_state, rendered as a ⟲ chip (orange waiting / red stuck), inserted before the ⛙ auto-merge chip — chronological, and it keepsautoIco(last icon) meaning "auto-merge" for every existing #888 assertion. The justification is sharper than auto-merge's:prStatusJSONnever shipsmergeStateStatus, so a wedged branch rendered as a fully green pill.Decisions stated in the code
shouldAttemptAutoMergekeeps its own draft guard).DRAFTmasked behind-ness permanently, so this is where the change bites most.mergeStateStatus. Wanted (MRs do fall behind, nothing else handles them), bounded to one probe per head, but a deliberate scope expansion rather than a side effect.shouldUpdateBranchBeforeMergeis NOT widened. So acrow:mergePR that isBLOCKED-but-behind now routes to auto-rebase (force-push) rather than auto-merge (merge commit). On a repo with "dismiss stale reviews on push", that dismisses approvals. Flagging it rather than letting someone discover it via a dismissed review.Out of scope:
gh pr update-branch --rebase, and inverting precedence wholesale.Testing
make testgreen except CrowDaemon's 2 pre-existingapp.js-source-shape failures (refreshTerminalsRebindsTheActiveTerminalToTheFreshRow,bootCatchResetsTheHistory) — verified identical withapp.jsstashed toHEAD.make paritypasses at 12 events;make docsregenerated and committed.CrowEngineis not in CI's Linux allow-list androw.test.jsis omitted fromtest:ci(10 pre-existing CROW-802 failures), so most of the new tests won't run in CI. Verified locally:CrowGit27 tests (the CI-covered ones):alreadyUpToDate,behindBase× 4 incl. the remote-vs-HEAD distinction and.unknown-not-.upToDateon a bad ref.CrowEngine664 tests.shouldAttemptAutoRebaseagainst BLOCKED/DIRTY/DRAFT/UNKNOWN/UNSTABLE/HAS_HOOKS/CLEAN;rejectsUnknownStateinverted with a comment explaining why rather than deleted; escalation threshold pinned to the backoff curve; verdictpermanent ⟺ blockedinvariant. NewIssueTrackerBehindPRHandoffTestsdrives real dispatch bookkeeping for the precedence hand-off, bothautoMergeInFlightleak paths, pruning, and the up-to-date re-check.row.test.js: 57 passed / 10 failed, baseline was 43 / 10 — 14 new assertions, same 10 pre-existing failures. (Found and fixed a real bug doing this: the harness's ownT.prStatusInlineshim dropped the new 4th argument.)Not yet exercised end-to-end against a live BLOCKED-and-behind PR — that's the one acceptance criterion no test reaches.
🤖 Generated with Claude Code