fix(status): keep startup paths free of plugin warmup

This commit is contained in:
Vincent Koc 2026-03-19 16:25:37 -07:00
parent 8e132aed6e
commit 0f69b5c11a
5 changed files with 57 additions and 5 deletions

View File

@ -82,6 +82,25 @@ describe("lookupContextTokens", () => {
expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(321_000);
});
it("can skip async warmup for read-only callers", async () => {
const { ensureOpenClawModelsJson } = mockContextModuleDeps(() => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
},
},
},
}));
const { lookupContextTokens } = await import("./context.js");
expect(
lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false }),
).toBeUndefined();
await flushAsyncWarmup();
expect(ensureOpenClawModelsJson).not.toHaveBeenCalled();
});
it("only warms eagerly for real openclaw startup commands that need model metadata", async () => {
const argvSnapshot = process.argv;
try {

View File

@ -225,12 +225,17 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
return loadPromise;
}
export function lookupContextTokens(modelId?: string): number | undefined {
export function lookupContextTokens(
modelId?: string,
options?: { allowAsyncLoad?: boolean },
): number | undefined {
if (!modelId) {
return undefined;
}
// Best-effort: kick off loading on demand, but don't block lookups.
void ensureContextWindowCacheLoaded();
if (options?.allowAsyncLoad !== false) {
void ensureContextWindowCacheLoaded();
}
return MODEL_CACHE.get(modelId);
}
@ -354,6 +359,7 @@ export function resolveContextTokensForModel(params: {
model?: string;
contextTokensOverride?: number;
fallbackContextTokens?: number;
allowAsyncLoad?: boolean;
}): number | undefined {
if (typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0) {
return params.contextTokensOverride;
@ -402,6 +408,7 @@ export function resolveContextTokensForModel(params: {
if (params.provider && ref && !ref.model.includes("/")) {
const qualifiedResult = lookupContextTokens(
`${normalizeProviderId(ref.provider)}/${ref.model}`,
{ allowAsyncLoad: params.allowAsyncLoad },
);
if (qualifiedResult !== undefined) {
return qualifiedResult;
@ -410,7 +417,9 @@ export function resolveContextTokensForModel(params: {
// Bare key fallback. For model-only calls with slash-containing IDs
// (e.g. "google/gemini-2.5-pro") this IS the raw discovery cache key.
const bareResult = lookupContextTokens(params.model);
const bareResult = lookupContextTokens(params.model, {
allowAsyncLoad: params.allowAsyncLoad,
});
if (bareResult !== undefined) {
return bareResult;
}
@ -421,6 +430,7 @@ export function resolveContextTokensForModel(params: {
if (!params.provider && ref && !ref.model.includes("/")) {
const qualifiedResult = lookupContextTokens(
`${normalizeProviderId(ref.provider)}/${ref.model}`,
{ allowAsyncLoad: params.allowAsyncLoad },
);
if (qualifiedResult !== undefined) {
return qualifiedResult;

View File

@ -5,7 +5,6 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { getAgentLocalStatuses } from "./status.agent-local.js";
@ -23,6 +22,7 @@ import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js";
let pluginRegistryModulePromise: Promise<typeof import("../cli/plugin-registry.js")> | undefined;
let pluginStatusModulePromise: Promise<typeof import("../plugins/status.js")> | undefined;
let configIoModulePromise: Promise<typeof import("../config/io.js")> | undefined;
let commandSecretTargetsModulePromise:
| Promise<typeof import("../cli/command-secret-targets.js")>
@ -40,6 +40,11 @@ function loadPluginRegistryModule() {
return pluginRegistryModulePromise;
}
function loadPluginStatusModule() {
pluginStatusModulePromise ??= import("../plugins/status.js");
return pluginStatusModulePromise;
}
function loadConfigIoModule() {
configIoModulePromise ??= import("../config/io.js");
return configIoModulePromise;
@ -194,7 +199,12 @@ export async function scanStatusJsonFast(
const memoryPlugin = resolveMemoryPluginStatus(cfg);
const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
const pluginCompatibility = shouldCollectPluginCompatibility(cfg)
? buildPluginCompatibilityNotices({ config: cfg })
? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) =>
// Keep plugin status loading off the empty-config `status --json` fast path.
// The plugin status module pulls in the full loader graph and materially bloats
// startup RSS even when plugin compatibility is never consulted.
buildPluginCompatibilityNotices({ config: cfg }),
)
: [];
return {

View File

@ -78,6 +78,7 @@ vi.mock("./status.link-channel.js", () => ({
}));
const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js");
const { resolveContextTokensForModel } = await import("../agents/context.js");
const { buildChannelSummary } = await import("../infra/channel-summary.js");
const { resolveLinkChannelContext } = await import("./status.link-channel.js");
const { getStatusSummary } = await import("./status.summary.js");
@ -105,4 +106,12 @@ describe("getStatusSummary", () => {
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});
it("does not trigger async context warmup while building status summaries", async () => {
await getStatusSummary();
expect(vi.mocked(resolveContextTokensForModel)).toHaveBeenCalledWith(
expect.objectContaining({ allowAsyncLoad: false }),
);
});
});

View File

@ -219,6 +219,9 @@ export async function getStatusSummary(
model: configModel,
contextTokensOverride: cfg.agents?.defaults?.contextTokens,
fallbackContextTokens: DEFAULT_CONTEXT_TOKENS,
// Keep `status`/`status --json` startup read-only. These summary lookups
// should not kick off background provider discovery or plugin scans.
allowAsyncLoad: false,
}) ?? DEFAULT_CONTEXT_TOKENS;
const now = Date.now();
@ -250,6 +253,7 @@ export async function getStatusSummary(
model,
contextTokensOverride: entry?.contextTokens,
fallbackContextTokens: configContextTokens ?? undefined,
allowAsyncLoad: false,
}) ?? null;
const total = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh =