Files
orca/src/main/github/pr-refresh-coordinator.ts
T
Neilandggbdpq a2f197fde7 Refresh visible reviews automatically and stop settled merged polling (#25788)
Refresh only actual on-screen review cards and the selected visible review panel, with a single metadata owner per execution host. Open reviews refresh every 60 seconds when selected and 120 seconds otherwise. Settled merged reviews stop automatic refresh; pending merged checks continue, hidden rows stop, and work changes or stale re-exposure discover fresh state.

Remove the polling setting. Preserve provider/SSH/runtime ownership, mixed-version cache behavior, failure backoff, bounded foreground admission, request coalescing, and mutation invalidation. Cover older persisted caches and unknown HEADs.

Validated with 1,628 focused tests, typechecking, lint/code-quality gates, hidden Electron viewport checks, and parallel Codex/Claude Opus 5.5 adversarial reviews. Measurements and policy details are in PR #25788.

Fixes #25746

Co-authored-by: ggbdpq <ggbdpq@gmail.com>
2026-10-06 00:53:49 -07:00

227 lines
7.4 KiB
TypeScript

import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import { getPRForBranchOutcome } from './client'
import {
aliasFromCandidate,
hostedReviewOptionArgs,
MANUAL_MERGEABILITY_PENDING_REFRESH_MS,
refreshKey,
shouldBroadcastQueued,
validateCandidate
} from './pr-refresh-candidate-policy'
import {
PRRefreshEventPublisher,
type PRRefreshOutcomeObserver
} from './pr-refresh-event-publisher'
import { PRRefreshPacing } from './pr-refresh-pacing'
import { PRRefreshQueue } from './pr-refresh-queue'
import { PRRefreshQueueDrainer } from './pr-refresh-queue-drainer'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import { PRRefreshRetryState } from './pr-refresh-retry-state'
import { PRRefreshVisibility } from './pr-refresh-visibility'
const retry = new PRRefreshRetryState()
const queue = new PRRefreshQueue((key) => retry.reset(key))
const pacing = new PRRefreshPacing()
const visibility = new PRRefreshVisibility()
const events = new PRRefreshEventPublisher()
const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events)
export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void {
events.setOutcomeObserver(observer)
}
function removeInvisibleVisibleRefreshes(): void {
const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key))
for (const entry of removed) {
events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: 'visible',
status: 'skipped',
skippedReason: 'fresh'
})
}
}
export function clearVisiblePRRefreshWindow(windowId: number): void {
const hadVisibleRefreshes = visibility.clearWindow(windowId)
pacing.clearActiveBurstWindow(windowId)
if (hadVisibleRefreshes) {
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
drainer.schedule()
}
}
export function pruneWorktreePRRefreshAliases(worktreeId: string): void {
queue.pruneWorktreeAliases(worktreeId)
}
export function enqueuePRRefresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason,
priority = 0,
windowId?: number
): void {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
events.record('skipped', reason, skippedReason)
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return
}
const enqueued = queue.enqueue(candidate, reason, priority, windowId)
const pending = queue.get(key)
if (pending && reason === 'visible' && candidate.isSelected && priority >= 80) {
// Foreground exposure keeps retry gates while bypassing the list budget once.
pending.bypassBackgroundBudget = true
}
events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason)
if (shouldBroadcastQueued(reason, enqueued.dueAt)) {
events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' })
}
drainer.schedule()
}
export function reportVisiblePRRefreshCandidates(
candidates: GitHubPRRefreshCandidate[],
generation: number,
windowId: number
): void {
const newlyExposed = new Set(candidates.map(refreshKey).filter((key) => !visibility.has(key)))
if (!visibility.report(candidates, generation, windowId)) {
return
}
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
for (const candidate of candidates) {
const key = refreshKey(candidate)
if (validateCandidate(candidate)) {
enqueuePRRefresh(candidate, 'visible', 40, windowId)
continue
}
queue.enqueue(
visibility.candidate(key, candidate),
'visible',
40,
windowId,
newlyExposed.has(key)
)
}
drainer.schedule()
}
export function _getVisiblePRRefreshWindowCountForTests(): number {
return visibility.windowCount
}
export function _getPRRefreshErrorBackoffCountForTests(): number {
return retry.errorBackoffCount
}
export function _getPRRefreshQueueSizeForTests(): number {
return queue.size
}
export function _getPRRefreshAliasCountForTests(key: string): number {
return queue.aliasCount(key)
}
export async function refreshPRNow(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason = 'manual'
): Promise<PRRefreshOutcome> {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = queue.get(key)
const aliasMap = new Map(existing ? existing.aliases : [])
aliasMap.set(alias.cacheKey, alias)
const aliases = Array.from(aliasMap.values())
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
const outcome: PRRefreshOutcome = {
kind: 'upstream-error',
errorType: 'unknown',
message: `Cannot refresh PR for this worktree: ${skippedReason}`,
fetchedAt: Date.now()
}
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return outcome
}
const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false)
const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key))
if (gateUntil > Date.now()) {
queue.protectBackgroundUntil(key, gateUntil)
queue.set(key, {
key,
candidate,
aliases: aliasMap,
reason,
priority: 40,
dueAt: gateUntil,
queuedAt: queue.nextOrder()
})
events.broadcast({
aliases,
reason,
status: 'paused',
pausedUntil: gateUntil,
skippedReason: 'rate-limit'
})
drainer.schedule(Math.max(1_000, gateUntil - Date.now()))
return {
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'GitHub is temporarily limiting requests. Try again after the limit resets.',
fetchedAt: Date.now(),
nextAutoRetryAt: gateUntil,
retryDisabledUntil: gateUntil
}
}
queue.delete(key)
const requestSequence = events.nextSequence()
const requestStartedAt = Date.now()
queue.noteRequestStarted(key, requestSequence)
events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence)
const outcome = await getPRForBranchOutcome(
candidate.repoPath,
candidate.branch,
candidate.linkedPRNumber ?? null,
candidate.connectionId ?? null,
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate, reason)
)
if (!queue.ownsRequest(key, requestSequence)) {
events.broadcast({ aliases, reason, outcome, requestStartedAt }, requestSequence)
return outcome
}
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
if (outcome.kind === 'upstream-error' && visibility.has(key)) {
plannedRetryAt = retry.nextVisibleErrorRetryAt(key, visibility.candidate(key, candidate))
broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt)
}
events.observe(candidate, outcome)
retry.noteManualGate(key, broadcastOutcome)
events.broadcast(
{ aliases, reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, {
plannedRetryAt,
...(reason === 'manual'
? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }
: {})
})
return broadcastOutcome
}