fix(doctor): stream bundled runtime dep repair progress

This commit is contained in:
Peter Steinberger
2026-04-27 12:25:11 +01:00
parent 05fce28ec0
commit db087a4be7
6 changed files with 189 additions and 16 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/doctor: run bundled plugin runtime-dependency repairs through the async npm installer with spinner/line progress and heartbeat updates, so long `openclaw doctor --fix` installs no longer look hung in TTY or piped output. Fixes #72775. Thanks @dfpalhano.
- Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top.
- Agents/Qwen: preserve exact custom `modelstudio` provider configs with foreign `api` owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831.
- Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash.
@@ -29,7 +30,6 @@ Docs: https://docs.openclaw.ai
- Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue.
- Agents/ACP: ship the Claude ACP adapter with OpenClaw and require Claude result messages before idle can complete a prompt, preventing parent agents from waking early on long-running `sessions_spawn(runtime: "acp", agentId: "claude")` children. Fixes #72080. Thanks @siavash-saki and @iannwu.
- CLI/tasks: route `tasks --json`, `tasks list --json`, and `tasks audit --json` through a lean JSON path so read-only task inspection no longer loads unrelated plugin/runtime command graphs. Fixes #66238. Thanks @ChuckChambers.
- CLI/doctor: stream bundled plugin runtime dependency repair progress before, during, and after npm installs, so long `doctor --fix` runs no longer look hung in TTY or captured logs. Fixes #72775. Thanks @dfpalhano.
- Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.
- WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset <note>` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.
- Tasks/memory: checkpoint and truncate SQLite WAL sidecars on a timer and before close for task, Task Flow, proxy capture, and builtin memory databases, bounding long-running gateway `*.sqlite-wal` growth. Fixes #72774. Thanks @dfpalhano.

View File

@@ -328,7 +328,7 @@ That stages grounded durable candidates into the short-term dreaming store while
<Accordion title="7b. Bundled plugin runtime deps">
Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, or a default-enabled bundled provider. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths.
The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time.
During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time.
</Accordion>
<Accordion title="8. Gateway service migrations and cleanup hints">

View File

@@ -564,8 +564,9 @@ describe("doctor bundled plugin runtime deps", () => {
}),
});
await Promise.resolve();
expect(logs).toEqual([expect.stringContaining("Installing bundled plugin runtime deps")]);
await vi.waitFor(() =>
expect(logs).toEqual([expect.stringContaining("Installing bundled plugin runtime deps")]),
);
await vi.advanceTimersByTimeAsync(15_000);
expect(logs).toContain("Still installing bundled plugin runtime deps after 15s...");
@@ -573,6 +574,38 @@ describe("doctor bundled plugin runtime deps", () => {
await repair;
});
it("awaits async runtime-deps repairs before reporting completion", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" });
const installed = createInstalledRuntimeDeps();
const notes: string[] = [];
let finishInstall!: () => void;
const repair = maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {}, log: () => {} } as never,
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: { enabled: true },
channels: { telegram: { enabled: true } },
},
installDeps: async (params) => {
installed.push(params);
await new Promise<void>((resolve) => {
finishInstall = resolve;
});
},
}).then(() => notes.push("done"));
await vi.waitFor(() => expect(installed).toHaveLength(1));
expect(notes).toEqual([]);
finishInstall();
await repair;
expect(notes).toEqual(["done"]);
});
it("repairs deps for configured channel owner plugins", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });

View File

@@ -124,7 +124,14 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
}
let heartbeat: NodeJS.Timeout | undefined;
let progress: { setLabel: (label: string) => void; done: () => void } | undefined;
try {
const { createCliProgress } = await import("../cli/progress.js");
progress = createCliProgress({
label: `Installing bundled plugin runtime deps (${missingSpecs.length})`,
indeterminate: true,
enabled: process.env.VITEST !== "true" || process.env.OPENCLAW_TEST_RUNTIME_LOG === "1",
});
const installStartedAt = Date.now();
logRuntimeDepsInstallProgress(
params.runtime,
@@ -148,6 +155,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
}
: undefined,
warn: (message) => logRuntimeDepsInstallProgress(params.runtime, message),
onProgress: (message) => progress?.setLabel(message),
});
logRuntimeDepsInstallProgress(
params.runtime,
@@ -161,5 +169,6 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
if (heartbeat) {
clearInterval(heartbeat);
}
progress?.done();
}
}

View File

@@ -17,6 +17,7 @@ import {
createBundledRuntimeDepsInstallEnv,
ensureBundledPluginRuntimeDeps,
installBundledRuntimeDeps,
installBundledRuntimeDepsAsync,
isWritableDirectory,
materializeBundledRuntimeMirrorDistFile,
repairBundledRuntimeDepsInstallRootAsync,
@@ -366,6 +367,74 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("reports async npm output as install progress", async () => {
const installRoot = makeTempDir();
const progress: string[] = [];
spawnMock.mockImplementation((_command, _args, options) => {
const cwd = String(options?.cwd ?? "");
const child = new EventEmitter() as ReturnType<typeof spawn>;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
queueMicrotask(() => {
child.stdout?.emit("data", Buffer.from("added 1 package\n"));
child.stderr?.emit("data", Buffer.from("\u001b[31mnpm notice\u001b[39m\r"));
writeInstalledPackage(cwd, "acpx", "0.5.3");
child.emit("close", 0, null);
});
return child;
});
await installBundledRuntimeDepsAsync({
installRoot,
missingSpecs: ["acpx@0.5.3"],
env: {},
onProgress: (message) => progress.push(message),
});
expect(progress).toContain("Starting npm install for bundled plugin runtime deps: acpx@0.5.3");
expect(progress).toContain("npm stdout: added 1 package");
expect(progress).toContain("npm stderr: npm notice");
});
it("emits heartbeat progress while async npm is silent", async () => {
vi.useFakeTimers();
try {
const installRoot = makeTempDir();
const progress: string[] = [];
let closeChild!: () => void;
spawnMock.mockImplementation((_command, _args, options) => {
const cwd = String(options?.cwd ?? "");
const child = new EventEmitter() as ReturnType<typeof spawn>;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
closeChild = () => {
writeInstalledPackage(cwd, "acpx", "0.5.3");
child.emit("close", 0, null);
};
return child;
});
const install = installBundledRuntimeDepsAsync({
installRoot,
missingSpecs: ["acpx@0.5.3"],
env: {},
onProgress: (message) => progress.push(message),
});
await vi.advanceTimersByTimeAsync(5_000);
expect(progress).toContain("npm install still running (5s elapsed)");
closeChild();
await expect(install).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("anchors non-isolated external install roots with a package manifest", () => {
const parentRoot = makeTempDir();
const installRoot = path.join(parentRoot, ".openclaw", "plugin-runtime-deps", "openclaw-test");

View File

@@ -10,6 +10,7 @@ import { createLowDiskSpaceWarning } from "../infra/disk-space.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { createNpmProjectInstallEnv } from "../infra/npm-install-env.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { beginBundledRuntimeDepsInstall } from "./bundled-runtime-deps-activity.js";
import { normalizePluginsConfig } from "./config-state.js";
import { satisfies, validRange, validSemver } from "./semver.runtime.js";
@@ -65,6 +66,7 @@ const BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS = 100;
const BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS = 5 * 60_000;
const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000;
const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_000;
const BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS = 5_000;
const BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]);
const BUNDLED_RUNTIME_MIRROR_PLUGIN_REGION_RE = /(?:^|\n)\/\/#region extensions\/[^/\s]+(?:\/|$)/u;
const MIRRORED_PACKAGE_RUNTIME_DEP_NAMES = ["tslog"] as const;
@@ -1587,13 +1589,58 @@ function formatBundledRuntimeDepsInstallError(result: {
return output || "npm install failed";
}
function formatBundledRuntimeDepsInstallElapsed(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000));
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
}
function emitBundledRuntimeDepsOutputProgress(
chunk: Buffer,
stream: "stdout" | "stderr",
onProgress: ((message: string) => void) | undefined,
): void {
if (!onProgress) {
return;
}
const lines = chunk
.toString("utf8")
.split(/\r\n|\n|\r/u)
.map((line) => sanitizeTerminalText(line).trim())
.filter((line) => line.length > 0)
.slice(-3);
for (const line of lines) {
onProgress(`npm ${stream}: ${line}`);
}
}
async function spawnBundledRuntimeDepsInstall(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
onProgress?: (message: string) => void;
}): Promise<void> {
await new Promise<void>((resolve, reject) => {
const startedAtMs = Date.now();
const heartbeat =
params.onProgress &&
setInterval(() => {
params.onProgress?.(
`npm install still running (${formatBundledRuntimeDepsInstallElapsed(Date.now() - startedAtMs)} elapsed)`,
);
}, BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS);
heartbeat?.unref?.();
const settle = (fn: () => void) => {
if (heartbeat) {
clearInterval(heartbeat);
}
fn();
};
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
@@ -1602,24 +1649,32 @@ async function spawnBundledRuntimeDepsInstall(params: {
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
child.stdout?.on("data", (chunk: Buffer) => {
stdout.push(chunk);
emitBundledRuntimeDepsOutputProgress(chunk, "stdout", params.onProgress);
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr.push(chunk);
emitBundledRuntimeDepsOutputProgress(chunk, "stderr", params.onProgress);
});
child.on("error", (error) => {
reject(new Error(formatBundledRuntimeDepsInstallError({ error })));
settle(() => reject(new Error(formatBundledRuntimeDepsInstallError({ error }))));
});
child.on("close", (status, signal) => {
if (status === 0 && !signal) {
resolve();
settle(resolve);
return;
}
reject(
new Error(
formatBundledRuntimeDepsInstallError({
status,
signal,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
}),
settle(() =>
reject(
new Error(
formatBundledRuntimeDepsInstallError({
status,
signal,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
}),
),
),
);
});
@@ -1703,6 +1758,7 @@ export async function installBundledRuntimeDepsAsync(params: {
missingSpecs: string[];
env: NodeJS.ProcessEnv;
warn?: (message: string) => void;
onProgress?: (message: string) => void;
}): Promise<void> {
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
const isolatedExecutionRoot =
@@ -1731,11 +1787,15 @@ export async function installBundledRuntimeDepsAsync(params: {
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
params.onProgress?.(
`Starting npm install for bundled plugin runtime deps: ${params.missingSpecs.join(", ")}`,
);
await spawnBundledRuntimeDepsInstall({
command: npmRunner.command,
args: npmRunner.args,
cwd: installExecutionRoot,
env: npmRunner.env ?? installEnv,
onProgress: params.onProgress,
});
assertBundledRuntimeDepsInstalled(installExecutionRoot, params.missingSpecs);
if (isolatedExecutionRoot) {
@@ -1858,6 +1918,7 @@ export async function repairBundledRuntimeDepsInstallRootAsync(params: {
env: NodeJS.ProcessEnv;
installDeps?: (params: BundledRuntimeDepsInstallParams) => Promise<void>;
warn?: (message: string) => void;
onProgress?: (message: string) => void;
}): Promise<{ installSpecs: string[] }> {
return await withBundledRuntimeDepsInstallRootLockAsync(params.installRoot, async () => {
const retainedManifestSpecs = readRetainedRuntimeDepsManifest(params.installRoot);
@@ -1872,6 +1933,7 @@ export async function repairBundledRuntimeDepsInstallRootAsync(params: {
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
env: params.env,
warn: params.warn,
onProgress: params.onProgress,
}));
const finishActivity = beginBundledRuntimeDepsInstall({
installRoot: params.installRoot,