feat(desktop): background PR status polling with priority tiers#995
Open
huntharo wants to merge 4 commits into
Open
feat(desktop): background PR status polling with priority tiers#995huntharo wants to merge 4 commits into
huntharo wants to merge 4 commits into
Conversation
huntharo
force-pushed
the
claude/pr-status-polling-5cb088
branch
from
July 15, 2026 02:08
2301039 to
93b6d54
Compare
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
force-pushed
the
claude/pr-status-polling-5cb088
branch
from
July 23, 2026 06:32
93b6d54 to
7f23c94
Compare
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viagh 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. TheghCLI can't do this — it's one subprocess per branch.The change reuses PwrAgent's existing
PrSummarymodel, sqlite caches,prStatusRegistry,PrStatusTokenBucket, terminal short-circuit, and thepullRequest/status/updatedpush bus — it adds coverage/cadence/transport, not a parallel data model.Built in four phases:
github-graphql-client.ts: batched aliased query (owner/name/number passed as GraphQL variables, injection-safe), hand-rolled backoff honoringretry-after/x-ratelimit-reset/ 5xx (deliberately avoids the octokit retry/throttling plugins, which drag inbottleneck), and partial-data salvage so one dead repo can't blank a batch. Derivations extracted topr-derivations.tsso theghand GraphQL transports can't drift;github-pr-fetcher.tsre-exports them so existing imports/tests are unchanged.pr-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 noAppServerServicereference, so it's unit-testable without booting the app.pollingFocusIPC: 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.pr-transitions.ts: a typedPrStatusTransitionemitted from the single diff chokepoint (rememberPrStatuses, covering both the poller and on-demand refreshes) whenevercheckState/lifecycleState/mergeState/reviewState/titleflips, plus anonPrStatusTransition()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.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. Restores the periodic cross-repo refresh that was lost.Verification
github-graphql-client,pr-polling-scheduler,pr-transitions,pr-discovery.lint:boundaries(clean, no octokit across the renderer/leaf boundary), and the Electron main-process build all pass.ghtoken over 4 PRs spanning 3 unrelated orgs —HTTP requests to GitHub: 1, 4 PRs returned. Queryingsindresorhus/p-map#88correctly resolved identity tohong4rc/p-map#88, validating the fork-PR base-vs-head handling.Notes
Three decisions a reviewer would otherwise have to reverse-engineer:
PrSummary.org/repo(and the status key) track the head repo, matching whatghwrites. For fork PRs these differ, so the client parses the base ref from the PR URL to query and readsheadRepository*from the response to build identity. Getting this wrong would have silently duplicated every fork PR.sincecrawl (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. Asince-crawl remains the right tool only for thread-independent discovery (e.g. an all-org dashboard), noted in the plan.gh-discovery/settings-singletonbecause 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