Files
multica/packages/core/runtimes/models.ts
Multica Eve c25a82eee0 perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* 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>
2026-07-29 16:03:26 +08:00

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