attemptFix refuses to keep trying past maxAttempts, and refuses outright when a failure also reproduces on the base branch. Both guards were written, tested — and never reached.
No production caller passed priorAttempts, so every failure looked like the first one and the ceiling was never met. On a bot that pushes commits, that is an absent anti-loop guard.
The docs had been asserting otherwise. docs/features/ci-repair.md promised "prior attempts on the pull request are counted, and the run stops at the limit", and the page subtitle sold the hard attempt limit as a feature. It is true now.
Where the count lives
A CI job remembers nothing between runs, so the tally goes on the pull request, beside the review marker:
<!-- buddy:fix-ci v1
{"attempts":2,"lastAttemptAt":"..."}
-->Per pull request, not per commit — deliberately. Buddy's own repair pushes a commit, so keying the count to the head sha would reset it every time a fix landed and defeat the guard entirely.
Only a real attempt increments it. Recording the ones the guard already refused would march the counter up without anything having been tried.
Two adjacent gaps this uncovered
The generated workflow never passed --pr. So a repair in CI reported to the job log and nowhere else — and had no pull request to read a count from. It passes one now, and skips when the run came from a fork, where workflow_run.pull_requests is empty.
@buddy fix-ci was worse than unwired. It called attemptFix with log: '', and classification is entirely a function of the log:
classifyFailure("") → { kind: "unknown", mechanical: false, evidence: [] }So it always produced a confident-looking unknown verdict with nothing behind it. A comment cannot say which run failed, so it now says that and points at the job that can.
What is deliberately left
failsOnBase stays unwired. Deciding it means finding the base branch's run for the same workflow, and the provider interface exposes no way to list runs — only getWorkflowRunLogs(runId). That needs a new capability rather than a call-site change, so it belongs in its own change rather than half-done here.
I also did not add a config key for maxAttempts. attemptFix already defaults to 3, and inventing config surface in a bug-fix PR seemed like the wrong trade.
Tests
9 new: the marker round-trip, that recording twice replaces rather than stacks, that an unreadable or negative count reads as absent rather than throwing, that it survives alongside the review marker in the same body, and that the guard it feeds actually stops at the ceiling.
Verification
- 1905 pass / 0 fail (+9), lint clean, typecheck clean
check:docsgreen: 75 pages, 33 commands, 221 config keys- Docs site builds: 73 pages
🤖 Generated with Claude Code