Skip to content

fix(groom): stop build_pr full-cloning the target repo to file a bail issue - #129

Merged
mattmillerai merged 1 commit into
mainfrom
matt/be-groom-build-pr-checkout
Aug 4, 2026
Merged

fix(groom): stop build_pr full-cloning the target repo to file a bail issue#129
mattmillerai merged 1 commit into
mainfrom
matt/be-groom-build-pr-checkout

Conversation

@mattmillerai

Copy link
Copy Markdown
Contributor

ELI-5

When groom's robot writes a code change, a second job pushes it and opens the PR. That job always downloaded the entire history of the target repo first — even when there was no code change to push and its only job was to write a GitHub issue. On a huge repo that download can randomly hang. It did: the download sat there for 15 minutes producing nothing, the job's timer ran out, and the finding it was supposed to record was silently lost. Now it only downloads when there's actually a patch to apply, downloads far less, and retries once if the download stalls.

What happened

A build_pr matrix cell on a large downstream caller was cancelled at its 15-minute job timeout, sitting in this fetch the whole time with zero output:

16:03:20  git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules \
            origin +refs/heads/*:refs/remotes/origin/* +refs/tags/*:refs/tags/*
16:18:16  ##[error]The operation was canceled.

Not one * [new branch] line, so it hung in server-side pack negotiation before any ref landed. The sibling cell ran the identical fetch 70 seconds earlier and finished it in 50s — the 15-minute budget was already ~13x the observed need. This is a stall, not slowness, which is why a bigger budget is not the fix.

Cost: that cell had already decided to file a bail issue, so the finding never got filed, never entered the dedup ledger, and the next sweep re-derives it from scratch and pays the finder + verifier for it again.

Changes

  1. Gate the checkout on the build result. New plan step reads result.json and the target-repo checkout is now if: needs_worktree == 'true'. The bail branch (result.get("status") != "patched") calls file_issue() and sys.exit(0)s before any git call, so it never needed a worktree — it was full-cloning a multi-GB monorepo in order to run gh issue create. Defaults closed: an unreadable or absent result.json yields bail, which is exactly the path that needs no worktree, so skipping it cannot strand a patch.
  2. fetch-depth: 01. Nothing downstream reads history — the apply step does checkout -b, git apply --index, git push origin <branch>. Pushing a new branch from a shallow clone is fine because the base commit is already on the remote.
  3. Two bounded attempts instead of one unbounded one. A step timeout-minutes on its own would not have recovered this: exceeding it fails the step and the job, and checkout's internal retry only covers git commands that exit non-zero inside a step that is still alive — never a step the runner killed. So attempt 1 is continue-on-error: true + timeout-minutes: 6, and a second attempt re-runs it on a fresh connection. The second has no continue-on-error, so a genuine two-strike failure is loud rather than falling through to an apply step with no worktree.
  4. **Job timeout-minutes: 15 → 20.** Headroom only, explicitly commented as the last line of defence rather than the fix. It also stops build_prbeing tighter thanbuild` (30 min), which does strictly more work over the same checkout.

Verification

  • python3 -c "yaml.safe_load(...)" parses; build_pr step order confirmed programmatically: plan → checkout(if, continue-on-error, t=6) → retry checkout(if, t=6) → labels → apply.
  • actionlint reports 9 findings, all pre-existing on main and all on lines this PR does not touch (github.job_workflow_sha / job.workflow_sha are real contexts its schema lacks). Zero new findings.
  • Traced the bail path in the apply step to confirm the skip is safe: if result.get("status") != "patched": file_issue(...); sys.exit(0) — no git invocation upstream of that exit, and the _groom_assets checkout that file_issue imports the ledger from is deliberately not gated.
  • Not exercised end-to-end on a live groom run; the next scheduled sweep on a large caller is the real test, and the failure mode if I got the gate wrong is a bail issue that doesn't get filed — the same failure this PR fixes, not a worse one.

… issue

A build_pr matrix cell on a large downstream caller spent its entire 15-minute
budget inside `git fetch` and was cancelled, dropping a CONFIRMED finding that
never reached the dedup ledger — so the next sweep pays to re-derive it.

Three things were wrong, in increasing order of how much they cost:

1. build_pr checked out the target repo UNCONDITIONALLY, before reading
   result.json. That cell had already bailed (patch over the size cap), so its
   only remaining work was `gh issue create` — the bail branch `sys.exit(0)`s
   before touching git. It full-cloned a multi-GB monorepo to write an issue.
   Now gated on status == "patched", defaulting closed: an unreadable result.json
   means the apply step takes the bail branch anyway, so skipping the worktree
   cannot strand a patch.

2. `fetch-depth: 0` fetched all history, branches and tags when nothing
   downstream reads history — `checkout -b`, `git apply --index`, `git push
   origin <branch>`. Shallow now; the base commit is already on the remote, so
   pushing a new branch from a shallow clone is fine.

3. Nothing bounded the fetch. A step `timeout-minutes` alone would not have
   helped: exceeding it fails the step, and checkout's internal retry only covers
   git commands that exit non-zero inside a live step, never one the runner
   killed. So attempt 1 is `continue-on-error` + bounded, and a second attempt
   re-runs it on a fresh connection.

The stall is not "the repo is slow" — the sibling cell ran the identical fetch
70 seconds earlier in 50s, and the failing one emitted zero bytes for 15 minutes.
It is a server-side pack-negotiation hang, so the job timeout (15 -> 20) is
headroom only, not the fix.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 957bf49b-1c3c-40e5-b48a-1d70fc0794ca

📥 Commits

Reviewing files that changed from the base of the PR and between 14b788d and 891e660.

📒 Files selected for processing (1)
  • .github/workflows/groom.yml

Comment @coderabbitai help to get the list of available commands.

@mattmillerai mattmillerai added the cursor-review Multi-model cursor review label Aug 4, 2026
@mattmillerai

Copy link
Copy Markdown
Contributor Author

Merging unreviewed. Blast radius: groom.yml build_pr job only — gates the target-repo checkout on the build result, shallows the fetch, and adds a bounded second attempt. No other job, input, or output changes. Why safe without review: every downstream caller pins this repo by full 40-hex commit SHA, so this reaches no consumer until a bump PR moves its pin; the next scheduled sweep on a pinned caller is the real test. CI green (unittest, sandbox-tests, Socket). Failure mode if the new gate is wrong: a bail issue doesn't get filed — precisely the bug being fixed, so this path cannot end up worse than it already is.

@mattmillerai
mattmillerai merged commit 176c0dd into main Aug 4, 2026
18 checks passed
@mattmillerai
mattmillerai deleted the matt/be-groom-build-pr-checkout branch August 4, 2026 21:17

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 7 finding(s).

Severity Count
🟡 Medium 3
🟢 Low 3
⚪ Nit 1

Panel: 8/8 reviewers contributed findings.

echo "needs_worktree=false" >> "$GITHUB_OUTPUT"
echo "::notice::build ${{ matrix.idx }} status=$status — no patch to apply, skipping the target-repo checkout."
fi
echo "status=$status" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumstatus is read from /tmp/build/result.json, an artifact produced by the credential-free builder agent, and written to $GITHUB_OUTPUT with no sanitization; a value containing a newline appends extra key=value lines and, since duplicate keys are last-wins, could override the needs_worktree=false written two lines above and force the credentialed checkout (a value with no = fails the step outright). The same unsanitized value is interpolated into the ::notice:: workflow command on line 2312, where a newline breaks out of the annotation and can forge further workflow commands in the public log. Today the producer only ever writes the literals patched/bail, so validating against that two-value allowlist before echoing makes the guarantee local instead of dependent on a distant job. Raised by 6 of 8 reviewers (gemini-3.1-pro adversarial, gemini-3.1-pro edge-case, kimi-k3-max adversarial, kimi-k3-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

path: repo
token: ${{ steps.bot_token.outputs.token }}

- name: Checkout target repo (second attempt after a stalled fetch)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The retry checks out into the same path: repo that attempt 1 may have left half-populated after the runner SIGKILLed it mid-fetch (unborn HEAD, temp packfiles, stale .git/*.lock). actions/checkout will first try git remote set-url / git clean -ffdx / git reset --hard against that tree and only re-clones if that fallback triggers, so the retry can fail deterministically — or burn its 6-minute budget on cleanup — in exactly the stalled-fetch scenario it was added for. An explicit rm -rf repo step between the attempts (or a distinct path for attempt 2) makes the recovery independent of that behavior. Raised by 5 of 8 reviewers (gemini-3.1-pro adversarial, kimi-k3-max adversarial, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case, kimi-k3-max edge-case).

# will take the bail branch (`result.get("status") != "patched"`), so the
# worktree is genuinely not needed. Skipping it cannot strand a patch —
# if the status is unreadable there is no patch to apply.
status=$(jq -r '.status // "bail"' /tmp/build/result.json 2>/dev/null || echo bail)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The "Default CLOSED … cannot strand a patch" justification does not hold: the apply step does an unguarded open() + json.load() on the same /tmp/build/result.json, so an absent or truncated artifact raises FileNotFoundError/JSONDecodeError before result.get("status") != "patched" is ever evaluated. The step dies without running gh issue create or writing a ledger marker — the dropped-CONFIRMED-finding outcome this change exists to prevent. Wrap the apply-step load in a try/except that falls through to the bail branch, and consider dropping 2>/dev/null here so a corrupt artifact is distinguishable from a genuine agent bail. Raised by 3 of 8 reviewers (claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max edge-case).

if: steps.plan.outputs.needs_worktree == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
continue-on-error: true
timeout-minutes: 6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowtimeout-minutes: 6 is a hard ceiling, not a stall detector — it cannot tell a fetch producing zero bytes from one that is slow but progressing. A caller repo whose checkout legitimately takes longer than 6 minutes previously succeeded inside the job budget and now fails both attempts, turning a slow success into a guaranteed cell failure with no bail issue and no ledger marker. The fetch-depth: 01 change makes this much less likely, but giving attempt 2 a larger budget than attempt 1 would remove the cliff entirely. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max edge-case, claude-opus-5-thinking-max adversarial).

- name: Checkout target repo (second attempt after a stalled fetch)
# No continue-on-error: if a fresh connection stalls too, fail the cell
# loudly rather than falling through to an apply step with no worktree.
if: ${{ steps.plan.outputs.needs_worktree == 'true' && steps.checkout_target.outcome == 'failure' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — Failing loudly when both attempts stall still drops the finding: the job dies before Apply patch -> open PR, so no gh issue create runs and no ledger marker is written, and the CONFIRMED finding is re-proposed on every subsequent run. The bail path is now hardened against that outcome while the patched path is not; an if: failure() step that files the bail issue would close the gap. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

id: checkout_target
if: steps.plan.outputs.needs_worktree == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
continue-on-error: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowactions/checkout declares post-if: success(), so a step killed by timeout-minutes never runs its credential-cleanup post step — the bot token written by attempt 1 as http.https://github.com/.extraheader stays in repo/.git/config, and continue-on-error: true is what keeps the job running with it present. Impact is bounded by the ephemeral runner and the short-lived app token, but pairing the wipe suggested for the retry with an explicit rm -rf repo would remove the stale credential too. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

echo "needs_worktree=true" >> "$GITHUB_OUTPUT"
else
echo "needs_worktree=false" >> "$GITHUB_OUTPUT"
echo "::notice::build ${{ matrix.idx }} status=$status — no patch to apply, skipping the target-repo checkout."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit${{ matrix.idx }} is interpolated straight into the shell body of a run: block, which is the template-injection pattern zizmor flags and which the rest of this job deliberately avoids (the apply step passes the same value via env: IDX:). The value is workflow-generated so it is not exploitable; passing it through env: just keeps the convention consistent. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants