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.

PR dealops#6219 Author yijunz166 Branch codex/ai-quote-polling-terminal-fix Files 1 Diff +30 / −19 Area Dealops 2 · client

Symptom

Modal intermittently flashed Failed to fetch even after the AI quote had been successfully created by the worker.

Root cause

Stream emitted a terminal complete event, but the polling loop kept firing. A subsequent jobs.getStatus failure overwrote the completed state.

Fix

handleAIQuoteEvent now returns a terminal flag. The poller exits on terminal events, and the catch block refuses to clobber completed / no_quote states.

Terminal-success events
Terminal-error event
Non-terminal (in-flight) events
Polling loop

1. Why this exists

The AI quote flow has two parallel channels reporting progress to the UI:

  1. An event stream the worker emits as it runs (step:start, ai:thinking, complete, …).
  2. A polling loop that calls trpc.jobs.getStatus to 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.

The race window. Between "stream emits 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.

Worker (stream) BullMQ job state Client poller t₀ time → step:start ai:thinking step:complete complete status: active status: completed poll #5 throws applies to UI: completed OLD: overwrites to "Failed to fetch" race 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.

Before — break out, keep polling
case 'complete':
  setState((prev) => ({
    ...prev,
    status: 'completed',
    result: data.result,
  }));
  break;

case 'no_quote':
  setState(...);
  break;

case 'error':
  setState(...);
  break;
After — signal terminal to caller
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.

Before
setState((prev) => ({
  ...prev,
  status: 'error',
  error: error.message
    || 'Failed to run AI quoting service',
}));
After
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

1 · Stream event arrives

The drain loop pulls a complete / no_quote / error event out of progress.events and applies it to state.

2 · Poll loop short-circuits

handleAIQuoteEvent returns true. The loop nulls activeJobRef and returns — no more polling requests are scheduled.

3 · Defense in depth

Even if some other in-flight request errors after step 2, the catch block sees prev.status === 'completed' and refuses to overwrite it.

Why belt-and-suspenders? Change B alone almost certainly fixes the reported symptom, but change C protects against any future code path that keeps polling after the stream terminates — including retries, other callers of the same catch block, or partially-cancelled fetches that reject after the loop has already exited.

5. What it doesn't change

6. Risks & open questions

Low blast radius. One file, one hook, in 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.
Worth confirming. Are there other consumers of 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.
Follow-up (out of scope). The underlying ordering — worker emits terminal event before BullMQ marks completion — is preserved. If the server-side sequence is ever tightened, this client fix remains correct and can stay in place.