Compare commits

...

1 Commits

Author SHA1 Message Date
Otto
0a73dc56ca 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.
2026-02-25 20:50:52 +08:00

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]);