Skip to content

feat(github)!: 3.0.0, rename gh-pr-events to agentic-utils-github and catch up after sleep - #12

Merged
brtkwr merged 1 commit into
mainfrom
feat/gh-pr-events-catchup
Oct 14, 2026
Merged

brtkwr merged 1 commit into
mainfrom
feat/gh-pr-events-catchup

Conversation

@brtkwr

@brtkwr brtkwr commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

3.0.0: the plugin is renamed from agentic-utils-gh-pr-events to agentic-utils-github, its MCP server from gh-pr-events to pr-events, and its folder to plugins/github. It now shows as plugin:agentic-utils-github:pr-events, the tools are mcp__plugin_agentic-utils-github_pr-events__subscribe_pr / __unsubscribe_pr, and the channel flag is --channels plugin:agentic-utils-github@agentic-utils. The default state dir moves to ~/.claude/state/pr-events, with no migration: sessions subscribe again once. The daemon check now looks for agentic-utils-github/ or plugins/github/ in the daemon's command line. The GH_PR_EVENTS_* environment variables keep their names, so existing settings such as GH_PR_EVENTS_BEAT_DIR keep working. Uninstall the old plugin: its sessions keep their own daemon, which can hold a repo's forward.

After a laptop sleep, or any network change, the plugin now picks up where it left off. It restarts its forwarders and recovers what each subscribed PR missed from GitHub's API. Before this, every subscription in the repo was silently dropped.

What I found first

A suspend test on a private scratch repo: gh webhook forward with a local receiver, SIGSTOPped for 60-100s, with a PR comment posted mid-stop.

  • The forwarder does not survive. On SIGCONT it passes on the one event left in its socket buffer, then hits websocket: close 1006 (abnormal closure) and exits 1. gh-webhook v0.2.0 has a retry for 1006, but it checks for the raw close error, the error arrives wrapped, and so the retry never runs.
  • GitHub deletes the forwarder's webhook within about 20s of the connection going quiet, and its delivery log goes with it: GET hooks/{id}/deliveries returns 404.

So replaying from the delivery log can't work after a real sleep. This PR catches up from the PR API instead.

What changed

  • Forwarder exits are restarted: retries go 1s, then doubling to 60s. Failures are counted per repo, whoever starts the next attempt, and after 10 in a row (about 6 min) the repo's subscriptions are dropped and their sessions told.

  • Sleep detection: a 5s timer firing more than 30s late restarts every forwarder.

  • Gaps: a forwarder exit, a sleep, or a daemon restart opens a gap for each PR in the repo at the last moment it had everything. Live events that arrive meanwhile, such as during the restarted forwarder's start, don't close it. The gap is persisted, so a daemon restart keeps it.

  • Catch-up runs from the start of the gap once the forwarder is back. It covers:

    • issue events (merged/closed/reopened/draft/labels/review requests/auto-merge/merge queue), read newest page first and stopping once past the gap;
    • comments, review comments, and reviews (dismissed ones skipped);
    • head moves, named after the head commit's author at its commit time, with force-push detection;
    • CI: runs and check suites on the old and new head, plus any workflow run started during the gap.

    Each item goes through the live path, prefixed (recovered after a gap), followed by a mergeability recheck. Each gh api call times out after 60s. After 5 failed attempts the PR's sessions get a channel_error naming the PR, the time it couldn't recover from, and why, and the gap stays open. A catch-up that finishes after the PR fell behind again (a second sleep) leaves the new gap open: a per-PR generation counter is bumped each time it falls behind. At startup the daemon checks gh --version and reports a gh older than 2.48 (which lacks --slurp).

  • No duplicates, no drops: each PR keeps its recently delivered events in sessions.json (key #seen, which a 2.0 daemon skips). They are evicted by age, and saved once per catch-up.

    • Live events are never deduped against each other.
    • A recovered event is skipped if its id was delivered.
    • A recovered PR action is matched to the live one of the same kind closest in time (within 2 min), with each live one standing for one recovered one.
  • Every PR event starts with the PR's name (owner/repo#n: …). Claude Code shows a notification as one line cut at the terminal width, with no markdown or links rendered (checked live: a markdown link shows raw), so the URL was being cut off. The full URL still follows, for Claude.

  • A PR closed without merging stays subscribed for 24h (GH_PR_EVENTS_REOPEN_HOURS) in case it's reopened. Before, closing unsubscribed it, so a reopen went unseen. A reopen clears the window, and so does a merge, which unsubscribes at once as before. If the PR isn't reopened in time, its sessions are told and it's unsubscribed. The window is persisted, so it survives a daemon restart.

  • Clean stop: stop() sets a flag first and clears pending restarts and retries, so no forwarder starts after it begins.

  • README Limits and How it works, plus the invariant in server.ts, updated. Bumped to 2.1.0 in both manifests.

Tests

Ten new daemon tests using the fake gh-webhook and a fake gh that serves API pages from files, including Link paging and a mode that rejects --slurp. Nothing contacts GitHub.

  • after a sleep (SIGSTOPped daemon), eight missed events are recovered once, in order. The issue events span three pages and only the last two are read, the dismissed review is skipped, a run listed twice arrives once, and the same comment arriving live afterwards isn't repeated;
  • a forwarder that exits is restarted and its PR caught up, with the subscription kept;
  • a restarted daemon recovers only what it hadn't delivered, and a third recovers nothing;
  • nine repeated live toggles (review request twice, label added/removed/added, draft/ready twice) all arrive, and a later catch-up recovers only the one action that wasn't live;
  • a 2h gap with a live comment during the start window still recovers the older comment;
  • a catch-up that can't succeed tells the session, both for gh 2.40 and for a gh that rejects --slurp;
  • a daemon SIGTERMed during a restart backoff leaves no forwarder behind;
  • a catch-up held in a slow fake gh while the PR falls behind again leaves the new gap open, so a live event before the next catch-up doesn't hide an older missed comment;
  • a PR closed without merging stays subscribed across a daemon restart and gets its reopen, and a later merge unsubscribes it;
  • a closed PR not reopened within the window is unsubscribed, its session is told, and its forwarder stops.

Test harness: daemon() now waits for a "ready" line the daemon logs once it has loaded sessions.json and installed its signal handlers. It used to wait only for daemon.json, which is written at election, before either. That raced two older tests on macOS, where a SIGTERM with no handler kills even a stopped process.

Mutation checks: with live dedupe, with live events closing the gap, without the stop flag, or without the generation check, the matching test fails.

Limits

The PR API doesn't cover everything a webhook does: dismissed reviews, resolved threads, and the reasons for auto-merge being switched off or a PR leaving the merge queue are not recovered. Neither are check suites from other apps on a head pushed and replaced within the gap. If a dying forwarder hands over a PR action after a catch-up recovered it, it arrives twice (live PR actions are never dropped as repeats). Catching up needs gh 2.48+.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E9K1xesQ7PxPwiV32EYsKm

@brtkwr
brtkwr force-pushed the feat/gh-pr-events-catchup branch 3 times, most recently from 8c53642 to c1ae5a3 Compare October 14, 2026 12:58
… catch up after sleep

Renames the plugin to agentic-utils-github (MCP server pr-events, folder
plugins/github, state dir ~/.claude/state/pr-events, no migration). The
GH_PR_EVENTS_* env vars keep their names.

gh webhook forward exits whenever its connection drops (it never retries a
wrapped close 1006), and GitHub deletes its webhook and delivery log with it,
so a sleep or network change used to drop every subscription in the repo.

The daemon now restarts a forwarder that exits (backing off, giving up after
10 failures in a row per repo), restarts all forwarders when a 5s timer fires
over 30s late, and opens a per-PR gap at the last moment each PR had
everything; live events don't close it, and nor does a catch-up that
finishes after the PR fell behind again. Once a forwarder is back it catches
each PR up from the PR API from the gap's start (issue events, comments,
review comments, reviews, head moves, CI including runs started in the gap),
replayed through the live path and marked as recovered, and tells the PR's
sessions if that keeps failing. Live events are never deduped against each
other; recovered ones are skipped by id, or for PR actions by kind and time.
stop() starts no forwarder once it begins.

Every PR event now starts with the PR's name (owner/repo#n), since Claude
Code cuts notification lines at the terminal width and renders no links. A
PR closed without merging stays subscribed for 24h in case it's reopened.

BREAKING CHANGE: the plugin, MCP server, tool names, channel flag and state
dir are renamed; reinstall and subscribe again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9K1xesQ7PxPwiV32EYsKm
@brtkwr
brtkwr force-pushed the feat/gh-pr-events-catchup branch from c1ae5a3 to cb5000b Compare October 14, 2026 13:28
@brtkwr brtkwr changed the title feat(gh-pr-events): 2.1.0, catch up on events missed while the machine slept feat(github)!: 3.0.0, rename gh-pr-events to agentic-utils-github and catch up after sleep Oct 14, 2026
@brtkwr
brtkwr merged commit c703f09 into main Oct 14, 2026
2 checks passed
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.

1 participant