fix(watchdog): stop one bad dispatch from killing the whole scan - #10180
fix(watchdog): stop one bad dispatch from killing the whole scan#10180MarkusNeusinger wants to merge 5 commits into
Conversation
Follow-up to #10179. That PR stopped implementation PRs from ENTERING the review dead-end; this one stops them from STAYING there. Three verified holes: 1. `dispatch()` called `gh workflow run` bare under `set -euo pipefail`, so a single unroutable dispatch aborted the entire scan and every PR after it went unscanned. Five of fourteen scheduled scans died this way on 2026-08-02..04 with HTTP 422 "Cannot trigger a 'workflow_dispatch' on a disabled workflow" — daily-regen.yml is disabled manually, and the watchdog's cron-liveness rescue keeps trying to revive it. The "-> dispatching" log line was printed BEFORE the attempt, so the run reported rescues it never performed. This is currently armed: daily-regen last ran 16:51 UTC against a 10 h liveness threshold, so the next scan after 02:51 UTC would die again. 2. `ai-rejected` + `ai-attempt-N` matched no case at all: Case 2 excluded any verdict label, Case 4 excluded any attempt label. That is precisely the state impl-review.yml leaves behind when its impl-repair dispatch fails — its own comment documents the attempted escape hatch, which only works if a second API call also succeeds. PR #9949 sat in it for ten days. Case 2 now excludes only `ai-approved`; the age guard and the watchdog:repair-rescued-N marker already provide the race protection and one-shot semantics. 3. `ai-review-rescued` was written once by Case 1 and cleared by nothing, so it meant "this PR was ever rescued" instead of "this failure streak was already rescued". Any PR that failed review twice in its lifetime was permanently outside automation — 9 of the 11 PRs stuck on 2026-08-05 carried that pair. A successful review now clears it together with `ai-review-failed`, via the REST API because `gh pr edit --remove-label` fails on this repo with a GraphQL "Projects (classic) is being deprecated" error. Verified with a harness that extracts dispatch() verbatim from the YAML and exercises 11 cases: disabled target, transient failure and success (scan survives all three, failures counted, no premature claim), plus the six Case 2 label shapes including the #9949 dead-end and the ai-approved case that must still fall through to Case 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the GitHub Actions “watchdog” safety net so a single broken dispatch or a previously-rescued review state can’t strand implementation PRs in a dead-end, continuing the recovery work from #10179.
Changes:
- Make watchdog
dispatch()non-fatal underset -euo pipefail, and improve logging for disabled-workflow vs transient dispatch errors. - Fix watchdog Case 2 matching so
ai-rejected+ai-attempt-Nno longer falls through all cases. - Clear
ai-review-failedandai-review-rescuedafter a successful review inimpl-review.ymlvia REST label deletion (avoidsgh pr edit --remove-labelGraphQL failure).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
CHANGELOG.md |
Adds an [Unreleased] Fixed entry documenting the three pipeline/watchdog fixes. |
.github/workflows/watchdog-stuck-jobs.yml |
Makes dispatch failures non-fatal and closes the “ai-rejected + ai-attempt-N” case gap. |
.github/workflows/impl-review.yml |
Clears “rescued/failed” labels on successful review using gh api label deletion. |
| DISPATCH_FAILURES=$(( DISPATCH_FAILURES + 1 )) | ||
| return 0 |
There was a problem hiding this comment.
Correct, and the gap was real: the counter was incremented and never read, so "counted and reported" only held for the inline per-failure warnings. Fixed in 5a8f0e4 — the scan's closing line now states the tally.
This matters beyond tidiness. Without it, a scan that dispatched nothing successfully reads exactly like a quiet, healthy one — and an always-green watchdog that silently rescues nothing is the precise failure mode this PR exists to fix.
if (( DISPATCH_FAILURES > 0 )); then
echo "::warning::Watchdog scan complete — ${DISPATCH_FAILURES} dispatch(es) failed (see warnings above)"
else
echo "::notice::Watchdog scan complete — all dispatches succeeded"
fiDeliberately not exit 1 on a non-zero tally: aborting the scan is what took it down in the first place, and a red run would bury the summary behind a failed step.
Your comment also surfaced an adjacent risk worth pinning down rather than reasoning about. The counter only works if the increments happen in the current shell. The real scans are while read ... done < <(jq -c '.[]') — process substitution, so the body does run in the current shell — but had they been jq | while read pipelines, every increment would have landed in a subshell and the tally would have printed 0 forever, which is worse than no tally at all. Two cases now assert it against the real loop form:
--- the counter must survive the scan loop and be reported ---
PASS counter survives while/process-substitution 3 failures reported
PASS healthy scan reports success, not silence ok
Suite is 25 cases, all passing.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The cron-liveness rescue assumed every silent daily-regen schedule was a starved one. It is not: the maintainer switches daily-regen off and on deliberately, to manage the monthly token budget. Treating that as starvation means the watchdog reports "schedule starved, re-dispatching" against an intentional operator decision, and tries to override it. The rescue also cannot succeed in that state — a disabled workflow rejects workflow_dispatch with HTTP 422 — so before the previous commit it killed the scan, and after it, it would still log a warning and burn a dispatch failure on every scan for as long as the workflow stays off. That is precisely the recurring noise that hid the real breakage. Section C now reads the workflow state before deciding: skip quietly for `disabled_manually` (intentional), skip loudly for `disabled_inactivity` (GitHub's 60-day auto-disable — worth flagging, equally undispatchable), and fall through to the existing quiet-window and gap logic otherwise, including for an unreadable state. Test suites extended to 23 cases; the new 12 cover the full workflow-state x quiet-window x gap matrix and assert that active behaviour, including the 10 h boundary, is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scope extended after maintainer inputThe maintainer confirmed that Making the failed dispatch non-fatal (commit 1) stops the watchdog from dying, but Section C would still, on every single scan for as long as the workflow stays off:
That is exactly the recurring noise that hid the real breakage in the first place. Section C now reads the workflow state before deciding:
Test suites extended from 11 to 23 cases. The 12 new ones cover the full workflow-state × quiet-window × gap matrix and pin that active behaviour — including the 10 h boundary — is untouched: The open question in the PR description is resolved: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/watchdog-stuck-jobs.yml:97
DISPATCH_FAILURESis incremented but never surfaced in logs, so it currently adds state without changing behavior. If the intent is to make failures visible, include the running count in the warning output (or otherwise report it) so operators can see how many dispatches failed in a run.
err=$(printf '%s' "$err" | head -c 300 | tr '\n' ' ')
if [[ "$err" == *"disabled workflow"* ]]; then
# Not transient and not something a retry fixes — a human turned
# the workflow off. Report it plainly and keep scanning.
echo "::warning::${label} → SKIPPED, target workflow is disabled: ${err}"
CHANGELOG.md:141
- The changelog entry says the safety net had "three" holes, but the bullet enumerates (1)–(4). This reads like an internal inconsistency in release notes; either adjust the count in the headline or drop the fourth numbered item from this bullet.
- **The pipeline's safety net had three holes, and one of them was armed** — follow-up to #10179,
Copilot review on #10180: DISPATCH_FAILURES was incremented but never read, so the "counted and reported" claim in the PR body and changelog was only half true — individual failures warned inline, but the total went nowhere. The summary line now states it. This matters beyond tidiness: without a tally, a scan that dispatched nothing successfully reads exactly like a quiet, healthy one, and an always-green watchdog that silently rescues nothing is the very failure mode this PR exists to fix. Deliberately not `exit 1` on a non-zero tally — aborting is what took the scan down in the first place, and a red run would bury the summary behind a failed step. Two test cases added (25 total) pinning that the counter actually survives the scan loop: the real loops are `while read ... done < <(jq -c '.[]')`, i.e. process substitution, so the body runs in the current shell. Had they been `jq | while read` pipelines, every increment would have happened in a subshell and the tally would always print 0 — the assertion now covers that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
CHANGELOG.md:143
- This changelog entry says the safety net had “three holes” but then enumerates (1)–(4). That internal inconsistency makes the release note hard to trust. Either adjust the headline count or merge/remove an item so the count matches the list.
- **The pipeline's safety net had three holes, and one of them was armed** — follow-up to #10179,
which stopped PRs *entering* the dead-end; this stops them *staying* there.
(1) `watchdog-stuck-jobs.yml` called `gh workflow run` bare under `set -euo pipefail`, so a
|
|
||
| err=$(printf '%s' "$err" | head -c 300 | tr '\n' ' ') | ||
| if [[ "$err" == *"disabled workflow"* ]]; then |
There was a problem hiding this comment.
Confirmed, and this is the best catch of the review — the PR was reintroducing the exact failure mode it exists to fix, in the error path where it would be hardest to notice. Fixed in the latest commit.
One correction to the mechanism, because it changes how the regression test has to be written: the threshold is the pipe buffer, not the 300-byte limit. head -c 300 exits immediately, but printf only takes SIGPIPE once its write no longer fits in the ~64 KiB pipe buffer — below that it completes and exits 0 before head closes the read end. Measured on this runner, not inferred:
--- message 60000 bytes, current form --- SURVIVED len=300 rc=0
--- message 70000 bytes, current form --- rc=141
--- message 200000 bytes, current form --- rc=141
--- 200000 bytes, proposed pure-bash form --- SURVIVED len=300 rc=0
That is exactly why the existing suite missed it: every stub error was short. A test using a 300-byte message would have passed against the buggy code and proved nothing.
Truncation is now pure parameter expansion — forks nothing, cannot fail:
err=${err//$'\n'/ }
err=${err:0:300}Newline stripping is kept deliberately: ::warning:: is line-oriented, so a raw newline leaks everything after it into the plain log.
Four regression cases added (29 total), and each was checked to fail against the pre-fix form before being accepted, so they pin the behaviour instead of merely accompanying it:
against the FIXED code: against the BUGGY form:
PASS 100 KiB error: scan survives FAIL rc=141 (want 0)
PASS 100 KiB error: message truncated FAIL rc=141 (want 0)
PASS multi-line huge error: scan survives FAIL rc=141 (want 0)
PASS huge multi-line error stays one log line FAIL 0 lines
Worth noting for the record: the pre-existing gh_retry helper in impl-review.yml uses head -c 500 /tmp/gh_retry.err | tr ..., which is safe — it reads from a file, so there is no upstream writer to receive SIGPIPE. Only the printf | form I introduced was vulnerable.
Copilot review on #10180 caught this PR reintroducing the exact bug it exists to fix. The truncation err=$(printf '%s' "$err" | head -c 300 | tr '\n' ' ') is a pipeline under `set -euo pipefail`. Once the message exceeds the pipe buffer, `head` exits after its 300 bytes, `printf` takes SIGPIPE and returns 141, `pipefail` propagates that to the assignment and `set -e` kills the scan — in the error path, which is precisely where this function must never fail. Reproduced rather than assumed. The threshold is the pipe buffer, not the 300-byte limit: measured on this runner, a 60 000-byte message survives with rc=0 and a 70 000-byte message exits 141. That is why it never showed up in the earlier tests, whose stub errors are short. Replaced with parameter expansion, which forks nothing and cannot fail. Newline stripping is kept because `::warning::` is line-oriented. Four regression cases added (29 total), covering a 100 KiB error, a multi-line 100 KiB error, correct truncation, and the single-line warning guarantee. Each was verified to FAIL against the pre-fix form (rc=141) before being accepted, so they pin the behaviour rather than merely accompanying it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
CHANGELOG.md:141
- The changelog headline says the safety net had “three holes”, but the entry then enumerates (1)–(4). Please align the headline with the actual list (e.g., change it to “four holes”) to avoid a confusing release note.
- **The pipeline's safety net had three holes, and one of them was armed** — follow-up to #10179,
Follow-up to #10179. That PR stopped implementation PRs from entering the review dead-end; this one stops them from staying there. Three verified holes in the pipeline's only safety net.
1. A failed dispatch aborted the entire scan — and this is armed right now
dispatch()calledgh workflow runbare underset -euo pipefail. One unroutable dispatch therefore killed the whole run, and every PR after it went unscanned — the safety net failing silently, in the one place where nothing else is watching.Five of the last fourteen scheduled scans died exactly this way (
30749881831,30781315973,30803034385,30824022333,30872621465):Workflow
265430494is.github/workflows/daily-regen.yml, statedisabled_manually. The watchdog's Section C cron-liveness rescue keeps trying to revive it and cannot. Worse, the→ dispatchingline was printed before the attempt, so the log claimed rescues that never happened — which is why Section C reported "dispatching" on 08-03 and 08-04 whiledaily-regenstayed down.This is not historical.
daily-regenlast ran2026-08-05T16:51ZandLIVENESS_HOURS=10, so from ~02:51Zthe gap trips again and the next scheduled scan (0 */6 * * *) dies with it.Dispatch failures are now counted and surfaced, never fatal; a disabled target is named as the non-transient condition it is; and the log line follows the attempt instead of preceding it.
2.
ai-rejected+ai-attempt-Nmatched no case at allCase 2 excluded any verdict label, Case 4 excluded any attempt label — so that pair fell through everything. It is exactly the state
impl-review.ymlleaves behind when itsimpl-repairdispatch fails; the file's own comment documents the escape hatch (dropai-rejectedso Case 2 picks it up) which only works if that API call also succeeds. PR #9949 sat in this state for ten days.Case 2 now excludes only
ai-approved(Case 3's business). The existingage > STALE_SECguard keeps it from racing an in-flight repair, and thewatchdog:repair-rescued-Nmarker keeps it one-shot.3.
ai-review-rescuedwas never clearedCase 1 writes it; nothing removes it. So it meant "this PR was ever rescued" instead of "this failure streak was already rescued once". Any PR that failed review twice in its lifetime was permanently outside automation — 9 of the 11 PRs stuck on 2026-08-05 carried this pair, and both
impl-review-retry.ymland watchdog Case 1 refused them.A successful review now clears it together with
ai-review-failed, restoring the intended per-streak semantics. Done via the REST API:gh pr edit --remove-labelfails on this repo withGraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)— hit while cleaning up these very PRs.Verification
Workflow changes have no verification loop in this repo, so
dispatch()is extracted verbatim from the YAML and exercised against a stubbedgh, and the Case 2 guard is replicated exactly as written:yaml.safe_loadclean on both changed workflows.Hole 3's fix rides in
impl-review.ymlrather than the watchdog, because a successful review is the only event that can truthfully say the failure streak ended. Its effect was observed live: after #10179 merged, #10130 and #10003 were resumed and both reviews now score0→ai-rejected→impl-repairdispatched (runs at 21:38), the hand-off that had not fired in ten days.Open question for the maintainer, deliberately not actioned here:
daily-regen.ymlwas disabled manually a few hours ago — plausibly to stop the flood of stranded PRs that #10179 has now fixed at the source. Re-enabling it is your call; this PR only makes the watchdog survive the disabled state instead of dying on it.🤖 Generated with Claude Code