fix: show doctor runtime dependency install progress

This commit is contained in:
Peter Steinberger
2026-04-27 12:24:57 +01:00
parent 5afa24a9fc
commit bbfdb38e4e
3 changed files with 137 additions and 15 deletions

View File

@@ -29,6 +29,7 @@ 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.
- CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.

View File

@@ -1,12 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
resolveBundledRuntimeDependencyPackageInstallRoot,
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
} from "../plugins/bundled-runtime-deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -84,7 +85,25 @@ function createNonInteractivePrompter(
} as DoctorPrompter;
}
function createRuntime(options: { logs?: string[]; errors?: string[] } = {}): RuntimeEnv {
return {
log: (message: unknown) => {
options.logs?.push(String(message));
},
error: (message: unknown) => {
options.errors?.push(String(message));
},
exit: (code: number) => {
throw new Error(`Unexpected runtime exit ${code}`);
},
};
}
describe("doctor bundled plugin runtime deps", () => {
afterEach(() => {
vi.useRealTimers();
});
it("skips source checkouts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
fs.mkdirSync(path.join(root, ".git"));
@@ -410,7 +429,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
@@ -441,7 +460,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
@@ -472,7 +491,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
@@ -496,6 +515,64 @@ describe("doctor bundled plugin runtime deps", () => {
expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["grammy@1.37.0"]);
});
it("logs runtime dependency repair progress before and after install", 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 logs: string[] = [];
await maybeRepairBundledPluginRuntimeDeps({
runtime: createRuntime({ logs }),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: { enabled: true },
channels: { telegram: { enabled: true } },
},
installDeps: async () => {},
});
expect(logs).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Installing bundled plugin runtime deps (1 missing, 1 install specs): grammy@1.37.0",
),
expect.stringContaining("Installed bundled plugin runtime deps in"),
]),
);
});
it("logs runtime dependency repair heartbeats while install is pending", async () => {
vi.useFakeTimers();
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 logs: string[] = [];
let finishInstall!: () => void;
const repair = maybeRepairBundledPluginRuntimeDeps({
runtime: createRuntime({ logs }),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
plugins: { enabled: true },
channels: { telegram: { enabled: true } },
},
installDeps: async () =>
await new Promise<void>((resolve) => {
finishInstall = resolve;
}),
});
await Promise.resolve();
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...");
finishInstall();
await repair;
});
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" });
@@ -503,7 +580,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
@@ -533,7 +610,7 @@ describe("doctor bundled plugin runtime deps", () => {
await expect(
maybeRepairBundledPluginRuntimeDeps({
runtime: { error: (message: string) => errors.push(message) } as never,
runtime: createRuntime({ errors }),
prompter: createNonInteractivePrompter(),
packageRoot: root,
config: {
@@ -558,7 +635,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter({ updateInProgress: true }),
packageRoot: root,
includeConfiguredChannels: true,
@@ -591,7 +668,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
env,
packageRoot: root,
@@ -641,7 +718,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
env,
packageRoot: root,
@@ -677,7 +754,7 @@ describe("doctor bundled plugin runtime deps", () => {
const installed = createInstalledRuntimeDeps();
await maybeRepairBundledPluginRuntimeDeps({
runtime: { error: () => {} } as never,
runtime: createRuntime(),
prompter: createNonInteractivePrompter(),
packageRoot: root,
includeConfiguredChannels: true,

View File

@@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import {
createBundledRuntimeDepsWritableInstallSpecs,
repairBundledRuntimeDepsInstallRoot,
repairBundledRuntimeDepsInstallRootAsync,
resolveBundledRuntimeDependencyPackageInstallRootPlan,
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
@@ -14,6 +14,25 @@ import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const RUNTIME_DEPS_INSTALL_HEARTBEAT_MS = 15_000;
function formatElapsedMs(elapsedMs: number): string {
if (elapsedMs < 1000) {
return `${elapsedMs}ms`;
}
const seconds = Math.round(elapsedMs / 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 logRuntimeDepsInstallProgress(runtime: RuntimeEnv, message: string): void {
runtime.log(message);
}
export async function maybeRepairBundledPluginRuntimeDeps(params: {
runtime: RuntimeEnv;
prompter: DoctorPrompter;
@@ -21,7 +40,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
env?: NodeJS.ProcessEnv;
packageRoot?: string | null;
includeConfiguredChannels?: boolean;
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
installDeps?: (params: BundledRuntimeDepsInstallParams) => void | Promise<void>;
}): Promise<void> {
const packageRoot =
params.packageRoot ??
@@ -104,18 +123,43 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
return;
}
let heartbeat: NodeJS.Timeout | undefined;
try {
const result = repairBundledRuntimeDepsInstallRoot({
const installStartedAt = Date.now();
logRuntimeDepsInstallProgress(
params.runtime,
`Installing bundled plugin runtime deps (${missingSpecs.length} missing, ${installSpecs.length} install specs): ${missingSpecs.join(", ")}`,
);
heartbeat = setInterval(() => {
logRuntimeDepsInstallProgress(
params.runtime,
`Still installing bundled plugin runtime deps after ${formatElapsedMs(Date.now() - installStartedAt)}...`,
);
}, RUNTIME_DEPS_INSTALL_HEARTBEAT_MS);
heartbeat.unref?.();
const result = await repairBundledRuntimeDepsInstallRootAsync({
installRoot: installRootPlan.installRoot,
missingSpecs,
installSpecs,
env: params.env ?? process.env,
installDeps: params.installDeps,
warn: (message) => params.runtime.log(message),
installDeps: params.installDeps
? async (installParams) => {
await params.installDeps?.(installParams);
}
: undefined,
warn: (message) => logRuntimeDepsInstallProgress(params.runtime, message),
});
logRuntimeDepsInstallProgress(
params.runtime,
`Installed bundled plugin runtime deps in ${formatElapsedMs(Date.now() - installStartedAt)}: ${result.installSpecs.join(", ")}`,
);
note(`Installed bundled plugin deps: ${result.installSpecs.join(", ")}`, "Bundled plugins");
} catch (error) {
params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`);
throw error instanceof Error ? error : new Error(String(error));
} finally {
if (heartbeat) {
clearInterval(heartbeat);
}
}
}