From 8e8993be3cf44bfbba514b13662de99544e67fd1 Mon Sep 17 00:00:00 2001 From: Otto Date: Mon, 23 Feb 2026 13:58:45 +0000 Subject: [PATCH] fix(copilot): prevent double output from StreamFinish/mark_task_completed race (#SECRT-2021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/backend/copilot/executor/processor.py | 13 ++++++++++++- .../src/app/(platform)/copilot/useCopilotPage.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/executor/processor.py b/autogpt_platform/backend/backend/copilot/executor/processor.py index 81d616a759..6d5b44c58a 100644 --- a/autogpt_platform/backend/backend/copilot/executor/processor.py +++ b/autogpt_platform/backend/backend/copilot/executor/processor.py @@ -257,10 +257,21 @@ class CoPilotProcessor: cluster_lock.refresh() last_refresh = current_time + # Intercept StreamFinish: don't publish it directly. + # mark_task_completed atomically sets status to "completed" + # and THEN publishes StreamFinish. Publishing StreamFinish + # before the status update causes a race where the frontend + # sees the stream as finished but the task is still "running", + # triggering a spurious resume that replays the entire stream + # (double output bug — SECRT-2021). + if isinstance(chunk, StreamFinish): + break + # Publish chunk to stream registry await stream_registry.publish_chunk(entry.task_id, chunk) - # Mark task as completed + # Mark task as completed — this publishes StreamFinish AFTER + # atomically setting status to "completed", preventing the race. await stream_registry.mark_task_completed(entry.task_id, status="completed") log.info("Task completed successfully") diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 0f6a17e666..6b965d0635 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -208,8 +208,13 @@ export function useCopilotPage() { queryClient.invalidateQueries({ queryKey: getGetV2GetSessionQueryKey(sessionId), }); - // Allow re-resume if the backend task is still running. - hasResumedRef.current = null; + // 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_task_completed (SECRT-2021). + if (status === "error") { + hasResumedRef.current = null; + } } }, [status, sessionId, queryClient]);