fix(cli): speed up gateway status config reads

This commit is contained in:
Vincent Koc
2026-04-26 20:32:28 -07:00
parent b0c70786fd
commit 831f03b814
3 changed files with 169 additions and 43 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz.
- Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex.
- Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc.
- CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc.
- Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc.
- Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq.
- Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockGatewayService } from "../../daemon/service.test-helpers.js";
import { captureEnv } from "../../test-utils/env.js";
@@ -340,6 +343,52 @@ describe("gatherDaemonStatus", () => {
});
});
it("uses the fast config path for plain same-file status reads", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-status-config-"));
const configPath = path.join(tmp, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({
gateway: {
bind: "custom",
customBindHost: "10.0.0.5",
controlUi: { enabled: true },
},
}),
);
process.env.OPENCLAW_STATE_DIR = tmp;
process.env.OPENCLAW_CONFIG_PATH = configPath;
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
environment: {
OPENCLAW_STATE_DIR: tmp,
OPENCLAW_CONFIG_PATH: configPath,
},
});
try {
const status = await gatherDaemonStatus({
rpc: {},
probe: false,
deep: false,
});
expect(readConfigFileSnapshotCalls).not.toHaveBeenCalled();
expect(loadConfigCalls).not.toHaveBeenCalled();
expect(status.config?.cli).toMatchObject({
path: configPath,
exists: true,
valid: true,
controlUi: { enabled: true },
});
expect(status.config?.daemon).toBe(status.config?.cli);
expect(status.gateway?.bindMode).toBe("custom");
expect(status.gateway?.customBindHost).toBe("10.0.0.5");
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("resolves daemon gateway auth password SecretRef values before probing", async () => {
daemonLoadedConfig = {
gateway: {

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import JSON5 from "json5";
import {
createConfigIO,
resolveConfigPath,
@@ -66,6 +68,12 @@ type DaemonConfigContext = {
configMismatch: boolean;
};
type StatusConfigRead = {
summary: ConfigSummary;
cfg: OpenClawConfig;
mode: "fast" | "full";
};
type ResolvedGatewayStatus = {
gateway: GatewayStatusSummary;
daemonPort: number;
@@ -119,6 +127,104 @@ function resolveSnapshotRuntimeConfig(snapshot: ConfigFileSnapshot | null): Open
return snapshot.runtimeConfig;
}
function coerceStatusConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
}
function hasOwnKey(value: unknown, key: string): boolean {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.hasOwnProperty.call(value, key),
);
}
function needsFullStatusConfigRead(raw: string, parsed: unknown): boolean {
return raw.includes("$include") || raw.includes("${") || hasOwnKey(parsed, "env");
}
async function readFastStatusConfig(configPath: string): Promise<StatusConfigRead | null> {
let raw: string;
try {
raw = await fs.readFile(configPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON5.parse(raw);
} catch (err) {
return {
summary: {
path: configPath,
exists: true,
valid: false,
issues: [{ path: "", message: `JSON5 parse failed: ${String(err)}` }],
},
cfg: {},
mode: "fast",
};
}
if (needsFullStatusConfigRead(raw, parsed)) {
return null;
}
const cfg = coerceStatusConfig(parsed);
return {
summary: {
path: configPath,
exists: true,
valid: true,
controlUi: cfg.gateway?.controlUi,
},
cfg,
mode: "fast",
};
}
async function readFullStatusConfig(params: {
env: NodeJS.ProcessEnv;
configPath: string;
}): Promise<StatusConfigRead> {
const io = createConfigIO({
env: params.env,
configPath: params.configPath,
pluginValidation: "skip",
});
const snapshot = await io.readConfigFileSnapshot().catch(() => null);
const cfg = resolveSnapshotRuntimeConfig(snapshot) ?? io.loadConfig();
return {
summary: {
path: snapshot?.path ?? params.configPath,
exists: snapshot?.exists ?? false,
valid: snapshot?.valid ?? true,
...(snapshot?.issues?.length ? { issues: snapshot.issues } : {}),
controlUi: cfg.gateway?.controlUi,
},
cfg,
mode: "full",
};
}
async function readStatusConfig(params: {
env: NodeJS.ProcessEnv;
configPath: string;
}): Promise<StatusConfigRead> {
return (
(await readFastStatusConfig(params.configPath)) ??
(await readFullStatusConfig({
env: params.env,
configPath: params.configPath,
}))
);
}
function appendProbeNote(
existing: string | undefined,
extra: string | undefined,
@@ -207,57 +313,27 @@ async function loadDaemonConfigContext(
mergedDaemonEnv as NodeJS.ProcessEnv,
resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv),
);
const cliIO = createConfigIO({
const sameConfigPath = cliConfigPath === daemonConfigPath;
const cliConfigRead = await readStatusConfig({
env: process.env,
configPath: cliConfigPath,
pluginValidation: "skip",
});
const sharesDaemonConfigContext = !serviceEnv && cliConfigPath === daemonConfigPath;
const daemonIO = sharesDaemonConfigContext
? cliIO
: createConfigIO({
env: mergedDaemonEnv,
const sharesDaemonConfigContext =
sameConfigPath && (cliConfigRead.mode === "fast" || !serviceEnv);
const daemonConfigRead = sharesDaemonConfigContext
? cliConfigRead
: await readStatusConfig({
env: mergedDaemonEnv as NodeJS.ProcessEnv,
configPath: daemonConfigPath,
pluginValidation: "skip",
});
const cliSnapshotPromise = cliIO.readConfigFileSnapshot().catch(() => null);
const daemonSnapshotPromise = sharesDaemonConfigContext
? cliSnapshotPromise
: daemonIO.readConfigFileSnapshot().catch(() => null);
const [cliSnapshot, daemonSnapshot] = await Promise.all([
cliSnapshotPromise,
daemonSnapshotPromise,
]);
const cliCfg = resolveSnapshotRuntimeConfig(cliSnapshot) ?? cliIO.loadConfig();
const daemonCfg =
sharesDaemonConfigContext && cliSnapshot === daemonSnapshot
? cliCfg
: (resolveSnapshotRuntimeConfig(daemonSnapshot) ?? daemonIO.loadConfig());
const cliConfigSummary: ConfigSummary = {
path: cliSnapshot?.path ?? cliConfigPath,
exists: cliSnapshot?.exists ?? false,
valid: cliSnapshot?.valid ?? true,
...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}),
controlUi: cliCfg.gateway?.controlUi,
};
const daemonConfigSummary: ConfigSummary = {
path: daemonSnapshot?.path ?? daemonConfigPath,
exists: daemonSnapshot?.exists ?? false,
valid: daemonSnapshot?.valid ?? true,
...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}),
controlUi: daemonCfg.gateway?.controlUi,
};
return {
mergedDaemonEnv,
cliCfg,
daemonCfg,
cliConfigSummary,
daemonConfigSummary,
configMismatch: cliConfigSummary.path !== daemonConfigSummary.path,
cliCfg: cliConfigRead.cfg,
daemonCfg: daemonConfigRead.cfg,
cliConfigSummary: cliConfigRead.summary,
daemonConfigSummary: daemonConfigRead.summary,
configMismatch: cliConfigRead.summary.path !== daemonConfigRead.summary.path,
};
}