From db087a4be7b22fb8891a4c3001d1eaad9f0308d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:25:11 +0100 Subject: [PATCH] fix(doctor): stream bundled runtime dep repair progress --- CHANGELOG.md | 2 +- docs/gateway/doctor.md | 2 +- ...doctor-bundled-plugin-runtime-deps.test.ts | 37 +++++++- .../doctor-bundled-plugin-runtime-deps.ts | 9 ++ src/plugins/bundled-runtime-deps.test.ts | 69 +++++++++++++++ src/plugins/bundled-runtime-deps.ts | 86 ++++++++++++++++--- 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bb17be299..a752cd7ca48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 1256159d657..19b4b443ad9 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -328,7 +328,7 @@ That stages grounded durable candidates into the short-term dreaming store while 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. diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 2c9e6083320..9588ac33b56 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -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((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" }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index ac601fc6df7..aed670c37d9 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -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(); } } diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 8e3d977322e..dc7c49f8232 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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; + 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; + 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"); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 447f3884464..c53aa6c75b7 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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 { await new Promise((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 { 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; 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,