diff --git a/CHANGELOG.md b/CHANGELOG.md index f337a9e942a..bd8d1312a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 6ebc6ae1e36..2c9e6083320 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -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((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, diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index 5a21bec18e8..ac601fc6df7 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -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; }): Promise { 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); + } } }