From 0aa28c71cabbfd055d14644ee570b1f290e0c692 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:19:44 -0500 Subject: [PATCH] fix(doctor): move forced exit to top-level command --- CHANGELOG.md | 1 + src/cli/program/register.maintenance.test.ts | 72 ++++++++++++++++++++ src/cli/program/register.maintenance.ts | 1 + src/cli/update-cli.test.ts | 37 ++++++++++ src/commands/doctor.ts | 7 +- 5 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/cli/program/register.maintenance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc2d423b6..a92b054d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT. - CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) - CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. +- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) - Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. - Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) - Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts new file mode 100644 index 0000000000..37c4160afb --- /dev/null +++ b/src/cli/program/register.maintenance.test.ts @@ -0,0 +1,72 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const doctorCommand = vi.fn(); +const dashboardCommand = vi.fn(); +const resetCommand = vi.fn(); +const uninstallCommand = vi.fn(); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +vi.mock("../../commands/doctor.js", () => ({ + doctorCommand, +})); + +vi.mock("../../commands/dashboard.js", () => ({ + dashboardCommand, +})); + +vi.mock("../../commands/reset.js", () => ({ + resetCommand, +})); + +vi.mock("../../commands/uninstall.js", () => ({ + uninstallCommand, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +describe("registerMaintenanceCommands doctor action", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exits with code 0 after successful doctor run", async () => { + doctorCommand.mockResolvedValue(undefined); + + const { registerMaintenanceCommands } = await import("./register.maintenance.js"); + const program = new Command(); + registerMaintenanceCommands(program); + + await program.parseAsync(["doctor", "--non-interactive", "--yes"], { from: "user" }); + + expect(doctorCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + nonInteractive: true, + yes: true, + }), + ); + expect(runtime.exit).toHaveBeenCalledWith(0); + }); + + it("exits with code 1 when doctor fails", async () => { + doctorCommand.mockRejectedValue(new Error("doctor failed")); + + const { registerMaintenanceCommands } = await import("./register.maintenance.js"); + const program = new Command(); + registerMaintenanceCommands(program); + + await program.parseAsync(["doctor"], { from: "user" }); + + expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed"); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(runtime.exit).not.toHaveBeenCalledWith(0); + }); +}); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 696ba8db27..5aa668977d 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -36,6 +36,7 @@ export function registerMaintenanceCommands(program: Command) { generateGatewayToken: Boolean(opts.generateGatewayToken), deep: Boolean(opts.deep), }); + defaultRuntime.exit(0); }); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index e8b0312fb8..13220417ad 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -113,6 +113,7 @@ const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = await import("../infra/update-check.js"); const { runCommandWithTimeout } = await import("../process/exec.js"); const { runDaemonRestart } = await import("./daemon-cli.js"); +const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardCommand } = await import("./update-cli.js"); @@ -200,6 +201,7 @@ describe("update-cli", () => { vi.mocked(resolveNpmChannelTag).mockReset(); vi.mocked(runCommandWithTimeout).mockReset(); vi.mocked(runDaemonRestart).mockReset(); + vi.mocked(doctorCommand).mockReset(); vi.mocked(defaultRuntime.log).mockReset(); vi.mocked(defaultRuntime.error).mockReset(); vi.mocked(defaultRuntime.exit).mockReset(); @@ -483,6 +485,41 @@ describe("update-cli", () => { expect(runDaemonRestart).toHaveBeenCalled(); }); + it("updateCommand continues after doctor sub-step and clears update flag", async () => { + const mockResult: UpdateRunResult = { + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }; + + const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + try { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runDaemonRestart).mockResolvedValue(true); + vi.mocked(doctorCommand).mockResolvedValue(undefined); + vi.mocked(defaultRuntime.log).mockClear(); + + await updateCommand({}); + + expect(doctorCommand).toHaveBeenCalledWith( + defaultRuntime, + expect.objectContaining({ nonInteractive: true }), + ); + expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined(); + + const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect( + logLines.some((line) => line.includes("Leveled up! New skills unlocked. You're welcome.")), + ).toBe(true); + } finally { + randomSpy.mockRestore(); + envSnapshot.restore(); + } + }); + it("updateCommand skips restart when --no-restart is set", async () => { const mockResult: UpdateRunResult = { status: "ok", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bbaa893bb8..b467b9b375 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,7 @@ -import fs from "node:fs"; import { intro as clackIntro, outro as clackOutro } from "@clack/prompts"; +import fs from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; @@ -9,14 +11,12 @@ import { resolveHooksGmailModel, } from "../agents/model-selection.js"; import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; -import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; @@ -318,5 +318,4 @@ export async function doctorCommand( } outro("Doctor complete."); - runtime.exit(0); }