diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index acc7cf2b75c..d7d8e008720 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -37,7 +37,8 @@ "../src/types/**/*" ], "exclude": [ - "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts" + "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts", + "../src/main/github/pull-requests/refresh/pr-refresh-coordinator-local-delivery.test.ts" ], "compilerOptions": { "composite": true, diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index a6772914789..91924b75c1c 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -3,6 +3,7 @@ "include": [ "../src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts", "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts", + "../src/main/github/pull-requests/refresh/pr-refresh-coordinator-local-delivery.test.ts", "../src/renderer/src/runtime/observed-provider-accounts.test.tsx", "../src/renderer/src/components/status-bar/ClaudeSwitcherMenu.observation.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-revisit-and-refresh.json b/mobile/rpc-foundation/goldens/pr-sidebar-revisit-and-refresh.json index c17446d4c80..06cfb03c4a9 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-revisit-and-refresh.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-revisit-and-refresh.json @@ -65,11 +65,6 @@ } } }, - "1c1b5062fc21": { - "name": "github.prForBranch#2", - "ordinal": 21, - "json": "{\"id\":\"frame-10\",\"deviceToken\":\"recording-device\",\"method\":\"github.prForBranch\",\"params\":{\"repo\":\"id:repo-9\",\"branch\":\"feature\",\"linkedPRNumber\":null,\"fallbackPRNumber\":12,\"acceptMergedFallbackPR\":true,\"currentHeadOid\":null}}" - }, "1f5765918736": { "name": "github.repoSlug#1", "ordinal": 2, @@ -149,6 +144,66 @@ } } }, + "2247b3ad39b7": { + "name": "github.prForBranch#2", + "ordinal": 20, + "args": [ + { + "name": "method", + "value": "github.prForBranch" + }, + { + "name": "params", + "value": { + "acceptMergedFallbackPR": true, + "branch": "feature", + "currentHeadOid": { + "$rpc": "null" + }, + "fallbackPRNumber": 12, + "linkedPRNumber": { + "$rpc": "null" + }, + "reason": "active", + "repo": "id:repo-9" + } + }, + { + "name": "options", + "value": { + "beforeSend": "[local beforeSend]", + "failWhenDisconnected": true + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-10", + "ok": true, + "result": { + "fetchedAt": 0, + "kind": "found", + "pr": { + "headSha": "head-sha-1", + "mergeable": "MERGEABLE", + "number": 12, + "state": "open", + "title": "Recorded", + "updatedAt": "2026-01-01T00:00:00Z", + "url": "https://github.com/owner/repo/pull/12" + } + } + } + } + }, + "2b3eba6c2e11": { + "name": "github.prForBranch#2", + "ordinal": 21, + "json": "{\"id\":\"frame-10\",\"deviceToken\":\"recording-device\",\"method\":\"github.prForBranch\",\"params\":{\"repo\":\"id:repo-9\",\"branch\":\"feature\",\"linkedPRNumber\":null,\"fallbackPRNumber\":12,\"acceptMergedFallbackPR\":true,\"currentHeadOid\":null,\"reason\":\"active\"}}" + }, "2dc2ca47b4e7": { "name": "github.prForBranch#1", "ordinal": 9, @@ -927,60 +982,6 @@ "$rpc": "undefined" } }, - "fd8df0a75a2b": { - "name": "github.prForBranch#2", - "ordinal": 20, - "args": [ - { - "name": "method", - "value": "github.prForBranch" - }, - { - "name": "params", - "value": { - "acceptMergedFallbackPR": true, - "branch": "feature", - "currentHeadOid": { - "$rpc": "null" - }, - "fallbackPRNumber": 12, - "linkedPRNumber": { - "$rpc": "null" - }, - "repo": "id:repo-9" - } - }, - { - "name": "options", - "value": { - "beforeSend": "[local beforeSend]", - "failWhenDisconnected": true - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-10", - "ok": true, - "result": { - "fetchedAt": 0, - "kind": "found", - "pr": { - "headSha": "head-sha-1", - "mergeable": "MERGEABLE", - "number": 12, - "state": "open", - "title": "Recorded", - "updatedAt": "2026-01-01T00:00:00Z", - "url": "https://github.com/owner/repo/pull/12" - } - } - } - } - }, "ffe160d981d2": { "name": "status.get#3", "ordinal": 32, @@ -1076,7 +1077,7 @@ "8b7e690466d7", "bfedf583a747", "214acb975f72", - "fd8df0a75a2b" + "2247b3ad39b7" ], "payloads": [ "1f5765918736", @@ -1088,7 +1089,7 @@ "e10925fa98de", "5d309fcf43a7", "5b1c7d371b2a", - "1c1b5062fc21" + "2b3eba6c2e11" ], "settlements": { "mount": "eb79a9b3682a", @@ -1110,7 +1111,7 @@ "8b7e690466d7", "bfedf583a747", "214acb975f72", - "fd8df0a75a2b", + "2247b3ad39b7", "e97818adb1c8", "2e217691ed33", "a7feeba7e968", @@ -1129,7 +1130,7 @@ "e10925fa98de", "5d309fcf43a7", "5b1c7d371b2a", - "1c1b5062fc21", + "2b3eba6c2e11", "03bcac42a924", "cdb9ad4feb12", "7b3f2de1ee65", @@ -1159,7 +1160,7 @@ "8b7e690466d7", "bfedf583a747", "214acb975f72", - "fd8df0a75a2b", + "2247b3ad39b7", "e97818adb1c8", "2e217691ed33", "a7feeba7e968", @@ -1178,7 +1179,7 @@ "e10925fa98de", "5d309fcf43a7", "5b1c7d371b2a", - "1c1b5062fc21", + "2b3eba6c2e11", "03bcac42a924", "cdb9ad4feb12", "7b3f2de1ee65", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 4c3ab3f7eda..1aba90fe233 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -3881,7 +3881,8 @@ "linkedPRNumber": null, "fallbackPRNumber": 12, "acceptMergedFallbackPR": true, - "currentHeadOid": null + "currentHeadOid": null, + "reason": "active" }, "paramsFollowDivergence": true, "reply": { diff --git a/mobile/src/query/host-query.ts b/mobile/src/query/host-query.ts index 7a03ae8efb4..77537757d5e 100644 --- a/mobile/src/query/host-query.ts +++ b/mobile/src/query/host-query.ts @@ -223,13 +223,15 @@ export function useHostQuery( options?: { enabled?: boolean subscribed?: boolean - refetchInterval?: number | false + // A function decides from the data each read leaves, so one read's answer can time the next. + refetchInterval?: number | false | ((data: TData | undefined) => number | false) // Replaces the family's own, e.g. once the host is known to signal every change. staleTime?: number } ): UseQueryResult { const { hostId, client } = host const enabled = client !== null && options?.enabled !== false + const refetchInterval = options?.refetchInterval useHostConnectionInvalidation(hostId, enabled ? client : null) // A screen alone on its page has no host list to keep the router; its signalled reads hold one. useHostEventRouter(hostId, enabled && query.signal ? client : null) @@ -245,7 +247,10 @@ export function useHostQuery( ...queryOptions, ...(options?.staleTime === undefined ? {} : { staleTime: options.staleTime }), subscribed: options?.subscribed, - refetchInterval: options?.refetchInterval + refetchInterval: + typeof refetchInterval === 'function' + ? (query) => refetchInterval(query.state.data) + : refetchInterval }) } diff --git a/mobile/src/query/host-read-intent.test.ts b/mobile/src/query/host-read-intent.test.ts new file mode 100644 index 00000000000..191b0ddee07 --- /dev/null +++ b/mobile/src/query/host-read-intent.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { hostReadIntent, noteAppForegrounded, wasAppForegroundedRecently } from './host-read-intent' +import { prForBranchIntentParams } from '../source-control/review/pr-for-branch-revalidation' + +const later = () => false +const justNow = () => true + +describe('hostReadIntent', () => { + it('treats a revalidation of data this launch read as the screen’s timer', () => { + expect(hostReadIntent({ live: false, previousFromStore: false }, { pr: 1 }, later)).toBe( + 'background' + ) + }) + + it('marks a revalidation right after the app came back as foreground', () => { + expect(hostReadIntent({ live: false, previousFromStore: false }, { pr: 1 }, justNow)).toBe( + 'foreground' + ) + }) + + it.each([ + ['an action’s read', { live: true, previousFromStore: false }, { pr: 1 }, 'action'], + ['the first read of a screen', { live: false, previousFromStore: false }, undefined, 'open'], + ['a copy from an earlier launch', { live: false, previousFromStore: true }, { pr: 1 }, 'open'] + ] as const)('%s is never a timer or a foreground return', (_label, read, previous, intent) => { + expect(hostReadIntent(read, previous, justNow)).toBe(intent) + expect(hostReadIntent(read, previous, later)).toBe(intent) + }) + + it('reads on the screen’s timer while the clock is set back, and fresh only after a real return', () => { + const timerRead = (now: number) => + prForBranchIntentParams({ + intent: hostReadIntent({ live: false, previousFromStore: false }, { pr: 1 }, () => + wasAppForegroundedRecently(now) + ) + }) + noteAppForegrounded(100_000) + expect(timerRead(40_000)).toEqual({ reason: 'swr', acceptRevalidationOnly: true }) + expect(timerRead(99_999)).toEqual({ reason: 'swr', acceptRevalidationOnly: true }) + noteAppForegrounded(40_000) + expect(timerRead(44_999)).toEqual({ reason: 'active' }) + expect(timerRead(45_000)).toEqual({ reason: 'swr', acceptRevalidationOnly: true }) + }) +}) diff --git a/mobile/src/query/host-read-intent.ts b/mobile/src/query/host-read-intent.ts new file mode 100644 index 00000000000..71c784d8515 --- /dev/null +++ b/mobile/src/query/host-read-intent.ts @@ -0,0 +1,43 @@ +import type { HostQueryRead } from './host-query' + +/** The app just came to the foreground: its next reads are the user looking, not a timer. */ +export const FOREGROUND_READ_WINDOW_MS = 5_000 + +let lastForegroundAt = Date.now() + +/** Called by the query provider's AppState listener when the app becomes active. */ +export function noteAppForegrounded(now = Date.now()): void { + lastForegroundAt = now +} + +/** A clock set back before the last foreground says nothing about a recent return. */ +export function wasAppForegroundedRecently(now = Date.now()): boolean { + const elapsed = now - lastForegroundAt + return elapsed >= 0 && elapsed < FOREGROUND_READ_WINDOW_MS +} + +/** + * What a PR read is to the host: + * - `action`: a Refresh or an action's read; it also re-learns host status. + * - `open`: the first read of a screen, or one that only has a copy from an earlier launch; the + * host may reuse its own lookup from moments ago. + * - `foreground`: the app just came back to the foreground; the user is looking again and the + * host reads fresh. + * - `background`: the screen's own timer, which the host may answer from its last read at its PR + * auto-refresh cadence, saying when to read again while it revalidates. + */ +export type HostReadIntent = 'action' | 'open' | 'foreground' | 'background' + +export function hostReadIntent( + read: HostQueryRead, + previous: unknown, + recentlyForegrounded: () => boolean = wasAppForegroundedRecently +): HostReadIntent { + if (read.live) { + return 'action' + } + if (previous === undefined || read.previousFromStore) { + return 'open' + } + return recentlyForegrounded() ? 'foreground' : 'background' +} diff --git a/mobile/src/query/orca-query-provider.tsx b/mobile/src/query/orca-query-provider.tsx index cc1a6935386..93bf6b03a30 100644 --- a/mobile/src/query/orca-query-provider.tsx +++ b/mobile/src/query/orca-query-provider.tsx @@ -2,12 +2,18 @@ import { useEffect, useState, type ReactNode } from 'react' import { AppState } from 'react-native' import { focusManager, QueryClientProvider } from '@tanstack/react-query' import { createOrcaQueryClient } from './orca-query-client' +import { noteAppForegrounded } from './host-read-intent' // React Native has no visibilitychange, so foreground revalidation follows AppState. function followAppState(setFocused: (focused?: boolean) => void): () => void { - const subscription = AppState.addEventListener('change', (state) => - setFocused(state === 'active') - ) + const subscription = AppState.addEventListener('change', (state) => { + const active = state === 'active' + if (active) { + // The focus refetches that follow are the user looking, not the screen's timer. + noteAppForegrounded() + } + setFocused(active) + }) return () => subscription.remove() } diff --git a/mobile/src/source-control/review/github-operations/github-pr-read-reply-schema.ts b/mobile/src/source-control/review/github-operations/github-pr-read-reply-schema.ts index 510b4c059ec..af1df196b4b 100644 --- a/mobile/src/source-control/review/github-operations/github-pr-read-reply-schema.ts +++ b/mobile/src/source-control/review/github-operations/github-pr-read-reply-schema.ts @@ -1,5 +1,9 @@ import * as z from 'zod' import { salvagedOptional, salvagingArray } from '../../../../../src/shared/zod-salvage' +import { + isPRRevalidationOnly, + type PRRevalidationOnly +} from '../../../../../src/shared/github/pull-requests/refresh/pull-request-refresh-types' import type { PRInfo } from '../../../../../src/shared/github/pull-requests/pull-request-types' import type { GitHubWorkItem, @@ -165,20 +169,38 @@ const pullRequestSchema = z ) // Expose only fields actually sent/read; do not fabricate PRRefreshOutcome metadata. +// `retryAfterMs`: the host is still revalidating this answer and asks to be read again that soon. export type GitHubPrForBranchOutcome = | { kind: 'upstream-error'; message: string } - | { kind: 'found'; pr: PRInfo } + | { kind: 'found'; pr: PRInfo; retryAfterMs?: number } + | { kind: 'no-pr'; retryAfterMs: number } + | { kind: 'revalidating'; retryAfterMs: number } + +const revalidationSchema = z.looseObject({ retryAfterMs: z.number().finite().nonnegative() }) + +function receiptRetryAfterMs(revalidation: unknown): { retryAfterMs?: number } { + const receipt = revalidationSchema.safeParse(revalidation) + return receipt.success ? { retryAfterMs: receipt.data.retryAfterMs } : {} +} // Accept legacy bare PR/null and classified outcomes; preserve upstream-error text as an in-band host result. export const githubPrForBranchSchema: z.ZodType = z.union( [ + // A host that kept no answer for this read only says when to read again; it carries no data. + z.custom(isPRRevalidationOnly).transform((reply) => ({ + kind: 'revalidating' as const, + retryAfterMs: reply.revalidation.retryAfterMs + })), z .looseObject({ kind: z.literal('upstream-error'), message: prText('message') }) .transform((outcome) => ({ kind: 'upstream-error' as const, message: outcome.message ?? '' })), - z.looseObject({ kind: z.literal('no-pr') }).transform(() => null), + z.looseObject({ kind: z.literal('no-pr') }).transform((outcome) => { + const { retryAfterMs } = receiptRetryAfterMs(outcome.revalidation) + return retryAfterMs === undefined ? null : { kind: 'no-pr' as const, retryAfterMs } + }), z.looseObject({ kind: z.literal('found'), pr: pullRequestSchema }).transform((outcome, ctx) => { if (!outcome.pr) { ctx.addIssue({ @@ -188,7 +210,11 @@ export const githubPrForBranchSchema: z.ZodType { if (!pr) { diff --git a/mobile/src/source-control/review/github-operations/github-pr-rpc.ts b/mobile/src/source-control/review/github-operations/github-pr-rpc.ts index 69ad04567f4..ee2b0ae4fed 100644 --- a/mobile/src/source-control/review/github-operations/github-pr-rpc.ts +++ b/mobile/src/source-control/review/github-operations/github-pr-rpc.ts @@ -8,10 +8,7 @@ import type { PRCheckDetail, PRCheckRunDetails } from '../../../../../src/shared/github/checks/check-types' -import type { - GitHubAssignableUser, - PRInfo -} from '../../../../../src/shared/github/pull-requests/pull-request-types' +import type { GitHubAssignableUser } from '../../../../../src/shared/github/pull-requests/pull-request-types' import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../src/shared/github/repositories/repository-identity' import type { GitHubWorkItemDetails } from '../../../../../src/shared/github/work-items/work-item-types' import type { HostedReviewInfo } from '../../../../../src/shared/source-control/hosted-review/hosted-review-contracts' @@ -19,7 +16,6 @@ import { refusedRpcMessageOrFallback } from '../../../transport/rpc/rpc-refusal- import type { RpcResponse } from '../../../transport/rpc/rpc-wire-types' import '../creation/mobile-pr-create' import { mobileRepoSelectorFromWorktreeId } from '../creation/mobile-hosted-review-service' -import type { GitHubPrForBranchOutcome } from './github-pr-read-reply-schema' import { githubPrAssignableUsersRead, githubPrCheckDetailsRead, @@ -33,6 +29,12 @@ import type { GitHubPrSettleableOperation } from './github-pr-mutation-outcome' import { githubPrRequestParams, type GitHubPrRepoSlug } from './github-pr-repo-slug' import type { UnvalidatedRpcRequestPort } from '../../../transport/rpc/unvalidated-rpc-request-port' import { translate } from '../../../i18n/mobile-i18n' +import type { HostReadIntent } from '../../../query/host-read-intent' +import { + prForBranchIntentParams, + resolveGithubPrForBranchOutcome, + type GitHubPrForBranchRead +} from '../pr-for-branch-revalidation' export type GitHubPrReadOutcome = { ok: true; result: T } | { ok: false; error: string } @@ -107,13 +109,11 @@ export async function fetchPRForBranch( currentHeadOid?: string | null // An action's read: re-learn host status, and join no host lookup begun before it. refresh?: boolean + intent?: HostReadIntent } -): Promise> { +): Promise { return observe(true, args.refresh === true) - async function observe( - adopt: boolean, - refresh = false - ): Promise> { + async function observe(adopt: boolean, refresh = false): Promise { const contract = await captureMobilePRCheckContract( client, { @@ -123,7 +123,7 @@ export async function fetchPRForBranch( ) try { let sourceMismatch = false - const result = await settleGithubPrRead( + const read = await settleGithubPrRead( { operation: githubPrForBranchRead.operation, interpret: (reply) => @@ -138,7 +138,7 @@ export async function fetchPRForBranch( fallbackPRNumber: args.fallbackPRNumber ?? null, ...(args.acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}), currentHeadOid: args.currentHeadOid ?? null, - ...(args.refresh ? { userRefresh: true } : {}) + ...prForBranchIntentParams(args) }), contract.options ) @@ -151,6 +151,7 @@ export async function fetchPRForBranch( if (adopt && sourceMismatch && contract.routeCurrent()) { return observe(false, true) } + const result: GitHubPrForBranchRead = read.ok ? { ok: true, ...read.result } : read if (!contract.current()) { if (adopt && !contract.complete && contract.routeCurrent()) { return observe(false) @@ -159,7 +160,7 @@ export async function fetchPRForBranch( } if (result.ok && result.result && contract.complete) { return { - ok: true, + ...result, result: acceptPRCheckObservation(result.result, null, { scope: contract.scope!, complete: true, @@ -174,16 +175,6 @@ export async function fetchPRForBranch( } } -function resolveGithubPrForBranchOutcome(outcome: GitHubPrForBranchOutcome | null): PRInfo | null { - if (outcome === null) { - return null - } - if (outcome.kind === 'upstream-error') { - throw new Error(outcome.message) - } - return outcome.kind === 'found' ? outcome.pr : null -} - export async function fetchWorkItemDetails( client: RpcClient, worktreeId: string, @@ -235,6 +226,8 @@ export function fetchPRChecks( prRepo?: GitHubPrRepoSlug | null // An action's read: past gh's response cache, joining no host read begun before it. refresh?: boolean + // The screen's own timer: the host may answer from its last read at its PR auto-refresh cadence. + background?: boolean } ): Promise> { return settleGithubPrRead(githubPrChecksRead, () => @@ -245,7 +238,8 @@ export function fetchPRChecks( worktreeId, { prNumber: args.prNumber, - ...(args.refresh ? { noCache: true, userRefresh: true } : {}) + ...(args.refresh ? { noCache: true, userRefresh: true } : {}), + ...(args.background && !args.refresh ? { reason: 'swr' } : {}) }, { prRepo: args.prRepo, headSha: args.headSha } ) diff --git a/mobile/src/source-control/review/mobile-pr-revalidation.test.ts b/mobile/src/source-control/review/mobile-pr-revalidation.test.ts new file mode 100644 index 00000000000..8b4f3237533 --- /dev/null +++ b/mobile/src/source-control/review/mobile-pr-revalidation.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { directWire, directWireTracker } from '../../transport/rpc/direct-rpc-wire-test-harness' +import type { PRInfo } from '../../../../src/shared/github/pull-requests/pull-request-types' +import { fetchPRForBranch } from './github-operations/github-pr-rpc' +import { loadPrBranchReview, type PrBranchReview } from './pull-request/mobile-pr-sidebar-state' +import type { WorktreeGitHubPRLink } from './creation/mobile-pr-link' + +const pr = { + number: 7, + title: 'Shown PR', + state: 'open', + url: 'https://github.com/acme/repo/pull/7', + checksStatus: 'success', + updatedAt: '2026-11-01T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'head-a' +} satisfies PRInfo + +function phoneHost() { + const socket = directWire() + const tracker = directWireTracker(socket) + const reply = (method: string, result: unknown) => { + const frame = socket.frames().find((sent) => sent.method === method) + tracker.resolve({ id: frame.id, ok: true, result }) + } + const port = { sendRequest: tracker.sendRequest.bind(tracker) } + return { socket, tracker, reply, port } +} + +describe('a phone PR screen’s timer read', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('opts into revalidation, stays answerable while the host holds it, and keeps what is shown on a receipt', async () => { + const host = phoneHost() + const timer = fetchPRForBranch(host.port, 'repo-1::/repo', { + branch: 'feature', + intent: 'background' + }) + await vi.advanceTimersByTimeAsync(0) + expect(host.socket.frames()[0].params).toMatchObject({ + reason: 'swr', + acceptRevalidationOnly: true + }) + + // The host holds the timer read for its admission wait; status and actions still answer. + const status = host.tracker.sendRequest('status.get') + await vi.advanceTimersByTimeAsync(0) + host.reply('status.get', { runtimeId: 'host' }) + await expect(status).resolves.toMatchObject({ ok: true }) + + await vi.advanceTimersByTimeAsync(25_000) + host.reply('github.prForBranch', { revalidation: { invalidated: true, retryAfterMs: 10_000 } }) + await expect(timer).resolves.toEqual({ + ok: true, + result: null, + revalidationOnly: true, + retryAfterMs: 10_000 + }) + }) + + it.each([ + ['foreground', { reason: 'active' }], + ['open', {}] + ] as const)('a %s read reads fresh without opting in', async (intent, expected) => { + const host = phoneHost() + void fetchPRForBranch(host.port, 'repo-1::/repo', { branch: 'feature', intent }) + await vi.advanceTimersByTimeAsync(0) + const params = host.socket.frames()[0].params + expect(params).toMatchObject(expected) + expect(params).not.toHaveProperty('acceptRevalidationOnly') + expect(params).not.toHaveProperty('foreground') + }) +}) + +describe('loadPrBranchReview with a revalidation receipt', () => { + const previous: PrBranchReview = { + kind: 'found', + pr, + pushTarget: null, + linkedPRNumber: 7 + } + const deps = (reply: Awaited>) => ({ + fetchForBranch: vi.fn(async () => ({ ok: true as const, result: null })), + fetchWorktreeGitHubPRLink: vi.fn(async (): Promise => ({ + linkedPR: 7, + suppressedGitHubPR: null, + pushTarget: null + })), + clearWorktreeReviewLink: vi.fn(async () => {}), + fetchPRForBranch: vi.fn(async () => reply) + }) + + it('keeps the shown PR and its link when the host sends no answer, and asks to read again soon', async () => { + const loader = deps({ ok: true, result: null, revalidationOnly: true, retryAfterMs: 12_000 }) + const review = await loadPrBranchReview(loader, { + worktreeId: 'repo-1::/repo', + branch: 'feature', + intent: 'background', + previous + }) + expect(review).toEqual({ ...previous, retryAfterMs: 12_000 }) + expect(loader.clearWorktreeReviewLink).not.toHaveBeenCalled() + }) + + it.each([ + ['the link moved to another PR', 7, { linkedPR: 8, suppressedGitHubPR: null }], + ['the shown PR is now suppressed', null, { linkedPR: null, suppressedGitHubPR: 7 }], + ['this read unlinked the shown PR', null, { linkedPR: 7, suppressedGitHubPR: null }] + ] as const)('does not keep the shown PR on a receipt when %s', async (_case, shownLink, link) => { + const receipt = { + ok: true as const, + result: null, + revalidationOnly: true as const, + retryAfterMs: 12_000 + } + const loader = deps(receipt) + loader.fetchWorktreeGitHubPRLink.mockResolvedValue({ ...link, pushTarget: null }) + // The linked PR moved to another branch, so the read clears the link and reads by branch. + loader.fetchPRForBranch.mockResolvedValueOnce( + link.linkedPR === 7 + ? { ok: true, result: { ...pr, headRefName: 'other', headSha: 'head-other' } } + : receipt + ) + const review = await loadPrBranchReview(loader, { + worktreeId: 'repo-1::/repo', + branch: 'feature', + headSha: 'head-b', + intent: 'background', + previous: { ...previous, linkedPRNumber: shownLink } + }) + expect(review).toMatchObject({ kind: 'none', retryAfterMs: 12_000 }) + expect(review).not.toHaveProperty('pr') + }) + + it('shows a cached answer the host is still revalidating, then asks again soon', async () => { + const loader = deps({ ok: true, result: { ...pr, title: 'Cached' }, retryAfterMs: 10_000 }) + expect( + await loadPrBranchReview(loader, { + worktreeId: 'repo-1::/repo', + branch: 'feature', + intent: 'background', + previous + }) + ).toMatchObject({ kind: 'found', pr: { title: 'Cached' }, retryAfterMs: 10_000 }) + }) +}) diff --git a/mobile/src/source-control/review/pr-for-branch-revalidation.ts b/mobile/src/source-control/review/pr-for-branch-revalidation.ts new file mode 100644 index 00000000000..aed4f2895a2 --- /dev/null +++ b/mobile/src/source-control/review/pr-for-branch-revalidation.ts @@ -0,0 +1,44 @@ +import type { PRInfo } from '../../../../src/shared/github/pull-requests/pull-request-types' +import type { HostReadIntent } from '../../query/host-read-intent' +import type { GitHubPrForBranchOutcome } from './github-operations/github-pr-read-reply-schema' + +/** A PR read; `retryAfterMs` asks for another read that soon while the host revalidates, and + * `revalidationOnly` says the host sent no answer at all, so nothing shown may change. */ +export type GitHubPrForBranchRead = + | { ok: true; result: PRInfo | null; retryAfterMs?: number; revalidationOnly?: true } + | { ok: false; error: string } + +// Open reads keep the host's short reuse of its own lookup; a return to the app reads fresh; the +// screen's timer lets the host answer from its last read and say when to read again. +export function prForBranchIntentParams(args: { refresh?: boolean; intent?: HostReadIntent }) { + if (args.refresh) { + return { userRefresh: true } + } + if (args.intent === 'foreground') { + return { reason: 'active' as const } + } + if (args.intent === 'background') { + return { reason: 'swr' as const, acceptRevalidationOnly: true } + } + return {} +} + +export function resolveGithubPrForBranchOutcome( + outcome: GitHubPrForBranchOutcome | null +): Omit, 'ok'> { + if (outcome === null) { + return { result: null } + } + switch (outcome.kind) { + case 'upstream-error': + throw new Error(outcome.message) + case 'revalidating': + return { result: null, retryAfterMs: outcome.retryAfterMs, revalidationOnly: true } + case 'no-pr': + return { result: null, retryAfterMs: outcome.retryAfterMs } + case 'found': + return outcome.retryAfterMs === undefined + ? { result: outcome.pr } + : { result: outcome.pr, retryAfterMs: outcome.retryAfterMs } + } +} diff --git a/mobile/src/source-control/review/pull-request/mobile-pr-queries.ts b/mobile/src/source-control/review/pull-request/mobile-pr-queries.ts index c5f910fee7d..db67db42f8a 100644 --- a/mobile/src/source-control/review/pull-request/mobile-pr-queries.ts +++ b/mobile/src/source-control/review/pull-request/mobile-pr-queries.ts @@ -1,4 +1,5 @@ import type { QueryClient, QueryKey } from '@tanstack/react-query' +import { hostReadIntent } from '../../../query/host-read-intent' import type { PRComment } from '../../../../../src/shared/github/comments/comment-types' import type { PRCheckDetail } from '../../../../../src/shared/github/checks/check-types' import { @@ -175,6 +176,7 @@ export const prBranchReviewQuery = defineHostQuery({ headSha: input.head, // Only an action's read re-learns host status; revalidations reuse what this connection knows. refresh: read.live, + intent: hostReadIntent(read, previous), previous } ), @@ -188,19 +190,14 @@ export const prChecksQuery = defineHostQuery({ refetchOnWindowFocus: true, // The event also fires when the checks rollup moves, which the summary alone can't list. signal: pullRequestChangedSignal, - fetch: async ( - client, - target, - input: PrChecksInput, - _previous, - read - ): Promise => { + fetch: async (client, target, input: PrChecksInput, previous, read): Promise => { const outcome = await fetchPRChecks(client, target.worktree ?? '', { prNumber: input.number, headSha: input.head, // Fork checks must use the PR's own repo identity. prRepo: prRepoSlug(input), - refresh: read.live + refresh: read.live, + background: hostReadIntent(read, previous) === 'background' }) if (!outcome.ok) { throw new Error(outcome.error) diff --git a/mobile/src/source-control/review/pull-request/mobile-pr-sidebar-state.ts b/mobile/src/source-control/review/pull-request/mobile-pr-sidebar-state.ts index 126211201a8..29b0ee815a8 100644 --- a/mobile/src/source-control/review/pull-request/mobile-pr-sidebar-state.ts +++ b/mobile/src/source-control/review/pull-request/mobile-pr-sidebar-state.ts @@ -8,6 +8,8 @@ import type { GitPushTarget } from '../../../../../src/shared/worktree/types' import type { GitHubWorkItemDetails } from '../../../../../src/shared/github/work-items/work-item-types' import type { HostedReviewInfo } from '../../../../../src/shared/source-control/hosted-review/hosted-review-contracts' import type { GitHubPrReadOutcome } from '../github-operations/github-pr-rpc' +import type { GitHubPrForBranchRead } from '../pr-for-branch-revalidation' +import type { HostReadIntent } from '../../../query/host-read-intent' import { isGitHubPRSuppressed } from '../../../../../src/shared/worktree/hosted-review/github-pr-suppression' import { prBranchFallbackNeedsHint, @@ -80,8 +82,9 @@ export type PrSidebarLoadDeps = { acceptMergedFallbackPR?: boolean currentHeadOid?: string | null refresh?: boolean + intent?: HostReadIntent } - ) => Promise> + ) => Promise } // The guard desktop applies before persisting the clear, or null when the link still holds. @@ -118,11 +121,14 @@ function resolveStaleLink( /** The hosted-review hint a branch lookup fell back to, kept so a branch with no PR isn't asked twice per read. */ export type PrBranchHint = { number: number | null; readAt: number } -/** The branch's GitHub review: its PR and the workspace's push target, or none. */ +/** The branch's GitHub review: its PR and the workspace's push target, or none. `retryAfterMs`: + * the host is still revalidating this answer, so the screen reads again that soon. */ export type PrBranchReview = // linkedPRNumber: the link the PR was read with, so a later link change drops it as a fallback. - | { kind: 'found'; pr: PRInfo; pushTarget: GitPushTarget | null; linkedPRNumber: number | null } - | { kind: 'none'; hint?: PrBranchHint } + ( + | { kind: 'found'; pr: PRInfo; pushTarget: GitPushTarget | null; linkedPRNumber: number | null } + | { kind: 'none'; hint?: PrBranchHint } + ) & { retryAfterMs?: number } // The host re-reads a selected branch's hosted review once a minute, so a younger hint is still its answer. const BRANCH_HINT_STALE_MS = 60_000 @@ -141,6 +147,7 @@ export async function loadPrBranchReview( branch: string headSha?: string | null refresh?: boolean + intent?: HostReadIntent previous?: PrBranchReview } ): Promise { @@ -188,11 +195,32 @@ export async function loadPrBranchReview( ? { acceptMergedFallbackPR: true } : {}), currentHeadOid: requestHeadOid, - ...(args.refresh ? { refresh: true } : {}) + ...(args.refresh ? { refresh: true } : {}), + // Only a revalidation of an answer already shown may be told to wait. + ...(args.intent === 'background' && !args.previous ? {} : { intent: args.intent }) }) } let linkedPR = link.linkedPR + // The host kept no answer for this read: what is shown stays while the current link and + // suppression would still show it, never a PR this read unlinked. + const unanswered = (outcome: GitHubPrForBranchRead, unlinked: number | null = null) => { + const shown = args.previous + if (!outcome.ok || !outcome.revalidationOnly || !shown) { + return null + } + const context = { linkedPR, suppressedGitHubPR: link.suppressedGitHubPR } + return shown.kind === 'found' && + (shown.linkedPRNumber !== linkedPR || + shown.pr.number === unlinked || + isGitHubPRSuppressed(context, shown.pr.number)) + ? null + : { ...shown, retryAfterMs: outcome.retryAfterMs } + } let prOutcome = await fetchPR(linkedPR, fallbackPR(linkedPR)) + const unchanged = unanswered(prOutcome) + if (unchanged) { + return unchanged + } const staleLink = prOutcome.ok ? resolveStaleLink(prOutcome.result, link, args.branch, requestHeadOid) : null @@ -204,6 +232,10 @@ export async function loadPrBranchReview( deps.clearWorktreeReviewLink(args.worktreeId, staleLink), fetchPR(null, null) ]) + const relinked = unanswered(prOutcome, link.linkedPR) + if (relinked) { + return relinked + } } if (!prOutcome.ok) { throw new Error(prOutcome.error) @@ -216,13 +248,18 @@ export async function loadPrBranchReview( ) ) { const hint = await hintRead - return hint ? { kind: 'none', hint } : { kind: 'none' } + return { + kind: 'none', + ...(hint ? { hint } : {}), + ...(prOutcome.retryAfterMs === undefined ? {} : { retryAfterMs: prOutcome.retryAfterMs }) + } } return { kind: 'found', pr: prOutcome.result, pushTarget: link.pushTarget, - linkedPRNumber: linkedPR + linkedPRNumber: linkedPR, + ...(prOutcome.retryAfterMs === undefined ? {} : { retryAfterMs: prOutcome.retryAfterMs }) } } diff --git a/mobile/src/source-control/review/pull-request/use-mobile-pr-polling-revalidation.test.ts b/mobile/src/source-control/review/pull-request/use-mobile-pr-polling-revalidation.test.ts new file mode 100644 index 00000000000..1290449d94d --- /dev/null +++ b/mobile/src/source-control/review/pull-request/use-mobile-pr-polling-revalidation.test.ts @@ -0,0 +1,174 @@ +import { createElement } from 'react' +import { environmentManager, QueryClientProvider, type QueryClient } from '@tanstack/react-query' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRInfo } from '../../../../../src/shared/github/pull-requests/pull-request-types' +import type { PRAutoRefreshCadence } from '../../../../../src/shared/review-refresh-policy' +import { createOrcaQueryClient } from '../../../query/orca-query-client' +import { hostQueryKey } from '../../../query/host-query-key' +import type { RpcResponse } from '../../../transport/rpc/rpc-wire-types' +import { stubRpcClient } from '../../../transport/rpc/rpc-client-test-stub' +import { + DESKTOP_SOURCE_CONTROL_DEFAULTS, + NO_SOURCE_CONTROL_SETTINGS_INPUT, + sourceControlSettingsQuery +} from '../../settings/mobile-source-control-settings' +import { prBranchReviewQuery, type PrQueryScope } from './mobile-pr-queries' +import type { PrBranchReview } from './mobile-pr-sidebar-state' +import { useMobilePrPolling } from './use-mobile-pr-polling' + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect: (effect: () => (() => void) | void) => React.useEffect(effect, [effect]) + } +}) + +const WORKTREE = 'repo42::/p' +const TARGET = { exec: 'local' as const, worktree: WORKTREE } +const BRANCH = { branch: 'feature', head: 'head-a' } +const pr = { + number: 7, + title: 'Shown', + state: 'open', + url: 'https://github.com/acme/repo/pull/7', + checksStatus: 'pending', + updatedAt: '2026-11-01T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'head-a' +} satisfies PRInfo + +type PendingCall = { method: string; params: unknown; settle: (reply: RpcResponse) => void } + +describe('the phone PR screen’s timer with host revalidation receipts', () => { + let renderer: { unmount: () => void } | null = null + let queryClient: QueryClient + let calls: PendingCall[] + let serverEnvironment: boolean + + beforeEach(() => { + serverEnvironment = environmentManager.isServer() + // The phone schedules client timers; this test otherwise runs as a Node server. + environmentManager.setIsServer(() => false) + vi.useFakeTimers() + calls = [] + queryClient = createOrcaQueryClient() + }) + afterEach(async () => { + act(() => renderer?.unmount()) + renderer = null + await queryClient.cancelQueries() + queryClient.clear() + vi.useRealTimers() + environmentManager.setIsServer(() => serverEnvironment) + }) + + // Only the PR read is answered by the test; every other host read fails, as an unreachable one would. + const client = stubRpcClient({ + sendRequest: (method, params) => + method === 'github.prForBranch' + ? new Promise((settle) => calls.push({ method, params, settle })) + : Promise.reject(new Error(`not answered: ${method}`)) + }) + const scope: PrQueryScope = { + host: { hostId: 'host-1', client }, + target: TARGET, + branchInput: BRANCH, + checksInput: null, + detailsInput: null + } + const branchKey = hostQueryKey('host-1', prBranchReviewQuery.family, TARGET, BRANCH) + const settingsKey = hostQueryKey( + 'host-1', + sourceControlSettingsQuery.family, + { exec: 'local' }, + NO_SOURCE_CONTROL_SETTINGS_INPUT + ) + + function setHostCadence(prAutoRefresh: PRAutoRefreshCadence): void { + act(() => { + queryClient.setQueryData(settingsKey, { ...DESKTOP_SOURCE_CONTROL_DEFAULTS, prAutoRefresh }) + }) + } + + async function mount(): Promise { + setHostCadence('default') + const shown: PrBranchReview = { kind: 'found', pr, pushTarget: null, linkedPRNumber: null } + queryClient.setQueryData(branchKey, shown) + function Harness(): null { + useMobilePrPolling({ + queries: scope, + state: { + kind: 'ready', + data: { pr, details: null, checks: [], checksError: null, pushTarget: null } + }, + live: 'checks', + connected: true + }) + return null + } + await act(async () => { + renderer = create( + createElement(QueryClientProvider, { client: queryClient }, createElement(Harness)) + ) + }) + } + + async function nextPRRead(): Promise { + await vi.waitFor(() => expect(calls).toHaveLength(1), { interval: 10 }) + return calls.shift()! + } + + async function answer(call: PendingCall, result: unknown): Promise { + await act(async () => call.settle({ id: 'call', ok: true, result, _meta: { runtimeId: 'r' } })) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + } + + const shownTitle = () => { + const review = queryClient.getQueryData(branchKey) + return review?.kind === 'found' ? review.pr.title : undefined + } + + it('keeps the shown PR on a receipt, reads again at the receipt’s delay, and stops once the host is Off', async () => { + await mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + const timer = await nextPRRead() + expect(timer.params).toMatchObject({ reason: 'swr', acceptRevalidationOnly: true }) + await answer(timer, { revalidation: { invalidated: true, retryAfterMs: 15_000 } }) + expect(shownTitle()).toBe('Shown') + + // The receipt's short retry, not the screen's minute. + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + const retry = await nextPRRead() + await answer(retry, { + kind: 'found', + fetchedAt: 1, + pr: { ...pr, title: 'Cached' }, + revalidation: { retryAfterMs: 40_000 } + }) + expect(shownTitle()).toBe('Cached') + + // A longer known gate waits longer; the host turns Off before the reply lands. + await act(async () => { + await vi.advanceTimersByTimeAsync(39_000) + }) + expect(calls).toHaveLength(0) + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + const gated = await nextPRRead() + setHostCadence('off') + await answer(gated, { revalidation: { invalidated: true, retryAfterMs: 10_000 } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60_000) + }) + expect(calls).toHaveLength(0) + expect(shownTitle()).toBe('Cached') + }) +}) diff --git a/mobile/src/source-control/review/pull-request/use-mobile-pr-polling.ts b/mobile/src/source-control/review/pull-request/use-mobile-pr-polling.ts index aa525b65cde..0bec7c86914 100644 --- a/mobile/src/source-control/review/pull-request/use-mobile-pr-polling.ts +++ b/mobile/src/source-control/review/pull-request/use-mobile-pr-polling.ts @@ -6,7 +6,12 @@ import { } from '../../../../../src/shared/github/checks/checks-detail-polling-policy' import type { PRCheckDetail } from '../../../../../src/shared/github/checks/check-types' import type { PRInfo } from '../../../../../src/shared/github/pull-requests/pull-request-types' +import type { PRAutoRefreshCadence } from '../../../../../src/shared/review-refresh-policy' import { useHostQuery } from '../../../query/host-query' +import { + NO_SOURCE_CONTROL_SETTINGS_INPUT, + sourceControlSettingsQuery +} from '../../settings/mobile-source-control-settings' import { NO_BRANCH, NO_CHECKS, @@ -40,6 +45,7 @@ type Cadence = { } const FIRST_BACKOFF_MS = 60_000 +const REVALIDATION_RETRY_FLOOR_MS = 10_000 const NO_HOST = { hostId: '', client: null } const NO_TARGET = { exec: 'local' as const } @@ -85,10 +91,11 @@ function stepCadence( return cadence } -function pollDelayMs( +export function pollDelayMs( cadence: Cadence, state: PRInfo['state'], - checksStatus: PRInfo['checksStatus'] | undefined + checksStatus: PRInfo['checksStatus'] | undefined, + hostCadence: PRAutoRefreshCadence = 'default' ): number | null { const policy = new ChecksDetailPollingPolicy() if (cadence.failed) { @@ -96,7 +103,8 @@ function pollDelayMs( } else if (cadence.checks) { policy.accept(cadence.checks) } - return policy.delayMs(state, checksStatus, cadence.backoffMs) + // The host that reads the PR owns the cadence; the phone's timer follows it (and the host caps it). + return policy.delayMs(state, checksStatus, cadence.backoffMs, hostCadence) } // Desktop's checks-panel cadence (shared policy): 1 min while open, backing off to 2 min when nothing @@ -125,14 +133,26 @@ export function useMobilePrPolling({ queries, state, live, connected }: Input): const current = cadence.resource === resource ? cadence : freshCadence(resource) const prState = pr?.state const checksStatus = pr?.checksStatus + const hostSettings = useHostQuery( + sourceControlSettingsQuery, + host, + { exec: target.exec }, + NO_SOURCE_CONTROL_SETTINGS_INPUT, + { enabled: running } + ) + const hostCadence = hostSettings.data?.prAutoRefresh ?? 'default' const delay = useMemo( - () => (running && prState ? pollDelayMs(current, prState, checksStatus) : null), - [running, current, prState, checksStatus] + () => (running && prState ? pollDelayMs(current, prState, checksStatus, hostCadence) : null), + [running, current, prState, checksStatus, hostCadence] ) const refetchInterval = delay ?? false useHostQuery(prBranchReviewQuery, host, target, queries?.branchInput ?? NO_BRANCH, { enabled: running && queries?.branchInput != null, - refetchInterval + // A host still revalidating the shown answer asks to be read again soon; "off" asks nothing. + refetchInterval: (review) => + running && hostCadence !== 'off' && review?.retryAfterMs !== undefined + ? Math.max(REVALIDATION_RETRY_FLOOR_MS, review.retryAfterMs) + : refetchInterval }) const checksRead = useHostQuery(prChecksQuery, host, target, queries?.checksInput ?? NO_CHECKS, { enabled: running && queries?.checksInput != null, @@ -153,5 +173,7 @@ export function useMobilePrPolling({ queries, state, live, connected }: Input): setCadence(next) } - return { polling: pr !== null && running && (pr.state === 'open' || pr.state === 'draft') } + return { + polling: pr !== null && delay !== null && (pr.state === 'open' || pr.state === 'draft') + } } diff --git a/mobile/src/source-control/settings/mobile-source-control-settings-operations.ts b/mobile/src/source-control/settings/mobile-source-control-settings-operations.ts index a1bab6ee8ea..92481acfa24 100644 --- a/mobile/src/source-control/settings/mobile-source-control-settings-operations.ts +++ b/mobile/src/source-control/settings/mobile-source-control-settings-operations.ts @@ -3,17 +3,24 @@ import type { RpcCompatibleReader } from '../../transport/rpc/rpc-operation-cont import { normalizeSourceControlGroupOrder } from '../../../../src/shared/source-control/listing/source-control-group-order' import type { SourceControlGroupOrder } from '../../../../src/shared/ui-chrome-types' import { settingsMember, settingsField } from '../../settings/mobile-settings-projection' +import { + normalizePRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../src/shared/review-refresh-policy' export type MobileSourceControlSettings = { groupOrder: SourceControlGroupOrder compareAgainstUpstream: boolean diffShowWhitespace: boolean + /** The host's PR auto-refresh cadence; the PR screen's own timer follows it. */ + prAutoRefresh: PRAutoRefreshCadence } const SOURCE_CONTROL_SETTING = { groupOrder: 'sourceControlGroupOrder', compareAgainstUpstream: 'sourceControlCompareAgainstUpstream', - diffShowWhitespace: 'diffShowWhitespace' + diffShowWhitespace: 'diffShowWhitespace', + prAutoRefresh: 'prAutoRefresh' } as const export const SOURCE_CONTROL_SETTINGS_KEYS: readonly string[] = Object.values(SOURCE_CONTROL_SETTING) @@ -32,7 +39,8 @@ const sourceControlSettingsReader: RpcCompatibleReader< value: { groupOrder: normalizeSourceControlGroupOrder(field(SOURCE_CONTROL_SETTING.groupOrder)), compareAgainstUpstream: field(SOURCE_CONTROL_SETTING.compareAgainstUpstream) === true, - diffShowWhitespace: field(SOURCE_CONTROL_SETTING.diffShowWhitespace) === true + diffShowWhitespace: field(SOURCE_CONTROL_SETTING.diffShowWhitespace) === true, + prAutoRefresh: normalizePRAutoRefreshCadence(field(SOURCE_CONTROL_SETTING.prAutoRefresh)) }, salvage: { droppedPaths: [], droppedCount: 0 } } diff --git a/mobile/src/source-control/settings/mobile-source-control-settings.ts b/mobile/src/source-control/settings/mobile-source-control-settings.ts index e9d0cce5794..f90c56db983 100644 --- a/mobile/src/source-control/settings/mobile-source-control-settings.ts +++ b/mobile/src/source-control/settings/mobile-source-control-settings.ts @@ -13,16 +13,19 @@ import { import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' -const DESKTOP_DEFAULTS: MobileSourceControlSettings = { +export const DESKTOP_SOURCE_CONTROL_DEFAULTS: MobileSourceControlSettings = { groupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, compareAgainstUpstream: false, - diffShowWhitespace: false + diffShowWhitespace: false, + prAutoRefresh: 'default' } +const DESKTOP_DEFAULTS = DESKTOP_SOURCE_CONTROL_DEFAULTS -const NO_INPUT = {} +export const NO_SOURCE_CONTROL_SETTINGS_INPUT = {} +const NO_INPUT = NO_SOURCE_CONTROL_SETTINGS_INPUT // A host without settingsChanged gets a reread on every mount and foreground, behind the cached copy. -const sourceControlSettingsQuery = defineHostQuery({ +export const sourceControlSettingsQuery = defineHostQuery({ family: 'settings.sourceControl', staleTime: 0, refetchOnWindowFocus: true, diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index 06184e27b1e..5fecd1ee389 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -1,12 +1,10 @@ import { normalizePRCheckHeadSha } from '../../../../shared/github/checks/pr-check-head' +import { getPRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' import { withGitHubRepoExecution, - githubRepoContext, - ghRepoExecOptions, type LocalGitExecOptions } from '../../repositories/github-repository-identity' import { runCoalescedProbe, type CoalescedProbes } from '../../../git/remotes/coalesced-probe' -import { codeHostReadExecutionScope as githubReadExecutionScope } from '../../../source-control/code-host-read-execution-scope' import type { PRCheckDetail } from '../../../../shared/github/checks/check-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/github/work-items/work-item-ssh-error' import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file' @@ -30,6 +28,14 @@ import { } from './pr-checks-response-mapping' import { parseActionsRunId } from './check-detail-field-mapping' import { checkRunTimes } from '../../../../shared/source-control/checks/pr-check-time' +import { + latestPRChecks, + prChecksProbeKey, + prChecksResourceKey, + producePRChecks, + readPacedPRChecks, + type PRChecksRead +} from './pr-checks-last-outcome' import { readRestPRCheckRuns, readRestPRCommitStatuses, @@ -238,36 +244,59 @@ async function readPRChecks( } } -const checksReads: CoalescedProbes = new Map() +const checksReads: CoalescedProbes = new Map() +/** `timed`: a client's timer tick, which this host may answer from its last read at its PR + * auto-refresh cadence. A manual read (`userRefresh`, `noCache`) always reaches the provider. */ export function getPRChecks( repoPath: string, prNumber: number, headSha?: string, prRepo?: GitHubApiRepository | null, - options?: { noCache?: boolean; userRefresh?: boolean }, + options?: { noCache?: boolean; userRefresh?: boolean; timed?: boolean }, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { return withGitHubRepoExecution(repoPath, connectionId, localGitOptions, async () => { - const key = JSON.stringify([ + const resource = prChecksResourceKey( repoPath, + connectionId, prNumber, - headSha ?? null, + headSha, prRepo ?? null, - Boolean(options?.noCache), - connectionId ?? null, - githubReadExecutionScope( - ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) - ) - ]) - return runCoalescedProbe( + localGitOptions + ) + if (options?.timed && !options.noCache && !options.userRefresh) { + const paced = readPacedPRChecks(resource, getPRAutoRefreshCadence()) + if (paced) { + return paced + } + } + // The resource key carries the review generation, so a read after a PR mutation never joins + // one that started before it; the producer records its own result at its own start order. + const probeKey = prChecksProbeKey(resource, Boolean(options?.noCache)) + const read = await runCoalescedProbe( checksReads, - key, - () => - readPRChecks(repoPath, prNumber, headSha, prRepo, options, connectionId, localGitOptions), + probeKey, + (ownsKey) => + producePRChecks( + resource, + ownsKey, + () => + readPRChecks( + repoPath, + prNumber, + headSha, + prRepo, + options, + connectionId, + localGitOptions + ), + () => checksReads.get(probeKey)?.promise + ), undefined, { userRefresh: options?.userRefresh === true } ) + return latestPRChecks(resource, read) }) } diff --git a/src/main/github/client/check/pr-checks-last-outcome.ts b/src/main/github/client/check/pr-checks-last-outcome.ts new file mode 100644 index 00000000000..08b3667aa4a --- /dev/null +++ b/src/main/github/client/check/pr-checks-last-outcome.ts @@ -0,0 +1,177 @@ +import type { PRCheckDetail } from '../../../../shared/github/checks/check-types' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../shared/github/repositories/repository-identity' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + reviewRefreshIntervalMs, + type PRAutoRefreshCadence +} from '../../../../shared/review-refresh-policy' +import { + hostedReviewRepoScope, + scopeGeneration +} from '../../../source-control/hosted-review-scope-generations' +import { codeHostReadExecutionScope } from '../../../source-control/code-host-read-execution-scope' +import { + ghRepoExecOptions, + githubRepoContext, + type LocalGitExecOptions +} from '../../repositories/github-repository-identity' +import { githubPRStackExecutionScope } from '../github-exec-scope' + +const MAX_ENTRIES = 256 + +type PRChecksReadStart = { startedAt: number; sequence: number } +export type PRChecksRead = { start: PRChecksReadStart; checks: PRCheckDetail[] } + +const lastReads = new Map() +let readSequence = 0 + +/** One PR head's checks as a resource: where the read runs (SSH connection or local/WSL), the + * repository's review generation, and the resolved read scope (gh account, cwd, environment). A + * changed account or a bumped generation is a different resource, so checks read under the + * previous credential or before a PR mutation are never answered to the current one. */ +export function prChecksResourceKey( + repoPath: string, + connectionId: string | null | undefined, + prNumber: number, + headSha: string | undefined, + prRepo: GitHubOwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} +): string { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + return JSON.stringify([ + githubPRStackExecutionScope(connectionId, localGitOptions), + repoPath, + scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))), + codeHostReadExecutionScope(ghOptions), + prNumber, + headSha ?? null, + prRepo ? [prRepo.host ?? null, prRepo.owner, prRepo.repo] : null + ]) +} + +function recordPRChecksRead(key: string, read: PRChecksRead): void { + const existing = lastReads.get(key) + if (existing && existing.start.sequence > read.start.sequence) { + return + } + lastReads.delete(key) + lastReads.set(key, read) + while (lastReads.size > MAX_ENTRIES) { + lastReads.delete(lastReads.keys().next().value!) + } +} + +export const prChecksProbeKey = (resource: string, noCache: boolean): string => + JSON.stringify([resource, noCache]) + +/** The provider reads of one resource that are running now, of either intent, including stale or + * user-refreshed reads a newer one replaced in the coalescer, and the newest answer one of them + * had accepted. It lives until the last of those reads has settled; one answer, no history. */ +type PRChecksOverlap = { running: number; latest?: PRChecksRead } +const overlaps = new Map() + +/** + * The producer of one coalesced checks read; joiners only await it. A timed read and a manual + * (`noCache`) read of the same resource can run side by side, and a stale or user-refreshed read + * can replace one still running, so finishing last proves nothing: the answer of the read that + * started last wins. A read that still owns its key publishes that answer. A replaced read + * publishes nothing and answers its callers with the newest accepted answer, even one the + * completed cache has since evicted, or with its successor's while that still runs; its own result + * is used only when nothing newer was accepted. + */ +export async function producePRChecks( + resource: string, + ownsKey: () => boolean, + readChecks: () => Promise, + successor: () => Promise | undefined +): Promise { + readSequence += 1 + const start = { startedAt: Date.now(), sequence: readSequence } + let overlap = overlaps.get(resource) + if (!overlap) { + overlap = { running: 0 } + overlaps.set(resource, overlap) + } + const shared = overlap + shared.running += 1 + const newer = () => + shared.latest && shared.latest.start.sequence > start.sequence ? shared.latest : undefined + try { + let own: PRChecksRead | undefined + let failure: unknown + try { + own = { start, checks: await readChecks() } + } catch (error) { + failure = error + } + if (!ownsKey()) { + const running = newer() ? undefined : successor() + if (running) { + try { + const replaced = await running + return newer() ?? replaced + } catch (error) { + // A successor that fails leaves this read's own answer as the newest there is. + failure = own ? failure : error + } + } + const answer = newer() ?? own + if (!answer) { + throw failure + } + return answer + } + const answer = newer() ?? own + if (!answer) { + throw failure + } + shared.latest = answer + recordPRChecksRead(resource, answer) + return answer + } finally { + shared.running -= 1 + if (shared.running === 0 && overlaps.get(resource) === shared) { + overlaps.delete(resource) + } + } +} + +/** What a caller of `read` receives: a read that started later and already settled is newer. */ +export function latestPRChecks(key: string, read: PRChecksRead): PRCheckDetail[] { + const latest = lastReads.get(key) + return latest && latest.start.sequence > read.start.sequence ? latest.checks : read.checks +} + +/** With a slower host cadence, a client's timed checks read reuses this host's last full read of the + * same PR head until the host's own selected interval has passed ("off": until a foreground, + * refresh or changed-state read). A different head is a different key, so a push always reads. */ +export function readPacedPRChecks( + key: string, + cadence: PRAutoRefreshCadence +): PRCheckDetail[] | null { + if (cadence === 'default') { + return null + } + const entry = lastReads.get(key) + if (!entry) { + return null + } + // Checks are only polled while they may still move, so pace them like a selected open PR. + const interval = reviewRefreshIntervalMs( + { state: 'open', checksStatus: 'pending', hasReview: true, selected: true }, + cadence + ) + if (interval !== null && Date.now() - entry.start.startedAt >= interval) { + return null + } + return entry.checks +} + +export function _prChecksResultCountForTests(): { completed: number; running: number } { + return { completed: lastReads.size, running: overlaps.size } +} + +export function _resetPacedPRChecksForTests(): void { + lastReads.clear() + overlaps.clear() +} diff --git a/src/main/github/client/lookup/pr-branch-read-resources.ts b/src/main/github/client/lookup/pr-branch-read-resources.ts index 46d94d85763..9d19e3d8101 100644 --- a/src/main/github/client/lookup/pr-branch-read-resources.ts +++ b/src/main/github/client/lookup/pr-branch-read-resources.ts @@ -1,17 +1,114 @@ import type { CoalescedProbes } from '../../../git/remotes/coalesced-probe' -import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' import type { readTimedPROutcome } from './pr-last-outcome' +import type { prBranchReadKeys } from './pr-read-identity' +/** + * One exact PR read (scope, branch, links, options) and what reads it: its coalesced timed and + * fresh probes, the provider reads running for it (including ones a fresh read replaced), the + * start of the newest one, and failures a retry still owes, each with what a newer start retires. + * It lives until none of those remain. + */ +export type PRBranchReadResource = { + probes: CoalescedProbes + running: number + latestStart: number + failures: Set<() => void> +} + +export type PRReadSettlement = ( + outcome: PRRefreshOutcome, + producer: PRBranchReadProducer +) => PRRefreshOutcome + +export type PreparedPRBranchRead = { + key: string + matchesRequest: (request: Parameters) => boolean + reprepare: () => Promise + cached: () => ReturnType + isCurrent: () => boolean + accepts: (outcome: PRRefreshOutcome) => boolean + didRead: () => boolean + release: () => void + hold: () => () => void + /** The read's shared result; null only when it had to keep its result and found no room. */ + readResult: ( + settle?: PRReadSettlement, + requireRetention?: boolean + ) => Promise + canRetain: () => boolean + scheduleError: (expected: PRRefreshOutcome, outcome: PRRefreshOutcome) => void + pending: () => Promise | null + withExecution: (operation: () => Promise) => Promise + /** The read's own outcome, for callers that reply with it as it is. */ + read: (settle?: PRReadSettlement) => Promise +} + +/** What a provider read finished with, shared by everything that joined it: its outcome (settled + * once, by the read itself), its start and whether it is still the newest read of its request in + * scope. Each consumer decides what it may publish from this, not from whether it settled. */ export type PRBranchReadResult = { outcome: PRRefreshOutcome sequence: number current: () => boolean } -type PRBranchReadResource = { - probes: CoalescedProbes - running: number - latestStart: number +/** A failed provider read's claim on its resource while a retry owes it: current until a newer + * provider read of the same resource starts, or its scope changes. Released exactly once; a + * newer start runs its `onSuperseded` retirement while it is still kept. */ +export type PRReadFailureHold = { + current: () => boolean + release: () => void + onSuperseded: (retire: () => void) => void +} + +/** `hold`: for a full read, claims this provider read's failure for a retry it owes, whether or not + * the exact cache kept the outcome. `current`: no newer provider read of the request has started + * and its scope is unchanged. */ +export type PRBranchReadProducer = { + retained: boolean + hold?: () => PRReadFailureHold + current?: () => boolean +} + +/** A read that had to keep its outcome in the exact cache and found no room started nothing. */ +export class PRLookupRetentionRefused extends Error {} + +export function throwUnlessRetentionRefused(error: unknown): void { + if (!(error instanceof PRLookupRetentionRefused)) { + throw error + } +} + +/** What a refresh key is made of, for the read a caller made without its own settlement. */ +export type PRBranchReadRequest = Pick< + GitHubPRRefreshCandidate, + 'repoPath' | 'branch' | 'localGitOptions' +> & + Partial> + +export type PRBranchReadCompletion = ( + outcome: PRRefreshOutcome, + producer: PRBranchReadProducer, + prepared: PreparedPRBranchRead, + request: PRBranchReadRequest +) => PRRefreshOutcome + +let completion: PRBranchReadCompletion | null = null + +/** The refresh owner settles full reads whose caller brought no settlement (another client's + * read, a provider lookup), so a visible row's next automatic work follows the newest read. */ +export function setPRBranchReadCompletion(next: PRBranchReadCompletion | null): void { + completion = next +} + +export function completePRBranchRead( + ...args: Parameters +): PRRefreshOutcome { + return completion ? completion(...args) : args[0] } const resources = new Map() @@ -19,12 +116,13 @@ const resources = new Map() export function prBranchReadResource(key: string): PRBranchReadResource { let resource = resources.get(key) if (!resource) { - resource = { probes: new Map(), running: 0, latestStart: 0 } + resource = { probes: new Map(), running: 0, latestStart: 0, failures: new Set() } resources.set(key, resource) } return resource } +/** A probe still running for the resource, joinable while younger than two minutes. */ export function pendingPRBranchRead( key: string, intent: 'fresh' | 'timed' @@ -33,29 +131,48 @@ export function pendingPRBranchRead( return pending && Date.now() - pending.startedAt < 2 * 60_000 ? pending.promise : null } +/** Drops the resource once no probe, provider read or owed retry refers to it. */ export function retirePRBranchReadResource(key: string, resource: PRBranchReadResource): void { - if (resource.probes.size === 0 && resource.running === 0 && resources.get(key) === resource) { + if ( + resource.probes.size === 0 && + resource.running === 0 && + resource.failures.size === 0 && + resources.get(key) === resource + ) { resources.delete(key) } } +/** Marks an actual provider read of the resource as started; returns its end. */ export function startPRBranchRead( key: string, resource: PRBranchReadResource, sequence: number -): { current: () => boolean; finish: () => void } { +): () => void { resource.running += 1 - resource.latestStart = sequence - return { - current: () => - Math.max(resource.latestStart, resources.get(key)?.latestStart ?? 0) === sequence, - finish: () => { - resource.running -= 1 - retirePRBranchReadResource(key, resource) - } + resource.latestStart = Math.max(resource.latestStart, sequence) + for (const retire of Array.from(resource.failures)) { + retire() + } + return () => { + resource.running -= 1 + retirePRBranchReadResource(key, resource) } } +/** No provider read of the request started after this one, even once its resource was retired and + * a new one created for later reads. */ +function newestPRBranchRead( + key: string, + resource: PRBranchReadResource, + sequence: number +): boolean { + return Math.max(resource.latestStart, resources.get(key)?.latestStart ?? 0) <= sequence +} + +/** What a consumer of a finished read may publish as its request's current state: the read's own + * outcome while it is still the newest read in scope; otherwise a newer read's accepted exact + * result once one finished, or nothing while that read runs or kept no result. */ export function deliverablePRBranchOutcome( prepared: Pick, result: PRBranchReadResult @@ -67,28 +184,45 @@ export function deliverablePRBranchOutcome( return cached && cached.sequence > result.sequence ? cached.outcome : null } -export type PRReadSettlement = ( - outcome: PRRefreshOutcome, - producer: { retained: boolean } -) => PRRefreshOutcome +/** The settlement context of an actual provider read: whether the exact cache kept its outcome, a + * full read's failure claim, and whether it is still the newest read of its request in scope. */ +export function prBranchReadProducer( + key: string, + resource: PRBranchReadResource, + sequence: number, + inScope: () => boolean, + { retained, full }: { retained: boolean; full: boolean } +): PRBranchReadProducer { + return { + retained, + hold: full ? () => holdPRBranchReadFailure(key, resource, sequence, inScope) : undefined, + current: () => newestPRBranchRead(key, resource, sequence) && inScope() + } +} -export type PreparedPRBranchRead = { - key: string - reprepare: () => Promise - cached: () => ReturnType - isCurrent: () => boolean - accepts: (outcome: PRRefreshOutcome) => boolean - didRead: () => boolean - release: () => void - hold: () => () => void - readRetained: (settle?: PRReadSettlement) => Promise - canRetain: () => boolean - scheduleError: (expected: PRRefreshOutcome, outcome: PRRefreshOutcome) => void - readResult: ( - settle?: PRReadSettlement, - requireRetention?: boolean - ) => Promise - pending: () => Promise | null - withExecution: (operation: () => Promise) => Promise - read: (settle?: PRReadSettlement) => Promise +function holdPRBranchReadFailure( + key: string, + resource: PRBranchReadResource, + sequence: number, + inScope: () => boolean +): PRReadFailureHold { + let retire = (): void => {} + const notice = (): void => retire() + resource.failures.add(notice) + return { + current: () => + resource.failures.has(notice) && newestPRBranchRead(key, resource, sequence) && inScope(), + release: () => { + if (resource.failures.delete(notice)) { + retirePRBranchReadResource(key, resource) + } + }, + onSuperseded: (next) => { + retire = next + } + } +} + +export function _prBranchReadResourceCountForTests(): number { + return resources.size } diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts index e4952ecfe99..2cf56e2b7d7 100644 --- a/src/main/github/client/lookup/pr-for-branch-outcome.ts +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -22,7 +22,13 @@ import { recordPRLastOutcome, startPRLookup } from './pr-last-outcome' -import { prLookupScope, lastOutcomeKey, completeReadKey } from './pr-read-identity' +import { + prLookupScope, + lastOutcomeKey, + completeReadKey, + exactRequestMatcher, + snapshotPRLookupOptions +} from './pr-read-identity' import { refreshPRPolicyContext, recordPRPolicyOutcome } from './pr-quiet-policy-evidence' import { hostedReviewRepoScope, @@ -30,15 +36,18 @@ import { } from '../../../source-control/hosted-review-scope-generations' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { - prBranchReadResource, + completePRBranchRead, pendingPRBranchRead, + prBranchReadProducer, + prBranchReadResource, retirePRBranchReadResource, startPRBranchRead, + PRLookupRetentionRefused, + throwUnlessRetentionRefused, type PRBranchReadResult, type PRReadSettlement, type PreparedPRBranchRead } from './pr-branch-read-resources' -class PRLookupRetentionRefused extends Error {} /** A phone read may take the last lookup's open PR; it was already observed when it was looked up. */ export async function readRecentPhonePROutcome( @@ -80,21 +89,7 @@ export async function preparePRBranchRead( fallbackPRNumber?: number | null, options: GitHubPRBranchLookupOptions = {} ): Promise { - options = { - ...options, - ...(options.localGitExecOptions - ? { - localGitExecOptions: { - ...options.localGitExecOptions, - ...(options.localGitExecOptions.ghAccount - ? { ghAccount: { ...options.localGitExecOptions.ghAccount } } - : {}) - } - } - : {}), - ...(options.sharedLinkPaths ? { sharedLinkPaths: [...options.sharedLinkPaths] } : {}), - ...(options.exactRepository ? { exactRepository: { ...options.exactRepository } } : {}) - } + options = snapshotPRLookupOptions(options) const reprepare = () => outsideGitHubRepoExecution(() => preparePRBranchRead(repoPath, branch, linkedPRNumber, connectionId, fallbackPRNumber, options) @@ -114,6 +109,7 @@ export async function preparePRBranchRead( ) { return { key: '', + matchesRequest: () => false, reprepare, cached: () => null, isCurrent: () => true, @@ -121,7 +117,6 @@ export async function preparePRBranchRead( didRead: () => false, release: () => {}, hold: () => () => {}, - readRetained: async () => ({ kind: 'no-pr', fetchedAt: Date.now() }), readResult: async () => ({ outcome: { kind: 'no-pr', fetchedAt: Date.now() }, sequence: 0, @@ -136,6 +131,17 @@ export async function preparePRBranchRead( } const policyRevision = refreshPRPolicyContext() const scope = prLookupScope(repoPath, connectionId, options) + const matchesRequest = exactRequestMatcher( + [ + repoPath, + branch, + linkedPRNumber ?? null, + connectionId ?? null, + fallbackPRNumber ?? null, + options + ], + scope.accountFromProject + ) const { localGitOptions, ghOptions, executionScope } = scope const key = completeReadKey( repoPath, @@ -148,12 +154,23 @@ export async function preparePRBranchRead( const ownerKey = lastOutcomeKey(repoPath, branchName, scope) const coalescingKey = options.resetQuietBackoff || options.userRefresh ? 'fresh' : 'timed' const pendingAt = (intent: 'fresh' | 'timed') => pendingPRBranchRead(key, intent) + const isCurrent = () => + outsideGitHubRepoExecution(() => { + const current = prLookupScope(repoPath, connectionId, options) + return ( + current.accountFromProject === scope.accountFromProject && + lastOutcomeKey(repoPath, branchName, current) === ownerKey + ) + }) const narrowed = options.checkPageLimit !== undefined || options.exactRepository || options.includeDerivedData === false let didRead = false - const readCore = (requireRetention = false, settle?: PRReadSettlement) => + const readCore = ( + requireRetention: boolean, + settle?: PRReadSettlement + ): Promise => withExecution(() => { const resource = prBranchReadResource(key) return ( @@ -174,7 +191,7 @@ export async function preparePRBranchRead( if (requireRetention && !retained) { throw new PRLookupRetentionRefused() } - const producer = startPRBranchRead(key, resource, start.sequence) + const ended = startPRBranchRead(key, resource, start.sequence) didRead = true try { await acquire() @@ -215,27 +232,32 @@ export async function preparePRBranchRead( cached: () => readTimedPROutcome(ownerKey, key) }) } - if ( - settle && - prepared.isCurrent() && - producer.current() && - (retained ? readTimedPROutcome(ownerKey, key)?.outcome === outcome : ownsKey()) - ) { - outcome = settle(outcome, { retained }) + const producer = prBranchReadProducer(key, resource, start.sequence, isCurrent, { + retained, + full: !narrowed + }) + // Settled once, only by the newest read of its request, still in scope, whose + // result the exact record kept or whose probe it still owns. A result the record + // refused (a newer read, or its owner's invalidation) is never current. + const refused = retained && readTimedPROutcome(ownerKey, key)?.outcome !== outcome + if (refused || (!retained && !ownsKey()) || !producer.current?.()) { + return { sequence: start.sequence, current: () => false, outcome } } + const settled = settle + ? settle(outcome, producer) + : completePRBranchRead(outcome, producer, prepared, request) return { - outcome, sequence: start.sequence, + outcome: settled, current: () => - producer.current() && - prepared.isCurrent() && - (!retained || prepared.accepts(outcome)) + producer.current!() && + (!retained || readTimedPROutcome(ownerKey, key)?.outcome === settled) } } finally { if (retained) { releaseExactPRLookup(key, start.sequence) } - producer.finish() + ended() } }, 2 * 60_000, @@ -243,47 +265,29 @@ export async function preparePRBranchRead( ).finally(() => retirePRBranchReadResource(key, resource)) ) }) - const readResult = async ( - settle?: PRReadSettlement, - requireRetention = false - ): Promise => { - try { - return await readCore(requireRetention, settle) - } catch (error) { - if (!(error instanceof PRLookupRetentionRefused)) { - throw error - } + // A join of a read that had to keep its result, and found no room, reads for itself. + const readResult = (settle?: PRReadSettlement, requireRetention = false) => + readCore(requireRetention, settle).catch((error: unknown) => { + throwUnlessRetentionRefused(error) return requireRetention ? null : readCore(false, settle) - } - } - const read = async (settle?: PRReadSettlement): Promise => - (await readResult(settle))!.outcome - const readRetained = async (settle?: PRReadSettlement): Promise => - (await readResult(settle, true))?.outcome ?? null + }) + const request = { repoPath, branch, linkedPRNumber, connectionId, localGitOptions } const prepared: PreparedPRBranchRead = { key, + matchesRequest, reprepare, cached: () => readTimedPROutcome(ownerKey, key), - isCurrent: () => - outsideGitHubRepoExecution( - () => - lastOutcomeKey( - repoPath, - branchName, - prLookupScope(repoPath, connectionId, options) - ) === ownerKey - ), + isCurrent, accepts: (outcome) => readTimedPROutcome(ownerKey, key)?.outcome === outcome, didRead: () => didRead, release: () => {}, hold: () => holdExactPROutcome(key), - readRetained, readResult, canRetain: () => canRetainPROutcome(key), scheduleError: (expected, outcome) => scheduleExactPRError(key, expected, outcome), pending: () => pendingAt('fresh') ?? pendingAt('timed'), withExecution, - read + read: async (settle) => (await readResult(settle))!.outcome } return prepared } diff --git a/src/main/github/client/lookup/pr-last-outcome.ts b/src/main/github/client/lookup/pr-last-outcome.ts index cc2a6032eb4..ba7c0ec4210 100644 --- a/src/main/github/client/lookup/pr-last-outcome.ts +++ b/src/main/github/client/lookup/pr-last-outcome.ts @@ -205,6 +205,7 @@ export function readTimedPROutcome( ): { outcome: PRRefreshOutcome quietIntervalMs: number | null + /** The start of the provider read that produced it. */ sequence: number } | null { const entry = exactOutcomes.get(requestKey) diff --git a/src/main/github/client/lookup/pr-read-identity.ts b/src/main/github/client/lookup/pr-read-identity.ts index ba55c8871b9..99eac7ab0fa 100644 --- a/src/main/github/client/lookup/pr-read-identity.ts +++ b/src/main/github/client/lookup/pr-read-identity.ts @@ -15,10 +15,12 @@ export function prLookupScope( options: GitHubPRBranchLookupOptions ) { const localGitOptions = hostedReviewLocalGitOptionArgs(options)[0] ?? {} - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(context) return { localGitOptions, ghOptions, + accountFromProject: context.ghAccountFromProject === true, executionScope: githubPRStackExecutionScope(connectionId, localGitOptions), generation: scopeGeneration( hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId })) @@ -41,6 +43,47 @@ export function lastOutcomeKey( ]) } +export function snapshotPRLookupOptions( + options: GitHubPRBranchLookupOptions +): GitHubPRBranchLookupOptions { + return { + ...options, + ...(options.localGitExecOptions + ? { + localGitExecOptions: { + ...options.localGitExecOptions, + ...(options.localGitExecOptions.ghAccount + ? { ghAccount: { ...options.localGitExecOptions.ghAccount } } + : {}) + } + } + : {}), + ...(options.sharedLinkPaths ? { sharedLinkPaths: [...options.sharedLinkPaths] } : {}), + ...(options.exactRepository ? { exactRepository: { ...options.exactRepository } } : {}) + } +} + +function completeReadInputs( + repoPath: string, + branchName: string, + linkedPRNumber: number | null | undefined, + fallbackPRNumber: number | null | undefined, + options: GitHubPRBranchLookupOptions +) { + return [ + repoPath, + branchName, + linkedPRNumber ?? null, + fallbackPRNumber ?? null, + options.acceptMergedFallbackPR ?? false, + options.currentHeadOid ?? null, + options.checkPageLimit ?? null, + options.exactRepository ?? null, + options.includeDerivedData !== false, + options.sharedLinkPaths ?? null + ] +} + export function completeReadKey( repoPath: string, branchName: string, @@ -51,33 +94,70 @@ export function completeReadKey( ): string { return JSON.stringify([ scope.executionScope, - repoPath, scope.generation, - branchName, - linkedPRNumber ?? null, - fallbackPRNumber ?? null, - options.acceptMergedFallbackPR ?? false, - options.currentHeadOid ?? null, - options.checkPageLimit ?? null, - options.exactRepository ?? null, - options.includeDerivedData !== false, - options.sharedLinkPaths ?? null, - scope.readScope + scope.readScope, + ...completeReadInputs(repoPath, branchName, linkedPRNumber, fallbackPRNumber, options) ]) } -export function cachedPRBranchOutcome( +/** The owner and exact keys a read of this request would use now. */ +export function prBranchReadKeys( repoPath: string, branch: string, linkedPRNumber: number | null, connectionId: string | null, fallbackPRNumber: number | null, options: GitHubPRBranchLookupOptions -): ReturnType { +): [owner: string, exact: string] { const branchName = branch.replace(/^refs\/heads\//, '') const scope = prLookupScope(repoPath, connectionId, options) - return readTimedPROutcome( + return [ lastOutcomeKey(repoPath, branchName, scope), completeReadKey(repoPath, branchName, linkedPRNumber, fallbackPRNumber, options, scope) - ) + ] +} + +/** Repository and execution inputs that decide a request's scope (the account and generation it + * reads with): two requests with the same inputs share one scope sampled at one moment. */ +function scopeInputs( + repoPath: string, + connectionId: string | null, + options: GitHubPRBranchLookupOptions, + accountFromProject: boolean +): string { + const local = hostedReviewLocalGitOptionArgs(options)[0] ?? {} + return JSON.stringify([ + repoPath, + connectionId ?? null, + local.wslDistro ?? null, + accountFromProject ? null : (local.ghAccount ?? null) + ]) +} + +/** Compares request inputs without discovering accounts; callers fence the prepared scope separately. */ +export function exactRequestMatcher( + request: Parameters, + accountFromProject = false +): (other: Parameters) => boolean { + const identity = ([repoPath, branch, linked, connectionId, fallback, options]: Parameters< + typeof prBranchReadKeys + >) => + JSON.stringify([ + scopeInputs(repoPath, connectionId, options, accountFromProject), + ...completeReadInputs( + repoPath, + branch.replace(/^refs\/heads\//, ''), + linked, + fallback, + options + ) + ]) + const expected = identity(request) + return (other) => identity(other) === expected +} + +export function cachedPRBranchOutcome( + ...request: Parameters +): ReturnType { + return readTimedPROutcome(...prBranchReadKeys(...request)) } diff --git a/src/main/github/pr-watch/pr-earlier-head-jobs-cadence.test.ts b/src/main/github/pr-watch/pr-earlier-head-jobs-cadence.test.ts new file mode 100644 index 00000000000..fd204672616 --- /dev/null +++ b/src/main/github/pr-watch/pr-earlier-head-jobs-cadence.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { setPRAutoRefreshCadence } from '../../../shared/review-refresh-policy' + +const mocks = vi.hoisted(() => ({ + read: vi.fn(async () => []), + pending: vi.fn(() => [{ headSha: 'old-head', movedAt: 1, expiresAt: null }]) +})) +vi.mock('../pull-requests/refresh/pr-refresh-coordinator', () => ({ schedulePRWatchRead: vi.fn() })) +vi.mock('./pr-head-check-read', () => ({ readPRHeadCheckRollup: mocks.read })) + +import { PREarlierHeadJobs } from './pr-earlier-head-jobs' +import { prWatchPlanDueAt } from '../pull-requests/refresh/pr-refresh-watch-jobs' + +afterEach(() => setPRAutoRefreshCadence('default')) + +it.each([ + ['default', 120_000], + ['reduced', 10 * 60_000], + ['minimal', 20 * 60_000], + // A chat is waiting for the result, so off slows to minimal instead of stopping. + ['off', 20 * 60_000] +] as const)( + 'an earlier head’s next read follows the %s watch cadence', + async (cadence, interval) => { + setPRAutoRefreshCadence(cadence) + const jobs = new PREarlierHeadJobs({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read-only probe only requests pending heads. + database: { read: async () => mocks.pending() } as never, + apply: async () => false, + logger: { warn: vi.fn() }, + now: () => 1_000 + }) + const readAt = await jobs.read( + { prKey: 'github.com/acme/repo#7', headSha: 'old-head' }, + { repoId: 'r', repoPath: '/repo', branch: 'topic', repoKind: 'git', cacheKey: 'k' } + ) + expect(readAt).toBe(1_000) + expect(prWatchPlanDueAt({ kind: 'head', at: readAt! })).toBe(1_000 + interval) + } +) diff --git a/src/main/github/pr-watch/pr-earlier-head-jobs.ts b/src/main/github/pr-watch/pr-earlier-head-jobs.ts index 3569cf8477d..e3a6617a638 100644 --- a/src/main/github/pr-watch/pr-earlier-head-jobs.ts +++ b/src/main/github/pr-watch/pr-earlier-head-jobs.ts @@ -6,7 +6,6 @@ import { prWatchQueueKey, type PRWatchJob } from '../pull-requests/refresh/pr-re import { readPRHeadCheckRollup } from './pr-head-check-read' import { parsePRKey } from './pr-watch-types' -const HEAD_CHECKS_INTERVAL_MS = 120_000 const SPACING_MS = 10_000 /** Heads a PR moved off before their checks were final, each read by SHA until it is or expires. */ @@ -86,6 +85,6 @@ export class PREarlierHeadJobs { error }) } - return this.stopped ? null : now() + HEAD_CHECKS_INTERVAL_MS + return this.stopped ? null : now() } } diff --git a/src/main/github/pr-watch/pr-watch-service.ts b/src/main/github/pr-watch/pr-watch-service.ts index 355204dd264..f48d5928808 100644 --- a/src/main/github/pr-watch/pr-watch-service.ts +++ b/src/main/github/pr-watch/pr-watch-service.ts @@ -12,7 +12,8 @@ import { observedPRCheckRollup } from '../client/lookup/pr-refresh-outcome-assem import * as refresh from '../pull-requests/refresh/pr-refresh-coordinator' import { prWatchSettled, - prWatchNextDueAt, + prWatchPlanDueAt, + prWatchReadPlan, type PRWatchJob } from '../pull-requests/refresh/pr-refresh-watch-jobs' import { PREarlierHeadJobs } from './pr-earlier-head-jobs' @@ -262,9 +263,10 @@ export class PRWatchService { await this.endChatWatch(prKey) return } - const nextDueAt = prWatchNextDueAt(outcome) - if (nextDueAt !== null) { - refresh.schedulePRWatchRead({ prKey }, active, nextDueAt, { defer: true }) + const plan = prWatchReadPlan(outcome) + const nextDueAt = plan && prWatchPlanDueAt(plan) + if (plan && nextDueAt !== null) { + refresh.schedulePRWatchRead({ prKey }, active, nextDueAt, { defer: true, plan }) } } @@ -310,9 +312,8 @@ export class PRWatchService { (key) => this.endChatWatch(key) ) } - private readonly failed = (error: unknown): void => { + private readonly failed = (error: unknown): void => this.deps.logger.warn('updating CI-line watches failed', { scope: 'pr-watch', error }) - } private inOrder(prKey: string, run: () => Promise): Promise { return orderPRWatchOutcome(this.outcomes, prKey, run) diff --git a/src/main/github/pull-requests/refresh/hosted-pr-read-jobs.ts b/src/main/github/pull-requests/refresh/hosted-pr-read-jobs.ts index ecd7a1d962f..f233a76d56c 100644 --- a/src/main/github/pull-requests/refresh/hosted-pr-read-jobs.ts +++ b/src/main/github/pull-requests/refresh/hosted-pr-read-jobs.ts @@ -3,9 +3,11 @@ import type { PRRefreshOutcome, PRSnapshotReply } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' -import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy' -import { deliverablePRBranchOutcome } from '../../client/lookup/pr-branch-read-resources' +import { getPRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' +import { freshTimedPROutcome } from './pr-refresh-candidate-policy' import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' +import { deliverablePRBranchOutcome } from '../../client/lookup/pr-branch-read-resources' +import { hostedPRReadSettlement } from './hosted-pr-read-settlement' import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' import type { PRRefreshPacing } from './pr-refresh-pacing' import type { PRRefreshRetryState } from './pr-refresh-retry-state' @@ -40,31 +42,16 @@ export class HostedPRReadJobs { ) => void ) {} + private get owners(): Parameters[0] { + return { queue: this.queue, retry: this.retry, observe: this.observe } + } + has(prepared: PreparedPRBranchRead): boolean { return this.jobs.has(`hosted::${prepared.key}`) } fresh(prepared: PreparedPRBranchRead): PRRefreshOutcome | null { - return this.freshOutcome(prepared.cached()) - } - - freshOutcome( - cached: ReturnType, - selected = true - ): PRRefreshOutcome | null { - if (!cached || cached.outcome.kind === 'upstream-error') { - return null - } - const outcome = cached.outcome - const interval = - cached.quietIntervalMs ?? - reviewRefreshIntervalMs({ - state: outcome.kind === 'found' ? outcome.pr.state : null, - checksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - hasReview: outcome.kind === 'found', - selected - }) - return interval === null || Date.now() - outcome.fetchedAt < interval ? outcome : null + return freshTimedPROutcome(prepared.cached()) } private async legacyRead( @@ -73,11 +60,8 @@ export class HostedPRReadJobs { ): Promise { const current = await prepared.reprepare() try { - const result = await current.readResult((outcome) => { - this.observe(candidate, outcome) - return outcome - }) - return result!.outcome + const settle = hostedPRReadSettlement(this.owners, candidate, current, false) + return (await current.readResult(settle))!.outcome } finally { current.release() } @@ -93,6 +77,7 @@ export class HostedPRReadJobs { ) } + /** When a client should ask again for this request: the next budgeted start, or its gate. */ retryAfterMs(prepared: PreparedPRBranchRead): number { return Math.max( RETRY_MS, @@ -138,26 +123,16 @@ export class HostedPRReadJobs { acceptRevalidationOnly: boolean ): Promise { try { - const result = await prepared.readResult((outcome) => { - const settled = - acceptRevalidationOnly && outcome.kind === 'upstream-error' - ? this.retry.settleProducerError( - outcome, - `hosted::${prepared.key}`, - { ...candidate, isSelected: false }, - prepared, - this.queue, - true - ) - : outcome - this.observe(candidate, settled) - return settled - }, acceptRevalidationOnly) - const outcome = result - ? acceptRevalidationOnly + const result = await prepared.readResult( + hostedPRReadSettlement(this.owners, candidate, prepared, acceptRevalidationOnly), + acceptRevalidationOnly + ) + // A client that takes receipts gets only the request's current result; another gets its own. + const outcome = !result + ? null + : acceptRevalidationOnly ? deliverablePRBranchOutcome(prepared, result) : result.outcome - : null return await this.complete(prepared, candidate, outcome, acceptRevalidationOnly) } finally { prepared.release() @@ -191,7 +166,9 @@ export class HostedPRReadJobs { } const fallback = () => this.complete(prepared, candidate, null, acceptRevalidationOnly) const gate = this.gateUntil(prepared) - if (prepared.cached()?.outcome.kind === 'upstream-error' && gate > Date.now()) { + // Off plans no automatic error retry; a foreground or manual read retries instead. + const failed = prepared.cached()?.outcome.kind === 'upstream-error' + if (failed && (gate > Date.now() || getPRAutoRefreshCadence() === 'off')) { return fallback() } let job = this.jobs.get(key) @@ -227,10 +204,7 @@ export class HostedPRReadJobs { if (this.jobs.get(key) === job) { this.jobs.delete(key) } - const listenersAtCompletion = Array.from(listeners) - for (const listener of listenersAtCompletion) { - listener(outcome) - } + Array.from(listeners).forEach((listener) => listener(outcome)) listeners.clear() } } @@ -291,18 +265,45 @@ export class HostedPRReadJobs { } }) } + /** A cadence change: Off retires every job not yet started, including one the drainer holds + * while it awaits quota (ending its lease makes the drainer drop it); others wait for their + * retimed gate. */ + retime(): void { + const off = getPRAutoRefreshCadence() === 'off' + for (const [key, job] of this.jobs) { + const entry = this.queue.get(key) + if (job.running) { + continue + } + if (off) { + job.expiresAt = 0 + if (!(entry?.hosted === job && this.completeFromCache(entry))) { + job.finish(null) + } + } else if (entry?.hosted === job) { + entry.dueAt = Math.min(job.expiresAt, Math.max(Date.now(), this.gateUntil(job.prepared))) + } + } + } + + /** Ends a job that need not start: its answer is fresh, its scope changed, its lease ended, or it + * is no longer the job this owner keeps for its key (retired while the drainer awaited quota, + * maybe replaced). Only its own queue entry is removed. */ completeFromCache(entry: PRRefreshQueueEntry): boolean { const job = entry.hosted if (!job) { return false } - const current = job.prepared.isCurrent() - const fresh = current ? this.fresh(job.prepared) : null - if (job.expiresAt > Date.now() && current && !fresh && job.prepared.canRetain()) { + const fresh = job.prepared.isCurrent() ? this.fresh(job.prepared) : null + const live = + this.jobs.get(entry.key) === job && job.prepared.isCurrent() && job.expiresAt > Date.now() + if (live && !fresh && job.prepared.canRetain()) { return false } - this.queue.delete(entry.key) - job.finish(current && job.expiresAt > Date.now() ? fresh : null) + if (this.queue.get(entry.key) === entry) { + this.queue.delete(entry.key) + } + job.finish(live ? fresh : null) return true } } diff --git a/src/main/github/pull-requests/refresh/hosted-pr-read-settlement.ts b/src/main/github/pull-requests/refresh/hosted-pr-read-settlement.ts new file mode 100644 index 00000000000..33d84f9b266 --- /dev/null +++ b/src/main/github/pull-requests/refresh/hosted-pr-read-settlement.ts @@ -0,0 +1,44 @@ +import type { + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import { + completePRBranchRead, + type PRReadSettlement, + type PreparedPRBranchRead +} from '../../client/lookup/pr-branch-read-resources' +import type { PRRefreshQueue } from './pr-refresh-queue' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' + +/** + * Settles a read a paired client's request started, once, and only while it is the newest read of + * its request in scope: an opted job's failure keeps the job's own retry, the read plans a visible + * row of its request, and observers see it. + */ +export function hostedPRReadSettlement( + owners: { + queue: PRRefreshQueue + retry: PRRefreshRetryState + observe: (candidate: GitHubPRRefreshCandidate, outcome: PRRefreshOutcome) => void + }, + candidate: GitHubPRRefreshCandidate, + prepared: PreparedPRBranchRead, + job: boolean +): PRReadSettlement { + return (outcome, producer) => { + const settled = + job && outcome.kind === 'upstream-error' + ? owners.retry.settleProducerError( + outcome, + `hosted::${prepared.key}`, + { ...candidate, isSelected: false }, + prepared, + owners.queue + ) + : outcome + const own = job ? { ...producer, retained: false } : producer + completePRBranchRead(settled, own, prepared, candidate) + owners.observe(candidate, settled) + return settled + } +} diff --git a/src/main/github/pull-requests/refresh/pr-auto-refresh-cadence-sync.ts b/src/main/github/pull-requests/refresh/pr-auto-refresh-cadence-sync.ts new file mode 100644 index 00000000000..0ed05a3a86d --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-auto-refresh-cadence-sync.ts @@ -0,0 +1,27 @@ +import type { RuntimeStore } from '../../../runtime/construction/runtime-store-contract' +import { + getPRAutoRefreshCadence, + setPRAutoRefreshCadence +} from '../../../../shared/review-refresh-policy' +import { retimePRRefreshesForCadence } from './pr-refresh-coordinator' + +/** Apply this host's PR auto-refresh setting to every main-process poller, now and on change. + * Runs for desktop and headless `orca serve` alike, so an operator can cap a shared box. */ +export function syncPRAutoRefreshCadence( + store: Pick | null +): void { + if (!store?.getSettings) { + return + } + setPRAutoRefreshCadence(store.getSettings().prAutoRefresh) + store.onSettingsChanged?.((updates, settings) => { + if (!('prAutoRefresh' in updates)) { + return + } + const before = getPRAutoRefreshCadence() + setPRAutoRefreshCadence(settings.prAutoRefresh) + if (getPRAutoRefreshCadence() !== before) { + retimePRRefreshesForCadence() + } + }) +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-admission.ts b/src/main/github/pull-requests/refresh/pr-refresh-admission.ts new file mode 100644 index 00000000000..c8d73c0d15c --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-admission.ts @@ -0,0 +1,217 @@ +import { + automaticDueAt, + candidateReadRequest, + isBudgetedBackground +} from './pr-refresh-candidate-policy' +import { exactRequestMatcher } from '../../client/lookup/pr-read-identity' +import { usesActiveRefreshPacing, type PRRefreshPacing } from './pr-refresh-pacing' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' +import type { PRRefreshVisibility } from './pr-refresh-visibility' +import type { PRWatchJobRunner } from './pr-refresh-watch-jobs' + +/** + * Whether a dequeued read may start, decided from current state at each wait it crosses: after + * quota, the plan its own reason still has; after its read was prepared, the failure that still + * owns its request. Work that may not start yet goes back to the queue, merged with what was + * queued for its key meanwhile. + */ +export class PRRefreshAdmission { + constructor( + private readonly queue: PRRefreshQueue, + private readonly pacing: PRRefreshPacing, + private readonly visibility: PRRefreshVisibility, + private readonly retry: PRRefreshRetryState, + private readonly events: PRRefreshEventPublisher, + private readonly drainer: { + readonly watchJobs: PRWatchJobRunner + schedule: (delay: number) => void + } + ) {} + + /** Local automatic or returned row work, held by its row's failure; for `visible` work, its + * demand is a window showing the row. Actions, selected bypass reads, host jobs and watches keep + * their own rules and lifetimes. */ + private rowWork(entry: PRRefreshQueueEntry): boolean { + return ( + !entry.hosted && + !entry.watch && + !entry.bypassBackgroundBudget && + (entry.reason === 'visible' || entry.reason === 'swr') + ) + } + + /** Whether row work captured before a wait still has its request and a row that wants it: its + * local request is still this attempt's, and some window still shows the same exact request, + * through any of its aliases. A captured candidate never proves demand by itself. Without both, + * the attempt ends: nothing starts, is charged, queued again or claimed, and only its own + * loading state, while it still owns it, closes. */ + private stillDemanded( + entry: PRRefreshQueueEntry, + requestSequence: number, + prepared?: PreparedPRBranchRead + ): boolean { + // Scope is checked by the prepared read; demand compares only the exact request inputs. + if (this.queue.ownsRequest(entry.key, requestSequence) && this.visibility.has(entry.key)) { + const sameRequest = + prepared?.matchesRequest ?? + exactRequestMatcher(candidateReadRequest(entry.candidate, 'visible')) + const shownRequest = (shown: GitHubPRRefreshCandidate) => + sameRequest(candidateReadRequest(shown, 'visible')) + if (this.visibility.hasDemand(entry.key, shownRequest)) { + return true + } + } + this.endAttempt(entry, requestSequence) + return false + } + + /** Ends a dequeued attempt that will not start: only its own loading state closes, at its own + * sequence and while it still owns the request, so a newer request is never disturbed. */ + private endAttempt(entry: PRRefreshQueueEntry, requestSequence: number): void { + if (this.queue.ownsRequest(entry.key, requestSequence)) { + this.events.broadcast( + { + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'skipped', + skippedReason: 'fresh' + }, + requestSequence + ) + } + } + + private get watchJobs(): PRWatchJobRunner { + return this.drainer.watchJobs + } + + private wake(delay: number): void { + this.drainer.schedule(delay) + } + + /** Charges a read's start to the list budget and active pacing, once it really starts. */ + chargeBudget(entry: PRRefreshQueueEntry): void { + if (entry.bypassBackgroundBudget !== true && isBudgetedBackground(entry.reason)) { + this.pacing.noteBackgroundStart() + this.watchJobs.noteBudgetedStart(entry) + } + if (usesActiveRefreshPacing(entry)) { + this.pacing.noteActiveStart(entry) + } + } + + /** When an automatic attempt still in its own reason (a follow-up or a failure's retry, not a + * return, an action or a forced reread) is due under the current cadence, from the failure that + * still owns it or else its row's data; null once that no longer gives it. Checked again once it has + * waited for quota, before it may start. A watch is due when its own plan says; other work is + * due as queued (0). */ + automaticPlan(entry: PRRefreshQueueEntry): number | null { + if (entry.watch) { + return this.watchJobs.plannedDueAt(entry) ?? 0 + } + if (entry.reason !== 'visible' || entry.hosted || entry.watch || entry.bypassBackgroundBudget) { + return 0 + } + const candidate = this.visibility.candidate(entry.key, entry.candidate) + if (entry.errorRetry) { + const retryAt = this.retry.retimedVisibleErrorRetryAt(entry.key, candidate) + if (retryAt !== null) { + return Number.isFinite(retryAt) ? retryAt : null + } + // No failure owns it any more: it is the row's ordinary automatic work. + entry.errorRetry = false + entry.followUp = true + } + return entry.followUp ? automaticDueAt(candidate) : 0 + } + + /** Whether an attempt that waited for quota may start now. Automatic work the cadence no longer + * gives, or a retired job, ends; work its current plan or the pause it learned puts later goes + * back, merged with what was queued for its key meanwhile. The pause holds either way. */ + admitAfterQuota( + entry: PRRefreshQueueEntry, + pausedUntil: number | null, + retired: boolean, + requestSequence: number + ): boolean { + if (pausedUntil !== null) { + this.queue.protectBackgroundUntil(entry.key, pausedUntil) + } + if ( + this.rowWork(entry) && + entry.reason === 'visible' && + !this.stillDemanded(entry, requestSequence) + ) { + return false + } + const planned = retired ? null : this.automaticPlan(entry) + if (planned === null) { + return false + } + const dueAt = Math.min(Math.max(planned, pausedUntil ?? 0), entry.hosted?.expiresAt ?? Infinity) + if (pausedUntil === null && dueAt <= Date.now()) { + return true + } + entry.dueAt = dueAt + this.queue.requeueCaptured(entry) + if (!entry.hosted && pausedUntil !== null) { + this.events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'paused', + pausedUntil, + skippedReason: 'rate-limit' + }) + } + this.wake(Math.max(1_000, dueAt - Date.now())) + return false + } + + /** The last check before a local automatic or returned read starts, after quota and after its + * read was prepared, with nothing awaited between it and the provider start: while a failure + * that still owns the row's request holds it (its plan, or a rate limit the host reported), the + * read does not start. Its demand waits for that time, merged with what was queued meanwhile, + * and the row shows that failure. Nothing about the failure is recorded again. Actions, selected + * bypass reads, host jobs and watches keep their own rules. */ + admitPreparedRead( + entry: PRRefreshQueueEntry, + prepared: PreparedPRBranchRead, + requestSequence: number, + requestStartedAt: number + ): boolean { + if (!this.rowWork(entry)) { + return true + } + // Waits passed since the quota check: the request, its scope and a row showing it must remain. + if (!prepared.isCurrent()) { + this.endAttempt(entry, requestSequence) + return false + } + if (entry.reason === 'visible' && !this.stillDemanded(entry, requestSequence, prepared)) { + return false + } + const candidate = this.visibility.candidate(entry.key, entry.candidate) + // Scope was checked just above, in this same synchronous decision. + const exact = prepared.cached()?.outcome + const failure = this.retry.currentFailure(entry.key, candidate, exact) + if (!failure || failure.gateAt <= Date.now()) { + return true + } + entry.dueAt = failure.gateAt + this.queue.requeueCaptured(entry) + this.queue.protectBackgroundUntil(entry.key, failure.gateAt) + const aliases = Array.from(entry.aliases.values()) + this.events.broadcast( + failure.outcome + ? { aliases, reason: entry.reason, outcome: failure.outcome, requestStartedAt } + : { aliases, reason: entry.reason, status: 'skipped', skippedReason: 'fresh' }, + requestSequence + ) + this.wake(Math.max(0, failure.gateAt - Date.now())) + return false + } +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy-cadence.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy-cadence.test.ts new file mode 100644 index 00000000000..0342c73f736 --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy-cadence.test.ts @@ -0,0 +1,58 @@ +import { expect, it, vi } from 'vitest' +import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' +import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome' +import { runHostedPRRead } from './pr-refresh-coordinator' +import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy' + +vi.mock('../../client/lookup/branch-lookup-resolution', () => ({ + resolvePRForBranchOutcome: async () => ({ + kind: 'found', + fetchedAt: Date.now(), + pr: makePR({ + headSha: 'head', + checksStatus: 'success', + checksReadComplete: true, + checksFinal: true, + autoMergeEnabled: false, + mergeQueueRequired: false + }) + }) +})) +vi.mock('../../execution/github-operation-slots', () => ({ + acquire: async () => {}, + release: () => {} +})) + +it('stretches a confirmed quiet PR’s interval by the cadence, as it does every other interval', async () => { + const candidate = makeCandidate({ + branch: 'branch', + currentHeadOid: 'head', + cachedHeadOid: 'head', + cachedFetchedAt: 2_000, + cachedPRState: 'open', + cachedChecksStatus: 'success', + cachedHasPR: true + }) + const read = () => + runHostedPRRead(candidate, () => + getPRForBranchOutcome('/repo', 'branch', null, null, null, { currentHeadOid: 'head' }) + ) + vi.useFakeTimers() + vi.setSystemTime(1_000) + await read() + vi.setSystemTime(2_000) + await read() + const unconfirmed = { ...candidate, fallbackPRNumber: 9 } + expect( + (['default', 'reduced', 'minimal', 'off'] as const).map((cadence) => [ + refreshIntervalForCandidate(candidate, cadence), + refreshIntervalForCandidate(unconfirmed, cadence) + ]) + ).toEqual([ + [300_000, 120_000], + [1_500_000, 600_000], + [3_000_000, 1_200_000], + [Infinity, Infinity] + ]) + vi.useRealTimers() +}) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy.ts b/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy.ts index 7bed1ef64b1..16b189de5a2 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-candidate-policy.ts @@ -1,7 +1,9 @@ import { shouldAcceptMergedFallbackPR } from '../../../../shared/github/pull-requests/refresh/pr-branch-fallback-rule' import { - REVIEW_REFRESH_COOLDOWN_MS, - reviewRefreshIntervalMs + settledForegroundAgeMs, + reviewRefreshIntervalMs, + scaleReviewRefreshInterval, + type PRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' import type { GitHubPRRefreshAlias, @@ -14,7 +16,9 @@ import type { GitHubPRBranchLookupOptions } from '../../client/lookup/pull-reque import { PR_CHECK_PAGE_LIMIT } from '../../client/check/pr-check-rollup' import { parsePRKey } from '../../pr-watch/pr-watch-types' import { quietPRIntervalForCandidate } from '../../client/lookup/pr-quiet-policy-evidence' -import { cachedPRBranchOutcome } from '../../client/lookup/pr-read-identity' +import type { readTimedPROutcome } from '../../client/lookup/pr-last-outcome' +import { cachedPRBranchOutcome, type prBranchReadKeys } from '../../client/lookup/pr-read-identity' +import type { PRBranchReadRequest } from '../../client/lookup/pr-branch-read-resources' export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 export const POST_PUSH_DELAY_MS = 2_500 @@ -68,7 +72,23 @@ export function admissionTierForRefreshReason( return reason === 'manual' ? 'interactive' : 'background' } -export function refreshKey(candidate: GitHubPRRefreshCandidate): string { +/** The read a queued refresh of the row makes for this reason. */ +export function candidateReadRequest( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + requestOptions?: Parameters[2] +): Parameters { + return [ + candidate.repoPath, + candidate.branch, + candidate.linkedPRNumber ?? null, + candidate.connectionId ?? null, + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + hostedReviewOptionArgs(candidate, reason, requestOptions)[0] ?? {} + ] +} + +export function refreshKey(candidate: PRBranchReadRequest): string { const connectionScope = candidate.connectionId ?? 'local' const runtimeScope = candidate.connectionId ? 'remote' @@ -112,14 +132,6 @@ export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { return reason === 'visible' || reason === 'swr' || reason === 'watch' } -export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - return delay > 0 && delay <= 5_000 -} - export function shouldSkipFresh( candidate: GitHubPRRefreshCandidate, reason: GitHubPRRefreshReason @@ -134,51 +146,46 @@ export function shouldSkipFresh( return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) } -export function foregroundRefreshAgeMs(candidate: GitHubPRRefreshCandidate): number { - const interval = refreshIntervalForCandidate(candidate) - return Number.isFinite(interval) ? interval : REVIEW_REFRESH_COOLDOWN_MS +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null || hasStaleHead(candidate) + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) } -export function foregroundExposureDueAt( - candidate: GitHubPRRefreshCandidate, - reexposed: boolean -): number | null { - return reexposed && - !Number.isFinite(refreshIntervalForCandidate(candidate)) && - candidate.cachedFetchedAt != null && - Date.now() - candidate.cachedFetchedAt >= foregroundRefreshAgeMs(candidate) - ? Date.now() +/** When a visible row's automatic follow-up is due under the current cadence: its validated + * interval after its data, now when it has none yet, or null when there is no timer for it (Off, + * or data even Default does not poll). Creation, retiming and the check before a start all use it. */ +export function automaticDueAt(candidate: GitHubPRRefreshCandidate): number | null { + return Number.isFinite(refreshIntervalForCandidate(candidate)) + ? (freshRetryAt(candidate) ?? Date.now()) : null } -export function freshRetryAt( - candidate: GitHubPRRefreshCandidate, - foreground = false -): number | null { - return candidate.cachedFetchedAt == null || hasStaleHead(candidate) - ? null - : candidate.cachedFetchedAt + - (foreground ? foregroundRefreshAgeMs(candidate) : refreshIntervalForCandidate(candidate)) +/** How old data may be when a foreground return reads it again: its Default interval (the + * validated quiet one where that applies), or the settled age for data Default runs no timer for. + * The same age decides whether a return earns a read and whether a cached answer satisfies it. */ +export function foregroundAgeMs(candidate: GitHubPRRefreshCandidate): number { + const defaultInterval = refreshIntervalForCandidate(candidate, 'default') + return Number.isFinite(defaultInterval) ? defaultInterval : settledForegroundAgeMs() } +/** The exact completed answer a queued read may take instead of reading: the same validated + * interval that schedules its follow-up has not passed since that answer was read. A foreground + * return judges the current answer by the foreground age that earned it the read. */ export function freshQueuedPROutcome( candidate: GitHubPRRefreshCandidate, reason: GitHubPRRefreshReason, foreground = false ): PRRefreshOutcome | null { - const cached = cachedPRBranchOutcome( - candidate.repoPath, - candidate.branch, - candidate.linkedPRNumber ?? null, - candidate.connectionId ?? null, - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - hostedReviewOptionArgs(candidate, reason)[0] ?? {} - ) - const outcome = cached?.outcome + const outcome = cachedPRBranchOutcome(...candidateReadRequest(candidate, reason))?.outcome if (!outcome || outcome.kind === 'upstream-error') { return null } - const dueAt = freshRetryAt(visibleCandidateAfterOutcome(candidate, outcome), foreground) + const current = visibleCandidateAfterOutcome(candidate, outcome) + if (foreground) { + return Date.now() - outcome.fetchedAt < foregroundAgeMs(current) ? outcome : null + } + const dueAt = freshRetryAt(current) return dueAt !== null && dueAt > Date.now() ? outcome : null } @@ -225,23 +232,72 @@ export function hasStaleHead(candidate: GitHubPRRefreshCandidate): boolean { ) } -export function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { +export function refreshIntervalForCandidate( + candidate: GitHubPRRefreshCandidate, + cadence?: PRAutoRefreshCadence +): number { if (candidate.cachedFetchedAt != null && !hasStaleHead(candidate)) { const quiet = quietPRIntervalForCandidate(candidate) if (quiet !== null) { - return quiet + return scaleReviewRefreshInterval(quiet, cadence) ?? Number.POSITIVE_INFINITY } } return ( - reviewRefreshIntervalMs({ - state: candidate.cachedPRState, - checksStatus: candidate.cachedChecksStatus, - hasReview: candidate.cachedHasPR, - selected: candidate.isSelected - }) ?? Number.POSITIVE_INFINITY + reviewRefreshIntervalMs( + { + state: candidate.cachedPRState, + checksStatus: candidate.cachedChecksStatus, + hasReview: candidate.cachedHasPR, + selected: candidate.isSelected + }, + cadence + ) ?? Number.POSITIVE_INFINITY ) } +/** An exact completed answer a client's timed snapshot read may still take: its quiet or normal + * host interval, scaled by the cadence, has not passed since it was read. */ +export function freshTimedPROutcome( + cached: ReturnType +): PRRefreshOutcome | null { + if (!cached || cached.outcome.kind === 'upstream-error') { + return null + } + const outcome = cached.outcome + const interval = + cached.quietIntervalMs !== null + ? scaleReviewRefreshInterval(cached.quietIntervalMs) + : reviewRefreshIntervalMs({ + state: outcome.kind === 'found' ? outcome.pr.state : null, + checksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + hasReview: outcome.kind === 'found', + selected: true + }) + return interval === null || Date.now() - outcome.fetchedAt < interval ? outcome : null +} + +/** How old a candidate's data must be for a foreground exposure to read it now instead of waiting + * for its timer, or null when the exposure earns no early read. A row shown again that settled + * under Default reads after the settled age (Default's cooldown, a minute otherwise). When the cadence stretches or stops the timer, a row shown + * again, or the selected PR on window return, reads once the Default interval has passed: the + * cadence slows the timers, never the user's return, and never makes it faster than Default. */ +export function foregroundExposureAgeMs( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority: number, + reexposed: boolean +): number | null { + const defaultInterval = refreshIntervalForCandidate(candidate, 'default') + if (!Number.isFinite(defaultInterval)) { + return reexposed ? foregroundAgeMs(candidate) : null + } + const foreground = + reexposed || (reason === 'visible' && candidate.isSelected === true && priority >= 80) + return foreground && defaultInterval < refreshIntervalForCandidate(candidate) + ? defaultInterval + : null +} + function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' } diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-active-visible-priority.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-active-visible-priority.test.ts index 61692bcb6c6..ae51e200e43 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-active-visible-priority.test.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-active-visible-priority.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types' const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { @@ -40,7 +40,15 @@ describe('pr-refresh-coordinator', () => { moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) }) + // Transforming the coordinator's module graph is the slow first step on a loaded machine: do it + // once under the hook limit, so each test still imports its own fresh instance, quickly. + beforeAll(async () => { + await import('./pr-refresh-coordinator') + }) + afterEach(() => { + // A body that failed must not leave its timers to run on the next test's clock. + vi.clearAllTimers() vi.useRealTimers() }) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-alias-coalescing.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-alias-coalescing.test.ts index 721960799a4..b4f8ab31b3c 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-alias-coalescing.test.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-alias-coalescing.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') @@ -25,7 +25,15 @@ describe('pr-refresh-coordinator', () => { moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) }) + // Transforming the coordinator's module graph is the slow first step on a loaded machine: do it + // once under the hook limit, so each test still imports its own fresh instance, quickly. + beforeAll(async () => { + await import('./pr-refresh-coordinator') + }) + afterEach(() => { + // A body that failed must not leave its timers to run on the next test's clock. + vi.clearAllTimers() vi.useRealTimers() }) @@ -148,4 +156,90 @@ describe('pr-refresh-coordinator', () => { ]) expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) + + it('keeps a shown alias alive after its co-shown alias leaves, until the last alias leaves', async () => { + const { reportVisiblePRRefreshCandidates, pruneWorktreePRRefreshAliases } = + await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'found', + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), + // ast-grep-ignore: test-no-wall-clock -- Outcome time advances with the fake-clock follow-up. + fetchedAt: Date.now() + })) + const first = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a', + currentHeadOid: 'head-a' + }) + const second = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b', + currentHeadOid: 'head-b' + }) + reportVisiblePRRefreshCandidates([first, second], 1, 1) + await vi.advanceTimersByTimeAsync(0) + const outcomes = () => + sendMock.mock.calls.map(([, event]) => event).filter((event) => event.outcome) + expect(outcomes()).toHaveLength(1) + expect(outcomes()[0].aliases.map((alias) => alias.cacheKey)).toEqual([ + '/repo::feature/a', + '/repo::feature/b' + ]) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + pruneWorktreePRRefreshAliases('wt-b') + await vi.advanceTimersByTimeAsync(120_000) + expect(outcomes()).toHaveLength(2) + expect(outcomes()[1].aliases.map((alias) => alias.cacheKey)).toEqual(['/repo::feature/a']) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + + pruneWorktreePRRefreshAliases('wt-a') + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(outcomes()).toHaveLength(2) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('delivers a newly shown alias while automatic timers are off', async () => { + const policy = await import('../../../../shared/review-refresh-policy') + policy.setPRAutoRefreshCadence('off') + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'found', + pr: makePR({ checksStatus: 'pending' }), + // ast-grep-ignore: test-no-wall-clock -- Outcome time advances with the fake-clock follow-up. + fetchedAt: Date.now() + })) + const first = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a' + }) + reportVisiblePRRefreshCandidates([first], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + // An unselected open PR keeps the Default two-minute freshness floor in every cadence. + await vi.advanceTimersByTimeAsync(120_000) + const second = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b' + }) + reportVisiblePRRefreshCandidates([first, second], 2, 1) + await vi.advanceTimersByTimeAsync(0) + const delivered = sendMock.mock.calls.map(([, event]) => event).filter((event) => event.outcome) + expect(delivered).toHaveLength(2) + expect(delivered[1].aliases.map((alias) => alias.cacheKey)).toEqual([ + '/repo::feature/a', + '/repo::feature/b' + ]) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-auto-refresh-cadence.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-auto-refresh-cadence.test.ts new file mode 100644 index 00000000000..a41253dcd3a --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-auto-refresh-cadence.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') + return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks } +}) + +vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks)) +vi.mock('../../client/lookup/pr-for-branch-outcome', () => + moduleMocks.clientModuleMock(coordinatorMocks) +) +vi.mock('../../repositories/github-api-repository-remote-probe', () => ({ + getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock +})) +vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks)) + +import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' + +const { getPRForBranchOutcomeMock } = coordinatorMocks + +async function loadCoordinatorWithCadence(cadence: string) { + // resetModules gives each test fresh module state; set the cadence on that same instance. + const policy = await import('../../../../shared/review-refresh-policy') + policy.setPRAutoRefreshCadence(cadence) + return { policy, coordinator: await import('./pr-refresh-coordinator') } +} + +describe('pr-refresh-coordinator auto-refresh cadence', () => { + beforeEach(() => { + moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) + vi.setSystemTime(1_000) + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'found', + pr: makePR({ checksStatus: 'pending' }), + fetchedAt: Date.now() + })) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps the two-minute visible follow-up by default', async () => { + const { coordinator } = await loadCoordinatorWithCadence('default') + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('stretches visible follow-ups to ten minutes when reduced', async () => { + const { coordinator } = await loadCoordinatorWithCadence('reduced') + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(9 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('reads a newly visible PR once and then stops timed reads when off', async () => { + const { coordinator } = await loadCoordinatorWithCadence('off') + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(24 * 60 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + + it('still reads at once on selection when off', async () => { + const { coordinator } = await loadCoordinatorWithCadence('off') + coordinator.enqueuePRRefresh( + makeCandidate({ + cachedFetchedAt: Date.now(), + cachedHasPR: true, + cachedPRState: 'open', + cachedChecksStatus: 'pending' + }), + 'active', + 80 + ) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + + it('does not retry a failed visible read on a timer when off', async () => { + const { coordinator } = await loadCoordinatorWithCadence('off') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + })) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + // Selecting the workspace still reads at once. + coordinator.enqueuePRRefresh(makeCandidate(), 'active', 80) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('retries a failed visible read no sooner than the stretched interval when reduced', async () => { + const { coordinator } = await loadCoordinatorWithCadence('reduced') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + })) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(9 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('drops a queued follow-up when the cadence changes to off, and resumes on Default without a new report', async () => { + const { policy, coordinator } = await loadCoordinatorWithCadence('default') + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + policy.setPRAutoRefreshCadence('off') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + policy.setPRAutoRefreshCadence('default') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('drops an error retry that was already queued when the cadence changes to off, and restores it on Default', async () => { + const { policy, coordinator } = await loadCoordinatorWithCadence('default') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + })) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + // A routine report (another workspace selected) still shows it as the same request. + await vi.advanceTimersByTimeAsync(4_000) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 2, 1) + + policy.setPRAutoRefreshCadence('off') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + // Selecting the workspace still reads at once. + coordinator.enqueuePRRefresh(makeCandidate(), 'active', 80) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + + // Back on Default the failed row is retried on its own, once its retry time has passed. + policy.setPRAutoRefreshCadence('default') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(getPRForBranchOutcomeMock.mock.calls.length).toBeGreaterThan(2) + }) + + it('restores a failed row’s own retry from its failure time and count, which the next Off retires again', async () => { + const { policy, coordinator } = await loadCoordinatorWithCadence('default') + const failure = async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + }) + getPRForBranchOutcomeMock.mockImplementation(failure) + // Fails at 1 s; Default would retry it at 121 s. + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + const switchTo = async (cadence: 'default' | 'off', at: number) => { + await vi.advanceTimersByTimeAsync(at - Date.now()) + policy.setPRAutoRefreshCadence(cadence) + coordinator.retimePRRefreshesForCadence() + } + await switchTo('off', 1_500) + await switchTo('default', 2_000) + await switchTo('off', 3_000) + // Restored early: the retry still runs at 121 s, not later from the restoration. + await switchTo('default', 100_000) + await vi.advanceTimersByTimeAsync(120_999 - Date.now()) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + + // That retry fails as the second real failure, so the next one is due at 241 s; a count that + // restoration had raised would push it to 361 s or later. + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'found', + pr: makePR({ checksStatus: 'pending' }), + fetchedAt: Date.now() + })) + await vi.advanceTimersByTimeAsync(240_999 - Date.now()) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3) + expect(coordinatorMocks.sendToTrustedUIRendererMock.mock.calls.at(-1)?.[1]).toMatchObject({ + outcome: { kind: 'found' } + }) + }) + + it('moves an already queued error retry to the stretched interval when the cadence changes to reduced', async () => { + const { policy, coordinator } = await loadCoordinatorWithCadence('default') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + })) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + policy.setPRAutoRefreshCadence('reduced') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(9 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('moves an already queued error retry back to the default interval when the cadence returns to default', async () => { + const { policy, coordinator } = await loadCoordinatorWithCadence('minimal') + getPRForBranchOutcomeMock.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'unknown', + message: 'offline', + fetchedAt: Date.now() + })) + coordinator.reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + policy.setPRAutoRefreshCadence('default') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + describe('foreground exposure under a stretched cadence', () => { + const openPR = { + cachedHasPR: true, + cachedPRState: 'open', + cachedChecksStatus: 'pending' + } as const + + it('reads the selected PR at once when it comes to the foreground, then follows the stretched interval', async () => { + const { coordinator } = await loadCoordinatorWithCadence('reduced') + const fetchedAt = Date.now() - 30_000 + const selected = makeCandidate({ ...openPR, isSelected: true, cachedFetchedAt: fetchedAt }) + coordinator.reportVisiblePRRefreshCandidates([selected], 1, 1) + await vi.advanceTimersByTimeAsync(90_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(0) + + // What the renderer sends for the selected workspace when it comes to the foreground: the + // data is two minutes old, past Default's minute but well inside Reduced's five. + coordinator.enqueuePRRefresh(selected, 'visible', 80, 1) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60_000 - 2_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(3_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('reads a re-shown row whose data is older than the Default interval', async () => { + const { coordinator } = await loadCoordinatorWithCadence('reduced') + coordinator.reportVisiblePRRefreshCandidates( + [makeCandidate({ ...openPR, cachedFetchedAt: Date.now() - 3 * 60_000 })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + // The timed read after it follows the stretched (10 min) interval. + await vi.advanceTimersByTimeAsync(9 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) + + it('leaves a re-shown row alone while its data is newer than the Default interval', async () => { + const { coordinator } = await loadCoordinatorWithCadence('reduced') + coordinator.reportVisiblePRRefreshCandidates( + [makeCandidate({ ...openPR, cachedFetchedAt: Date.now() - 60_000 })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(8 * 60_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(0) + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-foreground-cache.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-foreground-cache.test.ts new file mode 100644 index 00000000000..b0f969a983f --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-foreground-cache.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' + +const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') + return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks } +}) +// Only the provider is faked: the read, its exact cached result and the queue are the real ones. +const provider = vi.hoisted(() => ({ resolve: vi.fn() })) +vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks)) +vi.mock('./pr-refresh-rate-limit-gate', () => ({ + prRefreshRateLimitPausedUntil: async () => null +})) +vi.mock('../../client/lookup/branch-lookup-resolution', () => ({ + resolvePRForBranchOutcome: provider.resolve +})) +vi.mock('../../execution/github-operation-slots', () => ({ + acquire: async () => {}, + release: () => {} +})) + +beforeEach(() => { + moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) + provider.resolve.mockReset().mockImplementation(async () => ({ + kind: 'found', + // ast-grep-ignore: test-no-wall-clock -- resetPRRefreshCoordinatorMocks installs fake timers + fetchedAt: Date.now(), + pr: makePR({ checksStatus: 'pending' }) + })) +}) +afterEach(() => vi.useRealTimers()) + +it.each(['default', 'reduced', 'off'] as const)( + 'a return to a PR whose data is past the Default interval reads it under %s, then follows the timers', + async (cadence) => { + const policy = await import('../../../../shared/review-refresh-policy') + policy.setPRAutoRefreshCadence(cadence) + const coordinator = await import('./pr-refresh-coordinator') + const candidate = makeCandidate({ isSelected: true, currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(1) + + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(90_000) + coordinator.reportVisiblePRRefreshCandidates( + [ + { + ...candidate, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open', + cachedChecksStatus: 'pending' + } + ], + 2, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(2) + + // Default keeps its one-minute timer; Reduced waits five minutes and Off has none. + await vi.advanceTimersByTimeAsync(4 * 60_000) + expect(provider.resolve.mock.calls.length > 2).toBe(cadence === 'default') + } +) + +it.each([ + ['default', 5_000, 21_000], + ['reduced', 31_000, 91_000], + ['off', 31_000, 91_000] +] as const)( + 'a settled PR shown again under %s reads only once past its settled age, and delivers the change without a timer after', + async (cadence, earlyAt, dueAt) => { + const policy = await import('../../../../shared/review-refresh-policy') + policy.setPRAutoRefreshCadence(cadence) + const coordinator = await import('./pr-refresh-coordinator') + const settled = (title: string) => async () => ({ + kind: 'found', + // ast-grep-ignore: test-no-wall-clock -- resetPRRefreshCoordinatorMocks installs fake timers + fetchedAt: Date.now(), + pr: makePR({ title, state: 'merged', checksStatus: 'success' }) + }) + provider.resolve.mockImplementation(settled('merged')) + const candidate = makeCandidate({ currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(1) + const shownAgain = { + ...candidate, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'merged' as const, + cachedChecksStatus: 'success' as const + } + + // Shown again inside the settled age (10 s on Default, a minute otherwise): nothing is read. + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(earlyAt - 1_000) + coordinator.reportVisiblePRRefreshCandidates([shownAgain], 2, 1) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(1) + + // Shown again past it: the cached answer cannot satisfy the earned read, the change arrives. + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(dueAt - earlyAt) + provider.resolve.mockImplementation(settled('changed')) + coordinator.reportVisiblePRRefreshCandidates([shownAgain], 3, 1) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(2) + expect(coordinatorMocks.sendToTrustedUIRendererMock.mock.calls.at(-1)?.[1]).toMatchObject({ + outcome: { pr: { title: 'changed' } } + }) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(provider.resolve).toHaveBeenCalledTimes(2) + } +) + +it('a settled PR shown again takes the answer another reader just fetched instead of reading again', async () => { + const coordinator = await import('./pr-refresh-coordinator') + const { getPRForBranchOutcome } = await import('../../client/lookup/pr-for-branch-outcome') + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + const settled = (title: string) => async () => ({ + kind: 'found', + // ast-grep-ignore: test-no-wall-clock -- resetPRRefreshCoordinatorMocks installs fake timers + fetchedAt: Date.now(), + pr: makePR({ title, state: 'merged', checksStatus: 'success' }) + }) + provider.resolve.mockImplementation(settled('merged')) + const candidate = makeCandidate({ currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) + await vi.advanceTimersByTimeAsync(0) + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(20_000) + + // Another reader of the same request reads it just before the row is shown again. + provider.resolve.mockImplementation(settled('shared')) + await coordinator.runHostedPRRead(candidate, () => + getPRForBranchOutcome( + candidate.repoPath, + candidate.branch, + null, + null, + null, + hostedReviewOptionArgs(candidate, 'visible')[0] ?? {} + ) + ) + expect(provider.resolve).toHaveBeenCalledTimes(2) + coordinator.reportVisiblePRRefreshCandidates( + [ + { + ...candidate, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'merged', + cachedChecksStatus: 'success' + } + ], + 2, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(2) + expect(coordinatorMocks.sendToTrustedUIRendererMock.mock.calls.at(-1)?.[1]).toMatchObject({ + outcome: { pr: { title: 'shared' } } + }) +}) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-local-delivery.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-local-delivery.test.ts new file mode 100644 index 00000000000..e4b8e9cc55c --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-local-delivery.test.ts @@ -0,0 +1,650 @@ +import { afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest' +import type * as BranchLookup from '../../client/lookup/pr-for-branch-outcome' +import type { + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types' +import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' +import { + createTestStore, + makePRRefreshWorktree, + mockApi, + resetRemoteRuntimeMocks +} from '../../../../renderer/src/store/github/github-slice-test-harness' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../../../../renderer/src/store/github/requests/request-coordination' + +const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') + return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks } +}) +// Only the provider is faked: the lookup, its exact cache, the queue and the renderer's handling of +// the refresh events it receives are the real ones. +const provider = vi.hoisted(() => ({ resolve: vi.fn(), gate: vi.fn() })) +vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks)) +vi.mock('./pr-refresh-rate-limit-gate', () => ({ + prRefreshRateLimitPausedUntil: provider.gate +})) +vi.mock('../../client/lookup/branch-lookup-resolution', () => ({ + resolvePRForBranchOutcome: provider.resolve +})) +vi.mock('../../execution/github-operation-slots', () => ({ + acquire: async () => {}, + release: () => {} +})) + +const target = makeCandidate({ + branch: 'target', + cacheKey: '/repo::target', + worktreeId: 'wt-target', + currentHeadOid: 'head-sha' +}) +let renderer: ReturnType + +beforeEach(() => { + moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) + vi.setSystemTime(1_000) + provider.resolve.mockReset() + provider.gate.mockReset().mockResolvedValue(null) + resetRemoteRuntimeMocks() + mockApi.gh.refreshPRNow.mockReset() + mockApi.hostedReview.forBranch.mockResolvedValue(null) + _clearGitHubPRRefreshStartedEntriesForTest() + renderer = createTestStore() + renderer.setState({ + repos: [ + { id: 'repo-1', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 1, kind: 'git' } + ], + worktreesByRepo: { + 'repo-1': [ + makePRRefreshWorktree({ + id: 'wt-target', + branch: 'target', + head: 'head-sha', + path: '/repo' + }) + ] + } + }) + coordinatorMocks.sendToTrustedUIRendererMock.mockImplementation((_channel, event) => + renderer.getState().applyGitHubPRRefreshEvent(event) + ) +}) +// Transforming the coordinator's module graph is the slow first step on a loaded machine: do it +// once under the hook limit, so each test still imports its own fresh instance, quickly. +beforeAll(async () => { + await import('./pr-refresh-coordinator') +}) +afterEach(() => { + // A body that failed must not leave its timers to run on the next test's clock. + vi.clearAllTimers() + vi.useRealTimers() +}) + +function found(title: string): PRRefreshOutcome { + return { kind: 'found', fetchedAt: Date.now(), pr: makePR({ title, checksStatus: 'pending' }) } +} + +const reads = () => provider.resolve.mock.calls.length +const shown = () => renderer.getState().prCache['/repo::target']?.data?.title +const state = () => renderer.getState().prRefreshStates['/repo::target']?.status + +// 256 other exact results, each held: the exact cache has no room for this request's. +async function fillExactCache(lookup: typeof BranchLookup) { + provider.resolve.mockImplementation(async () => found('other')) + const holds: (() => void)[] = [] + for (let other = 0; other < 256; other += 1) { + const prepared = await lookup.preparePRBranchRead('/repo', `other-${other}`, null, null, null, { + currentHeadOid: 'head-sha' + }) + await prepared.read() + holds.push(prepared.hold()) + } + provider.resolve.mockReset() + return holds +} + +// A secondary rate limit as gh reports it, which the real parser turns into a 300 s Retry-After. +const secondaryLimit = () => + Object.assign(new Error('gh: HTTP 429'), { + stderr: 'HTTP 429: You have exceeded a secondary rate limit.\nRetry-After: 300' + }) + +async function load() { + const coordinator = await import('./pr-refresh-coordinator') + const lookup = await import('../../client/lookup/pr-for-branch-outcome') + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + // A paired client's foreground read of the same request, through the runtime's direct path; the + // exact cache has room for its result unless the test fills it. A forced one replaces a read of + // the same request already running. + const directRead = async (forced = false) => { + const prepared = await lookup.preparePRBranchRead( + '/repo', + 'target', + null, + null, + null, + hostedReviewOptionArgs(target, 'active', forced ? { userRefresh: true } : {})[0] ?? {} + ) + return coordinator.runHostedPRRead(target, prepared) + } + // The row's own read starts at 1 s and is held; the paired client's read runs at 5 s. + const replacedAt5s = async (newer: () => PRRefreshOutcome) => { + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(4_000) + provider.resolve.mockImplementationOnce(async () => newer()) + await directRead() + await vi.advanceTimersByTimeAsync(1_000) + return own + } + return { coordinator, lookup, directRead, replacedAt5s } +} + +it.each([ + ['no PR', { kind: 'no-pr', fetchedAt: 6_000 }], + ['an older PR', { kind: 'found', fetchedAt: 6_000, pr: makePR({ title: 'older' }) }] +] as const)( + 'an own read a newer read replaced, ending with %s, shows the newer data and keeps its plan', + async (_label, late) => { + const { replacedAt5s } = await load() + const own = await replacedAt5s(() => found('newer')) + own.resolve(late) + await vi.advanceTimersByTimeAsync(0) + expect(shown()).toBe('newer') + expect(state()).not.toBe('in-flight') + + // The newer read's ordinary follow-up, two minutes after it. + provider.resolve.mockImplementation(async () => found('changed')) + await vi.advanceTimersByTimeAsync(124_999 - Date.now()) + expect(reads()).toBe(2) + await vi.advanceTimersByTimeAsync(1) + expect(reads()).toBe(3) + expect(shown()).toBe('changed') + } +) + +it('a newer read’s failure stands over an older success, and keeps its own retry', async () => { + const { replacedAt5s } = await load() + const own = await replacedAt5s(() => ({ + kind: 'upstream-error', + errorType: 'network', + message: 'offline', + fetchedAt: Date.now() + })) + own.resolve({ kind: 'found', fetchedAt: 6_000, pr: makePR({ title: 'older' }) }) + await vi.advanceTimersByTimeAsync(0) + expect(shown()).toBeUndefined() + expect(state()).toBe('error') + + provider.resolve.mockImplementation(async () => found('changed')) + await vi.advanceTimersByTimeAsync(124_999 - Date.now()) + expect(reads()).toBe(2) + await vi.advanceTimersByTimeAsync(1) + expect(reads()).toBe(3) + expect(shown()).toBe('changed') +}) + +it('a refresh that joined the replaced read shows the newer data, once, with no read of its own', async () => { + const { coordinator, directRead } = await load() + const observed: string[] = [] + coordinator.addPRRefreshOutcomeObserver((_candidate, outcome) => observed.push(outcome.kind)) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(1_000) + // A refresh of the same row joins the read already running instead of starting another. + const joined = coordinator.refreshPRNow(target, 'visible') + await vi.advanceTimersByTimeAsync(3_000) + expect(reads()).toBe(1) + provider.resolve.mockImplementationOnce(async () => found('newer')) + await directRead() + own.resolve({ kind: 'found', fetchedAt: Date.now(), pr: makePR({ title: 'older' }) }) + expect(await joined).toMatchObject({ pr: { title: 'newer' } }) + await vi.advanceTimersByTimeAsync(0) + // Only the paired client's read was observed; the replaced read and its join publish nothing. + expect(reads()).toBe(2) + expect(observed).toEqual(['found']) + expect(shown()).toBe('newer') + expect(state()).not.toBe('in-flight') +}) + +it.each(['no newer request', 'a newer request of the row'] as const)( + 'a row read replaced by a still-running newer read ends its own loading with %s, and the newer data follows', + async (successor) => { + const { coordinator, directRead } = await load() + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(4_000) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead() + await vi.advanceTimersByTimeAsync(0) + // A refresh of the row joins the newer read; it is the row's request from now on. + const refresh = + successor === 'a newer request of the row' ? coordinator.refreshPRNow(target, 'manual') : null + await vi.advanceTimersByTimeAsync(1_000) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + await vi.advanceTimersByTimeAsync(0) + // The replaced no-PR never lands; only a request the row no longer has is left loading. + expect(shown()).toBeUndefined() + expect(state()).toBe(refresh ? 'in-flight' : 'skipped') + + newer.resolve(found('newer')) + await paired + await refresh + await vi.advanceTimersByTimeAsync(0) + if (!refresh) { + // Its kept demand runs once the request's own 10 s spacing allows, from the exact cache. + await vi.advanceTimersByTimeAsync(10_999 - Date.now()) + expect(shown()).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + } + expect(shown()).toBe('newer') + expect(state()).not.toBe('in-flight') + expect(reads()).toBe(2) + } +) + +it('a user’s refresh a newer read replaced answers without data, and the newer data arrives queued', async () => { + const { coordinator, directRead } = await load() + // The renderer's real refresh action, over the main handler's call. + mockApi.gh.refreshPRNow.mockImplementation( + (args: { candidate: typeof target; reason?: GitHubPRRefreshReason; userRefresh?: boolean }) => + coordinator.refreshPRNow(args.candidate, args.reason, { userRefresh: args.userRefresh }) + ) + const cacheKey = 'repo-1::target' + const shownByAction = () => renderer.getState().prCache[cacheKey]?.data?.title + renderer.setState({ + prCache: { [cacheKey]: { data: makePR({ title: 'shown' }), fetchedAt: 500 } } + }) + coordinator.reportVisiblePRRefreshCandidates( + [ + { + ...target, + cacheKey, + repoId: 'repo-1', + cachedFetchedAt: 500, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open', + cachedChecksStatus: 'pending' + } + ], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(1_000) + expect(reads()).toBe(0) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + const action = renderer.getState().fetchPRForBranch('/repo', 'target', { + force: true, + repoId: 'repo-1', + worktreeId: 'wt-target' + }) + await vi.advanceTimersByTimeAsync(1_000) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead(true) + await vi.advanceTimersByTimeAsync(1_000) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + // The action's reply carries no data: the shown PR stays, and the refresh no longer loads. + expect((await action)?.title).toBe('shown') + expect(shownByAction()).toBe('shown') + expect(renderer.getState().prRefreshStates[cacheKey]?.status).not.toBe('in-flight') + + newer.resolve(found('newer')) + await paired + // The refresh's own demand comes back after its 10 s spacing and takes the newer result. + await vi.advanceTimersByTimeAsync(12_000 - Date.now()) + expect(shownByAction()).toBe('newer') + expect(reads()).toBe(2) +}) + +it('a legacy client’s own finished read answers it at once, with no other read, at full capacity', async () => { + const { coordinator, lookup, directRead } = await load() + // 256 other exact results, each held: the exact cache has no room for this request's. + const holds = await fillExactCache(lookup) + const otherReads = reads() + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + const snapshot = async (opted: boolean) => + coordinator.runHostedPRSnapshot( + target, + await lookup.preparePRBranchRead( + '/repo', + 'target', + null, + null, + null, + hostedReviewOptionArgs(target, 'swr')[0] ?? {} + ), + Date.now() + 25_000, + undefined, + opted + ) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + const legacy = snapshot(false) + await vi.advanceTimersByTimeAsync(0) + // A second older client asks while that read is still running, before any newer read: it waits + // on the running read instead of starting one. + const sibling = snapshot(false) + await vi.advanceTimersByTimeAsync(0) + expect(reads() - otherReads).toBe(1) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead(true) + await vi.advanceTimersByTimeAsync(0) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + // Both are answered with their own read's result while the newer read still runs; no third read. + expect(await legacy).toMatchObject({ kind: 'no-pr' }) + expect(await sibling).toMatchObject({ kind: 'no-pr' }) + expect(await snapshot(true)).toMatchObject({ revalidation: { invalidated: true } }) + expect(reads() - otherReads).toBe(2) + newer.resolve(found('newer')) + await paired + holds.forEach((release) => release()) +}) + +it('a read whose scope changed while it ran settles nothing and is not observed', async () => { + const { coordinator, lookup, directRead } = await load() + const { bumpScopeGeneration, hostedReviewRepoScope } = + await import('../../../source-control/hosted-review-scope-generations') + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + const observed: string[] = [] + coordinator.addPRRefreshOutcomeObserver((_candidate, outcome) => observed.push(outcome.kind)) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + const opted = coordinator.runHostedPRSnapshot( + target, + await lookup.preparePRBranchRead( + '/repo', + 'target', + null, + null, + null, + hostedReviewOptionArgs(target, 'swr')[0] ?? {} + ), + Date.now() + 25_000, + undefined, + true + ) + await vi.advanceTimersByTimeAsync(0) + // A review created meanwhile invalidates what the repo's reads saw. + bumpScopeGeneration(hostedReviewRepoScope('/repo', 'local')) + own.resolve({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'slow down', + fetchedAt: Date.now(), + retryDisabledUntil: Date.now() + 300_000 + }) + expect(await opted).toMatchObject({ revalidation: { invalidated: true } }) + expect(observed).toEqual([]) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(0) + + // A read in the new scope settles and is observed as usual. + provider.resolve.mockImplementationOnce(async () => found('current')) + await directRead() + expect(observed).toEqual(['found']) +}) + +it.each([ + ['after its kept demand left the queue', 12_000, false, false], + ['before its kept demand left the queue', 9_000, false, false], + ['after its kept demand left the queue, with no room to keep it', 12_000, true, false], + ['after its kept demand left the queue, then Reduced at 13 s', 12_000, false, true], + ['with no room to keep it, then Reduced at 13 s', 12_000, true, true] +] as const)( + 'a newer read’s rate limit that lands %s holds the row until its retry, then the row recovers', + async (_label, failsAt, full, reduced) => { + const policy = await import('../../../../shared/review-refresh-policy') + const { coordinator, lookup, directRead } = await load() + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + const holds = full ? await fillExactCache(lookup) : [] + const observed: string[] = [] + coordinator.addPRRefreshOutcomeObserver((_candidate, outcome) => observed.push(outcome.kind)) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(4_000) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead() + await vi.advanceTimersByTimeAsync(1_000) + // The row's own read is replaced at 6 s; its demand comes back at 11 s, after its spacing. + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + await vi.advanceTimersByTimeAsync(0) + expect(state()).toBe('skipped') + const quota = Promise.withResolvers() + if (failsAt === 12_000) { + // That demand is taken at 11 s and waits for quota while the newer read fails. + const gateChecks = provider.gate.mock.calls.length + provider.gate.mockReturnValueOnce(quota.promise) + await vi.advanceTimersByTimeAsync(11_000 - Date.now()) + expect(provider.gate.mock.calls.length).toBe(gateChecks + 1) + expect(provider.gate.mock.calls.at(-1)).toEqual([ + expect.objectContaining({ branch: 'target', currentHeadOid: 'head-sha' }), + true + ]) + } + await vi.advanceTimersByTimeAsync(failsAt - Date.now()) + newer.reject(secondaryLimit()) + await paired + if (reduced) { + // The host moves to Reduced before the quota answer: the retry follows its plan from the + // failure (12 s + 600 s), the reported limit still ends at 312 s. + await vi.advanceTimersByTimeAsync(13_000 - Date.now()) + policy.setPRAutoRefreshCadence('reduced') + coordinator.retimePRRefreshesForCadence() + await vi.advanceTimersByTimeAsync(14_000 - Date.now()) + } + quota.resolve(null) + await vi.advanceTimersByTimeAsync(0) + + // No read starts before the retry; the row shows the scheduled failure and the host's limit. + const limitedUntil = failsAt + 300_000 + const retryAt = reduced ? failsAt + 600_000 : limitedUntil + expect(observed).toEqual(['upstream-error']) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(1) + if (failsAt === 12_000) { + expect(renderer.getState().prRefreshStates['/repo::target']).toMatchObject({ + status: 'error', + nextAutoRetryAt: retryAt, + retryDisabledUntil: limitedUntil + }) + } + if (!full) { + const prepared = await lookup.preparePRBranchRead( + '/repo', + 'target', + null, + null, + null, + hostedReviewOptionArgs(target, 'visible')[0] ?? {} + ) + expect(prepared.cached()?.outcome).toMatchObject({ + kind: 'upstream-error', + fetchedAt: failsAt, + nextAutoRetryAt: retryAt, + retryDisabledUntil: limitedUntil + }) + } + provider.resolve.mockImplementation(async () => found('recovered')) + await vi.advanceTimersByTimeAsync(retryAt - 1 - Date.now()) + expect(reads()).toBe(2) + await vi.advanceTimersByTimeAsync(1) + expect(reads()).toBe(3) + expect(shown()).toBe('recovered') + expect(observed).toEqual(['upstream-error', 'found']) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(0) + holds.forEach((release) => release()) + } +) + +it('a return a newer read replaced waits out that read’s rate limit, then reads', async () => { + const { coordinator, directRead } = await load() + const shownTarget = { + ...target, + cachedFetchedAt: 500, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open' as const, + cachedChecksStatus: 'pending' as const + } + coordinator.reportVisiblePRRefreshCandidates([shownTarget], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(reads()).toBe(0) + // The user's return (an active read) is replaced by a newer read; it stays owed as a return. + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + const action = coordinator.refreshPRNow(shownTarget, 'active') + await vi.advanceTimersByTimeAsync(4_000) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead(true) + await vi.advanceTimersByTimeAsync(1_000) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + expect(await action).toMatchObject({ revalidation: { invalidated: true } }) + // The newer read fails at 7 s with a 300 s Retry-After; the owed return is held until then. + await vi.advanceTimersByTimeAsync(1_000) + newer.reject(secondaryLimit()) + await paired + provider.resolve.mockImplementation(async () => found('recovered')) + await vi.advanceTimersByTimeAsync(306_999 - Date.now()) + expect(reads()).toBe(2) + await vi.advanceTimersByTimeAsync(1) + expect(reads()).toBe(3) + expect(shown()).toBe('recovered') +}) + +it.each(['queued', 'the refresh action'] as const)( + 'under Off, an active read a newer read replaced (%s) still shows that newer read, with no read of its own', + async (route) => { + const policy = await import('../../../../shared/review-refresh-policy') + policy.setPRAutoRefreshCadence('off') + const { coordinator, directRead } = await load() + const cacheKey = 'repo-1::target' + const row = { + ...target, + cacheKey, + repoId: 'repo-1', + cachedFetchedAt: 500, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open' as const, + cachedChecksStatus: 'pending' as const + } + const shownByRow = () => renderer.getState().prCache[cacheKey]?.data?.title + renderer.setState({ + prCache: { [cacheKey]: { data: makePR({ title: 'shown' }), fetchedAt: 500 } } + }) + mockApi.gh.refreshPRNow.mockImplementation((args) => + coordinator.refreshPRNow(args.candidate, args.reason, { userRefresh: args.userRefresh }) + ) + coordinator.reportVisiblePRRefreshCandidates([row], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(reads()).toBe(0) + // The user opens the checks tab (or refreshes its conflicts): an active read at 1 s. + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + let action: Promise | null = null + if (route === 'queued') { + coordinator.enqueuePRRefresh(row, 'active', 80, 1) + } else { + action = renderer.getState().fetchPRForBranch('/repo', 'target', { + force: true, + reason: 'active', + repoId: 'repo-1', + worktreeId: 'wt-target' + }) + } + await vi.advanceTimersByTimeAsync(4_000) + expect(reads()).toBe(1) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead(true) + await vi.advanceTimersByTimeAsync(1_000) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + await vi.advanceTimersByTimeAsync(0) + if (action) { + expect((await action)?.title).toBe('shown') + } + expect(shownByRow()).toBe('shown') + expect(renderer.getState().prRefreshStates[cacheKey]?.status).not.toBe('in-flight') + await vi.advanceTimersByTimeAsync(1_000) + newer.resolve(found('newer')) + await paired + // The owed return runs once its request's 10 s spacing allows and takes the newer result. + await vi.advanceTimersByTimeAsync(10_999 - Date.now()) + expect(shownByRow()).toBe('shown') + await vi.advanceTimersByTimeAsync(1) + expect(shownByRow()).toBe('newer') + // The newer read's own observation time, not the time it was shown. + expect(renderer.getState().prCache[cacheKey]?.fetchedAt).toBe(7_000) + expect(reads()).toBe(2) + // Off still runs no timer for the row. + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(reads()).toBe(2) + } +) + +it.each(['its last window', 'one of two windows showing it'] as const)( + 'a kept wake waiting for quota when %s closes starts no read after the newer read succeeded', + async (closed) => { + const { coordinator, directRead } = await load() + const resources = await import('../../client/lookup/pr-branch-read-resources') + coordinatorMocks.getAllWebContentsMock.mockReturnValue([ + { id: 1, isDestroyed: () => false }, + { id: 2, isDestroyed: () => false } + ]) + const own = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => own.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + if (closed === 'one of two windows showing it') { + coordinator.reportVisiblePRRefreshCandidates([target], 1, 2) + } + await vi.advanceTimersByTimeAsync(4_000) + const newer = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => newer.promise) + const paired = directRead() + await vi.advanceTimersByTimeAsync(1_000) + own.resolve({ kind: 'no-pr', fetchedAt: Date.now() }) + await vi.advanceTimersByTimeAsync(0) + // The kept demand is taken at 11 s and waits for quota. + const gateChecks = provider.gate.mock.calls.length + const quota = Promise.withResolvers() + provider.gate.mockReturnValueOnce(quota.promise) + await vi.advanceTimersByTimeAsync(11_000 - Date.now()) + expect(provider.gate.mock.calls.length).toBe(gateChecks + 1) + // The newer read succeeds at 12 s; window 1 closes at 13 s; quota answers at 14 s. + await vi.advanceTimersByTimeAsync(1_000) + newer.resolve(found('newer')) + await paired + await vi.advanceTimersByTimeAsync(1_000) + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(1_000) + quota.resolve(null) + await vi.advanceTimersByTimeAsync(0) + expect(reads()).toBe(2) + expect(provider.gate.mock.calls.length).toBe(gateChecks + 1) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(0) + expect(resources._prBranchReadResourceCountForTests()).toBe(0) + provider.resolve.mockImplementation(async () => found('changed')) + if (closed === 'its last window') { + // No row wants it any more: nothing was queued again, and nothing reads later. + expect(coordinator._getPRRefreshQueueSizeForTests()).toBe(0) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(reads()).toBe(2) + } else { + // Window 2 still shows the row: its ordinary follow-up reads two minutes after the newer read. + await vi.advanceTimersByTimeAsync(131_999 - Date.now()) + expect(reads()).toBe(2) + await vi.advanceTimersByTimeAsync(1) + expect(reads()).toBe(3) + } + } +) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-restoration.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-restoration.test.ts new file mode 100644 index 00000000000..e949513cda7 --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-restoration.test.ts @@ -0,0 +1,690 @@ +import { afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest' +import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' + +const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./pr-refresh-coordinator-test-mocks') + return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks } +}) +// Only the provider and the rate-limit probe are faked: the lookup, its exact cache, the retry +// owner and the queue are the real ones. +const provider = vi.hoisted(() => ({ resolve: vi.fn(), gate: vi.fn() })) +vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks)) +vi.mock('./pr-refresh-rate-limit-gate', () => ({ + prRefreshRateLimitPausedUntil: provider.gate +})) +vi.mock('../../client/lookup/branch-lookup-resolution', () => ({ + resolvePRForBranchOutcome: provider.resolve +})) +vi.mock('../../execution/github-operation-slots', () => ({ + acquire: async () => {}, + release: () => {} +})) + +beforeEach(() => { + moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks) + vi.setSystemTime(1_000) + provider.gate.mockReset().mockResolvedValue(null) + provider.resolve.mockReset().mockImplementation(async () => found('first')) +}) +// Transforming the coordinator's module graph is the slow first step on a loaded machine: do it +// once under the hook limit, so each test still imports its own fresh instance, quickly. +beforeAll(async () => { + await import('./pr-refresh-coordinator') +}) +afterEach(() => { + // A body that failed must not leave its timers to run on the next test's clock. + vi.clearAllTimers() + vi.useRealTimers() +}) + +function found(title: string): PRRefreshOutcome { + return { + kind: 'found', + fetchedAt: Date.now(), + pr: makePR({ title, checksStatus: 'pending' }) + } +} + +function failed(): PRRefreshOutcome { + return { kind: 'upstream-error', errorType: 'network', message: 'offline', fetchedAt: Date.now() } +} + +const readsOf = (branch: string) => + provider.resolve.mock.calls.filter(([read]) => read.branchName === branch).length + +/** The title of the last outcome the renderer received for the branch. */ +const deliveredTo = (branch: string) => + coordinatorMocks.sendToTrustedUIRendererMock.mock.calls + .map(([, event]) => event) + .findLast((event) => event.outcome && event.aliases.some((alias) => alias.branch === branch)) + ?.outcome.pr?.title + +async function load() { + const policy = await import('../../../../shared/review-refresh-policy') + const coordinator = await import('./pr-refresh-coordinator') + const lookup = await import('../../client/lookup/pr-for-branch-outcome') + const resources = await import('../../client/lookup/pr-branch-read-resources') + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + const switchTo = async (cadence: 'default' | 'reduced' | 'minimal' | 'off', at: number) => { + await vi.advanceTimersByTimeAsync(at - Date.now()) + policy.setPRAutoRefreshCadence(cadence) + coordinator.retimePRRefreshesForCadence() + } + // 256 other exact results, each held: the exact cache has no room for another. + const fillExactCache = async () => { + const holds: (() => void)[] = [] + for (let other = 0; other < 256; other += 1) { + const prepared = await lookup.preparePRBranchRead( + '/repo', + `other-${other}`, + null, + null, + null, + { + currentHeadOid: 'head-sha' + } + ) + await prepared.read() + holds.push(prepared.hold()) + } + return holds + } + // A paired client's foreground read of the same request, through the runtime's direct path. + const directRead = async (candidate: ReturnType) => { + const prepared = await lookup.preparePRBranchRead( + candidate.repoPath, + candidate.branch, + null, + null, + null, + hostedReviewOptionArgs(candidate, 'active')[0] ?? {} + ) + return coordinator.runHostedPRRead(candidate, prepared) + } + const claims = () => resources._prBranchReadResourceCountForTests() + return { coordinator, lookup, switchTo, fillExactCache, directRead, claims } +} + +it('restores a failure the full exact cache could not keep as its own retry, which Off retires again', async () => { + const { coordinator, switchTo, fillExactCache, claims } = await load() + const holds = await fillExactCache() + provider.resolve.mockImplementation(async () => failed()) + const target = makeCandidate({ branch: 'target', currentHeadOid: 'head-sha' }) + // Fails at 1 s with no exact entry; Default would retry it at 121 s. + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(readsOf('target')).toBe(1) + for (const release of holds) { + release() + } + + await switchTo('off', 1_500) + await switchTo('default', 2_000) + await switchTo('off', 3_000) + await vi.advanceTimersByTimeAsync(130_000 - Date.now()) + expect(readsOf('target')).toBe(1) + + provider.resolve.mockImplementation(async () => found('recovered')) + await switchTo('default', Date.now()) + await vi.advanceTimersByTimeAsync(0) + expect(readsOf('target')).toBe(2) + expect(coordinatorMocks.sendToTrustedUIRendererMock.mock.calls.at(-1)?.[1]).toMatchObject({ + outcome: { pr: { title: 'recovered' } } + }) + // Recovery released the failure's claim on its request. + expect(claims()).toBe(0) +}) + +it.each(['succeeded', 'failed'] as const)( + 'a cadence turned back on reads once for every shown worktree of a PR whose last read %s', + async (last) => { + const { coordinator, switchTo } = await load() + if (last === 'failed') { + provider.resolve.mockImplementation(async () => failed()) + } + const rows = ['a', 'b'].map((branch) => + makeCandidate({ + branch, + worktreeId: `wt-${branch}`, + cacheKey: `/repo::${branch}`, + linkedPRNumber: 12, + currentHeadOid: 'head-sha' + }) + ) + coordinator.reportVisiblePRRefreshCandidates(rows, 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(1) + await switchTo('off', 2_000) + provider.resolve.mockImplementation(async () => found('changed')) + await switchTo('default', 200_000) + await vi.advanceTimersByTimeAsync(0) + expect(provider.resolve).toHaveBeenCalledTimes(2) + expect([deliveredTo('a'), deliveredTo('b')]).toEqual(['changed', 'changed']) + } +) + +// Off holds no timer, so its row reads again only once Default returns. +async function expectNextAutomaticRead( + cadence: 'default' | 'off', + dueAt: number, + switchTo: (cadence: 'default', at: number) => Promise +) { + provider.resolve.mockImplementation(async () => found('changed')) + const due = cadence === 'default' ? dueAt : 700_000 + await vi.advanceTimersByTimeAsync(due - 1 - Date.now()) + expect(readsOf('target')).toBe(2) + if (cadence === 'off') { + await switchTo('default', due) + } + await vi.advanceTimersByTimeAsync(due - Date.now()) + expect(readsOf('target')).toBe(3) + expect(deliveredTo('target')).toBe('changed') +} + +it.each(['default', 'off'] as const)( + 'a failure a newer read replaced while it ran owes no retry; under %s the row follows that read', + async (cadence) => { + const { coordinator, switchTo, fillExactCache, directRead, claims } = await load() + // The exact cache is full, so neither read below keeps its outcome there. + await fillExactCache() + const target = makeCandidate({ branch: 'target', currentHeadOid: 'head-sha' }) + const late = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => late.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(0) + if (cadence === 'off') { + await switchTo('off', 1_500) + } + // The newer read starts at 5 s and succeeds first; then the old one fails. + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) + provider.resolve.mockImplementationOnce(async () => found('newer')) + expect(await directRead(target)).toMatchObject({ pr: { title: 'newer' } }) + late.resolve(failed()) + await vi.advanceTimersByTimeAsync(0) + expect(claims()).toBe(0) + // The newer read kept nothing to show, so the old read only ends its loading state; its + // replaced failure is never published. + const targetEvents = coordinatorMocks.sendToTrustedUIRendererMock.mock.calls + .map(([, event]) => event) + .filter((event) => event.aliases.some((alias) => alias.branch === 'target')) + expect(targetEvents.map((event) => event.status ?? event.outcome.kind)).toEqual([ + 'in-flight', + 'skipped' + ]) + // Default: the ordinary follow-up two minutes after the newer read, not the old error's retry. + await expectNextAutomaticRead(cadence, 125_000, switchTo) + } +) + +it.each(['default', 'off'] as const)( + 'a newer read starting retires a failure already settled, its retry and its claim (%s)', + async (cadence) => { + const { coordinator, lookup, switchTo, fillExactCache, directRead, claims } = await load() + const { hostedReviewOptionArgs } = await import('./pr-refresh-candidate-policy') + await fillExactCache() + const target = makeCandidate({ branch: 'target', currentHeadOid: 'head-sha' }) + const running = Promise.withResolvers() + provider.resolve.mockImplementationOnce(() => running.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(0) + // Another reader of the same request joins the running read: it starts nothing, so the failure + // stays that read's, with its retry at 121 s. + const joined = lookup.getPRForBranchOutcome( + '/repo', + 'target', + null, + null, + null, + hostedReviewOptionArgs(target, 'visible')[0] ?? {} + ) + await vi.advanceTimersByTimeAsync(0) + running.resolve(failed()) + expect(await joined).toMatchObject({ kind: 'upstream-error' }) + await vi.advanceTimersByTimeAsync(0) + expect(readsOf('target')).toBe(1) + expect(claims()).toBe(1) + if (cadence === 'off') { + await switchTo('off', 2_000) + expect(claims()).toBe(1) + } + await vi.advanceTimersByTimeAsync(61_000 - Date.now()) + provider.resolve.mockImplementationOnce(async () => found('newer')) + await directRead(target) + expect(claims()).toBe(0) + await expectNextAutomaticRead(cadence, 181_000, switchTo) + } +) + +it.each(['HEAD', 'scope'] as const)( + 'a parked failure is not restored once the row is another request (%s)', + async (change) => { + const { coordinator, switchTo, fillExactCache, claims } = await load() + const { bumpScopeGeneration, hostedReviewRepoScope } = + await import('../../../source-control/hosted-review-scope-generations') + await fillExactCache() + provider.resolve.mockImplementation(async () => failed()) + const target = makeCandidate({ branch: 'target', currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(0) + await switchTo('off', 2_000) + expect(claims()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + if (change === 'HEAD') { + coordinator.reportVisiblePRRefreshCandidates([{ ...target, currentHeadOid: 'head-b' }], 2, 1) + } else { + bumpScopeGeneration(hostedReviewRepoScope('/repo', 'local')) + } + await switchTo('default', 4_000) + expect(claims()).toBe(0) + + provider.resolve.mockImplementation(async () => found('changed')) + await vi.advanceTimersByTimeAsync(121_000 - Date.now()) + expect(readsOf('target')).toBe(2) + expect(provider.resolve.mock.calls.at(-1)?.[0].options.currentHeadOid).toBe( + change === 'HEAD' ? 'head-b' : 'head-sha' + ) + expect(deliveredTo('target')).toBe('changed') + } +) + +it.each(['automatic', 'reported again', 'shown again'] as const)( + 'Off retires a restored follow-up waiting its turn, but not one a return upgraded (%s)', + async (kind) => { + const { coordinator, lookup, switchTo } = await load() + const x = makeCandidate({ branch: 'x', currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([x], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(readsOf('x')).toBe(1) + const shownX = { + ...x, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open' as const, + cachedChecksStatus: 'pending' as const + } + await switchTo('off', 2_000) + // Other reads push X's exact answer out of the bounded cache while X stays visible. + for (let other = 0; other < 256; other += 1) { + await lookup.getPRForBranchOutcome('/repo', `other-${other}`, null, null, null, { + currentHeadOid: 'head-sha' + }) + } + // A newly shown Y reads under Off, and its budgeted start spaces the next one by 10 s. + await vi.advanceTimersByTimeAsync(199_000 - Date.now()) + const y = makeCandidate({ branch: 'y', worktreeId: 'wt-y', cacheKey: '/repo::y' }) + coordinator.reportVisiblePRRefreshCandidates([shownX, y], 2, 1) + await vi.advanceTimersByTimeAsync(0) + expect(readsOf('y')).toBe(1) + + await switchTo('default', 200_000) + if (kind === 'shown again') { + coordinator.clearVisiblePRRefreshWindow(1) + } + if (kind !== 'automatic') { + // A routine report keeps the restored follow-up automatic; only a return earns a read. + coordinator.reportVisiblePRRefreshCandidates([shownX, y], 3, 1) + } + await switchTo('off', 201_000) + await vi.advanceTimersByTimeAsync(30_000) + expect(readsOf('x')).toBe(kind === 'shown again' ? 2 : 1) + + provider.resolve.mockImplementation(async () => found('changed')) + await switchTo('default', Date.now()) + await vi.advanceTimersByTimeAsync(15_000) + // The automatic one is restored and reads now; the shown-again one already read under Off. + expect(readsOf('x')).toBe(2) + expect(deliveredTo('x')).toBe(kind === 'shown again' ? 'first' : 'changed') + } +) + +it.each(['off', 'reduced'] as const)( + 'a restored follow-up that waited for quota starts only when %s still gives it', + async (cadence) => { + const { coordinator, lookup, switchTo } = await load() + const x = makeCandidate({ branch: 'x', currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([x], 1, 1) + await vi.advanceTimersByTimeAsync(0) + await switchTo('off', 2_000) + // Other reads push X's exact answer out of the bounded cache, so none can stand in for a start. + for (let other = 0; other < 256; other += 1) { + await lookup.getPRForBranchOutcome('/repo', `other-${other}`, null, null, null, { + currentHeadOid: 'head-sha' + }) + } + await vi.advanceTimersByTimeAsync(200_000 - Date.now()) + const quota = Promise.withResolvers() + provider.gate.mockReturnValueOnce(quota.promise) + await switchTo('default', 200_000) + await vi.advanceTimersByTimeAsync(0) + expect(provider.gate).toHaveBeenCalled() + await switchTo(cadence, 201_000) + provider.resolve.mockImplementation(async () => found('changed')) + if (cadence === 'off') { + quota.resolve(null) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(readsOf('x')).toBe(1) + return + } + // Reduced puts X's next read five intervals after its data (1 s + 600 s); a shorter learned + // pause does not bring it forward. + await vi.advanceTimersByTimeAsync(1_000) + quota.resolve(300_000) + await vi.advanceTimersByTimeAsync(600_999 - Date.now()) + expect(readsOf('x')).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(readsOf('x')).toBe(2) + expect(deliveredTo('x')).toBe('changed') + } +) + +/** Opens these renderer windows; a closed one reports destroyed before any cleanup for it runs. */ +function openWindows(...ids: number[]): (id: number) => void { + const closed = new Set() + coordinatorMocks.getAllWebContentsMock.mockReturnValue( + ids.map((id) => ({ id, isDestroyed: () => closed.has(id) })) + ) + return (id) => closed.add(id) +} + +const row = (branch: string) => + makeCandidate({ branch, worktreeId: `wt-${branch}`, cacheKey: `/repo::${branch}` }) + +it('a parked failure releases its claim once no window shows its row, however that demand ends', async () => { + const { coordinator, switchTo, claims } = await load() + const close = openWindows(1, 2, 3) + provider.resolve.mockImplementation(async () => failed()) + coordinator.reportVisiblePRRefreshCandidates([row('a'), row('shared'), row('removed')], 1, 1) + coordinator.reportVisiblePRRefreshCandidates([row('shared')], 1, 2) + await vi.advanceTimersByTimeAsync(30_000) + expect([readsOf('a'), readsOf('shared'), readsOf('removed')]).toEqual([1, 1, 1]) + // Off leaves no queued work; each failure keeps its claim while its row is shown. + await switchTo('off', 31_000) + expect(coordinator._getPRRefreshQueueSizeForTests()).toBe(0) + expect(claims()).toBe(3) + + // A removed workspace's row has no queued alias to prune, yet its demand ends. + coordinator.pruneWorktreePRRefreshAliases('wt-removed') + expect(claims()).toBe(2) + // Window 1 is already destroyed when its close cleanup runs; window 2 still shows the shared row. + close(1) + coordinator.clearVisiblePRRefreshWindow(1) + expect(claims()).toBe(1) + // Window 2 closes with no cleanup of its own; the next report, from window 3, prunes it. + close(2) + coordinator.reportVisiblePRRefreshCandidates([], 1, 3) + expect(claims()).toBe(0) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(0) +}) + +it.each([750, 7_500])( + 'a report from another window does no bookkeeping over %i unchanged rows, and its dropped row releases', + async (count) => { + const { coordinator, claims } = await load() + const { PRRefreshVisibility } = await import('./pr-refresh-visibility') + const { PRRefreshRetryState } = await import('./pr-refresh-retry-state') + openWindows(1, 2) + provider.resolve.mockImplementation(async () => failed()) + coordinator.reportVisiblePRRefreshCandidates([row('b')], 1, 2) + await vi.advanceTimersByTimeAsync(0) + expect(claims()).toBe(1) + coordinator.reportVisiblePRRefreshCandidates( + Array.from({ length: count }, (_, index) => row(`a-${index}`)), + 1, + 1 + ) + const memberships = vi.spyOn(PRRefreshVisibility.prototype, 'has') + const releases = vi.spyOn(PRRefreshRetryState.prototype, 'releaseFailure') + const rendererChecks = coordinatorMocks.webContentsFromIdMock + const work = (report: () => void) => { + for (const counter of [memberships, releases, rendererChecks]) { + counter.mockClear() + } + report() + return [memberships, releases, rendererChecks].map((counter) => counter.mock.calls.length) + } + // Row membership checks, failure releases and renderer checks: one prune of the two windows per + // report, and only B's dropped row is checked and released. + expect(work(() => coordinator.reportVisiblePRRefreshCandidates([row('b')], 2, 2))).toEqual([ + 0, 0, 2 + ]) + expect(work(() => coordinator.reportVisiblePRRefreshCandidates([], 1, 2))).toEqual([0, 0, 2]) + expect(work(() => coordinator.reportVisiblePRRefreshCandidates([], 3, 2))).toEqual([1, 1, 2]) + expect(claims()).toBe(0) + expect(coordinator._getPRRefreshErrorBackoffCountForTests()).toBe(0) + } +) + +it('a drain that finds every queued row’s window closed ends quietly; a row still shown reads on', async () => { + const { coordinator, claims } = await load() + const close = openWindows(1, 2) + coordinator.reportVisiblePRRefreshCandidates([row('a')], 1, 1) + coordinator.reportVisiblePRRefreshCandidates([row('b')], 1, 2) + await vi.advanceTimersByTimeAsync(20_000) + expect([readsOf('a'), readsOf('b')]).toEqual([1, 1]) + provider.resolve.mockImplementation(async () => found('changed')) + // Window 1 closes with no cleanup call: its follow-up at 121 s finds it gone when the drain runs. + close(1) + await vi.advanceTimersByTimeAsync(131_000 - Date.now()) + expect([readsOf('a'), readsOf('b')]).toEqual([1, 2]) + expect(deliveredTo('b')).toBe('changed') + // The last window closes too: its row's next follow-up is the only queued work, and goes. + close(2) + await vi.advanceTimersByTimeAsync(251_000 - Date.now()) + expect([readsOf('a'), readsOf('b')]).toEqual([1, 2]) + expect(coordinator._getPRRefreshQueueSizeForTests()).toBe(0) + expect(coordinator._getVisiblePRRefreshWindowCountForTests()).toBe(0) + expect(claims()).toBe(0) +}) + +it.each(['a routine report', 'another HEAD'] as const)( + 'an action that waited for quota keeps its turn when %s queues the row again meanwhile', + async (change) => { + const { coordinator } = await load() + const x = makeCandidate({ branch: 'x', isSelected: true, currentHeadOid: 'head-sha' }) + coordinator.reportVisiblePRRefreshCandidates([x], 1, 1) + await vi.advanceTimersByTimeAsync(0) + const shownX = { + ...x, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open' as const, + cachedChecksStatus: 'pending' as const + } + // At 2 s the user's action reads X; quota is still being checked when the row is reported. + await vi.advanceTimersByTimeAsync(1_000) + const quota = Promise.withResolvers() + provider.gate.mockReturnValueOnce(quota.promise) + coordinator.enqueuePRRefresh(shownX, 'active', 80, 1) + await vi.advanceTimersByTimeAsync(0) + const head = change === 'another HEAD' ? 'head-b' : 'head-sha' + coordinator.reportVisiblePRRefreshCandidates([{ ...shownX, currentHeadOid: head }], 2, 1) + // Quota answers with a pause until 22 s. + await vi.advanceTimersByTimeAsync(1_000) + quota.resolve(22_000) + provider.resolve.mockImplementation(async () => found('changed')) + await vi.advanceTimersByTimeAsync(21_999 - Date.now()) + expect(readsOf('x')).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(readsOf('x')).toBe(2) + // Another HEAD queued since is a newer request; the old captured one does not replace it. + expect(provider.resolve.mock.calls.at(-1)?.[0].options.currentHeadOid).toBe(head) + expect(deliveredTo('x')).toBe('changed') + } +) + +it('an action that waited for quota keeps its turn when another worktree of its PR is reported meanwhile', async () => { + const { coordinator } = await load() + const [firstWindow] = coordinatorMocks.getAllWebContentsMock() + coordinatorMocks.getAllWebContentsMock.mockReturnValue([firstWindow, { ...firstWindow, id: 2 }]) + const row = (branch: string) => + makeCandidate({ + branch, + worktreeId: `wt-${branch}`, + cacheKey: `/repo::${branch}`, + linkedPRNumber: 12, + currentHeadOid: 'head-sha' + }) + const x = row('x') + coordinator.reportVisiblePRRefreshCandidates([x], 1, 1) + await vi.advanceTimersByTimeAsync(0) + const shownX = { + ...x, + cachedFetchedAt: 1_000, + cachedHeadOid: 'head-sha', + cachedHasPR: true, + cachedPRState: 'open' as const, + cachedChecksStatus: 'pending' as const + } + // X is pushed at 2 s; its read is due after the push delay, at 4.5 s, and waits for quota. + await vi.advanceTimersByTimeAsync(1_000) + const quota = Promise.withResolvers() + let waitingForQuota = false + provider.gate.mockImplementationOnce(() => { + waitingForQuota = true + return quota.promise + }) + coordinator.enqueuePRRefresh(shownX, 'post-push', 80, 1) + await vi.advanceTimersByTimeAsync(4_500 - Date.now()) + expect(waitingForQuota).toBe(true) + // Meanwhile a second window first shows worktree Y on the same PR, which queues its own read; + // X stays shown in the first window. + coordinator.reportVisiblePRRefreshCandidates([row('y')], 1, 2) + // Y's own entry is the only queued work while X is out of the queue waiting. + expect(coordinator._getPRRefreshQueueSizeForTests()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + quota.resolve(22_000) + provider.resolve.mockImplementation(async () => found('pushed')) + await vi.advanceTimersByTimeAsync(21_999 - Date.now()) + expect(provider.resolve).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + // The push's own read goes, and Y, coalesced with it, gets that answer too. + expect(provider.resolve.mock.calls.map(([read]) => read.branchName)).toEqual(['x', 'x']) + expect([deliveredTo('x'), deliveredTo('y')]).toEqual(['pushed', 'pushed']) +}) + +it.each(['its read', 'its failure', 'an earlier head’s read'] as const)( + 'a queued watch planned from %s follows a cadence change from that same anchor', + async (anchor) => { + const { coordinator, switchTo } = await load() + const headReads = vi.fn(async () => Date.now()) + coordinator.setPRWatchJobHooks({ isWatched: () => true, readHeadChecks: headReads }) + if (anchor === 'its failure') { + provider.resolve.mockImplementation(async () => failed()) + } + const watched = makeCandidate({ branch: 'watched', linkedPRNumber: 12 }) + const job = + anchor === 'an earlier head’s read' + ? { prKey: 'github.com/acme/repo#12', headSha: 'old-head' } + : { prKey: 'github.com/acme/repo#12' } + const watchReads = () => + anchor === 'an earlier head’s read' ? headReads.mock.calls.length : readsOf('watched') + await switchTo('minimal', 1_000) + coordinator.schedulePRWatchRead(job, watched, 1_000) + await vi.advanceTimersByTimeAsync(0) + expect(watchReads()).toBe(1) + // Minimal put the next read at 1 201 s; Default, set at 2 s, puts it at 121 s from the same read. + await switchTo('default', 2_000) + await vi.advanceTimersByTimeAsync(120_999 - Date.now()) + expect(watchReads()).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(watchReads()).toBe(2) + } +) + +it('a watch that waited for quota while Off slowed it to Minimal reads at the Minimal time', async () => { + const { coordinator, switchTo } = await load() + coordinator.setPRWatchJobHooks({ isWatched: () => true, readHeadChecks: async () => null }) + const watched = makeCandidate({ branch: 'watched', linkedPRNumber: 12 }) + coordinator.schedulePRWatchRead({ prKey: 'github.com/acme/repo#12' }, watched, 1_000) + await vi.advanceTimersByTimeAsync(0) + // Due at 121 s on Default; quota is still being checked when Off (Minimal for watches) arrives. + await vi.advanceTimersByTimeAsync(121_000 - Date.now() - 1) + const quota = Promise.withResolvers() + provider.gate.mockReturnValueOnce(quota.promise) + await vi.advanceTimersByTimeAsync(1) + expect(provider.gate).toHaveBeenLastCalledWith(watched, true) + await switchTo('off', 121_500) + quota.resolve(null) + await vi.advanceTimersByTimeAsync(1_200_999 - Date.now()) + expect(readsOf('watched')).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(readsOf('watched')).toBe(2) +}) + +it('retiming an old HEAD failure cannot delay the newer HEAD return under Off', async () => { + const { coordinator, switchTo } = await load() + const target = makeCandidate({ branch: 'target', linkedPRNumber: 7, currentHeadOid: 'head-a' }) + const older = Promise.withResolvers() + const newer = Promise.withResolvers() + const observed: PRRefreshOutcome[] = [] + coordinator.addPRRefreshOutcomeObserver((_, outcome) => observed.push(outcome)) + provider.resolve.mockImplementationOnce(() => older.promise) + coordinator.reportVisiblePRRefreshCandidates([target], 1, 1) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(1_000) + const current = { ...target, currentHeadOid: 'head-b' } + coordinator.reportVisiblePRRefreshCandidates([current], 2, 1) + provider.resolve.mockImplementationOnce(() => newer.promise) + const manual = coordinator.refreshPRNow(current, 'manual') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1_000) + older.resolve(failed()) + await vi.advanceTimersByTimeAsync(0) + const errorsBeforeRetime = observed.filter((outcome) => outcome.kind === 'upstream-error').length + expect(errorsBeforeRetime).toBe(1) + await switchTo('off', 3_500) + await vi.advanceTimersByTimeAsync(500) + newer.resolve({ + kind: 'found', + fetchedAt: 4_000, + pr: makePR({ + number: 7, + title: 'newer', + headSha: 'head-b', + state: 'merged', + checksStatus: 'success' + }) + }) + expect(await manual).toMatchObject({ fetchedAt: 4_000, pr: { title: 'newer' } }) + expect(deliveredTo('target')).toBe('newer') + coordinator.clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(65_000 - Date.now()) + provider.resolve.mockImplementationOnce(async () => ({ + kind: 'found', + fetchedAt: Date.now(), + pr: makePR({ + number: 7, + title: 'returned', + headSha: 'head-b', + state: 'merged', + checksStatus: 'success' + }) + })) + coordinator.reportVisiblePRRefreshCandidates( + [ + { + ...current, + cachedFetchedAt: 4_000, + cachedHeadOid: 'head-b', + cachedHasPR: true, + cachedPRState: 'merged', + cachedChecksStatus: 'success' + } + ], + 3, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(deliveredTo('target')).toBe('returned') + const delivered = coordinatorMocks.sendToTrustedUIRendererMock.mock.calls + .map(([, event]) => event) + .findLast((event) => event.outcome?.pr?.title === 'returned') + expect(delivered?.outcome.fetchedAt).toBe(65_000) + expect(observed.filter((outcome) => outcome.kind === 'upstream-error')).toHaveLength( + errorsBeforeRetime + ) +}) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-test-mocks.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-test-mocks.ts index 063957a1c12..fde8ff64d80 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-coordinator-test-mocks.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator-test-mocks.ts @@ -64,8 +64,26 @@ export function clientModuleMock(mocks: PRRefreshCoordinatorMocks): ClientModule return { getPRForBranchOutcome: mocks.getPRForBranchOutcomeMock, preparePRBranchRead: async (...args) => { + const { prBranchReadKeys, exactRequestMatcher } = + await import('../../client/lookup/pr-read-identity') + const key = prBranchReadKeys( + args[0], + args[1], + args[2] ?? null, + args[3] ?? null, + args[4] ?? null, + args[5] ?? {} + )[1] const prepared: PreparedPRBranchRead = { - key: '', + key, + matchesRequest: exactRequestMatcher([ + args[0], + args[1], + args[2] ?? null, + args[3] ?? null, + args[4] ?? null, + args[5] ?? {} + ]), reprepare: async () => prepared, cached: () => null, isCurrent: () => true, @@ -81,14 +99,13 @@ export function clientModuleMock(mocks: PRRefreshCoordinatorMocks): ClientModule const outcome = await mocks.getPRForBranchOutcomeMock(...args) return settle ? settle(outcome, { retained: true }) : outcome }, - readResult: async (settle) => ({ - outcome: await prepared.read(settle), - sequence: 0, - current: () => true - }), - readRetained: async (settle) => { + readResult: async (settle) => { const outcome = await mocks.getPRForBranchOutcomeMock(...args) - return settle ? settle(outcome, { retained: true }) : outcome + return { + outcome: settle ? settle(outcome, { retained: true }) : outcome, + sequence: 0, + current: () => true + } } } return prepared diff --git a/src/main/github/pull-requests/refresh/pr-refresh-coordinator.ts b/src/main/github/pull-requests/refresh/pr-refresh-coordinator.ts index 522c1252ff1..e042fa24b9b 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-coordinator.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-coordinator.ts @@ -1,10 +1,7 @@ import { sharedPromiseSettlementWaiters } from '../../../../shared/promise-settlement-waiters' import { runImmediatePRRefresh } from './pr-refresh-immediate-read' -import { - deliverablePRBranchOutcome, - type PRBranchReadResult -} from '../../client/lookup/pr-branch-read-resources' import type { + GitHubPRRefreshAlias, GitHubPRRefreshCandidate, GitHubPRRefreshReason, PRRefreshOutcome, @@ -13,14 +10,15 @@ import type { import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' import { aliasFromCandidate, + isBudgetedBackground, refreshKey, - shouldBroadcastQueued, validateCandidate } from './pr-refresh-candidate-policy' import { PRRefreshEventPublisher, type PRRefreshOutcomeObserver } from './pr-refresh-event-publisher' +import { retimeQueuedErrorRetries } from './pr-refresh-error-retry-retiming' import { PRRefreshPacing } from './pr-refresh-pacing' import { PRRefreshQueue } from './pr-refresh-queue' import { PRRefreshQueueDrainer, type PRRefreshRegistrationCheck } from './pr-refresh-queue-drainer' @@ -28,18 +26,48 @@ import { prWatchQueueKey, schedulePRWatchEntry, type PRWatchJob, - type PRWatchJobHooks + type PRWatchJobHooks, + type PRWatchPlan } from './pr-refresh-watch-jobs' import { PRRefreshRetryState } from './pr-refresh-retry-state' import { PRRefreshVisibility } from './pr-refresh-visibility' import { refreshPRPolicyContext } from '../../client/lookup/pr-quiet-policy-evidence' +import { getPRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' +import { + deliverablePRBranchOutcome, + setPRBranchReadCompletion, + type PRBranchReadResult +} from '../../client/lookup/pr-branch-read-resources' +import { hostedPRReadSettlement } from './hosted-pr-read-settlement' const retry = new PRRefreshRetryState() -const queue = new PRRefreshQueue((key) => retry.reset(key)) +// Dropping an old gate index entry must not forget a visible row's current failure. +const queue = new PRRefreshQueue((key) => { + if (!visibility.has(key)) { + retry.reset(key) + } +}) const pacing = new PRRefreshPacing() -const visibility = new PRRefreshVisibility() +// A row no longer shown owes no retry for its failure (only its rate-limit gate stays), and its +// queued visible read is dropped. +const visibility = new PRRefreshVisibility((key) => { + retry.releaseFailure(key) + const entry = queue.get(key) + if (entry?.reason === 'visible') { + queue.delete(key) + events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) + } +}) const events = new PRRefreshEventPublisher() const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events) +setPRBranchReadCompletion((outcome, producer, prepared, request) => + drainer.scheduler.reconcileCompletion(outcome, prepared, request, producer) +) export function addPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver): () => void { return events.addOutcomeObserver(observer) @@ -58,9 +86,9 @@ export function schedulePRWatchRead( job: PRWatchJob, candidate: GitHubPRRefreshCandidate, dueAt: number, - options: { defer?: boolean } = {} + options: { defer?: boolean; plan?: PRWatchPlan } = {} ): void { - schedulePRWatchEntry(queue, job, candidate, dueAt, options.defer === true) + schedulePRWatchEntry(queue, job, candidate, dueAt, options.defer === true, options.plan) drainer.schedule() } @@ -69,32 +97,32 @@ export function cancelPRWatchRead(job: PRWatchJob): void { } /** A read a paired server made for a client reaches every observer. It takes no queue ownership, - * so a desktop read in flight for the same checkout keeps its follow-up. */ + * so a desktop read in flight for the same checkout keeps its follow-up. A prepared read is + * observed once, by its actual read while that is the newest in scope, and plans a visible row of + * its request; its caller still gets its own outcome. */ export async function runHostedPRRead( candidate: GitHubPRRefreshCandidate, read: (() => Promise) | PreparedPRBranchRead, didRead: () => boolean = () => true, release: () => void = () => {} ): Promise { - try { - if (typeof read !== 'function') { - const result = await read.readResult((outcome) => { - events.observe(candidate, outcome) - return outcome - }) - return result!.outcome + if (typeof read !== 'function') { + const owners = { queue, retry, observe: events.observe.bind(events) } + const settle = hostedPRReadSettlement(owners, candidate, read, false) + try { + return (await read.readResult(settle))!.outcome + } finally { + read.release() } + } + try { const outcome = await read() if (didRead()) { events.observe(candidate, outcome) } return outcome } finally { - if (typeof read !== 'function') { - read.release() - } else { - release() - } + release() } } @@ -129,40 +157,76 @@ export async function runHostedPRSnapshot( } throw error } - return drainer.hostedJobs.complete( - prepared, - candidate, - acceptRevalidationOnly ? deliverablePRBranchOutcome(prepared, result) : result.outcome, - acceptRevalidationOnly - ) + // A client that takes receipts gets only the request's current result; another gets its own. + const outcome = acceptRevalidationOnly + ? deliverablePRBranchOutcome(prepared, result) + : result.outcome + return drainer.hostedJobs.complete(prepared, candidate, outcome, acceptRevalidationOnly) } return drainer.hostedJobs.readCold(candidate, prepared, acceptRevalidationOnly) } -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() } } +/** A cadence change moves queued visible follow-ups, error retries and their shared schedules to + * the new interval, and the gates of queued host jobs with them; "off" drops them. Under a timed + * cadence visible rows then get their automatic work back without waiting for the windows to + * report again: a row whose failure is still current gets that failure's own retry, others the + * follow-up the cadence gives them. Either stays automatic, so the next Off retires it. */ +export function retimePRRefreshesForCadence(): void { + const failureRetries = retry.retimeErrors(queue) + queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate)) + retimeQueuedErrorRetries(queue, retry, (key, candidate) => visibility.candidate(key, candidate)) + drainer.hostedJobs.retime() + drainer.watchJobs.retime() + // Every shown row of a request shares its one restored read, so each gets the result. + const restored = new Map< + string, + { windowId: number; lead: GitHubPRRefreshCandidate; aliases: Map } + >() + const timed = getPRAutoRefreshCadence() !== 'off' + for (const [windowId, reported] of timed ? visibility.reported() : []) { + const key = refreshKey(reported) + if (validateCandidate(reported)) { + continue + } + const alias = aliasFromCandidate(reported) + const request = restored.get(key) + if (request) { + request.aliases.set(alias.cacheKey, alias) + } else { + restored.set(key, { windowId, lead: reported, aliases: new Map([[alias.cacheKey, alias]]) }) + } + } + for (const [key, { windowId, lead, aliases: shown }] of restored) { + const candidate = visibility.candidate(key, lead) + const aliases = [...shown.values()] + const failed = failureRetries.get(key) + const retryAt = + failed !== undefined && retry.failureMatches(key, candidate) ? failed : undefined + // Release an obsolete failure even when a queued action already owns the next read. + if (queue.get(key)) { + continue + } + if (retryAt === undefined) { + drainer.scheduler.restoreFollowUp(key, candidate, 40, aliases, windowId) + } else { + drainer.scheduler.scheduleErrorRetry(key, candidate, retryAt, 40, aliases, windowId) + } + } + drainer.schedule() +} + export function pruneWorktreePRRefreshAliases(worktreeId: string): void { queue.pruneWorktreeAliases(worktreeId) + visibility.removeWorktree(worktreeId) } export function enqueuePRRefresh( @@ -188,7 +252,8 @@ export function enqueuePRRefresh( pending.bypassBackgroundBudget = true } events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason) - if (shouldBroadcastQueued(reason, enqueued.dueAt)) { + const delay = enqueued.dueAt - Date.now() + if (!isBudgetedBackground(reason) && delay > 0 && delay <= 5_000) { events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' }) } drainer.schedule() @@ -200,11 +265,10 @@ export function reportVisiblePRRefreshCandidates( windowId: number ): void { refreshPRPolicyContext() - const newlyExposed = new Set(candidates.map(refreshKey).filter((key) => !visibility.has(key))) - if (!visibility.report(candidates, generation, windowId)) { + const reported = visibility.report(candidates, generation, windowId) + if (!reported) { return } - removeInvisibleVisibleRefreshes() queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate)) for (const candidate of candidates) { const key = refreshKey(candidate) @@ -212,13 +276,12 @@ export function reportVisiblePRRefreshCandidates( enqueuePRRefresh(candidate, 'visible', 40, windowId) continue } - queue.enqueue( - visibility.candidate(key, candidate), - 'visible', - 40, - windowId, - newlyExposed.has(key) - ) + // A row still shown as the same request keeps its automatic work; only a return, a new row or + // another request earns a read the cadence does not give. + const exposed = reported.exposed.has(key) + queue.enqueue(visibility.candidate(key, candidate), 'visible', 40, windowId, exposed, { + routine: !exposed && !reported.changed.has(key) + }) } drainer.schedule() } diff --git a/src/main/github/pull-requests/refresh/pr-refresh-error-retry-retiming.ts b/src/main/github/pull-requests/refresh/pr-refresh-error-retry-retiming.ts new file mode 100644 index 00000000000..23e5ef124fa --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-error-retry-retiming.ts @@ -0,0 +1,37 @@ +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { PRRefreshQueue } from './pr-refresh-queue' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' + +/** A cadence change re-plans the error retries that were already queued under the old cadence, + * from the time each read failed: "off" drops them (a failed read then waits for the next + * selection, push, window return or manual refresh), "reduced" and "minimal" move them out, and a + * return to a shorter cadence moves them in. Without this a retry armed under Default would still + * fire after the host was set to Off. */ +export function retimeQueuedErrorRetries( + queue: PRRefreshQueue, + retry: PRRefreshRetryState, + candidateFor: (key: string, candidate: GitHubPRRefreshCandidate) => GitHubPRRefreshCandidate +): void { + for (const entry of Array.from(queue.values())) { + if (!entry.errorRetry || entry.reason !== 'visible') { + continue + } + const planned = retry.retimedVisibleErrorRetryAt( + entry.key, + candidateFor(entry.key, entry.candidate) + ) + // No current failure owes it a retry (replaced, or its demand ended): it is not owed. + if (planned === null) { + queue.delete(entry.key) + continue + } + const retryAt = Math.max(planned, retry.manualGateUntil(entry.key)) + if (!Number.isFinite(retryAt)) { + queue.delete(entry.key) + continue + } + entry.dueAt = retryAt + // The gate set for the old retry time moves with it, so a sooner retry is not held back. + queue.protectBackgroundUntil(entry.key, retryAt) + } +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-immediate-read.ts b/src/main/github/pull-requests/refresh/pr-refresh-immediate-read.ts index 8438d1a3604..589bc4d3f37 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-immediate-read.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-immediate-read.ts @@ -6,12 +6,14 @@ import type { } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' import { preparePRBranchRead } from '../../client/lookup/pr-for-branch-outcome' import { deliverablePRBranchOutcome } from '../../client/lookup/pr-branch-read-resources' +import { deliverLocalPRRead } from './pr-refresh-local-delivery' import { aliasFromCandidate, + bypassesFreshnessDelay, + candidateReadRequest, refreshKey, validateCandidate, isBackground, - hostedReviewOptionArgs, MANUAL_MERGEABILITY_PENDING_REFRESH_MS } from './pr-refresh-candidate-policy' import { @@ -108,59 +110,65 @@ export async function runImmediatePRRefresh( const requestStartedAt = Date.now() queue.noteRequestStarted(key, requestSequence) events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence) - const prepared = await preparePRBranchRead( - candidate.repoPath, - candidate.branch, - candidate.linkedPRNumber ?? null, - candidate.connectionId ?? null, - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(candidate, reason, options) - ) + const prepared = await preparePRBranchRead(...candidateReadRequest(candidate, reason, options)) try { - const result = await prepared.readResult( - drainer.scheduler.settleRead(key, candidate, aliases, prepared, requestSequence) + const result = await prepared.readResult((outcome, producer) => { + if (!prepared.isCurrent()) { + return outcome + } + const settled = retry.settleProducerError( + outcome, + key, + visibility.candidate(key, candidate), + prepared, + queue, + producer, + visibility.has(key), + () => queue.ownsRequest(key, requestSequence) + ) + events.observe(candidate, settled, aliases) + return settled + }) + const read = { + key, + candidate, + aliases, + reason, + priority: 40, + // A user's action (manual, active, post-push) stays owed if a newer read replaced its own. + foreground: bypassesFreshnessDelay(reason), + requestSequence, + requestStartedAt + } + const outcome = deliverLocalPRRead( + { queue, events, scheduler: drainer.scheduler }, + read, + prepared, + result! ) - if (!result) { - throw new Error('An unrestricted PR read must return its own result') - } - const outcome = deliverablePRBranchOutcome(prepared, result) if (!outcome) { - if (queue.ownsRequest(key, requestSequence)) { - events.broadcast( - { aliases, reason, status: 'skipped', skippedReason: 'fresh' }, - requestSequence - ) - drainer.scheduler.completeWithoutOutcome( - key, - candidate, - aliases, - 40, - undefined, - !isBackground(reason) - ) - } - return { - revalidation: { invalidated: true, retryAfterMs: drainer.hostedJobs.retryAfterMs(prepared) } - } - } - 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 = - outcome.nextAutoRetryAt ?? - retry.nextVisibleErrorRetryAt(key, visibility.candidate(key, candidate)) - broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt) + // A replaced read answers its caller with the newer accepted result, or with no data: the + // shown data stays, and the row's queued demand delivers the newer result. + return ( + deliverablePRBranchOutcome(prepared, result!) ?? { + revalidation: { + invalidated: true, + retryAfterMs: drainer.hostedJobs.retryAfterMs(prepared) + } + } + ) } + const plannedRetryAt = visibility.has(key) + ? retry.errorFollowUpAt(key, visibility.candidate(key, candidate), outcome, true) + : undefined + const broadcastOutcome = + plannedRetryAt === undefined ? outcome : retry.withErrorSchedule(outcome, plannedRetryAt) retry.noteManualGate(key, broadcastOutcome) events.broadcast( { aliases, reason, outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + drainer.scheduler.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, ...(reason === 'manual' ? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS } diff --git a/src/main/github/pull-requests/refresh/pr-refresh-local-delivery.ts b/src/main/github/pull-requests/refresh/pr-refresh-local-delivery.ts new file mode 100644 index 00000000000..7580d65adc5 --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-local-delivery.ts @@ -0,0 +1,74 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import { + deliverablePRBranchOutcome, + type PRBranchReadResult, + type PreparedPRBranchRead +} from '../../client/lookup/pr-branch-read-resources' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshQueue } from './pr-refresh-queue' +import type { PRRefreshQueueScheduler } from './pr-refresh-queue-scheduler' + +export type LocalPRReadDelivery = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: GitHubPRRefreshAlias[] + reason: GitHubPRRefreshReason + priority: number + windowId?: number + /** A return or a user's refresh: its demand is to show current data now, not on a timer. */ + foreground: boolean + requestSequence: number + requestStartedAt: number +} + +/** + * Ends a local queued or immediate read whose provider read finished, whether it ran that read or + * joined it. Returns its own outcome when that is still the newest read of its request and the + * request is still this consumer's, for the caller to publish and plan from. Otherwise it plans + * nothing from it: it shows a newer read's accepted result if one finished; or, while it still + * owns the request, it ends that request at its own sequence without data and keeps the row's + * demand queued, so the newer result reaches the row later. It never publishes a replaced result. + */ +export function deliverLocalPRRead( + owners: { + readonly queue: PRRefreshQueue + readonly events: PRRefreshEventPublisher + readonly scheduler: Pick + }, + read: LocalPRReadDelivery, + prepared: Pick, + result: PRBranchReadResult +): PRRefreshOutcome | null { + const { key, aliases, reason, requestSequence, requestStartedAt } = read + const delivered = deliverablePRBranchOutcome(prepared, result) + const owned = owners.queue.ownsRequest(key, requestSequence) + if (owned && delivered === result.outcome) { + return result.outcome + } + if (delivered) { + owners.events.broadcast( + { aliases, reason, outcome: delivered, requestStartedAt }, + requestSequence + ) + } else if (owned) { + // Its own sequence: a newer request of the row stays the one the renderer shows loading. + owners.events.broadcast( + { aliases, reason, status: 'skipped', skippedReason: 'fresh' }, + requestSequence + ) + owners.scheduler.restoreFollowUp( + key, + read.candidate, + read.priority, + aliases, + read.windowId, + read.foreground + ) + } + return null +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-queue-drainer.ts b/src/main/github/pull-requests/refresh/pr-refresh-queue-drainer.ts index ad78c8d7c8d..94d79cd70fd 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-queue-drainer.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-queue-drainer.ts @@ -1,17 +1,18 @@ -import { admitPreparedPRRead } from './pr-refresh-read-admission' -import { deliverablePRBranchOutcome } from '../../client/lookup/pr-branch-read-resources' import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' -import { preparePRBranchRead } from '../../client/lookup/pr-for-branch-outcome' import { - hostedReviewOptionArgs, - freshQueuedPROutcome, + preparePRBranchRead, + type PRReadSettlement +} from '../../client/lookup/pr-for-branch-outcome' +import { completePRBranchRead } from '../../client/lookup/pr-branch-read-resources' +import { bypassesFreshnessDelay, + candidateReadRequest, + freshQueuedPROutcome, isBackground, - isBudgetedBackground, validateCandidate } from './pr-refresh-candidate-policy' import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' -import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing' +import type { PRRefreshPacing } from './pr-refresh-pacing' import type { PRRefreshQueue } from './pr-refresh-queue' import { PRWatchJobRunner } from './pr-refresh-watch-jobs' import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' @@ -23,6 +24,8 @@ import type { PRRefreshRetryState } from './pr-refresh-retry-state' import type { PRRefreshVisibility } from './pr-refresh-visibility' import { HostedPRReadJobs } from './hosted-pr-read-jobs' import { PRRefreshQueueScheduler } from './pr-refresh-queue-scheduler' +import { deliverLocalPRRead } from './pr-refresh-local-delivery' +import { PRRefreshAdmission } from './pr-refresh-admission' export type PRRefreshRegistrationCheck = (candidate: GitHubPRRefreshCandidate) => boolean @@ -32,7 +35,7 @@ export class PRRefreshQueueDrainer { readonly watchJobs: PRWatchJobRunner readonly hostedJobs: HostedPRReadJobs readonly scheduler: PRRefreshQueueScheduler - readonly scheduleVisibleFollowUp: PRRefreshQueueScheduler['scheduleVisibleFollowUp'] + private readonly admission: PRRefreshAdmission constructor( private readonly queue: PRRefreshQueue, @@ -51,7 +54,7 @@ export class PRRefreshQueueDrainer { this.watchJobs, () => this.drain() ) - this.scheduleVisibleFollowUp = this.scheduler.scheduleVisibleFollowUp.bind(this.scheduler) + this.admission = new PRRefreshAdmission(queue, pacing, visibility, retry, events, this) this.hostedJobs = new HostedPRReadJobs( queue, pacing, @@ -71,9 +74,13 @@ export class PRRefreshQueueDrainer { } this.draining = true try { - const { queue, pacing, events, scheduler, watchJobs } = this - while (this.queue.size > 0) { - let next = this.scheduler.ordered()[0] + for (;;) { + // One selection per decision: pruning closed windows may leave no work at all. + const ordered = this.scheduler.ordered() + let next = ordered[0] + if (!next) { + return + } if (this.hostedJobs.completeFromCache(next)) { continue } @@ -85,9 +92,9 @@ export class PRRefreshQueueDrainer { let delay = this.pacing.entryDelay(next) if (delay > 0) { - const runnable = this.scheduler - .ordered() - .find((entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0) + const runnable = ordered.find( + (entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0 + ) if (runnable && runnable.key !== next.key) { next = runnable delay = 0 @@ -141,7 +148,7 @@ export class PRRefreshQueueDrainer { const fresh = freshQueuedPROutcome(candidate, next.reason, next.foreground) if (fresh) { this.events.broadcast({ aliases, reason: next.reason, outcome: fresh }) - this.scheduleVisibleFollowUp( + this.scheduler.scheduleVisibleFollowUp( next.key, candidate, fresh, @@ -161,6 +168,7 @@ export class PRRefreshQueueDrainer { requestSequence ) } + let joinedRead = next.hosted?.prepared.pending() if (isBackground(next.reason) && !joinedRead) { const checkRateLimit = () => prRefreshRateLimitPausedUntil(next.candidate, true) @@ -171,38 +179,23 @@ export class PRRefreshQueueDrainer { next.hosted?.finish(null) continue } - if (pausedUntil !== null) { - this.queue.protectBackgroundUntil(next.key, pausedUntil) - const dueAt = Math.min(pausedUntil, next.hosted?.expiresAt ?? Infinity) - next.dueAt = dueAt - this.queue.set(next.key, next) - if (!next.hosted) { - this.events.broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil, - skippedReason: 'rate-limit' - }) - } - this.schedule(Math.max(1_000, dueAt - Date.now())) - continue - } - if (this.hostedJobs.completeFromCache(next)) { - continue - } + // A job retired or replaced while quota was checked never starts. if ( - next.watch && - next.bypassBackgroundBudget !== true && - isBudgetedBackground(next.reason) + !this.admission.admitAfterQuota( + next, + pausedUntil, + this.hostedJobs.completeFromCache(next), + requestSequence + ) ) { - this.pacing.noteBackgroundStart() - this.watchJobs.noteBudgetedStart(next) + continue } - if (next.watch && usesActiveRefreshPacing(next)) { - this.pacing.noteActiveStart(next) + // A watch's earlier-head read starts next; every other read is charged at its start. + if (next.watch) { + this.admission.chargeBudget(next) } } + if (await this.watchJobs.readHead(next)) { continue } @@ -212,57 +205,70 @@ export class PRRefreshQueueDrainer { const prepared = next.hosted?.prepared ?? (await preparePRBranchRead( - 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, { - watchPRKey: next.watch?.prKey - }) + ...candidateReadRequest(next.candidate, next.reason, { watchPRKey: next.watch?.prKey }) )) + // Automatic work joins a read already running for its request; a user's action reads fresh. joinedRead ??= bypassesFreshnessDelay(next.reason) ? undefined : prepared.pending() - const admitted = admitPreparedPRRead( - { queue, pacing, events, scheduler, watchJobs }, - next, - prepared, - aliases, - requestSequence, - requestStartedAt, - Boolean(joinedRead) - ) - if (!admitted) { - continue - } - const settle = this.scheduler.settleRead( - next.key, - next.candidate, - aliases, - prepared, - requestSequence, - Boolean(next.hosted) - ) - const result = await (joinedRead ?? prepared.readResult(settle, Boolean(next.hosted))) - const outcome = result ? deliverablePRBranchOutcome(prepared, result) : null - if (!outcome) { - this.scheduler.finishWithoutOutcome(next, aliases, requestSequence) - continue - } - if ( - next.hosted && - (!next.hosted.prepared.isCurrent() || !next.hosted.prepared.accepts(outcome)) - ) { - next.hosted.finish(null) - continue - } - if (!this.queue.ownsRequest(next.key, requestSequence)) { - next.hosted?.finish(null) - this.events.broadcast( - { aliases, reason: next.reason, outcome, requestStartedAt }, - requestSequence + const settle: PRReadSettlement = (outcome, producer) => { + if (!prepared.isCurrent()) { + return outcome + } + // A host job's failure keeps its job's own finite lifetime, so it takes no retry claim. + const settled = this.retry.settleProducerError( + outcome, + next.key, + next.hosted + ? { ...next.candidate, isSelected: false } + : this.visibility.candidate(next.key, next.candidate), + prepared, + this.queue, + next.hosted ? { retained: producer.retained } : producer, + Boolean(next.hosted) || this.visibility.has(next.key), + () => this.queue.ownsRequest(next.key, requestSequence) ) + this.events.observe(next.candidate, settled, aliases) + // A host job's read also plans the visible row of the same request, under its own claim. + const unretained = { ...producer, retained: false } + if (next.hosted) { + completePRBranchRead(settled, unretained, prepared, next.candidate) + } + return settled + } + // Nothing is awaited from this check to the provider start, so it sees current state. + if ( + !next.hosted && + !this.admission.admitPreparedRead(next, prepared, requestSequence, requestStartedAt) + ) { + continue + } + if (!next.watch && isBackground(next.reason) && !joinedRead) { + this.admission.chargeBudget(next) + } + const result = await (joinedRead ?? prepared.readResult(settle, Boolean(next.hosted))) + if ( + !result || + (next.hosted && + (!next.hosted.prepared.isCurrent() || + !next.hosted.prepared.accepts(result.outcome) || + !this.queue.ownsRequest(next.key, requestSequence))) + ) { + next.hosted?.finish(null) + continue + } + const read = { + ...next, + // A user's action or a validated return stays owed; automatic work follows the cadence. + foreground: next.foreground === true || bypassesFreshnessDelay(next.reason), + aliases, + requestSequence, + requestStartedAt + } + const owners = { queue: this.queue, events: this.events, scheduler: this.scheduler } + const outcome = + next.hosted || next.watch + ? result.outcome + : deliverLocalPRRead(owners, read, prepared, result) + if (!outcome || !this.queue.ownsRequest(next.key, requestSequence)) { continue } const plannedRetryAt = this.retry.errorFollowUpAt( @@ -291,7 +297,7 @@ export class PRRefreshQueueDrainer { this.watchJobs.afterOutcome(next, outcome) continue } - this.scheduleVisibleFollowUp( + this.scheduler.scheduleVisibleFollowUp( next.key, next.candidate, outcome, diff --git a/src/main/github/pull-requests/refresh/pr-refresh-queue-scheduler.ts b/src/main/github/pull-requests/refresh/pr-refresh-queue-scheduler.ts index 973c3d93800..d579a0eb354 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-queue-scheduler.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-queue-scheduler.ts @@ -3,18 +3,21 @@ import type { GitHubPRRefreshCandidate, PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' +import type { + PRBranchReadProducer, + PRBranchReadRequest +} from '../../client/lookup/pr-branch-read-resources' import { aliasFromCandidate, - freshRetryAt, + automaticDueAt, + candidateReadRequest, isBudgetedBackground, isMergeabilityPendingOutcome, + refreshKey, sameAliasRequestIdentity, visibleCandidateAfterOutcome } from './pr-refresh-candidate-policy' -import type { - PreparedPRBranchRead, - PRReadSettlement -} from '../../client/lookup/pr-for-branch-outcome' import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' import type { PRRefreshPacing } from './pr-refresh-pacing' import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' @@ -52,140 +55,6 @@ export class PRRefreshQueueScheduler { }, delay) } - settleRead( - key: string, - candidate: GitHubPRRefreshCandidate, - aliases: GitHubPRRefreshAlias[], - prepared: PreparedPRBranchRead, - requestSequence: number, - hosted = false - ): PRReadSettlement { - return (outcome, producer) => { - if (!prepared.isCurrent()) { - return outcome - } - const settled = - producer.retained && - outcome.kind === 'upstream-error' && - (hosted || this.visibility.has(key)) - ? this.retry.settleProducerError( - outcome, - key, - hosted - ? { ...candidate, isSelected: false } - : this.visibility.candidate(key, candidate), - prepared, - this.queue, - this.queue.ownsRequest(key, requestSequence) - ) - : outcome - this.events.observe(candidate, settled, aliases) - return settled - } - } - - completeWithoutOutcome( - key: string, - candidate: GitHubPRRefreshCandidate, - aliases: GitHubPRRefreshAlias[], - priority: number, - windowId?: number, - foreground = false - ): void { - if (!this.visibility.has(key) || this.queue.get(key)) { - return - } - const current = this.visibility.candidate(key, candidate) - const dueAt = Math.max( - freshRetryAt(current, foreground) ?? Date.now(), - this.queue.backgroundGateUntil(key), - this.retry.manualGateUntil(key) - ) - if (!Number.isFinite(dueAt)) { - return - } - this.queue.setVisibleFollowUp({ - key, - candidate: current, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: this.queue.nextOrder(), - followUp: !foreground, - foreground, - windowId - }) - this.schedule(Math.max(0, dueAt - Date.now())) - } - - finishWithoutOutcome( - next: PRRefreshQueueEntry, - aliases: GitHubPRRefreshAlias[], - requestSequence: number - ): void { - next.hosted?.finish(null) - if (!next.hosted && this.queue.ownsRequest(next.key, requestSequence)) { - this.events.broadcast( - { aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }, - requestSequence - ) - if (next.watch) { - this.watchJobs.skipped(next) - } else { - this.completeWithoutOutcome( - next.key, - next.candidate, - aliases, - next.priority, - next.windowId, - next.foreground - ) - } - } - } - - consumeGatedError( - entry: PRRefreshQueueEntry, - prepared: PreparedPRBranchRead, - requestSequence: number, - requestStartedAt: number - ): boolean { - if ( - entry.hosted || - entry.watch || - entry.bypassBackgroundBudget || - (entry.reason !== 'visible' && entry.reason !== 'swr') || - !prepared.isCurrent() - ) { - return false - } - const outcome = prepared.cached()?.outcome - if (outcome?.kind !== 'upstream-error') { - return false - } - const retryAt = Math.max(outcome.nextAutoRetryAt ?? 0, outcome.retryDisabledUntil ?? 0) - if (retryAt <= Date.now()) { - return false - } - const aliases = Array.from(entry.aliases.values()) - this.retry.noteManualGate(entry.key, outcome) - this.events.broadcast( - { aliases, reason: entry.reason, outcome, requestStartedAt }, - requestSequence - ) - this.scheduleVisibleFollowUp( - entry.key, - entry.candidate, - outcome, - entry.priority, - aliases, - entry.windowId, - { plannedRetryAt: retryAt } - ) - return true - } - scheduleVisibleFollowUp( key: string, candidate: GitHubPRRefreshCandidate, @@ -196,26 +65,22 @@ export class PRRefreshQueueScheduler { options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } ): void { if (outcome.kind === 'upstream-error') { + const retryCandidate = this.visibility.candidate(key, candidate) const retryAt = Math.max( - options?.plannedRetryAt ?? - this.retry.nextVisibleErrorRetryAt(key, this.visibility.candidate(key, candidate)), + options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key, retryCandidate), this.retry.manualGateUntil(key) ) - this.queue.protectBackgroundUntil(key, retryAt) - if (!this.visibility.has(key)) { - return - } - this.queue.setVisibleFollowUp({ + this.queue.protectBackgroundUntil( 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()) + Math.max( + this.retry.errorGateAt(key, retryCandidate, retryAt), + this.retry.manualGateUntil(key) + ) + ) + // Auto-refresh off: no timed retry; the next selection, push or manual refresh reads again. + if (Number.isFinite(retryAt)) { + this.scheduleErrorRetry(key, candidate, retryAt, priority, aliases, windowId) + } return } this.retry.reset(key) @@ -225,7 +90,7 @@ export class PRRefreshQueueScheduler { return } const followUpCandidate = this.visibility.candidate(key, refreshed) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const regularDueAt = automaticDueAt(followUpCandidate) ?? Number.POSITIVE_INFINITY const pendingDueAt = options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) ? outcome.fetchedAt + options.pendingMergeabilityDelayMs @@ -259,7 +124,129 @@ export class PRRefreshQueueScheduler { this.schedule(Math.max(0, dueAt - Date.now())) } + /** Queues the automatic follow-up the current cadence gives a visible row whose timed work came + * back, due now if its data is already stale; none when the cadence gives it no timer. It stays + * an automatic follow-up while it waits, so the next Off retires it. A `foreground` row (a + * return or a user's refresh whose read a newer read replaced) queues its return again instead, + * which a kept newer result answers from the exact cache. Both wait for the key's gates. */ + restoreFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number, + foreground = false + ): void { + const current = this.visibility.candidate(key, candidate) + const dueAt = foreground ? Date.now() : automaticDueAt(current) + if (dueAt === null || !this.visibility.has(key)) { + return + } + const gatedDueAt = Math.max(dueAt, this.queue.backgroundGateUntil(key)) + this.queue.setVisibleFollowUp({ + key, + candidate: current, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: gatedDueAt, + queuedAt: this.queue.nextOrder(), + followUp: !foreground, + foreground, + windowId + }) + this.schedule(Math.max(0, gatedDueAt - Date.now())) + } + + /** A full provider read of a visible row's exact request that the row's own queue did not run + * (another client's read, a host job, a provider lookup) plans the row's next automatic work as + * the row's own read would: its failure's retry, or the interval after its data. Work an action + * or a return queued keeps its own reason; a read a newer read already replaced plans nothing. */ + reconcileCompletion( + outcome: PRRefreshOutcome, + prepared: PreparedPRBranchRead, + request: PRBranchReadRequest, + producer: PRBranchReadProducer + ): PRRefreshOutcome { + const key = refreshKey(request) + const representative = this.visibility.shown(key) + if (!representative || !producer.current?.()) { + return outcome + } + const matchesCurrentRequest = (shown: GitHubPRRefreshCandidate) => + prepared.matchesRequest(candidateReadRequest(shown, 'visible')) + const shown = this.visibility.shown(key, matchesCurrentRequest) + if (!shown) { + return outcome + } + const pending = this.queue.get(key) + const candidate = this.visibility.candidate(key, shown) + // The row's failure is settled whatever is queued for it, so the last check before any read + // of the row (`admitPreparedRead`) finds it, even when the exact cache had no room for it. + const settled = this.retry.settleProducerError( + outcome, + key, + candidate, + prepared, + this.queue, + producer, + true, + () => { + return this.visibility.has(key) && this.visibility.hasDemand(key, matchesCurrentRequest) + } + ) + // A rate limit the read reported holds the row's retries, as a local read's would. + this.retry.noteManualGate(key, settled) + if (pending && !pending.followUp && !pending.errorRetry) { + // A return or an action queued for the row keeps its own plan; a success ends any failure. + if (settled.kind !== 'upstream-error') { + this.retry.reset(key) + } + return settled + } + const aliases = pending ? Array.from(pending.aliases.values()) : [aliasFromCandidate(shown)] + this.scheduleVisibleFollowUp(key, candidate, settled, 40, aliases, pending?.windowId, { + plannedRetryAt: this.retry.errorFollowUpAt(key, candidate, settled, true) + }) + // A follow-up already due sooner (one a replaced read kept for its row) still runs then: it + // shows this result from the exact cache, which this read alone never sends to the row. + const planned = this.queue.get(key) + if (settled.kind !== 'upstream-error' && pending?.followUp && planned?.followUp) { + planned.dueAt = Math.min(planned.dueAt, pending.dueAt) + this.schedule(Math.max(0, planned.dueAt - Date.now())) + } + return settled + } + + /** Queues the automatic retry a failure earned; it stays an error retry until it runs, is + * replaced or a cadence change retires it. */ + scheduleErrorRetry( + key: string, + candidate: GitHubPRRefreshCandidate, + retryAt: number, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number + ): void { + 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(), + errorRetry: true, + windowId + }) + this.schedule(retryAt - Date.now()) + } + ordered(): PRRefreshQueueEntry[] { + this.visibility.prune() this.watchJobs.age(this.queue.values(), Date.now()) return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b)) } diff --git a/src/main/github/pull-requests/refresh/pr-refresh-queue.ts b/src/main/github/pull-requests/refresh/pr-refresh-queue.ts index 835be7ef883..7d99031b699 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-queue.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-queue.ts @@ -1,5 +1,6 @@ import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../shared/review-refresh-policy' import type { HostedPRReadJob } from './hosted-pr-read-jobs' +import type { PRWatchPlan } from './pr-refresh-watch-jobs' import type { GitHubPRRefreshAlias, GitHubPRRefreshCandidate, @@ -7,10 +8,11 @@ import type { } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' import { aliasFromCandidate, + automaticDueAt, bypassesFreshnessDelay, - foregroundExposureDueAt, freshRetryAt, POST_PUSH_DELAY_MS, + foregroundExposureAgeMs, refreshKey, sameAliasRequestIdentity, shouldSkipFresh @@ -27,10 +29,15 @@ export type PRRefreshQueueEntry = { bypassBackgroundBudget?: boolean activeDelayNotified?: boolean followUp?: boolean + /** A timed retry of a failed visible read; re-planned when the auto-refresh cadence changes. */ + errorRetry?: boolean + /** Due because a foreground exposure found its data older than the Default interval. */ foreground?: boolean windowId?: number /** A watch job: the PR's current head, or with `headSha` one earlier head's checks. */ watch?: { prKey: string; headSha?: string } + /** What the watch's due time was planned from, so a cadence change can derive it again. */ + watchPlan?: PRWatchPlan hosted?: HostedPRReadJob } @@ -48,6 +55,7 @@ import { mergeFollowUpAlias, dropSupersededWorktreeAliases } from './pr-refresh-queue-aliases' +import { mergeCapturedRequest } from './pr-refresh-request-merge' export class PRRefreshQueue { private readonly entries = new Map() @@ -123,7 +131,7 @@ export class PRRefreshQueue { } entry.candidate = candidateFor(entry.key, entry.candidate) entry.dueAt = Math.max( - freshRetryAt(entry.candidate) ?? Date.now(), + automaticDueAt(entry.candidate) ?? Number.POSITIVE_INFINITY, this.backgroundNotBefore.get(entry.key)?.until ?? 0 ) if (!Number.isFinite(entry.dueAt)) { @@ -137,15 +145,28 @@ export class PRRefreshQueue { reason: GitHubPRRefreshReason, priority: number, windowId?: number, - reexposed = false + reexposed = false, + { routine = false }: { routine?: boolean } = {} ): PRRefreshEnqueue { const alias = aliasFromCandidate(candidate) const key = refreshKey(candidate) const existing = this.entries.get(key) const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null - const exposureDueAt = foregroundExposureDueAt(candidate, reexposed) + const exposureAgeMs = foregroundExposureAgeMs(candidate, reason, priority, reexposed) + const exposureDueAt = + exposureAgeMs !== null && + candidate.cachedFetchedAt != null && + Date.now() - candidate.cachedFetchedAt >= exposureAgeMs + ? Date.now() + : null + // A routine report of a row still shown as the same request is due only when the cadence gives + // it a read, never as discovery. + const routineDueAt = routine ? (automaticDueAt(candidate) ?? Number.POSITIVE_INFINITY) : null const dueAt = Math.max( - exposureDueAt ?? freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0), + routineDueAt ?? + exposureDueAt ?? + freshDueAt ?? + Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0), bypassesFreshnessDelay(reason) ? 0 : (this.backgroundNotBefore.get(key)?.until ?? 0) ) if (!Number.isFinite(dueAt) && !existing) { @@ -160,7 +181,8 @@ export class PRRefreshQueue { priority, dueAt, queuedAt: this.nextOrder(), - followUp: reason === 'visible' && freshDueAt !== null && exposureDueAt === null, + followUp: + routine || (reason === 'visible' && freshDueAt !== null && exposureDueAt === null), foreground: exposureDueAt !== null, windowId }) @@ -169,12 +191,14 @@ export class PRRefreshQueue { setLiveAlias(existing.aliases, alias) if ( + !routine && reason === 'visible' && (exposureDueAt !== null || !shouldSkipFresh(candidate, reason)) && existing.reason === 'visible' ) { existing.dueAt = Math.min(existing.dueAt, dueAt) existing.followUp = false + existing.errorRetry = false existing.foreground ||= exposureDueAt !== null } const shouldPromote = @@ -235,16 +259,11 @@ export class PRRefreshQueue { } } - removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] { - const removed: PRRefreshQueueEntry[] = [] - for (const [key, entry] of this.entries) { - if (entry.reason !== 'visible' || isVisible(key)) { - continue - } - this.entries.delete(key) - removed.push(entry) - } - return removed + /** Puts back work that waited for quota and may not start yet, merged with what was queued for + * its key meanwhile. A host job keeps its own entry. */ + requeueCaptured(entry: PRRefreshQueueEntry): void { + const current = this.entries.get(entry.key) + this.set(entry.key, current && !entry.hosted ? mergeCapturedRequest(current, entry) : entry) } setVisibleFollowUp(entry: PRRefreshQueueEntry): void { diff --git a/src/main/github/pull-requests/refresh/pr-refresh-quiet-policy.perf.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-quiet-policy.perf.test.ts index 4aa8e2d8c4e..f936eb9a51b 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-quiet-policy.perf.test.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-quiet-policy.perf.test.ts @@ -1,18 +1,50 @@ import { afterEach, expect, it, vi } from 'vitest' +import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness' +// The provider records what each actual read asked; the quota probe can be held. Windows 1-8 are +// open renderers. +const fixture = vi.hoisted(() => ({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the empty fixture log is populated only with this provider request shape. + reads: [] as { repoPath: string; ghOptions: { ghAccount?: { user?: string } } }[], + failure: false, + gate: vi.fn(async (): Promise => null), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture starts without a callback and installs a no-argument read hook. + onRead: null as null | (() => void) +})) vi.mock('../../client/lookup/branch-lookup-resolution', () => ({ - resolvePRForBranchOutcome: async () => ({ - kind: 'found', - fetchedAt: Date.now(), - pr: makePR({ - headSha: 'head', - checksStatus: 'success', - checksReadComplete: true, - checksFinal: true, - autoMergeEnabled: false, - mergeQueueRequired: false - }) + resolvePRForBranchOutcome: async (read: (typeof fixture.reads)[number]) => { + fixture.reads.push(read) + fixture.onRead?.() + if (fixture.failure) { + return { + kind: 'upstream-error', + errorType: 'network', + message: 'offline', + fetchedAt: Date.now() + } satisfies PRRefreshOutcome + } + return { + kind: 'found', + fetchedAt: Date.now(), + pr: makePR({ + headSha: 'head', + checksStatus: 'success', + checksReadComplete: true, + checksFinal: true, + autoMergeEnabled: false, + mergeQueueRequired: false + }) + } satisfies PRRefreshOutcome + } +})) +vi.mock('./pr-refresh-rate-limit-gate', () => ({ + prRefreshRateLimitPausedUntil: fixture.gate +})) +vi.mock('./pr-refresh-renderer-port', () => ({ + prRefreshRenderers: () => ({ + isRendererOpen: (id: number) => id >= 1 && id <= 8, + sendToTrustedUI: () => {} }) })) vi.mock('../../execution/github-operation-slots', () => ({ @@ -23,6 +55,11 @@ vi.mock('../../execution/github-operation-slots', () => ({ let release: (() => void) | undefined afterEach(() => { release?.() + fixture.reads.length = 0 + fixture.onRead = null + fixture.failure = false + fixture.gate.mockReset().mockImplementation(async () => null) + vi.clearAllTimers() vi.useRealTimers() vi.restoreAllMocks() }) @@ -109,3 +146,195 @@ it.each([750, 7500])( expect(refreshIntervalForCandidate({ ...candidate, cachedFetchedAt: 6_000 })).toBe(300_000) } ) + +// A visible row at worktree /repo/wt-0 (not the repository root), so each account discovery walks +// the whole installed worktree inventory. +async function installFleet(population: number) { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(1_000) + const accounts = await import('../../accounts/github-project-account') + const repository = { + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 0, + ghAccount: { host: 'github.com', user: 'first' } + } + const rows = Array.from({ length: population }, (_, row) => ({ + repoId: 'repo', + path: `/repo/wt-${row}` + })) + let visits = 0 + const inventory = vi.fn( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver only iterates the inventory; the iterable counts each worktree it visits. + ({ + *[Symbol.iterator]() { + for (const row of rows) { + visits += 1 + yield row + } + } + }) as unknown as readonly { repoId: string; path: string }[] + ) + const metadata = vi.fn(() => ({})) + release = accounts.installGitHubProjectAccounts( + { getRepos: () => [repository], getAllWorktreeMetaForHost: metadata }, + inventory + ) + const discovery = vi.spyOn(accounts, 'resolveGitHubProjectAccount') + const work = () => ({ + discoveries: discovery.mock.calls.length, + inventories: inventory.mock.calls.length, + metadata: metadata.mock.calls.length, + visits + }) + return { repository, work } +} + +it.each([ + [750, 1], + [750, 4], + [7_500, 1], + [7_500, 4] +])( + 'admits a visible row at %i workspaces with no inventory scans for demand decisions, %i window(s) showing it', + async (population, windows) => { + const { work } = await installFleet(population) + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + const row = makeCandidate({ repoId: 'repo', repoPath: '/repo/wt-0', branch: 'branch' }) + // Earlier windows show the same key with another HEAD (another request), so each decision + // compares them before it reaches the window that shows this one. + for (let window = 1; window < windows; window += 1) { + reportVisiblePRRefreshCandidates([], 1, window) + } + const quota = Promise.withResolvers() + fixture.gate.mockReturnValueOnce(quota.promise) + reportVisiblePRRefreshCandidates([row], 1, windows) + for (let window = 1; window < windows; window += 1) { + reportVisiblePRRefreshCandidates( + [ + { + ...row, + worktreeId: `wt-other-${window}`, + cacheKey: `other-${window}`, + currentHeadOid: 'old' + } + ], + 2, + window + ) + } + await vi.advanceTimersByTimeAsync(0) + expect(fixture.gate).toHaveBeenCalledTimes(1) + expect(fixture.reads).toEqual([]) + // From quota release to provider start, only preparation and its current-scope fence resolve ownership. + const atQuota = work() + const marks: { atStart?: ReturnType } = {} + fixture.onRead = () => { + marks.atStart ??= work() + } + quota.resolve(null) + await vi.advanceTimersByTimeAsync(0) + expect(fixture.reads).toHaveLength(1) + const spent = { + discoveries: marks.atStart!.discoveries - atQuota.discoveries, + inventories: marks.atStart!.inventories - atQuota.inventories, + metadata: marks.atStart!.metadata - atQuota.metadata, + visits: marks.atStart!.visits - atQuota.visits + } + // Demand comparisons add no account discovery, regardless of fleet size or window count. + expect(spent.discoveries).toBe(2) + expect(spent.inventories).toBe(2) + expect(spent.metadata).toBe(2) + expect(spent.visits).toBe(2 * population) + } +) + +it('reads with the account installed when it starts, after the account changed while it waited', async () => { + const { repository } = await installFleet(750) + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + const row = makeCandidate({ repoId: 'repo', repoPath: '/repo/wt-0', branch: 'branch' }) + const quota = Promise.withResolvers() + fixture.gate.mockReturnValueOnce(quota.promise) + reportVisiblePRRefreshCandidates([row], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(fixture.gate).toHaveBeenCalledTimes(1) + // The project's account changes while the read waits for quota. + repository.ghAccount = { host: 'github.com', user: 'second' } + quota.resolve(null) + await vi.advanceTimersByTimeAsync(0) + expect(fixture.reads).toHaveLength(1) + expect(fixture.reads[0]?.ghOptions.ghAccount?.user).toBe('second') +}) + +it.each(['bound', 'unbound'] as const)( + 'an explicitly bound paired read backs off the visible row of a known %s project', + async (binding) => { + const { repository } = await installFleet(750) + if (binding === 'unbound') { + Reflect.deleteProperty(repository, 'ghAccount') + } + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + const { getPRForBranchOutcome } = await import('../../client/lookup/pr-for-branch-outcome') + const row = makeCandidate({ + repoId: 'repo', + repoPath: '/repo/wt-0', + branch: 'target', + currentHeadOid: 'head' + }) + reportVisiblePRRefreshCandidates([row], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(fixture.reads).toHaveLength(1) + await vi.advanceTimersByTimeAsync(119_000 - Date.now()) + fixture.failure = true + const outcome = await getPRForBranchOutcome('/repo/wt-0', 'target', null, null, null, { + currentHeadOid: 'head', + localGitExecOptions: { ghAccount: { host: 'github.com', user: 'first' } }, + userRefresh: true + }) + expect(outcome).toMatchObject({ kind: 'upstream-error', nextAutoRetryAt: 239_000 }) + const atFailure = fixture.reads.length + await vi.advanceTimersByTimeAsync(238_999 - Date.now()) + expect(fixture.reads).toHaveLength(atFailure) + await vi.advanceTimersByTimeAsync(1) + expect(fixture.reads).toHaveLength(atFailure + 1) + } +) + +it.each(['unknown', 'removed'] as const)( + 'an explicit account failure does not back off default-account demand when its project is %s', + async (ownership) => { + await installFleet(750) + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + const { getPRForBranchOutcome } = await import('../../client/lookup/pr-for-branch-outcome') + const row = makeCandidate({ + repoId: 'repo', + repoPath: '/repo/wt-0', + branch: 'target', + currentHeadOid: 'head' + }) + reportVisiblePRRefreshCandidates([row], 1, 1) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(119_000 - Date.now()) + fixture.failure = true + if (ownership === 'unknown') { + release?.() + } else { + // Ownership changes during the read; the explicit fallback keeps the same account. + fixture.onRead = () => release?.() + } + const outcome = await getPRForBranchOutcome('/repo/wt-0', 'target', null, null, null, { + currentHeadOid: 'head', + localGitExecOptions: { ghAccount: { host: 'github.com', user: 'first' } }, + userRefresh: true + }) + expect(outcome).not.toHaveProperty('nextAutoRetryAt') + const atFailure = fixture.reads.length + await vi.advanceTimersByTimeAsync(121_000 - Date.now()) + expect(fixture.reads).toHaveLength(atFailure + 1) + expect(fixture.reads.at(-1)?.ghOptions.ghAccount).toBeUndefined() + } +) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-read-admission.ts b/src/main/github/pull-requests/refresh/pr-refresh-read-admission.ts deleted file mode 100644 index 4aa01a7a3ab..00000000000 --- a/src/main/github/pull-requests/refresh/pr-refresh-read-admission.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' -import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' -import { isBackground, isBudgetedBackground } 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 type { PRRefreshQueueScheduler } from './pr-refresh-queue-scheduler' -import type { PRWatchJobRunner } from './pr-refresh-watch-jobs' - -type PRReadAdmissionOwners = { - queue: PRRefreshQueue - pacing: PRRefreshPacing - events: PRRefreshEventPublisher - scheduler: PRRefreshQueueScheduler - watchJobs: PRWatchJobRunner -} - -export function admitPreparedPRRead( - { queue, pacing, events, scheduler, watchJobs }: PRReadAdmissionOwners, - next: PRRefreshQueueEntry, - prepared: PreparedPRBranchRead, - aliases: GitHubPRRefreshAlias[], - requestSequence: number, - requestStartedAt: number, - joinedRead: boolean -): boolean { - if (!queue.ownsRequest(next.key, requestSequence) || !prepared.isCurrent()) { - next.hosted?.finish(null) - prepared.release() - if (!next.hosted && queue.ownsRequest(next.key, requestSequence)) { - events.broadcast( - { aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }, - requestSequence - ) - if (next.watch) { - watchJobs.skipped(next) - } else { - scheduler.completeWithoutOutcome( - next.key, - next.candidate, - aliases, - next.priority, - next.windowId, - next.foreground - ) - } - } - return false - } - if (scheduler.consumeGatedError(next, prepared, requestSequence, requestStartedAt)) { - prepared.release() - return false - } - if (!next.watch && isBackground(next.reason) && !joinedRead) { - if (next.bypassBackgroundBudget !== true && isBudgetedBackground(next.reason)) { - pacing.noteBackgroundStart() - watchJobs.noteBudgetedStart(next) - } - if (usesActiveRefreshPacing(next)) { - pacing.noteActiveStart(next) - } - } - return true -} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-request-merge.ts b/src/main/github/pull-requests/refresh/pr-refresh-request-merge.ts new file mode 100644 index 00000000000..33d7920d06b --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-request-merge.ts @@ -0,0 +1,55 @@ +import { + aliasFromCandidate, + bypassesFreshnessDelay, + sameAliasRequestIdentity +} from './pr-refresh-candidate-policy' +import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import type { PRRefreshQueueEntry } from './pr-refresh-queue' + +/** How strong a queued request's claim to a read is: an action (manual, active, post-push), then a + * foreground return, other discovery, and automatic work last. */ +function requestStrength(entry: PRRefreshQueueEntry): number { + if (bypassesFreshnessDelay(entry.reason)) { + return 3 + } + if (entry.foreground || entry.bypassBackgroundBudget) { + return 2 + } + return entry.followUp || entry.errorRetry ? 0 : 1 +} + +/** The same shown row: one worktree, or one cache entry for a row with none. */ +function sameRow(left: GitHubPRRefreshAlias, right: GitHubPRRefreshAlias): boolean { + return left.worktreeId ? left.worktreeId === right.worktreeId : left.cacheKey === right.cacheKey +} + +/** + * Which entry a key keeps when work captured before a quota wait comes back to find `current` + * queued. `current` is newer: its aliases win for every row they cover, and a row it queued at + * another HEAD, link or scope supersedes the captured request, which then adds only its other + * rows. Otherwise the stronger claim leads (the captured one only when strictly stronger) and + * carries every row of both, so neither a routine report of another row nor a follow-up drops an + * action. + */ +export function mergeCapturedRequest( + current: PRRefreshQueueEntry, + captured: PRRefreshQueueEntry +): PRRefreshQueueEntry { + const capturedRow = aliasFromCandidate(captured.candidate) + const newer = [aliasFromCandidate(current.candidate), ...current.aliases.values()] + const superseded = newer.some( + (alias) => sameRow(alias, capturedRow) && !sameAliasRequestIdentity(alias, capturedRow) + ) + const worktrees = new Set(newer.filter((row) => row.worktreeId).map((row) => row.worktreeId)) + const caches = new Set(newer.filter((row) => !row.worktreeId).map((row) => row.cacheKey)) + const aliases = new Map(current.aliases) + for (const alias of captured.aliases.values()) { + if (!worktrees.has(alias.worktreeId) && !caches.has(alias.cacheKey)) { + aliases.set(alias.cacheKey, alias) + } + } + const kept = + !superseded && requestStrength(captured) > requestStrength(current) ? captured : current + kept.aliases = aliases + return kept +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-retry-state.ts b/src/main/github/pull-requests/refresh/pr-refresh-retry-state.ts index 3423968a04c..6780faef5a4 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-retry-state.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-retry-state.ts @@ -3,12 +3,44 @@ import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' import { lookupBackoffDelayMs } from '../../../source-control/hosted-review-refresh-pacing' -import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy' -import type { PreparedPRBranchRead } from '../../client/lookup/pr-for-branch-outcome' +import { + getPRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../shared/review-refresh-policy' +import { + aliasFromCandidate, + refreshIntervalForCandidate, + sameAliasRequestIdentity +} from './pr-refresh-candidate-policy' +import type { + PRReadSettlement, + PreparedPRBranchRead +} from '../../client/lookup/pr-for-branch-outcome' +import type { PRReadFailureHold } from '../../client/lookup/pr-branch-read-resources' import type { PRRefreshQueue } from './pr-refresh-queue' +type ErrorBackoff = { + failures: number + failedAt: number + /** The failure's one current scheduled outcome: what a producer settled, with the automatic + * retry the current cadence gives it (none under Off). Retiming, the current check, admission + * and delivery all read this; `retime` re-plans it and returns the key's gate, or null once a + * newer read replaced it. */ + settled?: PRRefreshOutcome + retime?: () => { gate: number; timed: boolean; ownsRequest: boolean } | null + /** Whether the settled failure still owns its request: no newer provider read started, its scope + * is unchanged and the exact cache still holds the error it scheduled. */ + current?: () => boolean + /** The failed provider read's claim, current until a newer read of its resource starts. */ + hold?: PRReadFailureHold + /** A newer read or a scope change replaced this failure; it owes no automatic retry. */ + revoked?: boolean + /** The row as it failed; a row now reported with another HEAD or links is another request. */ + candidate?: GitHubPRRefreshCandidate +} + export class PRRefreshRetryState { - private readonly errorBackoff = new Map() + private readonly errorBackoff = new Map() private readonly manualRetryGates = new Map() get errorBackoffCount(): number { @@ -16,10 +48,17 @@ export class PRRefreshRetryState { } reset(key: string): void { - this.errorBackoff.delete(key) + this.releaseFailure(key) this.manualRetryGates.delete(key) } + /** The row's demand ended (no longer visible): its failure owes nothing, though a manual + * rate-limit gate still holds. */ + releaseFailure(key: string): void { + this.errorBackoff.get(key)?.hold?.release() + this.errorBackoff.delete(key) + } + noteManualGate(key: string, outcome: PRRefreshOutcome): void { if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { this.manualRetryGates.set(key, outcome.retryDisabledUntil) @@ -32,20 +71,135 @@ export class PRRefreshRetryState { return this.manualRetryGates.get(key) ?? 0 } - nextVisibleErrorRetryAt(key: string, candidate: GitHubPRRefreshCandidate): number { - const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1 - const interval = refreshIntervalForCandidate(candidate) - const retryAt = - Date.now() + - Math.max(lookupBackoffDelayMs(failures), Number.isFinite(interval) ? interval : 0) - this.errorBackoff.set(key, { failures, retryAt }) - return retryAt + /** Infinity when the cadence is off: an ordinary visible read that failed waits for the next + * selection, push, window return or manual refresh, not for a timer. */ + nextVisibleErrorRetryAt( + key: string, + candidate: GitHubPRRefreshCandidate, + cadence: PRAutoRefreshCadence = getPRAutoRefreshCadence() + ): number { + const previous = this.errorBackoff.get(key) + previous?.hold?.release() + const failures = (previous?.failures ?? 0) + 1 + const failedAt = Date.now() + this.errorBackoff.set(key, { failures, failedAt }) + return planErrorRetry(candidate, failures, failedAt, cadence) + } + + /** The retry a failure already earned, planned again under the current cadence from the time + * the read failed: a retry queued before the cadence changed must not keep the old timing. + * null when no failure of this row's request still owns it; one that lost it is retired. */ + retimedVisibleErrorRetryAt( + key: string, + candidate: GitHubPRRefreshCandidate, + cadence: PRAutoRefreshCadence = getPRAutoRefreshCadence() + ): number | null { + const state = this.errorBackoff.get(key) + if (!state || state.revoked) { + return null + } + if ( + state.current?.() === false || + (state.candidate && !sameRequest(state.candidate, candidate)) + ) { + revoke(state) + return null + } + return planErrorRetry(candidate, state.failures, state.failedAt, cadence) + } + + /** The failure that still owns the row's request, and when it lets the row read again: this + * owner's settled failure while its read is the newest in scope (whether or not the exact cache + * kept it), else the exact record's accepted error. Its time is its plan under the current + * cadence (Default's when the cadence plans none) and any rate limit the host reported. Nothing + * is recorded: the failure was settled once, by its read. */ + currentFailure( + key: string, + candidate: GitHubPRRefreshCandidate, + exact?: PRRefreshOutcome + ): { gateAt: number; outcome?: PRRefreshOutcome } | null { + const state = this.errorBackoff.get(key) + // Only a failure a producer settled and still owns counts; a bare backoff entry does not. + if ( + state?.current?.() === true && + !state.revoked && + state.candidate !== undefined && + sameRequest(state.candidate, candidate) + ) { + const cadence = getPRAutoRefreshCadence() + const planned = planErrorRetry( + candidate, + state.failures, + state.failedAt, + cadence === 'off' ? 'default' : cadence + ) + const limited = + state.settled?.kind === 'upstream-error' ? (state.settled.retryDisabledUntil ?? 0) : 0 + return { + gateAt: Math.max(planned, limited, this.manualGateUntil(key)), + outcome: state.settled + } + } + if (exact?.kind === 'upstream-error') { + return { + gateAt: Math.max(exact.nextAutoRetryAt ?? 0, exact.retryDisabledUntil ?? 0), + outcome: exact + } + } + return null + } + + /** How long a failed key stays closed to background reads. Off plans no retry, yet holds the key + * as long as Default would, so a repeated report never re-reads a failure faster than Default. */ + errorGateAt(key: string, candidate: GitHubPRRefreshCandidate, retryAt: number): number { + if (Number.isFinite(retryAt)) { + return retryAt + } + const state = this.errorBackoff.get(key) + return state ? planErrorRetry(candidate, state.failures, state.failedAt, 'default') : Date.now() + } + + /** Whether the recorded failure belongs to the row as it is reported now (same HEAD and links); + * a failure of another request is released rather than restored for this one. */ + failureMatches(key: string, candidate: GitHubPRRefreshCandidate): boolean { + const failed = this.errorBackoff.get(key)?.candidate + if (failed && sameRequest(failed, candidate)) { + return true + } + this.releaseFailure(key) + return false + } + + /** Moves every retained error's shared schedule and gate to the current cadence, and returns + * when each still-current failure retries under it (none under Off), from its own failure. */ + retimeErrors(queue: PRRefreshQueue): Map { + const retries = new Map() + for (const [key, state] of this.errorBackoff) { + const plan = state.retime?.() + if (plan === null) { + revoke(state) + } else if (plan !== undefined) { + const gate = Math.max(plan.gate, this.manualGateUntil(key)) + if (plan.ownsRequest) { + queue.protectBackgroundUntil(key, gate) + } + if (plan.timed) { + retries.set(key, gate) + } + } + } + return retries } withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { if (outcome.kind !== 'upstream-error') { return outcome } + if (!Number.isFinite(retryAt)) { + // Off: no automatic retry, though a rate limit still gates a manual one. + const { nextAutoRetryAt: _unscheduled, ...unscheduled } = outcome + return unscheduled + } const cooldownUntil = outcome.retryDisabledUntil return { ...outcome, @@ -59,29 +213,128 @@ export class PRRefreshRetryState { outcome: PRRefreshOutcome, visible: boolean ): number | undefined { - return outcome.kind === 'upstream-error' - ? (outcome.nextAutoRetryAt ?? - (visible ? this.nextVisibleErrorRetryAt(key, candidate) : undefined)) - : undefined + if (outcome.kind !== 'upstream-error') { + return undefined + } + // Already settled (its schedule may have moved since): the current one, never counted again. + const settled = this.errorBackoff.get(key)?.settled + if ( + settled === outcome || + (settled?.kind === 'upstream-error' && + settled.fetchedAt === outcome.fetchedAt && + settled.errorType === outcome.errorType) + ) { + return settled.nextAutoRetryAt ?? Number.POSITIVE_INFINITY + } + const scheduled = getPRAutoRefreshCadence() === 'off' ? undefined : outcome.nextAutoRetryAt + return scheduled ?? (visible ? this.nextVisibleErrorRetryAt(key, candidate) : undefined) } + /** + * Settles the actual provider read's failure once, before its callers continue: plans the retry + * it owes and keeps how to re-plan it. A read the exact cache kept also schedules the shared + * error there; a full read the cache refused still owes its retry through its own claim. A newer + * read of the request starting retires that retry and its claim, keeping the failure count and + * rate gate. Other outcomes, and reads with no demand, pass through. Only a read that still owns + * its request also holds the request's queued background work until that retry. + */ settleProducerError( outcome: PRRefreshOutcome, key: string, candidate: GitHubPRRefreshCandidate, prepared: PreparedPRBranchRead, queue: PRRefreshQueue, - ownsRequest: boolean + producer: Parameters[1] = { retained: true }, + demand = true, + ownsRequest: () => boolean = () => true ): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error' || !demand || (!producer.retained && !producer.hold)) { + return outcome + } + const hold = producer.hold?.() + if (hold && !hold.current()) { + // A newer read already owns the request: this late failure owes it no retry. + hold.release() + return outcome + } + const rateLimitedUntil = outcome.retryDisabledUntil ?? 0 const retryAt = this.nextVisibleErrorRetryAt(key, candidate) + const state = this.errorBackoff.get(key)! const result = this.withErrorSchedule(outcome, retryAt) - prepared.scheduleError(outcome, result) - if (ownsRequest) { + if (producer.retained) { + prepared.scheduleError(outcome, result) + } + state.settled = result + state.hold = hold + state.candidate = candidate + state.current = () => + !(hold && !hold.current()) && !(producer.retained && !prepared.accepts(state.settled!)) + hold?.onSuperseded(() => { + if (this.errorBackoff.get(key) === state) { + revoke(state) + if (queue.get(key)?.errorRetry) { + queue.delete(key) + } + } + }) + // Re-plans from the original failure (its time, count and reported limit). A kept error is + // replaced only where the exact record still holds this one; when it does not, the failure no + // longer owns its request. A claimed one needs no room in the cache. + state.retime = () => { + if (!state.current!()) { + return null + } + const planned = planErrorRetry( + candidate, + state.failures, + state.failedAt, + getPRAutoRefreshCadence() + ) + const next = this.withErrorSchedule(outcome, planned) + if (producer.retained) { + prepared.scheduleError(state.settled!, next) + if (!prepared.accepts(next)) { + return null + } + } + state.settled = next + return { + gate: Math.max(this.errorGateAt(key, candidate, planned), rateLimitedUntil), + timed: Number.isFinite(planned), + ownsRequest: ownsRequest() + } + } + if (ownsRequest()) { queue.protectBackgroundUntil( key, - Math.max(retryAt, outcome.kind === 'upstream-error' ? (outcome.retryDisabledUntil ?? 0) : 0) + Math.max(this.errorGateAt(key, candidate, retryAt), rateLimitedUntil) ) } return result } } + +function revoke(state: ErrorBackoff): void { + state.retime = undefined + state.revoked = true + state.hold?.release() +} + +function sameRequest(left: GitHubPRRefreshCandidate, right: GitHubPRRefreshCandidate): boolean { + return sameAliasRequestIdentity(aliasFromCandidate(left), aliasFromCandidate(right)) +} + +function planErrorRetry( + candidate: GitHubPRRefreshCandidate, + failures: number, + failedAt: number, + cadence: PRAutoRefreshCadence +): number { + if (cadence === 'off') { + return Number.POSITIVE_INFINITY + } + const interval = refreshIntervalForCandidate(candidate, cadence) + return ( + failedAt + Math.max(lookupBackoffDelayMs(failures), Number.isFinite(interval) ? interval : 0) + ) +} diff --git a/src/main/github/pull-requests/refresh/pr-refresh-visibility.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-visibility.test.ts index e6406f9e356..7bda186519e 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-visibility.test.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-visibility.test.ts @@ -33,11 +33,13 @@ describe('PR refresh owner liveness', () => { registry.delete(1) destroyed.mockReturnValue(true) webContentsFromIdMock.mockClear() + visibility.prune() expect(visibility.has(refreshKey(candidate))).toBe(true) expect(visibility.windowCount).toBe(1) expect(webContentsFromIdMock.mock.calls).toEqual([[1], [2], [3]]) registry.delete(3) webContentsFromIdMock.mockClear() + visibility.prune() expect(visibility.has(refreshKey(candidate))).toBe(false) expect(webContentsFromIdMock.mock.calls).toEqual([[3]]) expect(visibility.windowCount).toBe(0) @@ -56,13 +58,13 @@ describe('PR refresh owner liveness', () => { visibility.report([{ ...candidate, isSelected: true }], 2, 1) visibility.report([candidate], 1, 2) visibility.update({ ...candidate, cachedFetchedAt: 3, cachedHeadOid: 'new-head' }) - expect(visibility.report([], 1, 1)).toBe(false) + expect(visibility.report([], 1, 1)).toBeNull() expect(visibility.candidate(key, candidate)).toMatchObject({ isSelected: true, cachedFetchedAt: 3, cachedHeadOid: 'new-head' }) - expect(visibility.report([candidate], 3, 1)).toBe(true) + expect(visibility.report([candidate], 3, 1)).not.toBeNull() expect(visibility.candidate(key, candidate)).toMatchObject({ isSelected: false, cachedFetchedAt: 3, @@ -73,6 +75,7 @@ describe('PR refresh owner liveness', () => { expect(visibility.has(key)).toBe(true) expect(visibility.candidate(key, candidate)).toMatchObject({ isSelected: true }) registry.delete(2) + visibility.prune() expect(visibility.has(key)).toBe(false) }) }) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-visibility.ts b/src/main/github/pull-requests/refresh/pr-refresh-visibility.ts index d46cf26cefa..0075ffdef27 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-visibility.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-visibility.ts @@ -1,61 +1,176 @@ import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' -import { refreshKey } from './pr-refresh-candidate-policy' +import { + aliasFromCandidate, + refreshKey, + sameAliasRequestIdentity +} from './pr-refresh-candidate-policy' import { prRefreshRenderers } from './pr-refresh-renderer-port' +type VisiblePRAliases = { + rows: Map + latest: GitHubPRRefreshCandidate + selected: boolean +} + +/** Which rows windows show. A key's demand ends when the last window stops showing it, whether a + * report drops it, the window clears or closes, or its workspace is removed; `demandEnded` hears + * it then, before that evidence is gone. */ export class PRRefreshVisibility { private readonly visibleByWindow = new Map< number, - { generation: number; candidates: Map } + { generation: number; candidates: Map } >() + constructor(private readonly demandEnded: (key: string) => void = () => {}) {} + get windowCount(): number { return this.visibleByWindow.size } clearWindow(windowId: number): boolean { - return this.visibleByWindow.delete(windowId) + const cleared = this.removeWindow(windowId) + this.prune() + return cleared } - report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean { - this.pruneDestroyedWindows() + /** Replaces a window's rows unless the report is older than the last one accepted (null). Returns + * the keys no window showed before (`exposed`) and those it shows as another request than + * before (`changed`); work only the window's own old and new rows. */ + report( + candidates: GitHubPRRefreshCandidate[], + generation: number, + windowId: number + ): { exposed: Set; changed: Set } | null { + this.prune() const existing = this.visibleByWindow.get(windowId) if (existing && generation < existing.generation) { - return false + return null } - const reported = new Map() + const exposed = new Set() + const changed = new Set() + const reported = new Map() for (const candidate of candidates) { const key = refreshKey(candidate) + const aliasKey = candidate.worktreeId + ? `worktree:${candidate.worktreeId}` + : `cache:${candidate.cacheKey}` + const prior = existing?.candidates.get(key)?.rows.get(aliasKey) + if (!prior) { + exposed.add(key) + } else if ( + prior && + !sameAliasRequestIdentity(aliasFromCandidate(prior), aliasFromCandidate(candidate)) + ) { + changed.add(key) + } const previous = this.candidate(key, candidate) - reported.set(key, { + let aliases = reported.get(key) + if (!aliases) { + aliases = { rows: new Map(), latest: candidate, selected: false } + reported.set(key, aliases) + } + const current = { ...previous, ...candidate, ...(previous.cachedFetchedAt != null && previous.cachedFetchedAt > (candidate.cachedFetchedAt ?? 0) ? this.cachedFields(previous) : {}), - isSelected: candidate.isSelected === true || reported.get(key)?.isSelected === true - }) + isSelected: candidate.isSelected === true + } + aliases.rows.set(aliasKey, current) + aliases.selected ||= current.isSelected + if ((current.cachedFetchedAt ?? 0) >= (aliases.latest.cachedFetchedAt ?? 0)) { + aliases.latest = current + } } this.visibleByWindow.set(windowId, { generation, candidates: reported }) - return true + for (const key of existing?.candidates.keys() ?? []) { + if (!reported.has(key)) { + this.endDemand(key) + } + } + return { exposed, changed } + } + + /** A removed workspace's rows are no longer shown, whatever its windows reported last. */ + removeWorktree(worktreeId: string): void { + const removed = new Set() + for (const window of this.visibleByWindow.values()) { + for (const [key, aliases] of window.candidates) { + let changed = false + for (const [cacheKey, candidate] of aliases.rows) { + if (candidate.worktreeId === worktreeId) { + aliases.rows.delete(cacheKey) + changed = true + removed.add(key) + } + } + if (aliases.rows.size === 0) { + window.candidates.delete(key) + } else if (changed) { + aliases.latest = aliases.rows.values().next().value! + aliases.selected = false + for (const candidate of aliases.rows.values()) { + aliases.selected ||= candidate.isSelected === true + if ((candidate.cachedFetchedAt ?? 0) > (aliases.latest.cachedFetchedAt ?? 0)) { + aliases.latest = candidate + } + } + } + } + } + removed.forEach((key) => this.endDemand(key)) + } + + /** Every reported candidate, with the window that reported it. */ + *reported(): Generator<[number, GitHubPRRefreshCandidate]> { + this.prune() + for (const [windowId, window] of this.visibleByWindow) { + for (const aliases of window.candidates.values()) { + for (const candidate of aliases.rows.values()) { + yield [windowId, candidate] + } + } + } } has(key: string): boolean { - this.pruneDestroyedWindows() - return Array.from(this.visibleByWindow.values()).some((window) => window.candidates.has(key)) + return this.shown(key) !== undefined + } + + /** Whether any window still shows a row of the key that `matches` (the same exact request, from + * whichever of its aliases). */ + hasDemand(key: string, matches: (shown: GitHubPRRefreshCandidate) => boolean): boolean { + return this.shown(key, matches) !== undefined + } + + /** A currently reported alias of the key, optionally matching an exact request. */ + shown( + key: string, + matches: (shown: GitHubPRRefreshCandidate) => boolean = () => true + ): GitHubPRRefreshCandidate | undefined { + for (const window of this.visibleByWindow.values()) { + for (const candidate of window.candidates.get(key)?.rows.values() ?? []) { + if (matches(candidate)) { + return candidate + } + } + } + return undefined } candidate(key: string, fallback: GitHubPRRefreshCandidate): GitHubPRRefreshCandidate { let latest = fallback let selected = false for (const window of this.visibleByWindow.values()) { - const candidate = window.candidates.get(key) - if (!candidate) { + const aliases = window.candidates.get(key) + if (!aliases) { continue } - selected ||= candidate.isSelected === true - if ((candidate.cachedFetchedAt ?? 0) > (latest.cachedFetchedAt ?? 0)) { - latest = { ...fallback, ...this.cachedFields(candidate) } + selected ||= aliases.selected + if ((aliases.latest.cachedFetchedAt ?? 0) > (latest.cachedFetchedAt ?? 0)) { + latest = { ...fallback, ...this.cachedFields(aliases.latest) } } } return { ...latest, isSelected: selected } @@ -64,9 +179,17 @@ export class PRRefreshVisibility { update(candidate: GitHubPRRefreshCandidate): void { const key = refreshKey(candidate) for (const window of this.visibleByWindow.values()) { - const current = window.candidates.get(key) - if (current && (current.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) { - window.candidates.set(key, { ...current, ...this.cachedFields(candidate) }) + const aliases = window.candidates.get(key) + if (!aliases) { + continue + } + for (const [aliasKey, current] of aliases.rows) { + if ((current.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) { + aliases.rows.set(aliasKey, { ...current, ...this.cachedFields(candidate) }) + } + } + if ((aliases.latest.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) { + aliases.latest = candidate } } } @@ -83,11 +206,25 @@ export class PRRefreshVisibility { } } - private pruneDestroyedWindows(): void { - for (const windowId of this.visibleByWindow.keys()) { + /** Drops windows whose renderer is gone, ending their rows' demand. */ + prune(): void { + for (const windowId of Array.from(this.visibleByWindow.keys())) { if (!prRefreshRenderers().isRendererOpen(windowId)) { - this.visibleByWindow.delete(windowId) + this.removeWindow(windowId) } } } + + private removeWindow(windowId: number): boolean { + const window = this.visibleByWindow.get(windowId) + this.visibleByWindow.delete(windowId) + window?.candidates.forEach((_, key) => this.endDemand(key)) + return window !== undefined + } + + private endDemand(key: string): void { + if (!this.has(key)) { + this.demandEnded(key) + } + } } diff --git a/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs-cadence.test.ts b/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs-cadence.test.ts new file mode 100644 index 00000000000..1a0e284c51a --- /dev/null +++ b/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs-cadence.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' +import { setPRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' +import { makePR } from './pr-refresh-coordinator-test-harness' +import { prWatchNextDueAt } from './pr-refresh-watch-jobs' + +const runningChecks: PRRefreshOutcome = { + kind: 'found', + pr: makePR({ checksStatus: 'pending' }), + fetchedAt: 1_000 +} + +describe('CI-line watch cadence', () => { + afterEach(() => { + setPRAutoRefreshCadence('default') + }) + + it('reads running checks on the background interval by default', () => { + expect(prWatchNextDueAt(runningChecks)).toBe(1_000 + 120_000) + }) + + it('follows a reduced host cadence', () => { + setPRAutoRefreshCadence('reduced') + expect(prWatchNextDueAt(runningChecks)).toBe(1_000 + 10 * 60_000) + }) + + it('slows instead of ending when auto-refresh is off, so the chat still gets its result', () => { + setPRAutoRefreshCadence('off') + expect(prWatchNextDueAt(runningChecks)).toBe(1_000 + 20 * 60_000) + }) +}) diff --git a/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs.ts b/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs.ts index f11b009baea..354c4b83fa1 100644 --- a/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs.ts +++ b/src/main/github/pull-requests/refresh/pr-refresh-watch-jobs.ts @@ -5,7 +5,12 @@ import type { import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types' import { prChecksDone } from '../../../../shared/github/pull-requests/refresh/pr-quiet-refresh' export { prChecksDone } from '../../../../shared/github/pull-requests/refresh/pr-quiet-refresh' -import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy' +import { + getPRAutoRefreshCadence, + reviewRefreshIntervalMs, + scaleReviewRefreshInterval, + type PRAutoRefreshCadence +} from '../../../../shared/review-refresh-policy' import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' import type { PRRefreshRetryState } from './pr-refresh-retry-state' @@ -14,7 +19,7 @@ export type PRWatchJob = NonNullable export type PRWatchJobHooks = { /** False once the watch ends, so its job is not read again. */ isWatched: (job: PRWatchJob) => boolean - /** Reads one earlier head's checks; resolves the next due time, or null when that head is done. */ + /** Reads one earlier head's checks; resolves when it read them, or null when that head is done. */ readHeadChecks: ( job: Required, candidate: GitHubPRRefreshCandidate @@ -30,6 +35,15 @@ const AGED_PR_WATCH_PRIORITY = 50 const YIELDING_PR_WATCH_PRIORITY = 30 // A watch skipped while its repo's WSL distro is stopped looks again later, without starting it. const PR_WATCH_SKIPPED_RETRY_MS = 15 * 60_000 +const HEAD_CHECKS_INTERVAL_MS = 120_000 + +/** What a watch's next read is planned from, so a cadence change derives it again from the same + * anchor: a read of the PR (when, and the state it showed), an earlier head's read, or the + * watch's current failure. A first read or a WSL skip has no plan; its time stands. */ +export type PRWatchPlan = + | { kind: 'read'; at: number; pr: Parameters[0] } + | { kind: 'head'; at: number } + | { kind: 'failure' } export function prWatchQueueKey(job: PRWatchJob): string { return job.headSha ? `sha-checks::${job.prKey}::${job.headSha}` : `watch::${job.prKey}` @@ -40,34 +54,73 @@ export function prWatchSettled(pr: PRInfo): boolean { return (pr.state === 'merged' || pr.state === 'closed') && prChecksDone(pr) } -/** A watch's next read: the background cadence, or none once the PR has settled. */ -export function prWatchNextDueAt(outcome: PRRefreshOutcome): number | null { +/** The cadence every CI-line watch read follows, current head and earlier heads alike: a chat asked + * for this PR's CI result, so "off" slows the watch to Minimal rather than ending it unanswered. */ +export function prWatchCadence( + cadence: PRAutoRefreshCadence = getPRAutoRefreshCadence() +): Exclude { + return cadence === 'off' ? 'minimal' : cadence +} + +/** The plan a read of the PR gives its watch; none for a failed read. */ +export function prWatchReadPlan( + outcome: PRRefreshOutcome +): Extract | null { if (outcome.kind === 'upstream-error') { return null } const pr = outcome.kind === 'found' ? outcome.pr : null - const interval = reviewRefreshIntervalMs({ - state: pr?.state ?? null, - checksStatus: pr && !prChecksDone(pr) ? 'pending' : (pr?.checksStatus ?? null), - hasReview: pr !== null, - selected: false - }) - return interval === null ? null : outcome.fetchedAt + interval + return { + kind: 'read', + at: outcome.fetchedAt, + pr: { + state: pr?.state ?? null, + checksStatus: pr && !prChecksDone(pr) ? 'pending' : (pr?.checksStatus ?? null), + hasReview: pr !== null, + selected: false + } + } } -/** A new watch keeps the earliest due time; a read from elsewhere only defers a queued one. */ +/** When a read- or head-planned watch reads next under the current watch cadence, from its anchor; + * null once its read showed the PR settled. */ +export function prWatchPlanDueAt(plan: Exclude): number | null { + if (plan.kind === 'head') { + return ( + plan.at + + (scaleReviewRefreshInterval(HEAD_CHECKS_INTERVAL_MS, prWatchCadence()) ?? + HEAD_CHECKS_INTERVAL_MS) + ) + } + const interval = reviewRefreshIntervalMs(plan.pr, prWatchCadence()) + return interval === null ? null : plan.at + interval +} + +/** A watch's next read: the background cadence, or none once the PR has settled. */ +export function prWatchNextDueAt(outcome: PRRefreshOutcome): number | null { + const plan = prWatchReadPlan(outcome) + return plan && prWatchPlanDueAt(plan) +} + +/** A new watch keeps the earliest due time; a read from elsewhere only defers a queued one. The + * plan that sets the due time stays with it. */ export function schedulePRWatchEntry( queue: PRRefreshQueue, job: PRWatchJob, candidate: GitHubPRRefreshCandidate, dueAt: number, - defer: boolean + defer: boolean, + plan?: PRWatchPlan ): void { const key = prWatchQueueKey(job) const existing = queue.get(key) if (existing) { existing.candidate = candidate - existing.dueAt = defer ? Math.max(existing.dueAt, dueAt) : Math.min(existing.dueAt, dueAt) + const next = defer ? Math.max(existing.dueAt, dueAt) : Math.min(existing.dueAt, dueAt) + if (next !== existing.dueAt) { + existing.dueAt = next + existing.watchPlan = plan + } return } if (defer) { @@ -82,7 +135,8 @@ export function schedulePRWatchEntry( priority: PR_WATCH_PRIORITY, dueAt, queuedAt: queue.nextOrder(), - watch: job + watch: job, + watchPlan: plan }) } @@ -121,6 +175,28 @@ export class PRWatchJobRunner { this.followUp(entry, Date.now() + PR_WATCH_SKIPPED_RETRY_MS) } + /** When a queued watch reads next under the current watch cadence, from the read or failure it + * was planned from; undefined when it has no plan, so its time stands. */ + plannedDueAt(entry: PRRefreshQueueEntry): number | null | undefined { + const plan = entry.watchPlan + if (!plan) { + return undefined + } + return plan.kind === 'failure' + ? this.retry.retimedVisibleErrorRetryAt(entry.key, entry.candidate, prWatchCadence()) + : prWatchPlanDueAt(plan) + } + + /** A cadence change: every queued watch takes the time its plan gives under the new cadence. */ + retime(): void { + for (const entry of this.queue.values()) { + const dueAt = entry.watch ? this.plannedDueAt(entry) : undefined + if (dueAt != null) { + entry.dueAt = Math.max(dueAt, this.queue.backgroundGateUntil(entry.key)) + } + } + } + /** Runs an earlier head's job; false for any other entry, which the drain reads as usual. */ async readHead(entry: PRRefreshQueueEntry): Promise { const headSha = entry.watch?.headSha @@ -128,25 +204,37 @@ export class PRWatchJobRunner { return false } const job = { prKey: entry.watch.prKey, headSha } - this.followUp(entry, (await this.hooks?.readHeadChecks(job, entry.candidate)) ?? null) + const at = (await this.hooks?.readHeadChecks(job, entry.candidate)) ?? null + const plan = at === null ? null : ({ kind: 'head', at } as const) + this.followUp(entry, plan && prWatchPlanDueAt(plan), plan) return true } afterOutcome(entry: PRRefreshQueueEntry, outcome: PRRefreshOutcome): void { if (outcome.kind === 'upstream-error') { - this.followUp(entry, this.retry.nextVisibleErrorRetryAt(entry.key, entry.candidate)) + // Watches keep retrying under "off" (at Minimal), unlike ordinary visible reads. + this.followUp( + entry, + this.retry.nextVisibleErrorRetryAt(entry.key, entry.candidate, prWatchCadence()), + { kind: 'failure' } + ) return } this.retry.reset(entry.key) - this.followUp(entry, prWatchNextDueAt(outcome)) + const plan = prWatchReadPlan(outcome) + this.followUp(entry, plan && prWatchPlanDueAt(plan), plan) } - private followUp(entry: PRRefreshQueueEntry, dueAt: number | null): void { + private followUp( + entry: PRRefreshQueueEntry, + dueAt: number | null, + plan: PRWatchPlan | null = null + ): void { const job = entry.watch if (!job || dueAt === null || !(this.hooks?.isWatched(job) ?? false)) { return } - schedulePRWatchEntry(this.queue, job, entry.candidate, dueAt, false) + schedulePRWatchEntry(this.queue, job, entry.candidate, dueAt, false, plan ?? undefined) this.drainer.schedule(Math.max(0, dueAt - Date.now())) } } diff --git a/src/main/github/repositories/github-repository-identity.ts b/src/main/github/repositories/github-repository-identity.ts index 0e2f1c7640d..f915b0ab2ce 100644 --- a/src/main/github/repositories/github-repository-identity.ts +++ b/src/main/github/repositories/github-repository-identity.ts @@ -24,6 +24,8 @@ export type GitHubRepoContext = { admissionTier?: GitAdmissionTier /** SSH keeps the binding even when cwd is omitted from gh options. */ ghAccount?: GhAccountBinding + /** Captured authority; a known unbound project also overrides caller account input. */ + ghAccountFromProject?: boolean } export type LocalGitExecOptions = { @@ -90,9 +92,14 @@ export function githubRepoContext( scope.wslDistro === context.wslDistro ? scope : null - const account = pinned ?? resolveGitHubProjectAccount(context) ?? context + const project = pinned ? null : resolveGitHubProjectAccount(context) + const account = pinned ?? project ?? context const binding = account.ghAccount ? Object.freeze({ ...account.ghAccount }) : undefined - return Object.freeze({ ...context, ghAccount: binding }) + return Object.freeze({ + ...context, + ghAccount: binding, + ghAccountFromProject: pinned ? pinned.ghAccountFromProject === true : project !== null + }) } export function ghRepoExecOptions(context: GitHubRepoContext): { diff --git a/src/main/runtime/client/settings/runtime-client-settings-shape.ts b/src/main/runtime/client/settings/runtime-client-settings-shape.ts index cb86e2fdfd2..73e93acd51c 100644 --- a/src/main/runtime/client/settings/runtime-client-settings-shape.ts +++ b/src/main/runtime/client/settings/runtime-client-settings-shape.ts @@ -42,6 +42,7 @@ export type RuntimeClientSettings = Pick< | 'nativeChatCiResultLines' | 'sourceControlGroupOrder' | 'sourceControlCompareAgainstUpstream' + | 'prAutoRefresh' | 'diffShowWhitespace' > & { /** Always set; older hosts omit it, so clients must not write it to them (settings.update is strict). */ @@ -88,4 +89,5 @@ export type RuntimeClientSettingsUpdate = Pick< | 'nativeChatPermissionMode' | 'nativeChatAutoContinueAfterUsageLimit' | 'workspaceCleanupPolicy' + | 'prAutoRefresh' > diff --git a/src/main/runtime/client/settings/runtime-client-settings.ts b/src/main/runtime/client/settings/runtime-client-settings.ts index ac6fe4c8738..fb882ea4912 100644 --- a/src/main/runtime/client/settings/runtime-client-settings.ts +++ b/src/main/runtime/client/settings/runtime-client-settings.ts @@ -26,6 +26,7 @@ import { applyAgentStatusHooksEnabled } from '../../../agent-hooks/installation/ import { isWslHostOrcadProcess } from '../../../ipc/wsl-guest-windows-drive-path' import { isAgentStatusHooksEnabledForAgent } from '../../../../shared/agent-hook-listener/configuration/agent-status-hooks-setting' import type { RuntimeStore } from '../../construction/runtime-store-contract' +import { normalizePRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' import { agentLaunchSettingsMutationUpdates, projectAgentLaunchSettings, @@ -101,6 +102,7 @@ export class RuntimeClientSettingsController { // Paired clients order their Source Control lists the way this desktop does. sourceControlGroupOrder: normalizeSourceControlGroupOrder(settings.sourceControlGroupOrder), sourceControlCompareAgainstUpstream: settings.sourceControlCompareAgainstUpstream === true, + prAutoRefresh: normalizePRAutoRefreshCadence(settings.prAutoRefresh), diffShowWhitespace: settings.diffShowWhitespace === true, // Omitted without a service: clients treat absence as "ask the host", which names the real cause. ...(this.sharingServices?.artifacts() === false diff --git a/src/main/runtime/construction/runtime-store-contract.ts b/src/main/runtime/construction/runtime-store-contract.ts index 26469e02989..12d8595eb0d 100644 --- a/src/main/runtime/construction/runtime-store-contract.ts +++ b/src/main/runtime/construction/runtime-store-contract.ts @@ -177,6 +177,7 @@ export type RuntimeStore = { prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] sourceControlGroupOrder?: GlobalSettings['sourceControlGroupOrder'] sourceControlCompareAgainstUpstream?: GlobalSettings['sourceControlCompareAgainstUpstream'] + prAutoRefresh?: GlobalSettings['prAutoRefresh'] diffShowWhitespace?: GlobalSettings['diffShowWhitespace'] artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] diff --git a/src/main/runtime/integrations/github/runtime-github-review-query-commands-checks-cap.test.ts b/src/main/runtime/integrations/github/runtime-github-review-query-commands-checks-cap.test.ts new file mode 100644 index 00000000000..96a08f24bea --- /dev/null +++ b/src/main/runtime/integrations/github/runtime-github-review-query-commands-checks-cap.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { setPRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' +import { + _prChecksResultCountForTests, + _resetPacedPRChecksForTests +} from '../../../github/client/check/pr-checks-last-outcome' +import { + __resetHostedReviewScopeGenerationsForTests, + bumpScopeGeneration, + hostedReviewRepoScope +} from '../../../source-control/hosted-review-scope-generations' + +// Only the provider is faked: each gh call waits for the test to answer it with a check name. +type ProviderCall = { answer: (name: string) => void; fail: () => void } +const provider = vi.hoisted(() => { + const calls: ProviderCall[] = [] + return { calls, offline: false } +}) +vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({ + ghExecFileAsync: () => + new Promise((resolve, reject) => + provider.offline + ? reject(new Error('offline')) + : provider.calls.push({ + answer: (name) => + resolve({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + headRefOid: 'head-a', + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: 'CheckRun', + name, + status: 'COMPLETED', + conclusion: 'SUCCESS' + } + ] + } + } + } + } + ] + } + } + } + } + }), + stderr: '' + }), + fail: () => reject(new Error('offline')) + }) + ) +})) +vi.mock('../../../github/repositories/github-api-repository', () => ({ + resolveGitHubRepoExecution: async () => ({ + ownerRepo: { owner: 'acme', repo: 'repo' }, + ghOptions: {} + }) +})) +vi.mock('../../../github/client/lookup/pr-lookup-rate-limit', () => ({ + assertRateLimitBudget: async () => {} +})) +vi.mock('../../../github/api/rate-limit', () => ({ noteRepositoryRateLimitSpend: () => {} })) + +import { RuntimeGitHubReviewQueryCommands } from './runtime-github-review-query-commands' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' +} +const commands = new RuntimeGitHubReviewQueryCommands({ + resolveRepo: async () => repo, + getLocalGitArgs: () => [] +}) +const prRepo = { owner: 'acme', repo: 'repo' } + +const read = (options: Parameters[4] = {}, head = 'head-a') => + commands.getRepoPRChecks(repo.id, 7, head, prRepo, options) +const timed = (head?: string) => read({ reason: 'swr' }, head) +const manual = () => read({ noCache: true, userRefresh: true }) +const names = async (checks: ReturnType) => (await checks).map((check) => check.name) + +async function answerNext(name: string): Promise { + await vi.waitFor(() => expect(provider.calls.length).toBeGreaterThan(0)) + provider.calls.shift()!.answer(name) +} + +describe('github.prChecks: one checks result per PR head, read on the host’s cadence', () => { + beforeEach(() => { + provider.calls = [] + provider.offline = false + _resetPacedPRChecksForTests() + __resetHostedReviewScopeGenerationsForTests() + }) + afterEach(async () => { + // Settle whatever a failed case left running: each failed call releases its command slot, so + // reads still queued for a slot run too (failing at once, offline) until none is left. + provider.offline = true + for (const call of provider.calls.splice(0)) { + call.fail() + } + await vi.waitFor(() => expect(_prChecksResultCountForTests().running).toBe(0)) + setPRAutoRefreshCadence('default') + vi.useRealTimers() + }) + + it('reads the provider for every timed read by default, and answers from the last read while off', async () => { + const first = timed() + await answerNext('first') + await first + const again = timed() + await answerNext('second') + expect(await names(again)).toEqual(['second']) + + setPRAutoRefreshCadence('off') + expect(await names(timed())).toEqual(['second']) + expect(provider.calls).toHaveLength(0) + // Another head is another resource. + const otherHead = timed('head-b') + await answerNext('other head') + expect(await names(otherHead)).toEqual(['other head']) + }) + + it('while off, a manual read still reaches the provider and becomes every later timed answer; a failed one changes nothing', async () => { + setPRAutoRefreshCadence('off') + const first = timed() + await answerNext('timed') + await first + const refreshed = manual() + await answerNext('manual') + expect(await names(refreshed)).toEqual(['manual']) + expect(await names(timed())).toEqual(['manual']) + + // Every provider path (GraphQL, REST and gh pr checks) fails. + provider.offline = true + await expect(manual()).rejects.toThrow() + provider.offline = false + expect(await names(timed())).toEqual(['manual']) + }) + + it('a read that joins an older timed read never hands back or keeps that older answer over a newer manual one', async () => { + setPRAutoRefreshCadence('off') + const olderTimed = timed() + await vi.waitFor(() => expect(provider.calls).toHaveLength(1)) + const refreshed = manual() + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.pop()!.answer('manual') + expect(await names(refreshed)).toEqual(['manual']) + + const joined = read() + provider.calls.shift()!.answer('older timed') + expect(await names(joined)).toEqual(['manual']) + expect(await names(olderTimed)).toEqual(['manual']) + expect(await names(timed())).toEqual(['manual']) + expect(provider.calls).toHaveLength(0) + }) + + it('keeps that newer manual answer authoritative while the older read lives through cache pressure', async () => { + setPRAutoRefreshCadence('off') + const olderTimed = timed() + await vi.waitFor(() => expect(provider.calls).toHaveLength(1)) + const refreshed = manual() + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.pop()!.answer('manual') + expect(await names(refreshed)).toEqual(['manual']) + const joined = read() + // 256 other PR heads complete while the older read is still out, filling the completed bound. + for (let head = 0; head < 256; head += 1) { + const other = read({}, `other-head-${head}`) + await vi.waitFor(() => expect(provider.calls).toHaveLength(2), { interval: 1 }) + provider.calls.pop()!.answer(`other ${head}`) + expect(await names(other)).toEqual([`other ${head}`]) + } + provider.calls.shift()!.answer('older timed') + expect(await names(joined)).toEqual(['manual']) + expect(await names(olderTimed)).toEqual(['manual']) + expect(await names(timed())).toEqual(['manual']) + expect(provider.calls).toHaveLength(0) + expect(_prChecksResultCountForTests()).toEqual({ completed: 256, running: 0 }) + }) + + it.each(['answers', 'fails'] as const)( + 'a read replaced by a user refresh that ended long ago %s with the newest answer accepted since, even after eviction', + async (outcome) => { + const replaced = timed() + const joined = read() + await vi.waitFor(() => expect(provider.calls).toHaveLength(1)) + // A user refresh of the same read replaces the running one instead of joining it, and ends. + const refreshed = read({ userRefresh: true }) + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.pop()!.answer('refreshed') + expect(await names(refreshed)).toEqual(['refreshed']) + // A later manual read of the other intent starts and ends while the replaced read still runs. + const manualRead = manual() + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.pop()!.answer('manual') + expect(await names(manualRead)).toEqual(['manual']) + for (let head = 0; head < 256; head += 1) { + const other = read({}, `evicting-head-${head}`) + await vi.waitFor(() => expect(provider.calls).toHaveLength(2), { interval: 1 }) + provider.calls.pop()!.answer(`other ${head}`) + await other + } + if (outcome === 'answers') { + provider.calls.shift()!.answer('replaced') + } else { + provider.offline = true + provider.calls.shift()!.fail() + } + expect(await names(replaced)).toEqual(['manual']) + expect(await names(joined)).toEqual(['manual']) + expect(provider.calls).toHaveLength(0) + expect(_prChecksResultCountForTests()).toEqual({ completed: 256, running: 0 }) + } + ) + + it('a replaced read answers with its own result when its successor fails and nothing newer was accepted', async () => { + const replaced = timed() + const joined = read() + await vi.waitFor(() => expect(provider.calls).toHaveLength(1)) + const refreshed = read({ userRefresh: true }) + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.shift()!.answer('replaced') + provider.offline = true + provider.calls.shift()!.fail() + await expect(refreshed).rejects.toThrow() + expect(await names(replaced)).toEqual(['replaced']) + expect(await names(joined)).toEqual(['replaced']) + expect(_prChecksResultCountForTests().running).toBe(0) + }) + + it('keeps at most 256 completed answers while every one of them is being read again, and a new manual read still works', async () => { + const heads = Array.from({ length: 256 }, (_, head) => `cached-head-${head}`) + for (const head of heads) { + const cached = read({}, head) + await vi.waitFor(() => expect(provider.calls).toHaveLength(1), { interval: 1 }) + provider.calls.shift()!.answer(head) + await cached + } + // All 256 are read again at once; only four gh commands may run at a time, the rest wait for + // a command slot while their reads are already live. + const rereads = heads.map((head) => read({ noCache: true }, head)) + await vi.waitFor(() => expect(provider.calls).toHaveLength(4), { interval: 1 }) + expect(_prChecksResultCountForTests()).toEqual({ completed: 256, running: 256 }) + + // A new manual read of another PR head waits its turn for a slot behind them, then reads. + const fresh = read({ noCache: true, userRefresh: true }, 'new-head') + let answered = 0 + while (answered < heads.length + 1) { + await vi.waitFor(() => expect(provider.calls.length).toBeGreaterThan(0), { interval: 1 }) + const admitted = provider.calls.splice(0) + expect(admitted.length).toBeLessThanOrEqual(4) + for (const call of admitted) { + call.answer(answered === heads.length ? 'new' : 'reread') + answered += 1 + } + } + expect(await names(fresh)).toEqual(['new']) + await Promise.all(rereads) + expect(_prChecksResultCountForTests()).toEqual({ completed: 256, running: 0 }) + }) + + it('a read after a PR mutation starts its own provider read instead of joining one from before it', async () => { + setPRAutoRefreshCadence('off') + const beforeMutation = timed() + await vi.waitFor(() => expect(provider.calls).toHaveLength(1)) + bumpScopeGeneration(hostedReviewRepoScope(repo.path, 'local')) + const afterMutation = read() + await vi.waitFor(() => expect(provider.calls).toHaveLength(2)) + provider.calls.shift()!.answer('before mutation') + await beforeMutation + provider.calls.shift()!.answer('after mutation') + expect(await names(afterMutation)).toEqual(['after mutation']) + expect(await names(timed())).toEqual(['after mutation']) + }) +}) diff --git a/src/main/runtime/integrations/github/runtime-github-review-query-commands.ts b/src/main/runtime/integrations/github/runtime-github-review-query-commands.ts index 48e4118088f..6e4d008756c 100644 --- a/src/main/runtime/integrations/github/runtime-github-review-query-commands.ts +++ b/src/main/runtime/integrations/github/runtime-github-review-query-commands.ts @@ -8,6 +8,7 @@ import { getPRChecks } from '../../../github/client/check/get-pr-checks' import { getPRComments } from '../../../github/client/fetch/get-pr-comments' import { getPRFileContents } from '../../../github/pull-requests/files/pull-request-file-contents' import { getRepoRoutingConnectionId } from '../../../../shared/execution-host' +import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types' type LocalGitArgs = [] | [LocalProjectGhExecOptions] @@ -37,15 +38,21 @@ export class RuntimeGitHubReviewQueryCommands { prNumber: number, headSha?: string, prRepo?: GitHubOwnerRepo | null, - options?: { noCache?: boolean; userRefresh?: boolean } + options?: { + noCache?: boolean + userRefresh?: boolean + reason?: GitHubPRRefreshReason + } ): Promise>> { const repo = await this.deps.resolveRepo(repoSelector) + const { reason, ...readOptions } = options ?? {} return getPRChecks( repo.path, prNumber, headSha, prRepo ?? null, - options, + // Only a client's timer is capped by this host's cadence; other reasons stay fresh. + { ...readOptions, timed: reason === 'swr' }, getRepoRoutingConnectionId(repo), ...this.deps.getLocalGitArgs(repo) ) diff --git a/src/main/runtime/integrations/reviews/runtime-hosted-pr-cadence.test.ts b/src/main/runtime/integrations/reviews/runtime-hosted-pr-cadence.test.ts new file mode 100644 index 00000000000..5403ce92e10 --- /dev/null +++ b/src/main/runtime/integrations/reviews/runtime-hosted-pr-cadence.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PRAutoRefreshCadence } from '../../../../shared/review-refresh-policy' +import { fixture, found, mocks, read } from './runtime-hosted-pr-snapshot-test-harness' + +// The harness resets modules per test; change the cadence on that same instance, as the host's +// settings sync does. +async function setHostCadence(cadence: PRAutoRefreshCadence): Promise { + const policy = await import('../../../../shared/review-refresh-policy') + const coordinator = await import('../../../github/pull-requests/refresh/pr-refresh-coordinator') + policy.setPRAutoRefreshCadence(cadence) + coordinator.retimePRRefreshesForCadence() +} + +describe('hosted PR snapshots under the host’s auto-refresh cadence', () => { + it('stretches a confirmed quiet result by the cadence, and keeps it while off', async () => { + await setHostCadence('reduced') + await read('quiet', 'active') + await vi.advanceTimersByTimeAsync(5 * 60_000) + const confirmation = read('quiet') + await vi.advanceTimersByTimeAsync(0) + expect(await confirmation).toMatchObject({ fetchedAt: 301_000 }) + fixture.title = 'moved' + // Quiet is five minutes on Default; Reduced keeps it for twenty-five. + await vi.advanceTimersByTimeAsync(24 * 60_000) + expect(await read('quiet')).toMatchObject({ pr: { title: 'before' }, fetchedAt: 301_000 }) + await vi.advanceTimersByTimeAsync(60_000) + const due = read('quiet') + await vi.advanceTimersByTimeAsync(0) + expect(await due).toMatchObject({ pr: { title: 'moved' } }) + + await setHostCadence('off') + const calls = mocks.resolve.mock.calls.length + await vi.advanceTimersByTimeAsync(24 * 60 * 60_000) + expect(await read('quiet')).toMatchObject({ pr: { title: 'moved' } }) + expect(mocks.resolve).toHaveBeenCalledTimes(calls) + fixture.title = 'looked again' + expect(await read('quiet', 'active')).toMatchObject({ pr: { title: 'looked again' } }) + }) + + it('takes a failure’s retry off the timer while off and restores it from the failure time', async () => { + await read('failing', 'active') + await vi.advanceTimersByTimeAsync(60_000) + mocks.resolve.mockImplementation(async () => ({ + kind: 'upstream-error', + errorType: 'network', + message: 'offline', + // ast-grep-ignore: test-no-wall-clock -- the shared snapshot harness installs fake timers + fetchedAt: Date.now() + })) + const failing = read('failing') + await vi.advanceTimersByTimeAsync(0) + // Failed at 61 s: the retry waits for the two-minute background interval. + expect(await failing).toMatchObject({ kind: 'upstream-error', nextAutoRetryAt: 181_000 }) + + await setHostCadence('off') + const off = await read('failing') + expect(off).toMatchObject({ kind: 'upstream-error' }) + expect(off).not.toHaveProperty('nextAutoRetryAt') + await vi.advanceTimersByTimeAsync(10_000) + await setHostCadence('default') + expect(await read('failing')).toMatchObject({ nextAutoRetryAt: 181_000 }) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + + mocks.resolve.mockImplementation(async () => found()) + fixture.title = 'recovered' + // ast-grep-ignore: test-no-wall-clock -- the shared snapshot harness installs fake timers + await vi.advanceTimersByTimeAsync(181_000 - Date.now()) + const recovered = read('failing') + await vi.advanceTimersByTimeAsync(0) + expect(await recovered).toMatchObject({ kind: 'found', pr: { title: 'recovered' } }) + }) + + it('retires a queued revalidation when the host turns auto-refresh off', async () => { + await read('queued', 'active') + await vi.advanceTimersByTimeAsync(60_000) + // ast-grep-ignore: test-no-wall-clock -- the shared snapshot harness installs fake timers + mocks.gate.mockResolvedValue(Date.now() + 120_000) + const waiting = read('queued') + await vi.advanceTimersByTimeAsync(25_000) + expect(await waiting).toMatchObject({ fetchedAt: 1_000, revalidation: expect.any(Object) }) + + await setHostCadence('off') + mocks.gate.mockResolvedValue(null) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await setHostCadence('default') + fixture.title = 'read again' + const due = read('queued') + await vi.advanceTimersByTimeAsync(0) + expect(await due).toMatchObject({ pr: { title: 'read again' } }) + }) + + // A revalidation is held at its quota check when auto-refresh turns off; Default returns and a new + // timer read of the same PR replaces it before that check answers. + async function retireWhileAwaitingQuota(pauseMs: number | null) { + await read('held', 'active') + await vi.advanceTimersByTimeAsync(60_000) + const quota = Promise.withResolvers() + mocks.gate.mockReturnValue(quota.promise) + const waiting = read('held') + await vi.advanceTimersByTimeAsync(0) + await setHostCadence('off') + const retired = await waiting + await setHostCadence('default') + mocks.gate.mockResolvedValue(null) + fixture.title = 'read again' + const replacement = read('held') + await vi.advanceTimersByTimeAsync(0) + // ast-grep-ignore: test-no-wall-clock -- the shared snapshot harness installs fake timers + quota.resolve(pauseMs === null ? null : Date.now() + pauseMs) + return { retired, replacement } + } + + it('lets the retired revalidation neither start nor take the replacement’s place once quota admits it', async () => { + const { retired, replacement } = await retireWhileAwaitingQuota(null) + expect(retired).toMatchObject({ kind: 'found', fetchedAt: 1_000 }) + // The retired read had already taken the key's ordinary 10 s start cooldown before quota. + await vi.advanceTimersByTimeAsync(10_000) + expect(await replacement).toMatchObject({ pr: { title: 'read again' } }) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + }) + + it('keeps the quota pause the retired revalidation learned for the replacement, which then reads once', async () => { + const { retired, replacement } = await retireWhileAwaitingQuota(120_000) + expect(retired).toMatchObject({ kind: 'found', fetchedAt: 1_000 }) + await vi.advanceTimersByTimeAsync(25_000) + expect(await replacement).toMatchObject({ fetchedAt: 1_000, revalidation: expect.any(Object) }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(120_000) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + expect(await read('held')).toMatchObject({ pr: { title: 'read again' } }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ba6d9335f2b..f1227202404 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5,6 +5,7 @@ import { installRuntimeLinearCommandSurface } from './integrations/linear/runtim import { publishNativeChatPreferenceChanges } from './agent-session/launch/runtime-native-chat-preference-events' import { publishSettingsChanges } from './client/settings/runtime-settings-change-events' import { publishWorkspaceViewChanges } from './client/settings/runtime-workspace-view-change-events' +import { syncPRAutoRefreshCadence } from '../github/pull-requests/refresh/pr-auto-refresh-cadence-sync' import { OrcaRuntimeWithSidebarTemporal, sidebarFactsRuntime @@ -206,6 +207,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { publishWorkspaceViewChanges(this.store, (event) => this.emitClientEvent(event)) publishNativeChatPreferenceChanges(this.store, (event) => this.emitClientEvent(event)) publishSettingsChanges(this.store, (event) => this.emitClientEvent(event)) + syncPRAutoRefreshCadence(this.store) } /** What mobileRelay.hosts.list answers changed. */ diff --git a/src/main/runtime/rpc/methods/source-control/github/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/source-control/github/github-pull-request-methods.ts index 924b2cf2bbe..69f791bafd0 100644 --- a/src/main/runtime/rpc/methods/source-control/github/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/source-control/github/github-pull-request-methods.ts @@ -39,7 +39,8 @@ export const GITHUB_PULL_REQUEST_METHODS = [ handler: async (params, { runtime }) => runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, params.prRepo ?? null, { noCache: params.noCache, - ...(params.userRefresh === true ? { userRefresh: true } : {}) + ...(params.userRefresh === true ? { userRefresh: true } : {}), + ...(params.reason !== undefined ? { reason: params.reason } : {}) }) }), defineMethod({ diff --git a/src/main/source-control/hosted-review-branch-cache.test.ts b/src/main/source-control/hosted-review-branch-cache.test.ts index 7058e91a3e0..8c7ac6e88a8 100644 --- a/src/main/source-control/hosted-review-branch-cache.test.ts +++ b/src/main/source-control/hosted-review-branch-cache.test.ts @@ -4,6 +4,7 @@ import { __resetHostedReviewBranchCacheForTests, withHostedReviewBranchCache } from './hosted-review-branch-cache' +import { setPRAutoRefreshCadence } from '../../shared/review-refresh-policy' import { HOSTED_REVIEW_LOOKUP_DEADLINE_MS, LOOKUP_BACKOFF_MAX_MS, @@ -117,4 +118,41 @@ describe('hosted review branch cache (#11532)', () => { expect(recovered).toHaveBeenCalledTimes(1) }) }) + + describe('host PR auto-refresh cadence', () => { + afterEach(() => { + setPRAutoRefreshCadence('default') + }) + + it('re-reads a found review after a minute by default', async () => { + const lookup = vi.fn(async () => openReview) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.advanceTimersByTime(60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('keeps a found review five times longer when reduced', async () => { + setPRAutoRefreshCadence('reduced') + const lookup = vi.fn(async () => openReview) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.advanceTimersByTime(4 * 60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('serves the cached answer until a forced or user read when off', async () => { + setPRAutoRefreshCadence('off') + const lookup = vi.fn(async () => openReview) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.advanceTimersByTime(24 * 60 * 60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + await withHostedReviewBranchCache(identity, { headOid: null, userRefresh: true }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts index ea11461c5f4..825c9e81624 100644 --- a/src/main/source-control/hosted-review-branch-cache.ts +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -46,6 +46,7 @@ import { noteFailure } from './hosted-review-lookup-backoff' import { observeProviderRead } from './hosted-review-provider-read' +import { scaleReviewRefreshInterval } from '../../shared/review-refresh-policy' import { ACTIVE_REFRESH_INTERVAL_MS, HOSTED_REVIEW_LOOKUP_DEADLINE_MS, @@ -84,10 +85,14 @@ function isHeadSensitive(entry: CacheEntry): boolean { } function refreshIntervalMs(entry: CacheEntry, active: boolean): number { - if (entry.review !== null) { - return FOUND_REVIEW_TTL_MS - } - return active ? ACTIVE_REFRESH_INTERVAL_MS : NO_REVIEW_REFRESH_INTERVAL_MS + const base = + entry.review !== null + ? FOUND_REVIEW_TTL_MS + : active + ? ACTIVE_REFRESH_INTERVAL_MS + : NO_REVIEW_REFRESH_INTERVAL_MS + // A slower host cadence keeps answers longer; forced, user and newly selected reads still ask. + return scaleReviewRefreshInterval(base) ?? Number.POSITIVE_INFINITY } function isFresh(entry: CacheEntry, headOid: string | null, active: boolean): boolean { diff --git a/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx b/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx index 3e8f3cd102f..f2cf038beb0 100644 --- a/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx +++ b/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx @@ -3,11 +3,14 @@ import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch' import { useGitStatusPolling } from '../components/right-sidebar/source-control/sync/useGitStatusPolling' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync/use-web-session-tabs-sync' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' +import { useRemoteHostPRAutoRefreshCadence } from './use-remote-host-pr-auto-refresh-cadence' /** Hosts services that track the active workspace, so a switch re-renders this null node instead of App. */ export function ActiveWorkspaceSyncGate(): null { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) useWebSessionTabsSync() + // Learn each paired host's PR auto-refresh setting before the schedulers below plan with it. + useRemoteHostPRAutoRefreshCadence() useVisibleReviewRefreshReporting() // Poll above the removable sidebar to keep rebase/merge badges fresh; wait for session readiness to protect first paint. useGitStatusPolling({ enabled: workspaceSessionReady }) diff --git a/src/renderer/src/app-shell/use-remote-host-pr-auto-refresh-cadence.ts b/src/renderer/src/app-shell/use-remote-host-pr-auto-refresh-cadence.ts new file mode 100644 index 00000000000..e180457a74c --- /dev/null +++ b/src/renderer/src/app-shell/use-remote-host-pr-auto-refresh-cadence.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store/index' +import { installRemoteHostPRAutoRefreshCadenceSync } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync' + +/** Keeps every paired host's PR auto-refresh cadence known to this window's review schedulers. */ +export function useRemoteHostPRAutoRefreshCadence(): void { + useEffect(() => installRemoteHostPRAutoRefreshCadenceSync(useAppStore), []) +} diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts index 5605fc1e900..26902fd5f30 100644 --- a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts @@ -4,6 +4,7 @@ import { useAppStore } from '@/store/index' import { isWindowVisible } from '@/lib/window-activity/window-visibility-interval' import { isWebClientLocation } from '@/app-shell/platform/web-client-location' import { createVisibleHostedReviewRefreshScheduler } from '@/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler' +import { followOwningHostCadence } from '@/store/slices/hosted-review/refresh/owning-host-cadence-replan' import { createWorkspaceReferenceRefreshTargetsSelector } from '@/components/sidebar/workspace-card/review/workspace-reference-refresh-targets' import { getVisibleHostedReviewRefreshTargets, @@ -20,26 +21,33 @@ export function useVisibleHostedReviewRefresh({ enabled }: { enabled: boolean }) const selectReferences = createWorkspaceReferenceRefreshTargetsSelector({ selectedOnly }) let hostedTargets: ReturnType = [] let referenceTargets: ReturnType = [] + let referenceInputs = referenceTargets + const { update: applyTargets, dispose: stopFollowingCadence } = followOwningHostCadence( + () => [...hostedTargets, ...referenceTargets], + (targets) => { + // Keep the cached targets at the host's current cadence without rebuilding their inputs. + referenceTargets = targets.slice(hostedTargets.length) + hostedTargets = targets.slice(0, hostedTargets.length) + scheduler.update(targets) + } + ) const update = (reviewInputsChanged: boolean): void => { if (!isWindowVisible()) { return } const state = useAppStore.getState() const references = selectReferences(state, reviewInputsChanged) - if (!reviewInputsChanged && references === referenceTargets) { + if (!reviewInputsChanged && references === referenceInputs) { return } + referenceInputs = references referenceTargets = references if (reviewInputsChanged) { hostedTargets = getVisibleHostedReviewRefreshTargets(state, useAppStore.getState, { selectedOnly }) } - scheduler.update([ - // Collapsed review rollups also need background reads on paired clients; this shared queue bounds them. - ...hostedTargets, - ...referenceTargets - ]) + applyTargets() } const visibilityChanged = (): void => { scheduler.setVisible(false) @@ -55,6 +63,7 @@ export function useVisibleHostedReviewRefresh({ enabled }: { enabled: boolean }) return () => { unsubscribe() stopRevisionChanges() + stopFollowingCadence() document.removeEventListener('visibilitychange', visibilityChanged) scheduler.dispose() } diff --git a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts index e80c181e486..8115df6a38d 100644 --- a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts +++ b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts @@ -7,10 +7,7 @@ import { getHostedReviewCacheKey } from '../store/slices/hosted-review/hosted-re import { getVisibleHostedReviewWorkspaces } from '../store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets' import { shouldCoordinateVisibleGitHubReview } from '../store/slices/hosted-review/refresh/visible-hosted-review-refresh-ownership' import { getPRRefreshRuntimeRepoTarget } from '../store/github/repositories/repository-routing' -import { - reviewRefreshIntervalMs, - REVIEW_REFRESH_COOLDOWN_MS -} from '../../../shared/review-refresh-policy' +import { reviewForegroundFreshnessMs } from '../../../shared/review-refresh-policy' import { selectDetectedWorktreesByRepo, selectWorktreesByRepo @@ -139,13 +136,13 @@ export function refreshForegroundVisibleReview(state: AppState): void { ) { return } - const interval = - reviewRefreshIntervalMs({ - state: candidate.cachedPRState, - checksStatus: candidate.cachedChecksStatus, - hasReview: candidate.cachedHasPR, - selected: true - }) ?? REVIEW_REFRESH_COOLDOWN_MS + // Window return is the user looking: a stretched cadence still reads data older than Default's. + const interval = reviewForegroundFreshnessMs({ + state: candidate.cachedPRState, + checksStatus: candidate.cachedChecksStatus, + hasReview: candidate.cachedHasPR, + selected: true + }) if ( candidate.cachedFetchedAt == null || Date.now() - candidate.cachedFetchedAt >= interval || diff --git a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer-cadence.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer-cadence.test.tsx new file mode 100644 index 00000000000..b295ac52721 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer-cadence.test.tsx @@ -0,0 +1,409 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ChecksDetailPollingPolicy } from '../../../../../../shared/github/checks/checks-detail-polling-policy' +import { + _resetPRAutoRefreshCadencesForTests, + setPRAutoRefreshCadence, + setPRAutoRefreshCadenceForHost +} from '../../../../../../shared/review-refresh-policy' +import { createModel } from './checks-panel-polling-test-model' +import type { ChecksPanelPollingInput } from './use-checks-panel-polling' + +vi.mock('@/store/github/refresh/remote-host-pr-auto-refresh-cadence', async () => { + const policy = await import('../../../../../../shared/review-refresh-policy') + return { + hostPRAutoRefreshCadence: (environmentId: string | null) => + policy.getPRAutoRefreshCadenceForHost(environmentId) + } +}) +vi.mock('@/store/github/repositories/repository-routing', () => ({ + getPRRefreshOwnerRuntimeEnvironmentId: (candidate: { executionHostId?: string | null }) => + candidate.executionHostId?.startsWith('runtime:') + ? candidate.executionHostId.slice('runtime:'.length) + : null +})) + +import { useChecksDetailTimer } from './use-checks-detail-timer' +import { RuntimeRpcCallQueuePool } from '../../../../../../shared/runtime-rpc-call-queue' + +describe('checks detail timer follows PR auto-refresh cadence changes while mounted', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }) + }) + afterEach(() => { + cleanup() + vi.useRealTimers() + _resetPRAutoRefreshCadencesForTests() + }) + + function mount( + executionHostId?: `runtime:${string}`, + pr: Partial> = {}, + fetchImpl: (options?: { background?: boolean }) => Promise = async () => {}, + gitlab = false + ) { + const overrides: Partial = {} + if (Object.keys(pr).length > 0) { + overrides.pr = { ...createModel().pr!, ...pr } + } + if (executionHostId) { + overrides.repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'Repo', + badgeColor: '', + addedAt: 1, + executionHostId + } + } + const model = createModel(overrides) + if (gitlab) { + model.activeGitLabReview = { + provider: 'gitlab', + number: 42, + title: 'Review', + state: 'open', + url: '', + status: 'pending', + updatedAt: '', + mergeable: 'UNKNOWN' + } + } + const fetchChecks = vi.fn(fetchImpl) + const fetchGitLabDetails = vi.fn(async () => {}) + const rendered = renderHook(() => { + const modelRef = useRef(model) + const policyRef = useRef(new ChecksDetailPollingPolicy()) + useChecksDetailTimer({ + model, + modelRef, + policyRef, + requestIdentity: 'same-pr', + fetchChecks, + fetchGitLabDetails + }) + }) + return { ...rendered, fetchChecks, fetchGitLabDetails, model } + } + + it('starts polling again when off becomes default, without another event', async () => { + setPRAutoRefreshCadence('off') + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + + await act(async () => { + setPRAutoRefreshCadence('default') + await vi.advanceTimersByTimeAsync(0) + }) + // Five minutes since the last read: the pending backoff (60 s) is long overdue, so it reads now. + expect(fetchChecks).toHaveBeenCalledTimes(2) + }) + + it('disarms an armed poll when default becomes off', async () => { + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + setPRAutoRefreshCadence('off') + await vi.advanceTimersByTimeAsync(60 * 60_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + }) + + it('polls a paired host’s PR by that host’s cadence, not this app’s', async () => { + setPRAutoRefreshCadence('off') + setPRAutoRefreshCadenceForHost('env-1', 'default') + const { fetchChecks } = mount('runtime:env-1') + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000 + 1_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(2) + // A timer tick on a paired host is a background read the host may pace. + expect(fetchChecks).toHaveBeenLastCalledWith({ force: false, background: true }) + }) + + async function focusWindow(): Promise { + await act(async () => { + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + }) + } + + it('reads fresh checks when the window regains focus while off, without a visibility change', async () => { + setPRAutoRefreshCadence('off') + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60_000) + }) + await focusWindow() + expect(fetchChecks).toHaveBeenCalledTimes(2) + // A focus read is the user looking: the host must not pace it. + expect(fetchChecks).toHaveBeenLastCalledWith({ force: true }) + // Focus starts no timer while off. + await act(async () => { + await vi.advanceTimersByTimeAsync(60 * 60_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(2) + }) + + it('does not re-read on focus while off within the minute after the last read', async () => { + setPRAutoRefreshCadence('off') + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + await focusWindow() + expect(fetchChecks).toHaveBeenCalledTimes(1) + }) + + it('does not re-read settled checks on focus while off', async () => { + setPRAutoRefreshCadence('off') + const { fetchChecks } = mount(undefined, { state: 'merged', checksStatus: 'success' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60_000) + }) + await focusWindow() + expect(fetchChecks).toHaveBeenCalledTimes(1) + }) + + it('reads at once when the window returns under reduced, then follows the stretched interval', async () => { + setPRAutoRefreshCadence('reduced') + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(2 * 60_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(1) + await focusWindow() + expect(fetchChecks).toHaveBeenCalledTimes(2) + // The return is the user looking: the host must not pace it. + expect(fetchChecks).toHaveBeenLastCalledWith({ force: true }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60_000 - 2_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(2) + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(fetchChecks).toHaveBeenCalledTimes(3) + expect(fetchChecks).toHaveBeenLastCalledWith({ force: false, background: true }) + }) + + it('does not re-read on a return under reduced within the Default interval', async () => { + setPRAutoRefreshCadence('reduced') + const { fetchChecks } = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + await focusWindow() + expect(fetchChecks).toHaveBeenCalledTimes(1) + }) + + it('keeps the stopped-distro guard on a GitLab focus return', async () => { + setPRAutoRefreshCadence('reduced') + const { fetchGitLabDetails } = mount(undefined, {}, undefined, true) + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000) + }) + await focusWindow() + expect(fetchGitLabDetails).toHaveBeenLastCalledWith({ background: true }) + }) + + // Three finite waves of eight ordinary calls (21 s each after their + // own admission) hold the checks read about 63 s before it is sent. + it.each(['background', 'fresh', 'reopen', 'fresh-reopen'] as const)( + 'satisfies a return while a %s checks read waits for admission', + async (arm) => { + setPRAutoRefreshCadenceForHost('env-1', 'reduced') + const pool = new RuntimeRpcCallQueuePool() + type Observation = { observedAt: number; by: string } + type Operation = { + id: number + background: boolean + issuedAt: number + admittedAt?: number + completedAt?: number + returned?: Observation + } + const operations: Operation[] = [] + const blockers: { queuedAt: number; admittedAt?: number; doneAt?: number }[] = [] + const pending: Promise[] = [] + let hostRead: Observation | null = null + let visible: Observation | null = null + const saturate = () => { + // ast-grep-ignore: test-no-polling-loop-tsx -- Fixed fake-clock operations fill the real RPC admission pool. + for (let call = 0; call < 24; call += 1) { + const blocker: (typeof blockers)[number] = { queuedAt: Date.now() } + blockers.push(blocker) + pending.push( + pool.enqueue('env-1', 'files.read', async () => { + blocker.admittedAt = Date.now() + // ast-grep-ignore: test-no-real-time-wait-tsx -- Fake-clock provider time holds the real admission slot. + await new Promise((resolve) => setTimeout(resolve, 21_000)) + blocker.doneAt = Date.now() + }) + ) + } + } + const fetchImpl = async (options: { background?: boolean } = {}) => { + const operation: Operation = { + id: operations.length, + background: options.background === true, + issuedAt: Date.now() + } + operations.push(operation) + const answered = pool.enqueue( + 'env-1', + 'github.prChecks', + async () => { + operation.admittedAt = Date.now() + // ast-grep-ignore: test-no-real-time-wait-tsx -- Fake-clock provider completion preserves the tested response ordering. + await new Promise((resolve) => setTimeout(resolve, 2_000)) + const paced = + operation.background && + hostRead !== null && + Date.now() - hostRead.observedAt < 300_000 + if (!paced) { + hostRead = { observedAt: Date.now(), by: `operation ${operation.id}` } + } + operation.completedAt = Date.now() + operation.returned = hostRead! + return hostRead! + }, + 0, + undefined, + operation.background ? { reason: 'swr' } : {} + ) + pending.push(answered) + visible = await answered + } + let original: Operation + let panel: ReturnType | undefined + if (arm === 'fresh' || arm === 'fresh-reopen') { + // The lane is full before the panel opens: the opening (fresh) read itself waits. + saturate() + panel = mount('runtime:env-1', {}, fetchImpl) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + original = operations[0]! + hostRead = { observedAt: original.issuedAt - 50_000, by: 'another reader' } + } else { + panel = mount('runtime:env-1', {}, fetchImpl) + await act(async () => { + await vi.advanceTimersByTimeAsync(2_100) + }) + // Reduced, open and pending: the next timer read is about 300 s after the first completed. + // The lane fills one second before it; then the tick is awaited. + await act(async () => { + await vi.advanceTimersByTimeAsync(operations[0]!.completedAt! + 299_000 - Date.now()) + }) + expect(operations.length, 'no timer read before the lane fills').toBe(1) + saturate() + for (let step = 0; step < 50 && operations.length === 1; step += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + } + original = operations[1]! + // Another reader of the same head filled the host's paced read 50 s before this one. + hostRead = { observedAt: original.issuedAt - 50_000, by: 'another reader' } + } + const A = original.issuedAt + // The window comes back 61 s after the original read was issued: past Default's 60 s age. + await act(async () => { + await vi.advanceTimersByTimeAsync(A + 61_000 - Date.now()) + }) + const focusAt = Date.now() + if (arm === 'reopen' || arm === 'fresh-reopen') { + await act(async () => { + panel!.model.isPanelVisible = false + panel!.rerender() + }) + await act(async () => { + panel!.model.isPanelVisible = true + panel!.rerender() + }) + } + if (arm === 'background' || arm === 'fresh') { + await act(async () => { + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + }) + } + const afterFocus = operations.length + // Admission about 63 s after issue, answer 2 s later; then 10 s more, well before the next tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(A + 75_000 - Date.now()) + }) + await Promise.all(pending) + const evidence = JSON.stringify({ arm, A, focusAt, operations, blockers, visible }, null, 1) + expect(original.background, `original intent\n${evidence}`).toBe( + arm === 'background' || arm === 'reopen' + ) + expect(focusAt - A, `focus at A + 61 s\n${evidence}`).toBe(61_000) + expect(afterFocus, `the return found the original in flight\n${evidence}`).toBe( + operations.indexOf(original) + 1 + ) + expect( + original.admittedAt! - A >= 61_000, + `held before send past the return\n${evidence}` + ).toBe(true) + expect( + original.completedAt! > focusAt, + `original completed after the return\n${evidence}` + ).toBe(true) + const later = operations.slice(operations.indexOf(original) + 1) + if (arm === 'background' || arm === 'reopen') { + expect( + original.returned, + `original returned the paced read observed at A - 50 s\n${evidence}` + ).toEqual({ observedAt: A - 50_000, by: 'another reader' }) + // The qualifying return stays owed after that stale completion, as + // exactly one fresh read, and its fresh observation is what the checks show. + expect( + later.map((operation) => operation.background), + `exactly one owed fresh read\n${evidence}` + ).toEqual([false]) + expect( + visible!.observedAt >= focusAt && visible!.by === `operation ${later[0]?.id}`, + `the fresh read's observation is shown\n${evidence}` + ).toBe(true) + } else { + // Control: the original fresh completion satisfies the return; no duplicate trailing read. + expect(original.returned!.observedAt >= focusAt, `fresh answer\n${evidence}`).toBe(true) + expect(visible, `fresh answer shown\n${evidence}`).toEqual(original.returned) + expect(later, `no duplicate read\n${evidence}`).toEqual([]) + } + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer.ts b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer.ts index b3513630940..3d860f2a21c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-detail-timer.ts @@ -1,6 +1,12 @@ import { useEffect, useRef, type RefObject } from 'react' -import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../../../shared/review-refresh-policy' +import { + REVIEW_REFRESH_COOLDOWN_MS, + subscribePRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../../../shared/review-refresh-policy' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-activity/window-visibility-timeout-poller' +import { getPRRefreshOwnerRuntimeEnvironmentId } from '@/store/github/repositories/repository-routing' +import { hostPRAutoRefreshCadence } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence' import type { ChecksDetailPollingPolicy } from '../../../../../../shared/github/checks/checks-detail-polling-policy' import type { ChecksPanelPollingInput, ChecksPanelPollingState } from './use-checks-panel-polling' @@ -35,7 +41,7 @@ export function useChecksDetailTimer({ const lastIdentityRef = useRef(null) const lastAttemptAtRef = useRef(-Infinity) const nextAttemptAtRef = useRef(-Infinity) - const inFlightRef = useRef<{ identity: string; token: symbol } | null>(null) + const inFlightRef = useRef<{ identity: string; token: symbol; background: boolean } | null>(null) // The first read after the panel opens (or its review changes) is the user looking; later polls are background (E9). const wakeNextReadRef = useRef(true) useEffect(() => { @@ -55,16 +61,35 @@ export function useChecksDetailTimer({ wakeNextReadRef.current = true return } - const policyDelay = (): number | null => { + const ownerCadence = (): PRAutoRefreshCadence => { + const current = modelRef.current + // The host that reads these checks owns the cadence: a paired host's setting, else this app's. + return hostPRAutoRefreshCadence( + getPRRefreshOwnerRuntimeEnvironmentId({ + cacheKey: current.prCacheKey ?? '', + executionHostId: current.repo?.executionHostId ?? null + }) + ) + } + const policyDelay = (cadence: PRAutoRefreshCadence = ownerCadence()): number | null => { const current = modelRef.current return policyRef.current.delayMs( current.activeGitLabReview?.state ?? current.pr?.state, current.activeGitLabReview?.status ?? current.pr?.checksStatus, - current.pollIntervalRef.current + current.pollIntervalRef.current, + cadence ) } + // Under a stretched or stopped cadence the window coming back still reads checks older than + // Default's delay, never sooner: the cadence slows the timer, not the user's return. + const returnReadDue = (now: number): boolean => { + const defaultDelay = ownerCadence() === 'default' ? null : policyDelay('default') + return defaultDelay !== null && now - lastAttemptAtRef.current >= defaultDelay + } + const stoppedByCadence = (): boolean => + ownerCadence() === 'off' && policyDelay('default') !== null const cleanup = installWindowVisibilityTimeoutPoller({ - run: async () => { + run: async ({ trigger }) => { if (inFlightRef.current?.identity === requestIdentity) { return } @@ -72,30 +97,48 @@ export function useChecksDetailTimer({ const nextAttemptAt = nextAttemptAtRef.current if ( now - lastAttemptAtRef.current < REVIEW_REFRESH_COOLDOWN_MS || - (Number.isFinite(nextAttemptAt) - ? now < nextAttemptAt - : nextAttemptAt === Infinity && - !forceNextFetchRef.current && - now - lastAttemptAtRef.current < 60_000) + (!( + wakeNextReadRef.current || + forceNextFetchRef.current || + (trigger === 'visibility' && returnReadDue(now)) + ) && + (Number.isFinite(nextAttemptAt) + ? now < nextAttemptAt + : nextAttemptAt === Infinity && + !forceNextFetchRef.current && + (stoppedByCadence() || now - lastAttemptAtRef.current < 60_000))) ) { return } const token = Symbol() - inFlightRef.current = { identity: requestIdentity, token } const shown = wakeNextReadRef.current + const force = + forceNextFetchRef.current || + trigger === 'visibility' || + (shown && Number.isFinite(lastAttemptAtRef.current)) wakeNextReadRef.current = false lastAttemptAtRef.current = now nextAttemptAtRef.current = Infinity - const force = forceNextFetchRef.current forceNextFetchRef.current = false + // Only the timer's own tick is a background read the host may pace; the window coming + // back, the panel opening and a changed aggregate state must read fresh. + const background = trigger === 'timer' && !shown && !force + inFlightRef.current = { identity: requestIdentity, token, background } try { await (isGitLabReview ? fetchGitLabDetails(shown ? {} : { background: true }) - : fetchChecks({ force, ...(shown ? { wakeWslDistro: true } : {}) })) + : fetchChecks({ + force, + ...(shown ? { wakeWslDistro: true } : {}), + ...(background ? { background: true } : {}) + })) } finally { if (inFlightRef.current?.token === token) { inFlightRef.current = null if (lastIdentityRef.current === requestIdentity) { + if (!background && modelRef.current.isPanelVisible) { + wakeNextReadRef.current = false + } const delay = policyDelay() nextAttemptAtRef.current = delay === null ? Infinity : Date.now() + delay refreshRef.current?.() @@ -108,19 +151,45 @@ export function useChecksDetailTimer({ if (inFlightRef.current?.identity === requestIdentity) { return delay } + if (wakeNextReadRef.current || forceNextFetchRef.current) { + return Math.max(0, lastAttemptAtRef.current + REVIEW_REFRESH_COOLDOWN_MS - Date.now()) + } if (delay === null) { - return forceNextFetchRef.current - ? Math.max(0, lastAttemptAtRef.current + REVIEW_REFRESH_COOLDOWN_MS - Date.now()) - : null + return null } return Number.isFinite(nextAttemptAtRef.current) ? Math.max(0, nextAttemptAtRef.current - Date.now()) : delay + }, + // Returning to the window is a promised foreground read in every mode: when only the owning + // host's cadence (not settled checks) stopped the timer, focus still reads once. + refreshOnFocusWhileStopped: stoppedByCadence, + replayVisibilityWhileInFlight: () => { + const current = inFlightRef.current + const qualified = + current?.identity === requestIdentity && current.background && returnReadDue(Date.now()) + if (qualified) { + forceNextFetchRef.current = true + } + return qualified } }) refreshRef.current = cleanup.refresh + // A cadence change (this app's or the owning host's) re-plans a mounted panel at once: a timer + // stopped under "off" starts again, and one armed for Default is disarmed. + const unsubscribeCadence = subscribePRAutoRefreshCadence(() => { + if (inFlightRef.current?.identity === requestIdentity) { + return + } + const delay = policyDelay() + if (Number.isFinite(lastAttemptAtRef.current)) { + nextAttemptAtRef.current = delay === null ? Infinity : lastAttemptAtRef.current + delay + } + cleanup.replan() + }) return () => { refreshRef.current = null + unsubscribeCadence() cleanup() } }, [ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-polling.tsx index 6db6809a6df..8b03903ff0a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-polling.tsx @@ -77,12 +77,15 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { async ({ force = false, prNumberOverride, - wakeWslDistro = false + wakeWslDistro = false, + background = false }: { force?: boolean prNumberOverride?: number | null /** Opening the panel or tab: a user act that may start a stopped WSL distro. */ wakeWslDistro?: boolean + /** The panel's own timer: a paired host may answer from its last read at its cadence. */ + background?: boolean } = {}) => { const { repo, @@ -122,7 +125,8 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { throwOnError: true, repoId: repo.id, repoOwnerExecutionHostId: getRepoExecutionHostId(repo), - ...(wakeWslDistro ? { wakeWslDistro: true } : {}) + ...(wakeWslDistro ? { wakeWslDistro: true } : {}), + ...(background ? { background: true } : {}) } ) if (!isCurrentAsyncResult(requestKey)) { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-return-cache.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-return-cache.test.tsx new file mode 100644 index 00000000000..f3499db2489 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-return-cache.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RuntimeRpcCallQueuePool } from '../../../../../../shared/runtime-rpc-call-queue' +import type { PRCheckDetail } from '../../../../../../shared/github/checks/check-types' +import { + _resetPRAutoRefreshCadencesForTests, + setPRAutoRefreshCadenceForHost +} from '../../../../../../shared/review-refresh-policy' +import { + createTestStore, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from '@/store/github/github-slice-test-harness' +import { createModel } from './checks-panel-polling-test-model' +import { useChecksPanelPolling } from './use-checks-panel-polling' + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }) + setPRAutoRefreshCadenceForHost('env-1', 'reduced') + resetRemoteRuntimeMocks() +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + _resetPRAutoRefreshCadencesForTests() + resetRemoteRuntimeMocks() +}) + +it.each(['background', 'fresh'] as const)( + 'shows fresh checks after returning during a %s read through the real callback and renderer cache', + async (intent) => { + const pendingChecks: PRCheckDetail[] = [ + { name: 'Build', status: 'in_progress', conclusion: null, url: null } + ] + const freshChecks: PRCheckDetail[] = [ + { name: 'Build', status: 'completed', conclusion: 'success', url: null } + ] + const pool = new RuntimeRpcCallQueuePool() + const store = createTestStore() + const model = createModel() + model.repo = { ...model.repo!, executionHostId: 'runtime:env-1' } + store.setState({ repos: [model.repo!], worktreesByRepo: {} }) + model.fetchPRChecks = store.getState().fetchPRChecks + const reads: { + issuedAt: number + noCache: boolean + background: boolean + admittedAt?: number + completedAt?: number + }[] = [] + const pending: Promise[] = [] + let hostObservedAt = -Infinity + let reusedObservationAt = -Infinity + const saturate = () => { + // ast-grep-ignore: test-no-polling-loop-tsx -- Fixed fake-clock operations fill the real RPC admission pool. + for (let call = 0; call < 24; call += 1) { + pending.push( + pool.enqueue( + 'env-1', + 'files.read', + // ast-grep-ignore: test-no-real-time-wait-tsx -- Fake-clock provider time holds the real admission slot. + () => new Promise((resolve) => setTimeout(resolve, 21_000)) + ) + ) + } + } + runtimeEnvironmentCall.mockImplementation(async ({ method, params }) => { + if (method !== 'github.prChecks') { + throw new Error(`Unexpected RPC method: ${method}`) + } + const read: (typeof reads)[number] = { + issuedAt: Date.now(), + noCache: params?.noCache === true, + background: params?.reason === 'swr' + } + reads.push(read) + const answer = pool.enqueue( + 'env-1', + 'github.prChecks', + async () => { + read.admittedAt = Date.now() + // ast-grep-ignore: test-no-real-time-wait-tsx -- Fake-clock provider completion preserves the tested response ordering. + await new Promise((resolve) => setTimeout(resolve, 2_000)) + read.completedAt = Date.now() + if ( + (intent === 'background' && reads[0] === read) || + (read.background && !read.noCache) + ) { + if (reads[0] !== read) { + reusedObservationAt = read.issuedAt - 50_000 + } + return pendingChecks + } + hostObservedAt = Date.now() + return freshChecks + }, + 0, + undefined, + params + ) + pending.push(answer) + return { id: 'rpc', ok: true, result: await answer } + }) + if (intent === 'fresh') { + saturate() + } + renderHook(() => useChecksPanelPolling(model)) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + if (intent === 'background') { + await act(async () => { + await vi.advanceTimersByTimeAsync(301_000) + }) + saturate() + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + } + const original = reads.at(-1)! + const focusAt = original.issuedAt + 61_000 + await act(async () => { + await vi.advanceTimersByTimeAsync(focusAt - Date.now()) + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(original.issuedAt + 75_000 - Date.now()) + }) + await Promise.all(pending) + expect(model.setChecks).toHaveBeenLastCalledWith(freshChecks) + expect(Object.values(store.getState().checksCache).at(-1)?.data).toEqual(freshChecks) + expect(hostObservedAt).toBeGreaterThanOrEqual(focusAt) + expect(original.admittedAt).toBeGreaterThanOrEqual(focusAt) + expect(original.completedAt).toBeGreaterThan(focusAt) + if (intent === 'background') { + expect(reusedObservationAt).toBe(original.issuedAt - 50_000) + } + expect( + reads + .slice(reads.indexOf(original) + 1) + .map(({ noCache, background }) => ({ noCache, background })) + ).toEqual(intent === 'background' ? [{ noCache: true, background: false }] : []) + } +) diff --git a/src/renderer/src/components/settings/page/settings-git-task-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-git-task-section-renderers.tsx index 04bfb30567b..a66a496e7c9 100644 --- a/src/renderer/src/components/settings/page/settings-git-task-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-git-task-section-renderers.tsx @@ -45,7 +45,9 @@ export function renderGitSettingsSection( /> ) : null} diff --git a/src/renderer/src/components/settings/source-control/GitProviderApiBudgetPane.tsx b/src/renderer/src/components/settings/source-control/GitProviderApiBudgetPane.tsx index 16567ec5530..3ee10f3faea 100644 --- a/src/renderer/src/components/settings/source-control/GitProviderApiBudgetPane.tsx +++ b/src/renderer/src/components/settings/source-control/GitProviderApiBudgetPane.tsx @@ -5,23 +5,39 @@ import { matchesSettingsSearch } from '../search/settings-search' import { GitHubRateLimitPanel } from '../../github/github-rate-limit-display' import { GitLabRateLimitPanel } from '../../gitlab/gitlab-rate-limit-display' import { useTranslate } from '@/i18n/use-translate' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { SettingsHostScope } from '../host-scope/settings-host-scope' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { PRAutoRefreshSetting } from './PRAutoRefreshSetting' +import { prAutoRefreshMatchesSearch } from './pr-auto-refresh-search' type GitProviderApiBudgetPaneProps = { settingsSearchQuery?: string /** The host chosen at the top of Settings; budgets come from that host's CLI. */ - target: RuntimeClientTarget + hostScope: SettingsHostScope + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise } export function GitProviderApiBudgetPane({ settingsSearchQuery, - target + hostScope, + settings, + updateSettings }: GitProviderApiBudgetPaneProps): React.JSX.Element | null { const t = useTranslate() + const target = hostScope.target const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery) const searchQuery = settingsSearchQuery ?? storeSearchQuery const visibleSections = [ + prAutoRefreshMatchesSearch(searchQuery, t) ? ( + + ) : null, matchesSettingsSearch(searchQuery, { title: t('auto.components.settings.GitPane.fdbcd676b0', 'GitHub API budget'), description: t( diff --git a/src/renderer/src/components/settings/source-control/PRAutoRefreshControl.tsx b/src/renderer/src/components/settings/source-control/PRAutoRefreshControl.tsx new file mode 100644 index 00000000000..265d3e724d0 --- /dev/null +++ b/src/renderer/src/components/settings/source-control/PRAutoRefreshControl.tsx @@ -0,0 +1,110 @@ +import { useId } from 'react' +import { Info } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { PRAutoRefreshCadence } from '../../../../../shared/review-refresh-policy' +import { useTranslate } from '@/i18n/use-translate' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SearchableSetting } from '../section-layout/SearchableSetting' +import { SettingsRow, SettingsSegmentedControl } from '../form-controls/SettingsFormControls' +import { + getPRAutoRefreshCadenceHint, + getPRAutoRefreshCadenceLabel, + getPRAutoRefreshDescription, + getPRAutoRefreshTitle, + PR_AUTO_REFRESH_KEYWORDS +} from './pr-auto-refresh-search' + +export type PRAutoRefreshControlProps = { + cadence: PRAutoRefreshCadence | null + onChange: (cadence: PRAutoRefreshCadence) => void + remoteHostName: string | null + unsupported?: boolean + disabled?: boolean +} + +export function PRAutoRefreshControl({ + cadence, + onChange, + remoteHostName, + unsupported = false, + disabled = false +}: PRAutoRefreshControlProps): React.JSX.Element { + const t = useTranslate() + const title = getPRAutoRefreshTitle(t) + const description = getPRAutoRefreshDescription(t) + const hintId = useId() + const unavailable = disabled || unsupported || cadence === null + // Keep saved Minimal cadence intact; it belongs to the Less often choice. + const selected = unsupported ? 'default' : cadence === 'minimal' ? 'reduced' : cadence + const hint = selected === null ? null : getPRAutoRefreshCadenceHint(selected, t) + const disabledReason = unsupported + ? t( + 'auto.components.settings.GitPane.prAutoRefreshUnsupportedHost', + 'Update Orca on {{host}} to change this', + { + host: + remoteHostName ?? + t('auto.components.settings.GitPane.prAutoRefreshUnknownHost', 'the selected server') + } + ) + : undefined + return ( + + + {title} + + + + + + {description} + + + + } + description={ + + {hint} + + } + control={ + + value={selected ?? ''} + onChange={(next) => { + if (!unavailable && next && next !== selected) { + onChange(next) + } + }} + ariaLabel={title} + ariaDescribedBy={hint ? hintId : undefined} + size="sm" + options={(['default', 'reduced', 'off'] as const).map((value) => ({ + value, + label: getPRAutoRefreshCadenceLabel(value, t), + disabled: unavailable, + tooltip: disabledReason + }))} + /> + } + /> + + ) +} diff --git a/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.fixture.tsx b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.fixture.tsx new file mode 100644 index 00000000000..444809e6180 --- /dev/null +++ b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.fixture.tsx @@ -0,0 +1,143 @@ +import { useState, type ReactNode } from 'react' +import { + setPRAutoRefreshCadenceForHost, + type PRAutoRefreshCadence +} from '../../../../../shared/review-refresh-policy' +import { useHostPRAutoRefreshCadence } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence' +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { PRAutoRefreshControl } from './PRAutoRefreshControl' +import { SettingsGroup } from '../section-layout/SettingsGroup' + +function Interactive({ + initial, + remoteHostName +}: { + initial: PRAutoRefreshCadence + remoteHostName: string | null +}): React.JSX.Element { + const [cadence, setCadence] = useState(initial) + return ( + + ) +} + +function Frame({ children }: { children: ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +const width = 760 + +export const localDefault = defineFixture({ + width, + render: () => ( + + + + ) +}) + +export const localReduced = defineFixture({ + width, + render: () => ( + + + + ) +}) + +export const localOff = defineFixture({ + width, + render: () => ( + + + + ) +}) + +export const remoteMinimal = defineFixture({ + width, + render: () => ( + + + + ) +}) + +export const narrow = defineFixture({ + width: 360, + render: () => ( + + + + ) +}) + +export const info = defineFixture({ + width, + render: () => ( + + + + ), + interact: (frame) => { + frame.querySelector('[aria-label="About PR Updates"]')?.focus() + } +}) + +const EXTERNAL_CHANGE_HOST = 'fixture-boston' + +/** The real subscribed path of the Settings row for a paired host: the control shows the one + * shared copy of that host's setting, not its own snapshot. */ +function SubscribedRemote(): React.JSX.Element { + const cadence = useHostPRAutoRefreshCadence(EXTERNAL_CHANGE_HOST) + return ( + setPRAutoRefreshCadenceForHost(EXTERNAL_CHANGE_HOST, next)} + remoteHostName="Boston build server" + /> + ) +} + +/** Mounted showing Default; another client then sets the host to Off (what the host's + * `settingsChanged` read remembers), and the mounted control follows without a remount. */ +export const remoteExternalChange = defineFixture({ + width, + setup: () => setPRAutoRefreshCadenceForHost(EXTERNAL_CHANGE_HOST, 'default'), + render: () => ( + + + + ), + interact: () => setPRAutoRefreshCadenceForHost(EXTERNAL_CHANGE_HOST, 'off') +}) + +export const remoteReading = defineFixture({ + width, + render: () => ( + + {}} remoteHostName="dev-box" /> + + ) +}) + +export const remoteOlderHost = defineFixture({ + width, + render: () => ( + + {}} + remoteHostName="dev-box" + /> + + ), + interact: (frame) => { + frame.querySelector('[role="radio"]')?.focus() + } +}) diff --git a/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.test.tsx b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.test.tsx new file mode 100644 index 00000000000..b05bf7fec70 --- /dev/null +++ b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { i18n } from '@/i18n/i18n' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + _resetPRAutoRefreshCadencesForTests, + setPRAutoRefreshCadenceForHost +} from '../../../../../shared/review-refresh-policy' +import { _resetHostPRAutoRefreshCadenceGenerationsForTests } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence' + +const rpc = vi.hoisted(() => ({ + settings: new Map(), + callRuntimeRpc: vi.fn() +})) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: rpc.callRuntimeRpc })) +vi.mock('@/store/retained-views/app-state-view', () => ({ + useAppStateValue: (selector: (state: unknown) => unknown) => + selector({ + settingsSearchQuery: '', + runtimeEnvironments: [ + { id: 'env-a', name: 'Boston build server' }, + { id: 'env-b', name: 'Lab box' } + ] + }) +})) +vi.mock('@/store/index', async () => { + const { useStore } = await import('zustand') + const { createStore } = await import('zustand/vanilla') + const store = createStore(() => ({ settingsSearchQuery: '' })) + return { + useAppStore: Object.assign((select: (s: unknown) => unknown) => useStore(store, select), store) + } +}) +import { PRAutoRefreshSetting } from './PRAutoRefreshSetting' + +let container: HTMLDivElement +let root: Root +beforeEach(async () => { + await i18n.changeLanguage('en') + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + rpc.settings.clear() + rpc.callRuntimeRpc + .mockReset() + .mockImplementation( + async ( + target: { environmentId: string }, + method: string, + params?: { prAutoRefresh?: string } + ) => { + if (method === 'settings.update' && params?.prAutoRefresh) { + rpc.settings.set(target.environmentId, params.prAutoRefresh) + } + return { settings: { prAutoRefresh: rpc.settings.get(target.environmentId) ?? 'default' } } + } + ) +}) +afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + _resetPRAutoRefreshCadencesForTests() + _resetHostPRAutoRefreshCadenceGenerationsForTests() +}) + +function option(index: number): HTMLButtonElement { + const button = container.querySelectorAll('button[role="radio"]')[index] + if (!button) { + throw new Error(`No option ${index}`) + } + return button +} +function selected(): number { + return Array.from(container.querySelectorAll('button[role="radio"]')).findIndex( + (button) => button.getAttribute('aria-checked') === 'true' + ) +} +async function mount(environmentId: string): Promise { + await act(async () => + root.render( + + {}} + /> + + ) + ) + await act(async () => {}) +} + +describe('PRAutoRefreshSetting for a paired host', () => { + it('shows the host’s current setting after another client changes it', async () => { + rpc.settings.set('env-a', 'default') + await mount('env-a') + expect(selected()).toBe(0) + + // The host's settingsChanged read (or another window's control) remembers the new value. + await act(async () => { + setPRAutoRefreshCadenceForHost('env-a', 'off') + }) + expect(selected()).toBe(2) + }) + + it('lets the user choose Default back after another client picked Off', async () => { + rpc.settings.set('env-a', 'default') + await mount('env-a') + await act(async () => { + setPRAutoRefreshCadenceForHost('env-a', 'off') + }) + await act(async () => option(0).click()) + await act(async () => {}) + expect(rpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-a' }, + 'settings.update', + { prAutoRefresh: 'default' }, + expect.anything() + ) + expect(selected()).toBe(0) + }) + + it('shows a value the host sync already knows without waiting for its own read', async () => { + let answer!: (value: { settings: { prAutoRefresh: string } }) => void + rpc.callRuntimeRpc.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + setPRAutoRefreshCadenceForHost('env-a', 'minimal') + await mount('env-a') + expect(selected()).toBe(1) + await act(async () => { + answer({ settings: { prAutoRefresh: 'reduced' } }) + }) + expect(selected()).toBe(1) + }) + + it('reads the setting of the host Settings switched to, and keeps each host’s own value', async () => { + rpc.settings.set('env-a', 'off') + rpc.settings.set('env-b', 'reduced') + await mount('env-a') + expect(selected()).toBe(2) + await mount('env-b') + expect(selected()).toBe(1) + await mount('env-a') + expect(selected()).toBe(2) + }) + + it('falls back to Default with a clear hint when the host is an older Orca without the setting', async () => { + // An older host's settings.get carries no prAutoRefresh key. + rpc.callRuntimeRpc.mockImplementation(async () => ({ settings: {} })) + await mount('env-a') + expect(selected()).toBe(0) + for (const button of container.querySelectorAll('button[role="radio"]')) { + expect(button.getAttribute('aria-disabled')).toBe('true') + } + // Nothing is written to a host that would reject the key. + await act(async () => option(2).click()) + expect(rpc.callRuntimeRpc).not.toHaveBeenCalledWith( + expect.anything(), + 'settings.update', + expect.anything(), + expect.anything() + ) + }) +}) diff --git a/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.tsx b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.tsx new file mode 100644 index 00000000000..6e2cb46370d --- /dev/null +++ b/src/renderer/src/components/settings/source-control/PRAutoRefreshSetting.tsx @@ -0,0 +1,121 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import { + normalizePRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../../shared/review-refresh-policy' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target' +import { useAppStateValue } from '@/store/retained-views/app-state-view' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import type { SettingsHostScope } from '../host-scope/settings-host-scope' +import { + useHostLacksPRAutoRefresh, + useHostPRAutoRefreshCadence +} from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence' +import { + readHostPRAutoRefreshCadence, + rememberHostPRAutoRefreshWrite +} from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync' +import { PRAutoRefreshControl } from './PRAutoRefreshControl' + +/** The cadence belongs to the host that runs the GitHub reads, so a remote host is read and + * written through its own settings. The control shows the one shared copy of that host's setting + * (not a snapshot of its own): a change made from another client, or by the host's own + * `settingsChanged`, is reflected while this row is mounted, so what it shows is what choosing + * an option is compared against. */ +function usePRAutoRefreshHostCadence( + target: RuntimeClientTarget, + localSettings: GlobalSettings, + updateLocalSettings: (updates: Partial) => void | Promise, + t: Translate +): { + cadence: PRAutoRefreshCadence | null + /** The paired host is an older Orca without the setting: it polls at Default and cannot be set. */ + unsupported: boolean + update: (cadence: PRAutoRefreshCadence) => Promise +} { + const environmentId = runtimeTargetEnvironmentId(target) + const remote = useHostPRAutoRefreshCadence(environmentId) + const unsupported = useHostLacksPRAutoRefresh(environmentId) + + useEffect(() => { + if (!environmentId) { + return + } + // Re-read on mount even when known: the host may have changed while this client had no contact. + // The sync's own fences drop the answer if a write or an unpairing lands first. + void readHostPRAutoRefreshCadence(environmentId) + }, [environmentId]) + + if (!environmentId) { + return { + cadence: normalizePRAutoRefreshCadence(localSettings.prAutoRefresh), + unsupported: false, + update: async (cadence) => { + await updateLocalSettings({ prAutoRefresh: cadence }) + } + } + } + return { + cadence: remote, + unsupported, + update: async (cadence) => { + try { + const result = await callRuntimeRpc<{ settings: Partial }>( + { kind: 'environment', environmentId }, + 'settings.update', + { prAutoRefresh: cadence }, + { timeoutMs: 15_000 } + ) + // Remembered for the host it was written to, even if Settings has moved on to another host. + rememberHostPRAutoRefreshWrite(environmentId, result.settings.prAutoRefresh) + } catch (error) { + toast.error( + t( + 'auto.components.settings.GitPane.prAutoRefreshUpdateFailed', + 'Could not update PR auto-refresh on this host.' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + } + } + } +} + +export function PRAutoRefreshSetting({ + hostScope, + settings, + updateSettings +}: { + /** The host chosen at the top of Settings; its timers (and its clients') are what change. */ + hostScope: SettingsHostScope + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise +}): React.JSX.Element { + const t = useTranslate() + const environmentId = runtimeTargetEnvironmentId(hostScope.target) + const remoteHostName = useAppStateValue((state) => + environmentId + ? (state.runtimeEnvironments.find((entry) => entry.id === environmentId)?.name ?? + t('auto.components.settings.GitPane.prAutoRefreshUnknownHost', 'the selected server')) + : null + ) + const { cadence, unsupported, update } = usePRAutoRefreshHostCadence( + hostScope.target, + settings, + updateSettings, + t + ) + return ( + void update(next)} + remoteHostName={remoteHostName} + unsupported={unsupported} + disabled={!hostScope.available} + /> + ) +} diff --git a/src/renderer/src/components/settings/source-control/git-provider-api-budget-search.ts b/src/renderer/src/components/settings/source-control/git-provider-api-budget-search.ts index 3e260c34a51..6af6664dc29 100644 --- a/src/renderer/src/components/settings/source-control/git-provider-api-budget-search.ts +++ b/src/renderer/src/components/settings/source-control/git-provider-api-budget-search.ts @@ -1,8 +1,18 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from '../search/settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { + getPRAutoRefreshDescription, + getPRAutoRefreshTitle, + PR_AUTO_REFRESH_KEYWORDS +} from './pr-auto-refresh-search' export const getGitProviderApiBudgetSearchEntries = createLocalizedCatalog(() => [ + { + title: getPRAutoRefreshTitle(), + description: getPRAutoRefreshDescription(), + keywords: PR_AUTO_REFRESH_KEYWORDS + }, { title: translate('auto.components.settings.git.search.52d52b099d', 'GitHub API budget'), description: translate( diff --git a/src/renderer/src/components/settings/source-control/pr-auto-refresh-search.ts b/src/renderer/src/components/settings/source-control/pr-auto-refresh-search.ts new file mode 100644 index 00000000000..d4109ba404b --- /dev/null +++ b/src/renderer/src/components/settings/source-control/pr-auto-refresh-search.ts @@ -0,0 +1,74 @@ +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { PRAutoRefreshCadence } from '../../../../../shared/review-refresh-policy' +import { matchesSettingsSearch } from '../search/settings-search' + +export const PR_AUTO_REFRESH_KEYWORDS = [ + 'pull request', + 'pr', + 'checks', + 'polling', + 'refresh interval', + 'background refresh', + 'github enterprise', + 'cpu', + 'gh' +] + +export function getPRAutoRefreshTitle(t: Translate = translate): string { + return t('auto.components.settings.GitPane.prAutoRefreshTitle', 'PR Updates') +} + +export function getPRAutoRefreshDescription(t: Translate = translate): string { + return t( + 'auto.components.settings.GitPane.prAutoRefreshDescription', + 'How often Orca checks GitHub for pull request and CI changes.' + ) +} + +export function getPRAutoRefreshCadenceLabel( + cadence: PRAutoRefreshCadence, + t: Translate = translate +): string { + switch (cadence) { + case 'default': + return t('auto.components.settings.GitPane.prAutoRefreshDefault', 'Automatic') + case 'minimal': + case 'reduced': + return t('auto.components.settings.GitPane.prAutoRefreshReduced', 'Less often') + case 'off': + return t('auto.components.settings.GitPane.prAutoRefreshOff', 'Manual') + } +} + +export function getPRAutoRefreshCadenceHint( + cadence: PRAutoRefreshCadence, + t: Translate = translate +): string { + switch (cadence) { + case 'default': + return t( + 'auto.components.settings.GitPane.prAutoRefreshHintAutomatic', + 'Keeps pull requests and checks up to date.' + ) + case 'minimal': + case 'reduced': + return t( + 'auto.components.settings.GitPane.prAutoRefreshHintLessOften', + 'Checks GitHub less often. Useful if you hit GitHub rate limits.' + ) + case 'off': + return t( + 'auto.components.settings.GitPane.prAutoRefreshHintOff', + 'Updates when you push or click Refresh.' + ) + } +} + +export function prAutoRefreshMatchesSearch(searchQuery: string, t?: Translate): boolean { + return matchesSettingsSearch(searchQuery, { + title: getPRAutoRefreshTitle(t), + description: getPRAutoRefreshDescription(t), + keywords: PR_AUTO_REFRESH_KEYWORDS + }) +} diff --git a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts index ca3a5fef27b..eebd98ffbf2 100644 --- a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts +++ b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts @@ -4,6 +4,8 @@ import { getVisibleHostedReviewWorkspaces } from '@/store/slices/hosted-review/r import { getHostedReviewCacheKey } from '@/store/slices/hosted-review/hosted-review-cache-identity' import type { VisibleHostedReviewRefreshTarget } from '@/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler' import { reviewRefreshIntervalMs } from '../../../../../../shared/review-refresh-policy' +import { getPRRefreshOwnerRuntimeEnvironmentId } from '@/store/github/repositories/repository-routing' +import { hostPRAutoRefreshCadence } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence' import { withTimeout } from '../../../../../../shared/promise-timeout-fallback' import { getWorkspaceAttachments } from '../../../../../../shared/workspace-attachments/workspace-attachments' import { @@ -111,6 +113,8 @@ function referenceRefreshTargets( (state.prCache[candidate.cacheKey]?.data ? 'github' : undefined) const primaryExactKey = getDetachedWorkspaceReviewRequest(worktree, repo)?.key const runtimeVersion = getWorkspaceReferenceRuntimeVersion(worktree, repo) + const cadenceHost = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + const cadence = hostPRAutoRefreshCadence(cadenceHost) for (const item of getWorkspaceAttachments(worktree)) { if (item.type === 'issue') { continue @@ -132,17 +136,21 @@ function referenceRefreshTargets( } const entry = state.hostedReviewCache[key] const review = matchWorkspaceReferenceReview(item, entry?.data) ? entry?.data : null + const intervalInput = { + state: review?.state, + checksStatus: review?.status, + hasReview: review ? true : null, + selected + } targets.set(key, { key, revision: key, selected, fetchedAt: entry?.fetchedAt ?? null, - intervalMs: reviewRefreshIntervalMs({ - state: review?.state, - checksStatus: review?.status, - hasReview: review ? true : null, - selected - }), + cadence, + cadenceHost, + intervalMs: reviewRefreshIntervalMs(intervalInput, cadence), + foregroundIntervalMs: reviewRefreshIntervalMs(intervalInput, 'default'), refresh: (_force, signal) => withTimeout( readWorkspaceReferenceDetails( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fd5105cd28e..04d5127f88d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8976,7 +8976,19 @@ "f2765fbbf6": "GitLab API budget", "8cdbaf4b03": "Jira key casing", "fdbcd676b0": "GitHub API budget", - "074f7aa0d6": "Default compare base" + "074f7aa0d6": "Default compare base", + "prAutoRefreshUpdateFailed": "Could not update PR auto-refresh on this host.", + "prAutoRefreshDefault": "Automatic", + "prAutoRefreshReduced": "Less often", + "prAutoRefreshOff": "Manual", + "prAutoRefreshAbout": "About PR Updates", + "prAutoRefreshHintAutomatic": "Keeps pull requests and checks up to date.", + "prAutoRefreshHintLessOften": "Checks GitHub less often. Useful if you hit GitHub rate limits.", + "prAutoRefreshTitle": "PR Updates", + "prAutoRefreshDescription": "How often Orca checks GitHub for pull request and CI changes.", + "prAutoRefreshHintOff": "Updates when you push or click Refresh.", + "prAutoRefreshUnsupportedHost": "Update Orca on {{host}} to change this", + "prAutoRefreshUnknownHost": "the selected server" }, "HiddenExperimentalGroup": { "7c4e18d2f6": "Arms the bold-glitch capture gestures on this machine: {{samplingShortcut}} starts a sampling burst; {{captureShortcut}} captures pane evidence to disk.", diff --git a/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.test.ts b/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.test.ts new file mode 100644 index 00000000000..086b482db83 --- /dev/null +++ b/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installWindowVisibilityTimeoutPoller } from './window-visibility-timeout-poller' + +describe('window visibility timeout poller on window focus', () => { + const cleanups: (() => void)[] = [] + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }) + }) + afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } + vi.useRealTimers() + }) + + function install(args: { refreshOnFocusWhileStopped?: () => boolean }) { + const run = vi.fn(async () => {}) + const cleanup = installWindowVisibilityTimeoutPoller({ + run, + getDelayMs: () => null, + cooldownMs: 10_000, + ...args + }) + cleanups.push(cleanup) + return run + } + + async function focus(): Promise { + window.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + } + + it('ignores focus while no timer is wanted, by default', async () => { + const run = install({}) + await vi.advanceTimersByTimeAsync(5 * 60_000) + await focus() + expect(run).toHaveBeenCalledTimes(1) + }) + + it('runs once on focus when a setting, not the data, stopped the timer', async () => { + const run = install({ refreshOnFocusWhileStopped: () => true }) + await vi.advanceTimersByTimeAsync(5 * 60_000) + await focus() + expect(run).toHaveBeenCalledTimes(2) + expect(run).toHaveBeenLastCalledWith({ trigger: 'visibility' }) + // Still no timer afterwards. + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('keeps the cooldown and in-flight guards for a focus run', async () => { + let finish!: () => void + const run = vi.fn(() => new Promise((resolve) => (finish = resolve))) + const cleanup = installWindowVisibilityTimeoutPoller({ + run, + getDelayMs: () => null, + cooldownMs: 10_000, + refreshOnFocusWhileStopped: () => true + }) + cleanups.push(cleanup) + await vi.advanceTimersByTimeAsync(5 * 60_000) + await focus() + expect(run).toHaveBeenCalledTimes(1) + finish() + await vi.advanceTimersByTimeAsync(0) + await focus() + expect(run).toHaveBeenCalledTimes(2) + finish() + await vi.advanceTimersByTimeAsync(1_000) + await focus() + expect(run).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.ts b/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.ts index 2f158ee1bc5..db9691c4ba1 100644 --- a/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.ts +++ b/src/renderer/src/lib/window-activity/window-visibility-timeout-poller.ts @@ -2,13 +2,22 @@ import { isWindowVisible } from './window-visibility-interval' export type WindowVisibilityTimeoutPollerTimer = ReturnType +/** Why a run started: its own timer, the window coming back, an explicit refresh, or install. */ +export type WindowVisibilityPollerTrigger = 'timer' | 'visibility' | 'refresh' | 'install' + export function installWindowVisibilityTimeoutPoller(args: { - run: () => Promise | void + run: (context: { trigger: WindowVisibilityPollerTrigger }) => Promise | void getDelayMs: () => number | null + /** With `getDelayMs` null (no timer): whether the window regaining focus still runs once, because + * the timer was stopped by a setting (PR auto-refresh off) rather than by the data being settled. + * Default: focus only matters while a timer is wanted. */ + refreshOnFocusWhileStopped?: () => boolean + /** Keep one eligible return while a request runs; the caller decides whether its completion can satisfy it. */ + replayVisibilityWhileInFlight?: () => boolean cooldownMs?: number setTimeoutFn?: (callback: () => void, delayMs: number) => WindowVisibilityTimeoutPollerTimer clearTimeoutFn?: (handle: WindowVisibilityTimeoutPollerTimer) => void -}): (() => void) & { refresh: () => void } { +}): (() => void) & { refresh: () => void; replan: () => void } { const setTimeoutFn = args.setTimeoutFn ?? ((callback: () => void, delayMs: number): WindowVisibilityTimeoutPollerTimer => @@ -19,6 +28,7 @@ export function installWindowVisibilityTimeoutPoller(args: { let timeoutId: WindowVisibilityTimeoutPollerTimer | null = null let disposed = false let inFlight = false + let visibilityPending = false let lastRunAt = -Infinity const clearScheduledPoll = (): void => { @@ -40,14 +50,21 @@ export function installWindowVisibilityTimeoutPoller(args: { } timeoutId = setTimeoutFn(() => { timeoutId = null - runAndSchedule() + runAndSchedule('timer') }, remainingMs ?? delayMs) } - function runAndSchedule(explicitRefresh = false): void { - if (disposed || !isWindowVisible() || inFlight) { + function runAndSchedule(trigger: WindowVisibilityPollerTrigger): void { + if (disposed || !isWindowVisible()) { return } + if (inFlight) { + if (trigger === 'visibility' && args.replayVisibilityWhileInFlight?.()) { + visibilityPending = true + } + return + } + const explicitRefresh = trigger === 'refresh' const elapsedMs = Date.now() - lastRunAt const cooldownRemainingMs = (args.cooldownMs ?? 0) - elapsedMs if (cooldownRemainingMs > 0) { @@ -55,7 +72,7 @@ export function installWindowVisibilityTimeoutPoller(args: { clearScheduledPoll() timeoutId = setTimeoutFn(() => { timeoutId = null - runAndSchedule(true) + runAndSchedule('refresh') }, cooldownRemainingMs) } return @@ -65,10 +82,15 @@ export function installWindowVisibilityTimeoutPoller(args: { inFlight = true const finish = (): void => { inFlight = false - schedulePoll() + if (visibilityPending) { + visibilityPending = false + runAndSchedule('visibility') + } else { + schedulePoll() + } } try { - void Promise.resolve(args.run()).then(finish, finish) + void Promise.resolve(args.run({ trigger })).then(finish, finish) } catch { finish() } @@ -84,18 +106,21 @@ export function installWindowVisibilityTimeoutPoller(args: { } return } - runAndSchedule() + runAndSchedule('visibility') } else { clearScheduledPoll() } } + // Focus without a visibility change (the user switched apps) is the user coming back: run when a + // timer is wanted, or when only a setting stopped it. The run keeps the cooldown and in-flight + // guards, and schedules no timer afterwards while the delay stays null. const reconcileFocus = (): void => { - if (args.getDelayMs() !== null) { + if (args.getDelayMs() !== null || args.refreshOnFocusWhileStopped?.() === true) { reconcileVisibility() } } - runAndSchedule() + runAndSchedule('install') if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('focus', reconcileFocus) } @@ -105,6 +130,7 @@ export function installWindowVisibilityTimeoutPoller(args: { const cleanup = (): void => { disposed = true + visibilityPending = false clearScheduledPoll() if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') { window.removeEventListener('focus', reconcileFocus) @@ -113,5 +139,13 @@ export function installWindowVisibilityTimeoutPoller(args: { document.removeEventListener('visibilitychange', reconcileVisibility) } } - return Object.assign(cleanup, { refresh: () => runAndSchedule(true) }) + return Object.assign(cleanup, { + refresh: () => runAndSchedule('refresh'), + // The delay policy changed: arm or disarm the next tick without running anything now. + replan: () => { + if (!inFlight) { + schedulePoll() + } + } + }) } diff --git a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts index 8284f0aad14..a96dbbfdcee 100644 --- a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts +++ b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts @@ -9,6 +9,7 @@ import { admitSshConnectionState } from '../../../../../shared/ssh-retained-payl import { getRuntimeEnvironmentRevision } from '../environment-revisions/runtime-environment-revision' import { subscribeRuntimeEnvironment } from '../environment-revisions/runtime-environment-pairing-refresh' import { answerBrowserClientHostRequest } from '../../browser/client-hosting/browser-client-host-request-answer' +import { readHostPRAutoRefreshCadence } from '@/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync' export type RuntimeClientEventSubscription = { unsubscribe: () => void @@ -82,6 +83,13 @@ function handleRuntimeClientEventResponse( void answerBrowserClientHostRequest(environmentId, message.requestId) return } + // The host's own PR auto-refresh setting governs this client's timers for that host's workspaces. + if (message.type === 'settingsChanged') { + if (Array.isArray(message.keys) && message.keys.includes('prAutoRefresh')) { + void readHostPRAutoRefreshCadence(environmentId) + } + return + } if (message.type === 'sshStateChanged') { const state = admitSshConnectionState(message.state, message.targetId) if (state) { diff --git a/src/renderer/src/store/github/checks/check-actions.ts b/src/renderer/src/store/github/checks/check-actions.ts index 8eb0f7cb5a0..1aa29905ec5 100644 --- a/src/renderer/src/store/github/checks/check-actions.ts +++ b/src/renderer/src/store/github/checks/check-actions.ts @@ -176,7 +176,9 @@ export const createCheckActions = ( headSha, prRepo: prRepo ?? null, noCache, - ...(userRefresh ? { userRefresh: true } : {}) + ...(userRefresh ? { userRefresh: true } : {}), + // A timer tick is budgeted background work the host paces by its own cadence. + ...(options?.background && !noCache ? { reason: 'swr' } : {}) }, { timeoutMs: 30_000 } ) diff --git a/src/renderer/src/store/github/github-slice-contract.ts b/src/renderer/src/store/github/github-slice-contract.ts index 450eaa75f38..e95907a18e7 100644 --- a/src/renderer/src/store/github/github-slice-contract.ts +++ b/src/renderer/src/store/github/github-slice-contract.ts @@ -92,6 +92,8 @@ export type GitHubSlice = { /** Refresh or a check action: a user act that may start a stopped WSL distro. */ wakeWslDistro?: boolean userRefresh?: boolean + /** The checks panel's own timer: a paired host may answer at its PR auto-refresh cadence. */ + background?: boolean } ) => Promise fetchPRCheckDetails: ( diff --git a/src/renderer/src/store/github/github-slice-test-harness.ts b/src/renderer/src/store/github/github-slice-test-harness.ts index cf43db8cbae..5ad05001f53 100644 --- a/src/renderer/src/store/github/github-slice-test-harness.ts +++ b/src/renderer/src/store/github/github-slice-test-harness.ts @@ -40,8 +40,15 @@ export const runtimeEnvironmentSubscribe: Mock< export const mockApi = { gh: { prForBranch: stubMock().mockResolvedValue(null), - refreshPRNow: - stubMock<[{ candidate: GitHubPRRefreshCandidate; reason?: GitHubPRRefreshReason }]>(), + refreshPRNow: stubMock< + [ + { + candidate: GitHubPRRefreshCandidate + reason?: GitHubPRRefreshReason + userRefresh?: boolean + } + ] + >(), enqueuePRRefresh: stubMock< [{ candidate: GitHubPRRefreshCandidate; reason: GitHubPRRefreshReason; priority?: number }] diff --git a/src/renderer/src/store/github/pull-requests/pull-request-execution.ts b/src/renderer/src/store/github/pull-requests/pull-request-execution.ts index 3315464036a..0c5155b559b 100644 --- a/src/renderer/src/store/github/pull-requests/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-requests/pull-request-execution.ts @@ -147,6 +147,7 @@ export function startPullRequestLookup(args: { ...requestOptions, repoOwnerExecutionHostId: options?.repoOwnerExecutionHostId }) + // Why: a reply without data leaves the shown PR as it is; queued work delivers the newer one. return isPRRevalidationOnly(response) ? response : normalizeGitHubPRForBranchOutcome(response) diff --git a/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.test.ts b/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.test.ts new file mode 100644 index 00000000000..f24a55b96b1 --- /dev/null +++ b/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { + getPRAutoRefreshCadence, + setPRAutoRefreshCadence +} from '../../../../../shared/review-refresh-policy' +import { installPRAutoRefreshCadenceObserver } from './pr-auto-refresh-cadence-observer' + +type State = { settings: Pick | null } + +function fakeStore(initial: State) { + let state = initial + const listeners = new Set<(next: State, previous: State) => void>() + return { + getState: () => state, + subscribe: (listener: (next: State, previous: State) => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + set(next: State) { + const previous = state + state = next + for (const listener of listeners) { + listener(state, previous) + } + } + } +} + +describe('installPRAutoRefreshCadenceObserver', () => { + afterEach(() => { + setPRAutoRefreshCadence('default') + }) + + it('applies the loaded setting and follows later changes', () => { + const store = fakeStore({ settings: null }) + installPRAutoRefreshCadenceObserver(store) + expect(getPRAutoRefreshCadence()).toBe('default') + + store.set({ settings: { prAutoRefresh: 'minimal' } }) + expect(getPRAutoRefreshCadence()).toBe('minimal') + + store.set({ settings: { prAutoRefresh: 'off' } }) + expect(getPRAutoRefreshCadence()).toBe('off') + }) +}) diff --git a/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.ts b/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.ts new file mode 100644 index 00000000000..3ea6746fb66 --- /dev/null +++ b/src/renderer/src/store/github/refresh/pr-auto-refresh-cadence-observer.ts @@ -0,0 +1,20 @@ +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { setPRAutoRefreshCadence } from '../../../../../shared/review-refresh-policy' + +type CadenceState = { settings: Pick | null } + +type CadenceStoreHost = { + getState: () => CadenceState + subscribe: (listener: (state: CadenceState, previous: CadenceState) => void) => () => void +} + +/** Every renderer review scheduler reads the shared cadence; set it before their own subscribers + * recompute targets on the same settings change. */ +export function installPRAutoRefreshCadenceObserver(store: CadenceStoreHost): void { + setPRAutoRefreshCadence(store.getState().settings?.prAutoRefresh) + store.subscribe((state, previous) => { + if (state.settings?.prAutoRefresh !== previous.settings?.prAutoRefresh) { + setPRAutoRefreshCadence(state.settings?.prAutoRefresh) + } + }) +} diff --git a/src/renderer/src/store/github/refresh/refresh-event-actions.ts b/src/renderer/src/store/github/refresh/refresh-event-actions.ts index 7a301f72c31..ac71269bdc3 100644 --- a/src/renderer/src/store/github/refresh/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh/refresh-event-actions.ts @@ -69,6 +69,8 @@ export const createRefreshEventActions = ( nextSequences[alias.cacheKey] ?? 0, prRefreshStartedLinkedReviewScopes.get(alias.cacheKey)?.sequence ?? 0 ) + // Why: a request main ends without data closes at its own sequence, while it is still the + // one shown loading; an older request or a repeat never overwrites a newer state. const completesOwnedRequest = event.status === 'skipped' && event.sequence === previousSequence && diff --git a/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.test.ts b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.test.ts new file mode 100644 index 00000000000..4438a0e94b0 --- /dev/null +++ b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.test.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _resetPRAutoRefreshCadencesForTests, + getKnownPRAutoRefreshCadenceForHost, + getPRAutoRefreshCadenceForHost +} from '../../../../../shared/review-refresh-policy' +import type { AppState } from '../../types' + +const regained = vi.hoisted(() => ({ listeners: new Map void>() })) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/runtime/hosts/connections/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => { + regained.listeners.set(environmentId, listener) + return () => regained.listeners.delete(environmentId) + } +})) + +import { + _hostPRAutoRefreshReadsInFlightForTests, + installRemoteHostPRAutoRefreshCadenceSync, + readHostPRAutoRefreshCadence +} from './remote-host-pr-auto-refresh-cadence-sync' +import { + _resetHostPRAutoRefreshCadenceGenerationsForTests, + _trackedHostPRAutoRefreshStateCountForTests, + forgetHostPRAutoRefreshCadence, + hostLacksPRAutoRefresh, + rememberHostPRAutoRefreshCadence +} from './remote-host-pr-auto-refresh-cadence' + +type State = Pick + +function store(initial: State) { + let state = initial + const listeners = new Set<(state: AppState, previous: AppState) => void>() + return { + getState: () => state, + subscribe: (listener: (state: AppState, previous: AppState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + set: (next: Partial) => { + const previous = state + state = { ...state, ...next } + for (const listener of listeners) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sync reads only the two fields this store carries. + listener(state as AppState, previous as AppState) + } + } + } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields runtimeHostContactForEntry reads for a live verdict. +const live = { verified: true, status: { runtimeId: 'r' } } as never +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) +function environment(id: string) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sync reads only `id` of an environment. + return { id, name: id } as AppState['runtimeEnvironments'][number] +} + +describe('remote host PR auto-refresh cadence sync', () => { + afterEach(() => { + _resetPRAutoRefreshCadencesForTests() + _resetHostPRAutoRefreshCadenceGenerationsForTests() + regained.listeners.clear() + }) + + it('reads a live host’s setting when it appears, again on regained contact, and forgets an unpaired host', async () => { + const settings = new Map([['env-1', 'minimal']]) + const reader = vi.fn(async (id: string) => ({ prAutoRefresh: settings.get(id) })) + const host = store({ + runtimeEnvironments: [environment('env-1')], + runtimeStatusByEnvironmentId: new Map([['env-1', live]]) + }) + const stop = installRemoteHostPRAutoRefreshCadenceSync(host, reader) + await flush() + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('minimal') + expect(reader).toHaveBeenCalledTimes(1) + + settings.set('env-1', 'off') + regained.listeners.get('env-1')?.() + await flush() + expect(reader).toHaveBeenCalledTimes(2) + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('off') + + host.set({ runtimeEnvironments: [] }) + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('default') + expect(regained.listeners.has('env-1')).toBe(false) + stop() + }) + + it('does not ask a host it has no contact with', async () => { + const reader = vi.fn(async () => ({ prAutoRefresh: 'off' })) + const host = store({ + runtimeEnvironments: [environment('env-2')], + runtimeStatusByEnvironmentId: new Map() + }) + const stop = installRemoteHostPRAutoRefreshCadenceSync(host, reader) + await flush() + expect(reader).not.toHaveBeenCalled() + expect(getPRAutoRefreshCadenceForHost('env-2')).toBe('default') + stop() + }) + + it('keeps the last known value when a read fails, and a change during a read is re-read once', async () => { + let calls = 0 + const reader = vi.fn(async () => { + calls += 1 + if (calls === 1) { + return { prAutoRefresh: 'reduced' } + } + throw new Error('offline') + }) + await readHostPRAutoRefreshCadence('env-3', reader) + expect(getPRAutoRefreshCadenceForHost('env-3')).toBe('reduced') + await Promise.all([ + readHostPRAutoRefreshCadence('env-3', reader), + readHostPRAutoRefreshCadence('env-3', reader), + readHostPRAutoRefreshCadence('env-3', reader) + ]) + // The failed read plus one trailing read for the changes reported while it was pending. + expect(calls).toBe(3) + expect(getPRAutoRefreshCadenceForHost('env-3')).toBe('reduced') + }) + + it('re-reads after settingsChanged arrives while an older settings snapshot is in flight', async () => { + let finish!: (value: { prAutoRefresh: string }) => void + let calls = 0 + const reader = vi.fn(async () => { + calls += 1 + if (calls === 1) { + return await new Promise<{ prAutoRefresh: string }>((resolve) => { + finish = resolve + }) + } + return { prAutoRefresh: 'default' } + }) + const first = readHostPRAutoRefreshCadence('env-4', reader) + // The host had already snapshotted Off; another client changed it and settingsChanged arrived. + const changed = readHostPRAutoRefreshCadence('env-4', reader) + finish({ prAutoRefresh: 'off' }) + await Promise.all([first, changed]) + expect(calls).toBe(2) + expect(getPRAutoRefreshCadenceForHost('env-4')).toBe('default') + }) + + it('a change during a read that fails is still re-read', async () => { + let fail!: (error: Error) => void + let calls = 0 + const reader = vi.fn(async () => { + calls += 1 + if (calls === 1) { + return await new Promise<{ prAutoRefresh: string }>((_resolve, reject) => { + fail = reject + }) + } + return { prAutoRefresh: 'minimal' } + }) + const first = readHostPRAutoRefreshCadence('env-5', reader) + const changed = readHostPRAutoRefreshCadence('env-5', reader) + fail(new Error('timed out')) + await Promise.all([first, changed]) + expect(getPRAutoRefreshCadenceForHost('env-5')).toBe('minimal') + }) + + it('a slow read does not revert a value the Settings control wrote meanwhile', async () => { + let finish!: (value: { prAutoRefresh: string }) => void + const reader = vi.fn( + async () => + await new Promise<{ prAutoRefresh: string }>((resolve) => { + finish = resolve + }) + ) + const read = readHostPRAutoRefreshCadence('env-6', reader) + rememberHostPRAutoRefreshCadence('env-6', 'off') + finish({ prAutoRefresh: 'default' }) + await read + expect(getPRAutoRefreshCadenceForHost('env-6')).toBe('off') + }) + + it('a slow read does not re-add a host that was unpaired meanwhile', async () => { + let finish!: (value: { prAutoRefresh: string }) => void + const reader = vi.fn( + async () => + await new Promise<{ prAutoRefresh: string }>((resolve) => { + finish = resolve + }) + ) + rememberHostPRAutoRefreshCadence('env-7', 'minimal') + const read = readHostPRAutoRefreshCadence('env-7', reader) + forgetHostPRAutoRefreshCadence('env-7') + finish({ prAutoRefresh: 'minimal' }) + await read + expect(getPRAutoRefreshCadenceForHost('env-7')).toBe('default') + }) + + it('keeps nothing for hosts that come and go once their reads end, and reads a re-paired host afresh', async () => { + const held: { resolve: (value: { prAutoRefresh: string }) => void; reject: () => void }[] = [] + const reader = vi.fn((id: string) => + id === 'env-held' + ? new Promise<{ prAutoRefresh: string }>((resolve, reject) => + held.push({ resolve, reject: () => reject(new Error('closed')) }) + ) + : Promise.resolve({ prAutoRefresh: 'reduced' }) + ) + const ids = [...Array.from({ length: 20 }, (_, index) => `env-${index}`), 'env-held'] + const host = store({ + runtimeEnvironments: [...ids, 'env-stays'].map(environment), + runtimeStatusByEnvironmentId: new Map([...ids, 'env-stays'].map((id) => [id, live])) + }) + const stop = installRemoteHostPRAutoRefreshCadenceSync(host, reader) + await flush() + // The held host reports a change while its read is out, then it and the others are unpaired. + regained.listeners.get('env-held')?.() + host.set({ runtimeEnvironments: [environment('env-stays')] }) + // Only the held read's own request may still be settling. + expect(_hostPRAutoRefreshReadsInFlightForTests()).toBe(1) + expect(_trackedHostPRAutoRefreshStateCountForTests()).toBe(0) + + // Paired again before the old read ends: the old read neither answers nor clears the new one. + host.set({ runtimeEnvironments: [environment('env-stays'), environment('env-held')] }) + held[0].resolve({ prAutoRefresh: 'off' }) + await flush() + expect(getPRAutoRefreshCadenceForHost('env-held')).toBe('default') + expect(_hostPRAutoRefreshReadsInFlightForTests()).toBe(1) + held[1].resolve({ prAutoRefresh: 'minimal' }) + await flush() + expect(getPRAutoRefreshCadenceForHost('env-held')).toBe('minimal') + expect(getPRAutoRefreshCadenceForHost('env-stays')).toBe('reduced') + + // A read that fails after its host is forgotten ends the same way, with no trailing read. + regained.listeners.get('env-held')?.() + regained.listeners.get('env-held')?.() + host.set({ runtimeEnvironments: [] }) + held[2].reject() + await flush() + expect(reader.mock.calls.filter(([id]) => id === 'env-held')).toHaveLength(3) + expect(_hostPRAutoRefreshReadsInFlightForTests()).toBe(0) + expect(_trackedHostPRAutoRefreshStateCountForTests()).toBe(0) + expect(getKnownPRAutoRefreshCadenceForHost('env-held')).toBeNull() + expect(getKnownPRAutoRefreshCadenceForHost('env-stays')).toBeNull() + stop() + }) + + it('marks a host that answers without the setting (an older Orca) as lacking it, scheduled as Default', async () => { + // An older host's settings.get has no prAutoRefresh key at all. + const answers: { prAutoRefresh?: string }[] = [{}, { prAutoRefresh: 'reduced' }] + const reader = vi.fn(async () => answers.shift() ?? {}) + await readHostPRAutoRefreshCadence('env-old', reader) + expect(hostLacksPRAutoRefresh('env-old')).toBe(true) + expect(getPRAutoRefreshCadenceForHost('env-old')).toBe('default') + + // The host was updated and answers with the setting on the next contact. + await readHostPRAutoRefreshCadence('env-old', reader) + expect(hostLacksPRAutoRefresh('env-old')).toBe(false) + expect(getPRAutoRefreshCadenceForHost('env-old')).toBe('reduced') + }) + + it('does not call a host that has not answered, or whose read failed, an older host', async () => { + expect(hostLacksPRAutoRefresh('env-new')).toBe(false) + await readHostPRAutoRefreshCadence('env-new', async () => { + throw new Error('offline') + }) + expect(hostLacksPRAutoRefresh('env-new')).toBe(false) + }) +}) diff --git a/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.ts b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.ts new file mode 100644 index 00000000000..a6dce59938b --- /dev/null +++ b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync.ts @@ -0,0 +1,165 @@ +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { runtimeHostContactForEntry } from '../../../../../shared/remote-runtime/host-status/runtime-host-contact' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import { subscribeRuntimeHostContactRegained } from '@/runtime/hosts/connections/runtime-host-contact-regained' +import type { AppState } from '../../types' +import { + beginHostPRAutoRefreshCadenceRead, + endHostPRAutoRefreshCadenceRead, + forgetHostPRAutoRefreshCadence, + noteHostLacksPRAutoRefresh, + rememberHostPRAutoRefreshCadence, + type HostPRAutoRefreshCadenceRead +} from './remote-host-pr-auto-refresh-cadence' + +const READ_TIMEOUT_MS = 15_000 + +type HostStore = { + getState: () => Pick + subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void +} + +type Reader = (environmentId: string) => Promise<{ prAutoRefresh?: unknown }> + +const defaultReader: Reader = async (environmentId) => { + const result = await callRuntimeRpc<{ settings: Partial }>( + { kind: 'environment', environmentId }, + 'settings.get', + undefined, + { timeoutMs: READ_TIMEOUT_MS } + ) + return result.settings +} + +type HostRead = { + /** Settles after this read and, when a change arrived during it, the trailing read too. */ + settled: Promise + /** A change was reported while the host had not answered: its answer may predate the change. */ + dirty: boolean + authority: HostPRAutoRefreshCadenceRead +} + +const inFlight = new Map() + +export function _hostPRAutoRefreshReadsInFlightForTests(): number { + return inFlight.size +} + +/** + * Ask the host for its setting. One read per host at a time; a request made while one is pending + * (the host's `settingsChanged`, regained contact) marks it dirty: its answer is not remembered, and + * one trailing read follows so the change is never lost. A failed read keeps the last known value; + * an answer without the setting marks the host as an older Orca that lacks it. + * An answer is also dropped when a newer authoritative value landed meanwhile (a write from the + * Settings control, the host being forgotten), so a slow read never reverts it. Nothing outlives + * the read: a forgotten host keeps no state here. + */ +export function readHostPRAutoRefreshCadence( + environmentId: string, + reader: Reader = defaultReader +): Promise { + const pending = inFlight.get(environmentId) + // A read begun before the host was forgotten cannot answer for it once it is paired again. + if (pending && !pending.authority.retired) { + pending.dirty = true + return pending.settled + } + return startHostRead(environmentId, reader).settled +} + +function startHostRead(environmentId: string, reader: Reader): HostRead { + const authority = beginHostPRAutoRefreshCadenceRead(environmentId) + const read: HostRead = { dirty: false, settled: Promise.resolve(), authority } + read.settled = reader(environmentId) + .then((settings) => { + if (!read.dirty && !authority.revoked) { + // A host that knows the setting always sends it; an answer without it is an older Orca. + if (settings.prAutoRefresh === undefined) { + noteHostLacksPRAutoRefresh(environmentId) + } else { + rememberHostPRAutoRefreshCadence(environmentId, settings.prAutoRefresh) + } + } + }) + .catch((error: unknown) => { + console.warn('Failed to read a host’s PR auto-refresh setting:', error) + }) + .then(() => { + endHostPRAutoRefreshCadenceRead(environmentId, authority) + if (inFlight.get(environmentId) === read) { + inFlight.delete(environmentId) + } + // A forgotten host gets no trailing read; if it is paired again it is read afresh. + if (read.dirty && !authority.retired) { + return startHostRead(environmentId, reader).settled + } + return undefined + }) + inFlight.set(environmentId, read) + return read +} + +/** + * The host's answer to this client's own write. It shows at once, but it is the host's value when + * the write finished, which can be older than a change another client made since: a read that + * change already started is therefore not dropped; it is marked dirty, so its own answer is not + * trusted either and one trailing read settles the host's current value. + */ +export function rememberHostPRAutoRefreshWrite(environmentId: string, value: unknown): void { + const pending = inFlight.get(environmentId) + if (pending && !pending.authority.retired) { + pending.dirty = true + } + rememberHostPRAutoRefreshCadence(environmentId, value, { keepPendingRead: Boolean(pending) }) +} + +/** + * Keeps every paired host's PR auto-refresh cadence current: read when the host is first seen live + * and on each regained contact (a restarted host may have another setting), forgotten when the + * host is unpaired. A host's `settingsChanged` event re-reads it between contacts. + */ +export function installRemoteHostPRAutoRefreshCadenceSync( + store: HostStore, + reader: Reader = defaultReader +): () => void { + const watched = new Map void>() + const readIfLive = (environmentId: string): void => { + const entry = store.getState().runtimeStatusByEnvironmentId.get(environmentId) + if (runtimeHostContactForEntry(entry).verdict === 'live') { + void readHostPRAutoRefreshCadence(environmentId, reader) + } + } + const reconcile = (): void => { + const ids = new Set(store.getState().runtimeEnvironments.map((environment) => environment.id)) + for (const [environmentId, unsubscribe] of watched) { + if (!ids.has(environmentId)) { + unsubscribe() + watched.delete(environmentId) + forgetHostPRAutoRefreshCadence(environmentId) + } + } + for (const environmentId of ids) { + if (watched.has(environmentId)) { + continue + } + watched.set( + environmentId, + subscribeRuntimeHostContactRegained(environmentId, () => readIfLive(environmentId)) + ) + readIfLive(environmentId) + } + } + reconcile() + const unsubscribe = store.subscribe((state, previous) => { + if (state.runtimeEnvironments !== previous.runtimeEnvironments) { + reconcile() + } + }) + return () => { + unsubscribe() + for (const stop of watched.values()) { + stop() + } + watched.clear() + } +} diff --git a/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence.ts b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence.ts new file mode 100644 index 00000000000..93d6d361c8c --- /dev/null +++ b/src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence.ts @@ -0,0 +1,147 @@ +import { useSyncExternalStore } from 'react' +import { + getKnownPRAutoRefreshCadenceForHost, + getPRAutoRefreshCadenceForHost, + setPRAutoRefreshCadenceForHost, + subscribePRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../../shared/review-refresh-policy' + +// Why no store or RPC imports here: store slice code reads these; the reads and host-contact +// subscriptions that feed them live in remote-host-pr-auto-refresh-cadence-sync.ts. + +/** A host's settings.get while it is in flight. A newer authoritative value (a write from the + * Settings control, another answer) revokes it so its answer is dropped; forgetting the host also + * retires it, so nothing it started reaches a host that is gone. */ +export type HostPRAutoRefreshCadenceRead = { revoked: boolean; retired: boolean } + +// Only reads still in flight are kept, so a host leaves nothing behind once it is forgotten. +const pendingReads = new Map() + +export function beginHostPRAutoRefreshCadenceRead( + environmentId: string +): HostPRAutoRefreshCadenceRead { + const read = { revoked: false, retired: false } + pendingReads.set(environmentId, read) + return read +} + +export function endHostPRAutoRefreshCadenceRead( + environmentId: string, + read: HostPRAutoRefreshCadenceRead +): void { + if (pendingReads.get(environmentId) === read) { + pendingReads.delete(environmentId) + } +} + +function revokePendingRead(environmentId: string, retire = false): void { + const read = pendingReads.get(environmentId) + if (!read) { + return + } + read.revoked = true + if (retire) { + read.retired = true + pendingReads.delete(environmentId) + } +} + +// Hosts that answered `settings.get` without the setting: an Orca that predates it. Such a host +// polls at Default and rejects a write of the key, so the control must say so instead of waiting +// for an answer that never comes. Kept apart from "not answered yet". +const hostsLackingSetting = new Set() +const lackListeners = new Set<() => void>() + +function setHostLacksSetting(environmentId: string, lacks: boolean): void { + if (hostsLackingSetting.has(environmentId) === lacks) { + return + } + if (lacks) { + hostsLackingSetting.add(environmentId) + } else { + hostsLackingSetting.delete(environmentId) + } + for (const listener of Array.from(lackListeners)) { + listener() + } +} + +/** The cadence of a paired host, as this client last learned it; Default until it answers. */ +export function hostPRAutoRefreshCadence(environmentId: string | null): PRAutoRefreshCadence { + return getPRAutoRefreshCadenceForHost(environmentId) +} + +/** A value just read from or written to the host (Settings control, settings.get) is authoritative. + * A write's answer may be older than a read already asked for (another client's later change + * started it), so the write keeps that read and its owner checks it again (`keepPendingRead`). */ +export function rememberHostPRAutoRefreshCadence( + environmentId: string, + value: unknown, + { keepPendingRead = false }: { keepPendingRead?: boolean } = {} +): void { + if (!keepPendingRead) { + revokePendingRead(environmentId) + } + setHostLacksSetting(environmentId, false) + setPRAutoRefreshCadenceForHost(environmentId, value) +} + +export function forgetHostPRAutoRefreshCadence(environmentId: string): void { + revokePendingRead(environmentId, true) + setHostLacksSetting(environmentId, false) + setPRAutoRefreshCadenceForHost(environmentId, null) +} + +/** The host answered without the setting (an older Orca). It keeps no value here, so every + * scheduler reads it as Default, which is what that host does. */ +export function noteHostLacksPRAutoRefresh(environmentId: string): void { + revokePendingRead(environmentId) + setPRAutoRefreshCadenceForHost(environmentId, null) + setHostLacksSetting(environmentId, true) +} + +export function hostLacksPRAutoRefresh(environmentId: string): boolean { + return hostsLackingSetting.has(environmentId) +} + +function subscribeHostLacksSetting(listener: () => void): () => void { + lackListeners.add(listener) + return () => { + lackListeners.delete(listener) + } +} + +/** Whether the paired host is an older Orca without the setting; false for no host. */ +export function useHostLacksPRAutoRefresh(environmentId: string | null): boolean { + return useSyncExternalStore(subscribeHostLacksSetting, () => + environmentId ? hostLacksPRAutoRefresh(environmentId) : false + ) +} + +/** The host's setting as this client last learned it, or null while unknown (still reading). */ +export function knownHostPRAutoRefreshCadence(environmentId: string): PRAutoRefreshCadence | null { + return getKnownPRAutoRefreshCadenceForHost(environmentId) +} + +/** The one authoritative copy of a paired host's setting, live: whatever reads or writes it (the + * host's `settingsChanged`, regained contact, this or another window's Settings control) shows + * here at once. null while the host has not answered, or for no host. */ +export function useHostPRAutoRefreshCadence( + environmentId: string | null +): PRAutoRefreshCadence | null { + return useSyncExternalStore(subscribePRAutoRefreshCadence, () => + environmentId ? knownHostPRAutoRefreshCadence(environmentId) : null + ) +} + +/** How many hosts this client keeps any per-host read or support state for. */ +export function _trackedHostPRAutoRefreshStateCountForTests(): number { + return new Set([...pendingReads.keys(), ...hostsLackingSetting]).size +} + +export function _resetHostPRAutoRefreshCadenceGenerationsForTests(): void { + pendingReads.clear() + hostsLackingSetting.clear() + lackListeners.clear() +} diff --git a/src/renderer/src/store/slices/hosted-review/refresh/owning-host-cadence-replan.ts b/src/renderer/src/store/slices/hosted-review/refresh/owning-host-cadence-replan.ts new file mode 100644 index 00000000000..e50dec3b469 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review/refresh/owning-host-cadence-replan.ts @@ -0,0 +1,51 @@ +import { + getPRAutoRefreshCadenceForHost, + scaleReviewRefreshInterval, + subscribePRAutoRefreshCadence, + type PRAutoRefreshCadence +} from '../../../../../../shared/review-refresh-policy' +import type { VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler' + +/** Which host's cadence governs a visible target, and what that cadence is. */ +export type OwningHostCadence = { + /** The owning host's auto-refresh cadence; "off" also stops timed and receipt retries. */ + cadence?: PRAutoRefreshCadence + /** The paired host that runs this read, null for this app; only its cadence re-plans the target. */ + cadenceHost?: string | null +} + +/** + * Keeps the targets `build` last made and hands them to `apply`. A host's cadence change re-plans + * only that host's targets from their own Default interval; nothing is built again, so another + * host's change costs no row work however many rows are visible. Store and visibility changes still + * call `update` to rebuild the whole set. + */ +export function followOwningHostCadence( + build: () => readonly VisibleHostedReviewRefreshTarget[], + apply: (targets: readonly VisibleHostedReviewRefreshTarget[]) => void +): { update: () => void; dispose: () => void } { + let targets: readonly VisibleHostedReviewRefreshTarget[] = [] + let hosts = new Set() + const update = (): void => { + targets = build() + hosts = new Set(targets.map((target) => target.cadenceHost ?? null)) + apply(targets) + } + const dispose = subscribePRAutoRefreshCadence((hostKey) => { + if (!hosts.has(hostKey)) { + return + } + const cadence = getPRAutoRefreshCadenceForHost(hostKey) + targets = targets.map((target) => + (target.cadenceHost ?? null) !== hostKey || target.foregroundIntervalMs === undefined + ? target + : { + ...target, + cadence, + intervalMs: scaleReviewRefreshInterval(target.foregroundIntervalMs, cadence) + } + ) + apply(targets) + }) + return { update, dispose } +} diff --git a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.test.ts b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.test.ts index 1c362ed2503..ef2eea05d5e 100644 --- a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.test.ts +++ b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetPRAutoRefreshCadencesForTests, + setPRAutoRefreshCadenceForHost +} from '../../../../../../shared/review-refresh-policy' import { createVisibleHostedReviewRefreshScheduler, type VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler' +import { followOwningHostCadence } from './owning-host-cadence-replan' const schedulers: ReturnType[] = [] @@ -96,6 +101,177 @@ describe('visible hosted review scheduler', () => { } } ) + + it('does not retry a failed read on a timer when the owning host’s cadence is off', async () => { + const refresh = vi.fn(async () => false) + setup([target({ fetchedAt: null, intervalMs: null, cadence: 'off', selected: true, refresh })]) + await vi.advanceTimersByTimeAsync(0) + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('arms no receipt retry under off, including for a reply that lands after off was chosen', async () => { + let finish!: (result: { succeeded: boolean; retryAfterMs: number }) => void + const refresh = vi.fn( + () => + new Promise<{ succeeded: boolean; retryAfterMs: number }>((resolve) => (finish = resolve)) + ) + const row = target({ fetchedAt: Date.now() - 120_000, cadence: 'default', refresh }) + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(0) + scheduler.update([{ ...row, cadence: 'off', intervalMs: null }]) + finish({ succeeded: true, retryAfterMs: 10_000 }) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + describe('a cadence change reconciles a retry armed after a failure', () => { + it('drops a queued error retry when the owning host’s cadence becomes off', async () => { + const refresh = vi.fn(async () => false) + const row = target({ fetchedAt: null, intervalMs: 60_000, cadence: 'default', refresh }) + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(0) + expect(refresh).toHaveBeenCalledTimes(1) + scheduler.update([{ ...row, cadence: 'off', intervalMs: null }]) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('re-times a queued error retry to a stretched cadence', async () => { + const refresh = vi.fn(async () => false) + const row = target({ fetchedAt: null, intervalMs: 60_000, cadence: 'default', refresh }) + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(0) + expect(refresh).toHaveBeenCalledTimes(1) + scheduler.update([{ ...row, cadence: 'reduced', intervalMs: 5 * 60_000 }]) + await vi.advanceTimersByTimeAsync(4 * 60_000) + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000) + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it('arms a retry for a read that failed under off once the cadence is enabled again', async () => { + const refresh = vi.fn(async () => false) + const row = target({ fetchedAt: null, intervalMs: null, cadence: 'off', refresh }) + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(0) + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(refresh).toHaveBeenCalledTimes(1) + scheduler.update([{ ...row, cadence: 'default', intervalMs: 60_000 }]) + await vi.advanceTimersByTimeAsync(0) + expect(refresh).toHaveBeenCalledTimes(2) + }) + }) + + describe('window return under a stretched or stopped cadence', () => { + it('reads once the Default interval has passed, then waits the stretched interval', async () => { + const row = target({ + fetchedAt: Date.now(), + intervalMs: 5 * 60_000, + foregroundIntervalMs: 60_000, + cadence: 'reduced', + selected: true + }) + const scheduler = setup([row]) + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(30_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(row.refresh).not.toHaveBeenCalled() + + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(90_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(row.refresh).toHaveBeenCalledWith(true, expect.any(AbortSignal), 'discovery') + await vi.advanceTimersByTimeAsync(5 * 60_000 - 1_000) + expect(row.refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(row.refresh).toHaveBeenLastCalledWith(false, expect.any(AbortSignal), 'timer') + }) + + it('never re-reads a no-PR row on return faster than Default would under off', async () => { + const noPR = target({ intervalMs: null, foregroundIntervalMs: 15 * 60_000, cadence: 'off' }) + const scheduler = setup([noPR]) + for (let returns = 0; returns < 10; returns += 1) { + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(70_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + } + expect(noPR.refresh).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4 * 60_000) + scheduler.setVisible(false) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(noPR.refresh).toHaveBeenCalledWith(true, expect.any(AbortSignal), 'discovery') + }) + }) + + describe('a read that failed under a stretched cadence', () => { + // Minimal: the failure's automatic retry is 600 s out; Default would retry it after 60 s. + const failing = () => + target({ + fetchedAt: null, + intervalMs: 600_000, + foregroundIntervalMs: 60_000, + cadence: 'minimal', + selected: true, + refresh: vi.fn(async () => false) + }) + + it('does not hold back the next HEAD', async () => { + const row = failing() + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(60_000) + expect(row.refresh).toHaveBeenCalledTimes(1) + scheduler.update([{ ...row, revision: 'new-head|' }]) + await vi.advanceTimersByTimeAsync(0) + expect(row.refresh).toHaveBeenCalledTimes(2) + expect(row.refresh).toHaveBeenLastCalledWith(true, expect.any(AbortSignal), 'discovery') + }) + + it('reads on a return once Default would, not before', async () => { + const row = failing() + const scheduler = setup([row]) + await vi.advanceTimersByTimeAsync(0) + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(30_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(row.refresh).toHaveBeenCalledTimes(1) + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(32_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(row.refresh).toHaveBeenCalledTimes(2) + expect(row.refresh).toHaveBeenLastCalledWith(true, expect.any(AbortSignal), 'discovery') + }) + }) + + it('reads a row newly shown in a visible window once Default would, else on its own interval', async () => { + const scheduler = setup([]) + // Reduced reads every 300 s; Default every 60 s. One row's data is 120 s old, the other's 30 s. + const row = (key: string, ageMs: number) => + target({ + key, + fetchedAt: Date.now() - ageMs, + intervalMs: 300_000, + foregroundIntervalMs: 60_000, + cadence: 'reduced', + selected: true + }) + const old = row('old', 120_000) + const young = row('young', 30_000) + scheduler.update([old, young]) + await vi.advanceTimersByTimeAsync(0) + expect(old.refresh).toHaveBeenCalledWith(true, expect.any(AbortSignal), 'discovery') + expect(young.refresh).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(270_000) + expect(young.refresh).toHaveBeenCalledWith(false, expect.any(AbortSignal), 'timer') + }) }) it('limits refocus discovery to opted-in open GitHub rows and the existing stopped interval', async () => { @@ -122,3 +298,50 @@ it('limits refocus discovery to opted-in open GitHub rows and the existing stopp scheduler.dispose() vi.useRealTimers() }) + +describe('replanning on a host’s cadence change', () => { + afterEach(() => _resetPRAutoRefreshCadencesForTests()) + + it.each([750, 7500])( + 'builds no rows for a cadence change and re-plans only the changed host’s targets beside %i others', + (rows) => { + const aTargets = Array.from({ length: rows }, (_, row) => + target({ + key: `a-${row}`, + cadenceHost: 'env-a', + intervalMs: 120_000, + foregroundIntervalMs: 120_000 + }) + ) + const bTarget = target({ + key: 'b', + cadenceHost: 'env-b', + intervalMs: 120_000, + foregroundIntervalMs: 120_000 + }) + const build = vi.fn(() => [...aTargets, bTarget]) + const applied: (readonly VisibleHostedReviewRefreshTarget[])[] = [] + const following = followOwningHostCadence(build, (targets) => applied.push(targets)) + following.update() + + setPRAutoRefreshCadenceForHost('env-b', 'minimal') + expect(build).toHaveBeenCalledTimes(1) + const replanned = applied.at(-1)! + expect(replanned.slice(0, rows).every((row, index) => row === aTargets[index])).toBe(true) + expect(replanned[rows]).toMatchObject({ key: 'b', cadence: 'minimal', intervalMs: 1_200_000 }) + + setPRAutoRefreshCadenceForHost('env-b', 'minimal') + setPRAutoRefreshCadenceForHost('env-c', 'off') + expect(applied).toHaveLength(2) + + setPRAutoRefreshCadenceForHost('env-a', 'reduced') + expect(applied.at(-1)![0]).toMatchObject({ cadence: 'reduced', intervalMs: 600_000 }) + expect(applied.at(-1)![rows]).toBe(replanned[rows]) + + following.dispose() + setPRAutoRefreshCadenceForHost('env-b', 'off') + expect(applied).toHaveLength(3) + expect(build).toHaveBeenCalledTimes(1) + } + ) +}) diff --git a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.ts b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.ts index 56a05906ad1..7c7df5eed0f 100644 --- a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.ts +++ b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler.ts @@ -1,4 +1,5 @@ import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../../../shared/review-refresh-policy' +import type { OwningHostCadence } from './owning-host-cadence-replan' export type VisibleHostedReviewRefreshTarget = { key: string @@ -6,6 +7,9 @@ export type VisibleHostedReviewRefreshTarget = { aliasRevisions?: ReadonlyMap fetchedAt: number | null intervalMs: number | null + /** The interval under the Default cadence. When `intervalMs` is stretched or stopped, a window + * return still reads data this old: the cadence slows timers, not the user's return. */ + foregroundIntervalMs?: number | null selected: boolean refreshOnForeground?: boolean refresh: ( @@ -13,19 +17,45 @@ export type VisibleHostedReviewRefreshTarget = { signal?: AbortSignal, intent?: 'discovery' | 'timer' ) => Promise -} +} & OwningHostCadence export type VisibleHostedReviewRefreshResult = | boolean - | { succeeded: boolean; retryAfterMs?: number } + // retryDisabledUntil: the host's own hold on this request after a failed read (a Retry-After). + | { succeeded: boolean; retryAfterMs?: number; retryDisabledUntil?: number } type Entry = { target: VisibleHostedReviewRefreshTarget lastAttemptAt: number failures: number + /** When the last attempt failed; its retry is re-timed from it whenever the cadence changes. */ + failedAt: number | null retryAt: number | null discovery: boolean deferredUntil: number | null + /** The provider's hold on the current request: nothing reads it sooner, whatever earned it. */ + heldUntil: number | null +} + +// A newer result or another request identity ends the failure, its retry and its provider hold. +const NO_FAILURE = { failures: 0, failedAt: null, retryAt: null, heldUntil: null } as const + +/** The target's interval under the Default cadence (its own interval when it gives none). */ +function defaultIntervalMs(target: VisibleHostedReviewRefreshTarget): number | null { + return target.foregroundIntervalMs === undefined ? target.intervalMs : target.foregroundIntervalMs +} + +/** The timed retry a failed read earns under the entry's current cadence: none under "off" (a + * failed read waits for the next window return, selection or refresh), else the longer of the + * cadence interval (or the given one) and an exponential backoff. */ +function retryAtFor(entry: Entry, intervalMs = entry.target.intervalMs): number | null { + if (entry.failedAt === null || entry.failures === 0 || entry.target.cadence === 'off') { + return null + } + return ( + entry.failedAt + + Math.max(intervalMs ?? 0, Math.min(900_000, 60_000 * 2 ** Math.min(entry.failures - 1, 4))) + ) } function discoveryIdentityChanged( @@ -58,19 +88,19 @@ export function createVisibleHostedReviewRefreshScheduler() { let disposed = false const clearTimer = (): void => { - if (timer !== null) { - clearTimeout(timer) - } + clearTimeout(timer ?? undefined) timer = null } - const dueAt = (entry: Entry): number => { + const plannedAt = (entry: Entry): number => { const cooldown = entry.lastAttemptAt + REVIEW_REFRESH_COOLDOWN_MS if (entry.deferredUntil !== null && !entry.discovery) { return Math.max(cooldown, entry.deferredUntil) } if (entry.retryAt !== null) { - return Math.max(cooldown, entry.retryAt) + // Discovery (a return, a newly shown row) waits for a failure no longer than Default would. + const floor = entry.discovery ? retryAtFor(entry, defaultIntervalMs(entry.target)) : null + return Math.max(cooldown, Math.min(entry.retryAt, floor ?? entry.retryAt)) } if (entry.discovery) { return cooldown @@ -82,6 +112,9 @@ export function createVisibleHostedReviewRefreshScheduler() { return Math.max(cooldown, anchor + entry.target.intervalMs) } + // The plan only ever waits longer for a provider hold, and a hold alone never plans a read. + const dueAt = (entry: Entry): number => Math.max(plannedAt(entry), entry.heldUntil ?? -Infinity) + const remember = (key: string, entry: Entry): void => { recent.delete(key) recent.set(key, entry) @@ -99,7 +132,8 @@ export function createVisibleHostedReviewRefreshScheduler() { target: VisibleHostedReviewRefreshTarget, result: VisibleHostedReviewRefreshResult ): void => { - const succeeded = typeof result === 'boolean' ? result : result.succeeded + const reply: Exclude = + typeof result === 'boolean' ? { succeeded: result } : result const cancelled = inFlight.get(key)?.controller.signal.aborted inFlight.get(key)?.controller.abort() inFlight.delete(key) @@ -113,19 +147,21 @@ export function createVisibleHostedReviewRefreshScheduler() { entry.deferredUntil = null } else { entry.discovery = false - entry.failures = succeeded ? 0 : entry.failures + 1 - const retry = typeof result === 'boolean' ? undefined : result.retryAfterMs + entry.failures = reply.succeeded ? 0 : entry.failures + 1 + const retry = reply.retryAfterMs + // Read against the current target, so a reply arriving after Off was chosen arms nothing. entry.deferredUntil = - succeeded && retry !== undefined && Number.isFinite(retry) && retry >= 0 + reply.succeeded && + entry.target.cadence !== 'off' && + retry !== undefined && + Number.isFinite(retry) && + retry >= 0 ? Date.now() + Math.max(REVIEW_REFRESH_COOLDOWN_MS, retry) : null - entry.retryAt = succeeded - ? null - : Date.now() + - Math.max( - entry.target.intervalMs ?? 0, - Math.min(900_000, 60_000 * 2 ** Math.min(entry.failures - 1, 4)) - ) + entry.failedAt = reply.succeeded ? null : Date.now() + entry.retryAt = retryAtFor(entry) + const hold = reply.succeeded ? undefined : reply.retryDisabledUntil + entry.heldUntil = hold !== undefined && Number.isFinite(hold) ? hold : null } } reconcile() @@ -202,35 +238,53 @@ export function createVisibleHostedReviewRefreshScheduler() { if (!entry) { const previous = recent.get(target.key) recent.delete(target.key) + const changed = !!previous && discoveryIdentityChanged(previous.target, target) + // Another HEAD or link is another request, which the old one's failure does not hold. const preserveFailure = previous !== undefined && + !changed && (target.fetchedAt ?? -Infinity) <= (previous.target.fetchedAt ?? -Infinity) entry = { target, lastAttemptAt: previous?.lastAttemptAt ?? -Infinity, failures: preserveFailure ? previous.failures : 0, - retryAt: preserveFailure ? previous.retryAt : null, + failedAt: preserveFailure ? previous.failedAt : null, + retryAt: null, + heldUntil: preserveFailure ? previous.heldUntil : null, + // First shown: data older than Default's interval is read now, whatever the cadence. discovery: - (previous !== undefined && discoveryIdentityChanged(previous.target, target)) || + changed || target.fetchedAt === null || - Date.now() - target.fetchedAt >= (target.intervalMs ?? 60_000), + Date.now() - target.fetchedAt >= (defaultIntervalMs(target) ?? 60_000), deferredUntil: null } + // A kept failure retries by this target's cadence, not the one it failed under. + entry.retryAt = retryAtFor(entry) entries.set(target.key, entry) - } else if (discoveryIdentityChanged(entry.target, target)) { + continue + } + const cadenceChanged = + entry.target.cadence !== target.cadence || entry.target.intervalMs !== target.intervalMs + if (discoveryIdentityChanged(entry.target, target)) { entry.discovery = true entry.deferredUntil = null + Object.assign(entry, NO_FAILURE) } else if ( (target.fetchedAt ?? -Infinity) > (entry.target.fetchedAt ?? -Infinity) && (!inFlight.has(target.key) || !discoveryIdentityChanged(inFlight.get(target.key)?.target ?? target, target)) ) { - entry.failures = 0 - entry.retryAt = null - entry.discovery = false - entry.deferredUntil = null + Object.assign(entry, NO_FAILURE, { discovery: false, deferredUntil: null }) } entry.target = target + // The owning host's cadence changed: a retry armed under the old cadence is dropped under + // "off" and re-timed otherwise, so a stretched cadence never keeps the shorter retry. + if (cadenceChanged) { + entry.retryAt = retryAtFor(entry) + if (target.cadence === 'off') { + entry.deferredUntil = null + } + } } reconcile() }, @@ -248,11 +302,18 @@ export function createVisibleHostedReviewRefreshScheduler() { } } if (visible) { + const now = Date.now() for (const entry of entries.values()) { + const { intervalMs, fetchedAt, refreshOnForeground } = entry.target + const defaultMs = defaultIntervalMs(entry.target) + const returnAgeMs = Math.max(60_000, defaultMs ?? 60_000) + // A stretched or stopped cadence still reads on return, never sooner than Default would. + const stretched = defaultMs !== null && (intervalMs === null || defaultMs < intervalMs) if ( - (entry.target.intervalMs === null || entry.target.refreshOnForeground === true) && - Date.now() - (entry.target.fetchedAt ?? -Infinity) >= - Math.max(60_000, entry.target.intervalMs ?? 60_000) + stretched + ? now - Math.max(fetchedAt ?? -Infinity, entry.lastAttemptAt) >= returnAgeMs + : (intervalMs === null || refreshOnForeground === true) && + now - (fetchedAt ?? -Infinity) >= returnAgeMs ) { entry.discovery = true } diff --git a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts index 887a70e8e6e..e91dcb24d12 100644 --- a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts +++ b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts @@ -17,7 +17,11 @@ import { findIndexedDetectedWorktrees } from '@/lib/workspace/ownership/worktree import { findIndexedRepoOwnerForHost } from '@/lib/workspace/ownership/repo-owner-index' import { worktreeOwnerHostIds } from '@/lib/workspace/ownership/workspace-catalog-owner-index' import { findKnownWorktreeById } from '../../worktrees/listing/detected-worktree-meta' -import { getPRRefreshRuntimeRepoTarget } from '../../../github/repositories/repository-routing' +import { + getPRRefreshOwnerRuntimeEnvironmentId, + getPRRefreshRuntimeRepoTarget +} from '../../../github/repositories/repository-routing' +import { hostPRAutoRefreshCadence } from '../../../github/refresh/remote-host-pr-auto-refresh-cadence' import type { VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler' import { shouldCoordinateVisibleGitHubReview } from './visible-hosted-review-refresh-ownership' import { @@ -170,6 +174,15 @@ export function getVisibleHostedReviewRefreshTargets( prEntry.fetchedAt >= (hostedEntry?.fetchedAt ?? -Infinity) const review = usePR ? prEntry.data : hostedEntry?.data const fetchedAt = usePR ? prEntry.fetchedAt : (hostedEntry?.fetchedAt ?? null) + // The host that runs the read owns the cadence: a paired host's setting, else this app's. + const cadenceHost = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + const cadence = hostPRAutoRefreshCadence(cadenceHost) + const intervalInput = { + state: review?.state, + checksStatus: usePR ? prEntry.data?.checksStatus : hostedEntry?.data?.status, + hasReview: review ? true : fetchedAt !== null ? false : null, + selected + } const target: VisibleHostedReviewRefreshTarget = { key, revision: `${candidate.currentHeadOid ?? ''}|${linkedReviewHintKey(hints)}`, @@ -181,14 +194,12 @@ export function getVisibleHostedReviewRefreshTargets( ? null : fetchedAt, selected, + cadence, + cadenceHost, refreshOnForeground: !!runtime && knownGitHub && (review?.state === 'open' || review?.state === 'draft'), - intervalMs: reviewRefreshIntervalMs({ - state: review?.state, - checksStatus: usePR ? prEntry.data?.checksStatus : hostedEntry?.data?.status, - hasReview: review ? true : fetchedAt !== null ? false : null, - selected - }), + intervalMs: reviewRefreshIntervalMs(intervalInput, cadence), + foregroundIntervalMs: reviewRefreshIntervalMs(intervalInput, 'default'), refresh: async (force = true, signal, intent) => { let outcome: PRSnapshotReply | undefined const before = getState() @@ -224,9 +235,12 @@ export function getVisibleHostedReviewRefreshTargets( })) const after = getState() if (runtime && outcome) { + const failed = 'kind' in outcome && outcome.kind === 'upstream-error' ? outcome : null return { - succeeded: !('kind' in outcome) || outcome.kind !== 'upstream-error', - retryAfterMs: outcome.revalidation?.retryAfterMs + succeeded: !failed, + retryAfterMs: outcome.revalidation?.retryAfterMs, + // The host's Retry-After for this request: no automatic read or return goes sooner. + retryDisabledUntil: failed?.retryDisabledUntil } } const afterFetchedAt = knownGitHub diff --git a/src/renderer/src/store/store-observer-installers.ts b/src/renderer/src/store/store-observer-installers.ts index 9d03b5f60f5..13b846222c9 100644 --- a/src/renderer/src/store/store-observer-installers.ts +++ b/src/renderer/src/store/store-observer-installers.ts @@ -1,5 +1,6 @@ import type { AppState } from './types' import { installTerminalParkingInputs } from './terminals/parking/terminal-parking-input-transitions' +import { installPRAutoRefreshCadenceObserver } from './github/refresh/pr-auto-refresh-cadence-observer' export type StoreObserverHost = { getState: () => AppState @@ -10,5 +11,6 @@ export type StoreObserverHost = { export type StoreObserverInstaller = (store: StoreObserverHost) => void export const STORE_OBSERVER_INSTALLERS: readonly StoreObserverInstaller[] = [ - installTerminalParkingInputs + installTerminalParkingInputs, + installPRAutoRefreshCadenceObserver ] diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 7403d95fa1c..e67033a3698 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -172,6 +172,7 @@ export function buildDefaultSettings(args: { sourceControlViewMode: 'list', sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, + prAutoRefresh: 'default', showTitlebarAppName: true, showTasksButton: true, showAutomationsButton: true, @@ -299,12 +300,7 @@ export function buildDefaultSettings(args: { // Why: local desktop stays the default until the user picks a saved runtime environment. activeRuntimeEnvironmentId: null, // Why: hydrate a stable empty shape so renderer optional-chained reads never hit undefined. - githubProjects: { - pinned: [], - recent: [], - lastViewByProject: {}, - activeProject: null - }, + githubProjects: { pinned: [], recent: [], lastViewByProject: {}, activeProject: null }, // Why: keep agent/model maps empty so first use follows the default agent's model, not a frozen stale choice. commitMessageAi: { enabled: true, diff --git a/src/shared/github/checks/checks-detail-polling-policy-cadence.test.ts b/src/shared/github/checks/checks-detail-polling-policy-cadence.test.ts new file mode 100644 index 00000000000..599b6b454a0 --- /dev/null +++ b/src/shared/github/checks/checks-detail-polling-policy-cadence.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { ChecksDetailPollingPolicy } from './checks-detail-polling-policy' +import { setPRAutoRefreshCadence } from '../../review-refresh-policy' + +describe('checks detail polling under a PR auto-refresh cadence', () => { + afterEach(() => setPRAutoRefreshCadence('default')) + + it('keeps today’s failure retry by default', () => { + const policy = new ChecksDetailPollingPolicy() + policy.fail() + expect(policy.delayMs('open', 'pending', 120_000)).toBe(120_000) + expect(policy.delayMs('open', 'pending', 30_000)).toBe(60_000) + }) + + it('does not retry a failed read on a timer when off, in either host form', () => { + const policy = new ChecksDetailPollingPolicy() + policy.fail() + setPRAutoRefreshCadence('off') + expect(policy.delayMs('open', 'pending', 120_000)).toBeNull() + setPRAutoRefreshCadence('default') + expect(policy.delayMs('open', 'pending', 120_000, 'off')).toBeNull() + }) + + it('stretches the failure retry like every other timed read', () => { + const policy = new ChecksDetailPollingPolicy() + policy.fail() + expect(policy.delayMs('open', 'pending', 60_000, 'reduced')).toBe(5 * 60_000) + expect(policy.delayMs('open', 'pending', 60_000, 'minimal')).toBe(10 * 60_000) + }) + + it('follows the passed host cadence over the process cadence', () => { + const policy = new ChecksDetailPollingPolicy() + setPRAutoRefreshCadence('off') + // A paired host on Default keeps polling even though this app is off. + expect(policy.delayMs('open', 'pending', 60_000, 'default')).toBe(60_000) + }) +}) diff --git a/src/shared/github/checks/checks-detail-polling-policy.ts b/src/shared/github/checks/checks-detail-polling-policy.ts index 813a1e031e4..b57bbb32c5f 100644 --- a/src/shared/github/checks/checks-detail-polling-policy.ts +++ b/src/shared/github/checks/checks-detail-polling-policy.ts @@ -1,7 +1,12 @@ import type { PRCheckDetail } from './check-types' import type { CheckStatus } from '../pull-requests/pull-request-types' import type { HostedReviewState } from '../../source-control/hosted-review/hosted-review-contracts' -import { reviewRefreshIntervalMs } from '../../review-refresh-policy' +import { + getPRAutoRefreshCadence, + reviewRefreshIntervalMs, + scaleReviewRefreshInterval, + type PRAutoRefreshCadence +} from '../../review-refresh-policy' export function detailedChecksStatus(checks: readonly PRCheckDetail[]): CheckStatus | undefined { if (checks.length === 0) { @@ -64,21 +69,30 @@ export class ChecksDetailPollingPolicy { this.failed = true } + /** `cadence`: the setting of the host that reads these checks; null means no timed read, which + * under "off" includes the retry after a failure. */ delayMs( state: HostedReviewState | undefined, aggregateStatus: CheckStatus | undefined, - backoffMs: number + backoffMs: number, + cadence: PRAutoRefreshCadence = getPRAutoRefreshCadence() ): number | null { if (this.failed) { - return Math.max(state === 'closed' ? 900_000 : 60_000, backoffMs) + return scaleReviewRefreshInterval( + Math.max(state === 'closed' ? 900_000 : 60_000, backoffMs), + cadence + ) } const status = this.hasDetails && this.status !== undefined ? this.status : aggregateStatus - const interval = reviewRefreshIntervalMs({ - state, - checksStatus: status, - hasReview: true, - selected: true - }) + const interval = reviewRefreshIntervalMs( + { + state, + checksStatus: status, + hasReview: true, + selected: true + }, + cadence + ) if (interval === null) { return null } diff --git a/src/shared/global-settings-base.ts b/src/shared/global-settings-base.ts index 487c572b5d7..5ea70f0ed0e 100644 --- a/src/shared/global-settings-base.ts +++ b/src/shared/global-settings-base.ts @@ -25,6 +25,7 @@ import type { SshRemoteControlSettings } from './ssh-remote-control-settings' import type { AgentWorkspaceTrustSettings } from './agent-launch/permissions/agent-workspace-trust-decision' import type { KeybindingGlobalSettings } from './keybindings/global-settings' import type { WorkspaceCleanupPolicySettings } from './workspace-cleanup/workspace-cleanup-policy' +import type { PRAutoRefreshSettings } from './review-refresh-policy' /** Settings groups owned by their feature modules and composed into GlobalSettings. */ export type GlobalSettingsBase = AntigravityChatAuthSettings & @@ -53,4 +54,5 @@ export type GlobalSettingsBase = AntigravityChatAuthSettings & NestedWorkerDepthSettings & AgentWorkspaceTrustSettings & KeybindingGlobalSettings & - WorkspaceCleanupPolicySettings + WorkspaceCleanupPolicySettings & + PRAutoRefreshSettings diff --git a/src/shared/review-refresh-policy.test.ts b/src/shared/review-refresh-policy.test.ts new file mode 100644 index 00000000000..1a2bf70dde2 --- /dev/null +++ b/src/shared/review-refresh-policy.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetPRAutoRefreshCadencesForTests, + getPRAutoRefreshCadenceForHost, + setPRAutoRefreshCadenceForHost, + subscribePRAutoRefreshCadence, + getPRAutoRefreshCadence, + normalizePRAutoRefreshCadence, + reviewRefreshIntervalMs, + scaleReviewRefreshInterval, + setPRAutoRefreshCadence +} from './review-refresh-policy' + +describe('review refresh cadence', () => { + afterEach(() => { + setPRAutoRefreshCadence('default') + }) + + it('keeps today’s intervals by default', () => { + expect(reviewRefreshIntervalMs({ state: 'open', selected: true })).toBe(60_000) + expect(reviewRefreshIntervalMs({ state: 'open', selected: false })).toBe(120_000) + expect(reviewRefreshIntervalMs({ hasReview: false, selected: false })).toBe(15 * 60_000) + }) + + it('stretches every timed interval for reduced and minimal', () => { + expect(reviewRefreshIntervalMs({ state: 'open', selected: true }, 'reduced')).toBe(5 * 60_000) + expect(reviewRefreshIntervalMs({ state: 'open', selected: false }, 'reduced')).toBe(10 * 60_000) + expect(reviewRefreshIntervalMs({ state: 'open', selected: true }, 'minimal')).toBe(10 * 60_000) + expect(reviewRefreshIntervalMs({ state: 'open', selected: false }, 'minimal')).toBe(20 * 60_000) + }) + + it('turns every timed interval off, and keeps settled merged reviews stopped', () => { + expect(reviewRefreshIntervalMs({ state: 'open', selected: true }, 'off')).toBeNull() + expect(reviewRefreshIntervalMs({ hasReview: false, selected: true }, 'off')).toBeNull() + expect( + reviewRefreshIntervalMs({ state: 'merged', checksStatus: 'success' }, 'reduced') + ).toBeNull() + expect(scaleReviewRefreshInterval(null, 'minimal')).toBeNull() + }) + + it('follows the process cadence when no cadence is passed', () => { + setPRAutoRefreshCadence('minimal') + expect(getPRAutoRefreshCadence()).toBe('minimal') + expect(reviewRefreshIntervalMs({ state: 'open', selected: true })).toBe(10 * 60_000) + }) + + it('treats unknown or missing values as default', () => { + expect(normalizePRAutoRefreshCadence(undefined)).toBe('default') + expect(normalizePRAutoRefreshCadence('sometimes')).toBe('default') + setPRAutoRefreshCadence(42) + expect(getPRAutoRefreshCadence()).toBe('default') + }) +}) + +describe('per-host PR auto-refresh cadence', () => { + afterEach(() => { + _resetPRAutoRefreshCadencesForTests() + }) + + it('reads local work by the process cadence and an unknown host as default', () => { + setPRAutoRefreshCadence('off') + expect(getPRAutoRefreshCadenceForHost(null)).toBe('off') + // A local "off" must not stop a paired host whose own setting is default. + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('default') + expect( + reviewRefreshIntervalMs({ selected: true }, getPRAutoRefreshCadenceForHost('env-1')) + ).toBe(60_000) + }) + + it('keeps each paired host’s own setting and forgets it when asked', () => { + setPRAutoRefreshCadenceForHost('env-1', 'minimal') + setPRAutoRefreshCadenceForHost('env-2', 'reduced') + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('minimal') + expect(getPRAutoRefreshCadenceForHost('env-2')).toBe('reduced') + expect(getPRAutoRefreshCadence()).toBe('default') + setPRAutoRefreshCadenceForHost('env-1', null) + expect(getPRAutoRefreshCadenceForHost('env-1')).toBe('default') + }) + + it('notifies subscribers once per actual change, naming the host that changed', () => { + const seen: (string | null)[] = [] + const unsubscribe = subscribePRAutoRefreshCadence((hostKey) => seen.push(hostKey)) + setPRAutoRefreshCadence('reduced') + setPRAutoRefreshCadence('reduced') + setPRAutoRefreshCadenceForHost('env-1', 'off') + setPRAutoRefreshCadenceForHost('env-1', 'off') + unsubscribe() + setPRAutoRefreshCadence('off') + expect(seen).toEqual([null, 'env-1']) + }) +}) + +describe('foreground exposure freshness', () => { + afterEach(() => { + _resetPRAutoRefreshCadencesForTests() + }) + + it.each(['default', 'reduced', 'minimal', 'off'] as const)( + 'compares a window return against the Default interval when the cadence is %s', + async (cadence) => { + const { reviewForegroundFreshnessMs, setPRAutoRefreshCadence } = + await import('./review-refresh-policy') + setPRAutoRefreshCadence(cadence) + expect(reviewForegroundFreshnessMs({ selected: true })).toBe(60_000) + expect(reviewForegroundFreshnessMs({ selected: false })).toBe(120_000) + expect(reviewForegroundFreshnessMs({ hasReview: false, selected: false })).toBe(15 * 60_000) + // Default runs no timer for settled data: Default keeps its cooldown, others wait a minute. + expect(reviewForegroundFreshnessMs({ state: 'merged', checksStatus: 'success' })).toBe( + cadence === 'default' ? 10_000 : 60_000 + ) + } + ) +}) diff --git a/src/shared/review-refresh-policy.ts b/src/shared/review-refresh-policy.ts index c6076569559..a5ae5816042 100644 --- a/src/shared/review-refresh-policy.ts +++ b/src/shared/review-refresh-policy.ts @@ -4,7 +4,126 @@ import type { CheckStatus } from './github/pull-requests/pull-request-types' export const REVIEW_REFRESH_COOLDOWN_MS = 10_000 export const CLOSED_REVIEW_REFRESH_INTERVAL_MS = 15 * 60_000 -export function reviewRefreshIntervalMs(input: { +/** How often PR/review state refreshes on a timer. Pushing and manual refresh still read at once in + * every mode, and returning to the window reads data older than its Default interval. */ +export const PR_AUTO_REFRESH_CADENCES = ['default', 'reduced', 'minimal', 'off'] as const +export type PRAutoRefreshCadence = (typeof PR_AUTO_REFRESH_CADENCES)[number] + +/** The global-settings group this feature owns, composed into GlobalSettings. */ +export type PRAutoRefreshSettings = { + /** Timed PR/review/checks refresh cadence on this host; absent means 'default'. */ + prAutoRefresh?: PRAutoRefreshCadence +} + +const CADENCE_INTERVAL_FACTOR: Record, number> = { + default: 1, + reduced: 5, + minimal: 10 +} + +function isPRAutoRefreshCadence(value: unknown): value is PRAutoRefreshCadence { + return PR_AUTO_REFRESH_CADENCES.some((cadence) => cadence === value) +} + +export function normalizePRAutoRefreshCadence(value: unknown): PRAutoRefreshCadence { + return isPRAutoRefreshCadence(value) ? value : 'default' +} + +// Why one cadence per execution host: the setting belongs to the host that runs the GitHub reads +// and is presented that way ("applies to and every client connected to it"). Each process +// keeps its own host's setting as the process cadence; a client that schedules work for a paired +// host keeps that host's setting beside it, keyed by environment id, and never lets its own +// setting slow or speed another host's timers. A host whose setting is unknown is read as Default, +// which is also what the host itself enforces for that client's timed reads. +let processCadence: PRAutoRefreshCadence = 'default' +const hostCadences = new Map() +/** Called with the host whose cadence changed: null for this process's own setting. */ +export type PRAutoRefreshCadenceListener = (hostKey: string | null) => void +const cadenceListeners = new Set() + +function notifyCadenceChanged(hostKey: string | null): void { + // A snapshot: a listener may unsubscribe itself or another while we iterate. + for (const listener of Array.from(cadenceListeners)) { + listener(hostKey) + } +} + +export function setPRAutoRefreshCadence(value: unknown): void { + const next = normalizePRAutoRefreshCadence(value) + if (next === processCadence) { + return + } + processCadence = next + notifyCadenceChanged(null) +} + +export function getPRAutoRefreshCadence(): PRAutoRefreshCadence { + return processCadence +} + +/** Remember a paired host's own setting; null forgets it (the host is gone or unpaired). */ +export function setPRAutoRefreshCadenceForHost(hostKey: string, value: unknown): void { + const before = hostCadences.get(hostKey) + if (value === null || value === undefined) { + if (before === undefined) { + return + } + hostCadences.delete(hostKey) + } else { + const next = normalizePRAutoRefreshCadence(value) + if (next === before) { + return + } + hostCadences.set(hostKey, next) + } + notifyCadenceChanged(hostKey) +} + +/** The cadence that governs work on `hostKey`: this process's own setting for local work (null), + * the remembered setting of a paired host, or Default while that host has not said. */ +export function getPRAutoRefreshCadenceForHost( + hostKey: string | null | undefined +): PRAutoRefreshCadence { + if (hostKey === null || hostKey === undefined) { + return processCadence + } + return hostCadences.get(hostKey) ?? 'default' +} + +/** A paired host's remembered setting, or null while that host has not said. Schedulers use + * `getPRAutoRefreshCadenceForHost` (Default meanwhile); a control showing the host's setting + * must not present Default as an answer the host never gave. */ +export function getKnownPRAutoRefreshCadenceForHost(hostKey: string): PRAutoRefreshCadence | null { + return hostCadences.get(hostKey) ?? null +} + +/** Fires after any cadence (this process's or a remembered host's) changes, naming the host, so a + * mounted timer can replan the work that host runs instead of waiting for its next tick. */ +export function subscribePRAutoRefreshCadence(listener: PRAutoRefreshCadenceListener): () => void { + cadenceListeners.add(listener) + return () => { + cadenceListeners.delete(listener) + } +} + +export function _resetPRAutoRefreshCadencesForTests(): void { + processCadence = 'default' + hostCadences.clear() + cadenceListeners.clear() +} + +/** Stretch a timed interval by the cadence; null means no timed refresh. */ +export function scaleReviewRefreshInterval( + intervalMs: number | null, + cadence: PRAutoRefreshCadence = processCadence +): number | null { + if (intervalMs === null || cadence === 'off') { + return null + } + return intervalMs * CADENCE_INTERVAL_FACTOR[cadence] +} + +function defaultReviewRefreshIntervalMs(input: { state?: HostedReviewState | null checksStatus?: CheckStatus | null hasReview?: boolean | null @@ -22,3 +141,31 @@ export function reviewRefreshIntervalMs(input: { } return input.selected ? 60_000 : 120_000 } + +export function reviewRefreshIntervalMs( + input: { + state?: HostedReviewState | null + checksStatus?: CheckStatus | null + hasReview?: boolean | null + selected?: boolean + }, + cadence: PRAutoRefreshCadence = processCadence +): number | null { + return scaleReviewRefreshInterval(defaultReviewRefreshIntervalMs(input), cadence) +} + +/** How old settled data (no timer even on Default) must be before a foreground exposure reads it: + * Default keeps its short cooldown, a stretched or stopped cadence waits at least a minute. */ +export function settledForegroundAgeMs(cadence: PRAutoRefreshCadence = processCadence): number { + return cadence === 'default' ? REVIEW_REFRESH_COOLDOWN_MS : 60_000 +} + +/** How old data must be before a foreground exposure (the window coming back, a row shown again) + * reads it again: the Default interval in every mode, since a cadence slows the timers, never the + * user's return, and never makes it faster than Default. */ +export function reviewForegroundFreshnessMs( + input: Parameters[0], + cadence: PRAutoRefreshCadence = processCadence +): number { + return defaultReviewRefreshIntervalMs(input) ?? settledForegroundAgeMs(cadence) +} diff --git a/src/shared/rpc-contract/client-ui/client-settings-params.ts b/src/shared/rpc-contract/client-ui/client-settings-params.ts index 067ee832fc6..a8302ba6db0 100644 --- a/src/shared/rpc-contract/client-ui/client-settings-params.ts +++ b/src/shared/rpc-contract/client-ui/client-settings-params.ts @@ -12,6 +12,7 @@ import { normalizePRBotAuthorOverrides } from '../../source-control/review-comme import { WorktreeVisibilityDefaultsUpdate } from '../workspaces/worktree-visibility-defaults-params' import { WorkspaceCleanupRulesParam } from '../workspace-cleanup/workspace-cleanup-policy-params' import { AGENT_CHAT_PERMISSION_MODES } from '../../agent-launch/permissions/agent-chat-permission-mode' +import { PR_AUTO_REFRESH_CADENCES } from '../../review-refresh-policy' export const TaskProviderParam = z.custom(isTaskProvider, { message: 'Unknown task provider' @@ -133,7 +134,8 @@ export const SettingsUpdate = z .optional(), nativeChatAutoContinueAfterUsageLimit: z.boolean().optional(), nativeChatPermissionMode: z.enum(AGENT_CHAT_PERMISSION_MODES).optional(), - workspaceCleanupPolicy: WorkspaceCleanupRulesParam.optional() + workspaceCleanupPolicy: WorkspaceCleanupRulesParam.optional(), + prAutoRefresh: z.enum(PR_AUTO_REFRESH_CADENCES).optional() }) .strict() .default({}) diff --git a/src/shared/rpc-contract/github/github-pull-request-params.ts b/src/shared/rpc-contract/github/github-pull-request-params.ts index 2573a5e20af..ca98494f297 100644 --- a/src/shared/rpc-contract/github/github-pull-request-params.ts +++ b/src/shared/rpc-contract/github/github-pull-request-params.ts @@ -43,7 +43,9 @@ export const PRCommentReaction = RepoSelector.extend({ export const PullRequestChecks = PullRequest.extend({ headSha: OptionalString, - userRefresh: z.boolean().optional() + userRefresh: z.boolean().optional(), + /** `swr`: a client's timer; the host's PR auto-refresh cadence may answer from its last read. */ + reason: OptionalPRRefreshReason }) export const PullRequestCheckDetails = RepoSelector.extend({ diff --git a/tests/e2e/github-pr-hosted-receipt-loop.unit.test.ts b/tests/e2e/github-pr-hosted-receipt-loop.unit.test.ts index 24610ba73a5..53d8b1e320f 100644 --- a/tests/e2e/github-pr-hosted-receipt-loop.unit.test.ts +++ b/tests/e2e/github-pr-hosted-receipt-loop.unit.test.ts @@ -282,3 +282,125 @@ it('applies data-bearing retry replies to both clients and sends selection after } } }) + +it('holds a return under Minimal until the host’s real Retry-After on the request, then reads once', async () => { + const { + createTestStore, + makePRRefreshWorktree, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall + } = await import('../../src/renderer/src/store/github/github-slice-test-harness') + const { getVisibleHostedReviewRefreshTargets } = + await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets') + const { createVisibleHostedReviewRefreshScheduler } = + await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler') + const { PrForBranch } = + await import('../../src/shared/rpc-contract/github/github-pull-request-params') + const policy = await import('../../src/shared/review-refresh-policy') + const coordinator = + await import('../../src/main/github/pull-requests/refresh/pr-refresh-coordinator') + const seed = await commands.getRepoPRForBranch( + 'repo', + 'held', + 12, + null, + false, + 'head-a', + 'active' + ) + if (!('kind' in seed) || seed.kind !== 'found') { + throw new Error('expected the initial PR') + } + resetRemoteRuntimeMocks() + const store = createTestStore() + store.setState({ + repos: [ + { + id: 'repo', + path: '/repo', + kind: 'git', + executionHostId: 'runtime:env-1', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 0 + } + ], + worktreesByRepo: { + repo: [ + makePRRefreshWorktree({ + id: 'held-worktree', + repoId: 'repo', + branch: 'held', + head: 'head-a', + linkedPR: 12, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + }, + detectedWorktreesByRepo: {}, + activeWorktreeId: 'held-worktree', + activeWorkspaceExecutionHostId: 'runtime:env-1', + visibleReviewWorktreeIds: ['held-worktree'], + prCache: { 'runtime:env-1::repo::held': { data: seed.pr, fetchedAt: 1_000 } } + }) + // The host runs Minimal: its timer reads every 600 s, Default would every 60 s. + policy.setPRAutoRefreshCadenceForHost('env-1', 'minimal') + const reasons: string[] = [] + runtimeEnvironmentCall.mockImplementation(async (args) => { + if (args.method !== 'github.prForBranch') { + throw new Error('unexpected fixture RPC') + } + reasons.push(PrForBranch.parse(args.params).reason ?? 'missing') + return { id: 'rpc', ok: true, result: await rpcRead(args.params) } + }) + const observed: string[] = [] + const removeObserver = coordinator.addPRRefreshOutcomeObserver((_candidate, outcome) => + observed.push(outcome.kind) + ) + const scheduler = createVisibleHostedReviewRefreshScheduler() + const update = () => + scheduler.update(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)) + const unsubscribe = store.subscribe(update) + try { + // Shown at 61 s with data a Default interval old, it is read at once; GitHub answers with a + // secondary rate limit and a 300 s Retry-After, while the primary quota stays healthy. + await vi.advanceTimersByTimeAsync(60_000) + expect(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)).toMatchObject([ + { selected: true, cadence: 'minimal', intervalMs: 600_000, foregroundIntervalMs: 60_000 } + ]) + mocks.resolve.mockRejectedValueOnce( + Object.assign(new Error('gh: HTTP 429'), { + stderr: 'HTTP 429: You have exceeded a secondary rate limit.\nRetry-After: 300' + }) + ) + update() + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(reasons).toEqual(['active']) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + const observedAfterFailure = observed.length + + // Hidden, then back at 122 s: Default would retry the failure from 121 s, but the host holds + // this request until 361 s, so neither the return nor anything else reads it sooner. + scheduler.setVisible(false) + await vi.advanceTimersByTimeAsync(61_000) + scheduler.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(reasons).toEqual(['active']) + await vi.advanceTimersByTimeAsync(238_999) + expect(reasons).toEqual(['active']) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + expect(observed).toHaveLength(observedAfterFailure) + + // The owed return reads once, as soon as the hold ends. + await vi.advanceTimersByTimeAsync(1) + expect(reasons).toEqual(['active', 'active']) + expect(mocks.resolve).toHaveBeenCalledTimes(3) + } finally { + removeObserver() + unsubscribe() + scheduler.dispose() + policy._resetPRAutoRefreshCadencesForTests() + } +}) diff --git a/tests/e2e/pr-auto-refresh-setting-order.unit.test.ts b/tests/e2e/pr-auto-refresh-setting-order.unit.test.ts new file mode 100644 index 00000000000..03fad47703b --- /dev/null +++ b/tests/e2e/pr-auto-refresh-setting-order.unit.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../src/shared/global-settings-types' +import type { RuntimeClientEvent } from '../../src/shared/runtime-client-events' +import { TooltipProvider } from '@/components/ui/tooltip' + +// The host is the real settings controller over an in-memory profile store, publishing its real +// settings-change events; its agent-view reconciliation is a finite step the test releases. The +// client is the real Settings control, host-cadence sync and client-event handler. Between them, +// unary answers travel in the order the host produced them, and can be held at that boundary; the +// event stream is a separate connection, so an event can arrive before a held answer. +const wire = vi.hoisted(() => ({ + call: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture starts without a handler and registers this callback shape during mount. + eventHandlers: null as null | { onResponse: (response: unknown) => void }, + reconcile: vi.fn(async () => {}) +})) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: wire.call })) +vi.mock('@/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh', () => ({ + subscribeRuntimeEnvironment: async ( + _request: unknown, + handlers: { onResponse: (response: unknown) => void } + ) => { + wire.eventHandlers = handlers + return { unsubscribe: () => (wire.eventHandlers = null) } + } +})) +vi.mock('@/store/retained-views/app-state-view', () => ({ + useAppStateValue: (selector: (state: unknown) => unknown) => + selector({ + settingsSearchQuery: '', + runtimeEnvironments: [{ id: 'env-a', name: 'Boston build server' }] + }) +})) +vi.mock('@/store/index', async () => { + const { useStore } = await import('zustand') + const { createStore } = await import('zustand/vanilla') + const store = createStore(() => ({ settingsSearchQuery: '' })) + return { + useAppStore: Object.assign((select: (s: unknown) => unknown) => useStore(store, select), store) + } +}) +vi.mock('../../src/main/telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../src/main/claude/agent-view/agent-view-service', () => ({ + getClaudeAgentViewService: () => ({ reconcile: wire.reconcile }) +})) +vi.mock('../../src/main/agent-hooks/installation/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: vi.fn(async () => {}) +})) + +let root: Root +let container: HTMLDivElement +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + act(() => root.unmount()) + document.body.replaceChildren() + const { _resetPRAutoRefreshCadencesForTests } = + await import('../../src/shared/review-refresh-policy') + const { _resetHostPRAutoRefreshCadenceGenerationsForTests } = + await import('../../src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence') + _resetPRAutoRefreshCadencesForTests() + _resetHostPRAutoRefreshCadenceGenerationsForTests() +}) + +async function host() { + const { getDefaultSettings } = await import('../../src/shared/constants') + const { RuntimeClientSettingsController } = + await import('../../src/main/runtime/client/settings/runtime-client-settings') + const { publishSettingsChanges } = + await import('../../src/main/runtime/client/settings/runtime-settings-change-events') + let settings: GlobalSettings = { ...getDefaultSettings('/home/host'), prAutoRefresh: 'default' } + const listeners = new Set<(updates: Partial, next: GlobalSettings) => void>() + const store = { + getSettings: () => settings, + updateSettings: (updates: Partial, options?: { notifyListeners?: boolean }) => { + settings = { ...settings, ...updates } + if (options?.notifyListeners !== false) { + listeners.forEach((listener) => listener(updates, settings)) + } + }, + onSettingsChanged: ( + listener: (updates: Partial, next: GlobalSettings) => void + ) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + runDurableMutation: async (mutate: () => T) => mutate() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller uses only these store members for settings reads and writes. + const controller = new RuntimeClientSettingsController(store as never) + const events: RuntimeClientEvent[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the publisher reads only getSettings and onSettingsChanged. + publishSettingsChanges(store as never, (event) => events.push(event)) + return { controller, events, current: () => settings.prAutoRefresh } +} + +it('converges after a real late write response revokes a later settings event read', async () => { + const { i18n } = await import('../../src/renderer/src/i18n/i18n') + await i18n.changeLanguage('en') + const H = await host() + // Answers leave the host as it produces them; while `held`, they wait at the connection. + const answers: { result: unknown; deliver: (result: unknown) => void }[] = [] + let held = false + const pump = () => { + while (!held && answers.length > 0) { + const answer = answers.shift()! + answer.deliver(answer.result) + } + } + const produced: Record = { update: [], get: [] } + wire.call.mockImplementation( + (_target: unknown, method: string, params?: Pick) => + new Promise((deliver) => { + const answer = + method === 'settings.update' + ? H.controller.update({ prAutoRefresh: params!.prAutoRefresh }) + : Promise.resolve(H.controller.get()) + void answer.then((settings) => { + produced[method === 'settings.update' ? 'update' : 'get'].push(settings.prAutoRefresh) + answers.push({ result: { settings }, deliver }) + pump() + }) + }) + ) + const deliverEvents = () => { + for (const event of H.events.splice(0)) { + wire.eventHandlers?.onResponse({ id: 'events', ok: true, result: event }) + } + } + const { subscribeRuntimeClientEvents } = + await import('../../src/renderer/src/runtime/rpc/subscriptions/runtime-client-events') + const { _hostPRAutoRefreshReadsInFlightForTests } = + await import('../../src/renderer/src/store/github/refresh/remote-host-pr-auto-refresh-cadence-sync') + const { getKnownPRAutoRefreshCadenceForHost } = + await import('../../src/shared/review-refresh-policy') + const { PRAutoRefreshSetting } = + await import('../../src/renderer/src/components/settings/source-control/PRAutoRefreshSetting') + const subscription = await subscribeRuntimeClientEvents('env-a', () => {}) + await act(async () => + root.render( + createElement( + TooltipProvider, + null, + createElement(PRAutoRefreshSetting, { + hostScope: { target: { kind: 'environment', environmentId: 'env-a' }, available: true }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a remote host never reads the local settings. + settings: {} as never, + updateSettings: () => {} + }) + ) + ) + ) + await act(async () => {}) + const options = () => + Array.from(container.querySelectorAll('button[role="radio"]')) + const selected = () => + options().findIndex((button) => button.getAttribute('aria-checked') === 'true') + expect(selected()).toBe(0) + + // 1. This client chooses Reduced (A). The host writes it and announces it (EA) while its + // agent-view reconciliation is still running; EA's read (RA) answers Reduced meanwhile. + const reconciliation = Promise.withResolvers() + wire.reconcile.mockImplementationOnce(() => reconciliation.promise) + const reduced = options()[1] + await act(async () => reduced.click()) + await act(async () => deliverEvents()) + await act(async () => {}) + expect(produced.get).toEqual(['default', 'reduced']) + + // 2. Reconciliation ends; A's answer (the host's value then: Reduced) waits at the connection. + held = true + await act(async () => reconciliation.resolve()) + await act(async () => {}) + expect(produced.update).toEqual(['reduced']) + + // 3. Another client sets Off (B). Its event (EB) reaches this client on the event connection and + // starts a read (RB), whose answer (Off) queues behind A's. + await act(async () => { + await H.controller.update({ prAutoRefresh: 'off' }) + deliverEvents() + }) + await act(async () => {}) + expect(H.current()).toBe('off') + expect(_hostPRAutoRefreshReadsInFlightForTests()).toBe(1) + + // 4. The connection delivers A, then RB, in the order the host produced them. + held = false + await act(async () => pump()) + for (let turn = 0; turn < 5; turn += 1) { + await act(async () => {}) + } + + expect(produced.update).toEqual(['reduced']) + expect(produced.get.slice(0, 3)).toEqual(['default', 'reduced', 'off']) + expect(getKnownPRAutoRefreshCadenceForHost('env-a')).toBe('off') + expect(selected()).toBe(2) + expect(_hostPRAutoRefreshReadsInFlightForTests()).toBe(0) + subscription.unsubscribe() +}) diff --git a/tests/e2e/pr-refresh-actual-completion.unit.test.ts b/tests/e2e/pr-refresh-actual-completion.unit.test.ts index eb2031837bc..dd6ca3b1e9e 100644 --- a/tests/e2e/pr-refresh-actual-completion.unit.test.ts +++ b/tests/e2e/pr-refresh-actual-completion.unit.test.ts @@ -128,12 +128,12 @@ it.each(['queued', 'immediate'] as const)( immediate = coordinator.refreshPRNow(candidate, 'active') } await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(5_000) + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) await expect(foreground(origin === 'immediate')).resolves.toMatchObject({ fetchedAt: 5_000, pr: { title: 'current' } }) - vi.setSystemTime(6_000) + await vi.advanceTimersByTimeAsync(6_000 - Date.now()) old.resolve({ kind: 'no-pr', fetchedAt: 6_000 }) await immediate await vi.advanceTimersByTimeAsync(0) @@ -171,9 +171,9 @@ it('keeps the accepted external failure and gate when the old local success comp })) coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(5_000) + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) await expect(foreground()).resolves.toMatchObject({ errorType: 'rate_limited' }) - vi.setSystemTime(6_000) + await vi.advanceTimersByTimeAsync(6_000 - Date.now()) old.resolve(found('obsolete')) await vi.advanceTimersByTimeAsync(0) expect(store.getState().prCache[candidate.cacheKey]?.data).toBeUndefined() @@ -205,10 +205,10 @@ it.each(['queued', 'immediate'] as const)( coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) const immediate = origin === 'immediate' ? coordinator.refreshPRNow(candidate, 'active') : null await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(5_000) + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) const external = foreground(origin === 'immediate') await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(6_000) + await vi.advanceTimersByTimeAsync(6_000 - Date.now()) old.resolve({ kind: 'no-pr', fetchedAt: 6_000 }) if (immediate) { await expect(immediate).resolves.toEqual({ @@ -246,10 +246,10 @@ it('keeps rejected raw data out of the actual local action reply writer', async force: true }) await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(5_000) + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) const external = foreground(true) await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(6_000) + await vi.advanceTimersByTimeAsync(6_000 - Date.now()) old.resolve({ kind: 'no-pr', fetchedAt: 6_000 }) await expect(action).resolves.toMatchObject({ title: 'displayed' }) expect(store.getState().prCache[candidate.cacheKey]).toMatchObject({ @@ -260,12 +260,7 @@ it('keeps rejected raw data out of the actual local action reply writer', async await vi.advanceTimersByTimeAsync(1_000) fresh.resolve(found('current')) await external - await vi.advanceTimersByTimeAsync(4_000) - expect(store.getState().prCache[candidate.cacheKey]).toMatchObject({ - fetchedAt: 500, - data: { title: 'displayed' } - }) - await vi.advanceTimersByTimeAsync(109_499) + await vi.advanceTimersByTimeAsync(3_999) expect(store.getState().prCache[candidate.cacheKey]).toMatchObject({ fetchedAt: 500, data: { title: 'displayed' } @@ -275,6 +270,13 @@ it('keeps rejected raw data out of the actual local action reply writer', async fetchedAt: 7_000, data: { title: 'current' } }) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(109_500) + expect(store.getState().prCache[candidate.cacheKey]).toMatchObject({ + fetchedAt: 7_000, + data: { title: 'current' } + }) + expect(mocks.resolve).toHaveBeenCalledTimes(2) }) it.each([ @@ -292,10 +294,10 @@ it.each([ try { coordinator.reportVisiblePRRefreshCandidates([candidate], 1, 1) await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(5_000) + await vi.advanceTimersByTimeAsync(5_000 - Date.now()) const external = foreground() await vi.advanceTimersByTimeAsync(0) - vi.setSystemTime(6_000) + await vi.advanceTimersByTimeAsync(6_000 - Date.now()) old.resolve({ kind: 'no-pr', fetchedAt: 6_000 }) await vi.advanceTimersByTimeAsync(0) expect(store.getState().prRefreshStates[candidate.cacheKey]?.status).toBe('skipped') @@ -322,11 +324,27 @@ it.each([ } expect(mocks.resolve).toHaveBeenCalledTimes(2) expect(observe).toHaveBeenCalledTimes(1) - expect(store.getState().prRefreshStates[candidate.cacheKey]).toMatchObject({ - status: 'error', + const { preparePRBranchRead } = + await import('../../src/main/github/client/lookup/pr-for-branch-outcome') + const prepared = await preparePRBranchRead('/repo', candidate.branch, null, null, null, { + currentHeadOid: 'head-sha' + }) + expect(prepared.cached()?.outcome).toMatchObject({ + kind: 'upstream-error', + fetchedAt: failureAt, nextAutoRetryAt: retryAt, retryDisabledUntil: retryAt }) + if (duringQuota) { + expect(store.getState().prRefreshStates[candidate.cacheKey]).toMatchObject({ + status: 'error', + nextAutoRetryAt: retryAt, + retryDisabledUntil: retryAt + }) + } else { + expect(store.getState().prRefreshStates[candidate.cacheKey]?.status).toBe('skipped') + expect(store.getState().prCache[candidate.cacheKey]).toBeUndefined() + } await vi.advanceTimersByTimeAsync(remaining) expect(mocks.resolve).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(1)