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