fix(cli): reduce plugin listing startup memory (#103132)

This commit is contained in:
Peter Steinberger
2026-07-09 23:28:11 +01:00
committed by GitHub
parent ecad886cd1
commit a1ce77d2a9
5 changed files with 95 additions and 4 deletions
+1
View File
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **CLI plugin listing:** skip state-migration runtime loading when no legacy inputs exist, reducing packaged cold-start memory while preserving migrations for legacy plugin indexes and configured session stores.
- **Unicode-safe bounded text:** preserve complete UTF-16 surrogate pairs when shortening previews, prompts, diagnostics, labels, session keys, link metadata, and identity values across Control UI, CLI, Gateway, plugins, QA, memory, and Android surfaces. (#102625, #102626, #102627, #102656, #102816, #102823, #102833, #102877, #102949, #102963, #102969, #102988, #103010, #103034) Thanks @zhangguiping-xydt, @wings1029, @wangyan2026, @Pandah97, @MoerAI, @SunnyShu0925, @zhangqueping, @zw-xysk, @cxbAsDev, @lzyyzznl, @coder-master-0915, @LeonidasLux, @mushuiyu886, @ly85206559, and @Simon-XYDT.
- **CLI model tables:** sanitize, truncate, and pad model-list cells by rendered terminal width so emoji, CJK, and other wide graphemes keep columns aligned. (#102819) Thanks @Kevin23-design and @vincentkoc.
- **Skills prompt compaction:** preserve every included skill identity before using the remaining prompt budget for shortened, UTF-16-safe descriptions, retaining trigger guidance without exceeding the hard limit. (#88426) Thanks @abel-zer0.
+12
View File
@@ -95,6 +95,9 @@ function resolveDefaultLimitsMb(platform = process.platform) {
// higher RSS for the same launcher path, so keep it supported without hiding
// Linux help-path regressions.
help: platform === "darwin" ? 300 : 100,
// Plugin discovery is heavier than help, but must stay below the doctor/channel
// runtime graph that an empty metadata-only invocation must not import.
pluginsList: platform === "darwin" ? 500 : 350,
statusJson: 400,
gatewayStatus: 500,
};
@@ -109,6 +112,15 @@ const cases = [
args: ["openclaw.mjs", "--help"],
limitMb: readPositiveNumberEnv("OPENCLAW_STARTUP_MEMORY_HELP_MB", DEFAULT_LIMITS_MB.help),
},
{
id: "pluginsList",
label: "plugins list --json",
args: ["openclaw.mjs", "plugins", "list", "--json"],
limitMb: readPositiveNumberEnv(
"OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB",
DEFAULT_LIMITS_MB.pluginsList,
),
},
{
id: "statusJson",
label: "status --json",
+65 -2
View File
@@ -163,6 +163,11 @@ describe("ensureConfigReady", () => {
commandPath: ["agent"],
expectedDoctorCalls: 0,
},
{
name: "skips doctor flow for plugin listing without legacy state",
commandPath: ["plugins", "list"],
expectedDoctorCalls: 0,
},
{
name: "runs doctor flow for commands that may mutate state without legacy state",
commandPath: ["message"],
@@ -250,6 +255,43 @@ describe("ensureConfigReady", () => {
});
});
it("preserves plugin listing migrations when the legacy plugin install index exists", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "plugins/installs.json");
const migratedSnapshot = {
...makeSnapshot(),
config: { plugins: { entries: { legacy: { enabled: true } } } },
runtimeConfig: { plugins: { entries: { legacy: { enabled: true } } } },
sourceConfig: { plugins: { entries: { legacy: { enabled: true } } } },
};
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
snapshot: migratedSnapshot,
baseConfig: {},
});
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
migratedSnapshot.runtimeConfig,
migratedSnapshot.sourceConfig,
);
});
it("preserves plugin listing migrations when the shared state database exists", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "state/openclaw.sqlite");
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("runs doctor flow before agent commands when default exec approvals must move to a custom state dir", async () => {
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
@@ -301,7 +343,10 @@ describe("ensureConfigReady", () => {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("runs doctor flow for read-only commands with configured custom session stores", async () => {
it.each([
{ name: "status", commandPath: ["status"] },
{ name: "plugin listing", commandPath: ["plugins", "list"] },
])("runs doctor flow for $name with configured custom session stores", async ({ commandPath }) => {
const root = useTempOpenClawHome();
const customStore = path.join(root, "sessions", "sessions.json");
const snapshot = {
@@ -315,7 +360,7 @@ describe("ensureConfigReady", () => {
baseConfig: {},
});
await runEnsureConfigReady(["status"]);
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
@@ -337,6 +382,24 @@ describe("ensureConfigReady", () => {
);
});
it("pins plugin listing config without loading state migration runtime", async () => {
const snapshot = {
...makeSnapshot(),
config: { plugins: { entries: { alpha: { enabled: true } } } },
runtimeConfig: { plugins: { entries: { alpha: { enabled: true } } } },
sourceConfig: { plugins: { entries: { alpha: { enabled: true } } } },
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
snapshot.runtimeConfig,
snapshot.sourceConfig,
);
});
it("retries the cached config snapshot after a read rejection", async () => {
const originalVitest = process.env.VITEST;
process.env.VITEST = "false";
+4
View File
@@ -142,6 +142,7 @@ function hasLegacyStateMigrationInputs(): boolean {
path.join(stateDir, "agents"),
path.join(stateDir, "plugins", "installs.json"),
path.join(stateDir, "sessions"),
path.join(stateDir, "state", "openclaw.sqlite"),
].some(fileOrDirExists) ||
sqliteSidecarPaths.some(
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
@@ -154,9 +155,12 @@ function hasLegacyStateMigrationInputs(): boolean {
function shouldRunStateMigrationOnlyWithLegacyInputs(commandPath: string[]): boolean {
const commandName = commandPath[0];
const subcommandName = commandPath[1];
// Metadata-only plugin listing still migrates known legacy inputs, but an empty
// state must not cold-load doctor and bundled channel runtime graphs.
return (
commandName === "agent" ||
commandName === "status" ||
(commandName === "plugins" && subcommandName === "list") ||
(commandName === "tasks" &&
(subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName)))
);
+13 -2
View File
@@ -52,6 +52,17 @@ describe("check-cli-startup-memory", () => {
expect(testing.resolveDefaultLimitsMb("darwin").help).toBeGreaterThan(100);
});
it("guards packaged plugin listing startup memory", () => {
expect(testing.resolveDefaultLimitsMb("linux").pluginsList).toBe(350);
expect(testing.resolveDefaultLimitsMb("darwin").pluginsList).toBeGreaterThan(350);
expect(testing.cases).toContainEqual(
expect.objectContaining({
id: "pluginsList",
args: ["openclaw.mjs", "plugins", "list", "--json"],
}),
);
});
it("keeps invalid startup memory env values from bypassing budgets", () => {
expect(() =>
testing.readPositiveNumberEnv("OPENCLAW_STARTUP_MEMORY_HELP_MB", 100, {
@@ -188,8 +199,8 @@ describe("check-cli-startup-memory", () => {
},
),
).toThrow("--help timed out after 1234ms");
expect(seenTimeouts).toEqual([1234, 1234, 1234]);
expect(seenKillSignals).toEqual(["SIGKILL", "SIGKILL", "SIGKILL"]);
expect(seenTimeouts).toEqual(testing.cases.map(() => 1234));
expect(seenKillSignals).toEqual(testing.cases.map(() => "SIGKILL"));
});
it("rejects zero RSS markers instead of passing empty resource evidence", () => {