fix(dev-lead): a bot-comment retry cancelled before it starts no longer holds the pending window or an attempt (#2262)
Summary
The bot-comment retry sweep now knows what happened to the run it dispatched.
- After a successful dispatch the sweep finds the run it created (the dev-lead caller stub's
repository_dispatchrun titleddev-lead-reviews-retry, created at or after the dispatch) and records it on the retry marker asrun=<id>. It records only when exactly one candidate exists; otherwise the marker keeps today's time-based behaviour. The marker is edited, not re-posted, because the caller stub subscribes toissue_commentcreatedonly, so the edit starts no run in the lane. - On later scans it reads each recorded run. A run that completed as cancelled with no job having run a step is lost: it is not pending and does not count toward
BOT_COMMENT_RETRY_MAX_ATTEMPTS. - A recorded run that is still queued or running now holds every comment on the PR, bounded by the pending window.
- After a lost retry, the PR's next retry waits
BOT_COMMENT_RETRY_LOST_BACKOFF_SEC(default: the 15-minuteMIN_AGE). BOT_COMMENT_RETRY_MAX_TOTALstill counts every marker, lost ones included, so a lane that keeps cancelling is bounded.bcr_retry_decisionsstays pure: the run states are passed in as an optional sixth argument, and all network calls are in the sweep.
Problem
Closes #2262. A retry's dispatched run joins the PR's concurrency lane, where GitHub keeps one pending run. When a newer event cancelled it before any step ran, the sweep still treated the marker as "queued or running" for 150 minutes and counted it as one of two attempts.
Reading the real runs for the issue's evidence changed the picture: on PR #2205 the retries cancelled each other. Run 38073395880 (the "CodeAnt Nitpicks" retry) was created at 17:50:45 and cancelled at 18:00:00, three seconds after the sweep posted the retry marker for the second comment at 17:59:57. The pending check was per comment, so the next scan dispatched for comment B while comment A's retry was still queued. That is why this change adds the PR-wide hold and the backoff: releasing a cancelled retry promptly with nothing else would turn the loss into a ping-pong up to MAX_TOTAL.
Risk
Medium. It changes when the sweep dispatches fix-bot-comment retries, in scripts/dev-lead-retry.sh and scripts/lib/bot-comment-retry.sh. Every unknown falls back to today's behaviour: a marker with no run=, a run missing from the map, an unreadable Actions API, a run cancelled after it started, a truncated listing. A non-numeric run= is ignored, and run ids are taken only from markers that pass the existing trusted-login rule. Older readers are unaffected: run= is a trailing attribute, and the unchanged library decides identically on markers that carry it.
Known limits, not addressed here:
- A dispatch that creates no run at all is not detected; an empty listing cannot be told apart from a filter mismatch, and reading it as lost would duplicate live runs. So acceptance criterion 1 is met for a cancelled run and not for "never started".
- Markers already on open PRs carry no
run=and keep the time-based behaviour, as does any dispatch whose run cannot be paired (two retries reaching one repo within a few seconds). - Posting a retry marker still fires
issue_comment.createdinto the lane. That is existing behaviour. - The bot-thread retry (#2046) has the same blind spot for its own cancelled run;
bot-thread-retry.shis untouched. - The sweep needs to read Actions runs in each target repo. A classic PAT with
repocan; a fine-grained token needs Actions read. If it cannot, each affected scan logs a::warning::and keeps today's behaviour. Private target repos are unverified. - Pairing can still record another PR's run when two retries reach one repo within seconds and ours is listed later than both looks. The effect is bounded by
BOT_COMMENT_RETRY_MAX_TOTAL. - The recording path has not run against a real dispatch yet. That needs a live check once a channel carries it.
Test plan
- 19 new bats tests: 16 in
tests/dev-lead/unit/test_bot_comment_retry.bats, 2 intest_dev_lead_retry.bats, 1 intest_intent_bot_comment_retry.bats. They cover a cancelled retry being re-dispatched, cancelled retries not exhausting the attempt limit, the hard bound, and a live run not being duplicated, at the pure-decision level and through the sweep with a stubbedgh. No existing test line is changed. batson the three touched files: 86/86, 36/36, 23/23 (run twice, by the implementing agent and again independently).- Counterfactual: with the
scripts/hunks reverse-applied, all 19 new tests fail and the 126 existing ones pass. - A wider run of 18 related suites: 611 passed, 0 failed.
shellcheck --severity=warning -xon both scripts: clean.scripts/check-retry-payload-fields.shandscripts/check-duplicate-decls.sh: pass.- Read-only against the real API: the state reader returns cancelled-and-not-started for runs 38073395880 and 38077617157 and success for 38073354743; the pairing function picks exactly 38073395880 from the real listing for the evidence window.
Review follow-up (b816e34b)
- Backoff longer than the pending window (CodeAnt).
bcr_retry_run_idsreads a retry's run inside the longer of the two windows, so a lost run past the pending window is still known lost and the backoff holds. - Skipped steps (CodeAnt). A job whose only steps were
skippedno longer counts as started. A cancelled step, or one with no conclusion, still does: it may have begun. - Pairing the wrong run (CodeAnt, QA Lead). A run is recorded only when two looks in a row show the same single candidate. One look could record another PR's retry run that was listed before this dispatch's own. This narrows the race and does not close it: if our run is listed later than both looks, or never, the other run is still the only candidate. Added to the known limits below.
- Lookup time (CodeAnt).
BOT_COMMENT_RETRY_RUN_LOOKUP_DEADLINE_SEC(default 1200): once the scan has run that long, a dispatch records no run instead of waiting, so the waits cannot use up the workflow's 30-minute limit. The confirming look adds one wait (5 s by default) to each dispatch. - Real API data (QA Lead).
tests/fixtures/bot-comment-retry/holds what the Actions API returned on 2026-10-11 for PR #2205's cancelled retry run 38073395880, its jobs, and theevent=repository_dispatchlisting around it, cut down to the fields read. The pairing and the run-state reader are tested against it. The server-sidecreated=andevent=filters were exercised by that read; the tests still use a fakeghthat ignores them. - Cancelled before any job existed (QA Lead): a sweep-level test pins that it reads as lost.
- Not done: the rollout check QA Lead suggests (compare
[run]lines with dispatches for a few days after promotion and treat 0% as a failed rollout). It is a step for whoever promotes the channel. - 7 new tests; 5 fail on the previous scripts, and the fixture test and the "no job existed" test pass either way.
test_bot_comment_retry93/93,test_dev_lead_retry36/36,test_intent_bot_comment_retry23/23.shellcheck,check-retry-payload-fields.shandcheck-duplicate-decls.sh: clean.
Rollback
Revert this PR. Markers that already carry run= are read by the older code exactly as before.
Monitoring
The dev-lead-retry workflow log: [run] ⦠retry attempt N is Actions run <id> after a dispatch, [lost] ⦠was cancelled before it started on a later scan, and the ::warning:: lines when a run cannot be read. Inert until the dev-lead channel tag moves onto it (next autocut to dev-lead/v139-next, then ring promotion); pr-review's own channel also runs this scan.
Interaction contract
- N/A: no agentic role added or changed.
š¤ Generated with Claude Code