perf(agent-manager): reduce GitHub API usage in PR status polling (#8582)

Poll only the active worktree on each 15s timer tick instead of all
worktrees. Inactive worktrees refresh when selected or on manual
refresh. On first poll and visibility resume, all worktrees are fetched
once to populate badges.

Add a 60s TTL cache on PR lookup results so repeated polls skip the
expensive multi-strategy gh pr view cascade and only re-fetch checks
and comments (the fast-changing data).

With 10 worktrees this reduces API calls from ~5,000-9,800/hr to
~480-720/hr, staying well within GitHub's 5,000/hr rate limit.
This commit is contained in:
Marius
2026-04-08 13:49:09 +02:00
committed by GitHub
parent 242c167171
commit c292de9d37
@@ -14,6 +14,7 @@ interface PRStatusPollerOptions {
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
const BACKOFF_MULTIPLIER = 2
const PR_LOOKUP_TTL = 60_000 // 1 minute — PR number for a branch rarely changes
export class PRStatusPoller {
private timer: ReturnType<typeof setTimeout> | undefined
@@ -27,6 +28,7 @@ export class PRStatusPoller {
private ghProbeTime = 0
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private readonly intervalMs: number
constructor(private readonly options: PRStatusPollerOptions) {
@@ -48,9 +50,12 @@ export class PRStatusPoller {
this.visible = visible
if (!this.active) return
if (visible) {
// Resume — poll immediately then schedule normally
// Resume — expire all PR caches and fetch all worktrees once to catch up,
// then resume the normal active-only poll cycle.
if (this.timer) clearTimeout(this.timer)
this.timer = undefined
this.prCache.clear()
this.lastHash.clear()
void this.poll()
return
}
@@ -74,16 +79,22 @@ export class PRStatusPoller {
this.ghAvailable = undefined
this.ghProbeTime = 0
this.cachedRepo = undefined
this.prCache.clear()
}
/** Force-refresh a specific worktree immediately. */
/** Force-refresh a specific worktree immediately, bypassing the PR cache. */
refresh(worktreeId: string): void {
if (!this.active) return
this.prCache.delete(worktreeId)
void this.fetchOne(worktreeId)
}
setActiveWorktreeId(id: string | undefined): void {
const prev = this.activeWorktreeId
this.activeWorktreeId = id
// When switching to a different worktree, fetch it immediately so the
// badge updates without waiting for the next poll cycle.
if (id && id !== prev && this.active) void this.fetchOne(id)
}
private start(): void {
@@ -146,8 +157,20 @@ export class PRStatusPoller {
}
this.lastError = undefined
// Only poll the active worktree on each timer tick. Inactive worktrees
// refresh when selected (setActiveWorktreeId) or manually (refresh()).
// On first poll (lastHash empty) fetch all worktrees once to populate badges.
const worktrees = this.options.getWorktrees()
const results = await Promise.allSettled(worktrees.map((wt) => this.fetchOne(wt.id)))
const initial = this.lastHash.size === 0
const targets = initial ? worktrees : worktrees.filter((wt) => wt.id === this.activeWorktreeId)
if (targets.length === 0) {
this.failures = 0
return
}
const results = await Promise.allSettled(targets.map((wt) => this.fetchOne(wt.id)))
const ok = results.every((r) => r.status === "fulfilled")
if (ok) {
this.failures = 0
@@ -164,7 +187,7 @@ export class PRStatusPoller {
if (!this.options.getWorkspaceRoot()) return
try {
const pr = await this.fetchPRForBranch(wt.branch, wt.path)
const pr = await this.cachedFetchPR(worktreeId, wt.branch, wt.path)
if (!pr) {
const hash = `${worktreeId}:none`
if (this.lastHash.get(worktreeId) === hash) return
@@ -217,6 +240,15 @@ export class PRStatusPoller {
private static readonly PR_JSON_FIELDS =
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
/** Return a cached PR lookup if still fresh, otherwise fetch and cache. */
private async cachedFetchPR(worktreeId: string, branch: string, cwd: string): Promise<PRResult | null> {
const cached = this.prCache.get(worktreeId)
if (cached && Date.now() < cached.expires) return cached.result
const result = await this.fetchPRForBranch(branch, cwd)
this.prCache.set(worktreeId, { result, expires: Date.now() + PR_LOOKUP_TTL })
return result
}
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).