mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
98 lines
4.3 KiB
TypeScript
98 lines
4.3 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type { RuntimeModelsResult } from "../types/agent";
|
|
|
|
export const runtimeModelsKeys = {
|
|
all: () => ["runtimes", "models"] as const,
|
|
forRuntime: (runtimeId: string) =>
|
|
[...runtimeModelsKeys.all(), runtimeId] as const,
|
|
};
|
|
|
|
const POLL_INTERVAL_MS = 500;
|
|
const POLL_TIMEOUT_MS = 30_000;
|
|
|
|
// How long a LIVE discovery result (one the daemon just produced) is trusted
|
|
// without re-asking. Discovery is a round trip to the user's machine, and a
|
|
// catalog only changes when they upgrade a CLI, switch accounts, or edit a
|
|
// provider config, so re-running it inside one form session is pure waste.
|
|
export const LIVE_MODELS_STALE_TIME_MS = 5 * 60_000;
|
|
// Kept long so a runtime revisited later in the same session renders from the
|
|
// client cache instead of the "discovering models" spinner. Freshness is
|
|
// governed by staleTime; gcTime only decides how long the entry survives while
|
|
// unused.
|
|
export const MODELS_GC_TIME_MS = 30 * 60_000;
|
|
|
|
// resolveRuntimeModels initiates a list-models request against the daemon
|
|
// (via heartbeat piggyback) and polls until the daemon reports back or
|
|
// the request times out. Returns both the models list and a
|
|
// `supported` flag: `supported=false` means the provider ignores
|
|
// per-agent model selection entirely (hermes today) — the UI uses
|
|
// this to disable its dropdown instead of accepting a value that
|
|
// wouldn't be honoured at runtime.
|
|
//
|
|
// `cached` reports that the server answered from its catalog cache rather than
|
|
// a live daemon round trip (MUL-5444). It is not cosmetic: it feeds the
|
|
// staleTime policy below so the client never extends the server's staleness
|
|
// window past what the server itself promises.
|
|
export async function resolveRuntimeModels(
|
|
runtimeId: string,
|
|
): Promise<RuntimeModelsResult> {
|
|
const initial = await api.initiateListModels(runtimeId);
|
|
const start = Date.now();
|
|
let current = initial;
|
|
while (current.status === "pending" || current.status === "running") {
|
|
if (Date.now() - start > POLL_TIMEOUT_MS) {
|
|
throw new Error("model discovery timed out");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
current = await api.getListModelsResult(runtimeId, initial.id);
|
|
}
|
|
// Only an explicit `completed` is a catalog. Anything else — failed, timeout,
|
|
// or a status this client does not know (newer server, or a response that fell
|
|
// back to the malformed-record shape) — is surfaced as an error so the picker
|
|
// shows "discovery failed" and keeps manual entry available. Treating an
|
|
// unrecognised status as success would render an empty dropdown that looks
|
|
// authoritative.
|
|
if (current.status !== "completed") {
|
|
throw new Error(
|
|
current.error || `model discovery failed (status: ${current.status})`,
|
|
);
|
|
}
|
|
return {
|
|
models: current.models ?? [],
|
|
supported: current.supported !== false,
|
|
cached: current.cached === true,
|
|
cachedAt: current.cached_at,
|
|
};
|
|
}
|
|
|
|
// staleTimeFor is the freshness policy, split out so it can be unit-tested
|
|
// without a QueryClient.
|
|
//
|
|
// A cached answer is treated as stale immediately. The server serves a snapshot
|
|
// up to `modelCatalogServeWindow` old and queues its own background refresh;
|
|
// if the client ALSO held that response as fresh for minutes, the observable
|
|
// staleness would be server window + client window (and the refreshed catalog
|
|
// would never reach the tab that triggered the refresh). Returning 0 keeps the
|
|
// bound at the server's window alone: the next mount / focus revalidates, the
|
|
// refreshed snapshot is picked up, and because the query already holds data
|
|
// React Query refetches in the background — `isLoading` stays false, so no
|
|
// picker flashes an empty loading state (MUL-5444).
|
|
export function staleTimeFor(data: RuntimeModelsResult | undefined): number {
|
|
if (!data) return 0;
|
|
return data.cached ? 0 : LIVE_MODELS_STALE_TIME_MS;
|
|
}
|
|
|
|
export function runtimeModelsOptions(runtimeId: string | null | undefined) {
|
|
return queryOptions({
|
|
queryKey: runtimeId
|
|
? runtimeModelsKeys.forRuntime(runtimeId)
|
|
: runtimeModelsKeys.all(),
|
|
queryFn: () => resolveRuntimeModels(runtimeId as string),
|
|
enabled: Boolean(runtimeId),
|
|
staleTime: (query) => staleTimeFor(query.state.data),
|
|
gcTime: MODELS_GC_TIME_MS,
|
|
retry: false,
|
|
});
|
|
}
|