Fix AI quote polling terminal state race
Stop polling job status once the stream reports complete, no_quote, or error — and never overwrite an already-terminal UI state with a late Failed to fetch.
Modal intermittently flashed Failed to fetch even after the AI quote had been successfully created by the worker.
Stream emitted a terminal complete event, but the polling loop kept firing. A subsequent jobs.getStatus failure overwrote the completed state.
handleAIQuoteEvent now returns a terminal flag. The poller exits on terminal events, and the catch block refuses to clobber completed / no_quote states.
1. Why this exists
The AI quote flow has two parallel channels reporting progress to the UI:
- An event stream the worker emits as it runs (
step:start,ai:thinking,complete, …). - A polling loop that calls
trpc.jobs.getStatusto drain any buffered stream events and check BullMQ job status.
These two channels are not synchronized. The worker can emit a terminal complete event containing pricingQuoteId before BullMQ transitions the job to status: completed. In the previous code, receiving the terminal stream event updated the UI to completed, but the polling loop kept running against a job that was, from BullMQ's perspective, still in flight.
complete" and "BullMQ marks job complete", any transient network hiccup in the next jobs.getStatus call landed in the catch block — which unconditionally set status: 'error' with error.message = 'Failed to fetch'. The quote had already been written; the UI just refused to admit it.
2. The race, visualized
Time flows left → right. The worker (top lane) finishes and writes the quote before BullMQ (middle lane) has flipped the job status. The client poller (bottom lane) keeps firing during that window.
3. What changes
Change A — Every event branch declares whether it is terminal
handleAIQuoteEvent switches from returning void to returning boolean. Non-terminal events (17 of them) return false; the three terminal events return true. A default trailing return false covers any unmatched shape.
case 'complete':
setState((prev) => ({
...prev,
status: 'completed',
result: data.result,
}));
break;
case 'no_quote':
setState(...);
break;
case 'error':
setState(...);
break;
case 'complete':
setState((prev) => ({
...prev,
status: 'completed',
result: data.result,
}));
return true;
case 'no_quote':
setState(...);
return true;
case 'error':
setState(...);
return true;
Change B — Polling loop exits on terminal events
Inside the drain loop, the return value is captured. On true, the loop clears activeJobRef and returns out of the polling function entirely — no further jobs.getStatus calls will happen for this job.
for (const item of progress.events) {
if (item.sequence > lastSequence) {
lastSequence = item.sequence;
const reachedTerminalState = handleAIQuoteEvent(item.event);
if (reachedTerminalState) {
activeJobRef.current = null;
return;
}
}
}
Change C — Catch block refuses to clobber terminal UI states
The polling catch handler now inspects the current state before writing an error. If the UI is already completed or no_quote, the previous state is returned unchanged.
setState((prev) => ({
...prev,
status: 'error',
error: error.message
|| 'Failed to run AI quoting service',
}));
setState((prev) => {
if (prev.status === 'completed'
|| prev.status === 'no_quote') {
return prev;
}
return {
...prev,
status: 'error',
error: error.message
|| 'Failed to run AI quoting service',
};
});
4. How it works end-to-end
The drain loop pulls a complete / no_quote / error event out of progress.events and applies it to state.
handleAIQuoteEvent returns true. The loop nulls activeJobRef and returns — no more polling requests are scheduled.
Even if some other in-flight request errors after step 2, the catch block sees prev.status === 'completed' and refuses to overwrite it.
5. What it doesn't change
- Wire protocol of stream events — no new event types, no changed payloads.
- tRPC route shapes —
jobs.getStatusis unchanged. - Worker-side behavior — the ordering of "emit
complete" before "BullMQ marks completed" is not being fixed; the client just tolerates it now. - Error handling for genuinely-failed runs — if the terminal event is
error, the UI still transitions tostatus: 'error'. - The step-level reducers (
step:start,step:progress, etc.) — they gained areturn falsebut behavior is identical.
6. Risks & open questions
apps/client/src/dashboard/OpportunityV2/hooks/useAIQuoteStream.ts. All changes are additive returns or defensive guards. Author verified with tsc --pretty false on the client package.
handleAIQuoteEvent's return value outside this hook? The diff suggests not (it's declared inside the hook), but a quick grep for callers would rule it out.