use-aop-refresh-watcher.ts3.6 KBView on GitHub /**
* Watches the background agent refresh started by an AOP change.
*
* Changing a conversation's AOP no longer blocks the HTTP response on the refresh — that
* inline await was holding requests past CloudFront's 60s origin timeout (see
* apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md). The mutation now
* returns as soon as `aopId` is durable and hands back the refresh's `runId`.
*
* That trade means the conversation's fields keep changing for ~30s (p90 ~90s) after the
* response, with nothing on screen to explain it. This hook closes that gap: while the
* run is live the conversation reports "updating fields", and when it reaches a terminal
* status the conversation queries are invalidated so the refreshed values land.
*
* It polls rather than listening: `sseEventBus` exists server-side but its
* subscription-manager has no importers, so there is no live push channel to the client
* for executions today. If one is wired up later, this hook is the single place to swap.
*/
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query';
import { isTerminalExecutionStatus } from '@zero/server/schemas';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { useEffect } from 'react';
const POLL_INTERVAL_MS = 4_000;
/**
* Stop watching after this long even if the execution never reaches a terminal status.
* The server closes a stranded row itself (see failRefreshExecution), so this only covers
* the case where the process handling the refresh died outright — without it the
* indicator would spin forever. Comfortably above the observed 176s worst case.
*/
const MAX_WATCH_MS = 5 * 60 * 1000;
export function useAopRefreshWatcher(conversationId: string | null | undefined) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const runId = useCedarStore((state) =>
conversationId ? state.conversations[conversationId]?.aopRefreshRunId : undefined,
);
const setAopRefreshRun = useCedarStore((state) => state.setAopRefreshRun);
// `skipToken` rather than a placeholder input plus `enabled: false`: there is no run to
// watch, so there is no input to name. A sentinel would also be one `enabled` regression
// away from actually sending itself to the server.
const { data } = useQuery({
...trpc.agentExecutions.getAgentExecutionsByIds.queryOptions(
runId ? { runIds: [runId] } : skipToken,
),
refetchInterval: POLL_INTERVAL_MS,
staleTime: 0,
});
// Run finished → drop the indicator and pull the refreshed fields in.
useEffect(() => {
if (!runId || !conversationId) return;
const status = data?.[runId]?.status;
// Terminal-status list comes from the server's own column union — see
// db/execution-status.ts. A renamed status is a type error there, not a spinner here.
if (!isTerminalExecutionStatus(status)) return;
setAopRefreshRun(conversationId, null);
void queryClient.invalidateQueries({
queryKey=[redacted] id: conversationId }),
});
void queryClient.invalidateQueries({
queryKey=[redacted],
});
}, [data, runId, conversationId, setAopRefreshRun, queryClient, trpc]);
// Safety valve — never leave the indicator spinning indefinitely.
useEffect(() => {
if (!runId || !conversationId) return;
const timer = setTimeout(() => setAopRefreshRun(conversationId, null), MAX_WATCH_MS);
return () => clearTimeout(timer);
}, [runId, conversationId, setAopRefreshRun]);
return { isRefreshingFields: !!runId };
}