diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 2dfa396c..b73d5dbd 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1824,6 +1824,7 @@ pub fn run() { prs::has_unpushed_commits, prs::push_branch, prs::clear_branch_pr_status, + prs::recover_branch_pr, // Utilities util_commands::open_url, util_commands::is_sq_available, diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 44201eb4..909c4b04 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -153,6 +153,17 @@ This is critical - the application parses this to link the PR. None }; + // Emit "running" event *before* returning so the global session listener + // registers this session atomically — avoiding the race where the session + // completes before the frontend `.then()` callback fires. + session_runner::emit_session_running( + &app_handle, + &session.id, + &branch_id, + &branch.project_id, + "pr", + ); + session_runner::start_session( session_runner::SessionConfig { session_id: session.id.clone(), @@ -422,6 +433,73 @@ pub fn clear_branch_pr_status( Ok(()) } +/// Look up an existing open PR for a branch on GitHub and persist it. +/// +/// Called on component mount when `branch.prNumber` is null but the branch has +/// been pushed. Runs `gh pr view ` in the background so the frontend +/// is not blocked. Returns the recovered PR number, or None if no PR exists. +#[tauri::command(rename_all = "camelCase")] +pub async fn recover_branch_pr( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result, String> { + let store = get_store(&store)?; + + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + // If the branch already has a PR number, nothing to recover + if branch.pr_number.is_some() { + return Ok(branch.pr_number); + } + + let project = store + .get_project(&branch.project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", branch.project_id))?; + + let is_remote = branch.branch_type == store::BranchType::Remote; + let branch_name = branch.branch_name.clone(); + + let (repo_slug, _) = resolve_branch_repo_and_subpath(&store, &project, &branch)?; + + let working_dir = if is_remote { + crate::paths::repos_dir() + .map(|d| d.join(&repo_slug)) + .ok_or_else(|| "Cannot determine clone path for remote branch".to_string())? + } else { + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + PathBuf::from(&workdir.path) + }; + + let pr_info = tauri::async_runtime::spawn_blocking(move || { + git::get_pr_for_branch(&working_dir, &branch_name) + }) + .await + .map_err(|e| format!("recover_branch_pr task failed: {e}"))? + .map_err(|e| e.to_string())?; + + if let Some(ref info) = pr_info { + let pr_number = info.number; + store + .update_branch_pr_number(&branch_id, Some(pr_number)) + .map_err(|e| e.to_string())?; + log::info!( + "recover_branch_pr: recovered PR #{} for branch_id={}", + pr_number, + branch_id + ); + Ok(Some(pr_number)) + } else { + Ok(None) + } +} + /// Check if a branch has commits that haven't been pushed to the remote. #[tauri::command(rename_all = "camelCase")] pub async fn has_unpushed_commits( @@ -586,6 +664,17 @@ The push must succeed before you finish (unless you output the non-fast-forward None }; + // Emit "running" event *before* returning so the global session listener + // registers this session atomically — avoiding the race where the session + // completes before the frontend `.then()` callback fires. + session_runner::emit_session_running( + &app_handle, + &session.id, + &branch_id, + &branch.project_id, + "push", + ); + session_runner::start_session( session_runner::SessionConfig { session_id: session.id.clone(), diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 5a60062b..510d88ec 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -42,6 +42,8 @@ import { initBloxEnv } from './lib/stores/bloxEnv.svelte'; import { listenForSessionStatus } from './lib/listeners/sessionStatusListener'; import { darkMode } from './lib/stores/isDark.svelte'; + import * as prPollingService from './lib/services/prPollingService'; + import { projectsList } from './lib/features/projects/projectsSidebarState.svelte'; import type { StoreIncompatibility } from './lib/types'; const updaterEnabled = import.meta.env.VITE_UPDATER_ENABLED === 'true'; @@ -62,6 +64,17 @@ let resetting = $state(false); let storeError = $state(null); + // ========================================================================= + // App-wide PR polling — sync project list and selected project reactively + // ========================================================================= + $effect(() => { + prPollingService.setProjects(projectsList.current.map((p) => p.id)); + }); + + $effect(() => { + prPollingService.setSelectedProject(navigation.selectedProjectId); + }); + // Konami code: ↑↑↓↓←→←→BA const konamiSequence = [ 'ArrowUp', diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 5d1abce6..e4cd43b3 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -852,6 +852,12 @@ export function updateBranchPr(branchId: string, prNumber: number | null): Promi return invoke('update_branch_pr', { branchId, prNumber }); } +/** Look up an existing open PR for a branch on GitHub and persist it. + * Returns the recovered PR number, or null if no PR exists. */ +export function recoverBranchPr(branchId: string): Promise { + return invoke('recover_branch_pr', { branchId }); +} + /** Check whether a branch has local commits not yet pushed to the remote. */ export function hasUnpushedCommits(branchId: string): Promise { return invoke('has_unpushed_commits', { branchId }); diff --git a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte index 4a1972b9..48409428 100644 --- a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte @@ -15,7 +15,7 @@ } from 'lucide-svelte'; import Spinner from '../../shared/Spinner.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; - import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + import { listen } from '@tauri-apps/api/event'; import type { Branch, BranchTimeline as BranchTimelineData } from '../../types'; import * as commands from '../../api/commands'; import { extractPrNumber, extractPrUrl, isPushRejectedNonFastForward } from './branchCardHelpers'; @@ -23,8 +23,7 @@ import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import { prStateStore, type PrState } from '../../stores/prState.svelte'; import { pushStateStore, type PushState } from '../../stores/pushState.svelte'; - import { projectStateStore } from '../../stores/projectState.svelte'; - import { sessionRegistry } from '../../stores/sessionRegistry.svelte'; + import * as prPollingService from '../../services/prPollingService'; interface Props { branch: Branch; @@ -79,10 +78,8 @@ // PR head SHA — updated from events and branch prop let prHeadSha = $state(null); - // PR status polling state - let prStatusPollTimer: ReturnType | null = null; - let prStatusRefreshing = $state(false); - let lastImmediateRefreshPrNumber = $state(null); + // Stale-data indicator (set by the centralized polling service) + let prStatusStale = $state(false); // PR status fields (local state, updated via events) let prStatusState = $state(null); @@ -124,17 +121,13 @@ let showPushErrorDialog = $state(false); let showForcePushDialog = $state(false); - // Window focus tracking for smart polling - let isWindowFocused = $state(true); - let handleFocus: (() => void) | null = null; - let handleBlur: (() => void) | null = null; - - let unlistenPrStatus: UnlistenFn | null = null; - let unlistenPrStatusCleared: UnlistenFn | null = null; - + // ========================================================================= + // Event listeners for PR status (fix race condition by awaiting promises) + // ========================================================================= $effect(() => { const branchId = branch.id; - listen<{ + + const unlistenStatusPromise = listen<{ branchId: string; prState: string; prChecksStatus: string; @@ -151,12 +144,16 @@ prStatusMergeable = payload.prMergeable; prStatusDraft = payload.prDraft; prHeadSha = payload.prHeadSha; + // Update the polling service with the new checks status + prPollingService.updateChecksStatus( + branchId, + branch.projectId, + payload.prChecksStatus === 'PENDING' + ); } - }).then((unlisten) => { - unlistenPrStatus = unlisten; }); - listen('pr-status-cleared', (event) => { + const unlistenClearedPromise = listen('pr-status-cleared', (event) => { if (event.payload === branchId) { prStatusState = null; prStatusChecks = null; @@ -165,13 +162,11 @@ prStatusDraft = null; prHeadSha = null; } - }).then((unlisten) => { - unlistenPrStatusCleared = unlisten; }); return () => { - unlistenPrStatus?.(); - unlistenPrStatusCleared?.(); + unlistenStatusPromise.then((fn) => fn()); + unlistenClearedPromise.then((fn) => fn()); }; }); @@ -219,117 +214,45 @@ return () => clearInterval(interval); }); - // PR status polling: adaptive intervals based on status + // Subscribe to stale-data notifications from the polling service. + // Using $effect with cleanup so the subscription is immune to double-mount + // (e.g. HMR, keyed re-render) and automatically tracks branch.projectId. $effect(() => { - const shouldPoll = branch.prNumber && isWindowFocused; - - if (prStatusState === 'MERGED' || prStatusState === 'CLOSED') { - if (prStatusPollTimer) { - clearInterval(prStatusPollTimer); - prStatusPollTimer = null; - } - return; - } - - let pollInterval: number; - if (prStatusChecks === 'PENDING') { - pollInterval = 15_000; - } else { - pollInterval = 60_000; - } - - if (shouldPoll) { - if (prStatusPollTimer) { - clearInterval(prStatusPollTimer); - } - - prStatusPollTimer = setInterval(async () => { - if (prStatusRefreshing) { - return; - } - try { - prStatusRefreshing = true; - let timeoutId: ReturnType; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error('refreshPrStatus timed out after 60s')), - 60_000 - ); - }); - try { - await Promise.race([commands.refreshPrStatus(branch.id), timeout]); - } finally { - clearTimeout(timeoutId!); - } - } catch (e) { - console.error(`[BranchCardPrButton] Poll refresh failed for branch=${branch.id}:`, e); - } finally { - prStatusRefreshing = false; - } - }, pollInterval); - } else { - if (prStatusPollTimer) { - clearInterval(prStatusPollTimer); - prStatusPollTimer = null; - } - } - - return () => { - if (prStatusPollTimer) { - clearInterval(prStatusPollTimer); - prStatusPollTimer = null; + const projectId = branch.projectId; + const unsub = prPollingService.onStale((staleProjectId, isStale) => { + if (staleProjectId === projectId) { + prStatusStale = isStale; } - }; - }); - - // Kick off an immediate refresh whenever this branch gains a PR number. - // This covers branches hydrated after mount, such as project/repo creation from an existing PR. - $effect(() => { - const prNumber = branch.prNumber; - - if (!prNumber) { - lastImmediateRefreshPrNumber = null; - return; - } - - if (!isWindowFocused || prStatusRefreshing || lastImmediateRefreshPrNumber === prNumber) { - return; - } - - lastImmediateRefreshPrNumber = prNumber; - commands - .refreshPrStatus(branch.id) - .catch((e) => console.error('Failed to fetch initial PR status:', e)); + }); + return () => unsub(); }); onMount(() => { window.addEventListener('keydown', handleOptionDown); window.addEventListener('keyup', handleOptionUp); - handleFocus = () => { - isWindowFocused = true; - if (branch.prNumber && !prStatusRefreshing) { - commands - .refreshPrStatus(branch.id) - .catch((e) => console.error('Failed to refresh PR status on focus:', e)); - } - }; - handleBlur = () => { - isWindowFocused = false; - }; - window.addEventListener('focus', handleFocus); - window.addEventListener('blur', handleBlur); + // PR recovery: if the branch has been pushed but has no PR number, + // check GitHub for an existing open PR on this branch name. + // The shouldAttemptRecovery guard prevents N concurrent `gh pr view` + // CLI calls when many components mount simultaneously. + if (!branch.prNumber && isRemote && prPollingService.shouldAttemptRecovery(branch.id)) { + commands + .recoverBranchPr(branch.id) + .then((prNumber) => { + if (prNumber) { + branch.prNumber = prNumber; + prPollingService.refreshNow(branch.projectId); + } + }) + .catch(() => { + // PR recovery is best-effort; clear the guard so it can be + // retried on next mount (e.g. after a transient network error). + prPollingService.clearRecoveryAttempt(branch.id); + }); + } }); onDestroy(() => { - unlistenPrStatus?.(); - unlistenPrStatusCleared?.(); - if (prStatusPollTimer) { - clearInterval(prStatusPollTimer); - prStatusPollTimer = null; - } - if (handleFocus) window.removeEventListener('focus', handleFocus); - if (handleBlur) window.removeEventListener('blur', handleBlur); window.removeEventListener('keydown', handleOptionDown); window.removeEventListener('keyup', handleOptionUp); }); @@ -402,9 +325,10 @@ commands .createPr(branch.id, provider, draft) .then((sessionId) => { - sessionRegistry.register(sessionId, branch.projectId, 'pr', branch.id); + // Session is already registered by the global listener via the + // backend's "running" event — just update the local store with the + // real session ID so the fallback poller can track it. prStateStore.setPrCreating(branch.id, sessionId); - projectStateStore.addRunningSession(branch.projectId, sessionId); }) .catch((e) => { prStateStore.setPrError(branch.id, e instanceof Error ? e.message : String(e)); @@ -429,9 +353,7 @@ if (prNumber) { await commands.updateBranchPr(branch.id, prNumber); branch.prNumber = prNumber; - commands - .refreshPrStatus(branch.id) - .catch((e) => console.error('Failed to fetch initial PR status:', e)); + prPollingService.refreshNow(branch.projectId); } prStateStore.setPrCreated(branch.id, foundUrl); } else { @@ -468,9 +390,10 @@ commands .pushBranch(branch.id, provider, force) .then((sessionId) => { - sessionRegistry.register(sessionId, branch.projectId, 'push', branch.id); + // Session is already registered by the global listener via the + // backend's "running" event — just update the local store with the + // real session ID so the fallback poller can track it. pushStateStore.setPushing(branch.id, sessionId); - projectStateStore.addRunningSession(branch.projectId, sessionId); }) .catch((e) => { pushStateStore.setPushError(branch.id, e instanceof Error ? e.message : String(e)); @@ -507,7 +430,7 @@ prHeadSha = latestCommit.sha; } // Immediately refresh PR status so checks update right away - commands.refreshPrStatus(branch.id).catch(() => {}); + prPollingService.refreshNow(branch.projectId); setTimeout(() => { pushStateStore.clearPushState(branch.id); }, 1_500); @@ -675,7 +598,9 @@ {optionHeld ? 'Create draft PR' : 'Create PR'} {/if} - {#if prStatusIndicator} + {#if prStatusStale} + ! + {:else if prStatusIndicator} {/if} @@ -815,6 +740,14 @@ background-color: var(--text-faint, #64748b); } + .pr-status-stale { + font-size: 9px; + font-weight: 700; + color: var(--text-faint, #94a3b8); + margin-left: 2px; + opacity: 0.7; + } + .pr-status-indicator.pending { background-color: var(--text-muted, #94a3b8); animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; diff --git a/apps/staged/src/lib/services/prPollingService.ts b/apps/staged/src/lib/services/prPollingService.ts new file mode 100644 index 00000000..447b91c0 --- /dev/null +++ b/apps/staged/src/lib/services/prPollingService.ts @@ -0,0 +1,334 @@ +/** + * Centralized PR status polling service. + * + * Polls all projects app-wide. The selected project polls more frequently + * than background projects, and projects with pending CI checks poll fastest. + * + * The backend's `refreshAllPrStatuses` already emits per-branch + * `pr-status-changed` events, so components only need to listen for those. + */ + +import { refreshAllPrStatuses } from '../commands'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type StaleCallback = (projectId: string, isStale: boolean) => void; + +// --------------------------------------------------------------------------- +// Intervals +// --------------------------------------------------------------------------- + +const PENDING_INTERVAL = 15_000; // any project with pending CI checks +const SELECTED_INTERVAL = 60_000; // selected project, no pending checks +const BACKGROUND_INTERVAL = 5 * 60_000; // non-selected, no pending checks +const MAX_CONSECUTIVE_FAILURES = 3; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** All project IDs to poll. */ +const allProjectIds = new Set(); + +/** Currently selected (viewed) project. */ +let selectedProjectId: string | null = null; + +/** Branches with pending checks, keyed by branchId → projectId. */ +const pendingBranches = new Map(); + +/** When each project was last successfully polled. */ +const lastPolledAt = new Map(); + +/** Consecutive failure count per projectId. */ +const failures = new Map(); + +/** Registered stale-data callbacks. */ +const staleCallbacks = new Set(); + +let timerId: ReturnType | null = null; +let refreshInFlight = false; +let windowFocused = true; +let listenersAttached = false; + +/** Project IDs queued for immediate refresh while another refresh is in-flight. */ +const pendingRefreshProjectIds = new Set(); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function projectHasPendingChecks(projectId: string): boolean { + for (const pId of pendingBranches.values()) { + if (pId === projectId) return true; + } + return false; +} + +function getProjectInterval(projectId: string): number { + if (projectHasPendingChecks(projectId)) return PENDING_INTERVAL; + if (projectId === selectedProjectId) return SELECTED_INTERVAL; + return BACKGROUND_INTERVAL; +} + +/** Return project IDs whose polling interval has elapsed. */ +function getProjectsDue(): string[] { + const now = Date.now(); + const due: string[] = []; + for (const projectId of allProjectIds) { + const interval = getProjectInterval(projectId); + const last = lastPolledAt.get(projectId) ?? 0; + if (now - last >= interval) { + due.push(projectId); + } + } + return due; +} + +async function poll() { + if (refreshInFlight || !windowFocused || allProjectIds.size === 0) { + // Don't reschedule here — the in-flight operation's `finally` block + // already calls scheduleNext(), and the other two cases (unfocused / + // empty) intentionally have no timer running. + return; + } + + refreshInFlight = true; + const due = getProjectsDue(); + + for (const projectId of due) { + try { + await refreshAllPrStatuses(projectId); + lastPolledAt.set(projectId, Date.now()); + // Reset failure counter on success + const prev = failures.get(projectId) ?? 0; + if (prev > 0) { + failures.set(projectId, 0); + notifyStale(projectId, false); + } + } catch (e) { + const count = (failures.get(projectId) ?? 0) + 1; + failures.set(projectId, count); + console.error( + `[PrPollingService] refreshAllPrStatuses failed for project=${projectId} (attempt ${count}):`, + e + ); + if (count === MAX_CONSECUTIVE_FAILURES) { + notifyStale(projectId, true); + } + } + } + + refreshInFlight = false; + scheduleNext(); +} + +function scheduleNext() { + stopTimer(); + if (allProjectIds.size === 0 || !windowFocused) return; + + const now = Date.now(); + let minDelay = Infinity; + for (const projectId of allProjectIds) { + const interval = getProjectInterval(projectId); + const last = lastPolledAt.get(projectId) ?? 0; + const remaining = Math.max(0, interval - (now - last)); + minDelay = Math.min(minDelay, remaining); + } + + if (!Number.isFinite(minDelay)) return; + // Floor at 1s to avoid tight loops + timerId = setTimeout(poll, Math.max(1_000, minDelay)); +} + +function stopTimer() { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } +} + +function notifyStale(projectId: string, isStale: boolean) { + for (const cb of staleCallbacks) { + try { + cb(projectId, isStale); + } catch { + // ignore callback errors + } + } +} + +// --------------------------------------------------------------------------- +// Focus / blur handlers +// --------------------------------------------------------------------------- + +function handleFocus() { + windowFocused = true; + poll(); +} + +function handleBlur() { + windowFocused = false; + stopTimer(); +} + +function ensureWindowListeners() { + if (listenersAttached) return; + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + listenersAttached = true; +} + +function removeWindowListeners() { + if (!listenersAttached) return; + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + listenersAttached = false; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Set the full list of project IDs to poll. Starts/stops polling as needed. */ +export function setProjects(projectIds: string[]): void { + const newIds = new Set(projectIds); + + // Short-circuit if the set of project IDs hasn't changed + if (newIds.size === allProjectIds.size && projectIds.every((id) => allProjectIds.has(id))) { + return; + } + + // Remove projects no longer in the list + for (const id of allProjectIds) { + if (!newIds.has(id)) { + allProjectIds.delete(id); + lastPolledAt.delete(id); + failures.delete(id); + } + } + + // Clean up pending branches for removed projects + for (const [branchId, projectId] of pendingBranches) { + if (!newIds.has(projectId)) { + pendingBranches.delete(branchId); + } + } + + // Add new projects + for (const id of newIds) { + allProjectIds.add(id); + } + + if (allProjectIds.size > 0) { + ensureWindowListeners(); + // Trigger poll — new projects have no lastPolledAt so they'll be due + poll(); + } else { + stopTimer(); + removeWindowListeners(); + failures.clear(); + } +} + +/** Set the currently selected project (polls more frequently). */ +export function setSelectedProject(projectId: string | null): void { + if (selectedProjectId === projectId) return; + selectedProjectId = projectId; + if (projectId && allProjectIds.has(projectId)) { + // Selected project's interval just changed — trigger a poll if it's due + poll(); + } else { + scheduleNext(); + } +} + +/** Update whether a branch has pending CI checks (affects its project's poll interval). */ +export function updateChecksStatus( + branchId: string, + projectId: string, + hasPendingChecks: boolean +): void { + const hadPending = pendingBranches.has(branchId); + if (hasPendingChecks) { + pendingBranches.set(branchId, projectId); + } else { + pendingBranches.delete(branchId); + } + if (hadPending !== hasPendingChecks) { + scheduleNext(); + } +} + +/** Register a callback for stale-data notifications. Returns an unsubscribe function. */ +export function onStale(callback: StaleCallback): () => void { + staleCallbacks.add(callback); + return () => staleCallbacks.delete(callback); +} + +// --------------------------------------------------------------------------- +// PR recovery coordination +// --------------------------------------------------------------------------- + +/** Branch IDs for which recovery has already been attempted (or is in progress). */ +const recoveryAttempted = new Set(); + +/** + * Guard for PR recovery: returns true if recovery should proceed for this + * branch, false if it has already been attempted or is in progress. + * Prevents N concurrent `gh pr view` CLI calls when many BranchCardPrButton + * components mount simultaneously for branches without PR numbers. + */ +export function shouldAttemptRecovery(branchId: string): boolean { + if (recoveryAttempted.has(branchId)) return false; + recoveryAttempted.add(branchId); + return true; +} + +/** + * Clear the recovery guard for a branch so it can be retried. + * Call this when recovery fails (e.g. network error) so a transient + * failure doesn't permanently prevent recovery for that branch. + */ +export function clearRecoveryAttempt(branchId: string): void { + recoveryAttempted.delete(branchId); +} + +/** Trigger an immediate refresh for a specific project (e.g. after PR creation or push). */ +export function refreshNow(projectId: string): void { + if (refreshInFlight) { + // Queue so the project is refreshed as soon as the current operation finishes. + pendingRefreshProjectIds.add(projectId); + return; + } + refreshInFlight = true; + refreshAllPrStatuses(projectId) + .then(() => { + lastPolledAt.set(projectId, Date.now()); + // Reset failure counter on success + const prev = failures.get(projectId) ?? 0; + if (prev > 0) { + failures.set(projectId, 0); + notifyStale(projectId, false); + } + }) + .catch((e) => + console.error(`[PrPollingService] immediate refresh failed for project=${projectId}:`, e) + ) + .finally(() => { + refreshInFlight = false; + // Drain queued immediate-refresh requests one at a time. + if (pendingRefreshProjectIds.size > 0) { + const queued = [...pendingRefreshProjectIds]; + pendingRefreshProjectIds.clear(); + // Re-queue all but the first; they'll drain on the next finally cycle. + for (let i = 1; i < queued.length; i++) { + pendingRefreshProjectIds.add(queued[i]); + } + refreshNow(queued[0]); + } else { + scheduleNext(); + } + }); +}