Files
orca/src/main/github/pr-refresh-coordinator.ts
T
Neil 3929371709 fix(wsl): background repo probes, PR refresh and worktree scans never start a stopped distro (E9) (#34632)
* fix(wsl): background repo probes, PR refresh and worktree scans never start a stopped distro (E9)

Any Windows touch of a \\wsl.localhost path, or Git/gh run for it, boots a
stopped distro. These background paths ran without asking the stopped-distro
gate: the worktree scan refresh (Git-admin fingerprint reads plus git worktree
list), the remote-identity and username enrichment sweeps, the startup
upstream backfill, the quiet-period ref maintenance, and queued PR refreshes.
Each now asks blockedWslDistroForPath first and skips, keeping known data,
until the distro runs; a host path never asks the gate.

* fix(wsl): a user act's wake allowance ends with the act

The allowance lived in an AsyncLocalStorage store, so any timer, listener or
queue created during a user act (opening a workspace, a wake read, the first
git status) kept it forever and could start the distro later, after a
terminate. The scope now carries an active flag cleared when the act settles.

* fix(wsl): skill discovery and automatic refreshPRNow never start a stopped distro (E9)

- Skill discovery (mounted by passive UI) leaves out roots on a stopped or
  unverifiable distro, and skips plugin discovery for such a cwd, instead of
  reading \\wsl.localhost; they are not reported missing, so the next scan
  picks them up.
- refreshPRNow skips automatic reasons (visible, active, post-push) on a
  blocked distro like the drainer does; only a manual refresh may start it.
- Ref maintenance's opt-out probe takes the same sync wslDistroForPath
  early-out as the other paths.
- Tests: stopped-with-no-cache worktree scans answer from stored rows, and
  'unverifiable' is treated as blocked.

* fix(wsl): skill discovery for a WSL target never starts a stopped distro (E9)

Passive UI discovers skills for projects whose agents run in WSL. The target
resolution probed the distro home (wsl.exe) and the scan ran in the distro,
so either booted a stopped one. Discovery now refuses a stopped or
unverifiable WSL target before the home probe and answers no skills and no
sources, so nothing reads as missing and the next scan retries. Delete and
share keep resolving without the gate.

* fix(wsl): a settled user act marks its distro woken; host skill scans never await the gate

- runAsWslUserAct (the gate's wrapper) notes the distro woken inside the act
  when it settles, success or throw, so a follow-up is never answered by a
  'stopped' read taken before the act.
- Skill discovery takes sync early-outs: a host-only root set and a host cwd
  never await the WSL gate.

* fix(wsl): Share discovers as a user act; a stopped distro's skills read as unknown, not absent

- skills:prepareShare (and the skills.share RPC) discover as a user act on the
  target's distro, so an explicit Share on a stopped distro finds its skills.
- A blocked WSL target answers an 'unavailable' source per requested kind, and
  roots (or a workspace plugin folder) on a stopped distro are reported
  'unavailable' instead of dropped, so the installed-skill verdict says the
  status may be incomplete rather than offering Install.

* test(wsl): use a fixed fetchedAt in the stopped-distro PR refresh test

* fix(wsl): Share scans stopped distros' skill folders, with or without a target

The desktop Share dialog sends no target, so the user-act scope had no distro
and the gated scan reported a stopped distro's repo folders unavailable; Share
then failed with skill-share-source-not-found. discoverSkills takes
gateStoppedWslDistros: false for both share paths (IPC and RPC), skipping the
stopped-root split and the plugin-cwd check; passive discovery keeps the gate.
Gated and ungated scans of one target no longer share a coalesced answer.
2026-10-27 18:56:02 -07:00

248 lines
8.3 KiB
TypeScript

import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import { getPRForBranchOutcome } from './client'
import {
aliasFromCandidate,
hostedReviewOptionArgs,
isBackground,
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, type PRRefreshRegistrationCheck } 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'
import { blockedWslDistroForPath, wslDistroForPath } from '../wsl/wsl-unc-access-gate'
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)
}
export function setPRRefreshRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
drainer.setRegistrationCheck(check)
}
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
}
// E9: renderer fallbacks reach here with automatic reasons; only a manual refresh may start a stopped distro.
if (
isBackground(reason) &&
wslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro) &&
(await blockedWslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro))
) {
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason: 'fresh' })
// Local callers keep their cached PR on an upstream error; nothing is recorded or retried here.
return {
kind: 'upstream-error',
errorType: 'unknown',
message: 'WSL distro is not running',
fetchedAt: Date.now()
}
}
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
}