diff --git a/src/agents/sessions/resource-loader.test.ts b/src/agents/sessions/resource-loader.test.ts index dc27e8bbc5eb..7dc2586b74e1 100644 --- a/src/agents/sessions/resource-loader.test.ts +++ b/src/agents/sessions/resource-loader.test.ts @@ -1,9 +1,10 @@ // Resource loader tests cover prompt loading and transforms. -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { clearExtensionCache } from "./extensions/loader.js"; +import { DefaultPackageManager } from "./package-manager.js"; import { DefaultResourceLoader } from "./resource-loader.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -40,6 +41,37 @@ afterEach(() => { }); describe("DefaultResourceLoader", () => { + it("skips ambient package resolution while preserving explicit resource paths", async () => { + const root = tempDirs.make("openclaw-resource-loader-explicit-"); + const promptDir = join(root, "explicit-prompts"); + const promptPath = join(promptDir, "explicit.md"); + await mkdir(promptDir); + await writeFile(promptPath, "Explicit prompt"); + const resolvePackages = vi.spyOn(DefaultPackageManager.prototype, "resolve"); + + try { + const loader = new DefaultResourceLoader({ + cwd: root, + agentDir: root, + additionalPromptTemplatePaths: [promptDir], + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); + + await loader.reload(); + + expect(resolvePackages).not.toHaveBeenCalled(); + expect(loader.getPrompts().prompts).toEqual([ + expect.objectContaining({ name: "explicit", filePath: promptPath }), + ]); + } finally { + resolvePackages.mockRestore(); + } + }); + it("reuses extension modules between loaders and refreshes them on reload", async () => { const root = tempDirs.make("openclaw-resource-loader-extension-"); const extensionPath = join(root, "extension.ts"); diff --git a/src/agents/sessions/resource-loader.ts b/src/agents/sessions/resource-loader.ts index 14bfe97e7e4a..5978f67e93d1 100644 --- a/src/agents/sessions/resource-loader.ts +++ b/src/agents/sessions/resource-loader.ts @@ -26,7 +26,7 @@ import type { ExtensionRuntime, LoadExtensionsResult, } from "./extensions/types.js"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; +import { DefaultPackageManager, type PathMetadata, type ResolvedPaths } from "./package-manager.js"; import type { PromptTemplate } from "./prompt-templates.js"; import { loadPromptTemplates } from "./prompt-templates.js"; import { SettingsManager } from "./settings-manager.js"; @@ -50,6 +50,13 @@ export interface ResourceLoader { reload(): Promise; } +const EMPTY_RESOLVED_PATHS: ResolvedPaths = { + extensions: [], + skills: [], + prompts: [], + themes: [], +}; + function resolvePromptInput(input: string | undefined, description: string): string | undefined { if (!input) { return undefined; @@ -349,7 +356,10 @@ export class DefaultResourceLoader implements ResourceLoader { clearExtensionCache(); } await this.settingsManager.reload(); - const resolvedPaths = await this.packageManager.resolve(); + const resolvedPaths = + this.noExtensions && this.noSkills && this.noPromptTemplates && this.noThemes + ? EMPTY_RESOLVED_PATHS + : await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources( this.additionalExtensionPaths, { diff --git a/src/auto-reply/reply/get-reply-from-config.runtime.ts b/src/auto-reply/reply/get-reply-from-config.runtime.ts index 5e971584061f..a4bd0bd78d83 100644 --- a/src/auto-reply/reply/get-reply-from-config.runtime.ts +++ b/src/auto-reply/reply/get-reply-from-config.runtime.ts @@ -1,2 +1,9 @@ /** Runtime facade for config-driven reply resolution. */ -export { getReplyFromConfig } from "./get-reply.js"; +import { prewarmReplyRunRuntimes } from "./get-reply-run-helpers.js"; +import { getReplyFromConfig, prewarmReplyModelCatalogRuntime } from "./get-reply.js"; + +export { getReplyFromConfig }; + +export async function prewarmConfigDrivenReplyRuntime(): Promise { + await Promise.all([prewarmReplyModelCatalogRuntime(), prewarmReplyRunRuntimes()]); +} diff --git a/src/auto-reply/reply/get-reply-run-helpers.ts b/src/auto-reply/reply/get-reply-run-helpers.ts index 14aac81b5d42..e40a49bff14f 100644 --- a/src/auto-reply/reply/get-reply-run-helpers.ts +++ b/src/auto-reply/reply/get-reply-run-helpers.ts @@ -274,6 +274,14 @@ const sessionUpdatesRuntimeLoader = createLazyImportLoader( () => import("./session-updates.runtime.js"), ); +export async function prewarmReplyRunRuntimes(): Promise { + await Promise.all([ + sessionUpdatesRuntimeLoader.load(), + embeddedAgentRuntimeLoader.load(), + agentRunnerRuntimeLoader.load(), + ]); +} + export function loadEmbeddedAgentRuntime() { return embeddedAgentRuntimeLoader.load(); } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index ea44f9911d07..7a54e6700158 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -121,6 +121,9 @@ const mediaUnderstandingApplyRuntimeLoader = createLazyImportLoader( const linkUnderstandingApplyRuntimeLoader = createLazyImportLoader( () => import("../../link-understanding/apply.runtime.js"), ); +const preparedModelCatalogRuntimeLoader = createLazyImportLoader( + () => import("../../agents/prepared-model-catalog.js"), +); const replyResolverTimingLog = createSubsystemLogger("auto-reply/reply-resolver-timing"); const commandsCoreRuntimeLoader = createLazyImportLoader( @@ -147,6 +150,10 @@ function loadCommandsCoreRuntime() { return commandsCoreRuntimeLoader.load(); } +export async function prewarmReplyModelCatalogRuntime(): Promise { + await preparedModelCatalogRuntimeLoader.load(); +} + function hasLinkCandidate(ctx: MsgContext): boolean { const message = ctx.commandText; if (!message) { @@ -270,7 +277,7 @@ export async function getReplyFromConfig( // Gateway turns consume one committed model-runtime generation. Later config/secret // publications must not mix a new global config with an older prepared catalog owner. const owner = await ( - await import("../../agents/prepared-model-catalog.js") + await preparedModelCatalogRuntimeLoader.load() ).loadResolvedPublishedModelCatalogOwner({ agentId }); // The published generation may refresh config, directories, and catalog together, but the // admitted session must never cross agent ownership while doing so. diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 563049f76ce3..9906c264486e 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -66,6 +66,7 @@ const hoisted = vi.hoisted(() => { const refreshPreparedModelRuntimeSnapshots = vi.fn( async (_cfg?: unknown, _options?: unknown) => {}, ); + const prewarmConfigDrivenReplyRuntime = vi.fn(async () => {}); const loadAgentRuntimePluginRegistryHandle = vi.fn(); const prewarmContextWindowCacheAfterReady = vi.fn(async () => {}); const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() })); @@ -106,6 +107,7 @@ const hoisted = vi.hoisted(() => { getModelRefStatus, prepareModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, + prewarmConfigDrivenReplyRuntime, loadAgentRuntimePluginRegistryHandle, prewarmContextWindowCacheAfterReady, scheduleGatewayHandlerPrewarm, @@ -212,6 +214,11 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({ refreshPreparedModelRuntimeSnapshots: hoisted.refreshPreparedModelRuntimeSnapshots, })); +vi.mock("../auto-reply/reply/get-reply-from-config.runtime.js", () => ({ + getReplyFromConfig: vi.fn(), + prewarmConfigDrivenReplyRuntime: hoisted.prewarmConfigDrivenReplyRuntime, +})); + vi.mock("../agents/runtime-plugins.js", () => ({ loadAgentRuntimePluginRegistryHandle: hoisted.loadAgentRuntimePluginRegistryHandle, })); @@ -489,6 +496,8 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.prepareModelRuntimeSnapshot.mockResolvedValue({}); hoisted.refreshPreparedModelRuntimeSnapshots.mockReset(); hoisted.refreshPreparedModelRuntimeSnapshots.mockResolvedValue(undefined); + hoisted.prewarmConfigDrivenReplyRuntime.mockReset(); + hoisted.prewarmConfigDrivenReplyRuntime.mockResolvedValue(undefined); hoisted.loadAgentRuntimePluginRegistryHandle.mockReset(); hoisted.prewarmContextWindowCacheAfterReady.mockReset(); hoisted.prewarmContextWindowCacheAfterReady.mockResolvedValue(undefined); @@ -2224,6 +2233,59 @@ describe("startGatewayPostAttachRuntime", () => { expect(hoisted.scheduleRestartAbortedMainSessionRecovery).not.toHaveBeenCalled(); }); + it("awaits reply runtime after model publication and before channels and readiness", async () => { + const events: string[] = []; + let releaseReplyRuntime: (() => void) | undefined; + const trace = createStartupTraceRecorder(); + hoisted.refreshPreparedModelRuntimeSnapshots.mockImplementationOnce(async () => { + events.push("model-runtime"); + }); + hoisted.prewarmConfigDrivenReplyRuntime.mockImplementationOnce( + async () => + await new Promise((resolve) => { + events.push("reply-runtime:start"); + releaseReplyRuntime = () => { + events.push("reply-runtime:done"); + resolve(); + }; + }), + ); + const startChannels = vi.fn(async () => { + events.push("channels"); + }); + const onSidecarsReady = vi.fn(() => { + events.push("ready"); + }); + + const startup = startGatewayPostAttachRuntime({ + ...createPostAttachParams(), + startChannels, + onSidecarsReady, + startupTrace: trace.startupTrace, + }); + + await waitForGatewayTestState(() => { + expect(events).toEqual(["model-runtime", "reply-runtime:start"]); + }); + expect(startChannels).not.toHaveBeenCalled(); + expect(onSidecarsReady).not.toHaveBeenCalled(); + + if (!releaseReplyRuntime) { + throw new Error("Expected reply runtime release callback to be initialized"); + } + releaseReplyRuntime(); + await startup; + + expect(events).toEqual([ + "model-runtime", + "reply-runtime:start", + "reply-runtime:done", + "channels", + "ready", + ]); + expect(trace.measures).toContain("sidecars.reply-runtime"); + }); + it("marks startup main-session orphans before propagating model runtime failure", async () => { const modelRuntimeError = new Error("model runtime unavailable"); const startChannels = vi.fn(async () => {}); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 93a29477bb9f..4ab1ceaa4284 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -1,5 +1,6 @@ import { monitorEventLoopDelay, performance } from "node:perf_hooks"; import { setTimeout as sleep } from "node:timers/promises"; +import { loadGetReplyFromConfigRuntime } from "../auto-reply/reply/dispatch-from-config.runtime-loaders.js"; import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; import type { CliDeps } from "../cli/deps.types.js"; import { resolveStateDir } from "../config/paths.js"; @@ -706,6 +707,12 @@ export async function startGatewaySidecars(params: { params.prewarmPrimaryModel, ), ); + // Gateway readiness owns process-stable reply module activation so the first operator turn + // does not become the module loader under contention. + await measureStartup(params.startupTrace, "sidecars.reply-runtime", async () => { + const { prewarmConfigDrivenReplyRuntime } = await loadGetReplyFromConfigRuntime(); + await prewarmConfigDrivenReplyRuntime(); + }); await measureStartup(params.startupTrace, "sidecars.chat-metadata", async () => { await params.refreshChatMetadata?.(); }); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index cb5dacbfeded..9a75bd201ce7 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -284,10 +284,12 @@ vi.mock("/src/auto-reply/reply.js", () => ({ vi.mock("../auto-reply/reply/get-reply-from-config.runtime.js", () => ({ getReplyFromConfig: (...args: Parameters) => gatewayTestHoisted.getReplyFromConfig(...args), + prewarmConfigDrivenReplyRuntime: vi.fn(async () => {}), })); vi.mock("/src/auto-reply/reply/get-reply-from-config.runtime.js", () => ({ getReplyFromConfig: (...args: Parameters) => gatewayTestHoisted.getReplyFromConfig(...args), + prewarmConfigDrivenReplyRuntime: vi.fn(async () => {}), })); vi.mock("../cli/deps.js", async () => { const actual = await vi.importActual("../cli/deps.js");