From 387304e829030978b59e1dea0f5ec4aaa71f39b9 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:15:53 +0000 Subject: [PATCH 1/2] fix: refresh custom automation run results --- ...AutomationsSettings.render.client.test.tsx | 81 ++++++++++++++++++- .../automations/CustomAutomationsSection.tsx | 38 +++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 4047e6fe9..0801016e1 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -293,7 +293,14 @@ const mutations = vi.hoisted(() => ({ ) => void; } | null, latestCustomTriggerOptions: null as { - onSuccess?: (result: { outcome: 'launched'; taskId: string }) => void; + onSuccess?: ( + result: + | { outcome: 'launched'; taskId: string } + | { outcome: 'queued' } + | { outcome: 'completed' } + | { outcome: 'skipped'; reason: string } + | { outcome: 'failed'; error: string }, + ) => void; } | null, })); @@ -624,8 +631,34 @@ function closeAutomationDialog() { fireEvent.click(screen.getByRole('button', { name: 'Close' })); } +function setRunnableCustomAutomation() { + state.customAutomations = [ + { + id: 'automation-1', + name: 'Daily scan', + prompt: 'Find flaky tests.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + executionMode: 'fast', + environmentId: '__fast__', + target: {}, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByName: 'Ada', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; +} + describe('AutomationsSettings', () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); state.nextUpdateSettingsResult = null; state.customAutomationRunPendingId = null; @@ -1244,6 +1277,9 @@ describe('AutomationsSettings', () => { action: expect.objectContaining({ label: 'View task' }), }), ); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['automations', 'listCustomAutomations'], + }); state.customAutomations.push({ ...state.customAutomations[0]!, @@ -1300,6 +1336,49 @@ describe('AutomationsSettings', () => { ).not.toBeInTheDocument(); }); + it.each([ + { outcome: 'completed' as const }, + { outcome: 'failed' as const, error: 'launch failed' }, + ])('refreshes persisted custom automation state after $outcome', (result) => { + setRunnableCustomAutomation(); + render(); + + act(() => { + mutations.latestCustomTriggerOptions?.onSuccess?.(result); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['automations', 'listCustomAutomations'], + }); + }); + + it.each([ + { outcome: 'launched' as const, taskId: 'task-custom-1' }, + { outcome: 'queued' as const }, + ])('uses bounded follow-up refreshes after $outcome', (result) => { + vi.useFakeTimers(); + setRunnableCustomAutomation(); + const { unmount } = render(); + + try { + act(() => { + mutations.latestCustomTriggerOptions?.onSuccess?.(result); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + + act(() => { + vi.runAllTimers(); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(8); + } finally { + unmount(); + vi.useRealTimers(); + } + }); + it('offers and displays the all-repositories workspace target', async () => { state.customAutomations = [ { diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index deecc9e4e..025327005 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -135,6 +135,18 @@ function cadenceLabel(row: CustomAutomationListItem): string { : 'Custom schedule'; } +// Fast runs settle asynchronously, so refresh sparsely through the existing +// ten-minute launch-claim recovery window instead of polling indefinitely. +const RUN_RESULT_REFRESH_DELAYS_MS = [ + 5_000, + 15_000, + 30_000, + 60_000, + 2 * 60_000, + 5 * 60_000, + 10 * 60_000, +]; + function CustomAutomationRunButton({ automation, disabled, @@ -143,9 +155,35 @@ function CustomAutomationRunButton({ disabled: boolean; }) { const trpc = useTRPC(); + const queryClient = useQueryClient(); + const refreshTimeoutsRef = useRef([]); + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: trpc.automations.listCustomAutomations.queryKey(), + }); + + useEffect( + () => () => { + for (const timeout of refreshTimeoutsRef.current) { + window.clearTimeout(timeout); + } + }, + [], + ); + const triggerMutation = useMutation({ ...trpc.automations.triggerCustomAutomation.mutationOptions({ onSuccess: (result) => { + void invalidate(); + if (result.outcome === 'launched' || result.outcome === 'queued') { + for (const timeout of refreshTimeoutsRef.current) { + window.clearTimeout(timeout); + } + refreshTimeoutsRef.current = RUN_RESULT_REFRESH_DELAYS_MS.map( + (delay) => window.setTimeout(() => void invalidate(), delay), + ); + } + switch (result.outcome) { case 'launched': toast.success(`Running ${automation.name} now`, { From 60894a0f5213117326de02499bd15f1593521c01 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:40:42 +0000 Subject: [PATCH 2/2] fix: stop automation refreshes after unmount --- ...AutomationsSettings.render.client.test.tsx | 24 +++++++++++++++++++ .../automations/CustomAutomationsSection.tsx | 17 ++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 0801016e1..f2366a3bd 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -1379,6 +1379,30 @@ describe('AutomationsSettings', () => { } }); + it('does not schedule follow-up refreshes after unmount', () => { + vi.useFakeTimers(); + setRunnableCustomAutomation(); + const { unmount } = render(); + const onSuccess = mutations.latestCustomTriggerOptions?.onSuccess; + + try { + unmount(); + act(() => { + onSuccess?.({ outcome: 'queued' }); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + + act(() => { + vi.runAllTimers(); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it('offers and displays the all-repositories workspace target', async () => { state.customAutomations = [ { diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index 025327005..a718b056e 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -156,26 +156,31 @@ function CustomAutomationRunButton({ }) { const trpc = useTRPC(); const queryClient = useQueryClient(); + const isMountedRef = useRef(true); const refreshTimeoutsRef = useRef([]); const invalidate = () => queryClient.invalidateQueries({ queryKey: trpc.automations.listCustomAutomations.queryKey(), }); - useEffect( - () => () => { + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; for (const timeout of refreshTimeoutsRef.current) { window.clearTimeout(timeout); } - }, - [], - ); + }; + }, []); const triggerMutation = useMutation({ ...trpc.automations.triggerCustomAutomation.mutationOptions({ onSuccess: (result) => { void invalidate(); - if (result.outcome === 'launched' || result.outcome === 'queued') { + if ( + isMountedRef.current && + (result.outcome === 'launched' || result.outcome === 'queued') + ) { for (const timeout of refreshTimeoutsRef.current) { window.clearTimeout(timeout); }