Skip to content

feat(desktop): background PR status polling with priority tiers#995

Open
huntharo wants to merge 4 commits into
mainfrom
claude/pr-status-polling-5cb088
Open

feat(desktop): background PR status polling with priority tiers#995
huntharo wants to merge 4 commits into
mainfrom
claude/pr-status-polling-5cb088

Conversation

@huntharo

Copy link
Copy Markdown
Contributor

Summary

Keep every open project's pull-request status reasonably fresh, instead of refreshing only the selected/hovered thread. Before this, the sole recurring PR refresh was a renderer interval scoped to the selected thread, so the other 20–30 open projects showed stale chips ("a snapshot from the last time I moused over that PR 17 hours ago") — CI pass/fail, merges, and new merge conflicts on unfocused projects were invisible until interacted with.

A main-process poller now fetches many PRs across disparate repos in one aliased GraphQL request (in-process @octokit/graphql, token minted via gh auth token) on an attention-weighted cadence. One request of ~40 PRs across arbitrary repos costs ~1 rate-limit point (GraphQL's per-request floor), which is what makes minute-level polling of many projects affordable. The gh CLI can't do this — it's one subprocess per branch.

The change reuses PwrAgent's existing PrSummary model, sqlite caches, prStatusRegistry, PrStatusTokenBucket, terminal short-circuit, and the pullRequest/status/updated push bus — it adds coverage/cadence/transport, not a parallel data model.

Built in four phases:

  • Phase 0github-graphql-client.ts: batched aliased query (owner/name/number passed as GraphQL variables, injection-safe), hand-rolled backoff honoring retry-after / x-ratelimit-reset / 5xx (deliberately avoids the octokit retry/throttling plugins, which drag in bottleneck), and partial-data salvage so one dead repo can't blank a batch. Derivations extracted to pr-derivations.ts so the gh and GraphQL transports can't drift; github-pr-fetcher.ts re-exports them so existing imports/tests are unchanged.
  • Phase 1pr-polling-scheduler.ts: Focused (60s) / Warm (150s) / Cold (300s) tiers, round-robin by oldest-polled, terminal-PR drop, ×4 cadence stretch when the window is hidden. One token-bucket slot per request (not per PR). Fully dependency-injected — holds no AppServerService reference, so it's unit-testable without booting the app.
  • Phase 2pollingFocus IPC: the renderer tells main which thread is selected (main has no other way to know — selection is renderer route state) so its PRs land on the fast tier.
  • Phase 3pr-transitions.ts: a typed PrStatusTransition emitted from the single diff chokepoint (rememberPrStatuses, covering both the poller and on-demand refreshes) whenever checkState / lifecycleState / mergeState / reviewState / title flips, plus an onPrStatusTransition() subscriber seam. This is the webhook-ready hook for a future "CI failed → start a turn" ingestor. Logs today; no turn-starting, no thread events in this PR.
  • Phase 4pr-discovery.ts: a slow branch-lookup rotation across all open threads to surface newly-opened PRs on unfocused projects, reusing the existing branch-based attach/cache/coalesce path. Restores the periodic cross-repo refresh that was lost.

Verification

  • Full workspace suite: 4657/4657 tests, 365/365 files on the final run.
  • 73 new unit tests across 4 suites: github-graphql-client, pr-polling-scheduler, pr-transitions, pr-discovery.
  • Typecheck, ESLint (0 errors), lint:boundaries (clean, no octokit across the renderer/leaf boundary), and the Electron main-process build all pass.
  • Live GitHub API check: ran the real client against the operator's gh token over 4 PRs spanning 3 unrelated orgsHTTP requests to GitHub: 1, 4 PRs returned. Querying sindresorhus/p-map#88 correctly resolved identity to hong4rc/p-map#88, validating the fork-PR base-vs-head handling.

Notes

Three decisions a reviewer would otherwise have to reverse-engineer:

  • Query by base repo, identify by head repo. A PR number belongs to the repo it was opened against (base), but PrSummary.org/repo (and the status key) track the head repo, matching what gh writes. For fork PRs these differ, so the client parses the base ref from the PR URL to query and reads headRepository* from the response to build identity. Getting this wrong would have silently duplicated every fork PR.
  • Phase 4 uses a branch-lookup rotation, not a REST since crawl (a deviation from the plan). PwrAgent attaches PRs to threads by branch, so a "list PRs updated since T" crawl finds PRs with nowhere to attach. The branch lookup asks the question that maps to the UI and reuses the entire existing attach path — no new dependency, no schema migration, no git-remote parsing. A since-crawl remains the right tool only for thread-independent discovery (e.g. an all-org dashboard), noted in the plan.
  • The renderer's 60s selected-thread interval was kept, not folded in. It re-runs a branch lookup, which is the only thing that discovers a PR newly opened on the selected branch — the poller only refreshes PRs it already knows by number.
  • Build warning (benign): Rollup notes it can't code-split the lazily-imported gh-discovery / settings-singleton because other modules static-import them. The lazy import is kept deliberately — it's what lets the GraphQL client load under plain-node vitest without dragging in Electron.

Design docs: docs/brainstorms/2026-07-13-pr-status-polling-and-ci-events-requirements.md, docs/plans/2026-07-13-001-feat-pr-status-polling-plan.md.

🤖 Generated with Claude Code

@huntharo
huntharo force-pushed the claude/pr-status-polling-5cb088 branch from 2301039 to 93b6d54 Compare July 15, 2026 02:08
huntharo and others added 3 commits July 22, 2026 23:19
Keep every open project's pull-request status reasonably fresh instead of
refreshing only the selected/hovered thread. Previously the sole recurring
refresh was a renderer interval scoped to the selected thread, so the other
20-30 open projects showed stale chips until moused over.

A main-process poller now fetches many PRs across disparate repos in ONE
aliased GraphQL request (in-process @octokit/graphql, token via `gh auth
token`) on an attention-weighted cadence: focused project fast, the rest
slower, round-robin, bounded by the existing PR token bucket. One request of
~40 PRs across arbitrary repos costs ~1 rate-limit point, which is what makes
minute-level polling of many projects affordable — the `gh` CLI cannot batch
cross-repo.

- Phase 0: `github-graphql-client.ts` (batched aliased query, injection-safe
  variables, hand-rolled backoff honoring retry-after / x-ratelimit-reset,
  partial-data salvage). Derivations extracted to `pr-derivations.ts` so the
  `gh` and GraphQL transports cannot drift; `github-pr-fetcher.ts` re-exports
  them for existing imports.
- Phase 1: `pr-polling-scheduler.ts` — Focused/Warm/Cold tiers, oldest-polled
  round-robin, terminal-PR drop, hidden-window backoff. Wired into
  AppServerService, reusing the registries, caches, token bucket, and the
  existing `pullRequest/status/updated` push bus. Publishes deltas only.
- Phase 2: `pollingFocus` IPC so the renderer tells main which thread is
  selected (main has no other way to know — selection is renderer route state).
- Phase 3: `pr-transitions.ts` — a typed `PrStatusTransition` emitted from the
  single diff chokepoint whenever check/lifecycle/merge/review/title flips,
  with an `onPrStatusTransition()` subscriber seam shaped webhook-ready for a
  future CI-event-into-threads ingestor. Logs today; no turn-starting.
- Phase 4: `pr-discovery.ts` — a slow branch-lookup rotation across ALL open
  threads to surface newly-opened PRs on unfocused projects, reusing the
  existing branch-based attach/cache/coalesce path (PwrAgent attaches PRs to
  threads by branch, so a REST `since` crawl would find PRs with nowhere to
  attach). Restores the periodic cross-repo refresh that was lost.

Design captured in docs/brainstorms + docs/plans. GitHub I/O and scheduling
stay in the main process; packages/shared stays types-only; no octokit crosses
the renderer/leaf boundary. Verified against the live GitHub API (one request,
four PRs, three orgs) and by 73 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding @octokit/graphql pulled in new MIT-licensed transitive deps
(@octokit/{graphql,request,endpoint,request-error,types,openapi-types},
universal-user-agent). Refresh the aggregated notice so `licenses:check`
passes in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…x tier

Two follow-ups from review.

**Experimental gate.** Background PR polling now ships opt-in behind
Settings -> Experimental -> "Background Pull Request Status"
(`[experimental] background_pr_polling`), following the
`lightweightNavigationRefresh` pattern end to end. Default is OFF: with the
flag off, PR chips behave exactly as they did before the poller existed, so
this is a true kill switch rather than a tuning knob. `syncPrPollingSchedulerState`
runs on every navigation snapshot, so toggling starts/stops both the poller and
the discovery rotation live with no restart.

**Icebox tier.** A PR whose status has not changed AND whose threads have not
been touched for 24h now falls off the monitor list entirely - no cadence, no
budget, no request. This is deliberately self-latching: an iceboxed PR is never
polled, so its `lastChangedAt` cannot advance on its own, and it stays frozen
until the operator interacts with one of its threads. A branch abandoned two
weeks ago costs nothing, forever, until you look at it again.

Two independent thaw signals keep an active PR from being frozen out:
- `lastChangedAt` - the PR itself moved.
- `lastInteractionAt` - a thread was touched. Sampled from focus on EVERY tick
  (not just on change), so sitting on a thread for days cannot let it age into
  the icebox underneath you, plus an explicit `noteThreadInteraction()` from the
  `turn/completed` path so a turn finishing in an off-screen thread thaws it.

Focus always wins outright over the icebox, which is what makes a 24h threshold
safe to be aggressive about.

Discovery (Layer B) is not icebox-aware yet; it keeps its own slow rotation.
Noted as a follow-up in the plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@huntharo
huntharo force-pushed the claude/pr-status-polling-5cb088 branch from 93b6d54 to 7f23c94 Compare July 23, 2026 06:32
… calls

Discovery was the odd one out. Status polling moved in-process to batched
GraphQL, but the discovery rotation reused `refreshThreadPullRequests`, which
bottoms out in `gh pr list --head <branch>` - one subprocess per branch. That
got the PR->thread attachment for free but left the transport inconsistent.

Discovery now answers every due branch in ONE batched in-process request:

- `pr-status/git-remote.ts` - `parseGitHubRemote` + cached
  `resolveGitHubRepoForDirectory`. This is the one capability the subprocess
  path had for free: `gh` infers the repo from the cwd's remote, but
  `repository(owner:, name:)` must be told. Negative results are cached so a
  non-GitHub checkout does not re-shell every sweep.
- `buildBranchPrQuery` / `fetchPullRequestsForBranches` - PwrGit's
  `pullRequests(headRefName:)` pattern, with the `repository()` level aliased
  too so branches from different repos share one request (PwrGit batches only
  within a single repo). `first: 5` matches `gh pr list --limit 5`.
- `GithubPrFetcher.primeBranchLookup` - discovery primes the fetcher with the
  batched answers and the existing refresh path consumes them instead of
  spawning `gh`. PR->thread attachment logic is untouched, which is what made
  this safe to slot in.

Load-bearing invariant: `fetchPullRequestsForBranches` returns a key ONLY when
GitHub actually answered. Present-but-empty means an authoritative "no PRs";
an ABSENT key means "we don't know" and the caller falls back to `gh`.
Conflating the two would let one failed request blank out every chip. Primed
entries are single-use and short-TTL, so a later user-triggered refresh always
gets its own fresh read rather than silently reusing a prefetch.

Verified against the live API: one HTTP request covering four branches across
two orgs, with correct authoritative-empty answers for branches with no PR.

Still subprocess-based, noted in the plan: `readDefaultBranchInfo`'s git calls
per cwd (local, cheap), retained-PR `gh pr view` (already covered by the
by-number poller), and the pre-existing on-selection/hover/post-turn path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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