mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
fix: reduce cold first-turn latency after gateway restart (#120809)
Prewarm process-stable config-driven reply runtimes before Gateway readiness and skip ambient resource discovery when embedded sessions disable every ambient resource class. Isolated mock Gateway runs reduced median cold pre-model latency from 3.513s in the matched pre-fix control to 2.394s, while warm turns remained sub-450ms. Related: #119895.
This commit is contained in:
committed by
GitHub
parent
428c00a0a2
commit
ed760cbcdb
@@ -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");
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
await Promise.all([prewarmReplyModelCatalogRuntime(), prewarmReplyRunRuntimes()]);
|
||||
}
|
||||
|
||||
@@ -274,6 +274,14 @@ const sessionUpdatesRuntimeLoader = createLazyImportLoader(
|
||||
() => import("./session-updates.runtime.js"),
|
||||
);
|
||||
|
||||
export async function prewarmReplyRunRuntimes(): Promise<void> {
|
||||
await Promise.all([
|
||||
sessionUpdatesRuntimeLoader.load(),
|
||||
embeddedAgentRuntimeLoader.load(),
|
||||
agentRunnerRuntimeLoader.load(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function loadEmbeddedAgentRuntime() {
|
||||
return embeddedAgentRuntimeLoader.load();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<void>((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 () => {});
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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<GetReplyFromConfigFn>) =>
|
||||
gatewayTestHoisted.getReplyFromConfig(...args),
|
||||
prewarmConfigDrivenReplyRuntime: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("/src/auto-reply/reply/get-reply-from-config.runtime.js", () => ({
|
||||
getReplyFromConfig: (...args: Parameters<GetReplyFromConfigFn>) =>
|
||||
gatewayTestHoisted.getReplyFromConfig(...args),
|
||||
prewarmConfigDrivenReplyRuntime: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../cli/deps.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
|
||||
|
||||
Reference in New Issue
Block a user