fix(cli): improve config startup progress

This commit is contained in:
Peter Steinberger
2026-05-17 00:37:03 +01:00
parent c528f36507
commit 12debcb05e
10 changed files with 224 additions and 34 deletions

View File

@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Discord: keep progress drafts visible for message-tool-only guild replies under the default coding tool profile. Fixes #82747. Thanks @eliranwong.
- CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically.
- Diagnostics/usage/voice-call: treat explicit zero and non-finite limits as empty results and reject invalid voice-call numeric CLI flags. Fixes #82646, #82650, #82651, and #82653. (#82679) Thanks @leno23.
- CLI/config: avoid redundant startup config/plugin checks for the guided `openclaw config` flow and show progress while source checkout CLI artifacts build or load.
- Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518.
- Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths.
- Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145.

View File

@@ -682,10 +682,88 @@ const logRunner = (message, deps) => {
return;
}
const line = `[openclaw] ${message}\n`;
deps.runNodeProgress?.clearLine();
deps.stderr.write(line);
deps.runNodeProgress?.render();
deps.outputTee?.write(line);
};
const RUN_NODE_PROGRESS_FRAMES = ["-", "\\", "|", "/"];
const shouldUseRunNodeProgress = (deps) =>
deps.stderr?.isTTY === true &&
deps.env.OPENCLAW_RUNNER_PROGRESS !== "0" &&
deps.env.CI !== "true" &&
!deps.outputTee;
const createRunNodeProgress = (label, deps) => {
if (!shouldUseRunNodeProgress(deps)) {
return null;
}
const startedAt = Date.now();
let frameIndex = 0;
let active = true;
let visible = false;
const clearLine = () => {
if (!visible) {
return;
}
deps.stderr.write("\r\x1b[2K");
visible = false;
};
const render = () => {
if (!active) {
return;
}
const elapsedSeconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
const frame = RUN_NODE_PROGRESS_FRAMES[frameIndex % RUN_NODE_PROGRESS_FRAMES.length];
frameIndex += 1;
deps.stderr.write(`\r[openclaw] ${frame} ${label} (${elapsedSeconds}s)`);
visible = true;
};
const timer = setInterval(render, 120);
timer.unref?.();
render();
return {
clearLine,
render,
stop() {
if (!active) {
return;
}
active = false;
clearInterval(timer);
clearLine();
},
};
};
const withRunNodeProgress = async (deps, label, callback) => {
const previousProgress = deps.runNodeProgress;
const progress = createRunNodeProgress(label, deps);
if (progress) {
deps.runNodeProgress = progress;
}
try {
return await callback();
} finally {
if (progress) {
progress.stop();
deps.runNodeProgress = previousProgress;
}
}
};
const writeRunnerStream = (deps, stream, chunk) => {
deps.runNodeProgress?.clearLine();
stream.write(chunk);
deps.runNodeProgress?.render();
};
const shouldPipeSpawnedOutput = (deps) => Boolean(deps.outputTee || deps.runNodeProgress);
const sanitizeCpuProfileNamePart = (value) => {
const normalized = String(value ?? "")
.trim()
@@ -810,7 +888,7 @@ const runOpenClaw = async (deps) => {
};
const pipeSpawnedOutput = (childProcess, deps) => {
if (!deps.outputTee) {
if (!shouldPipeSpawnedOutput(deps)) {
return;
}
const stderrFilter =
@@ -818,16 +896,18 @@ const pipeSpawnedOutput = (childProcess, deps) => {
? createSyncIoTraceStderrFilter(deps)
: null;
childProcess.stdout?.on("data", (chunk) => {
deps.stdout.write(chunk);
deps.outputTee.write(chunk);
writeRunnerStream(deps, deps.stdout, chunk);
deps.outputTee?.write(chunk);
});
childProcess.stderr?.on("data", (chunk) => {
deps.runNodeProgress?.clearLine();
if (stderrFilter) {
stderrFilter.write(chunk);
} else {
deps.stderr.write(chunk);
}
deps.outputTee.write(chunk);
deps.runNodeProgress?.render();
deps.outputTee?.write(chunk);
});
childProcess.stderr?.on("end", () => {
stderrFilter?.flush();
@@ -1253,36 +1333,45 @@ export async function runNodeMain(params = {}) {
);
logRunner("Building bundled plugin assets.", deps);
const buildCmd = deps.execPath;
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(assetBuild, deps);
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
if (assetBuildInterruptedExitCode !== null) {
return assetBuildInterruptedExitCode;
}
if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) {
return assetBuildRes.exitCode;
}
const compileExitCode = await withRunNodeProgress(
deps,
"Building local CLI artifacts",
async () => {
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(assetBuild, deps);
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
if (assetBuildInterruptedExitCode !== null) {
return assetBuildInterruptedExitCode;
}
if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) {
return assetBuildRes.exitCode;
}
const buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(build, deps);
const build = deps.spawn(buildCmd, compilerArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(build, deps);
const buildRes = await waitForSpawnedProcess(build, deps);
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
if (interruptedExitCode !== null) {
return interruptedExitCode;
}
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
const buildRes = await waitForSpawnedProcess(build, deps);
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
if (interruptedExitCode !== null) {
return interruptedExitCode;
}
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
}
return 0;
},
);
if (compileExitCode !== 0) {
return compileExitCode;
}
if (!(await syncRuntimeArtifacts(deps))) {
return 1;

View File

@@ -105,6 +105,11 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { loadPlugins: "never" },
},
{ commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
{
commandPath: ["config"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["migrate"],
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },

View File

@@ -189,6 +189,15 @@ describe("command-path-policy", () => {
bypassConfigGuard: true,
loadPlugins: "never",
});
expectResolvedPolicy(["config"], {
bypassConfigGuard: true,
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["config", "set"], {
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["config", "validate"], {
bypassConfigGuard: true,
loadPlugins: "never",

View File

@@ -11,8 +11,10 @@ import {
describe("command-startup-policy", () => {
it("matches config guard bypass commands", () => {
expect(shouldBypassConfigGuardForCommandPath(["backup", "create"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "set"])).toBe(false);
expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false);
});

View File

@@ -168,6 +168,7 @@ describe("registerPreActionHooks", () => {
.option("--json")
.action(() => {});
const config = program.command("config");
config.option("--section <section>");
setCommandJsonMode(config.command("set"), "parse-only")
.argument("<path>")
.argument("<value>")
@@ -313,6 +314,26 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("lets bare config own config validation and plugin loading", async () => {
await runPreAction({
parseArgv: ["config"],
processArgv: ["node", "openclaw", "config"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("lets guided config sections own config validation and plugin loading", async () => {
await runPreAction({
parseArgv: ["config"],
processArgv: ["node", "openclaw", "config", "--section", "models"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("only allows invalid config for explicit official recovery reinstall requests", async () => {
await runPreAction({
parseArgv: ["plugins", "install", "@openclaw/discord"],

View File

@@ -75,6 +75,10 @@ function isBareParentDefaultHelpInvocation(actionCommand: Command, argv: string[
return primary === actionCommand.name() || actionCommand.aliases().includes(primary);
}
function isGuidedConfigAction(actionCommand: Command): boolean {
return actionCommand.name() === "config" && actionCommand.parent?.parent === undefined;
}
export function registerPreActionHooks(program: Command, programVersion: string) {
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
@@ -101,7 +105,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
if (!verbose) {
process.env.NODE_NO_WARNINGS ??= "1";
}
if (shouldBypassConfigGuardForCommandPath(commandPath)) {
if (shouldBypassConfigGuardForCommandPath(commandPath) || isGuidedConfigAction(actionCommand)) {
return;
}
await ensureCliExecutionBootstrap({

View File

@@ -292,6 +292,24 @@ describe("runCli exit behavior", () => {
expect(disposeRegisteredAgentHarnessesMock).toHaveBeenCalledTimes(1);
});
it("shows the standard spinner while loading the full CLI", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "config", aliases: () => [] }],
parseAsync,
});
await runCli(["node", "openclaw", "config"]);
expect(createCliProgressMock).toHaveBeenCalledWith({
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
});
expect(progressDoneMock).toHaveBeenCalledTimes(1);
});
it("pauses non-tty stdin after full CLI command completion", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);

View File

@@ -633,7 +633,6 @@ export async function runCli(argv: string[] = process.argv) {
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
fallback: "none",
});
let startupProgressStopped = false;
const stopStartupProgress = () => {

View File

@@ -1397,6 +1397,48 @@ describe("run-node script", () => {
});
});
it("shows tty progress while rebuilding source-checkout artifacts", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE],
buildPaths: [DIST_ENTRY, BUILD_STAMP],
});
const { spawn, spawnSync } = createSpawnRecorder();
const stderrChunks: string[] = [];
const stderr = {
isTTY: true,
write: vi.fn((chunk: string) => {
stderrChunks.push(String(chunk));
return true;
}),
} as unknown as NodeJS.WriteStream;
const stdout = {
write: vi.fn(() => true),
} as unknown as NodeJS.WriteStream;
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_FORCE_BUILD: "1",
},
spawn,
spawnSync,
stderr,
stdout,
runRuntimePostBuild: async () => {},
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
const stderrText = stderrChunks.join("");
expect(stderrText).toContain("Building local CLI artifacts");
expect(stderrText).toContain("\x1b[2K");
});
});
it("rebuilds when git HEAD changes even if source mtimes do not exceed the old build stamp", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {