diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f9b545a740..e4c1305628b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index 7c2a1942404e..a2a19c580d79 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -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", diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 17878a31ea52..387a93691aff 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -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"; diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 8d332d27b3f5..e45181b22170 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -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))) ); diff --git a/test/scripts/check-cli-startup-memory.test.ts b/test/scripts/check-cli-startup-memory.test.ts index 182296c75cb3..d86fd3995020 100644 --- a/test/scripts/check-cli-startup-memory.test.ts +++ b/test/scripts/check-cli-startup-memory.test.ts @@ -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", () => {