How to read the clock
One ring per day, oldest in the middle. A brass peg is the moment your cron: line said to run. The arc runs clockwise to the moment GitHub actually created the run, where the bird sits — green within 10 minutes, coral within the hour, raspberry beyond. A hollow ring is a slot that never got a run at all.
That gap is measured before any runner is involved, so it is GitHub's scheduler, not your job. cronlag reads the run history and the workflow file's commit history through the REST API, rebuilds every slot your cron promised (with GitHub's cron rules and timezone:), and lines the runs up against them.
Run it
npx github:Blackman99/cronlag robhunter/agentdeals -w reverify.yml
cronlag robhunter/agentdeals · last 7 days · times UTC
reverify.yml "Daily rolling re-verification" 0 6 * * *
▇▇▇▆▇▇▇
7 slots · 7 ran · 0 missed on time 0/7 · within 1h 0/7
lag min 5h55 · median 6h40 · p90 7h56 · max 8h27
LATE median run is created 6h40 after its cron slot
! "0 6 * * *" fires at minute 0, GitHub's busiest minute (its docs recommend another one).
→ cron: '37 6 * * *'
· Lag is measured from the cron slot to run creation, before any runner is involved —
that part is GitHub's scheduler, not your jobs.
→ run `cronlag catchup robhunter/agentdeals --dispatch` from an external cron every 10–15 min
No token needed for public repos (60 API calls an hour); with gh auth login or GITHUB_TOKEN it reads private ones too. --format markdown pastes straight into the issue you were about to write by hand.
Is it me or GitHub?
Every thread about missing schedules walks the same checklist. cronlag walks it for you, per workflow:
- Disabled after 60 days — public repos with no activity lose their schedules; cronlag warns from day 45 and prints the
gh workflow enableline. - Never registered — no scheduled run since the file last changed, slots later.
- Minute 0 — the busiest minute on GitHub; it suggests a quieter one.
- Under 5 minutes, forks, archived repos, invalid cron, no
workflow_dispatch. - Runner queue vs. scheduler — lag before creation is GitHub's; waiting after creation is your runners.
- Cron changed mid-window — old slots are judged by the old cron, from git history.
Make the bird sing anyway
cronlag catchup looks at each schedule's latest due slot. If it has no run after a grace period, --dispatch starts it through workflow_dispatch. Run it every 10–15 minutes from any cron that isn't GitHub's — a server, a Cloudflare Worker, a Raspberry Pi. Slots that already ran are left alone, so it never doubles up.
*/10 * * * * GITHUB_TOKEN=… npx -y github:Blackman99/cronlag catchup you/repo --dispatch
And when the late scheduled run finally turns up, mode: guard tells it to stand down:
jobs:
guard:
runs-on: ubuntu-latest
outputs: { skip: ${{ steps.g.outputs.skip }} }
steps:
- id: g
uses: Blackman99/cronlag@v0
with: { mode: guard }
work:
needs: guard
if: needs.guard.outputs.skip != 'true'
…
In CI, and for your agent
GitHub Action — weekly report into the job summary, fail when slots drop:
- uses: Blackman99/cronlag@v0
with: { days: 7, fail-on: dropped }
MCP server — agents that babysit scheduled jobs stop re-deriving lag tables from raw run lists. Read-only tools: schedule_report, catchup_plan, cron_explain.
{ "mcpServers": { "cronlag": { "command": "npx", "args": ["-y", "github:Blackman99/cronlag", "mcp"] } } }