Skip to content

Hand the live loop off explicitly instead of hoping the cron does - #56

Open
dgoodenough wants to merge 2 commits into
mainfrom
claude/live-update-health-t3lvkh
Open

dgoodenough wants to merge 2 commits into
mainfrom
claude/live-update-health-t3lvkh

Conversation

@dgoodenough

Copy link
Copy Markdown
Owner

The live loop holds a runner ~5.5h and exits under GitHub's 6h per-job ceiling. Continuation depended on the */15 cron having left a queued successor in the shared concurrency group — which the workflow header described as something the coarse cron did "reliably".

What happened

On 2026-08-27, round 2 of Worlds, it had not. Run #1019 yielded at 20:01Z announcing "the queued successor takes over" and nothing took over.

GitHub delivered zero scheduled fires for this workflow between 14:30Z and 23:17Z, when it was restarted by hand — including the 3h11m after the yield, when the concurrency slot was completely free. A congested queue does not explain that. The corroborating evidence that the scheduler rather than the queue was at fault: refresh.yml's 0 11 * * * cron fired that day at 20:57Z, ~10 hours late. That late run is also the only reason the site was 2h15m stale instead of 3h16m — the once-a-day job happened to land in the hole.

An earlier gap the same day went unnoticed entirely: #1018 ended 08:52Z, #1019 started 14:30Z. 5h38m uncovered.

Nothing in the pipeline was broken. The loop, the change-check gate, the publish path and the invariants were all healthy — which is exactly why no run went red and nobody was notified. A liveness failure that produces no failing run produces no alert either.

The fix

The yielding run now dispatches its own successor and confirms it exists. workflow_dispatch (with repository_dispatch) is the documented exception to the rule that GITHUB_TOKEN-triggered events start no workflow run, so this needs no PAT — only actions: write.

.github/dispatch-successor.sh (new) dispatches and then polls for the run, re-dispatching once and exiting non-zero if it cannot confirm one. The verification is the substance, not politeness: a 204 is not a run, as the 2026-08-26 Actions incident showed, and a hand-off that reports success with nothing enqueued reproduces this outage exactly.

In live-refresh.yml:

  • The dispatch happens before the current run exits, so the successor lands in the concurrency group as the pending run and starts as the slot frees — the shape the cron was supposed to produce, minus the dependency on delivery.
  • A failed hand-off fails the run, rather than exiting quietly with nothing lined up. The silence is what made this expensive.
  • A crash-out gets one automatic retry, marked after_failure=true; a run carrying that flag does not chain again. Transient breaks self-heal in a minute; persistent ones cannot spin runners.
  • after_failure is a string rather than a boolean on purpose — it is dispatched via gh -f, which sends strings, and the loop compares it as one. No coercion between them for no benefit.

Testing

7 new tests stub gh on PATH and drive polling to zero delay through the SUCCESSOR_* knobs the script exposes for that purpose. 150 pass locally.

Checked that they are not vacuous: removing the poll-for-the-run step fails 4 of the 7. The empty---repo case is a test rather than a manual check, because an empty array expanded under set -u aborts on bash before 4.4.

What this does not fix

Starting the loop when an event goes live still rides on the cron. That window is more forgiving — the loop exits in seconds when nothing is live, so a late start costs the opening minutes of a round rather than hours mid-round — but it is the same dependency, and a multi-hour delivery gap across a Sunday-morning tee time would show. HARDENING item 10 records the fix if it bites: keep looping while a points event starts within the next several hours, instead of exiting on "nothing live right now". Not built here, because it trades a runner held through event-eve nights against a risk that has not materialised.

🤖 Generated with Claude Code

https://claude.ai/code/session_018qBS4DHn53TNPoQZXRsiPq


Generated by Claude Code

claude added 2 commits August 28, 2026 02:26
The loop holds a runner ~5.5h and exits under GitHub's 6h per-job ceiling.
Continuation depended on the `*/15` cron having left a queued successor in
the shared concurrency group during that window — which the workflow header
described as something the coarse cron did "reliably".

On 2026-08-27, round 2 of Worlds, it did not. Run #1019 yielded at 20:01Z
announcing "the queued successor takes over" and nothing took over. GitHub
delivered zero scheduled fires for this workflow between 14:30Z and 23:17Z,
when it was restarted by hand — including the 3h11m after the yield when the
concurrency slot was completely free, so a congested queue does not explain
it. `refresh.yml`'s `0 11 * * *` cron fired that day at 20:57Z, ~10 hours
late, which is the corroborating evidence that the scheduler rather than the
queue was at fault; that late run is also the only reason the site was 2h15m
stale instead of 3h16m. An earlier gap the same day went unnoticed: #1018
ended 08:52Z, #1019 started 14:30Z, 5h38m uncovered.

Nothing in the pipeline was broken — the loop, the gate, the publish path
and the invariants were all healthy, which is exactly why no run went red
and nobody was notified. A liveness failure that produces no failing run
produces no alert either.

So the yielding run now dispatches its own successor and confirms it exists.
`workflow_dispatch` (with `repository_dispatch`) is the documented exception
to the rule that GITHUB_TOKEN-triggered events start no workflow run, so
this needs no PAT, only `actions: write`.

- .github/dispatch-successor.sh dispatches and then POLLS for the run,
  re-dispatching once and failing non-zero if it cannot confirm one. The
  verification is the substance: a 204 is not a run, as the 2026-08-26
  Actions incident showed, and a hand-off that reports success with nothing
  enqueued reproduces the outage exactly.
- The dispatch happens before the current run exits, so the successor lands
  in the concurrency group as the pending run and starts as the slot frees.
- A failed hand-off fails the run, rather than exiting quietly with nothing
  lined up — the silence is what made this expensive.
- A crash-out gets one automatic retry, marked after_failure=true; a run
  carrying that flag does not chain again, so transient breaks self-heal
  while persistent ones cannot spin runners.

Still on the cron: starting the loop when an event goes live. That window is
more forgiving (the loop exits in seconds when nothing is live, so a late
start costs the opening minutes of a round, not hours mid-round), and
HARDENING item 10 records the fix if it ever bites.

Tests stub `gh` on PATH and drive the SUCCESSOR_* knobs to zero delay.
Verified by mutation: removing the poll-for-the-run step fails 4 of the 7.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qBS4DHn53TNPoQZXRsiPq
Two PDGA rows at Worlds carried impossible cumulative scores on 2026-08-29:
MPO Sander Bahnerth cur=+981 at 16:10Z, and two hours later FPO Samantha
Zaborowski cur=+849, both far outside the per-round bounds. The publish-gate
checks did exactly their job — published the data, then failed the run so the
owner was notified.

What that costs had not been noticed. `exit 1` ends the run, and the run IS
the live loop, so each bad row also stopped the site updating. Worse, the
de-dupe meant to keep a persistent violation from re-alerting could never
fire: its marker lived in data/cache/, which is gitignored and carried
between runs only by actions/cache, whose save step is skipped when the job
ends non-zero. The alerting run is always the failing run, so the marker
written by the run that alerts was precisely the one guaranteed not to
survive. Both runs show "Post Restore results cache: skipped". Every restart
re-alerted and died again; round 4 ran on hourly updates (16:10, 17:26,
18:38Z) instead of six-minute ones, and the 17:26 one was the daily cron
rather than the loop.

The hand-off added in the previous commit did not cover this. It handles the
budget yield and the three-crash path via fail_step; the invariant block has
its own bare `exit 1`, a third exit with no successor. Found in production,
not in review.

- .github/invariant-alert.sh makes the alert decision and owns the marker.
  Exit 3 = new violations, 0 = clean or already alerted, 1 = broken, so the
  caller can order publish/commit/alert around it.
- The marker moves to data/invariant_alerted.txt, tracked alongside the other
  cross-run state the pipeline reads back, and the decision now runs BEFORE
  the state commit so it is committed rather than lost with the run.
- The invariant exit hands off to a successor, which reads that marker, sees
  the same violation set and stays quiet — the loop keeps publishing while
  the run still goes red.
- Alerting is per episode: a clean run clears the marker, so a row that
  breaks, is fixed, and breaks again is reported both times.
- The hand-off is withheld if the state push failed. That is the one way this
  could chain: a successor starting from the stale marker would re-alert.

Not addressed: refresh.yml's own invariant step has no de-dupe and will still
go red once a day while a violation persists. It is a daily job rather than
the liveness path, so that is the intended signal.

8 tests drive the script through the episode semantics. Verified by mutation:
disabling the de-dupe fails test_same_violation_stays_quiet, and not clearing
on a clean run fails test_recurrence_after_a_clean_run_alerts_again. The
unusable-path case uses a file-as-parent rather than chmod, which root ignores.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qBS4DHn53TNPoQZXRsiPq

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants