Files
buzz/desktop/src/features/agents/acpRuntimesQuery.ts
Will Pfleger c63ac746cd perf(desktop): split discover_acp_providers into cheap and forced paths (#6330)
Splits the `discover_acp_providers` Tauri command into a cheap cached
path and an explicit forced re-discovery path.

The profiling flight-recorder identified `discover_acp_providers` as the
acute UI-freeze correlate: a 20–65s probe pipeline (PATH re-fetch, CLI
auth probes, process spawns) was running on routine renders like channel
switches, because hot surfaces remounted the wrapping query and
automatic staleness refetches re-ran the full pipeline. This is
complementary to the idle auto-reload (#5588) and bounded-accumulator
(#5596) work.

## Backend
- Added a `force` parameter to `discover_acp_providers` /
`discover_acp_runtimes_from`. The default (cheap) path serves cached
availability and auth statuses with zero process spawns; `force: true`
runs the full pipeline (cache clear, PATH re-fetch, CLI auth probes).
- Added a single-flight coalescer (`forced_single_flight`) so concurrent
forced runs share one probe instead of stacking.
- Extracted the auth-status cache and `resolve_auth_statuses` into
`discovery/auth_status_cache.rs`.

## Renderer
- Raised `staleTime` to 30 min; forced refresh flows only through an
explicit `fetchQuery` path (`refreshAcpRuntimes`), so automatic
staleness refetches can never re-run probes.
- Added `useAcpRuntimesQueryForced` for Settings and onboarding
surfaces; connect/install/save/delete mutations force-refresh in
`onSettled`.
- Switched `SetupStep`, `HarnessCatalogDialog`, and
`HarnessesSettingsPanel` to the forced hook.

## Structure
The touched files were at or over the grandfathered 1000-line file-size
ceiling, so the change is delivered as cohesive extractions rather than
in-place growth: `forced_single_flight.rs`, `auth_status_cache.rs`,
`discovery/tests/forced_discovery.rs`, `acpRuntimesQuery.ts`, and
`tauriAcpDiscovery.ts`. Dependencies are one-directional (`hooks.ts`
imports from `acpRuntimesQuery.ts` and re-exports the forced hook, so
consumer imports are unchanged; no import cycle).

## Tests
Added Rust tests pinning the cheap-vs-forced split: a probe is spawned
only when forced, the cheap path reuses the cache, and auth status is
`Unknown` before any probe runs.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-20 19:44:05 -04:00

136 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery";
/**
* Shared React Query key for the ACP runtime catalog. Every consumer (cheap or
* forced) reads and writes this one entry, so a forced refresh updates the same
* cache the hot-path `useAcpRuntimesQuery` renders from.
*/
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
/**
* Separate key for the forced (full re-discovery) fetch. Forced refresh runs on
* *this* key, never the shared cheap key, so React Query's `fetchQuery` can
* never deduplicate a forced probe onto an in-flight cheap request for the
* shared key. The forced result is then written into the shared cache
* deliberately (see `refreshAcpRuntimes`).
*/
export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const;
/**
* Run a forced (full re-discovery) refresh and write the result into the shared
* runtime-catalog cache.
*
* This is the only path that pays the expensive discovery pipeline (cache
* clear, PATH re-fetch, CLI auth probes). Surfaces that need fresh state call
* it deliberately: Settings/onboarding on open and on their refresh buttons,
* and the connect/install/save/delete mutations in `onSettled`. A bare
* `invalidateQueries` would only re-run the cheap query path and never
* re-probe, so the freshly-changed auth/catalog state would not be reflected.
*
* The forced fetch runs on its own key so it can never coalesce onto an
* in-flight *cheap* request for the shared key (which would satisfy the caller
* with cached availability and never run the `{ force: true }` probe). Its
* result is then written into the shared cache with `setQueryData` so hot
* surfaces rendering `useAcpRuntimesQuery` re-render with the fresh catalog.
* Concurrent forced callers still dedup on the forced key; the backend
* coalesces overlapping forced runs as a second layer.
*/
export async function refreshAcpRuntimes(
queryClient: ReturnType<typeof useQueryClient>,
) {
try {
const result = await queryClient.fetchQuery({
queryKey: acpRuntimesForcedQueryKey,
queryFn: () => discoverAcpRuntimes({ force: true }),
staleTime: 0,
gcTime: 0,
});
queryClient.setQueryData(acpRuntimesQueryKey, result);
// A hot-surface cheap fetch may already be in flight on the shared key; cancel
// it so its (older, cached) result cannot land after and clobber the fresh
// forced catalog we just wrote.
await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey });
return result;
} catch {
// The forced probe rejected. `fetchQuery` has already recorded the error in
// the forced key's query state, where `useAcpRuntimesQueryForced` projects
// it into the hook's returned `error`/`isError`. Swallow the rejection here
// — at the single source — so the many fire-and-forget callers (mount,
// sign-in polling, refresh buttons, and the four mutation `onSettled`
// paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an
// unhandled rejection, and a new call site can never reintroduce one. The
// shared cache is left untouched so consumers keep the last good catalog
// alongside the surfaced error.
return undefined;
}
}
/**
* ACP runtimes query for surfaces that need fresh auth/version state: Settings
* harness panels and onboarding.
*
* It reads the shared runtime catalog (`enabled: false`, so it never fires its
* own cheap fetch — the forced probe below is the only fetcher) and re-renders
* whenever `refreshAcpRuntimes` writes a fresh catalog into that cache. Loading
* *and error* state are taken from a disabled observer on the forced key, so
* refresh buttons and the onboarding spinner reflect the forced probe and a
* failed probe surfaces as `error`/`isError` rather than a silent empty
* catalog. `forceRefresh` drives explicit refresh buttons and sign-in
* polling.
*
* `forceOnMount` (default `true`) is the surface owner's one force-on-mount.
* Child rows that share the same surface must pass `forceOnMount: false`: they
* consume the shared query state and the `forceRefresh` callback, but must not
* mount a *second* force effect. Each mounted force effect is a distinct forced
* probe, so an owner + N rows would otherwise re-run the 20–65s pipeline N+1
* times on entry (and race the catalog to a later state before the owner's
* first result renders).
*/
export function useAcpRuntimesQueryForced(options?: {
enabled?: boolean;
forceOnMount?: boolean;
}) {
const enabled = options?.enabled ?? true;
const forceOnMount = options?.forceOnMount ?? true;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: acpRuntimesQueryKey,
queryFn: () => discoverAcpRuntimes(),
staleTime: 30 * 60_000,
// Read-only observer: the forced refresh is the fetcher for these surfaces,
// so this must never fire a cheap fetch (which would race and could
// overwrite the fresh forced result with cached data).
enabled: false,
});
// Read-only observer on the forced key so the hook surfaces the forced
// probe's fetching *and error* state. `refreshAcpRuntimes` runs the fetch
// imperatively via `fetchQuery`; this disabled observer never fetches itself
// but reflects that query's state, so a rejected forced probe becomes a
// visible `error`/`isError` instead of an unhandled rejection with a silent
// empty/stale catalog.
const forcedQuery = useQuery({
queryKey: acpRuntimesForcedQueryKey,
queryFn: () => discoverAcpRuntimes({ force: true }),
enabled: false,
});
const forceRefresh = React.useCallback(
() => refreshAcpRuntimes(queryClient),
[queryClient],
);
React.useEffect(() => {
if (enabled && forceOnMount) void forceRefresh();
}, [enabled, forceOnMount, forceRefresh]);
const isFetching = query.isFetching || forcedQuery.isFetching;
return {
...query,
error: forcedQuery.error ?? query.error,
isError: forcedQuery.isError || query.isError,
isFetching,
isLoading: isFetching && query.data === undefined,
forceRefresh,
};
}