fix(agents): prevent model runtime startup timeout (#116039)

This commit is contained in:
Jason (Json)
2026-07-29 13:17:07 -06:00
committed by GitHub
parent e0ab154e5e
commit 46ec7a43a8
6 changed files with 76 additions and 20 deletions
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
const manifestMocks = vi.hoisted(() => ({
getCurrentPluginMetadataSnapshot: vi.fn(),
@@ -176,6 +177,35 @@ describe("bundled static model catalog snapshot cache", () => {
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("pins lifecycle lookups to the supplied plugin generation", () => {
const cfg = {};
const capturedPlugin = createMistralManifestPlugin();
const capturedSnapshot = {
plugins: [capturedPlugin],
manifestRegistry: { plugins: [capturedPlugin] },
} as unknown as PluginMetadataSnapshot;
const resolveModel = createBundledStaticCatalogModelResolver({
cfg,
metadataSnapshot: capturedSnapshot,
});
const replacementPlugin = createMistralManifestPlugin();
replacementPlugin.modelCatalog.providers.mistral.models =
replacementPlugin.modelCatalog.providers.mistral.models.map((model) => ({
...model,
id: "mistral-medium-next",
name: "Mistral Medium Next",
}));
setCurrentManifestPlugins([replacementPlugin]);
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })?.id).toBe(
"mistral-medium-3-5",
);
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-next" })).toBeUndefined();
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("uses the matching configured workspace snapshot", () => {
const cfg = {};
const workspaceDir = "/configured-workspace";
@@ -131,6 +131,7 @@ type StaticCatalogPlugin = Parameters<
type BundledStaticCatalogParams = {
cfg?: OpenClawConfig;
env: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
};
@@ -150,6 +151,11 @@ const defaultBundledStaticCatalogConfig: OpenClawConfig = {};
function resolveBundledStaticCatalogMetadataSnapshot(
params: BundledStaticCatalogParams,
): PluginMetadataSnapshot | undefined {
// Lifecycle callers pin the catalog to the plugin generation they are publishing.
// Rediscovery here can mix generations and repeat manifest work for every model lookup.
if (params.metadataSnapshot) {
return params.metadataSnapshot;
}
if (params.env !== process.env) {
return undefined;
}
@@ -280,11 +286,13 @@ export function createBundledStaticCatalogModelResolver(params?: {
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeRuntimeDiscovery?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
}): (lookup: BundledStaticCatalogLookup) => ProviderRuntimeModel | undefined {
const catalogParams = {
cfg: params?.cfg,
env: params?.env ?? process.env,
...(params?.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
workspaceDir: params?.workspaceDir,
};
let standaloneState: BundledStaticCatalogState | undefined;
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
})),
ensureRuntimePluginsLoaded: vi.fn(),
loadStaticCatalog: vi.fn<LoadStaticCatalog>(async () => []),
resolveStaticCatalogModel: vi.fn(() => undefined),
configuredAgentIds: [] as string[],
warn: vi.fn(),
mutationListener: undefined as
@@ -78,7 +79,7 @@ vi.mock("./runtime-plugins.js", () => ({
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
loadBundledProviderStaticCatalogContextModels: (...args: Parameters<LoadStaticCatalog>) =>
mocks.loadStaticCatalog(...args),
resolveBundledStaticCatalogModel: () => undefined,
createBundledStaticCatalogModelResolver: () => mocks.resolveStaticCatalogModel,
}));
vi.mock("../logging/subsystem.js", () => ({
+11 -9
View File
@@ -29,8 +29,8 @@ import {
type InlineModelEntry,
} from "./embedded-agent-runner/model.inline-provider.js";
import {
createBundledStaticCatalogModelResolver,
loadBundledProviderStaticCatalogContextModels,
resolveBundledStaticCatalogModel,
} from "./embedded-agent-runner/model.static-catalog.js";
import { staticModelIdMatches } from "./embedded-agent-runner/model.static-id.js";
import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js";
@@ -318,11 +318,19 @@ function collectPreparedModelRuntimeProviderIds(
function prepareConfiguredRuntimeModels(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
metadataSnapshot: PluginMetadataSnapshot;
providerStaticModels: readonly ProviderRuntimeModel[];
workspaceDir?: string;
}): PreparedConfiguredRuntimeModel[] {
const prepared: PreparedConfiguredRuntimeModel[] = [];
const seen = new Set<string>();
const resolveStaticCatalogModel = createBundledStaticCatalogModelResolver({
cfg: params.config,
env: params.env,
includeRuntimeDiscovery: true,
metadataSnapshot: params.metadataSnapshot,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
for (const { value } of collectConfiguredModelRefs(params.config)) {
const separator = value.indexOf("/");
if (separator <= 0 || separator >= value.length - 1) {
@@ -341,14 +349,7 @@ function prepareConfiguredRuntimeModels(params: {
// Match request-time fallback precedence exactly: manifest/runtime-discovery rows win,
// and the provider-static catalog fills only models absent from that surface.
const model =
resolveBundledStaticCatalogModel({
provider,
modelId,
cfg: params.config,
env: params.env,
workspaceDir: params.workspaceDir,
includeRuntimeDiscovery: true,
}) ??
resolveStaticCatalogModel({ provider, modelId }) ??
params.providerStaticModels.find((candidate) =>
staticModelIdMatches({
candidateId: candidate.id,
@@ -541,6 +542,7 @@ async function buildSnapshot(
const configuredRuntimeModels = prepareConfiguredRuntimeModels({
config: input.config,
env,
metadataSnapshot: pluginMetadataSnapshot,
providerStaticModels,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
@@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => {
buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })),
ensureRuntimePluginsLoaded: vi.fn(),
loadStaticCatalog: vi.fn(async () => []),
resolveStaticCatalogModel: vi.fn(() => undefined),
mutationListener: undefined as
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
| undefined,
@@ -80,7 +81,7 @@ vi.mock("./runtime-plugins.js", () => ({
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
loadBundledProviderStaticCatalogContextModels: mocks.loadStaticCatalog,
resolveBundledStaticCatalogModel: () => undefined,
createBundledStaticCatalogModelResolver: () => mocks.resolveStaticCatalogModel,
}));
vi.mock("../logging/subsystem.js", () => ({
+23 -9
View File
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
type LoadStaticCatalog =
typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels;
type ResolveStaticCatalog =
typeof import("./embedded-agent-runner/model.static-catalog.js").resolveBundledStaticCatalogModel;
type CreateStaticCatalogResolver =
typeof import("./embedded-agent-runner/model.static-catalog.js").createBundledStaticCatalogModelResolver;
type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
const mocks = vi.hoisted(() => ({
authStorage: { getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })) },
@@ -23,7 +24,8 @@ const mocks = vi.hoisted(() => ({
})),
ensureRuntimePluginsLoaded: vi.fn(),
loadStaticCatalog: vi.fn<LoadStaticCatalog>(async () => []),
resolveBundledStaticCatalogModel: vi.fn<ResolveStaticCatalog>(() => undefined),
resolveStaticCatalogModel: vi.fn<StaticCatalogResolver>(() => undefined),
createStaticCatalogResolver: vi.fn<CreateStaticCatalogResolver>(),
configuredAgentIds: [] as string[],
mutationListener: undefined as
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
@@ -80,8 +82,8 @@ vi.mock("./runtime-plugins.js", () => ({
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
loadBundledProviderStaticCatalogContextModels: (...args: Parameters<LoadStaticCatalog>) =>
mocks.loadStaticCatalog(...args),
resolveBundledStaticCatalogModel: (...args: Parameters<ResolveStaticCatalog>) =>
mocks.resolveBundledStaticCatalogModel(...args),
createBundledStaticCatalogModelResolver: (...args: Parameters<CreateStaticCatalogResolver>) =>
mocks.createStaticCatalogResolver(...args),
}));
vi.mock("../logging/subsystem.js", () => ({
@@ -117,7 +119,9 @@ describe("prepared model runtime snapshots", () => {
mocks.buildPreparedModelCatalogSnapshot.mockClear();
mocks.ensureRuntimePluginsLoaded.mockClear();
mocks.loadStaticCatalog.mockClear();
mocks.resolveBundledStaticCatalogModel.mockClear();
mocks.resolveStaticCatalogModel.mockReset();
mocks.createStaticCatalogResolver.mockReset();
mocks.createStaticCatalogResolver.mockReturnValue(mocks.resolveStaticCatalogModel);
mocks.modelRegistry.fork.mockClear();
mocks.configuredAgentIds = [];
});
@@ -286,7 +290,7 @@ describe("prepared model runtime snapshots", () => {
contextWindow: 1_050_000,
maxTokens: 128_000,
};
mocks.resolveBundledStaticCatalogModel.mockReturnValueOnce(runtimeModel);
mocks.resolveStaticCatalogModel.mockReturnValueOnce(runtimeModel);
const config = {
agents: {
defaults: {
@@ -311,7 +315,17 @@ describe("prepared model runtime snapshots", () => {
env: process.env,
workspaceDir: "/tmp/prepared-model-runtime-manifest-workspace",
});
expect(mocks.resolveBundledStaticCatalogModel).toHaveBeenCalledOnce();
expect(mocks.createStaticCatalogResolver).toHaveBeenCalledOnce();
expect(mocks.createStaticCatalogResolver).toHaveBeenCalledWith(
expect.objectContaining({
cfg: config,
env: process.env,
includeRuntimeDiscovery: true,
metadataSnapshot: snapshot.metadataSnapshot,
workspaceDir: "/tmp/prepared-model-runtime-manifest-workspace",
}),
);
expect(mocks.resolveStaticCatalogModel).toHaveBeenCalledOnce();
expect(snapshot.agentId).toBe("qa");
expect(snapshot.configuredRuntimeModels).toEqual([
{ provider: "openai", modelId: "gpt-5.4", model: runtimeModel },
@@ -350,7 +364,7 @@ describe("prepared model runtime snapshots", () => {
baseUrl: "https://provider-static.example.test/v1",
},
]);
mocks.resolveBundledStaticCatalogModel.mockReturnValueOnce(runtimeModel);
mocks.resolveStaticCatalogModel.mockReturnValueOnce(runtimeModel);
const snapshot = await publishPreparedModelRuntimeSnapshot({
config: { agents: { defaults: { model: { primary: "nvidia/nemotron-static" } } } },