mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
feat(github): PR auto-refresh cadence setting to cap background PR/checks polling (STA-8667) (#40541)
* feat(github): add a PR auto-refresh cadence setting that caps background PR/checks polling (STA-8667) Timed PR, review and checks refreshes were hardcoded (1/2 min visible, 15 min no-review), with no way to slow them on a busy headless `orca serve` or a shared GitHub Enterprise Server. Adds `prAutoRefresh: default | reduced | minimal | off` (5x / 10x / no timed refresh). Selecting a workspace, pushing, returning to the window and manual refresh still read at once in every mode. - shared policy scales every timed interval from one process-wide cadence, set from settings in main (desktop and serve) and in the renderer - main re-plans queued visible follow-ups when the cadence changes - a paired server's cadence also caps clients' timed `github.prForBranch` reads (reuses the last answer for the same head) and stretches the hosted review branch cache TTLs, so more clients add no GitHub calls - CI-line watches slow to the minimal cadence instead of stopping when off - Settings > Git & Source Control gains a host-aware PR Auto-Refresh control * feat(github): make the PR Auto-Refresh control say how often it checks and whose host it changes (STA-8667) UX round UX-I1: the hint under the segmented control states the actual cadence for the selection, derived from the shared policy, and when a paired server is the Settings host the control says it applies to that host and every connected client. Adds a presentational PRAutoRefreshControl with fixtures and a component test. * fix(github): make the PR auto-refresh cadence per execution host and close the C1 polling gaps (STA-8667) Review round C1 on #40541 found seven places where the new cadence did not hold. This settles the contract as one cadence per execution host and fixes each gap with a regression test: - The renderer schedules each workspace by the cadence of the host that runs its GitHub reads: its own setting for local work, the paired host's setting (read on contact, on settingsChanged and from the Settings control) for that host's workspaces. A local Off no longer stops a remote Default and the reverse. Policy, timers and the checks panel re-plan on any cadence change. - github.prForBranch and github.prChecks: a paired client's timer reads carry reason swr/visible and the host answers them from its last read at its cadence; window return, screen open, manual refresh and changed-state reads carry foreground/noCache and always reach the provider. Mobile sends the same intent for its own timer. - Off stops ordinary failure retries (main coordinator, renderer scheduler, checks detail policy); CI-line watches keep retrying at Minimal. - Earlier-head CI watches follow the same watch cadence as the current head. - The paced PR cache identity includes acceptMergedFallbackPR. * fix(github): close the C2 PR auto-refresh gaps: checks cache freshness and scope, retry and settings-sync races, focus return under Off, mobile foreground reads (STA-8667) - github.prChecks: every successful full read (timed, foreground, manual, noCache) is recorded as the host's last answer with start-order protection, so a later timed client never gets an older result back; the paced key now carries the execution scope, review generation and resolved gh read scope like the provider read itself. - Renderer visible scheduler: a retry armed after a failure is dropped when the owning host's cadence becomes Off and re-timed when it is stretched, instead of firing once under the old cadence. - Remote host cadence sync: a settingsChanged (or regained contact) during an in-flight settings.get marks it dirty and triggers one trailing read; a slow answer never reverts a value the Settings control wrote or re-adds an unpaired host. - Checks panel: a focus-only window return (document never hidden) reads once under Off with the usual cooldown and in-flight guards, unless the checks are settled. - Settings: the remote PR Auto-Refresh control shows the one shared, subscribed copy of the host's setting, so another client's change is reflected while mounted and the displayed option can be chosen back. - Mobile: a branch read right after the app comes to the foreground is sent as foreground and bypasses the host's 30 s reuse of its own desktop lookup. Each new test fails without its fix. * fix(github): close the C3 PR auto-refresh gaps: queued main error retries on cadence change, window return under Reduced/Minimal, older paired host fallback (STA-8667) * perf(github): share and pace timed pull request reads on the host Reuse exact completed results for automatic refreshes, back off unchanged settled pull requests, and route server revalidation through the existing host budget while preserving foreground reads and older clients. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): preserve shared producer settlement and selected demand Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): cover paired receipt retries and watch fairness * test(github): explain shared fake-clock fixture * test(github): typecheck host and renderer integration together * test(github): name exported fixture mock types * Revert "test(github): typecheck host and renderer integration together" This reverts commit756a38787c. * test(github): place host and renderer loops in cross-layer suite * fix(github): say what the PR auto-refresh setting actually limits (STA-8667) The Off hint now says CI checks a chat follows are still read at the Minimal interval, the interval hint no longer promises a one-minute read for settled pull requests, and the description no longer claims a cap on every client: clients on older Orca versions keep their Default timer, and returning to the window reads data older than its Default interval. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): close the auto-refresh review gaps (STA-8667) - A window return or re-shown row that is due under the Default interval now reads past the stretched timed cache instead of being answered from it. - Turning auto-refresh Off also retires a host revalidation that was waiting for quota, so it never starts or requeues afterwards. - A newer manual checks answer stays authoritative while an older read of the same checks is still out, even when 256 other checks fill the cache. - A paired host keeps no auto-refresh state after it is forgotten; a read from before unpairing never answers for it, and a re-paired host is read afresh. - A host's cadence change re-plans only the visible rows that host runs, and this app's own setting re-plans its rows in main instead of re-reporting. - Settled rows wait at least a minute on return under a stretched cadence; Default keeps its cooldown. - The setting's copy no longer promises a read on selecting a workspace. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): preserve refresh policy and shared callers Use the same validated freshness for cached queue reads and their follow-up. Keep forced mergeability rereads and detach cancelled consumers independently from the shared lookup. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): give checks, host reads and cadence replans their actual owners (STA-8667) - Checks: the running read of a resource, not the cache, decides which answer is current. A manual answer that finishes while an older read of the same checks still runs is handed to that read, which answers with it and never publishes its own; a read replaced as stale answers with its replacement. The completed cache is held to 256 answers at all times. - A cadence change on one paired host re-plans only that host's visible targets from their own Default interval, without rebuilding any rows. - Off no longer re-queues a failed row at its gate when the cadence is retimed; Off back to Default still resumes timed reads and error retries. - Tests cover a full cache of rows being read again, mixed-host replans and the full life of a forgotten host's read, including a failing one. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): let a replaced checks read answer only with its successor (STA-8667) Overlapping reads of one PR head share the newest accepted answer while any of them runs. A read replaced as stale or by a user refresh publishes nothing and answers its callers with that newer answer, even after it was evicted from the completed cache, or with its successor still running; its own result is used only when no newer answer exists, and its error only when there is no answer. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): preserve refresh policy and shared callers Use the same validated freshness for cached queue reads and their follow-up. Keep forced mergeability rereads and detach cancelled consumers independently from the shared lookup. Carried fromfc3b99db2a(#42033). Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): keep queued PR reads on their own job and validated freshness (STA-8667) - A host revalidation retired by Off while it awaited quota, then replaced by a new read of the same PR, can no longer start, delete or requeue over that new read; the quota pause it learned still holds the key for the new read. - A queued local read decides whether its cached answer is fresh with the same validated interval that schedules its follow-up; a foreground return is judged by the Default interval that made it due, and later follow-ups go back to the cadence (none under Off). Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): keep overlap authority, foreground reads and failure retries owned (STA-8667) - Checks: overlapping reads of one PR head share one record kept until the last of them settles, so a read replaced by a user refresh that has already ended still answers with a newer manual answer after it was evicted, rechecked after waiting on its successor. - A row shown again after its settled age reads instead of being answered by a cached settled result; one foreground age decides both whether the return earns a read and whether the current cached answer satisfies it. - Off back to Default restores a failed row's own retry from its failure, still an error retry the next Off retires, without a new report or manual action. - Test fixes: advance the replaced job's start cooldown, run the checks reads without command-slot limits, typed fixtures and clock-rule notes. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): refresh settled rows when exposed again Use the same finite foreground age to admit and consume the queued read. A recent exact result can still satisfy it, and settled rows do not acquire a recurring timer. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): keep the real command slots in the checks capacity proof (STA-8667) The all-live case now runs through the real four-slot command limiter: 256 rereads are live while four gh commands run, the new manual read waits its turn and then reads, and cleanup settles every queued read through its slot. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): keep a failure's retry and a restored follow-up automatic (STA-8667) - Each exact PR read now has one resource record (its timed and fresh probes, the provider reads running for it and the newest start). A failed full read claims that record for the retry it owes even when the exact cache had no room to keep the outcome, so the retry keeps its original failure time and count, Off retires it, and Default restores it as a retry. A newer read of the same request, a scope change or another HEAD ends the claim; a failure that a newer read had already replaced owes no retry. - A row no longer shown releases its failure's claim; dropping an old gate index entry no longer forgets a visible row's failure. - When timed cadence resumes, a row without a failure gets the automatic follow-up its interval gives it, still a follow-up, so the next Off retires it; one derivation decides creation, retiming and the check after a quota wait. A genuine return or action already queued keeps its own reason. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(mobile): run the PR timer as a client Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): plan automatic PR reads from the newest read and end them with their demand (STA-8667) - A newer provider read of the same request starting retires a failure that is already settled: its queued retry and its claim go, while its count and rate gate stay. The retry check before a start, including after a quota wait, re-validates that the failure still owns the request and row. - A full read whose caller brings no settlement of its own (another client's read, a host job, a provider lookup) plans the visible row's next automatic work, as the row's own read would. A late failure that a newer read replaced owes no retry and leaves the newer read's plan in place. - A routine report of a row still shown as the same request keeps its automatic work automatic. Only a return, a new row or another request earns a read the cadence does not give. - After a quota wait an automatic read takes its current plan: retired, later (it waits for the plan and the learned pause, without displacing work queued since) or ready. - Visibility ends a row's demand where it is removed: a report that drops it, a window cleared or pruned once its renderer is gone, a removed workspace. Only the affected window's rows are checked, so a report no longer walks every window's rows. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): accept only current PR read completions Keep shared publication and retry planning on the accepted actual read. Preserve legacy own replies, terminal loading and ordinary work after a superseded lookup, and honor the accepted error gate before automatic recovery. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): follow moved request contracts * fix(github): keep PR refresh delivery and demand current Use the accepted provider completion for publication and retry planning. Recheck live demand before automatic starts, preserve user actions under Off, and keep the current failure schedule after interval changes. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): explain the partial event fixture type assertion * fix(github): resolve a PR read's scope once per demand decision An admission decision now resolves the request's current scope once and compares each window's row in it through the same exact-key definition, instead of resolving the project account for the captured request and again for every compared alias. A row with other repository or execution inputs is still another request; the decision after preparation still checks the prepared read's own scope. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(github): preserve queued deadlines and detachable PR waits * fix(github): compare refresh wakes on the elapsed clock * test(github): format queued refresh deadline cases * fix(github): charge actual producers and fence retry gates * fix(github): preserve fresh intent and producer error policy * fix(github): retain owned cold-read retry gates * fix(github): refresh checks after a delayed background return Keep one qualified foreground return after a delayed automatic read, preserve GitLab background guards, and use the existing fresh fetch path when the panel returns. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * Keep retimed PR retry gates on their current request * Remove unused PR admission adapter restored by main merge * Clarify watch plan types and fake-clock regression fixtures * Align PR refresh fixtures with current read ownership and elapsed time * fix: retain shown pull request aliases through admission * fix: preserve new pull request row exposure with timers off * test: match fake clock exceptions to TSX lint rules * test: explain deliberate fake timer fixture waits * test: align PR revisit recording and remove duplicate fixture * test: honor default freshness in Off exposure proof * refactor(mobile): simplify PR read intent branches * fix(github): preserve alias actions and foreground refresh intent Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): release obsolete failures before queued restoration Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): retain provider retry holds on foreground return Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): keep both quota aliases visibly demanded Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(github): use fixed intervals for provider hold proof Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test: align PR refresh mocks with existing request types Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * perf(github): compare PR demand without rediscovering account ownership Keep live scope fences on prepared reads and compare captured request inputs for demand. Tighten the existing fleet contract, keep narrow settings panels readable, and follow the moved mobile polling owner. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(github): retain project account authority in shared PR demand Treat explicit and implicit bindings alike for a known project, and retire that equivalence when project ownership changes. Preserve unknown-account separation without restoring inventory scans. Use the existing muted icon button variant for PR Updates. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * test(settings): mount PR Updates with its tooltip provider Keep the late-response ordering regression focused on host state and option selection, without assertions on UI copy. Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brennan Benson
parent
d4c4c466c4
commit
da8d128db4
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3881,7 +3881,8 @@
|
||||
"linkedPRNumber": null,
|
||||
"fallbackPRNumber": 12,
|
||||
"acceptMergedFallbackPR": true,
|
||||
"currentHeadOid": null
|
||||
"currentHeadOid": null,
|
||||
"reason": "active"
|
||||
},
|
||||
"paramsFollowDivergence": true,
|
||||
"reply": {
|
||||
|
||||
@@ -223,13 +223,15 @@ export function useHostQuery<TInput extends HostQueryParams, TData>(
|
||||
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<TData> {
|
||||
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<TInput extends HostQueryParams, TData>(
|
||||
...queryOptions,
|
||||
...(options?.staleTime === undefined ? {} : { staleTime: options.staleTime }),
|
||||
subscribed: options?.subscribed,
|
||||
refetchInterval: options?.refetchInterval
|
||||
refetchInterval:
|
||||
typeof refetchInterval === 'function'
|
||||
? (query) => refetchInterval(query.state.data)
|
||||
: refetchInterval
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GitHubPrForBranchOutcome | null, unknown> = z.union(
|
||||
[
|
||||
// A host that kept no answer for this read only says when to read again; it carries no data.
|
||||
z.custom<PRRevalidationOnly>(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<GitHubPrForBranchOutcome | null,
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return { kind: 'found' as const, pr: outcome.pr }
|
||||
return {
|
||||
kind: 'found' as const,
|
||||
pr: outcome.pr,
|
||||
...receiptRetryAfterMs(outcome.revalidation)
|
||||
}
|
||||
}),
|
||||
pullRequestSchema.transform((pr, ctx) => {
|
||||
if (!pr) {
|
||||
|
||||
@@ -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<T> = { 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<GitHubPrReadOutcome<PRInfo | null>> {
|
||||
): Promise<GitHubPrForBranchRead> {
|
||||
return observe(true, args.refresh === true)
|
||||
async function observe(
|
||||
adopt: boolean,
|
||||
refresh = false
|
||||
): Promise<GitHubPrReadOutcome<PRInfo | null>> {
|
||||
async function observe(adopt: boolean, refresh = false): Promise<GitHubPrForBranchRead> {
|
||||
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<GitHubPrReadOutcome<PRCheckDetail[]>> {
|
||||
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 }
|
||||
)
|
||||
|
||||
@@ -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<ReturnType<typeof fetchPRForBranch>>) => ({
|
||||
fetchForBranch: vi.fn(async () => ({ ok: true as const, result: null })),
|
||||
fetchWorktreeGitHubPRLink: vi.fn(async (): Promise<WorktreeGitHubPRLink> => ({
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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<Extract<GitHubPrForBranchRead, { ok: true }>, '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 }
|
||||
}
|
||||
}
|
||||
@@ -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<PRCheckDetail[]> => {
|
||||
fetch: async (client, target, input: PrChecksInput, previous, read): Promise<PRCheckDetail[]> => {
|
||||
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)
|
||||
|
||||
@@ -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<GitHubPrReadOutcome<PRInfo | null>>
|
||||
) => Promise<GitHubPrForBranchRead>
|
||||
}
|
||||
|
||||
// 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<PrBranchReview> {
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+174
@@ -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<RpcResponse>((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<void> {
|
||||
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<PendingCall> {
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1), { interval: 10 })
|
||||
return calls.shift()!
|
||||
}
|
||||
|
||||
async function answer(call: PendingCall, result: unknown): Promise<void> {
|
||||
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<PrBranchReview>(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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PRCheckDetail[]> = new Map()
|
||||
const checksReads: CoalescedProbes<PRChecksRead> = 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<PRCheckDetail[]> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string, PRChecksRead>()
|
||||
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<string, PRChecksOverlap>()
|
||||
|
||||
/**
|
||||
* 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<PRCheckDetail[]>,
|
||||
successor: () => Promise<PRChecksRead> | undefined
|
||||
): Promise<PRChecksRead> {
|
||||
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()
|
||||
}
|
||||
@@ -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<PRBranchReadResult>
|
||||
running: number
|
||||
latestStart: number
|
||||
failures: Set<() => void>
|
||||
}
|
||||
|
||||
export type PRReadSettlement = (
|
||||
outcome: PRRefreshOutcome,
|
||||
producer: PRBranchReadProducer
|
||||
) => PRRefreshOutcome
|
||||
|
||||
export type PreparedPRBranchRead = {
|
||||
key: string
|
||||
matchesRequest: (request: Parameters<typeof prBranchReadKeys>) => boolean
|
||||
reprepare: () => Promise<PreparedPRBranchRead>
|
||||
cached: () => ReturnType<typeof readTimedPROutcome>
|
||||
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<PRBranchReadResult | null>
|
||||
canRetain: () => boolean
|
||||
scheduleError: (expected: PRRefreshOutcome, outcome: PRRefreshOutcome) => void
|
||||
pending: () => Promise<PRBranchReadResult> | null
|
||||
withExecution: <T>(operation: () => Promise<T>) => Promise<T>
|
||||
/** The read's own outcome, for callers that reply with it as it is. */
|
||||
read: (settle?: PRReadSettlement) => Promise<PRRefreshOutcome>
|
||||
}
|
||||
|
||||
/** 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<PRBranchReadResult>
|
||||
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<Pick<GitHubPRRefreshCandidate, 'connectionId' | 'linkedPRNumber'>>
|
||||
|
||||
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<PRBranchReadCompletion>
|
||||
): PRRefreshOutcome {
|
||||
return completion ? completion(...args) : args[0]
|
||||
}
|
||||
|
||||
const resources = new Map<string, PRBranchReadResource>()
|
||||
@@ -19,12 +116,13 @@ const resources = new Map<string, PRBranchReadResource>()
|
||||
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<PreparedPRBranchRead, 'isCurrent' | 'cached'>,
|
||||
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<PreparedPRBranchRead>
|
||||
cached: () => ReturnType<typeof readTimedPROutcome>
|
||||
isCurrent: () => boolean
|
||||
accepts: (outcome: PRRefreshOutcome) => boolean
|
||||
didRead: () => boolean
|
||||
release: () => void
|
||||
hold: () => () => void
|
||||
readRetained: (settle?: PRReadSettlement) => Promise<PRRefreshOutcome | null>
|
||||
canRetain: () => boolean
|
||||
scheduleError: (expected: PRRefreshOutcome, outcome: PRRefreshOutcome) => void
|
||||
readResult: (
|
||||
settle?: PRReadSettlement,
|
||||
requireRetention?: boolean
|
||||
) => Promise<PRBranchReadResult | null>
|
||||
pending: () => Promise<PRBranchReadResult> | null
|
||||
withExecution: <T>(operation: () => Promise<T>) => Promise<T>
|
||||
read: (settle?: PRReadSettlement) => Promise<PRRefreshOutcome>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<PreparedPRBranchRead> {
|
||||
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<PRBranchReadResult> =>
|
||||
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<PRBranchReadResult | null> => {
|
||||
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<PRRefreshOutcome> =>
|
||||
(await readResult(settle))!.outcome
|
||||
const readRetained = async (settle?: PRReadSettlement): Promise<PRRefreshOutcome | null> =>
|
||||
(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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof readTimedPROutcome> {
|
||||
): [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<typeof prBranchReadKeys>,
|
||||
accountFromProject = false
|
||||
): (other: Parameters<typeof prBranchReadKeys>) => 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<typeof prBranchReadKeys>
|
||||
): ReturnType<typeof readTimedPROutcome> {
|
||||
return readTimedPROutcome(...prBranchReadKeys(...request))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>(prKey: string, run: () => Promise<T>): Promise<T> {
|
||||
return orderPRWatchOutcome(this.outcomes, prKey, run)
|
||||
|
||||
@@ -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<typeof hostedPRReadSettlement>[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<PreparedPRBranchRead['cached']>,
|
||||
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<PRRefreshOutcome> {
|
||||
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<PRSnapshotReply> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<RuntimeStore, 'getSettings' | 'onSettingsChanged'> | 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<typeof hostedReviewOptionArgs>[2]
|
||||
): Parameters<typeof prBranchReadKeys> {
|
||||
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<typeof readTimedPROutcome>
|
||||
): 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'
|
||||
}
|
||||
|
||||
+9
-1
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+95
-1
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+320
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+170
@@ -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' } }
|
||||
})
|
||||
})
|
||||
@@ -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<typeof createTestStore>
|
||||
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
provider.resolve.mockImplementationOnce(() => own.promise)
|
||||
coordinator.reportVisiblePRRefreshCandidates([target], 1, 1)
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
const newer = Promise.withResolvers<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
provider.resolve.mockImplementationOnce(() => own.promise)
|
||||
coordinator.reportVisiblePRRefreshCandidates([target], 1, 1)
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
const newer = Promise.withResolvers<PRRefreshOutcome>()
|
||||
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<number | null>()
|
||||
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<PRRefreshOutcome>()
|
||||
provider.resolve.mockImplementationOnce(() => own.promise)
|
||||
const action = coordinator.refreshPRNow(shownTarget, 'active')
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
const newer = Promise.withResolvers<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
provider.resolve.mockImplementationOnce(() => own.promise)
|
||||
let action: Promise<PRInfo | null> | 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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<number | null>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<typeof makeCandidate>) => {
|
||||
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<void>
|
||||
) {
|
||||
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<PRRefreshOutcome>()
|
||||
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<PRRefreshOutcome>()
|
||||
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<number | null>()
|
||||
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<number>()
|
||||
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<number | null>()
|
||||
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<number | null>()
|
||||
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<number | null>()
|
||||
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<PRRefreshOutcome>()
|
||||
const newer = Promise.withResolvers<PRRefreshOutcome>()
|
||||
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
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<PRRefreshOutcome>) | PreparedPRBranchRead,
|
||||
didRead: () => boolean = () => true,
|
||||
release: () => void = () => {}
|
||||
): Promise<PRRefreshOutcome> {
|
||||
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<string, GitHubPRRefreshAlias> }
|
||||
>()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<PRRefreshQueueScheduler, 'restoreFollowUp'>
|
||||
},
|
||||
read: LocalPRReadDelivery,
|
||||
prepared: Pick<PreparedPRBranchRead, 'isCurrent' | 'cached'>,
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<string, PRRefreshQueueEntry>()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number | null> => 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<number | null>()
|
||||
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<typeof work> } = {}
|
||||
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<number | null>()
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, { failures: number; retryAt: number }>()
|
||||
private readonly errorBackoff = new Map<string, ErrorBackoff>()
|
||||
private readonly manualRetryGates = new Map<string, number>()
|
||||
|
||||
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<string, number> {
|
||||
const retries = new Map<string, number>()
|
||||
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<PRReadSettlement>[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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, GitHubPRRefreshCandidate>
|
||||
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<string, GitHubPRRefreshCandidate> }
|
||||
{ generation: number; candidates: Map<string, VisiblePRAliases> }
|
||||
>()
|
||||
|
||||
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<string>; changed: Set<string> } | null {
|
||||
this.prune()
|
||||
const existing = this.visibleByWindow.get(windowId)
|
||||
if (existing && generation < existing.generation) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
const reported = new Map<string, GitHubPRRefreshCandidate>()
|
||||
const exposed = new Set<string>()
|
||||
const changed = new Set<string>()
|
||||
const reported = new Map<string, VisiblePRAliases>()
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<PRRefreshQueueEntry['watch']>
|
||||
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<PRWatchJob>,
|
||||
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<typeof reviewRefreshIntervalMs>[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<PRAutoRefreshCadence, 'off'> {
|
||||
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<PRWatchPlan, { kind: 'read' }> | 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<PRWatchPlan, { kind: 'failure' }>): 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<boolean> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
+289
@@ -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<typeof commands.getRepoPRChecks>[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<typeof read>) => (await checks).map((check) => check.name)
|
||||
|
||||
async function answerNext(name: string): Promise<void> {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<Awaited<ReturnType<typeof getPRChecks>>> {
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -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<void> {
|
||||
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<number | null>()
|
||||
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' } })
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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), [])
|
||||
}
|
||||
@@ -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<typeof getVisibleHostedReviewRefreshTargets> = []
|
||||
let referenceTargets: ReturnType<typeof selectReferences> = []
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
+409
@@ -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<NonNullable<ChecksPanelPollingInput['pr']>> = {},
|
||||
fetchImpl: (options?: { background?: boolean }) => Promise<void> = async () => {},
|
||||
gitlab = false
|
||||
) {
|
||||
const overrides: Partial<ChecksPanelPollingInput> = {}
|
||||
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<void> {
|
||||
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<unknown>[] = []
|
||||
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<typeof mount> | 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([])
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
+85
-16
@@ -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<string | null>(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()
|
||||
}
|
||||
}, [
|
||||
|
||||
+6
-2
@@ -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)) {
|
||||
|
||||
+148
@@ -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<unknown>[] = []
|
||||
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 }] : [])
|
||||
}
|
||||
)
|
||||
@@ -45,7 +45,9 @@ export function renderGitSettingsSection(
|
||||
/>
|
||||
<GitProviderApiBudgetPane
|
||||
settingsSearchQuery={model.settingsSearchQuery}
|
||||
target={model.settingsHostScope.target}
|
||||
hostScope={model.settingsHostScope}
|
||||
settings={model.settings}
|
||||
updateSettings={model.updateSettings}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -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<GlobalSettings>) => void | Promise<void>
|
||||
}
|
||||
|
||||
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) ? (
|
||||
<PRAutoRefreshSetting
|
||||
key="pr-auto-refresh"
|
||||
hostScope={hostScope}
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: t('auto.components.settings.GitPane.fdbcd676b0', 'GitHub API budget'),
|
||||
description: t(
|
||||
|
||||
@@ -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 (
|
||||
<SearchableSetting
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={PR_AUTO_REFRESH_KEYWORDS}
|
||||
className="@container max-w-none"
|
||||
>
|
||||
<SettingsRow
|
||||
className="@max-sm:flex-col @max-sm:items-start"
|
||||
label={
|
||||
<>
|
||||
{title}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="muted-field-icon"
|
||||
size="icon-xs"
|
||||
aria-label={t(
|
||||
'auto.components.settings.GitPane.prAutoRefreshAbout',
|
||||
'About PR Updates'
|
||||
)}
|
||||
className="size-5"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<span id={hintId} aria-live="polite" className="block min-h-4">
|
||||
{hint}
|
||||
</span>
|
||||
}
|
||||
control={
|
||||
<SettingsSegmentedControl<PRAutoRefreshCadence | ''>
|
||||
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
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -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<PRAutoRefreshCadence>(initial)
|
||||
return (
|
||||
<PRAutoRefreshControl cadence={cadence} onChange={setCadence} remoteHostName={remoteHostName} />
|
||||
)
|
||||
}
|
||||
|
||||
function Frame({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<div className="settings-view-shell p-5">
|
||||
<SettingsGroup title="Source Control">{children}</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const width = 760
|
||||
|
||||
export const localDefault = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="default" remoteHostName={null} />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const localReduced = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="reduced" remoteHostName={null} />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const localOff = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="off" remoteHostName={null} />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const remoteMinimal = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="minimal" remoteHostName="dev-box" />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const narrow = defineFixture({
|
||||
width: 360,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="reduced" remoteHostName={null} />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const info = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<Interactive initial="default" remoteHostName={null} />
|
||||
</Frame>
|
||||
),
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLButtonElement>('[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 (
|
||||
<PRAutoRefreshControl
|
||||
cadence={cadence}
|
||||
onChange={(next) => 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: () => (
|
||||
<Frame>
|
||||
<SubscribedRemote />
|
||||
</Frame>
|
||||
),
|
||||
interact: () => setPRAutoRefreshCadenceForHost(EXTERNAL_CHANGE_HOST, 'off')
|
||||
})
|
||||
|
||||
export const remoteReading = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<PRAutoRefreshControl cadence={null} onChange={() => {}} remoteHostName="dev-box" />
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const remoteOlderHost = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<PRAutoRefreshControl
|
||||
cadence={null}
|
||||
unsupported
|
||||
onChange={() => {}}
|
||||
remoteHostName="dev-box"
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLButtonElement>('[role="radio"]')?.focus()
|
||||
}
|
||||
})
|
||||
@@ -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<string, string>(),
|
||||
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<HTMLButtonElement>('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<void> {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<PRAutoRefreshSetting
|
||||
hostScope={{ target: { kind: 'environment', environmentId }, available: true }}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a remote host never reads the local settings.
|
||||
settings={{} as never}
|
||||
updateSettings={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<GlobalSettings>) => void | Promise<void>,
|
||||
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<void>
|
||||
} {
|
||||
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<GlobalSettings> }>(
|
||||
{ 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<GlobalSettings>) => void | Promise<void>
|
||||
}): 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 (
|
||||
<PRAutoRefreshControl
|
||||
cadence={cadence}
|
||||
onChange={(next) => void update(next)}
|
||||
remoteHostName={remoteHostName}
|
||||
unsupported={unsupported}
|
||||
disabled={!hostScope.available}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+14
-6
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>((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)
|
||||
})
|
||||
})
|
||||
@@ -2,13 +2,22 @@ import { isWindowVisible } from './window-visibility-interval'
|
||||
|
||||
export type WindowVisibilityTimeoutPollerTimer = ReturnType<typeof setTimeout>
|
||||
|
||||
/** 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> | void
|
||||
run: (context: { trigger: WindowVisibilityPollerTrigger }) => Promise<void> | 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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<PRCheckDetail[]>
|
||||
fetchPRCheckDetails: (
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<GlobalSettings, 'prAutoRefresh'> | 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
|
||||
import { setPRAutoRefreshCadence } from '../../../../../shared/review-refresh-policy'
|
||||
|
||||
type CadenceState = { settings: Pick<GlobalSettings, 'prAutoRefresh'> | 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
+266
@@ -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<string, () => 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<AppState, 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId'>
|
||||
|
||||
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<State>) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<AppState, 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId'>
|
||||
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<GlobalSettings> }>(
|
||||
{ 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<void>
|
||||
/** A change was reported while the host had not answered: its answer may predate the change. */
|
||||
dirty: boolean
|
||||
authority: HostPRAutoRefreshCadenceRead
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, HostRead>()
|
||||
|
||||
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<void> {
|
||||
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<string, () => 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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, HostPRAutoRefreshCadenceRead>()
|
||||
|
||||
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<string>()
|
||||
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()
|
||||
}
|
||||
@@ -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<string | null>()
|
||||
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 }
|
||||
}
|
||||
+223
@@ -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<typeof createVisibleHostedReviewRefreshScheduler>[] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+90
-29
@@ -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<string, string>
|
||||
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<VisibleHostedReviewRefreshResult>
|
||||
}
|
||||
} & 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<VisibleHostedReviewRefreshResult, boolean> =
|
||||
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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user