mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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>
227 lines
7.4 KiB
TypeScript
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
|
|
}
|