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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-07-29 16:03:26 +08:00
committed by GitHub
co-authored by multica-agent Eve
parent 280fa28e0d
commit c25a82eee0
22 changed files with 2131 additions and 11 deletions
+84
View File
@@ -1509,3 +1509,87 @@ describe("ApiClient", () => {
});
});
});
describe("ApiClient model discovery response schema", () => {
const completed = {
id: "req-1",
runtime_id: "rt-1",
status: "completed",
supported: true,
created_at: "2026-07-29T00:00:00Z",
updated_at: "2026-07-29T00:00:01Z",
models: [{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }],
};
function stubJSON(body: unknown) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
}
it("parses a live completed discovery", async () => {
stubJSON(completed);
const result = await new ApiClient("https://api.example.test")
.initiateListModels("rt-1");
expect(result).toMatchObject({
status: "completed",
supported: true,
models: [{ id: "claude-sonnet-4-6" }],
});
});
it("keeps the cache markers on a server-cached snapshot", async () => {
stubJSON({ ...completed, cached: true, cached_at: "2026-07-29T00:00:00Z" });
const result = await new ApiClient("https://api.example.test")
.initiateListModels("rt-1");
expect(result.cached).toBe(true);
expect(result.cached_at).toBe("2026-07-29T00:00:00Z");
});
// The picker drives a state machine off `status`, so a malformed body must
// become an explicit failure — not a fabricated empty catalog, and not an
// endless "discovering models" spinner.
it("degrades a malformed initiate response to an explicit failure", async () => {
stubJSON({ status: 7, models: "nope" });
const result = await new ApiClient("https://api.example.test")
.initiateListModels("rt-1");
expect(result.status).toBe("failed");
expect(result.supported).toBe(true);
expect(result.error).toBe("invalid model discovery response");
expect(result.runtime_id).toBe("rt-1");
});
it("degrades a malformed poll response to an explicit failure", async () => {
stubJSON("not-an-object");
const result = await new ApiClient("https://api.example.test")
.getListModelsResult("rt-1", "req-9");
expect(result.status).toBe("failed");
expect(result.id).toBe("req-9");
expect(result.runtime_id).toBe("rt-1");
});
it("stays usable against a backend that omits supported", async () => {
const { supported: _omitted, ...withoutSupported } = completed;
stubJSON(withoutSupported);
const result = await new ApiClient("https://api.example.test")
.getListModelsResult("rt-1", "req-1");
expect(result.supported).toBe(true);
expect(result.status).toBe("completed");
});
});
+29 -2
View File
@@ -295,6 +295,8 @@ import {
EMPTY_GITHUB_CONNECT_RESPONSE,
EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE,
EMPTY_LIST_GITHUB_REPOSITORIES_RESPONSE,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -1680,15 +1682,40 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
}
// Both discovery endpoints feed a UI state machine (poll while
// pending/running, then render or fail), so the response is validated rather
// than cast: an unparseable body degrades to an explicit "failed" record that
// shows the discovery error and keeps manual model entry usable, instead of a
// fabricated empty catalog or an endless spinner (MUL-5444).
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
const raw = await this.fetch<unknown>(`/api/runtimes/${runtimeId}/models`, {
method: "POST",
});
return parseWithFallback<RuntimeModelListRequest>(
raw,
RuntimeModelListRequestSchema,
{ ...MALFORMED_RUNTIME_MODEL_LIST_REQUEST, runtime_id: runtimeId },
{ endpoint: "POST /api/runtimes/{id}/models" },
);
}
async getListModelsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/models/${requestId}`,
);
return parseWithFallback<RuntimeModelListRequest>(
raw,
RuntimeModelListRequestSchema,
{
...MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
id: requestId,
runtime_id: runtimeId,
},
{ endpoint: "GET /api/runtimes/{id}/models/{requestId}" },
);
}
async initiateListLocalSkills(
+118
View File
@@ -24,6 +24,8 @@ import {
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
ListPropertiesResponseSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
RuntimeModelListRequestSchema,
SearchProjectsResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
@@ -989,3 +991,119 @@ describe("CommentTriggerPreviewSchema.blocked", () => {
expect(parsed.blocked.map((b) => b.target_id)).toEqual(["s1", "a1"]);
});
});
describe("RuntimeModelListRequestSchema", () => {
const completed = {
id: "req-1",
runtime_id: "rt-1",
status: "completed",
supported: true,
created_at: "2026-07-29T00:00:00Z",
updated_at: "2026-07-29T00:00:01Z",
models: [
{
id: "gpt-5.6-sol",
label: "GPT-5.6-Sol",
provider: "openai",
default: true,
thinking: {
supported_levels: [{ value: "high", label: "High" }],
default_level: "low",
},
service_tiers: [{ id: "fast", name: "Fast" }],
},
],
};
it("parses a live completed discovery, keeping the fields the UI branches on", () => {
const parsed = parseWithFallback(
completed,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect(parsed.status).toBe("completed");
expect(parsed.supported).toBe(true);
expect(parsed.models?.[0]?.default).toBe(true);
expect(parsed.models?.[0]?.thinking?.supported_levels).toEqual([
{ value: "high", label: "High" },
]);
expect(parsed.models?.[0]?.service_tiers).toEqual([{ id: "fast", name: "Fast" }]);
expect(parsed.cached).toBeUndefined();
});
it("keeps the additive cache markers when the server serves a snapshot", () => {
const parsed = parseWithFallback(
{ ...completed, cached: true, cached_at: "2026-07-29T00:00:00Z" },
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect(parsed.cached).toBe(true);
expect(parsed.cached_at).toBe("2026-07-29T00:00:00Z");
});
// A backend that predates MUL-5444 sends neither marker; an even older one
// may omit `supported`. Both must stay usable rather than reading as
// "runtime manages the model itself" off an undefined.
it("defaults supported to true on an older backend that omits it", () => {
const { supported: _omitted, ...withoutSupported } = completed;
const parsed = parseWithFallback(
withoutSupported,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect(parsed.supported).toBe(true);
expect(parsed.cached).toBeUndefined();
});
it("passes an unknown status through instead of failing the whole response", () => {
const parsed = parseWithFallback(
{ ...completed, status: "superseded" },
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect(parsed.status).toBe("superseded");
});
// Malformed bodies must land on the "failed" fallback: `completed` would
// fabricate an empty catalog and `pending` would spin the picker until the
// client-side poll timeout.
it("falls back to an explicit failure on a malformed body", () => {
for (const malformed of [
null,
"nope",
42,
{},
{ status: 7 },
{ ...completed, status: undefined },
{ ...completed, supported: "yes" },
{ ...completed, models: "nope" },
{ ...completed, models: [{ label: "no id" }] },
]) {
const parsed = parseWithFallback(
malformed,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect(parsed.status).toBe("failed");
expect(parsed.supported).toBe(true);
expect(parsed.error).toBe("invalid model discovery response");
}
});
it("keeps unknown server fields instead of stripping them", () => {
const parsed = parseWithFallback(
{ ...completed, future_field: "keep me" },
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
{ endpoint: "test" },
);
expect((parsed as unknown as { future_field?: string }).future_field).toBe(
"keep me",
);
});
});
+78
View File
@@ -39,6 +39,7 @@ import type {
ListWebhookDeliveriesResponse,
NotificationPreferenceResponse,
ResourceLabelsResponse,
RuntimeModelListRequest,
SearchIssuesResponse,
SearchProjectsResponse,
Squad,
@@ -1820,3 +1821,80 @@ export const CreateBillingPortalSessionResponseSchema = z.object({
export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSessionResponse = {
url: "",
};
// ---------------------------------------------------------------------------
// Runtime model discovery (`POST /api/runtimes/:id/models`,
// `GET /api/runtimes/:id/models/:requestId`). Both endpoints return the same
// request record, and the UI drives a state machine off `status`, so the two
// fields that decide behaviour are pinned: `status` gates the polling loop and
// `supported` gates whether the picker is usable at all. Everything else stays
// lenient per the rules at the top of this file.
//
// `status` deliberately stays `z.string()` (a newer server may add a state);
// `resolveRuntimeModels` treats anything it does not recognise as an explicit
// failure rather than a completed-but-empty catalog. `supported` defaults to
// true so a server old enough to omit it keeps the picker enabled instead of
// rendering "managed by runtime" off an `undefined`.
//
// `cached` / `cached_at` are additive markers for a snapshot served from the
// server-side catalog cache (MUL-5444); an older backend omits them.
// ---------------------------------------------------------------------------
const RuntimeModelThinkingLevelSchema = z.object({
value: z.string(),
label: z.string().default(""),
description: z.string().optional(),
}).loose();
const RuntimeModelThinkingSchema = z.object({
supported_levels: z.array(RuntimeModelThinkingLevelSchema).default([]),
default_level: z.string().optional(),
}).loose();
const RuntimeModelServiceTierSchema = z.object({
id: z.string(),
name: z.string().default(""),
description: z.string().optional(),
}).loose();
// A model entry with no `id` is unselectable — `onChange(m.id)` would persist
// an empty model — so `id` is required and a malformed entry drops the whole
// response to the fallback rather than rendering a dead row.
const RuntimeModelSchema = z.object({
id: z.string(),
label: z.string().default(""),
provider: z.string().optional(),
default: z.boolean().optional(),
thinking: RuntimeModelThinkingSchema.nullable().optional()
.transform((v) => v ?? undefined),
service_tiers: z.array(RuntimeModelServiceTierSchema).optional(),
}).loose();
export const RuntimeModelListRequestSchema = z.object({
id: z.string().default(""),
runtime_id: z.string().default(""),
status: z.string(),
models: z.array(RuntimeModelSchema).optional(),
supported: z.boolean().default(true),
error: z.string().optional(),
created_at: z.string().default(""),
updated_at: z.string().default(""),
cached: z.boolean().optional(),
cached_at: z.string().optional(),
}).loose();
// Fallback for an unparseable model-discovery response. `failed` is the only
// honest choice: `completed` would fabricate an empty catalog (and silently
// clear a saved model when `supported` is read as false), while `pending`
// would spin the picker until the client-side poll timeout. `failed` surfaces
// "discovery failed" immediately and leaves the creatable manual-entry field
// working, which is the same degradation as a real discovery failure.
export const MALFORMED_RUNTIME_MODEL_LIST_REQUEST: RuntimeModelListRequest = {
id: "",
runtime_id: "",
status: "failed",
supported: true,
error: "invalid model discovery response",
created_at: "",
updated_at: "",
};
+251
View File
@@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import {
LIVE_MODELS_STALE_TIME_MS,
resolveRuntimeModels,
runtimeModelsKeys,
runtimeModelsOptions,
staleTimeFor,
} from "./models";
import type { RuntimeModelListRequest, RuntimeModelsResult } from "../types/agent";
const initiateListModels = vi.fn();
const getListModelsResult = vi.fn();
vi.mock("../api", () => ({
api: {
initiateListModels: (runtimeId: string) => initiateListModels(runtimeId),
getListModelsResult: (runtimeId: string, requestId: string) =>
getListModelsResult(runtimeId, requestId),
},
}));
const catalog = [{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }];
const refreshedCatalog = [{ id: "claude-opus-5", label: "Claude Opus 5" }];
function request(
overrides: Partial<RuntimeModelListRequest>,
): RuntimeModelListRequest {
return {
id: "req-1",
runtime_id: "rt-1",
status: "pending",
supported: true,
created_at: "2026-07-29T00:00:00Z",
updated_at: "2026-07-29T00:00:00Z",
...overrides,
};
}
function cachedResponse(
models: RuntimeModelListRequest["models"],
cachedAt: string,
): RuntimeModelListRequest {
return request({
status: "completed",
models,
cached: true,
cached_at: cachedAt,
});
}
beforeEach(() => {
initiateListModels.mockReset();
getListModelsResult.mockReset();
});
describe("resolveRuntimeModels", () => {
// The server answers a warm runtime straight from its catalog cache
// (MUL-5444). That response is already terminal, so discovery must resolve on
// the POST alone — one round trip, no polling, no spinner.
it("resolves from a cached completed response without polling", async () => {
initiateListModels.mockResolvedValue(
cachedResponse(catalog, "2026-07-29T00:00:00Z"),
);
const result = await resolveRuntimeModels("rt-1");
expect(result).toEqual({
models: catalog,
supported: true,
cached: true,
cachedAt: "2026-07-29T00:00:00Z",
});
expect(getListModelsResult).not.toHaveBeenCalled();
});
it("marks a live discovery as not cached", async () => {
initiateListModels.mockResolvedValue(
request({ status: "completed", models: catalog }),
);
const result = await resolveRuntimeModels("rt-1");
expect(result.cached).toBe(false);
expect(result.cachedAt).toBeUndefined();
});
it("still polls a pending response until the daemon reports back", async () => {
initiateListModels.mockResolvedValue(request({ status: "pending" }));
getListModelsResult
.mockResolvedValueOnce(request({ status: "running" }))
.mockResolvedValueOnce(request({ status: "completed", models: catalog }));
const result = await resolveRuntimeModels("rt-1");
expect(result.models).toEqual(catalog);
expect(result.supported).toBe(true);
expect(getListModelsResult).toHaveBeenCalledTimes(2);
expect(getListModelsResult).toHaveBeenLastCalledWith("rt-1", "req-1");
});
it("surfaces a failed discovery as an error", async () => {
initiateListModels.mockResolvedValue(
request({ status: "failed", error: "claude not installed" }),
);
await expect(resolveRuntimeModels("rt-1")).rejects.toThrow(
"claude not installed",
);
});
// A status this client does not know (newer server, or the malformed-response
// fallback record) must NOT read as "completed with no models" — that renders
// an authoritative-looking empty dropdown. The schema keeps `status` lenient,
// so such a value really can reach this code at runtime even though the TS
// union does not admit it.
it("treats an unrecognised status as an explicit failure", async () => {
initiateListModels.mockResolvedValue({
...request({}),
status: "superseded" as RuntimeModelListRequest["status"],
});
await expect(resolveRuntimeModels("rt-1")).rejects.toThrow(
/status: superseded/,
);
});
it("defaults supported to true when the server omits it", async () => {
initiateListModels.mockResolvedValue({
...request({ status: "completed", models: catalog }),
supported: undefined as unknown as boolean,
});
const result = await resolveRuntimeModels("rt-1");
expect(result.supported).toBe(true);
});
});
describe("staleTimeFor", () => {
// The server may serve a snapshot up to its own serve window old and refresh
// it in the background. If the client also held that response as fresh, the
// observable staleness would be server window + client window. Zero keeps the
// bound at the server's window alone.
it("treats a cached answer as immediately revalidatable", () => {
expect(staleTimeFor({ models: catalog, supported: true, cached: true })).toBe(0);
});
it("trusts a live answer for the full window", () => {
expect(staleTimeFor({ models: catalog, supported: true, cached: false })).toBe(
LIVE_MODELS_STALE_TIME_MS,
);
expect(staleTimeFor({ models: catalog, supported: true })).toBe(
LIVE_MODELS_STALE_TIME_MS,
);
});
it("has nothing to trust without data", () => {
expect(staleTimeFor(undefined)).toBe(0);
});
});
describe("runtimeModelsOptions", () => {
it("keeps unused entries long enough to render instantly on return", () => {
const options = runtimeModelsOptions("rt-1");
expect(options.gcTime).toBeGreaterThanOrEqual(LIVE_MODELS_STALE_TIME_MS);
expect(options.queryKey).toEqual(["runtimes", "models", "rt-1"]);
expect(options.enabled).toBe(true);
});
it("resolves freshness from the served answer, not a fixed window", () => {
const options = runtimeModelsOptions("rt-1");
expect(typeof options.staleTime).toBe("function");
const staleTime = options.staleTime as (query: {
state: { data?: { models: []; supported: boolean; cached?: boolean } };
}) => number;
expect(
staleTime({ state: { data: { models: [], supported: true, cached: true } } }),
).toBe(0);
expect(
staleTime({ state: { data: { models: [], supported: true, cached: false } } }),
).toBe(LIVE_MODELS_STALE_TIME_MS);
});
it("stays disabled without a runtime", () => {
expect(runtimeModelsOptions(null).enabled).toBe(false);
});
// The regression Sol-Boy flagged on PR #6098: a stale-but-served snapshot must
// reach the SAME client once the server-side refresh lands, and the picker
// must not blink an empty loading state while that happens.
it("picks up the refreshed catalog on revisit without a blank loading state", async () => {
initiateListModels
// First open: server hands back a 14-minute-old snapshot and queues its
// own background refresh.
.mockResolvedValueOnce(cachedResponse(catalog, "2026-07-29T00:00:00Z"))
// Revisit: the refresh has landed, so the same endpoint now serves the
// new catalog.
.mockResolvedValueOnce(
cachedResponse(refreshedCatalog, "2026-07-29T00:14:00Z"),
);
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const options = runtimeModelsOptions("rt-1");
await client.fetchQuery(options);
expect(
client.getQueryData(runtimeModelsKeys.forRuntime("rt-1")),
).toMatchObject({ models: catalog, cached: true });
// A cached answer is stale on arrival, so remounting the picker revalidates.
const query = client
.getQueryCache()
.find<RuntimeModelsResult>({
queryKey: runtimeModelsKeys.forRuntime("rt-1"),
})!;
expect(query.isStaleByTime(staleTimeFor(query.state.data))).toBe(true);
const observer = new QueryObserver(client, options);
const emissions: { isLoading: boolean; ids: string[] }[] = [];
const unsubscribe = observer.subscribe((result) => {
emissions.push({
isLoading: result.isLoading,
ids: (result.data?.models ?? []).map((m) => m.id),
});
});
try {
await vi.waitFor(() => {
const last = emissions.at(-1);
expect(last?.ids).toEqual(refreshedCatalog.map((m) => m.id));
});
} finally {
unsubscribe();
client.clear();
}
// Every emission during the background revalidation kept a rendered
// catalog: the pickers gate their spinner on `isLoading`, so this is what
// "no blank loading state" means concretely.
expect(emissions.every((e) => e.isLoading === false)).toBe(true);
expect(emissions.every((e) => e.ids.length > 0)).toBe(true);
// The first emission is the stale snapshot still on screen while the
// revalidation runs — proof the new catalog arrived by replacing rendered
// data, not after a gap.
expect(emissions[0]?.ids).toEqual(catalog.map((m) => m.id));
expect(initiateListModels).toHaveBeenCalledTimes(2);
});
});
+51 -6
View File
@@ -11,6 +11,17 @@ export const runtimeModelsKeys = {
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
@@ -18,6 +29,11 @@ const POLL_TIMEOUT_MS = 30_000;
// 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> {
@@ -31,10 +47,40 @@ export async function resolveRuntimeModels(
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
current = await api.getListModelsResult(runtimeId, initial.id);
}
if (current.status === "failed" || current.status === "timeout") {
throw new Error(current.error || "model discovery failed");
// 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 };
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) {
@@ -44,9 +90,8 @@ export function runtimeModelsOptions(runtimeId: string | null | undefined) {
: runtimeModelsKeys.all(),
queryFn: () => resolveRuntimeModels(runtimeId as string),
enabled: Boolean(runtimeId),
// Models rarely change; cache for 60s to match the server-side
// cache in agent.ListModels.
staleTime: 60_000,
staleTime: (query) => staleTimeFor(query.state.data),
gcTime: MODELS_GC_TIME_MS,
retry: false,
});
}
+18
View File
@@ -1042,6 +1042,15 @@ export interface RuntimeModelListRequest {
error?: string;
created_at: string;
updated_at: string;
/**
* True when the server answered from its own catalog cache instead of a live
* daemon round trip (MUL-5444). Informational only: such a response already
* arrives with `status: "completed"` and a populated `models`, so callers
* that ignore this field behave exactly as before. `cached_at` is the
* snapshot's capture time.
*/
cached?: boolean;
cached_at?: string;
}
// Result shape returned by resolveRuntimeModels — includes the
@@ -1050,6 +1059,15 @@ export interface RuntimeModelListRequest {
export interface RuntimeModelsResult {
models: RuntimeModel[];
supported: boolean;
/**
* True when the server answered from its catalog cache rather than a live
* daemon round trip (MUL-5444). Drives the query's freshness policy: a
* cached answer is immediately revalidatable so the client never extends the
* server's staleness window.
*/
cached?: boolean;
/** Capture time of the served snapshot, when the answer was cached. */
cachedAt?: string;
}
export type RuntimeLocalSkillStatus =
+4
View File
@@ -239,10 +239,14 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
if notifier, ok := opts.DaemonWakeup.(handler.WorkspaceSetRefreshNotifier); ok {
h.DaemonWorkspaceRefresh = notifier
}
if notifier, ok := opts.DaemonWakeup.(handler.DaemonPendingWorkNotifier); ok {
h.DaemonPendingWork = notifier
}
}
if rdb != nil {
h.UpdateStore = handler.NewRedisUpdateStore(rdb)
h.ModelListStore = handler.NewRedisModelListStore(rdb)
h.ModelCatalogCache = handler.NewRedisModelCatalogCache(rdb)
h.LocalSkillListStore = handler.NewRedisLocalSkillListStore(rdb)
h.LocalSkillImportStore = handler.NewRedisLocalSkillImportStore(rdb)
h.LivenessStore = handler.NewRedisLivenessStore(rdb)
+118
View File
@@ -74,8 +74,22 @@ const (
// intentionally independent from AgentTimeout, which only governs the
// provider process after the task reaches running.
defaultTaskPrepareTimeout = 5 * time.Minute
// pendingWorkHeartbeatTimeout bounds the out-of-band heartbeat a
// server-pushed daemon:pending_work hint triggers (MUL-5444). Short on
// purpose: the hint is only a latency optimisation, and the scheduled
// heartbeat still picks the request up if this attempt fails.
pendingWorkHeartbeatTimeout = 15 * time.Second
// pendingWorkHintBookkeepingTTL is how long a runtime's last-hint timestamp
// is retained before it is swept — purely to keep the map bounded.
pendingWorkHintBookkeepingTTL = 10 * time.Minute
)
// pendingWorkHintMinInterval is the floor between two hint-driven heartbeats
// for the same runtime. Keeps an interactive first open instant while stopping a
// caller-triggered hint from becoming a heartbeat amplifier. A var so tests can
// shrink it, same as the other timing knobs in this package.
var pendingWorkHintMinInterval = time.Second
func repoCheckoutModeFor(provider, goos string) string {
if provider == "codex" && goos == "linux" {
return repoCheckoutModeIsolated
@@ -332,6 +346,16 @@ type Daemon struct {
reregisterNextAttempt map[string]time.Time // workspace_id -> earliest time the next re-register attempt may run
reregisterLastCompletedAt map[string]time.Time // workspace_id -> wall-clock at which the last SUCCESSFUL re-register call returned (failures intentionally not stamped — see recordRegisterCompletion)
// pendingWorkMu guards pendingWorkInflight and pendingWorkLastRun, which
// coalesce and rate-limit server-pushed "heartbeat now" hints (MUL-5444).
// Several UI surfaces can request the same runtime's model list within
// milliseconds; without the guard each hint would fire its own out-of-band
// heartbeat, and an authenticated caller looping the list-models endpoint
// could turn that into a heartbeat amplifier.
pendingWorkMu sync.Mutex
pendingWorkInflight map[string]struct{} // runtime_id -> hint-driven heartbeat in flight
pendingWorkLastRun map[string]time.Time // runtime_id -> when the last hint-driven heartbeat started
cancelFunc context.CancelFunc // set by Run(); called by triggerRestart
rootCtx context.Context // set by Run(); used by long-running recoveries that must survive per-runtime ctx cancellation
restartBinary string // non-empty after a successful update; path to the new binary
@@ -428,6 +452,8 @@ func New(cfg Config, logger *slog.Logger) *Daemon {
deletingCodexStores: make(map[string]bool),
localPathLocks: NewLocalPathLocker(),
runtimeGoneInflight: make(map[string]struct{}),
pendingWorkInflight: make(map[string]struct{}),
pendingWorkLastRun: make(map[string]time.Time),
reregisterNextAttempt: make(map[string]time.Time),
reregisterLastCompletedAt: make(map[string]time.Time),
cancelPollInterval: 5 * time.Second,
@@ -2784,6 +2810,98 @@ func (d *Daemon) handleHeartbeatActions(ctx context.Context, runtimeID string, r
}
}
// handlePendingWorkHint reacts to a server-pushed daemon:pending_work frame by
// sending ONE immediate heartbeat for the runtime, then dispatching whatever
// that heartbeat claimed (MUL-5444).
//
// Why a heartbeat and not the work itself: the hint deliberately carries no
// request payload, so the server never has to un-claim anything when delivery
// fails, and a duplicated or replayed hint cannot duplicate work — the claim
// stays atomic inside the store's PopPending. A hint that never arrives (daemon
// offline, WS gap, relay drop) costs nothing but the pre-existing wait for the
// next scheduled heartbeat.
//
// The HTTP heartbeat is used on purpose rather than queueing a WS frame: the
// hint arrives on the read pump, the WS write path may be backed up or tearing
// down, and this is a human-interactive, low-frequency path (a user opened a
// model picker) where one extra request is cheaper than a missed wakeup. Note it
// intentionally bypasses the wsHeartbeatRecentlyAcked suppression that the
// scheduled HTTP tick honours — that suppression exists to avoid duplicate
// periodic writes, not to block an explicitly requested pull.
func (d *Daemon) handlePendingWorkHint(runtimeID, kind string) {
if runtimeID == "" {
return
}
if d.findRuntime(runtimeID) == nil {
// Not one of ours (stale relay fanout, or the runtime was just pruned).
return
}
d.pendingWorkMu.Lock()
if d.pendingWorkInflight == nil {
d.pendingWorkInflight = make(map[string]struct{})
}
if d.pendingWorkLastRun == nil {
d.pendingWorkLastRun = make(map[string]time.Time)
}
// Drop long-idle bookkeeping so the map can't grow with every runtime this
// process has ever seen.
for id, at := range d.pendingWorkLastRun {
if time.Since(at) > pendingWorkHintBookkeepingTTL {
delete(d.pendingWorkLastRun, id)
}
}
if _, inflight := d.pendingWorkInflight[runtimeID]; inflight {
d.pendingWorkMu.Unlock()
return
}
// Rate limit per runtime. The hint is caller-triggered (any workspace member
// hitting the list-models endpoint), so without a floor a request loop would
// become a heartbeat amplifier. A suppressed hint costs nothing but the
// pre-existing wait for the scheduled heartbeat.
if last, ok := d.pendingWorkLastRun[runtimeID]; ok && time.Since(last) < pendingWorkHintMinInterval {
d.pendingWorkMu.Unlock()
d.logger.Debug("pending work hint throttled", "runtime_id", runtimeID, "kind", kind)
return
}
d.pendingWorkInflight[runtimeID] = struct{}{}
d.pendingWorkLastRun[runtimeID] = time.Now()
d.pendingWorkMu.Unlock()
defer func() {
d.pendingWorkMu.Lock()
delete(d.pendingWorkInflight, runtimeID)
d.pendingWorkMu.Unlock()
}()
// Root context: handleHeartbeatActions hands this ctx to the actual work
// (model discovery shells out to a CLI for up to ~15s), so it must outlive
// this function. Only the heartbeat request itself is time-bounded.
ctx := d.recoveryContext()
if ctx.Err() != nil {
return
}
hbCtx, cancel := context.WithTimeout(ctx, pendingWorkHeartbeatTimeout)
resp, err := d.client.SendHeartbeat(hbCtx, runtimeID)
cancel()
if err != nil {
if isRuntimeNotFoundError(err) {
go d.handleRuntimeGone(runtimeID)
return
}
d.logger.Debug("pending work hint heartbeat failed", "runtime_id", runtimeID, "kind", kind, "error", err)
return
}
if resp == nil {
return
}
if resp.RuntimeGone {
go d.handleRuntimeGone(runtimeID)
return
}
d.logger.Debug("pending work hint served", "runtime_id", runtimeID, "kind", kind)
d.handleHeartbeatActions(ctx, runtimeID, resp)
}
// handleModelList resolves the provider's supported models (via static
// catalog or by shelling out to the agent CLI) and reports the result
// back to the server. Model discovery failures are reported as empty
@@ -0,0 +1,186 @@
package daemon
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// pendingWorkHintDaemon builds a Daemon that knows exactly one runtime and
// talks to an httptest server, so the hint path is exercised against the real
// daemon.Client instead of a mock. The per-runtime hint floor is disabled by
// default; TestHandlePendingWorkHint_ThrottlesRepeatHints restores it.
func pendingWorkHintDaemon(t *testing.T, handler http.HandlerFunc) (*Daemon, *int32) {
t.Helper()
withPendingWorkHintMinInterval(t, 0)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
handler(w, r)
}))
t.Cleanup(srv.Close)
d := &Daemon{
cfg: Config{HeartbeatInterval: time.Hour},
client: NewClient(srv.URL),
logger: slog.Default(),
runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}},
pendingWorkInflight: make(map[string]struct{}),
pendingWorkLastRun: make(map[string]time.Time),
}
return d, &calls
}
func withPendingWorkHintMinInterval(t *testing.T, d time.Duration) {
t.Helper()
prev := pendingWorkHintMinInterval
pendingWorkHintMinInterval = d
t.Cleanup(func() { pendingWorkHintMinInterval = prev })
}
// TestHandlePendingWorkHint_SendsImmediateHeartbeat is the core of MUL-5444:
// a server-pushed hint must produce a heartbeat right now instead of leaving the
// queued model-list request to wait for the next scheduled tick (up to a full
// HeartbeatInterval, 15s by default).
func TestHandlePendingWorkHint_SendsImmediateHeartbeat(t *testing.T) {
var gotPath, gotRuntime string
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
var body struct {
RuntimeID string `json:"runtime_id"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotRuntime = body.RuntimeID
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
})
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("expected exactly 1 heartbeat, got %d", got)
}
if gotPath != "/api/daemon/heartbeat" {
t.Fatalf("heartbeat path = %q", gotPath)
}
if gotRuntime != "rt-1" {
t.Fatalf("heartbeat runtime_id = %q, want rt-1", gotRuntime)
}
}
// TestHandlePendingWorkHint_IgnoresUnknownRuntime keeps a stale or
// cross-machine relay fanout from making this daemon heartbeat for a runtime it
// does not own.
func TestHandlePendingWorkHint_IgnoresUnknownRuntime(t *testing.T) {
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"runtime_id":"other","status":"ok"}`))
})
d.handlePendingWorkHint("not-mine", protocol.PendingWorkKindModelList)
d.handlePendingWorkHint("", protocol.PendingWorkKindModelList)
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("expected no heartbeat for an unknown runtime, got %d", got)
}
}
// TestHandlePendingWorkHint_CoalescesConcurrentHints pins the stampede guard:
// several UI surfaces (model picker, thinking level, service tier) each request
// the catalog for the same runtime within milliseconds, and one heartbeat
// already claims whatever is queued.
func TestHandlePendingWorkHint_CoalescesConcurrentHints(t *testing.T) {
release := make(chan struct{})
arrived := make(chan struct{}, 1)
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
select {
case arrived <- struct{}{}:
default:
}
<-release
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
}()
// Wait until the first hint is actually inside the HTTP call, then fire the
// siblings that must be dropped.
select {
case <-arrived:
case <-time.After(2 * time.Second):
close(release)
wg.Wait()
t.Fatal("first hint never reached the server")
}
for i := 0; i < 3; i++ {
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
}
close(release)
wg.Wait()
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("expected concurrent hints to coalesce into 1 heartbeat, got %d", got)
}
// The guard must release afterwards, otherwise the runtime would ignore
// every later hint for the lifetime of the process.
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
if got := atomic.LoadInt32(calls); got != 2 {
t.Fatalf("expected a later hint to heartbeat again, got %d total", got)
}
}
// TestHandlePendingWorkHint_ThrottlesRepeatHints pins the amplification guard:
// the hint is triggered by any workspace member hitting the list-models
// endpoint, so back-to-back requests must not turn into back-to-back heartbeats.
func TestHandlePendingWorkHint_ThrottlesRepeatHints(t *testing.T) {
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"runtime_id":"rt-1","status":"ok"}`))
})
withPendingWorkHintMinInterval(t, time.Minute)
for i := 0; i < 5; i++ {
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("expected repeat hints inside the floor to collapse into 1 heartbeat, got %d", got)
}
// Past the floor, a hint is served again — the throttle is a floor, not a
// one-shot latch.
withPendingWorkHintMinInterval(t, time.Nanosecond)
time.Sleep(2 * time.Millisecond)
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
if got := atomic.LoadInt32(calls); got != 2 {
t.Fatalf("expected a hint past the floor to heartbeat, got %d total", got)
}
}
// TestHandlePendingWorkHint_SurvivesHeartbeatFailure documents that a failed
// hint is a no-op: the scheduled heartbeat remains the correctness path, so the
// daemon must not panic or retry-storm here.
func TestHandlePendingWorkHint_SurvivesHeartbeatFailure(t *testing.T) {
d, calls := pendingWorkHintDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
})
d.handlePendingWorkHint("rt-1", protocol.PendingWorkKindModelList)
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("expected exactly 1 attempt, got %d", got)
}
}
+13
View File
@@ -402,6 +402,19 @@ func (d *Daemon) readTaskWakeupMessagesForConnection(conn *websocket.Conn, taskW
if d.workspaceChanges != nil {
d.workspaceChanges.broadcast()
}
case protocol.EventDaemonPendingWork:
var payload protocol.PendingWorkPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
d.logger.Debug("pending work websocket invalid payload", "error", err)
continue
}
if payload.RuntimeID == "" {
d.logger.Debug("pending work websocket missing runtime_id")
continue
}
// Own goroutine: the hint triggers an HTTP heartbeat plus the work it
// claims, and the read pump must stay free for the next frame.
go d.handlePendingWorkHint(payload.RuntimeID, payload.Kind)
case protocol.EventDaemonHeartbeatAck:
var ack HeartbeatResponse
if err := json.Unmarshal(msg.Payload, &ack); err != nil {
+47
View File
@@ -328,6 +328,14 @@ func (h *Hub) NotifyWorkspacesChanged(userID string) {
h.notifyWorkspacesChanged(userID, "")
}
// NotifyPendingWork tells daemons watching runtimeID that a heartbeat-carried
// request is queued, so they can heartbeat now instead of waiting for the next
// scheduled tick (MUL-5444). Best-effort like every other hub notification: the
// daemon's own heartbeat schedule remains the correctness path.
func (h *Hub) NotifyPendingWork(runtimeID, kind string) {
h.notifyPendingWork(runtimeID, kind, "")
}
func (h *Hub) notifyTaskAvailable(runtimeID, taskID, eventID string) {
if h == nil || runtimeID == "" {
return
@@ -366,6 +374,22 @@ func (h *Hub) notifyWorkspacesChanged(userID, eventID string) {
h.notifyUserFrame(userID, data, eventID)
}
func (h *Hub) notifyPendingWork(runtimeID, kind, eventID string) {
if h == nil || runtimeID == "" {
return
}
data, err := pendingWorkFrame(runtimeID, kind)
if err != nil {
return
}
delivered, deduped := h.notifyFrame(runtimeID, data, eventID)
if delivered {
M.WakeupDeliveredHit.Add(1)
} else if !deduped {
M.WakeupDeliveredMiss.Add(1)
}
}
func (h *Hub) DeliverDaemonRuntime(scopeID string, frame []byte, eventID string) {
if h == nil {
return
@@ -411,6 +435,19 @@ func (h *Hub) DeliverDaemonRuntime(scopeID string, frame []byte, eventID string)
} else if !deduped {
M.WakeupDeliveredMiss.Add(1)
}
case protocol.EventDaemonPendingWork:
var payload protocol.PendingWorkPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.RuntimeID == "" {
slog.Debug("daemon websocket relay: invalid pending_work payload", "error", err, "scope_id", scopeID, "event_id", eventID)
M.WakeupDeliveredMiss.Add(1)
return
}
delivered, deduped := h.notifyFrame(payload.RuntimeID, frame, eventID)
if delivered {
M.WakeupDeliveredHit.Add(1)
} else if !deduped {
M.WakeupDeliveredMiss.Add(1)
}
default:
M.WakeupDeliveredMiss.Add(1)
return
@@ -528,6 +565,16 @@ func workspacesChangedFrame() ([]byte, error) {
})
}
func pendingWorkFrame(runtimeID, kind string) ([]byte, error) {
return json.Marshal(protocol.Message{
Type: protocol.EventDaemonPendingWork,
Payload: mustMarshalRaw(protocol.PendingWorkPayload{
RuntimeID: runtimeID,
Kind: kind,
}),
})
}
func mustMarshalRaw(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
+29
View File
@@ -99,3 +99,32 @@ func (n *RelayNotifier) NotifyWorkspacesChanged(userID string) {
}
M.WakeupPublishedTotal.Add(1)
}
// NotifyPendingWork fans a runtime-scoped "heartbeat now" hint out to the local
// hub and, when Redis is configured, through the relay so the API node that
// actually holds the daemon's WebSocket delivers it (MUL-5444). Shard key is the
// runtime ID: hints for one runtime stay ordered relative to each other, and a
// dropped hint only costs the daemon its normal heartbeat delay.
func (n *RelayNotifier) NotifyPendingWork(runtimeID, kind string) {
if runtimeID == "" {
return
}
eventID := ulid.Make().String()
if n.local != nil {
n.local.notifyPendingWork(runtimeID, kind, eventID)
}
if n.relay == nil {
return
}
frame, err := pendingWorkFrame(runtimeID, kind)
if err != nil {
M.WakeupPublishErrors.Add(1)
return
}
if err := n.relay.PublishWithID(realtime.ScopeDaemonRuntime, runtimeID, "", frame, eventID); err != nil {
M.WakeupPublishErrors.Add(1)
slog.Warn("daemon websocket pending work publish failed", "error", err, "runtime_id", runtimeID, "kind", kind)
return
}
M.WakeupPublishedTotal.Add(1)
}
@@ -0,0 +1,144 @@
package daemonws
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// TestNotifyPendingWork pins the hint that removes the up-to-one-heartbeat
// wait before a queued model-list request is picked up (MUL-5444): daemons
// watching the runtime must receive a runtime-scoped daemon:pending_work frame.
func TestNotifyPendingWork(t *testing.T) {
M.Reset()
defer M.Reset()
hub := NewHub()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hub.HandleWebSocket(w, r, ClientIdentity{RuntimeIDs: []string{"runtime-1"}})
}))
defer server.Close()
conn := dialPendingWorkClient(t, hub, server.URL, "runtime-1")
defer conn.Close()
hub.NotifyPendingWork("runtime-1", protocol.PendingWorkKindModelList)
payload := readPendingWorkFrame(t, conn)
if payload.RuntimeID != "runtime-1" {
t.Fatalf("payload.RuntimeID = %q, want runtime-1", payload.RuntimeID)
}
if payload.Kind != protocol.PendingWorkKindModelList {
t.Fatalf("payload.Kind = %q, want %q", payload.Kind, protocol.PendingWorkKindModelList)
}
}
// TestDeliverDaemonRuntime_PendingWork covers the multi-node path: the node
// that holds the daemon's socket receives the frame off the Redis relay and has
// to route it by the payload's runtime_id, not by the relay shard key.
func TestDeliverDaemonRuntime_PendingWork(t *testing.T) {
M.Reset()
defer M.Reset()
hub := NewHub()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hub.HandleWebSocket(w, r, ClientIdentity{RuntimeIDs: []string{"runtime-1"}})
}))
defer server.Close()
conn := dialPendingWorkClient(t, hub, server.URL, "runtime-1")
defer conn.Close()
frame, err := pendingWorkFrame("runtime-1", protocol.PendingWorkKindModelList)
if err != nil {
t.Fatalf("pendingWorkFrame: %v", err)
}
hub.DeliverDaemonRuntime("runtime-1", frame, "event-1")
payload := readPendingWorkFrame(t, conn)
if payload.RuntimeID != "runtime-1" {
t.Fatalf("payload.RuntimeID = %q, want runtime-1", payload.RuntimeID)
}
// Same event id must not be delivered twice — a mirrored relay can publish
// the same hint through two paths.
hub.DeliverDaemonRuntime("runtime-1", frame, "event-1")
if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("expected no second delivery for a duplicate event id")
}
}
// TestNotifyPendingWork_IgnoresEmptyRuntime guards against a hint with no
// runtime scope fanning out to every connected daemon.
func TestNotifyPendingWork_IgnoresEmptyRuntime(t *testing.T) {
M.Reset()
defer M.Reset()
hub := NewHub()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hub.HandleWebSocket(w, r, ClientIdentity{RuntimeIDs: []string{"runtime-1"}})
}))
defer server.Close()
conn := dialPendingWorkClient(t, hub, server.URL, "runtime-1")
defer conn.Close()
hub.NotifyPendingWork("", protocol.PendingWorkKindModelList)
if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("expected no frame for an empty runtime id")
}
}
func dialPendingWorkClient(t *testing.T, hub *Hub, serverURL, runtimeID string) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(serverURL, "http")
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Dial: %v", err)
}
deadline := time.Now().Add(time.Second)
for hub.RuntimeConnectionCount(runtimeID) == 0 {
if time.Now().After(deadline) {
conn.Close()
t.Fatalf("runtime connection %s was not registered", runtimeID)
}
time.Sleep(10 * time.Millisecond)
}
return conn
}
func readPendingWorkFrame(t *testing.T, conn *websocket.Conn) protocol.PendingWorkPayload {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
_, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("ReadMessage: %v", err)
}
var msg protocol.Message
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal message: %v", err)
}
if msg.Type != protocol.EventDaemonPendingWork {
t.Fatalf("message type = %q, want %q", msg.Type, protocol.EventDaemonPendingWork)
}
var payload protocol.PendingWorkPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
return payload
}
+20
View File
@@ -139,6 +139,15 @@ type WorkspaceSetRefreshNotifier interface {
NotifyWorkspacesChanged(userID string)
}
// DaemonPendingWorkNotifier pushes a runtime-scoped "heartbeat now" hint to the
// daemon so a queued heartbeat-carried request (model discovery) is picked up
// immediately instead of on the daemon's next scheduled tick (MUL-5444).
// Satisfied by both *daemonws.Hub (single-node) and *daemonws.RelayNotifier
// (multi-node, fans out through Redis).
type DaemonPendingWorkNotifier interface {
NotifyPendingWork(runtimeID, kind string)
}
type Handler struct {
Queries *db.Queries
DB dbExecutor
@@ -162,6 +171,16 @@ type Handler struct {
Storage storage.Storage
CFSigner *auth.CloudFrontSigner
Analytics analytics.Client
// DaemonPendingWork pushes "heartbeat now" hints for queued
// heartbeat-carried requests (MUL-5444). Optional: when nil,
// requestDaemonPendingWork falls back to the local DaemonHub, which is the
// correct delivery scope for a single-node deployment.
DaemonPendingWork DaemonPendingWorkNotifier
// ModelCatalogCache serves the last known good model list for a runtime so
// the picker can render without waiting for a daemon round trip
// (stale-while-revalidate, MUL-5444). Nil-safe: every call site treats a nil
// cache as a permanent miss and falls back to the full discovery flow.
ModelCatalogCache ModelCatalogCache
// Metrics is the shared business-metrics collector built by main.go.
// May be nil in tests / self-hosted with the metrics listener disabled;
// every Record* method is nil-safe and obsmetrics.RecordEvent treats a
@@ -309,6 +328,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
EmailService: emailService,
UpdateStore: NewInMemoryUpdateStore(),
ModelListStore: NewInMemoryModelListStore(),
ModelCatalogCache: NewInMemoryModelCatalogCache(),
LocalSkillListStore: NewInMemoryLocalSkillListStore(),
LocalSkillImportStore: NewInMemoryLocalSkillImportStore(),
LivenessStore: NewNoopLivenessStore(),
@@ -0,0 +1,181 @@
package handler
import (
"context"
"sync"
"time"
)
// ---------------------------------------------------------------------------
// Runtime model catalog cache (stale-while-revalidate)
// ---------------------------------------------------------------------------
//
// Listing a runtime's models is a round trip to the user's machine: the request
// waits for the daemon's next heartbeat, the daemon shells out to the provider
// CLI (or drives an ACP handshake) and only then reports back. Even with the
// pending-work push hint (MUL-5444) that is seconds of latency on a UI surface
// people open repeatedly while filling in one form — switch runtime, look at the
// models, switch back.
//
// The catalog itself changes only when the user upgrades a CLI, logs into a
// different account, or edits a provider config, so it is a textbook
// stale-while-revalidate candidate: answer from the last known good snapshot
// immediately, and refresh in the background so the NEXT open is also warm.
//
// Windows are deliberately conservative:
// - modelCatalogServeWindow bounds how stale an answer the API will hand a
// client without waiting for the daemon.
// - modelCatalogRevalidateAfter bounds how long a snapshot can be served
// without triggering a background refresh, so a CLI upgrade converges
// within one open instead of lingering for the whole serve window.
//
// Only successful, non-empty, `supported` catalogs are cached. An empty list is
// almost always a transient discovery failure (CLI not logged in, timeout) —
// caching it would pin the picker empty (same reasoning as agent.cachedDiscovery
// in the daemon).
const (
// modelCatalogServeWindow is how long a cached catalog may answer a
// list-models request without waiting for the daemon.
modelCatalogServeWindow = 15 * time.Minute
// modelCatalogRevalidateAfter is the age past which serving from cache also
// enqueues a background refresh.
modelCatalogRevalidateAfter = 60 * time.Second
)
// ModelCatalogSnapshot is the last known good model list for one runtime.
type ModelCatalogSnapshot struct {
RuntimeID string `json:"runtime_id"`
Models []ModelEntry `json:"models"`
Supported bool `json:"supported"`
StoredAt time.Time `json:"stored_at"`
}
// Age reports how long ago the snapshot was captured.
func (s *ModelCatalogSnapshot) Age(now time.Time) time.Duration {
if s == nil {
return 0
}
return now.Sub(s.StoredAt)
}
// ModelCatalogCache stores the last successful model catalog per runtime. Both
// methods are best-effort from the caller's perspective: a Get error means
// "answer the slow way" and a Put error means "the next open is cold". Neither
// may fail a request.
//
// Implementations must be safe for concurrent use.
type ModelCatalogCache interface {
Get(ctx context.Context, runtimeID string) (*ModelCatalogSnapshot, error)
Put(ctx context.Context, runtimeID string, models []ModelEntry, supported bool) error
// Invalidate drops any snapshot for the runtime. Used when the cached
// catalog can no longer be trusted (e.g. the runtime row was deleted).
Invalidate(ctx context.Context, runtimeID string) error
}
// cacheableModelCatalog reports whether a completed discovery result is worth
// remembering. `supported=false` runtimes have no picker at all, and an empty
// catalog is treated as a transient failure rather than an authoritative
// "this runtime has no models".
func cacheableModelCatalog(models []ModelEntry, supported bool) bool {
return supported && len(models) > 0
}
// cloneModelEntries deep-copies a catalog so the in-memory backend hands out
// values a caller cannot mutate into the shared cache. A shallow slice copy is
// not enough: ModelEntry carries a *ModelThinking (with its own level slice) and
// a ServiceTiers slice, all of which would still alias the cached objects. The
// Redis backend gets this for free by round-tripping through JSON, and the two
// implementations must not differ in whether the returned value is independent.
func cloneModelEntries(models []ModelEntry) []ModelEntry {
if models == nil {
return nil
}
out := make([]ModelEntry, len(models))
for i, m := range models {
clone := m
if m.Thinking != nil {
thinking := *m.Thinking
if m.Thinking.SupportedLevels != nil {
thinking.SupportedLevels = append([]ThinkingLevel(nil), m.Thinking.SupportedLevels...)
}
clone.Thinking = &thinking
}
if m.ServiceTiers != nil {
clone.ServiceTiers = append([]ModelServiceTier(nil), m.ServiceTiers...)
}
out[i] = clone
}
return out
}
// InMemoryModelCatalogCache is the single-node implementation. Adequate for
// self-hosted and tests; multi-node deploys should use the Redis backend so
// every API replica shares one warm catalog.
type InMemoryModelCatalogCache struct {
mu sync.Mutex
entries map[string]ModelCatalogSnapshot
retainFor time.Duration
}
func NewInMemoryModelCatalogCache() *InMemoryModelCatalogCache {
return &InMemoryModelCatalogCache{
entries: make(map[string]ModelCatalogSnapshot),
retainFor: modelCatalogServeWindow,
}
}
func (c *InMemoryModelCatalogCache) Get(_ context.Context, runtimeID string) (*ModelCatalogSnapshot, error) {
if runtimeID == "" {
return nil, nil
}
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[runtimeID]
if !ok {
return nil, nil
}
if time.Since(entry.StoredAt) > c.retainFor {
delete(c.entries, runtimeID)
return nil, nil
}
// Copy so a caller mutating the response cannot corrupt the cache.
snapshot := entry
snapshot.Models = cloneModelEntries(entry.Models)
return &snapshot, nil
}
func (c *InMemoryModelCatalogCache) Put(_ context.Context, runtimeID string, models []ModelEntry, supported bool) error {
if runtimeID == "" || !cacheableModelCatalog(models, supported) {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
// Garbage-collect expired entries so the map can't grow unbounded as
// runtimes come and go.
now := time.Now()
for id, entry := range c.entries {
if now.Sub(entry.StoredAt) > c.retainFor {
delete(c.entries, id)
}
}
c.entries[runtimeID] = ModelCatalogSnapshot{
RuntimeID: runtimeID,
Models: cloneModelEntries(models),
Supported: supported,
StoredAt: now,
}
return nil
}
func (c *InMemoryModelCatalogCache) Invalidate(_ context.Context, runtimeID string) error {
if runtimeID == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, runtimeID)
return nil
}
@@ -0,0 +1,95 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// Redis-backed ModelCatalogCache so every API replica shares one warm model
// catalog per runtime. Without it each node would keep its own map and a user
// bouncing between replicas behind a load balancer would still pay the full
// daemon round trip on most opens (same multi-node reasoning as
// RedisModelListStore).
//
// Key layout — one plain string key per runtime, TTL = serve window, so
// expiry is Redis's job and there is nothing to sweep:
//
// mul:runtime_model_catalog:<runtime_id> → JSON-encoded ModelCatalogSnapshot
//
// No hash tag: every operation here is single-key, so cluster slot co-location
// buys nothing (unlike the Lua-scripted pending queues).
const modelCatalogKeyPrefix = "mul:runtime_model_catalog:"
func modelCatalogKey(runtimeID string) string { return modelCatalogKeyPrefix + runtimeID }
// RedisModelCatalogCache is the multi-node implementation of ModelCatalogCache.
type RedisModelCatalogCache struct {
rdb *redis.Client
ttl time.Duration
}
func NewRedisModelCatalogCache(rdb *redis.Client) *RedisModelCatalogCache {
return &RedisModelCatalogCache{rdb: rdb, ttl: modelCatalogServeWindow}
}
func (c *RedisModelCatalogCache) Get(ctx context.Context, runtimeID string) (*ModelCatalogSnapshot, error) {
if runtimeID == "" {
return nil, nil
}
raw, err := c.rdb.Get(ctx, modelCatalogKey(runtimeID)).Bytes()
if errors.Is(err, redis.Nil) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get model catalog: %w", err)
}
var snapshot ModelCatalogSnapshot
if err := json.Unmarshal(raw, &snapshot); err != nil {
// A snapshot we cannot decode is indistinguishable from a cold cache;
// drop it so the next Put replaces the bad value.
c.rdb.Del(ctx, modelCatalogKey(runtimeID))
return nil, fmt.Errorf("decode model catalog: %w", err)
}
// Guard against a snapshot whose TTL outlived the serve window (TTL is set
// from the same constant, but a config change or a manual write could
// diverge) — the age check is the contract, not the TTL.
if snapshot.StoredAt.IsZero() || time.Since(snapshot.StoredAt) > c.ttl {
return nil, nil
}
return &snapshot, nil
}
func (c *RedisModelCatalogCache) Put(ctx context.Context, runtimeID string, models []ModelEntry, supported bool) error {
if runtimeID == "" || !cacheableModelCatalog(models, supported) {
return nil
}
snapshot := ModelCatalogSnapshot{
RuntimeID: runtimeID,
Models: models,
Supported: supported,
StoredAt: time.Now(),
}
data, err := json.Marshal(snapshot)
if err != nil {
return fmt.Errorf("marshal model catalog: %w", err)
}
if err := c.rdb.Set(ctx, modelCatalogKey(runtimeID), data, c.ttl).Err(); err != nil {
return fmt.Errorf("persist model catalog: %w", err)
}
return nil
}
func (c *RedisModelCatalogCache) Invalidate(ctx context.Context, runtimeID string) error {
if runtimeID == "" {
return nil
}
if err := c.rdb.Del(ctx, modelCatalogKey(runtimeID)).Err(); err != nil {
return fmt.Errorf("invalidate model catalog: %w", err)
}
return nil
}
@@ -0,0 +1,128 @@
package handler
import (
"context"
"testing"
"time"
)
// Reuses the newRedisTestClient helper from
// runtime_local_skills_redis_store_test.go: same Redis instance, same gating on
// REDIS_TEST_URL, same FlushDB-per-test isolation. The in-memory twin of these
// assertions lives in runtime_model_catalog_test.go — both backends must agree,
// because a deployment silently swaps between them based on REDIS_URL.
func TestRedisModelCatalogCache_RoundTrip(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
cache := NewRedisModelCatalogCache(rdb)
if got, err := cache.Get(ctx, "runtime-1"); err != nil || got != nil {
t.Fatalf("cold cache should miss: got=%+v err=%v", got, err)
}
if err := cache.Put(ctx, "runtime-1", sampleCatalog(), true); err != nil {
t.Fatalf("put: %v", err)
}
got, err := cache.Get(ctx, "runtime-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected a cached snapshot")
}
if got.RuntimeID != "runtime-1" || len(got.Models) != 2 {
t.Fatalf("unexpected snapshot: %+v", got)
}
if !got.Models[0].Default {
t.Error("the default badge must survive the Redis round trip")
}
if got.StoredAt.IsZero() {
t.Error("StoredAt must be persisted — the serve/revalidate windows depend on it")
}
if age := got.Age(time.Now()); age < 0 || age > time.Minute {
t.Errorf("implausible snapshot age %s", age)
}
// A sibling API node reading the same key must see the same snapshot; that
// is the whole reason this backend exists.
sibling := NewRedisModelCatalogCache(rdb)
if shared, err := sibling.Get(ctx, "runtime-1"); err != nil || shared == nil {
t.Fatalf("snapshot not visible to a sibling node: got=%+v err=%v", shared, err)
}
}
func TestRedisModelCatalogCache_SkipsUncacheableResults(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
cache := NewRedisModelCatalogCache(rdb)
if err := cache.Put(ctx, "runtime-empty", nil, true); err != nil {
t.Fatalf("put empty: %v", err)
}
if got, _ := cache.Get(ctx, "runtime-empty"); got != nil {
t.Fatalf("empty catalog must not be cached: %+v", got)
}
if err := cache.Put(ctx, "runtime-unsupported", sampleCatalog(), false); err != nil {
t.Fatalf("put unsupported: %v", err)
}
if got, _ := cache.Get(ctx, "runtime-unsupported"); got != nil {
t.Fatalf("unsupported runtime must not be cached: %+v", got)
}
}
func TestRedisModelCatalogCache_InvalidateAndExpiry(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
cache := NewRedisModelCatalogCache(rdb)
if err := cache.Put(ctx, "runtime-1", sampleCatalog(), true); err != nil {
t.Fatalf("put: %v", err)
}
if err := cache.Invalidate(ctx, "runtime-1"); err != nil {
t.Fatalf("invalidate: %v", err)
}
if got, _ := cache.Get(ctx, "runtime-1"); got != nil {
t.Fatalf("expected a miss after Invalidate: %+v", got)
}
// The key must carry a TTL so a runtime that never comes back cannot pin a
// snapshot in Redis forever.
if err := cache.Put(ctx, "runtime-1", sampleCatalog(), true); err != nil {
t.Fatalf("re-put: %v", err)
}
ttl, err := rdb.TTL(ctx, modelCatalogKey("runtime-1")).Result()
if err != nil {
t.Fatalf("ttl: %v", err)
}
if ttl <= 0 || ttl > modelCatalogServeWindow {
t.Fatalf("ttl = %s, want (0, %s]", ttl, modelCatalogServeWindow)
}
// A snapshot older than the serve window is a miss even if the key is still
// present (the age check is the contract, not the TTL).
stale := NewRedisModelCatalogCache(rdb)
stale.ttl = time.Nanosecond
if got, err := stale.Get(ctx, "runtime-1"); err != nil || got != nil {
t.Fatalf("expected a miss past the serve window: got=%+v err=%v", got, err)
}
}
func TestRedisModelCatalogCache_DropsUndecodableSnapshot(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
cache := NewRedisModelCatalogCache(rdb)
if err := rdb.Set(ctx, modelCatalogKey("runtime-1"), "not-json", time.Minute).Err(); err != nil {
t.Fatalf("seed: %v", err)
}
if got, err := cache.Get(ctx, "runtime-1"); err == nil || got != nil {
t.Fatalf("expected a decode error and no snapshot: got=%+v err=%v", got, err)
}
// The poisoned value must be gone so it cannot fail every later read.
if n, err := rdb.Exists(ctx, modelCatalogKey("runtime-1")).Result(); err != nil || n != 0 {
t.Fatalf("undecodable snapshot was not dropped: exists=%d err=%v", n, err)
}
}
@@ -0,0 +1,380 @@
package handler
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
)
func sampleCatalog() []ModelEntry {
return []ModelEntry{
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
{ID: "claude-opus-5", Label: "Claude Opus 5", Provider: "anthropic"},
}
}
// TestInMemoryModelCatalogCache_RoundTrip is the happy path behind the
// stale-while-revalidate fast path (MUL-5444): a completed discovery is
// remembered so the next picker open answers without a daemon round trip.
func TestInMemoryModelCatalogCache_RoundTrip(t *testing.T) {
ctx := context.Background()
cache := NewInMemoryModelCatalogCache()
if got, err := cache.Get(ctx, "rt-1"); err != nil || got != nil {
t.Fatalf("cold cache should miss: got=%+v err=%v", got, err)
}
if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil {
t.Fatalf("put: %v", err)
}
got, err := cache.Get(ctx, "rt-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected a cached snapshot")
}
if len(got.Models) != 2 || got.Models[0].ID != "claude-sonnet-4-6" {
t.Fatalf("unexpected models: %+v", got.Models)
}
if !got.Models[0].Default {
t.Error("the default badge must survive the cache round trip")
}
if !got.Supported {
t.Error("supported flag lost")
}
if got.Age(time.Now()) < 0 {
t.Error("snapshot age must not be negative")
}
// Other runtimes keep their own entry — a catalog is per machine + per CLI
// install, never shareable across runtimes.
if other, err := cache.Get(ctx, "rt-2"); err != nil || other != nil {
t.Fatalf("unrelated runtime should miss: got=%+v err=%v", other, err)
}
}
// TestInMemoryModelCatalogCache_ReturnsIndependentCopies stops a caller that
// mutates the response (e.g. filtering the list) from corrupting the shared
// cache for every later reader.
func TestInMemoryModelCatalogCache_ReturnsIndependentCopies(t *testing.T) {
ctx := context.Background()
cache := NewInMemoryModelCatalogCache()
if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil {
t.Fatalf("put: %v", err)
}
first, err := cache.Get(ctx, "rt-1")
if err != nil || first == nil {
t.Fatalf("get: %+v %v", first, err)
}
first.Models[0].ID = "mutated"
second, err := cache.Get(ctx, "rt-1")
if err != nil || second == nil {
t.Fatalf("get: %+v %v", second, err)
}
if second.Models[0].ID != "claude-sonnet-4-6" {
t.Fatalf("cache was corrupted by a caller mutation: %+v", second.Models[0])
}
}
// TestInMemoryModelCatalogCache_IsolatesNestedFields extends the copy guarantee
// to everything a ModelEntry points at. A shallow slice copy would leave the
// *ModelThinking, its level slice, and ServiceTiers aliasing the cached objects,
// which also made this backend behave differently from the Redis one (JSON
// round-trip always yields an independent value).
func TestInMemoryModelCatalogCache_IsolatesNestedFields(t *testing.T) {
ctx := context.Background()
cache := NewInMemoryModelCatalogCache()
source := []ModelEntry{{
ID: "gpt-5.6-sol",
Label: "GPT-5.6-Sol",
Thinking: &ModelThinking{
DefaultLevel: "low",
SupportedLevels: []ThinkingLevel{{Value: "low", Label: "Low"}},
},
ServiceTiers: []ModelServiceTier{{ID: "fast", Name: "Fast"}},
}}
if err := cache.Put(ctx, "rt-1", source, true); err != nil {
t.Fatalf("put: %v", err)
}
// Mutating the caller's own slice after Put must not reach the cache.
source[0].Thinking.DefaultLevel = "mutated-by-writer"
source[0].Thinking.SupportedLevels[0].Label = "mutated-by-writer"
source[0].ServiceTiers[0].Name = "mutated-by-writer"
first, err := cache.Get(ctx, "rt-1")
if err != nil || first == nil {
t.Fatalf("get: %+v %v", first, err)
}
if first.Models[0].Thinking.DefaultLevel != "low" ||
first.Models[0].Thinking.SupportedLevels[0].Label != "Low" ||
first.Models[0].ServiceTiers[0].Name != "Fast" {
t.Fatalf("writer mutation leaked into the cache: %+v", first.Models[0])
}
// Mutating a returned snapshot must not reach the cache either.
first.Models[0].Thinking.DefaultLevel = "mutated-by-reader"
first.Models[0].Thinking.SupportedLevels[0].Value = "mutated-by-reader"
first.Models[0].ServiceTiers[0].ID = "mutated-by-reader"
second, err := cache.Get(ctx, "rt-1")
if err != nil || second == nil {
t.Fatalf("get: %+v %v", second, err)
}
if second.Models[0].Thinking == first.Models[0].Thinking {
t.Error("thinking pointer is shared between snapshots")
}
if second.Models[0].Thinking.DefaultLevel != "low" ||
second.Models[0].Thinking.SupportedLevels[0].Value != "low" ||
second.Models[0].ServiceTiers[0].ID != "fast" {
t.Fatalf("reader mutation leaked into the cache: %+v", second.Models[0])
}
}
// TestInMemoryModelCatalogCache_SkipsUncacheableResults pins the same rule the
// daemon's own discovery cache uses: an empty catalog is a transient failure
// (CLI not logged in, timeout), and caching it would pin the picker empty for
// the whole serve window.
func TestInMemoryModelCatalogCache_SkipsUncacheableResults(t *testing.T) {
ctx := context.Background()
cache := NewInMemoryModelCatalogCache()
if err := cache.Put(ctx, "rt-empty", nil, true); err != nil {
t.Fatalf("put empty: %v", err)
}
if got, _ := cache.Get(ctx, "rt-empty"); got != nil {
t.Fatalf("empty catalog must not be cached: %+v", got)
}
if err := cache.Put(ctx, "rt-unsupported", sampleCatalog(), false); err != nil {
t.Fatalf("put unsupported: %v", err)
}
if got, _ := cache.Get(ctx, "rt-unsupported"); got != nil {
t.Fatalf("unsupported runtime must not be cached: %+v", got)
}
if err := cache.Put(ctx, "", sampleCatalog(), true); err != nil {
t.Fatalf("put empty runtime id: %v", err)
}
if got, _ := cache.Get(ctx, ""); got != nil {
t.Fatalf("empty runtime id must not be cached: %+v", got)
}
}
// TestInMemoryModelCatalogCache_ExpiresAndInvalidates bounds how stale an
// answer the fast path can serve, and proves an explicit drop works.
func TestInMemoryModelCatalogCache_ExpiresAndInvalidates(t *testing.T) {
ctx := context.Background()
cache := NewInMemoryModelCatalogCache()
cache.retainFor = 20 * time.Millisecond
if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil {
t.Fatalf("put: %v", err)
}
time.Sleep(40 * time.Millisecond)
if got, err := cache.Get(ctx, "rt-1"); err != nil || got != nil {
t.Fatalf("expected expiry past the serve window: got=%+v err=%v", got, err)
}
cache.retainFor = modelCatalogServeWindow
if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil {
t.Fatalf("re-put: %v", err)
}
if err := cache.Invalidate(ctx, "rt-1"); err != nil {
t.Fatalf("invalidate: %v", err)
}
if got, _ := cache.Get(ctx, "rt-1"); got != nil {
t.Fatalf("expected a miss after Invalidate: %+v", got)
}
}
// failingModelCatalogCache reports a backend error on every read.
type failingModelCatalogCache struct{}
func (failingModelCatalogCache) Get(context.Context, string) (*ModelCatalogSnapshot, error) {
return nil, errors.New("redis down")
}
func (failingModelCatalogCache) Put(context.Context, string, []ModelEntry, bool) error { return nil }
func (failingModelCatalogCache) Invalidate(context.Context, string) error { return nil }
// TestCachedModelCatalog_DegradesToMiss proves the cache can never fail a
// request: a nil cache, a backend error, or a snapshot that is no longer
// serveable all fall back to the normal daemon round trip.
func TestCachedModelCatalog_DegradesToMiss(t *testing.T) {
ctx := context.Background()
if got := (&Handler{}).cachedModelCatalog(ctx, "rt-1"); got != nil {
t.Fatalf("nil cache should miss, got %+v", got)
}
h := &Handler{ModelCatalogCache: failingModelCatalogCache{}}
if got := h.cachedModelCatalog(ctx, "rt-1"); got != nil {
t.Fatalf("failing cache should miss, got %+v", got)
}
// A snapshot that somehow holds an unusable catalog is treated as a miss
// rather than answering the picker with an empty list.
inmem := NewInMemoryModelCatalogCache()
inmem.entries["rt-1"] = ModelCatalogSnapshot{RuntimeID: "rt-1", Supported: true, StoredAt: time.Now()}
h = &Handler{ModelCatalogCache: inmem}
if got := h.cachedModelCatalog(ctx, "rt-1"); got != nil {
t.Fatalf("empty cached catalog should miss, got %+v", got)
}
}
// pendingWorkRecorder records the runtime-scoped hints a handler pushes.
type pendingWorkRecorder struct {
mu sync.Mutex
hints []string
}
func (r *pendingWorkRecorder) NotifyPendingWork(runtimeID, kind string) {
r.mu.Lock()
defer r.mu.Unlock()
r.hints = append(r.hints, runtimeID+":"+kind)
}
func (r *pendingWorkRecorder) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.hints)
}
// TestRevalidateModelCatalog_EnqueuesAndHints covers the "revalidate" half of
// stale-while-revalidate: serving a stale snapshot must queue a refresh AND
// push the wakeup hint, so the next open is both fast and fresh.
func TestRevalidateModelCatalog_EnqueuesAndHints(t *testing.T) {
ctx := context.Background()
store := NewInMemoryModelListStore()
rec := &pendingWorkRecorder{}
h := &Handler{ModelListStore: store, DaemonPendingWork: rec}
h.revalidateModelCatalog(ctx, "rt-1")
pending, err := store.HasPending(ctx, "rt-1")
if err != nil {
t.Fatalf("has pending: %v", err)
}
if !pending {
t.Fatal("expected a background refresh request to be enqueued")
}
if rec.count() != 1 {
t.Fatalf("expected exactly 1 pending-work hint, got %d", rec.count())
}
// Stampede control: a second open while the first refresh is still queued
// must not pile on another request.
h.revalidateModelCatalog(ctx, "rt-1")
if rec.count() != 1 {
t.Fatalf("expected the queued refresh to suppress a second hint, got %d", rec.count())
}
}
// TestRequestDaemonPendingWork_PrefersNotifier keeps the notifier optional: a
// handler without one must not panic (single-node deployments fall back to the
// local hub, which is nil in unit tests).
func TestRequestDaemonPendingWork_PrefersNotifier(t *testing.T) {
rec := &pendingWorkRecorder{}
h := &Handler{DaemonPendingWork: rec}
h.requestDaemonPendingWork("rt-1", "model_list")
if rec.count() != 1 {
t.Fatalf("expected the notifier to receive the hint, got %d", rec.count())
}
h.requestDaemonPendingWork("", "model_list")
if rec.count() != 1 {
t.Fatalf("an empty runtime id must not produce a hint, got %d", rec.count())
}
// No notifier and no hub: still a no-op, never a panic.
(&Handler{}).requestDaemonPendingWork("rt-1", "model_list")
}
// TestCacheableModelCatalog pins which completed discovery results are worth
// remembering. It is also the branch ReportModelListResult uses to decide
// between warming the cache and dropping a snapshot the runtime no longer
// advertises.
func TestCacheableModelCatalog(t *testing.T) {
if !cacheableModelCatalog(sampleCatalog(), true) {
t.Error("a supported, non-empty catalog must be cacheable")
}
if cacheableModelCatalog(sampleCatalog(), false) {
t.Error("a runtime that ignores model selection must not be cached")
}
if cacheableModelCatalog(nil, true) {
t.Error("an empty catalog is a transient failure, not a cacheable answer")
}
if cacheableModelCatalog([]ModelEntry{}, true) {
t.Error("an empty (non-nil) catalog is not cacheable either")
}
}
// TestCachedModelListResponse_WireShape pins what a cache hit looks like on the
// wire. Existing clients only branch on status/models, so the response must be
// indistinguishable from a completed live discovery apart from the additive
// `cached` marker.
func TestCachedModelListResponse_WireShape(t *testing.T) {
storedAt := time.Now().Add(-2 * time.Minute)
resp := &ModelListRequest{
ID: randomID(),
RuntimeID: "rt-1",
Status: ModelListCompleted,
Models: sampleCatalog(),
Supported: true,
CreatedAt: storedAt,
UpdatedAt: storedAt,
Cached: true,
CachedAt: &storedAt,
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded struct {
ID string `json:"id"`
Status ModelListStatus `json:"status"`
Models []ModelEntry `json:"models"`
Supported bool `json:"supported"`
Cached bool `json:"cached"`
CachedAt *time.Time `json:"cached_at"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.Status != ModelListCompleted {
t.Fatalf("status = %q, want completed so clients stop polling", decoded.Status)
}
if decoded.ID == "" {
t.Error("a cache hit still needs a request id for client-side bookkeeping")
}
if len(decoded.Models) != 2 || !decoded.Supported {
t.Fatalf("cache hit must carry the catalog: %+v supported=%v", decoded.Models, decoded.Supported)
}
if !decoded.Cached || decoded.CachedAt == nil {
t.Fatalf("cache hit must be marked: cached=%v cached_at=%v", decoded.Cached, decoded.CachedAt)
}
// A live (non-cached) response must not carry the markers at all, so older
// clients see exactly the previous payload.
live, err := json.Marshal(&ModelListRequest{ID: "x", RuntimeID: "rt-1", Status: ModelListPending, Supported: true})
if err != nil {
t.Fatalf("marshal live: %v", err)
}
var liveFields map[string]any
if err := json.Unmarshal(live, &liveFields); err != nil {
t.Fatalf("unmarshal live: %v", err)
}
if _, ok := liveFields["cached"]; ok {
t.Error("live responses must omit the cached marker")
}
if _, ok := liveFields["cached_at"]; ok {
t.Error("live responses must omit cached_at")
}
}
+130 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ---------------------------------------------------------------------------
@@ -60,6 +61,15 @@ type ModelListRequest struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RunStartedAt *time.Time `json:"-"`
// Cached marks a response answered from the server-side catalog cache
// instead of a live daemon round trip (MUL-5444). Purely informational —
// Status is already "completed" and Models is already populated, so a client
// that ignores this field behaves exactly as before. CachedAt carries the
// snapshot's capture time for clients that want to surface freshness.
// Neither field is ever persisted in the request store; they only exist on
// the synthetic cache-hit response.
Cached bool `json:"cached,omitempty"`
CachedAt *time.Time `json:"cached_at,omitempty"`
}
// ModelEntry mirrors agent.Model for the wire. `Default` tags the
@@ -290,8 +300,17 @@ func modelListRequestTerminal(status ModelListStatus) bool {
// Handlers
// ---------------------------------------------------------------------------
// InitiateListModels creates a pending model list request for a runtime.
// Called by the frontend; the daemon picks it up on its next heartbeat.
// InitiateListModels answers a "list this runtime's models" request.
//
// Fast path: a cached catalog younger than modelCatalogServeWindow is returned
// as an already-completed request, so the picker renders immediately instead of
// waiting for the daemon (stale-while-revalidate). Serving a snapshot older than
// modelCatalogRevalidateAfter also enqueues a background refresh, which nobody
// polls — its only job is to warm the cache for the next open.
//
// Slow path: enqueue a pending request the daemon claims on its next heartbeat,
// and push a wakeup hint so "next heartbeat" is now rather than up to one
// HeartbeatInterval away.
func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
@@ -311,15 +330,104 @@ func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "runtime is offline")
return
}
resolvedRuntimeID := uuidToString(rt.ID)
req, err := h.ModelListStore.Create(r.Context(), uuidToString(rt.ID))
if cached := h.cachedModelCatalog(r.Context(), resolvedRuntimeID); cached != nil {
age := cached.Age(time.Now())
if age >= modelCatalogRevalidateAfter {
h.revalidateModelCatalog(r.Context(), resolvedRuntimeID)
}
storedAt := cached.StoredAt
writeJSON(w, http.StatusOK, &ModelListRequest{
// Synthetic ID: no store record backs a cache hit. Clients only poll
// GET /models/{id} while status is pending/running, which this
// response never is.
ID: randomID(),
RuntimeID: resolvedRuntimeID,
Status: ModelListCompleted,
Models: cached.Models,
Supported: cached.Supported,
CreatedAt: storedAt,
UpdatedAt: storedAt,
Cached: true,
CachedAt: &storedAt,
})
return
}
req, err := h.ModelListStore.Create(r.Context(), resolvedRuntimeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue model list request: "+err.Error())
return
}
h.requestDaemonPendingWork(resolvedRuntimeID, protocol.PendingWorkKindModelList)
writeJSON(w, http.StatusOK, req)
}
// cachedModelCatalog returns a usable cached catalog, or nil when the cache is
// absent, cold, or unreadable. Cache problems must never fail the request — the
// caller just falls back to the daemon round trip.
func (h *Handler) cachedModelCatalog(ctx context.Context, runtimeID string) *ModelCatalogSnapshot {
if h.ModelCatalogCache == nil {
return nil
}
snapshot, err := h.ModelCatalogCache.Get(ctx, runtimeID)
if err != nil {
slog.Warn("model catalog cache read failed", "error", err, "runtime_id", runtimeID)
return nil
}
if snapshot == nil || !cacheableModelCatalog(snapshot.Models, snapshot.Supported) {
return nil
}
return snapshot
}
// revalidateModelCatalog enqueues a background discovery request whose result
// only updates the cache. No client polls it; the store's own timeout sweeps the
// record if the daemon never answers.
//
// Stampede control: skip when a request for this runtime is already queued. A
// running request that has already been claimed is not visible to HasPending, so
// two opens in the same second can enqueue two refreshes — bounded, cheap
// (the daemon memoizes discovery for 60s), and strictly better than the
// alternative of never refreshing.
func (h *Handler) revalidateModelCatalog(ctx context.Context, runtimeID string) {
if h.ModelListStore == nil {
return
}
pending, err := h.ModelListStore.HasPending(ctx, runtimeID)
if err != nil {
slog.Debug("model catalog revalidate probe failed", "error", err, "runtime_id", runtimeID)
return
}
if pending {
return
}
if _, err := h.ModelListStore.Create(ctx, runtimeID); err != nil {
slog.Debug("model catalog revalidate enqueue failed", "error", err, "runtime_id", runtimeID)
return
}
h.requestDaemonPendingWork(runtimeID, protocol.PendingWorkKindModelList)
}
// requestDaemonPendingWork nudges the daemon to heartbeat now. Best-effort by
// design: the daemon's scheduled heartbeat remains the correctness path, so a
// missing notifier or an offline daemon only costs latency. Prefers the relay
// notifier (reaches the API node holding the daemon's socket) and falls back to
// the local hub, which is the whole cluster in single-node deployments.
func (h *Handler) requestDaemonPendingWork(runtimeID, kind string) {
if runtimeID == "" {
return
}
if h.DaemonPendingWork != nil {
h.DaemonPendingWork.NotifyPendingWork(runtimeID, kind)
return
}
if h.DaemonHub != nil {
h.DaemonHub.NotifyPendingWork(runtimeID, kind)
}
}
// GetModelListRequest returns the status of a model list request.
func (h *Handler) GetModelListRequest(w http.ResponseWriter, r *http.Request) {
requestID := chi.URLParam(r, "requestId")
@@ -391,6 +499,25 @@ func (h *Handler) ReportModelListResult(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, "failed to persist completion")
return
}
// Warm the catalog cache so the next picker open renders instantly
// (MUL-5444). A cache write failure is not the daemon's problem — the
// report itself succeeded — so it is logged, not surfaced.
//
// A completed-but-uncacheable result (empty catalog, or a runtime that
// does not honour per-agent model selection) is the freshest truth we
// have: drop any older snapshot instead of letting the fast path keep
// serving a catalog the runtime no longer advertises. Failed reports
// deliberately leave the cache alone — serving the last known good list
// through a transient discovery failure is the point of the cache.
if h.ModelCatalogCache != nil {
if cacheableModelCatalog(body.Models, supported) {
if err := h.ModelCatalogCache.Put(r.Context(), runtimeID, body.Models, supported); err != nil {
slog.Warn("model catalog cache write failed", "error", err, "runtime_id", runtimeID)
}
} else if err := h.ModelCatalogCache.Invalidate(r.Context(), runtimeID); err != nil {
slog.Warn("model catalog cache invalidate failed", "error", err, "runtime_id", runtimeID)
}
}
} else {
if err := h.ModelListStore.Fail(r.Context(), requestID, body.Error); err != nil {
slog.Error("ModelListStore Fail failed", "error", err, "request_id", requestID)
+8
View File
@@ -132,6 +132,14 @@ const (
EventDaemonTaskAvailable = "daemon:task_available"
EventDaemonRuntimeProfilesChanged = "daemon:runtime_profiles_changed"
EventDaemonWorkspacesChanged = "daemon:workspaces_changed"
// EventDaemonPendingWork is a runtime-scoped hint that a heartbeat-carried
// request (today: model-list discovery) is queued for that runtime. Without
// it the daemon only learns about the request on its next scheduled
// heartbeat, which adds up to one HeartbeatInterval (15s by default) of
// dead wait to an interactive UI flow (MUL-5444). The hint carries no work
// itself: the daemon still pulls the request through the normal heartbeat
// claim, so a lost or duplicated hint is harmless.
EventDaemonPendingWork = "daemon:pending_work"
// Generic daemon→server request/response over the WebSocket control
// connection (MUL-4257). The daemon sends EventDaemonRPCRequest with a
// correlation id + method + body; the server replies EventDaemonRPCResponse
+19
View File
@@ -84,6 +84,25 @@ type RuntimeProfilesChangedPayload struct {
// no workspace data is embedded in the event.
type WorkspacesChangedPayload struct{}
// PendingWorkKind values carried by PendingWorkPayload.Kind. The kind is
// advisory only — the daemon reacts identically to every kind (one immediate
// heartbeat, which claims whatever is queued) — so an unknown value from a
// newer server stays safe on an older daemon.
const (
PendingWorkKindModelList = "model_list"
)
// PendingWorkPayload is sent from server to daemon as a wakeup hint when a
// heartbeat-carried request is enqueued for a runtime. The daemon responds by
// sending one immediate heartbeat for RuntimeID instead of waiting for its next
// scheduled tick; the request itself is still claimed through the normal
// heartbeat path, so this event carries no work and is safe to lose, duplicate,
// or ignore (MUL-5444).
type PendingWorkPayload struct {
RuntimeID string `json:"runtime_id"`
Kind string `json:"kind,omitempty"`
}
// TaskProgressPayload is sent from daemon to server during task execution.
type TaskProgressPayload struct {
TaskID string `json:"task_id"`