From e9700acaced960304c1a238d9381d6957d5a5b37 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 30 Jul 2026 17:56:13 +0530 Subject: [PATCH 1/2] fix(harness-init): bound the status poll loop instead of retrying forever (#5157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HarnessInitOverlay` polled `openhuman.harness_init_status` every 2s and, on *any* failure, rescheduled unconditionally for the life of the window — no cap, no backoff. Against a core that does not serve the method this became a permanent 30-calls-per-minute loop, and since the core records every miss it produced 64,715 Sentry events (~9k/day) from a single client (CORE-RUST-1PY). The method is not retired: it is a live controller tagged `DomainGroup::Platform`. It legitimately misses on client/core surface skew — an older core behind a newer UI bundle, a runtime `DomainSet` without `Platform` (e.g. `DomainSet::harness()`), or a slim feature build. That first miss is expected; the 64,714 that follow are the client refusing to accept a permanent answer. - classify `unknown method: ` responses as a new `method_not_found` RPC error kind, prefix-anchored to mirror `dispatch::unknown_method_name`'s `strip_prefix`, and expose `isMethodNotFoundCoreRpcError` so pollers branch on `kind` rather than a message regex - stop the overlay poll on `method_not_found` (permanent — retrying an absent method can never succeed), and cap other failures at 5 attempts with 2s→30s exponential backoff so no fault can poll forever - keep the cold-start retry the loop exists for: the failure budget resets on the first success Also corrects the `KNOWN_PROBE_METHODS` rationale, which listed the method as a retired call with no canonical handler. It is served, so that note invited deleting a live controller — and because the allow-list makes the miss debug-only, a genuine regression would have gone silent in Sentry. Pins the registration with a test so the regression fails loudly instead. Regression coverage: without the overlay fix the new tests observe 61 calls in the same window where the fix makes 1 (absent method) and 5 (persistent fault). --- .../HarnessInitOverlay.test.tsx | 65 ++++++++++++++++++- .../InitProgressScreen/HarnessInitOverlay.tsx | 54 ++++++++++++++- .../services/__tests__/coreRpcClient.test.ts | 14 ++++ app/src/services/coreRpcClient.ts | 24 +++++++ src/core/dispatch.rs | 36 +++++++++- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx index 454b1c4a3c..2806523fe3 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoreRpcError } from '../../services/coreRpcClient'; import type { HarnessInitSnapshot } from '../../services/harnessInitService'; import { renderWithProviders } from '../../test/test-utils'; // Imported after the mock is registered. @@ -43,6 +44,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); describe('HarnessInitOverlay', () => { @@ -116,4 +118,65 @@ describe('HarnessInitOverlay', () => { renderWithProviders(); expect(await screen.findByText('Run in background')).toBeInTheDocument(); }); + + // --- #5157: the poll loop must be bounded ------------------------------- + // + // Before the fix, *any* status failure rescheduled the poll unconditionally + // every 2s for the life of the window. Against a core that never serves the + // method that was a permanent 30-calls-per-minute loop, and since the core + // records each miss it produced ~9k Sentry events/day from one client. + + it('stops polling when the core does not expose harness_init_status (#5157)', async () => { + vi.useFakeTimers(); + fetchHarnessInitStatus.mockRejectedValue( + new CoreRpcError('unknown method: openhuman.harness_init_status', 'method_not_found') + ); + + const { container } = renderWithProviders(); + + // Let the immediate poll settle, then run well past many poll intervals. + await vi.advanceTimersByTimeAsync(0); + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(120_000); + + // Permanently absent ⇒ exactly one attempt, ever. No retry, no overlay. + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1); + expect(container).toBeEmptyDOMElement(); + }); + + it('gives up after a bounded number of consecutive transient failures (#5157)', async () => { + vi.useFakeTimers(); + // A persistent non-method-not-found fault (core wedged, transport down). + fetchHarnessInitStatus.mockRejectedValue(new Error('error sending request for url')); + + renderWithProviders(); + + // 5 attempts, backing off 2s → 4s → 8s → 16s between them (30s total). + await vi.advanceTimersByTimeAsync(120_000); + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(5); + + // And it stays stopped rather than resuming later. + await vi.advanceTimersByTimeAsync(600_000); + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(5); + }); + + it('keeps polling after a transient failure while the core is still booting', async () => { + vi.useFakeTimers(); + // The legitimate cold-start case the retry exists for: fail once, then the + // core comes up. The failure budget must reset on success, not leak. + fetchHarnessInitStatus + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockResolvedValue(snapshot({ startedAt: 'cold-run' })); + + renderWithProviders(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + + // Retried once and recovered — the overlay renders the live run. + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(2); + expect(screen.getByText('Run in background')).toBeInTheDocument(); + }); }); diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx index bd8b79faf4..6d27750935 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -1,6 +1,7 @@ import debugFactory from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { isMethodNotFoundCoreRpcError } from '../../services/coreRpcClient'; import { fetchHarnessInitStatus, type HarnessInitSnapshot, @@ -12,6 +13,26 @@ const log = debugFactory('harness-init'); const POLL_MS = 2000; +// A status poll can legitimately fail while the core is still coming up, so a +// failure is retried. But the retry must be *bounded*: before #5157 any failure +// rescheduled the poll unconditionally every 2s for the life of the window. A +// core that never serves this method (version skew / domain-gated build) turned +// that into a permanent 30-calls-per-minute loop, and because each miss is +// recorded core-side it produced ~9k Sentry events/day from a single client. +// +// Two guards now bound it: +// - a permanent failure (`method_not_found`) stops the loop immediately — +// retrying an absent method can never succeed; +// - any other failure gets a capped number of attempts with exponential +// backoff, so even an unforeseen persistent fault decays and stops. +const MAX_TRANSIENT_FAILURES = 5; +const MAX_BACKOFF_MS = 30_000; + +/** Backoff for the Nth consecutive transient failure (1-based), capped. */ +function transientRetryDelayMs(consecutiveFailures: number): number { + return Math.min(POLL_MS * 2 ** (consecutiveFailures - 1), MAX_BACKOFF_MS); +} + // Persist the "Run in background" dismissal for the *current* provisioning run // so a remount or reload does not reopen the overlay (GH-5047). A run is keyed // by its `startedAt` timestamp — a genuinely new provisioning run gets a fresh @@ -107,12 +128,16 @@ export default function HarnessInitOverlay() { cancelledRef.current = false; let timeoutId: number | null = null; + let consecutiveFailures = 0; + const poll = async () => { + let retryDelayMs = POLL_MS; try { const next = await fetchHarnessInitStatusCoalesced(); if (cancelledRef.current || dismissedRef.current) { return; } + consecutiveFailures = 0; if (next) { setSnapshot(next); // If this run was already dismissed to the background (possibly in a @@ -134,11 +159,34 @@ export default function HarnessInitOverlay() { } } } catch (err) { - // Status can fail while the core is still coming up — keep polling. - log('status poll failed: %O', err); + if (cancelledRef.current || dismissedRef.current) { + return; + } + // The running core has no `harness_init_status` at all (version skew, + // domain-gated or slim build). Permanent — stop, and render nothing. + // There is no init run to report, so there is nothing to show (#5157). + if (isMethodNotFoundCoreRpcError(err)) { + log('status poll: core does not expose harness_init_status — stopping poll'); + return; + } + consecutiveFailures += 1; + // Status can fail while the core is still coming up — keep polling, but + // only for a bounded number of attempts, backing off between each. + if (consecutiveFailures >= MAX_TRANSIENT_FAILURES) { + log('status poll failed %d consecutive times — giving up: %O', consecutiveFailures, err); + return; + } + retryDelayMs = transientRetryDelayMs(consecutiveFailures); + log( + 'status poll failed (attempt %d/%d), retrying in %dms: %O', + consecutiveFailures, + MAX_TRANSIENT_FAILURES, + retryDelayMs, + err + ); } if (!cancelledRef.current && !dismissedRef.current) { - timeoutId = window.setTimeout(() => void poll(), POLL_MS); + timeoutId = window.setTimeout(() => void poll(), retryDelayMs); } }; diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 59e5178ec7..8c2e9b67d2 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -705,6 +705,10 @@ describe('classifyRpcError', () => { ['no backend session token; run auth_store_session first', undefined, 'auth_expired'], ['NO BACKEND SESSION TOKEN', undefined, 'auth_expired'], ['HTTP 429 rate-limit exceeded', undefined, 'rate_limited'], + // #5157 verbatim from Sentry (CORE-RUST-1PY) — the running core does not + // expose the method. Permanent, so pollers must be able to stop. + ['unknown method: openhuman.harness_init_status', undefined, 'method_not_found'], + ['unknown method: openhuman.memory_tree_create_namespace', undefined, 'method_not_found'], ['Budget exceeded for current period', undefined, 'budget_exceeded'], ['Insufficient budget for request', undefined, 'budget_exceeded'], ['error sending request for url', undefined, 'transport'], @@ -752,6 +756,16 @@ describe('classifyRpcError', () => { expect(classifyRpcError('anything', 429)).toBe('rate_limited'); }); + test('unknown-method match is prefix-anchored, mirroring the Rust strip_prefix', () => { + // `dispatch::unknown_method_name` classifies with `strip_prefix`, so the + // frontend anchors identically — a nested/quoted occurrence is not the + // core telling us *this* call's method is absent. + expect(classifyRpcError('unknown method: openhuman.harness_init_status')).toBe( + 'method_not_found' + ); + expect(classifyRpcError('tool failed: unknown method: openhuman.foo_bar')).toBe('unknown'); + }); + test('structured ThreadNotFound data wins over message text', () => { expect( classifyRpcError('thread thread-123 not found', undefined, { kind: 'ThreadNotFound' }) diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 6ccc7a3eb1..d8130c1b89 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -116,8 +116,15 @@ type CoreRpcErrorKind = | 'rate_limited' | 'budget_exceeded' | 'thread_not_found' + | 'method_not_found' // the running core does not expose this method — permanent | 'unknown'; +/** + * Prefix the core prepends to an unrecognised-method error. Mirrors + * `UNKNOWN_METHOD_PREFIX` in `src/core/dispatch.rs` — keep the two in sync. + */ +const UNKNOWN_METHOD_PREFIX = 'unknown method: '; + export class CoreRpcError extends Error { readonly kind: CoreRpcErrorKind; readonly httpStatus?: number; @@ -147,6 +154,11 @@ export function classifyRpcError( if (isThreadNotFoundRpcData(data)) return 'thread_not_found'; if (httpStatus === 401) return 'auth_expired'; if (httpStatus === 429) return 'rate_limited'; + // The running core has no such method — a transport-boundary version skew + // (older core than the UI bundle, a domain-gated `DomainSet`, or a slim + // feature build), never a transient fault. Classified before the generic + // arms so polling callers can stop instead of retrying forever (#5157). + if (message.startsWith(UNKNOWN_METHOD_PREFIX)) return 'method_not_found'; // Confirmed OpenHuman session expiry — explicit markers from the backend/core. if (/Session expired|SESSION_EXPIRED/i.test(message)) return 'auth_expired'; // Core-side "no backend session token" → the auth profile is gone but the @@ -238,6 +250,18 @@ function threadIdFromRpcData(data: unknown): string | null { return null; } +/** + * Whether `error` is the core reporting that it does not expose the method. + * + * This is a **permanent** condition for the life of the connection: the method + * is absent from the running core's registry, so retrying can never succeed. + * Pollers must treat it as terminal and stop — an unbounded retry loop against + * an absent method produced ~9k Sentry events/day from a single client (#5157). + */ +export function isMethodNotFoundCoreRpcError(error: unknown): error is CoreRpcError { + return error instanceof CoreRpcError && error.kind === 'method_not_found'; +} + export function isThreadNotFoundCoreRpcError( error: unknown, threadId?: string diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs index b089a2cd12..642cb9ab29 100644 --- a/src/core/dispatch.rs +++ b/src/core/dispatch.rs @@ -107,8 +107,19 @@ pub const UNKNOWN_METHOD_PREFIX: &str = "unknown method: "; /// and never will be (issue #3567): `rpc.discover` (JSON-RPC service /// discovery), `list_methods`, liveness `status`, `auth.status`, `config/get`. /// This also covers retired feature calls from older clients when no safe -/// canonical handler exists (#3565: `openhuman.memory_tree_create_namespace`, -/// #5157: `openhuman.harness_init_status`). +/// canonical handler exists (#3565: `openhuman.memory_tree_create_namespace`). +/// +/// `openhuman.harness_init_status` (#5157) is in the list for a *different* +/// reason and must not be read as retired — it is a **live, registered** +/// method (`harness_init::all_harness_init_registered_controllers`, tagged +/// `DomainGroup::Platform`). It only misses when the caller and the running +/// core disagree about the surface: an older core behind a newer UI bundle, a +/// runtime `DomainSet` without `Platform` (e.g. `DomainSet::harness()`), or a +/// slim feature build. Those are legitimate configurations, not core defects, +/// so the miss stays debug-only — but do **not** delete the controller on the +/// strength of this entry. `harness_init_status_is_registered_in_a_full_build` +/// below pins that the method really is served, so a genuine regression fails +/// a test instead of being silently swallowed by this allow-list. /// /// Each miss previously produced recurring Sentry events with zero user /// impact. The transport layer keeps these debug-only (never captured). The @@ -395,6 +406,27 @@ mod tests { assert!(!is_known_probe_method("")); } + /// `openhuman.harness_init_status` is allow-listed as a debug-only miss so + /// client/core surface skew stops paging Sentry (#5157) — but it is a + /// **live** method, not a retired one. That allow-list entry means a + /// genuine regression (controller dropped from the registry) would go + /// completely silent: no error, no warn, no Sentry event. This test is the + /// replacement signal — if the method stops being served in a full build, + /// this fails instead of the regression shipping unnoticed. + #[test] + fn harness_init_status_is_registered_in_a_full_build() { + let served: Vec = crate::core::all::all_controller_schemas() + .iter() + .map(crate::core::all::rpc_method_name) + .collect(); + assert!( + served.iter().any(|m| m == "openhuman.harness_init_status"), + "harness_init_status must remain a registered controller — it is \ + allow-listed in KNOWN_PROBE_METHODS for client/core skew only, so \ + losing the real handler would be silently swallowed" + ); + } + #[tokio::test] async fn dispatch_dotted_channel_list_aliases_route_to_registry() { for method in ["channels.list", "openhuman.channels.list"] { From 2ac7ad70b18616fa92ea8d552ac37d4eb76f6379 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 30 Jul 2026 19:18:29 +0530 Subject: [PATCH 2/2] fix(harness-init): keep watching a blocking overlay past the failure budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry cap added for #5157 could strand the blocking overlay. `shouldShow` includes `running`, so once a run is in progress the overlay covers the app; if the core then had a transient outage lasting more than MAX_TRANSIENT_FAILURES attempts, the loop gave up and left that `running` snapshot on screen with stale progress for the rest of the session — even after the core recovered and the run reached `done`. The pre-#5157 loop recovered from exactly that, so this was a regression the bound introduced. Split the give-up decision by whether anything blocking is displayed: - nothing on screen (the #5157 case — a core that never serves the method, no UI, a silent 30-calls-per-minute loop): give up at the cap, unchanged; - a `running` overlay on screen: keep watching, but drop to STALLED_POLL_MS (30s). That is 2 calls/min — 15x below the runaway loop #5157 fixed — and it only runs while a blocking overlay is actually up. `awaitingTerminalRef` mirrors that condition so the failure branch can read it without re-running the effect, and `isBlockingSnapshot` is now shared between the poll loop and the render path so the two cannot drift. Regression test drives a live run, then an outage well past the budget, then recovery, and asserts the overlay observes the terminal snapshot and clears itself. Verified failing before the fix: polling stopped at 6 calls and the overlay stayed on screen. Addresses the Codex review on #5276. --- .../HarnessInitOverlay.test.tsx | 45 +++++++++++ .../InitProgressScreen/HarnessInitOverlay.tsx | 74 ++++++++++++++++--- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx index 2806523fe3..964bdaaca8 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx @@ -161,6 +161,51 @@ describe('HarnessInitOverlay', () => { expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(5); }); + // Review follow-up on #5157: the failure cap must not strand the *blocking* + // overlay. If the core has a transient outage that outlasts the budget while + // a `running` snapshot is on screen, giving up would pin the app behind stale + // progress for the rest of the session — the pre-#5157 loop recovered from + // exactly that. The cap still applies when nothing blocking is displayed + // (covered by the give-up test above); here the loop drops to a slow cadence + // instead of stopping. + it('keeps watching a running overlay through an outage longer than the failure budget', async () => { + vi.useFakeTimers(); + fetchHarnessInitStatus + // A provisioning run is live — the overlay is now blocking the app. + .mockResolvedValueOnce(snapshot({ startedAt: 'stall-run' })) + // The core drops out for well past MAX_TRANSIENT_FAILURES attempts. + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + .mockRejectedValueOnce(new Error('error sending request for url')) + // ...and then comes back, having finished the run. + .mockResolvedValue( + snapshot({ overall: 'done', startedAt: 'stall-run', finishedAt: '2026-07-20T00:05:00Z' }) + ); + + renderWithProviders(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText('Run in background')).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300_000); + }); + + // It kept polling past the 5-attempt cap instead of freezing... + expect(fetchHarnessInitStatus.mock.calls.length).toBeGreaterThan(6); + // ...so the recovered core's terminal snapshot was observed and the + // blocking overlay cleared itself. (Asserted directly rather than through + // `waitFor`, which would wait on real timers while fake ones are installed.) + expect(screen.queryByText('Run in background')).not.toBeInTheDocument(); + }); + it('keeps polling after a transient failure while the core is still booting', async () => { vi.useFakeTimers(); // The legitimate cold-start case the retry exists for: fail once, then the diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx index 6d27750935..3adf19506e 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -28,11 +28,34 @@ const POLL_MS = 2000; const MAX_TRANSIENT_FAILURES = 5; const MAX_BACKOFF_MS = 30_000; +// Exhausting the retries must not strand a *blocking* overlay on stale +// progress. While a `running` snapshot is on screen the UI is covering the app +// and waiting for a terminal snapshot, so a transient outage (brief core +// overload, network blip) that outlasts the cap has to stay watchable — giving +// up there would freeze the overlay on a half-finished run until the user hits +// "Run in background", and the pre-#5157 loop did recover from exactly that. +// +// So the cap still applies whenever nothing blocking is displayed (the #5157 +// case: a core that never serves this method, with no UI on screen), and +// otherwise the loop drops to this much slower cadence instead of stopping. +// 2 calls/min is 15x below the runaway loop #5157 fixed, and only ever runs +// while a blocking overlay is actually up. +const STALLED_POLL_MS = MAX_BACKOFF_MS; + /** Backoff for the Nth consecutive transient failure (1-based), capped. */ function transientRetryDelayMs(consecutiveFailures: number): number { return Math.min(POLL_MS * 2 ** (consecutiveFailures - 1), MAX_BACKOFF_MS); } +/** + * Whether this snapshot puts the blocking overlay on screen. `running` is also + * the only non-terminal blocking state, so it is what the poll loop must keep + * watching for a terminal result. + */ +function isBlockingSnapshot(snapshot: HarnessInitSnapshot): boolean { + return snapshot.overall === 'running' || snapshot.overall === 'failed'; +} + // Persist the "Run in background" dismissal for the *current* provisioning run // so a remount or reload does not reopen the overlay (GH-5047). A run is keyed // by its `startedAt` timestamp — a genuinely new provisioning run gets a fresh @@ -123,6 +146,10 @@ export default function HarnessInitOverlay() { const cancelledRef = useRef(false); // Mirrors `dismissed` so the poll loop can stop without re-running the effect. const dismissedRef = useRef(false); + // Mirrors "a blocking overlay is currently on screen, still waiting on a + // terminal snapshot", so the failure branch can tell a stranded user from a + // silent background loop without re-running the effect. + const awaitingTerminalRef = useRef(false); useEffect(() => { cancelledRef.current = false; @@ -140,6 +167,7 @@ export default function HarnessInitOverlay() { consecutiveFailures = 0; if (next) { setSnapshot(next); + awaitingTerminalRef.current = next.overall === 'running'; // If this run was already dismissed to the background (possibly in a // prior mount / before a reload), stay hidden and stop polling — // don't let a remount reopen the overlay (GH-5047). @@ -173,17 +201,36 @@ export default function HarnessInitOverlay() { // Status can fail while the core is still coming up — keep polling, but // only for a bounded number of attempts, backing off between each. if (consecutiveFailures >= MAX_TRANSIENT_FAILURES) { - log('status poll failed %d consecutive times — giving up: %O', consecutiveFailures, err); - return; + // Nothing blocking on screen: this is the #5157 runaway loop, stop. + if (!awaitingTerminalRef.current) { + log( + 'status poll failed %d consecutive times — giving up: %O', + consecutiveFailures, + err + ); + return; + } + // A `running` overlay is covering the app. Stopping here would pin it + // to stale progress for the rest of the session even after the core + // recovers, so keep watching at a much slower cadence instead. + retryDelayMs = STALLED_POLL_MS; + log( + 'status poll failed %d consecutive times but a running overlay is on screen — ' + + 'continuing at %dms: %O', + consecutiveFailures, + retryDelayMs, + err + ); + } else { + retryDelayMs = transientRetryDelayMs(consecutiveFailures); + log( + 'status poll failed (attempt %d/%d), retrying in %dms: %O', + consecutiveFailures, + MAX_TRANSIENT_FAILURES, + retryDelayMs, + err + ); } - retryDelayMs = transientRetryDelayMs(consecutiveFailures); - log( - 'status poll failed (attempt %d/%d), retrying in %dms: %O', - consecutiveFailures, - MAX_TRANSIENT_FAILURES, - retryDelayMs, - err - ); } if (!cancelledRef.current && !dismissedRef.current) { timeoutId = window.setTimeout(() => void poll(), retryDelayMs); @@ -221,6 +268,10 @@ export default function HarnessInitOverlay() { log('user dismissed overlay to background for run %s', runKey(snapshot)); writeDismissedRun(runKey(snapshot)); dismissedRef.current = true; + // Nothing is blocking any more, so a later failure must not keep the slow + // watch alive. (`dismissedRef` already stops the loop; this keeps the two + // flags from disagreeing.) + awaitingTerminalRef.current = false; setDismissed(true); }, [snapshot]); @@ -236,8 +287,7 @@ export default function HarnessInitOverlay() { // Block only while a run is actively in progress, or hold a failed run on // screen until the user explicitly continues. `idle` (no run started yet) // and `done` never block. - const shouldShow = snapshot.overall === 'running' || snapshot.overall === 'failed'; - if (!shouldShow) { + if (!isBlockingSnapshot(snapshot)) { return null; }