From 67312a37531ad82d84b4f553cfa46cc4aa2837ca Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Tue, 30 Jun 2026 09:12:15 +0300 Subject: [PATCH] fix(#262): keep polling the reindex counter past the stale pre-reindex snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After "Reindex now" the "Indexed X of Y" counter froze at 0 until a manual reload. Root cause is purely client-side: right after the mutation the client still holds the PRE-reindex settings snapshot, which for an already fully-indexed workspace reads reindexing=false, indexed>=total. The deadline-clearing effect evaluated isReindexComplete() against that stale snapshot, read it as "done", and cleared the poll deadline before the first post-reindex poll ever landed — so polling never ran and the counter stayed at 0 (a reload just fetched one fresh snapshot). Gate completion on having actually observed the active run: a reindexSeenActiveRef, reset on each new reindex (mutation onSuccess, before setting the deadline) and latched true once a poll reports reindexing=true. isReindexComplete(status, seenActive) and nextReindexPollInterval now require seenActive, so the stale fully-indexed snapshot no longer reads as finished. The server pre-seeds reindexing=true from enqueue time, so seenActive latches early and a genuine completion still stops polling promptly; the REINDEX_POLL_CAP_MS cap is checked first and always wins, so polling can never run away. closes #262 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-provider-settings.spec.tsx | 57 ++++++++++++-- .../components/ai-provider-settings.tsx | 75 ++++++++++++++----- 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx index 1d58eba7..79f94ab7 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx @@ -77,7 +77,9 @@ describe('resolveKeyField (write-only key payload)', () => { describe('nextReindexPollInterval', () => { const INTERVAL = 5000; - const base = { now: 1_000, intervalMs: INTERVAL }; + // `seenActive: true` is the steady state for most of a run — a poll has + // observed `reindexing === true` (the server pre-seeds it from enqueue time). + const base = { now: 1_000, intervalMs: INTERVAL, seenActive: true }; it('does not poll when no reindex deadline is set', () => { expect( @@ -111,7 +113,7 @@ describe('nextReindexPollInterval', () => { ).toBe(INTERVAL); }); - it('stops once the run is finished AND fully indexed', () => { + it('stops once the run is finished AND fully indexed (after having been active)', () => { expect( nextReindexPollInterval({ ...base, @@ -121,11 +123,29 @@ describe('nextReindexPollInterval', () => { ).toBe(false); }); + it('does NOT stop on the stale pre-reindex snapshot (fully indexed, never seen active)', () => { + // Regression for #262: right after "Reindex now" the client still holds the + // PRE-reindex settings (an already fully-indexed workspace reads as + // reindexing=false, indexed>=total). Without the seenActive gate this looked + // "done" and stopped polling on the very first tick, freezing the counter at + // 0 until a manual reload. The fresh window has not observed the active run, + // so polling must continue until the first real poll lands. + expect( + nextReindexPollInterval({ + ...base, + seenActive: false, + deadline: 10_000, + status: { reindexing: false, indexedPages: 478, totalPages: 478 }, + }), + ).toBe(INTERVAL); + }); + it('keeps polling within the deadline when not yet done and no active flag', () => { // First poll right after enqueue, before the worker publishes progress. expect( nextReindexPollInterval({ ...base, + seenActive: false, deadline: 10_000, status: { reindexing: false, indexedPages: 0, totalPages: 478 }, }), @@ -138,12 +158,15 @@ describe('nextReindexPollInterval', () => { deadline: 1_000, now: 2_000, // past the deadline intervalMs: INTERVAL, + seenActive: true, status: { reindexing: true, indexedPages: 200, totalPages: 478 }, }), ).toBe(false); }); it('stops on an empty workspace (0 of 0) once the run is finished', () => { + // The pre-seed publishes reindexing=true even for 0 pages, so a poll sees the + // run active before the worker clears -> seenActive latches true. expect( nextReindexPollInterval({ ...base, @@ -156,26 +179,46 @@ describe('nextReindexPollInterval', () => { describe('isReindexComplete', () => { it('false when no status yet', () => { - expect(isReindexComplete(undefined)).toBe(false); + expect(isReindexComplete(undefined, true)).toBe(false); }); it('false while a run is still active (even at indexed==total)', () => { expect( - isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }), + isReindexComplete( + { reindexing: true, indexedPages: 478, totalPages: 478 }, + true, + ), ).toBe(false); }); it('false when finished but not yet fully indexed', () => { expect( - isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }), + isReindexComplete( + { reindexing: false, indexedPages: 120, totalPages: 478 }, + true, + ), ).toBe(false); }); - it('true once finished and fully indexed', () => { + it('true once finished and fully indexed (after having been active)', () => { expect( - isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }), + isReindexComplete( + { reindexing: false, indexedPages: 478, totalPages: 478 }, + true, + ), ).toBe(true); }); + + it('false on the stale pre-reindex snapshot: finished+fully indexed but never seen active', () => { + // The just-started edge: the gate keeps this from clearing the poll deadline + // before the first post-reindex poll arrives. + expect( + isReindexComplete( + { reindexing: false, indexedPages: 478, totalPages: 478 }, + false, + ), + ).toBe(false); + }); }); describe('isReindexButtonLoading', () => { diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index f9e5ee76..6e7cb185 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { z } from "zod/v4"; import { ActionIcon, @@ -185,14 +185,23 @@ type ReindexStatus = Pick< * has finished AND everything is indexed (server cleared its progress record and * fell back to the DB coverage count), or the deadline cap is hit — the cap * always wins so a stuck/never-clearing progress record can't poll forever. + * + * `seenActive` guards the just-started window: right after "Reindex now" the + * client still holds the PRE-reindex settings snapshot, which for an already + * fully-indexed workspace reads as `reindexing=false, indexed>=total`. Treating + * that stale snapshot as "done" would stop polling before the first post-reindex + * poll ever lands (counter frozen at 0). So completion is only honored once a + * poll has actually observed the active run (the enqueue-time pre-seed makes + * `reindexing=true` visible from the first poll until the run truly clears). */ export function nextReindexPollInterval(args: { deadline: number | null; now: number; intervalMs: number; status?: ReindexStatus; + seenActive: boolean; }): number | false { - const { deadline, now, intervalMs, status } = args; + const { deadline, now, intervalMs, status, seenActive } = args; if (deadline === null) return false; // Cap always wins. if (now > deadline) return false; @@ -200,20 +209,33 @@ export function nextReindexPollInterval(args: { if (status?.reindexing) return intervalMs; // Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse // isReindexComplete so the completeness check lives in exactly one place. - if (isReindexComplete(status)) return false; + if (isReindexComplete(status, seenActive)) return false; // Within the deadline and not yet done → keep polling. return intervalMs; } /** - * Whether the reindex poll deadline should be cleared: the server reports no - * active run AND the count is complete. The single source of truth for the - * "reindex finished" check — `nextReindexPollInterval` reuses it for its stop - * condition (sans the cap, which the effect handles via time). + * Whether the reindex poll deadline should be cleared: a poll has observed the + * active run (`seenActive`) AND the server now reports no active run AND the + * count is complete. The single source of truth for the "reindex finished" + * check — `nextReindexPollInterval` reuses it for its stop condition (sans the + * cap, which the effect handles via time). + * + * The `seenActive` requirement is what keeps the STALE pre-reindex snapshot + * (already fully indexed → `reindexing=false, indexed>=total`) from being read + * as "finished" in the window before the first post-reindex poll arrives. Once + * a poll has seen `reindexing=true` (guaranteed by the server's enqueue-time + * pre-seed for the whole run), this flips to a genuine completion check. */ -export function isReindexComplete(status?: ReindexStatus): boolean { +export function isReindexComplete( + status: ReindexStatus | undefined, + seenActive: boolean, +): boolean { return ( - !!status && !status.reindexing && status.indexedPages >= status.totalPages + seenActive && + !!status && + !status.reindexing && + status.indexedPages >= status.totalPages ); } @@ -290,6 +312,14 @@ export default function AiProviderSettings() { const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap const [reindexDeadline, setReindexDeadline] = useState(null); + // Whether any poll in the CURRENT window has actually observed the active run + // (`reindexing === true`). Reset when a new reindex is kicked off. Gates the + // completion check so the STALE pre-reindex snapshot (an already fully-indexed + // workspace reads as `reindexing=false, indexed>=total`) can't be mistaken for + // "finished" before the first post-reindex poll lands — which would freeze the + // counter at 0 until a manual reload. A ref (not state) because it must not + // trigger a render and is only ever read where `reindexing` is already false. + const reindexSeenActiveRef = useRef(false); // Only admins may read the (masked) AI settings; the server enforces this too. const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => @@ -298,6 +328,7 @@ export default function AiProviderSettings() { now: Date.now(), intervalMs: REINDEX_POLL_INTERVAL, status: query.state.data, + seenActive: reindexSeenActiveRef.current, }), ); @@ -305,12 +336,17 @@ export default function AiProviderSettings() { // unmount because the deadline state goes away with the component. useEffect(() => { if (reindexDeadline === null) return; - // "Done" matches the refetchInterval stop condition: the server reports no - // active run AND the count is complete (indexed >= total, incl. an empty - // workspace 0 >= 0), so the deadline clears promptly instead of waiting out - // the cap. While `reindexing` is still true we keep the deadline so polling - // continues for the whole run. - if (isReindexComplete(settings)) { + // Latch "we have seen the active run" the moment a poll reports it, so the + // completion check below (and the refetchInterval's) only fires once the run + // has genuinely started — never on the stale pre-reindex snapshot. + if (settings?.reindexing) reindexSeenActiveRef.current = true; + // "Done" matches the refetchInterval stop condition: a poll has observed the + // active run AND the server now reports no active run AND the count is + // complete (indexed >= total, incl. an empty workspace 0 >= 0), so the + // deadline clears promptly instead of waiting out the cap. While `reindexing` + // is still true (or no poll has seen it active yet) we keep the deadline so + // polling continues for the whole run. + if (isReindexComplete(settings, reindexSeenActiveRef.current)) { setReindexDeadline(null); return; } @@ -1117,8 +1153,13 @@ export default function AiProviderSettings() { reindexMutation.mutate(undefined, { // Begin bounded polling so the counter climbs as the async // background job indexes (it does not update on its own). - onSuccess: () => - setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS), + // Clear the "seen active" latch first so this fresh window + // doesn't inherit a previous run's completion state and stop + // immediately. + onSuccess: () => { + reindexSeenActiveRef.current = false; + setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS); + }, }) } >