perf: reduce raw gateway config startup work

This commit is contained in:
Peter Steinberger
2026-05-03 12:59:33 +01:00
parent 73be4ea901
commit 45a5374ca8
10 changed files with 149 additions and 181 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/performance: keep raw channel-config schema parsing from discovering bundled plugin runtime metadata, and add `pnpm gateway:watch --benchmark-no-force` for profiling startup without the default port cleanup.
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.

View File

@@ -143,6 +143,9 @@ npx speedscope .artifacts/gateway-watch-profiles/*.cpuprofile
```
Use `--benchmark-dir <path>` when you want profiles somewhere else.
Use `--benchmark-no-force` when you want the benchmarked child to skip the
default `--force` port cleanup and fail fast if the Gateway port is already in
use.
The tmux wrapper carries common non-secret runtime selectors such as
`OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`,

View File

@@ -53,6 +53,7 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
const passthroughArgs = [];
let benchmarkDir = null;
let benchmarkFlagSeen = false;
let benchmarkNoForceSeen = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -61,6 +62,12 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
continue;
}
if (arg === "--benchmark-no-force") {
benchmarkFlagSeen = true;
benchmarkNoForceSeen = true;
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
continue;
}
if (typeof arg === "string" && arg.startsWith("--benchmark=")) {
benchmarkFlagSeen = true;
benchmarkDir = arg.slice("--benchmark=".length) || DEFAULT_BENCHMARK_PROFILE_DIR;
@@ -91,7 +98,10 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR;
}
return {
args: passthroughArgs,
args: benchmarkNoForceSeen
? passthroughArgs.filter((arg) => arg !== "--force")
: passthroughArgs,
benchmarkNoForce: benchmarkNoForceSeen,
benchmarkProfileDir: nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || null,
env: nextEnv,
};
@@ -238,6 +248,9 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
if (resolvedArgs.benchmarkProfileDir) {
log(deps.stderr, `gateway:watch benchmark CPU profiles: ${resolvedArgs.benchmarkProfileDir}`);
}
if (resolvedArgs.benchmarkNoForce) {
log(deps.stderr, "gateway:watch benchmark running without --force");
}
if (TMUX_DISABLE_VALUES.has((deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) {
return runForegroundWatcher({

View File

@@ -7,6 +7,15 @@ const { collectChannelLegacyConfigRulesMock, listPluginDoctorLegacyConfigRulesMo
listPluginDoctorLegacyConfigRulesMock: vi.fn((): LegacyConfigRule[] => []),
}),
);
const loadPluginMetadataSnapshotMock = vi.hoisted(() =>
vi.fn(() => ({
manifestRegistry: {
diagnostics: [],
plugins: [],
},
plugins: [],
})),
);
vi.mock("../channels/plugins/legacy-config.js", () => ({
collectChannelLegacyConfigRules: collectChannelLegacyConfigRulesMock,
@@ -16,6 +25,10 @@ vi.mock("../plugins/doctor-contract-registry.js", () => ({
listPluginDoctorLegacyConfigRules: listPluginDoctorLegacyConfigRulesMock,
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
import { validateConfigObjectRaw } from "./validation.js";
describe("config validation legacy rule loading", () => {
@@ -24,6 +37,7 @@ describe("config validation legacy rule loading", () => {
collectChannelLegacyConfigRulesMock.mockReturnValue([]);
listPluginDoctorLegacyConfigRulesMock.mockReset();
listPluginDoctorLegacyConfigRulesMock.mockReturnValue([]);
loadPluginMetadataSnapshotMock.mockClear();
});
it("does not load channel or plugin doctor legacy rules for valid raw config", () => {
@@ -43,6 +57,7 @@ describe("config validation legacy rule loading", () => {
expect(result.ok).toBe(true);
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("does not load plugin doctor legacy rules for invalid raw config", () => {

View File

@@ -187,6 +187,40 @@ function collectAllowedValuesFromBundledChannelSchemaPath(
return collectAllowedValuesFromJsonSchemaNode(targetNode);
}
function collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigValidationIssue[] {
if (!config.channels || !isRecord(config.channels)) {
return [];
}
const issues: ConfigValidationIssue[] = [];
for (const [channelId, schema] of bundledChannelSchemaById) {
if (!Object.prototype.hasOwnProperty.call(config.channels, channelId)) {
continue;
}
const result = validateJsonSchemaValue({
schema: schema as Record<string, unknown>,
cacheKey: `raw-channel:${channelId}`,
value: config.channels[channelId],
applyDefaults: false,
});
if (result.ok) {
continue;
}
for (const error of result.errors) {
const message = error.additionalProperty
? `${error.message}: "${error.additionalProperty}"`
: error.message;
issues.push({
path:
error.path === "<root>" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`,
message: `invalid config: ${message}`,
allowedValues: error.allowedValues,
allowedValuesHiddenCount: error.allowedValuesHiddenCount,
});
}
}
return issues;
}
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
const message = typeof record.message === "string" ? record.message : "";
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
@@ -631,10 +665,18 @@ export function validateConfigObjectRaw(
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
};
}
const validatedConfig = validated.data as OpenClawConfig;
const channelIssues =
policyIssues.length > 0 ? collectRawBundledChannelConfigIssues(validatedConfig) : [];
if (channelIssues.length > 0) {
return {
ok: false,
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, channelIssues),
};
}
if (policyIssues.length > 0) {
return { ok: false, issues: policyIssues };
}
const validatedConfig = validated.data as OpenClawConfig;
const duplicates = findDuplicateAgentDirs(validatedConfig);
if (duplicates.length > 0) {
return {

View File

@@ -1,44 +1,18 @@
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginManifestChannelConfig } from "../plugins/manifest.js";
const loadPluginManifestRegistryMock = vi.hoisted(() =>
vi.fn<(options?: Record<string, unknown>) => PluginManifestRegistry>(() => ({
plugins: [],
diagnostics: [],
})),
);
const collectBundledChannelConfigsMock = vi.hoisted(() =>
vi.fn<(params: unknown) => Record<string, PluginManifestChannelConfig> | undefined>(
() => undefined,
),
);
const loadPluginMetadataSnapshotMock = vi.hoisted(() => vi.fn());
const collectBundledChannelConfigsMock = vi.hoisted(() => vi.fn());
describe("ChannelsSchema bundled runtime loading", () => {
beforeEach(() => {
loadPluginManifestRegistryMock.mockClear();
loadPluginManifestRegistryMock.mockReturnValue({
plugins: [],
diagnostics: [],
});
loadPluginMetadataSnapshotMock.mockClear();
collectBundledChannelConfigsMock.mockClear();
vi.doMock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: (options?: Record<string, unknown>) =>
loadPluginManifestRegistryMock(options),
loadPluginRegistrySnapshotWithMetadata: () => ({
source: "derived",
snapshot: { plugins: [] },
diagnostics: [],
}),
}));
vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: (options?: Record<string, unknown>) => ({
manifestRegistry: loadPluginManifestRegistryMock(options),
}),
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
vi.doMock("../plugins/bundled-channel-config-metadata.js", () => ({
collectBundledChannelConfigs: (params: unknown) => collectBundledChannelConfigsMock(params),
collectBundledChannelConfigs: collectBundledChannelConfigsMock,
}));
});
@@ -60,32 +34,11 @@ describe("ChannelsSchema bundled runtime loading", () => {
});
expect(parsed?.defaults?.groupPolicy).toBe("open");
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalledWith(
expect.objectContaining({
bundledChannelConfigCollector: expect.any(Function),
}),
);
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
});
it("loads bundled channel runtime discovery only when plugin-owned channel config is present", async () => {
loadPluginManifestRegistryMock.mockReturnValueOnce({
diagnostics: [],
plugins: [
{
id: "discord",
origin: "bundled",
channels: ["discord"],
channelConfigs: {
discord: {
runtime: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
},
},
} as unknown as PluginManifestRegistry["plugins"][number],
],
});
it("does not discover bundled channel runtime metadata during raw schema parsing", async () => {
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
import.meta.url,
"./zod-schema.providers.js?scope=channels-plugin-owned",
@@ -95,59 +48,7 @@ describe("ChannelsSchema bundled runtime loading", () => {
discord: {},
});
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
expect.objectContaining({
config: {},
}),
]);
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
});
it("loads a single plugin-owned runtime surface when the manifest omits runtime metadata", async () => {
collectBundledChannelConfigsMock.mockReturnValueOnce({
discord: {
schema: {},
runtime: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
},
});
loadPluginManifestRegistryMock.mockImplementationOnce(() => ({
diagnostics: [],
plugins: [
{
id: "discord",
origin: "bundled",
rootDir: "/repo/extensions/discord",
channels: ["discord"],
channelConfigs: {},
} as unknown as PluginManifestRegistry["plugins"][number],
],
}));
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
import.meta.url,
"./zod-schema.providers.js?scope=channels-plugin-owned-targeted-runtime",
);
runtime.ChannelsSchema.parse({
discord: {},
});
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
expect.objectContaining({
config: {},
}),
]);
expect(collectBundledChannelConfigsMock).toHaveBeenCalledTimes(1);
expect(collectBundledChannelConfigsMock).toHaveBeenCalledWith(
expect.objectContaining({
pluginDir: "/repo/extensions/discord",
manifest: expect.objectContaining({
id: "discord",
channels: ["discord"],
}),
}),
);
});
});

View File

@@ -1,8 +1,4 @@
import { z } from "zod";
import { collectBundledChannelConfigs } from "../plugins/bundled-channel-config-metadata.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginManifest } from "../plugins/manifest.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { ChannelsConfig } from "./types.channels.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import { ContextVisibilityModeSchema, GroupPolicySchema } from "./zod-schema.core.js";
@@ -15,35 +11,6 @@ const ChannelModelByChannelSchema = z
.record(z.string(), z.record(z.string(), z.string()))
.optional();
function getDirectChannelRuntimeSchema(channelId: string, registry: PluginManifestRegistry) {
const record = registry.plugins.find(
(plugin) => plugin.origin === "bundled" && plugin.channels.includes(channelId),
);
if (!record) {
return undefined;
}
const manifestRuntime = record.channelConfigs?.[channelId]?.runtime;
if (manifestRuntime) {
return manifestRuntime;
}
return collectBundledChannelConfigs({
pluginDir: record.rootDir,
manifest: {
id: record.id,
configSchema: record.configSchema ?? {},
channels: record.channels,
channelConfigs: record.channelConfigs,
} as PluginManifest,
packageManifest: record.packageManifest,
})?.[channelId]?.runtime;
}
function hasPluginOwnedChannelConfig(
value: ChannelsConfig,
): value is ChannelsConfig & Record<string, unknown> {
return Object.keys(value).some((key) => key !== "defaults" && key !== "modelByChannel");
}
function addLegacyChannelAcpBindingIssues(
value: unknown,
ctx: z.RefinementCtx,
@@ -76,43 +43,6 @@ function addLegacyChannelAcpBindingIssues(
}
}
function normalizeBundledChannelConfigs(
value: ChannelsConfig | undefined,
ctx: z.RefinementCtx,
): ChannelsConfig | undefined {
if (!value || !hasPluginOwnedChannelConfig(value)) {
return value;
}
let next: ChannelsConfig | undefined;
let registry: PluginManifestRegistry | undefined;
for (const channelId of Object.keys(value)) {
registry ??= loadPluginMetadataSnapshot({ config: {}, env: process.env }).manifestRegistry;
const runtimeSchema = getDirectChannelRuntimeSchema(channelId, registry);
if (!runtimeSchema) {
continue;
}
if (!Object.prototype.hasOwnProperty.call(value, channelId)) {
continue;
}
const parsed = runtimeSchema.safeParse(value[channelId]);
if (!parsed.success) {
for (const issue of parsed.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message ?? `Invalid channels.${channelId} config.`,
path: [channelId, ...(Array.isArray(issue.path) ? issue.path : [])],
});
}
continue;
}
next ??= { ...value };
next[channelId] = parsed.data as ChannelsConfig[string];
}
return next ?? value;
}
export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
.object({
defaults: z
@@ -129,5 +59,4 @@ export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
.superRefine((value, ctx) => {
addLegacyChannelAcpBindingIssues(value, ctx);
})
.transform((value, ctx) => normalizeBundledChannelConfigs(value as ChannelsConfig, ctx))
.optional() as z.ZodType<ChannelsConfig | undefined>;

View File

@@ -95,6 +95,35 @@ describe("gateway-watch tmux wrapper", () => {
);
});
it("can remove --force from benchmarked watch runs", () => {
const stdout = createOutput();
const stderr = createOutput();
const spawnSync = vi
.fn()
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
const code = runGatewayWatchTmuxMain({
args: ["gateway", "--force", "--benchmark-no-force"],
cwd: "/repo",
env: { SHELL: "/bin/zsh" },
nodePath: "/node",
spawnSync,
stderr: stderr.stream,
stdout: stdout.stream,
});
expect(code).toBe(0);
const command = spawnSync.mock.calls[1]?.[1]?.[6] as string;
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'");
expect(command).not.toContain("--benchmark-no-force");
expect(command).toContain("'gateway'");
expect(command).not.toContain("'--force'");
expect(stderr.chunks.join("")).toContain("gateway:watch benchmark running without --force");
});
it("preserves an explicit color override for the tmux child", () => {
const command = buildGatewayWatchTmuxCommand({
args: ["gateway", "--force"],

View File

@@ -86,6 +86,7 @@ export type JsonSchemaValidationError = {
path: string;
message: string;
text: string;
additionalProperty?: string;
allowedValues?: string[];
allowedValuesHiddenCount?: number;
};
@@ -152,6 +153,16 @@ function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType<typeof summa
return summarizeAllowedValues(allowedValues);
}
function resolveAdditionalProperty(error: ErrorObject): string | undefined {
if (error.keyword !== "additionalProperties") {
return undefined;
}
const additionalProperty = (error.params as { additionalProperty?: unknown }).additionalProperty;
return typeof additionalProperty === "string" && additionalProperty.trim()
? additionalProperty
: undefined;
}
function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaValidationError[] {
if (!errors || errors.length === 0) {
return [{ path: "<root>", message: "invalid config", text: "<root>: invalid config" }];
@@ -160,6 +171,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
const path = resolveAjvErrorPath(error);
const baseMessage = error.message ?? "invalid";
const allowedValuesSummary = getAjvAllowedValuesSummary(error);
const additionalProperty = resolveAdditionalProperty(error);
const message = allowedValuesSummary
? appendAllowedValuesHint(baseMessage, allowedValuesSummary)
: baseMessage;
@@ -169,6 +181,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
path,
message,
text: `${safePath}: ${safeMessage}`,
...(additionalProperty ? { additionalProperty } : {}),
...(allowedValuesSummary
? {
allowedValues: allowedValuesSummary.values,

View File

@@ -21,6 +21,25 @@ function isLegacySecretRefEnvMarker(value: unknown): value is string {
return typeof value === "string" && value.trim().startsWith(LEGACY_SECRETREF_ENV_MARKER_PREFIX);
}
function containsLegacySecretRefEnvMarker(value: unknown, seen = new WeakSet<object>()): boolean {
if (isLegacySecretRefEnvMarker(value)) {
return true;
}
if (!value || typeof value !== "object") {
return false;
}
if (seen.has(value)) {
return false;
}
seen.add(value);
if (Array.isArray(value)) {
return value.some((entry) => containsLegacySecretRefEnvMarker(entry, seen));
}
return Object.values(value as Record<string, unknown>).some((entry) =>
containsLegacySecretRefEnvMarker(entry, seen),
);
}
function toCandidate(
target: DiscoveredConfigSecretTarget,
defaults: NonNullable<OpenClawConfig["secrets"]>["defaults"] | undefined,
@@ -39,6 +58,9 @@ function toCandidate(
export function collectLegacySecretRefEnvMarkerCandidates(
config: OpenClawConfig,
): LegacySecretRefEnvMarkerCandidate[] {
if (!containsLegacySecretRefEnvMarker(config)) {
return [];
}
const defaults = config.secrets?.defaults;
return discoverConfigSecretTargets(config)
.map((target) => toCandidate(target, defaults))