fix(copilot): prevent double output from StreamFinish/mark_task_completed race (#SECRT-2021)

Requested by @0ubbe

The CoPilot consistently displays every response twice. Root cause is a
race condition between StreamFinish delivery and mark_task_completed:

1. Executor publishes StreamFinish to Redis stream
2. Frontend receives it, transitions to 'ready', invalidates cache
3. mark_task_completed runs AFTER StreamFinish (separate operation)
4. Session refetch sees task still 'running' → spurious resumeStream()
5. Resume replays entire Redis stream from '0-0' → double output

Backend fix (processor.py): Intercept StreamFinish from the generator
instead of publishing it directly. mark_task_completed atomically sets
status to 'completed' THEN publishes StreamFinish, ensuring clients
never see the finish event while the task is still marked as running.

Frontend fix (useCopilotPage.ts): Only reset hasResumedRef on error
(SSE drop), not on clean stream finish. This prevents the spurious
resume even if the backend timing changes.
This commit is contained in:
Otto
2026-02-23 13:58:45 +00:00
committed by Lluis Agusti
parent 9f002ce8f6
commit 0a73dc56ca

View File

@@ -236,7 +236,10 @@ export function useCopilotPage() {
}, [hydratedMessages, setMessages, status]);
// Ref: tracks whether we've already resumed for a given session.
// Format: Map<sessionId, hasResumed>
// Only cleared on error (SSE drop) to allow re-resume when the backend
// task is still running. Not cleared on clean finish (status "ready")
// to prevent a spurious resume if the session refetch races with
// mark_session_completed (SECRT-2021).
const hasResumedRef = useRef<Map<string, boolean>>(new Map());
// When the stream ends (or drops), invalidate the session cache so the
@@ -254,6 +257,13 @@ export function useCopilotPage() {
queryClient.invalidateQueries({
queryKey: getGetV2GetSessionQueryKey(sessionId),
});
// Only allow re-resume on error (SSE drop without clean finish).
// On clean finish (status === "ready"), the backend task is done —
// resetting the ref would allow a spurious resume if the session
// refetch races with mark_session_completed (SECRT-2021).
if (status === "error") {
hasResumedRef.current.delete(sessionId);
}
}
}, [status, sessionId, queryClient]);