fix(frontend): fence a confirmed background-task cancellation against stale polls (#7914)

A background-task detail or list read issued before the user cancels can
resolve after the mutation's success handler and overwrite the confirmed
"cancelled" snapshot with the pre-cancel "working" one, so the expanded card
shows the task as active again (and polling resumes) until a later response
corrects it.

useCancelBackgroundTask now awaits cancelQueries for the thread's
["background-tasks", threadId] prefix before seeding the confirmed detail and
list entry, so a read that started before the cancel is dropped instead of
landing afterwards. The following list invalidation is unchanged, so a mounted
card still refreshes from the server. This is the same ordering contract as the
memory mutations fixed in #6959.

Regression: frontend/tests/unit/core/background-tasks/hooks.dom.test.tsx uses a
real QueryClient with deferred detail and list reads; it ends on "working"
without the fix and keeps "cancelled" with it.

Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
This commit is contained in:
Rei 2026-11-06 10:29:57 +08:00 committed by GitHub
parent ee006e0c86
commit 097fa6dca7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 215 additions and 1 deletions

View File

@ -59,6 +59,7 @@ EXPECTED_GUIDANCE_PATHS = {
"frontend/src/components/workspace/AGENTS.md",
"frontend/src/components/workspace/browser-view/AGENTS.md",
"frontend/src/core/auth/AGENTS.md",
"frontend/src/core/background-tasks/AGENTS.md",
"frontend/src/core/memory/AGENTS.md",
"frontend/src/core/models/AGENTS.md",
"frontend/tests/e2e/AGENTS.md",

View File

@ -0,0 +1,8 @@
# Background-task queries
Cancelling a task must not be undone by a poll that started before the cancel
answered: the mutation drops the thread's `["background-tasks", threadId]`
reads before seeding the confirmed detail and list entry, then invalidates the
list prefix. Same ordering contract as `core/memory` (#6959). Regressions use a
real `QueryClient` in
`frontend/tests/unit/core/background-tasks/hooks.dom.test.tsx`.

View File

@ -55,7 +55,14 @@ export function useCancelBackgroundTask(threadId: string) {
const { t } = useI18n();
return useMutation({
mutationFn: (taskId: string) => cancelBackgroundTask(threadId, taskId),
onSuccess: (task) => {
onSuccess: async (task) => {
// This thread's task list and detail reads poll the same caches the
// confirmation is about to seed. A read that started before the cancel
// answered must not land afterwards and put the card back to "working"
// (#7177); same ordering contract as the memory mutations (#6959).
await queryClient.cancelQueries({
queryKey: backgroundTasksQueryKey(threadId),
});
queryClient.setQueryData(
backgroundTaskQueryKey(threadId, task.task_id),
task,

View File

@ -0,0 +1,198 @@
import { afterEach, expect, test, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import * as api from "@/core/background-tasks/api";
import {
backgroundTaskQueryKey,
backgroundTasksQueryKey,
useCancelBackgroundTask,
} from "@/core/background-tasks/hooks";
import type {
BackgroundTask,
BackgroundTaskDetail,
BackgroundTaskStatus,
} from "@/core/background-tasks/types";
import { enUS } from "@/core/i18n/locales/en-US";
rs.mock("@/core/background-tasks/api", () => ({
fetchBackgroundTasks: rs.fn(),
fetchBackgroundTask: rs.fn(),
cancelBackgroundTask: rs.fn(),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
const THREAD_ID = "thread-1";
const TASK_ID = "task-1";
function task(status: BackgroundTaskStatus): BackgroundTask {
return {
task_id: TASK_ID,
task_name: "MCP task",
status,
created_at: "2026-10-20T07:00:00Z",
updated_at: "2026-10-20T07:00:03Z",
error: null,
tracking_degraded: false,
cancel_requested: status === "cancelled",
};
}
function detail(status: BackgroundTaskStatus): BackgroundTaskDetail {
return {
...task(status),
last_polled_at: null,
last_poll_error: null,
last_cancel_error: null,
cancel_attempt_count: 0,
notification_status: "none",
notification_error: null,
notification_attempt_count: 0,
result: null,
result_preview: null,
result_truncated: false,
result_artifact: null,
input_required: null,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const clients: QueryClient[] = [];
afterEach(() => {
cleanup();
for (const client of clients.splice(0)) client.clear();
rs.resetAllMocks();
});
function makeClient(seed: BackgroundTaskDetail): QueryClient {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false, gcTime: Infinity },
},
});
clients.push(client);
client.setQueryData(backgroundTasksQueryKey(THREAD_ID), [task(seed.status)]);
client.setQueryData(backgroundTaskQueryKey(THREAD_ID, TASK_ID), seed);
return client;
}
function wrapperFor(client: QueryClient) {
return function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
test("a detail or list poll that started before the cancel cannot undo it", async () => {
const working = detail("working");
const cancelled = detail("cancelled");
const staleDetail = deferred<BackgroundTaskDetail>();
const staleList = deferred<BackgroundTask[]>();
rs.mocked(api.fetchBackgroundTask).mockReturnValue(staleDetail.promise);
rs.mocked(api.fetchBackgroundTasks).mockReturnValue(staleList.promise);
rs.mocked(api.cancelBackgroundTask).mockResolvedValue(cancelled);
const client = makeClient(working);
const { result } = renderHook(() => useCancelBackgroundTask(THREAD_ID), {
wrapper: wrapperFor(client),
});
// Both the expanded card's detail poll and the list poll were in flight when
// the user pressed Cancel, so both still carry the pre-cancel snapshot.
let detailRead!: Promise<BackgroundTaskDetail>;
let listRead!: Promise<BackgroundTask[]>;
act(() => {
detailRead = client.fetchQuery({
queryKey: backgroundTaskQueryKey(THREAD_ID, TASK_ID),
queryFn: () => api.fetchBackgroundTask(THREAD_ID, TASK_ID),
});
listRead = client.fetchQuery({
queryKey: backgroundTasksQueryKey(THREAD_ID),
queryFn: () => api.fetchBackgroundTasks(THREAD_ID),
});
});
expect(api.fetchBackgroundTask).toHaveBeenCalledTimes(1);
expect(api.fetchBackgroundTasks).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.mutateAsync(TASK_ID);
});
const detailKey = backgroundTaskQueryKey(THREAD_ID, TASK_ID);
const listKey = backgroundTasksQueryKey(THREAD_ID);
expect(client.getQueryData<BackgroundTaskDetail>(detailKey)?.status).toBe(
"cancelled",
);
expect(client.getQueryData<BackgroundTask[]>(listKey)?.[0]?.status).toBe(
"cancelled",
);
await act(async () => {
staleDetail.resolve(working);
staleList.resolve([task("working")]);
await Promise.allSettled([detailRead, listRead]);
});
// The confirmed cancellation survives the reads that resolved after it, so
// the card does not fall back to "working" and resume polling.
expect(client.getQueryData<BackgroundTaskDetail>(detailKey)?.status).toBe(
"cancelled",
);
expect(client.getQueryData<BackgroundTask[]>(listKey)?.[0]?.status).toBe(
"cancelled",
);
});
test("a failed cancel keeps the cache and lets the in-flight read finish", async () => {
const working = detail("working");
const refreshed = detail("working");
refreshed.last_polled_at = "2026-10-20T07:00:06Z";
const staleDetail = deferred<BackgroundTaskDetail>();
rs.mocked(api.fetchBackgroundTask).mockReturnValue(staleDetail.promise);
rs.mocked(api.cancelBackgroundTask).mockRejectedValue(
new Error("Cancel failed"),
);
const client = makeClient(working);
const { result } = renderHook(() => useCancelBackgroundTask(THREAD_ID), {
wrapper: wrapperFor(client),
});
let detailRead!: Promise<BackgroundTaskDetail>;
act(() => {
detailRead = client.fetchQuery({
queryKey: backgroundTaskQueryKey(THREAD_ID, TASK_ID),
queryFn: () => api.fetchBackgroundTask(THREAD_ID, TASK_ID),
});
});
await act(async () => {
await expect(result.current.mutateAsync(TASK_ID)).rejects.toThrow(
"Cancel failed",
);
});
expect(
client.getQueryData<BackgroundTaskDetail>(
backgroundTaskQueryKey(THREAD_ID, TASK_ID),
),
).toEqual(working);
await act(async () => {
staleDetail.resolve(refreshed);
await detailRead;
});
expect(
client.getQueryData<BackgroundTaskDetail>(
backgroundTaskQueryKey(THREAD_ID, TASK_ID),
)?.last_polled_at,
).toBe("2026-10-20T07:00:06Z");
});