fix(client): bound the staged-attachment poll and list every client poll

The Coach polled a staged document every 2.5 s until its content index
existed, which for a format that is never indexed meant for as long as the
Coach stayed open. It now stops with the same upload processing window the
documents list uses. Every client poll, its interval and its stop
condition is listed beside the dashboard cadence constant.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:49:23 +02:00
1 parent 9bc95713cb
commit 415da581f8
4 files changed
+120 -3

No files matched your search

@@ -22,6 +22,8 @@ import {
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
STAGED_ATTACHMENT_POLL_MS,
stagedAttachmentPollInterval,
} from "../vault-utils";
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
@@ -541,3 +543,36 @@ describe("buildProcedureChoices", () => {
expect(buildProcedureChoices([], null, labels)).toEqual([]);
});
});
describe("stagedAttachmentPollInterval", () => {
const now = Date.parse("2026-10-20T10:00:00Z");
const at = (msAgo: number) => new Date(now - msAgo).toISOString();
it("polls a fresh upload until its index lands", () => {
expect(
stagedAttachmentPollInterval(
{ createdAt: at(30_000), hasContentIndex: false },
now,
),
).toBe(STAGED_ATTACHMENT_POLL_MS);
expect(
stagedAttachmentPollInterval(
{ createdAt: at(30_000), hasContentIndex: true },
now,
),
).toBe(false);
});
it("stops polling a document that is never indexed once the window passes", () => {
expect(
stagedAttachmentPollInterval(
{ createdAt: at(RECENT_UPLOAD_WINDOW_MS + 1), hasContentIndex: false },
now,
),
).toBe(false);
});
it("does not poll before the detail has loaded", () => {
expect(stagedAttachmentPollInterval(undefined, now)).toBe(false);
});
});
+20
View File
@@ -167,6 +167,26 @@ export function isDocumentProcessing(
return !doc.hasContentIndex && isRecentlyUploaded(doc.createdAt, now);
}
/**
* How often the Coach polls a staged attachment for its content index.
*
* The same "still processing" question the vault list asks, so the same
* bounded window answers it: a document that will never be indexed (an
* unsupported format, indexing disabled) used to be polled every 2.5 s for as
* long as the Coach stayed open. `false` once the index exists or the window
* has passed.
*/
export const STAGED_ATTACHMENT_POLL_MS = 2_500;
export function stagedAttachmentPollInterval(
doc: Pick<InboundDocumentDto, "createdAt" | "hasContentIndex"> | undefined,
now: number = Date.now(),
): number | false {
return doc && isDocumentProcessing(doc, now)
? STAGED_ATTACHMENT_POLL_MS
: false;
}
/**
* Whether ANY document in the list is still inside its processing window —
* drives the vault list query's poll-while-indexing `refetchInterval`.
@@ -21,7 +21,10 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { parseUploadResponse } from "@/components/documents/vault-utils";
import {
parseUploadResponse,
stagedAttachmentPollInterval,
} from "@/components/documents/vault-utils";
import {
metricScopeLabelFallback,
scopeSourceMetricLabelKey,
@@ -399,10 +402,11 @@ export function CoachConversation({
queryFn: async () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
staleTime: 15_000,
// Bounded by the upload processing window, like the vault list: a
// document that is never indexed stops being polled.
refetchInterval: (query: {
state: { data?: InboundDocumentDetailDto };
}) =>
query.state.data && !query.state.data.hasContentIndex ? 2500 : false,
}) => stagedAttachmentPollInterval(query.state.data),
})),
});
const stagedDetailById = new Map<
+58
View File
@@ -13,3 +13,61 @@
* refetch lands on a warm cache entry rather than a guaranteed miss.
*/
export const DASHBOARD_REFETCH_INTERVAL_MS = 120_000;
/*
* ── Every client poll, in one place ─────────────────────────────────────
*
* The web client polls in three shapes, and each poll belongs to exactly
* one of them. A new poll picks its shape here, and is added to this list.
*
* 1. STANDING — a fixed interval while the surface is mounted. TanStack
* Query pauses it while the tab is hidden (`refetchIntervalInBackground`
* defaults to false), so it costs nothing on a background tab.
*
* | Surface | Interval | Why this figure |
* | ------------------------------------ | -------- | ------------------------------------------------- |
* | dashboard snapshot, analytics, digest | 120 s | this constant; matches the snapshot cache TTL |
* | version poller (`version-poller.tsx`) | 60 s | a deploy notice within a minute; plain timer |
* | notification channel status | 30 s | last-delivery results move while the panel is open |
* | admin app-log preview | 30 s | an operator watching a live log |
* | achievement unlock notifier | 5 min | out-of-band unlocks; an app-global poll |
* | admin host metrics chart | 5 min | the sampler's own cadence |
*
* 2. UNTIL-DONE — polls while a server job is visibly running and stops on
* the terminal state the response reports. The interval is short because
* a person is watching a progress indicator; the stop condition is what
* bounds it, so it must be a state the server always reaches.
*
* | Surface | Interval | Stops when |
* | ------------------------------------- | -------- | -------------------------------------------- |
* | Apple Health / Health Connect import | 2 s | the job reports a terminal status |
* | admin backup restore list | 2 s | no restore is active |
* | documents list | 4 s | no document is inside its 3-minute upload |
* | | | processing window without an index |
* | Coach staged attachment | 2.5 s | the same: index exists or window passed |
* | admin pulse day-mean / maintenance | 10 s | the recompute reports done |
*
* 3. BOUNDED — polls while the server says fresh model text is on its way
* (`preparing` / `revalidating`), with an attempt ceiling, because a
* generation that keeps failing would otherwise poll an open page
* forever. The ceilings live beside the deciders and are unit-tested.
*
* | Surface | Interval | Ceiling |
* | ----------------------------- | -------- | ------------------------------------------ |
* | status cards (`use-insight-status.ts`, `nextStatusPollInterval`) | 4 s | 12 attempts |
* | advisor / briefing (`nextAdvisorPollInterval`) | 25 s | 10 attempts |
* | period narrative | 5 s | stops once the narrative arrives |
*
* The period narrative has no attempt ceiling: it stops on a state the
* server reaches (the warm writes text or clears `revalidating`), and it
* is only mounted while a person is looking at it.
*
* A document that is never indexed is the reason the two document polls are
* bounded by the upload window (`RECENT_UPLOAD_WINDOW_MS` in
* `src/components/documents/vault-utils.ts`) rather than by the index alone:
* until v1.43.7 the Coach polled such an attachment every 2.5 s for as long
* as it stayed open.
*
* Server-side timers (job heartbeats, the heap monitor, the log flush) are
* not client polls and are not listed.
*/