fix(cli): route deferred plugin logs to stderr in status --json

This commit is contained in:
Charles Dusek 2026-03-22 16:31:57 -05:00 committed by Peter Steinberger
parent 0e1da034c2
commit 03c4bacbfb
4 changed files with 55 additions and 4 deletions

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loggingState } from "../logging/state.js";
const mocks = vi.hoisted(() => ({
resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-fast-json-missing-${process.pid}.json`),
@ -18,8 +19,12 @@ const mocks = vi.hoisted(() => ({
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
let originalForceStderr: boolean;
beforeEach(() => {
vi.clearAllMocks();
originalForceStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = false;
mocks.hasPotentialConfiguredChannels.mockReturnValue(false);
mocks.readBestEffortConfig.mockResolvedValue({
session: {},
@ -170,7 +175,26 @@ vi.mock("../plugins/status.js", () => ({
const { scanStatusJsonFast } = await import("./status.scan.fast-json.js");
afterEach(() => {
loggingState.forceConsoleToStderr = originalForceStderr;
});
describe("scanStatusJsonFast", () => {
it("routes plugin logs to stderr during deferred plugin loading", async () => {
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
let stderrDuringLoad = false;
mocks.ensurePluginRegistryLoaded.mockImplementation(() => {
stderrDuringLoad = loggingState.forceConsoleToStderr;
});
await scanStatusJsonFast({}, {} as never);
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalled();
expect(stderrDuringLoad).toBe(true);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("skips memory inspection for the lean status --json fast path", async () => {
const result = await scanStatusJsonFast({}, {} as never);

View File

@ -5,6 +5,7 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { loggingState } from "../logging/state.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { getAgentLocalStatuses } from "./status.agent-local.js";
@ -159,7 +160,14 @@ export async function scanStatusJsonFast(
const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg);
if (hasConfiguredChannels) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
ensurePluginRegistryLoaded({ scope: "configured-channels" });
// Route plugin registration logs to stderr so they don't corrupt JSON on stdout.
const prev = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = true;
try {
ensurePluginRegistryLoaded({ scope: "configured-channels" });
} finally {
loggingState.forceConsoleToStderr = prev;
}
}
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loggingState } from "../logging/state.js";
const mocks = vi.hoisted(() => ({
resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-scan-missing-${process.pid}.json`),
@ -18,11 +19,19 @@ const mocks = vi.hoisted(() => ({
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
let originalForceStderr: boolean;
beforeEach(() => {
vi.clearAllMocks();
originalForceStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = false;
mocks.hasPotentialConfiguredChannels.mockReturnValue(false);
});
afterEach(() => {
loggingState.forceConsoleToStderr = originalForceStderr;
});
vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
}));
@ -503,6 +512,8 @@ describe("scanStatus", () => {
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
});
// Verify plugin logs were routed to stderr during loading and restored after
expect(loggingState.forceConsoleToStderr).toBe(false);
expect(mocks.probeGateway).toHaveBeenCalledWith(
expect.objectContaining({ detailLevel: "presence" }),
);

View File

@ -10,6 +10,7 @@ import { resolveConfigPath } from "../config/paths.js";
import { callGateway } from "../gateway/call.js";
import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { loggingState } from "../logging/state.js";
import {
buildPluginCompatibilityNotices,
type PluginCompatibilityNotice,
@ -166,7 +167,14 @@ async function scanStatusJsonFast(opts: {
const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg);
if (hasConfiguredChannels) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
ensurePluginRegistryLoaded({ scope: "configured-channels" });
// Route plugin registration logs to stderr so they don't corrupt JSON on stdout.
const prev = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = true;
try {
ensurePluginRegistryLoaded({ scope: "configured-channels" });
} finally {
loggingState.forceConsoleToStderr = prev;
}
}
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";