From 430ea3c67159f741fa45be691a7f867895f28558 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 22:04:51 -0700 Subject: [PATCH] perf(gateway): prewarm first Control UI requests while idle (#158555) * perf(gateway): prewarm first-use chat paths while idle * fix(gateway): validate idle prewarm handler lookup * test(gateway): normalize idle prewarm fixture paths * test(gateway): narrow prewarm call order assertion --- docs/reference/test/performance.md | 9 + .../server-idle-task.lifecycle.test.ts | 34 +-- ...ls-connect-publication.integration.test.ts | 3 - .../server-plugin-reload.recovery.test.ts | 3 - .../server-startup-context-cache-prewarm.ts | 52 ---- .../server-startup-handler-prewarm.test.ts | 227 ++++++++++++++++-- src/gateway/server-startup-handler-prewarm.ts | 86 ++++++- .../server-startup-post-attach.test.ts | 59 +---- src/gateway/server-startup-post-attach.ts | 2 - .../server-startup-readiness.test-support.ts | 2 +- 10 files changed, 300 insertions(+), 177 deletions(-) delete mode 100644 src/gateway/server-startup-context-cache-prewarm.ts diff --git a/docs/reference/test/performance.md b/docs/reference/test/performance.md index 436f19cf6706..95aed18dba4d 100644 --- a/docs/reference/test/performance.md +++ b/docs/reference/test/performance.md @@ -285,6 +285,15 @@ Output includes first process output, `/healthz`, `/readyz`, HTTP listen log tim Use JSON output or `--output` when comparing changes. Use `--cpu-prof-dir` only after trace output points at import, compile, or CPU-bound work that phase timings alone cannot explain. +After readiness, the Gateway uses idle turns to prepare common Control UI handler +modules and configured local agent skill discovery. Each item yields to admitted +foreground work, and shutdown joins preparation that has already started. This +work does not execute chat requests, create connection state, or fetch live provider +catalogs. Context-window cache preparation shares this sequence and starts no +earlier than five seconds after scheduling. Compare immediate first requests with +requests after an idle interval; readiness alone does not guarantee every optional +cache is warm. + diff --git a/src/gateway/server-idle-task.lifecycle.test.ts b/src/gateway/server-idle-task.lifecycle.test.ts index 57800765905f..33e67eb74108 100644 --- a/src/gateway/server-idle-task.lifecycle.test.ts +++ b/src/gateway/server-idle-task.lifecycle.test.ts @@ -8,7 +8,6 @@ import { } from "../process/gateway-work-admission.js"; import { scheduleGatewayIdleTask } from "./server-idle-task.js"; import { createGatewaySidecarStopOwner } from "./server-sidecar-owners.js"; -import { scheduleContextCachePrewarm } from "./server-startup-context-cache-prewarm.js"; import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js"; afterEach(() => { @@ -16,7 +15,7 @@ afterEach(() => { resetGatewayWorkAdmission(); }); -it.each(["idle", "handler", "context"] as const)( +it.each(["idle", "handler"] as const)( "joins started %s work before the Gateway sidecar owner closes", async (kind) => { vi.useFakeTimers(); @@ -40,30 +39,19 @@ it.each(["idle", "handler", "context"] as const)( log, errorMessage: "idle lifecycle test failed", }) - : kind === "handler" - ? scheduleGatewayHandlerPrewarm({ - cfgAtStart: {}, - log, - items: [ - { name: "first", load: run }, - { name: "later", load: later }, - ], - }) - : scheduleContextCachePrewarm({ - getConfig: () => ({}), - log, - startupTrace: { - measure: async (_name, warm) => { - await run(); - return warm(); - }, - }, - }); + : scheduleGatewayHandlerPrewarm({ + getConfig: () => ({}), + log, + items: [ + { name: "first", load: run }, + { name: "later", load: later }, + ], + }); const owner = createGatewaySidecarStopOwner(); owner.publish(handle); let stopping: Promise | undefined; try { - await vi.advanceTimersByTimeAsync(kind === "context" ? 5_000 : 0); + await vi.advanceTimersByTimeAsync(0); expect([...events]).toEqual(["started"]); owner.beginClose(); stopping = owner.stop().then(() => { @@ -138,7 +126,7 @@ it("joins the outgoing handler when shutdown begins in its warning callback", as }); }); const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: {}, + getConfig: () => ({}), log: { warn }, items: [ { diff --git a/src/gateway/server-methods/models-connect-publication.integration.test.ts b/src/gateway/server-methods/models-connect-publication.integration.test.ts index 3b98d5e1f09e..888b48e06d94 100644 --- a/src/gateway/server-methods/models-connect-publication.integration.test.ts +++ b/src/gateway/server-methods/models-connect-publication.integration.test.ts @@ -23,9 +23,6 @@ import { } from "../test-helpers.e2e.js"; // Optional startup prewarming must not compete with the catalog request drain. -vi.mock("../server-startup-context-cache-prewarm.js", () => ({ - scheduleContextCachePrewarm: () => ({ stop() {} }), -})); vi.mock("../server-startup-handler-prewarm.js", () => ({ scheduleGatewayHandlerPrewarm: () => ({ stop() {} }), })); diff --git a/src/gateway/server-plugin-reload.recovery.test.ts b/src/gateway/server-plugin-reload.recovery.test.ts index 0bb350af83c7..b3c9fb8e9822 100644 --- a/src/gateway/server-plugin-reload.recovery.test.ts +++ b/src/gateway/server-plugin-reload.recovery.test.ts @@ -97,9 +97,6 @@ vi.mock("../plugins/plugin-lookup-table.js", async (importOriginal) => ({ })); // These independent startup tasks do not participate in plugin replacement. -vi.mock("./server-startup-context-cache-prewarm.js", () => ({ - scheduleContextCachePrewarm: () => ({ stop() {} }), -})); vi.mock("./server-startup-handler-prewarm.js", () => ({ scheduleGatewayHandlerPrewarm: () => ({ stop() {} }), })); diff --git a/src/gateway/server-startup-context-cache-prewarm.ts b/src/gateway/server-startup-context-cache-prewarm.ts deleted file mode 100644 index 19bb41485b77..000000000000 --- a/src/gateway/server-startup-context-cache-prewarm.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission.js"; -import { scheduleGatewayIdleTask, type GatewayIdleTaskHandle } from "./server-idle-task.js"; - -const CONTEXT_CACHE_PREWARM_START_DELAY_MS = 5_000; -const CONTEXT_CACHE_PREWARM_RETRY_DELAY_MS = 250; - -type StartupTrace = { - measure: (name: string, run: () => T | Promise) => Promise; -}; - -export function scheduleContextCachePrewarm(params: { - getConfig: () => OpenClawConfig; - startupTrace?: StartupTrace; - log: { warn: (msg: string) => void }; -}): GatewayIdleTaskHandle { - let stopped = false; - const warm = async () => { - if (stopped) { - return; - } - const { prewarmContextWindowCacheAfterReady } = await import("../agents/context.js"); - if (!stopped) { - await prewarmContextWindowCacheAfterReady({ - config: params.getConfig(), - isCancelled: () => stopped, - }); - } - }; - - // Source-backed provider discovery can consume the main thread. Give - // readiness probes and immediate client work a clean event-loop window. - const idleTask = scheduleGatewayIdleTask({ - delayMs: CONTEXT_CACHE_PREWARM_START_DELAY_MS, - retryDelayMs: CONTEXT_CACHE_PREWARM_RETRY_DELAY_MS, - isClosing: () => stopped, - isBusy: () => getActiveGatewayRootWorkCount({ excludeCurrent: true }) > 0, - run: () => - params.startupTrace - ? params.startupTrace.measure("post-ready.context-window-cache", warm) - : warm(), - log: params.log, - errorMessage: "post-ready.context-window-cache failed after gateway ready", - }); - - return { - stop: () => { - stopped = true; - return idleTask.stop(); - }, - }; -} diff --git a/src/gateway/server-startup-handler-prewarm.test.ts b/src/gateway/server-startup-handler-prewarm.test.ts index 32f3fb8c3ee1..1dd3778786ae 100644 --- a/src/gateway/server-startup-handler-prewarm.test.ts +++ b/src/gateway/server-startup-handler-prewarm.test.ts @@ -1,12 +1,22 @@ +import path from "node:path"; +import { expectDefined } from "@openclaw/normalization-core/expect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetGatewayWorkAdmission, + tryBeginGatewayIndependentRootWorkAdmission, tryBeginGatewayRootWorkAdmission, } from "../process/gateway-work-admission.js"; const mocks = vi.hoisted(() => ({ events: [] as string[], + executeRequest: vi.fn(), + ensureSkillsWatcher: vi.fn(), + prepareWorkspaceSkillEntries: vi.fn< + typeof import("../skills/loading/workspace-skill-loader.js").prepareWorkspaceSkillEntries + >(async () => ({ entries: [] })), + prewarmContextWindowCacheAfterReady: vi.fn(async () => {}), getMemoryCapabilityRegistration: vi.fn<() => { pluginId: string } | undefined>(), prewarmMemorySearchWorker: vi.fn(async () => { mocks.events.push("memory-search"); @@ -36,6 +46,40 @@ vi.mock("../plugins/management-service.js", () => ({ listManagedPlugins: mocks.listManagedPlugins, })); +vi.mock("./server/ws-connection/message-handler.js", () => { + mocks.events.push("connection"); + return { attachGatewayWsMessageHandler: mocks.executeRequest }; +}); +vi.mock("./server-chat.js", () => { + mocks.events.push("agent-events"); + return { createAgentEventHandler: mocks.executeRequest }; +}); +vi.mock("./server-session-key.js", () => ({ resolveSessionKeyForRun: mocks.executeRequest })); +vi.mock("./server-methods/core-handlers.js", async () => { + const { createLazyCoreHandlers } = await import("./server-methods/lazy-core-handlers.js"); + return { + coreGatewayHandlers: createLazyCoreHandlers({ + methods: ["chat.history", "chat.send", "sessions.list"], + loadHandlers: async () => { + mocks.events.push("handlers"); + return { + "chat.history": mocks.executeRequest, + "chat.send": mocks.executeRequest, + "sessions.list": mocks.executeRequest, + }; + }, + }), + }; +}); +vi.mock("../skills/loading/workspace-skill-loader.js", () => ({ + prepareWorkspaceSkillEntries: mocks.prepareWorkspaceSkillEntries, +})); +vi.mock("../agents/workspace-access.js", () => ({ getAgentWorkspaceAccess: () => undefined })); +vi.mock("../skills/runtime/refresh.js", () => ({ ensureSkillsWatcher: mocks.ensureSkillsWatcher })); +vi.mock("../agents/context.js", () => ({ + prewarmContextWindowCacheAfterReady: mocks.prewarmContextWindowCacheAfterReady, +})); + vi.mock("../plugins/memory-state.js", () => ({ getMemoryCapabilityRegistration: mocks.getMemoryCapabilityRegistration, })); @@ -45,9 +89,17 @@ vi.mock("../plugins/public-surface-loader.js", () => ({ })); const { scheduleGatewayHandlerPrewarm } = await import("./server-startup-handler-prewarm.js"); +const workspaces = { + main: path.resolve("prewarm-main"), + research: path.resolve("prewarm-research"), +}; beforeEach(() => { mocks.events.length = 0; + mocks.executeRequest.mockClear(); + mocks.ensureSkillsWatcher.mockClear(); + mocks.prepareWorkspaceSkillEntries.mockClear(); + mocks.prewarmContextWindowCacheAfterReady.mockClear(); mocks.loadCombinedSessionStoreForGatewayCore.mockClear(); mocks.listManagedPlugins.mockClear(); mocks.getMemoryCapabilityRegistration.mockReset(); @@ -61,32 +113,92 @@ afterEach(() => { }); describe("scheduleGatewayHandlerPrewarm", () => { - it.each([undefined, "memory-lancedb", "memory-core"])( - "warms retrieval only for active Memory Core (memory plugin: %s)", + it("prepares first-use modules, primary skills, and Memory Core in sequence without executing requests", async () => { + vi.useFakeTimers(); + mocks.getMemoryCapabilityRegistration.mockReturnValue({ pluginId: "memory-core" }); + const cfg: OpenClawConfig = { + agents: { + entries: { + main: { workspace: workspaces.main }, + research: { workspace: workspaces.research }, + }, + }, + }; + + const sidecar = scheduleGatewayHandlerPrewarm({ + getConfig: () => cfg, + log: { warn: vi.fn() }, + }); + + try { + expect(mocks.events).toEqual([]); + // Dynamic imports can enqueue the next idle timer after the current timer drain. + do { + await vi.runAllTimersAsync(); + await vi.dynamicImportSettled(); + } while (vi.getTimerCount() > 0); + + expect(mocks.events).toContain("connection"); + expect(mocks.events).toContain("agent-events"); + expect(mocks.events.filter((event) => event === "handlers")).toHaveLength(3); + expect(mocks.executeRequest).not.toHaveBeenCalled(); + expect(mocks.prepareWorkspaceSkillEntries.mock.calls).toEqual([ + [workspaces.main, { config: cfg, agentId: "main" }], + [workspaces.research, { config: cfg, agentId: "research" }], + ]); + expect(mocks.ensureSkillsWatcher.mock.calls).toEqual([ + [{ workspaceDir: workspaces.main, config: cfg, agentId: "main" }], + [{ workspaceDir: workspaces.research, config: cfg, agentId: "research" }], + ]); + expect(mocks.ensureSkillsWatcher.mock.invocationCallOrder[0]).toBeLessThan( + expectDefined( + mocks.prepareWorkspaceSkillEntries.mock.invocationCallOrder[0], + "skill preparation call", + ), + ); + expect(mocks.prewarmContextWindowCacheAfterReady).toHaveBeenCalledOnce(); + expect(mocks.loadCombinedSessionStoreForGatewayCore).not.toHaveBeenCalled(); + expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledOnce(); + expect(mocks.prewarmMemorySearchWorker).toHaveBeenCalledOnce(); + const memoryCall = expectDefined( + mocks.prewarmMemorySearchWorker.mock.invocationCallOrder[0], + "memory retrieval preparation call", + ); + expect(mocks.prewarmContextWindowCacheAfterReady.mock.invocationCallOrder[0]).toBeLessThan( + memoryCall, + ); + expect(memoryCall).toBeLessThan( + expectDefined( + mocks.listManagedPlugins.mock.invocationCallOrder[0], + "plugin preparation call", + ), + ); + expect(mocks.listManagedPlugins).toHaveBeenCalledWith({ config: cfg }); + } finally { + await sidecar.stop(); + } + }); + + it.each([undefined, "memory-lancedb"])( + "skips retrieval preparation when Memory Core is inactive (%s)", async (pluginId) => { vi.useFakeTimers(); mocks.getMemoryCapabilityRegistration.mockReturnValue(pluginId ? { pluginId } : undefined); - const cfg = { - agents: { list: [{ id: "main", default: true }, { id: "research" }] }, - } as never; - const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: cfg, + getConfig: () => ({ agents: { entries: {} } }), log: { warn: vi.fn() }, }); - - expect(mocks.events).toEqual([]); - await vi.runAllTimersAsync(); - - expect(mocks.events).toEqual( - pluginId === "memory-core" ? ["memory-search", "plugins"] : ["plugins"], - ); - expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledTimes( - pluginId === "memory-core" ? 1 : 0, - ); - expect(mocks.loadCombinedSessionStoreForGatewayCore).not.toHaveBeenCalled(); - expect(mocks.listManagedPlugins).toHaveBeenCalledWith({ config: cfg }); - await sidecar.stop(); + try { + do { + await vi.runAllTimersAsync(); + await vi.dynamicImportSettled(); + } while (vi.getTimerCount() > 0); + expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled(); + expect(mocks.prewarmMemorySearchWorker).not.toHaveBeenCalled(); + expect(mocks.listManagedPlugins).toHaveBeenCalledOnce(); + } finally { + await sidecar.stop(); + } }, ); @@ -96,7 +208,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { const load = vi.fn(async () => {}); const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: {} as never, + getConfig: () => ({}), log: { warn: vi.fn() }, items: [{ name: "sessions", load }], waitForPostReadyWork: () => gatewayReady, @@ -119,7 +231,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { } const load = vi.fn(async () => {}); const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: {} as never, + getConfig: () => ({}), log: { warn: vi.fn() }, items: [{ name: "sessions", load }], }); @@ -131,7 +243,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { await vi.advanceTimersByTimeAsync(249); expect(load).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); + expect(load).toHaveBeenCalledOnce(); await sidecar.stop(); }); @@ -141,7 +253,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { const load = vi.fn(async () => {}); const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: {} as never, + getConfig: () => ({}), log: { warn: vi.fn() }, items: [{ name: "sessions", load }], waitForPostReadyWork: () => gatewayReady, @@ -165,7 +277,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { .mockResolvedValue("request result"); scheduleGatewayHandlerPrewarm({ - cfgAtStart: {} as never, + getConfig: () => ({}), log: { warn }, items: [ { @@ -197,7 +309,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { ); const second = vi.fn(async () => {}); const sidecar = scheduleGatewayHandlerPrewarm({ - cfgAtStart: {} as never, + getConfig: () => ({}), log: { warn: vi.fn() }, items: [ { name: "first", load: first }, @@ -215,3 +327,66 @@ describe("scheduleGatewayHandlerPrewarm", () => { expect(second).not.toHaveBeenCalled(); }); }); + +it("keeps the context cache delayed and uses current config after foreground work", async () => { + vi.useFakeTimers(); + const initial: OpenClawConfig = { agents: { entries: {} } }; + let current = initial; + const handle = scheduleGatewayHandlerPrewarm({ + getConfig: () => current, + log: { warn: vi.fn() }, + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(mocks.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); + const request = tryBeginGatewayRootWorkAdmission(); + if (!request) { + throw new Error("Expected foreground admission"); + } + try { + await vi.advanceTimersByTimeAsync(1); + expect(mocks.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); + current = { agents: { entries: {} }, skills: { load: { watch: false } } }; + request.release(); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.prewarmContextWindowCacheAfterReady).toHaveBeenCalledWith({ + config: current, + isCancelled: expect.any(Function), + }); + } finally { + request.release(); + await handle.stop(); + } +}); + +it("skips optional discovery when foreground work arrives after idle admission", async () => { + vi.useFakeTimers(); + mocks.getMemoryCapabilityRegistration.mockReturnValue({ pluginId: "memory-core" }); + const handle = scheduleGatewayHandlerPrewarm({ + getConfig: () => ({ agents: { entries: { main: { workspace: workspaces.main } } } }), + log: { warn: vi.fn() }, + startupTrace: { + measure: async (_name, load) => { + const request = tryBeginGatewayIndependentRootWorkAdmission("test-request"); + if (!request) { + throw new Error("Expected foreground admission"); + } + try { + return await load(); + } finally { + request.release(); + } + }, + }, + }); + try { + do { + await vi.runAllTimersAsync(); + await vi.dynamicImportSettled(); + } while (vi.getTimerCount() > 0); + expect(mocks.prepareWorkspaceSkillEntries).not.toHaveBeenCalled(); + expect(mocks.ensureSkillsWatcher).not.toHaveBeenCalled(); + expect(mocks.prewarmMemorySearchWorker).not.toHaveBeenCalled(); + } finally { + await handle.stop(); + } +}); diff --git a/src/gateway/server-startup-handler-prewarm.ts b/src/gateway/server-startup-handler-prewarm.ts index 6d1dab24d3a9..1256021e5199 100644 --- a/src/gateway/server-startup-handler-prewarm.ts +++ b/src/gateway/server-startup-handler-prewarm.ts @@ -1,3 +1,4 @@ +import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission.js"; import { scheduleGatewayIdleTask, type GatewayIdleTaskHandle } from "./server-idle-task.js"; @@ -10,20 +11,80 @@ type StartupTrace = { type GatewayHandlerPrewarmItem = { name: string; + notBeforeMs?: number; load: () => Promise; }; -function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmItem[] { +function gatewayPrewarmItems( + getConfig: () => OpenClawConfig, + isCancelled: () => boolean, +): GatewayHandlerPrewarmItem[] { return [ + { name: "connection", load: () => import("./server/ws-connection/message-handler.js") }, + ...["chat.history", "chat.send", "sessions.list"].map((method) => ({ + name: method, + load: async () => { + const [{ coreGatewayHandlers }, { prepareGatewayRequestHandler }] = await Promise.all([ + import("./server-methods/core-handlers.js"), + import("./server-methods/lazy-core-handlers.js"), + ]); + if (!isCancelled()) { + const handler = coreGatewayHandlers[method]; + if (!handler) { + throw new Error(`Gateway prewarm handler not found: ${method}`); + } + await prepareGatewayRequestHandler(handler); + } + }, + })), + { name: "agent-events", load: () => import("./server-chat.js") }, + { name: "session-key", load: () => import("./server-session-key.js") }, + ...listAgentIds(getConfig()).map((agentId) => ({ + name: `skills.${agentId}`, + load: async () => { + const [ + { prepareWorkspaceSkillEntries }, + { getAgentWorkspaceAccess }, + { ensureSkillsWatcher }, + ] = await Promise.all([ + import("../skills/loading/workspace-skill-loader.js"), + import("../agents/workspace-access.js"), + import("../skills/runtime/refresh.js"), + ]); + const config = getConfig(); + if (isCancelled() || !listAgentIds(config).includes(agentId)) { + return; + } + const workspaceDir = resolveAgentWorkspaceDir(config, agentId); + // Remote workspaces retain request-owned discovery and connection lifetimes. + if (!getAgentWorkspaceAccess(workspaceDir, "loadSkills")) { + ensureSkillsWatcher({ workspaceDir, config, agentId }); + await prepareWorkspaceSkillEntries(workspaceDir, { config, agentId }); + } + }, + })), + { + name: "context-window-cache", + notBeforeMs: 5_000, + load: async () => { + const { prewarmContextWindowCacheAfterReady } = await import("../agents/context.js"); + if (!isCancelled()) { + await prewarmContextWindowCacheAfterReady({ config: getConfig(), isCancelled }); + } + }, + }, { name: "memory-search", load: async () => { const { getMemoryCapabilityRegistration } = await import("../plugins/memory-state.js"); - if (getMemoryCapabilityRegistration()?.pluginId !== "memory-core") { + if (isCancelled() || getMemoryCapabilityRegistration()?.pluginId !== "memory-core") { return; } const { loadBundledPluginPublicArtifactModuleSync } = await import("../plugins/public-surface-loader.js"); + if (isCancelled()) { + return; + } const { prewarmMemorySearchWorker } = loadBundledPluginPublicArtifactModuleSync<{ prewarmMemorySearchWorker: () => Promise; }>({ dirName: "memory-core", artifactBasename: "prewarm-api.js" }); @@ -34,23 +95,30 @@ function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmIt name: "plugins", load: async () => { const { listManagedPlugins } = await import("../plugins/management-service.js"); - await listManagedPlugins({ config: cfg }); + if (!isCancelled()) { + await listManagedPlugins({ config: getConfig() }); + } }, }, ]; } export function scheduleGatewayHandlerPrewarm(params: { - cfgAtStart: OpenClawConfig; + getConfig: () => OpenClawConfig; startupTrace?: StartupTrace; - log: { info?: (msg: string) => void; warn: (msg: string) => void }; + log: { warn: (msg: string) => void }; items?: readonly GatewayHandlerPrewarmItem[]; waitForPostReadyWork?: () => Promise; }): GatewayIdleTaskHandle { - // Session rows are resident; warm only process-stable plugin data and retrieval code. - // Provider catalogs stay request-driven because their adapters may do unbounded external work. - const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart); let stopped = false; + const startedAt = Date.now(); + // Warm code and local facts without executing requests or acquiring live provider catalogs. + const items = + params.items ?? + gatewayPrewarmItems( + params.getConfig, + () => stopped || getActiveGatewayRootWorkCount({ excludeCurrent: true }) > 0, + ); let nextIndex = 0; let currentItemName = "unknown"; let idleTask: GatewayIdleTaskHandle | undefined; @@ -71,7 +139,7 @@ export function scheduleGatewayHandlerPrewarm(params: { currentItemName = item.name; const load = () => item.load(); idleTask = scheduleGatewayIdleTask({ - delayMs: 0, + delayMs: Math.max(0, (item.notBeforeMs ?? 0) - (Date.now() - startedAt)), retryDelayMs: GATEWAY_HANDLER_PREWARM_RETRY_DELAY_MS, isClosing: () => stopped, isBusy: () => getActiveGatewayRootWorkCount({ excludeCurrent: true }) > 0, diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 5a0022bd80a5..99639b52d37c 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -118,7 +118,6 @@ const hoisted = vi.hoisted(() => { async (_cfg?: unknown, _options?: unknown) => {}, ); const prewarmConfigDrivenReplyRuntime = vi.fn(async () => {}); - const prewarmContextWindowCacheAfterReady = vi.fn(async () => {}); const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() })); return { startPluginServices, @@ -148,7 +147,6 @@ const hoisted = vi.hoisted(() => { prepareModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, prewarmConfigDrivenReplyRuntime, - prewarmContextWindowCacheAfterReady, scheduleGatewayHandlerPrewarm, }; }); @@ -249,9 +247,6 @@ vi.mock("../auto-reply/reply/get-reply-from-config.runtime.js", () => ({ getReplyFromConfig: vi.fn(), prewarmConfigDrivenReplyRuntime: hoisted.prewarmConfigDrivenReplyRuntime, })); -vi.mock("../agents/context.js", () => ({ - prewarmContextWindowCacheAfterReady: hoisted.prewarmContextWindowCacheAfterReady, -})); vi.mock("./server-startup-handler-prewarm.js", () => ({ scheduleGatewayHandlerPrewarm: hoisted.scheduleGatewayHandlerPrewarm, @@ -262,7 +257,6 @@ const { startGatewaySidecars: startGatewaySidecarsImpl, } = await import("./server-startup-post-attach.js"); const sentinelStartup = await import("./server-startup-restart-sentinel.js"); -const { scheduleContextCachePrewarm } = await import("./server-startup-context-cache-prewarm.js"); const { STARTUP_UNAVAILABLE_GATEWAY_METHODS } = await import("./methods/core-method-policy.js"); type PostAttachParams = Parameters[0]; @@ -521,8 +515,6 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.refreshPreparedModelRuntimeSnapshots.mockResolvedValue(undefined); hoisted.prewarmConfigDrivenReplyRuntime.mockReset(); hoisted.prewarmConfigDrivenReplyRuntime.mockResolvedValue(undefined); - hoisted.prewarmContextWindowCacheAfterReady.mockReset(); - hoisted.prewarmContextWindowCacheAfterReady.mockResolvedValue(undefined); hoisted.scheduleGatewayHandlerPrewarm.mockClear(); }); @@ -1746,55 +1738,6 @@ describe("startGatewayPostAttachRuntime", () => { expect(returned).toBe(true); }); - it("defers context-window cache prewarm to a post-ready sidecar", async () => { - vi.useFakeTimers(); - const startupConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; - const currentConfig = { ...startupConfig }; - const admission = tryBeginGatewayRootWorkAdmission(); - if (!admission) { - throw new Error("Expected request work admission"); - } - const sidecar = scheduleContextCachePrewarm({ - getConfig: () => currentConfig, - log: { warn: vi.fn() }, - }); - - try { - expect(hoisted.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(4_999); - expect(hoisted.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(hoisted.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); - - admission.release(); - await vi.advanceTimersByTimeAsync(249); - expect(hoisted.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - await vi.dynamicImportSettled(); - await waitForGatewayTestState(() => { - expect(hoisted.prewarmContextWindowCacheAfterReady).toHaveBeenCalledWith({ - config: currentConfig, - isCancelled: expect.any(Function), - }); - }); - } finally { - admission.release(); - await stopTrackedSidecar(sidecar); - } - }); - - it("cancels context-window cache prewarm when the gateway stops first", async () => { - vi.useFakeTimers(); - const sidecar = scheduleContextCachePrewarm({ - getConfig: () => ({}) as never, - log: { warn: vi.fn() }, - }); - - await stopTrackedSidecar(sidecar); - await vi.runAllTimersAsync(); - expect(hoisted.prewarmContextWindowCacheAfterReady).not.toHaveBeenCalled(); - }); - it("keeps transcripts auto-start alive when Gmail post-ready sidecars stop", async () => { const onPostReadySidecars = vi.fn(); const started = createDeferred(); @@ -1825,7 +1768,7 @@ describe("startGatewayPostAttachRuntime", () => { const gmailSidecars = onPostReadySidecars.mock.calls[0]; const lifetimeSidecars = [...publishedGatewayLifetimeSidecars]; expect(gmailSidecars).toHaveLength(2); - expect(lifetimeSidecars).toHaveLength(4); + expect(lifetimeSidecars).toHaveLength(3); await started.promise; diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index fa69841fb167..8ff1636782d3 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -36,7 +36,6 @@ import type { GatewayClient, GatewayContextResolver } from "./server-methods/sha import type { GatewayPluginRuntimeClaim } from "./server-plugin-runtime-generation.js"; import type { refreshLatestUpdateRestartSentinel } from "./server-restart-sentinel.js"; import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js"; -import { scheduleContextCachePrewarm } from "./server-startup-context-cache-prewarm.js"; import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js"; import type { logGatewayStartup } from "./server-startup-log.js"; import { @@ -999,7 +998,6 @@ export async function startGatewayPostAttachRuntime( // work can create sessions that the recovery scan must leave alone. params.unlockStartupMethods(); const newGatewayLifetimeSidecars = [ - scheduleContextCachePrewarm(params), scheduleGatewayHandlerPrewarm(params), ...(mainSessionRecoverySidecar ? [mainSessionRecoverySidecar] : []), ]; diff --git a/src/gateway/server-startup-readiness.test-support.ts b/src/gateway/server-startup-readiness.test-support.ts index 5a8288ae09f7..53403b5cac62 100644 --- a/src/gateway/server-startup-readiness.test-support.ts +++ b/src/gateway/server-startup-readiness.test-support.ts @@ -47,7 +47,7 @@ export function registerGatewayStartupReadinessTests(params: { expect(trace.mark).toHaveBeenCalledWith("sidecars.ready"); expect(trace.detail).toHaveBeenCalledWith("sidecars.ready", [ ["loadedPluginCount", 2], - ["postReadySidecarCount", 4], + ["postReadySidecarCount", 3], ]); }); }