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:
Peter Steinberger
2026-08-08 20:10:23 -07:00
committed by GitHub
parent 428c00a0a2
commit ed760cbcdb
8 changed files with 140 additions and 5 deletions
+33 -1
View File
@@ -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");
+12 -2
View File
@@ -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();
}
+8 -1
View File
@@ -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?.();
});
+2
View File
@@ -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");