From 46ec7a43a8af97856b2b4f13ca43ca537ac079fa Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:17:07 -0600 Subject: [PATCH] fix(agents): prevent model runtime startup timeout (#116039) --- ...odel.static-catalog.snapshot-cache.test.ts | 30 +++++++++++++++++ .../model.static-catalog.ts | 8 +++++ .../prepared-model-runtime.lifecycle.test.ts | 3 +- src/agents/prepared-model-runtime.owner.ts | 20 ++++++------ ...pared-model-runtime.startup-static.test.ts | 3 +- src/agents/prepared-model-runtime.test.ts | 32 +++++++++++++------ 6 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts index ced757502b3d..c64c761a48c3 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts @@ -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"; diff --git a/src/agents/embedded-agent-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts index 282ea6c12971..f9424e573afc 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -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; diff --git a/src/agents/prepared-model-runtime.lifecycle.test.ts b/src/agents/prepared-model-runtime.lifecycle.test.ts index 98c7b6abf7a2..ea01f52fca07 100644 --- a/src/agents/prepared-model-runtime.lifecycle.test.ts +++ b/src/agents/prepared-model-runtime.lifecycle.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ })), ensureRuntimePluginsLoaded: vi.fn(), loadStaticCatalog: vi.fn(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) => mocks.loadStaticCatalog(...args), - resolveBundledStaticCatalogModel: () => undefined, + createBundledStaticCatalogModelResolver: () => mocks.resolveStaticCatalogModel, })); vi.mock("../logging/subsystem.js", () => ({ diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index 44b0ec3ea31c..b2857569eee3 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -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(); + 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 } : {}), }); diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index c35ea774a359..f374397e5666 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -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", () => ({ diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 03c08789ee2f..94e77c384f13 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -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; 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(async () => []), - resolveBundledStaticCatalogModel: vi.fn(() => undefined), + resolveStaticCatalogModel: vi.fn(() => undefined), + createStaticCatalogResolver: vi.fn(), 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) => mocks.loadStaticCatalog(...args), - resolveBundledStaticCatalogModel: (...args: Parameters) => - mocks.resolveBundledStaticCatalogModel(...args), + createBundledStaticCatalogModelResolver: (...args: Parameters) => + 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" } } } },