mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* 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.
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import type {
|
|
GitHubPRRefreshAlias,
|
|
GitHubPRRefreshCandidate,
|
|
PRRefreshOutcome
|
|
} from '../../shared/github/pull-request-refresh-types'
|
|
import { getPRForBranchOutcome } from './client'
|
|
import {
|
|
aliasFromCandidate,
|
|
freshRetryAt,
|
|
hostedReviewOptionArgs,
|
|
isBackground,
|
|
isMergeabilityPendingOutcome,
|
|
sameAliasRequestIdentity,
|
|
validateCandidate,
|
|
visibleCandidateAfterOutcome
|
|
} from './pr-refresh-candidate-policy'
|
|
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
|
|
import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing'
|
|
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
|
|
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
|
|
import { blockedWslDistroForPath, wslDistroForPath } from '../wsl/wsl-unc-access-gate'
|
|
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
|
|
import type { PRRefreshVisibility } from './pr-refresh-visibility'
|
|
|
|
export type PRRefreshRegistrationCheck = (candidate: GitHubPRRefreshCandidate) => boolean
|
|
|
|
export class PRRefreshQueueDrainer {
|
|
private draining = false
|
|
private timer: ReturnType<typeof setTimeout> | null = null
|
|
// Why: queued work can outlive its project; re-check on every removal path before Git runs.
|
|
private registrationCheck: PRRefreshRegistrationCheck | null = null
|
|
|
|
constructor(
|
|
private readonly queue: PRRefreshQueue,
|
|
private readonly pacing: PRRefreshPacing,
|
|
private readonly visibility: PRRefreshVisibility,
|
|
private readonly retry: PRRefreshRetryState,
|
|
private readonly events: PRRefreshEventPublisher
|
|
) {}
|
|
|
|
setRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
|
|
this.registrationCheck = check
|
|
}
|
|
|
|
schedule(delay = 0): void {
|
|
if (this.timer) {
|
|
clearTimeout(this.timer)
|
|
}
|
|
this.timer = setTimeout(() => {
|
|
this.timer = null
|
|
void this.drain()
|
|
}, delay)
|
|
}
|
|
|
|
scheduleVisibleFollowUp(
|
|
key: string,
|
|
candidate: GitHubPRRefreshCandidate,
|
|
outcome: PRRefreshOutcome,
|
|
priority: number,
|
|
aliases: GitHubPRRefreshAlias[],
|
|
windowId?: number,
|
|
options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number }
|
|
): void {
|
|
if (outcome.kind === 'upstream-error') {
|
|
const retryAt = Math.max(
|
|
options?.plannedRetryAt ??
|
|
this.retry.nextVisibleErrorRetryAt(key, this.visibility.candidate(key, candidate)),
|
|
this.retry.manualGateUntil(key)
|
|
)
|
|
this.queue.protectBackgroundUntil(key, retryAt)
|
|
if (!this.visibility.has(key)) {
|
|
return
|
|
}
|
|
this.queue.setVisibleFollowUp({
|
|
key,
|
|
candidate,
|
|
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
|
|
reason: 'visible',
|
|
priority,
|
|
dueAt: retryAt,
|
|
queuedAt: this.queue.nextOrder(),
|
|
windowId
|
|
})
|
|
this.schedule(retryAt - Date.now())
|
|
return
|
|
}
|
|
this.retry.reset(key)
|
|
const refreshed = visibleCandidateAfterOutcome(candidate, outcome)
|
|
this.visibility.update(refreshed)
|
|
if (!this.visibility.has(key)) {
|
|
return
|
|
}
|
|
const followUpCandidate = this.visibility.candidate(key, refreshed)
|
|
const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now()
|
|
const pendingDueAt =
|
|
options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome)
|
|
? outcome.fetchedAt + options.pendingMergeabilityDelayMs
|
|
: null
|
|
const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt)
|
|
if (!Number.isFinite(dueAt)) {
|
|
const pending = this.queue.get(key)
|
|
if (
|
|
pending?.reason === 'visible' &&
|
|
sameAliasRequestIdentity(
|
|
aliasFromCandidate(pending.candidate),
|
|
aliasFromCandidate(followUpCandidate)
|
|
)
|
|
) {
|
|
this.queue.delete(key)
|
|
}
|
|
return
|
|
}
|
|
this.queue.setVisibleFollowUp({
|
|
key,
|
|
candidate: followUpCandidate,
|
|
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
|
|
reason: 'visible',
|
|
priority,
|
|
dueAt,
|
|
queuedAt: this.queue.nextOrder(),
|
|
bypassBackgroundBudget: pendingDueAt !== null,
|
|
followUp: true,
|
|
windowId
|
|
})
|
|
this.schedule(Math.max(0, dueAt - Date.now()))
|
|
}
|
|
|
|
private ordered(): PRRefreshQueueEntry[] {
|
|
return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b))
|
|
}
|
|
|
|
private nextQueuedWakeDelay(excludedKey: string): number | null {
|
|
const now = Date.now()
|
|
let nextDelay = Number.POSITIVE_INFINITY
|
|
for (const entry of this.queue.values()) {
|
|
if (entry.key === excludedKey) {
|
|
continue
|
|
}
|
|
const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry)
|
|
nextDelay = Math.min(nextDelay, delay)
|
|
}
|
|
return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null
|
|
}
|
|
|
|
private async drain(): Promise<void> {
|
|
if (this.draining) {
|
|
return
|
|
}
|
|
this.draining = true
|
|
try {
|
|
while (this.queue.size > 0) {
|
|
let next = this.ordered()[0]
|
|
const waitMs = next.dueAt - Date.now()
|
|
if (waitMs > 0) {
|
|
this.schedule(waitMs)
|
|
return
|
|
}
|
|
|
|
let delay = this.pacing.entryDelay(next)
|
|
if (delay > 0) {
|
|
const runnable = this.ordered().find(
|
|
(entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0
|
|
)
|
|
if (runnable && runnable.key !== next.key) {
|
|
next = runnable
|
|
delay = 0
|
|
} else {
|
|
this.notePacingDelay(next)
|
|
this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay))
|
|
return
|
|
}
|
|
}
|
|
|
|
this.queue.delete(next.key)
|
|
const aliases = Array.from(next.aliases.values())
|
|
const skippedReason = validateCandidate(next.candidate)
|
|
if (skippedReason) {
|
|
this.events.record('skipped', next.reason, skippedReason)
|
|
this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason })
|
|
continue
|
|
}
|
|
if (
|
|
!(this.registrationCheck?.(next.candidate) ?? true) ||
|
|
(next.reason === 'visible' && !this.visibility.has(next.key)) ||
|
|
// E9: gh/Git in a stopped distro's repo boots it; a queued refresh keeps the current status.
|
|
(isBackground(next.reason) &&
|
|
wslDistroForPath(next.candidate.repoPath, next.candidate.localGitOptions?.wslDistro) &&
|
|
(await blockedWslDistroForPath(
|
|
next.candidate.repoPath,
|
|
next.candidate.localGitOptions?.wslDistro
|
|
)))
|
|
) {
|
|
this.events.broadcast({
|
|
aliases,
|
|
reason: next.reason,
|
|
status: 'skipped',
|
|
skippedReason: 'fresh'
|
|
})
|
|
continue
|
|
}
|
|
const requestSequence = this.events.nextSequence()
|
|
const requestStartedAt = Date.now()
|
|
this.queue.noteRequestStarted(next.key, requestSequence)
|
|
this.events.broadcast(
|
|
{ aliases, reason: next.reason, status: 'in-flight', requestStartedAt },
|
|
requestSequence
|
|
)
|
|
|
|
if (isBackground(next.reason)) {
|
|
const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true)
|
|
if (!this.queue.ownsRequest(next.key, requestSequence)) {
|
|
continue
|
|
}
|
|
if (pausedUntil !== null) {
|
|
this.queue.protectBackgroundUntil(next.key, pausedUntil)
|
|
this.queue.set(next.key, { ...next, dueAt: pausedUntil })
|
|
this.events.broadcast({
|
|
aliases,
|
|
reason: next.reason,
|
|
status: 'paused',
|
|
pausedUntil,
|
|
skippedReason: 'rate-limit'
|
|
})
|
|
this.schedule(Math.max(1_000, pausedUntil - Date.now()))
|
|
continue
|
|
}
|
|
if (
|
|
next.bypassBackgroundBudget !== true &&
|
|
(next.reason === 'visible' || next.reason === 'swr')
|
|
) {
|
|
this.pacing.noteBackgroundStart()
|
|
}
|
|
if (usesActiveRefreshPacing(next)) {
|
|
this.pacing.noteActiveStart(next)
|
|
}
|
|
}
|
|
|
|
const outcome = await getPRForBranchOutcome(
|
|
next.candidate.repoPath,
|
|
next.candidate.branch,
|
|
next.candidate.linkedPRNumber ?? null,
|
|
next.candidate.connectionId ?? null,
|
|
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
|
|
...hostedReviewOptionArgs(next.candidate, next.reason)
|
|
)
|
|
if (!this.queue.ownsRequest(next.key, requestSequence)) {
|
|
this.events.broadcast(
|
|
{ aliases, reason: next.reason, outcome, requestStartedAt },
|
|
requestSequence
|
|
)
|
|
continue
|
|
}
|
|
let plannedRetryAt: number | undefined
|
|
let broadcastOutcome = outcome
|
|
if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) {
|
|
plannedRetryAt = this.retry.nextVisibleErrorRetryAt(
|
|
next.key,
|
|
this.visibility.candidate(next.key, next.candidate)
|
|
)
|
|
broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt)
|
|
}
|
|
this.events.observe(next.candidate, outcome)
|
|
this.retry.noteManualGate(next.key, broadcastOutcome)
|
|
this.events.broadcast(
|
|
{ aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt },
|
|
requestSequence
|
|
)
|
|
this.scheduleVisibleFollowUp(
|
|
next.key,
|
|
next.candidate,
|
|
outcome,
|
|
next.priority,
|
|
aliases,
|
|
next.windowId,
|
|
{ plannedRetryAt }
|
|
)
|
|
}
|
|
} finally {
|
|
this.draining = false
|
|
}
|
|
}
|
|
|
|
private notePacingDelay(entry: PRRefreshQueueEntry): void {
|
|
if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) {
|
|
entry.activeDelayNotified = true
|
|
this.events.broadcast({
|
|
aliases: Array.from(entry.aliases.values()),
|
|
reason: entry.reason,
|
|
status: 'queued'
|
|
})
|
|
}
|
|
if (
|
|
entry.bypassBackgroundBudget !== true &&
|
|
(entry.reason === 'visible' || entry.reason === 'swr') &&
|
|
this.pacing.nextBudgetDelay() > 0
|
|
) {
|
|
this.events.record('background-pause', entry.reason)
|
|
}
|
|
}
|
|
}
|