Skip to content

Commit 6011c9e

Browse files
authored
Expose WhatsApp responsiveness doctor lint findings (#98406)
Adds a default-disabled Doctor lint check for WhatsApp responsiveness pressure while keeping real cleanup in the existing doctor --fix path. Verification: - Focused WhatsApp responsiveness/contribution Vitest passed (71 tests). - Changed-file oxfmt passed. - Changed-file oxlint with core tsconfig passed. - SDK surface report passed. - SDK export sync passed. - git diff --check passed. - Selected source CLI proof passed earlier on this head: doctor --lint --json --only core/doctor/whatsapp-responsiveness exited 0, ran 1 check, and did not create <state>/identity/device.json. - Exact-head hosted checks passed/skipped on 39f0039; older cancelled proof/auto-response runs were superseded by passing current runs. Maintainer notes: - ClawSweeper platinum hermit / ready for maintainer review. - Accepted the opt-in lint check id and warning copy on the PR. - No public config/plugin/SDK/persisted data contract change; default doctor --lint remains unchanged.
1 parent df14527 commit 6011c9e

4 files changed

Lines changed: 305 additions & 8 deletions

File tree

src/commands/doctor-whatsapp-responsiveness.test.ts

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
1616
note: noteMock,
1717
}));
1818

19-
const { listLocalTuiProcesses, noteWhatsappResponsivenessHealth, terminateLocalTuiProcesses } =
20-
await import("./doctor-whatsapp-responsiveness.js");
19+
const {
20+
collectWhatsappResponsivenessHealthFindings,
21+
listLocalTuiProcesses,
22+
noteWhatsappResponsivenessHealth,
23+
terminateLocalTuiProcesses,
24+
} = await import("./doctor-whatsapp-responsiveness.js");
2125

2226
describe("doctor WhatsApp responsiveness", () => {
2327
beforeEach(() => {
@@ -39,11 +43,16 @@ describe("doctor WhatsApp responsiveness", () => {
3943
].join("\n"),
4044
});
4145

42-
expect(listLocalTuiProcesses()).toEqual([
43-
{ pid: 101, command: "openclaw-tui" },
44-
{ pid: 104, command: "openclaw tui --local" },
45-
{ pid: 105, command: "/usr/bin/openclaw chat" },
46-
]);
46+
if (process.platform === "win32") {
47+
expect(listLocalTuiProcesses()).toEqual([]);
48+
expect(spawnSyncMock).not.toHaveBeenCalled();
49+
} else {
50+
expect(listLocalTuiProcesses()).toEqual([
51+
{ pid: 101, command: "openclaw-tui" },
52+
{ pid: 104, command: "openclaw tui --local" },
53+
{ pid: 105, command: "/usr/bin/openclaw chat" },
54+
]);
55+
}
4756
});
4857

4958
it("terminates stale local TUI processes with a kill fallback", async () => {
@@ -118,6 +127,93 @@ describe("doctor WhatsApp responsiveness", () => {
118127
);
119128
});
120129

130+
it("collects a warning finding for local TUI pressure when WhatsApp is enabled", () => {
131+
const cfg = { channels: { whatsapp: { enabled: true } } } as OpenClawConfig;
132+
133+
const findings = collectWhatsappResponsivenessHealthFindings({
134+
cfg,
135+
status: {
136+
eventLoop: {
137+
degraded: true,
138+
reasons: ["event_loop_delay"],
139+
intervalMs: 30_000,
140+
delayP99Ms: 42,
141+
delayMaxMs: 12_000,
142+
utilization: 0.3,
143+
cpuCoreRatio: 0.4,
144+
},
145+
},
146+
listLocalTuiProcesses: () => [{ pid: 101, command: "openclaw-tui" }],
147+
});
148+
149+
expect(findings).toEqual([
150+
expect.objectContaining({
151+
checkId: "core/doctor/whatsapp-responsiveness",
152+
severity: "warning",
153+
path: "channels.whatsapp",
154+
target: "101",
155+
requirement: "local-tui-event-loop-pressure",
156+
fixHint: expect.stringContaining("openclaw doctor --fix"),
157+
}),
158+
]);
159+
});
160+
161+
it("keeps WhatsApp responsiveness findings quiet without the exact pressure signal", () => {
162+
const cfg = { channels: { whatsapp: { enabled: true } } } as OpenClawConfig;
163+
164+
expect(
165+
collectWhatsappResponsivenessHealthFindings({
166+
cfg,
167+
status: {
168+
eventLoop: {
169+
degraded: false,
170+
reasons: [],
171+
intervalMs: 1,
172+
delayP99Ms: 0,
173+
delayMaxMs: 0,
174+
utilization: 0,
175+
cpuCoreRatio: 0,
176+
},
177+
},
178+
listLocalTuiProcesses: () => [{ pid: 101, command: "openclaw-tui" }],
179+
}),
180+
).toEqual([]);
181+
expect(
182+
collectWhatsappResponsivenessHealthFindings({
183+
cfg,
184+
status: {
185+
eventLoop: {
186+
degraded: true,
187+
reasons: ["event_loop_delay"],
188+
intervalMs: 30_000,
189+
delayP99Ms: 42,
190+
delayMaxMs: 12_000,
191+
utilization: 0.3,
192+
cpuCoreRatio: 0.4,
193+
},
194+
},
195+
listLocalTuiProcesses: () => [],
196+
}),
197+
).toEqual([]);
198+
expect(
199+
collectWhatsappResponsivenessHealthFindings({
200+
cfg: { channels: { whatsapp: { enabled: false } } } as OpenClawConfig,
201+
status: {
202+
eventLoop: {
203+
degraded: true,
204+
reasons: ["event_loop_delay"],
205+
intervalMs: 30_000,
206+
delayP99Ms: 42,
207+
delayMaxMs: 12_000,
208+
utilization: 0.3,
209+
cpuCoreRatio: 0.4,
210+
},
211+
},
212+
listLocalTuiProcesses: () => [{ pid: 101, command: "openclaw-tui" }],
213+
}),
214+
).toEqual([]);
215+
});
216+
121217
it("does not treat generic model routing as a WhatsApp-only issue", async () => {
122218
const cfg = {
123219
channels: { whatsapp: { enabled: true } },

src/commands/doctor-whatsapp-responsiveness.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { note } from "../../packages/terminal-core/src/note.js";
55
import { formatCliCommand } from "../cli/command-format.js";
66
import type { OpenClawConfig } from "../config/types.openclaw.js";
7+
import type { HealthFinding } from "../flows/health-checks.js";
78
import type { StatusSummary } from "./status.types.js";
89

910
type LocalTuiProcess = {
@@ -18,6 +19,7 @@ type ProcessController = {
1819
};
1920

2021
const LOCAL_TUI_SUBCOMMANDS = new Set(["chat", "terminal", "tui"]);
22+
const WHATSAPP_RESPONSIVENESS_CHECK_ID = "core/doctor/whatsapp-responsiveness";
2123

2224
function tokenizeCommandLine(command: string): string[] {
2325
return command.trim().split(/\s+/u).filter(Boolean);
@@ -93,6 +95,43 @@ function formatPidList(processes: LocalTuiProcess[]): string {
9395
return processes.map((proc) => String(proc.pid)).join(", ");
9496
}
9597

98+
/** Collects read-only structured findings for WhatsApp responsiveness pressure. */
99+
export function collectWhatsappResponsivenessHealthFindings(params: {
100+
cfg: OpenClawConfig;
101+
status?: Pick<StatusSummary, "eventLoop"> | null;
102+
listLocalTuiProcesses?: () => LocalTuiProcess[];
103+
}): readonly HealthFinding[] {
104+
if (!hasWhatsappEnabled(params.cfg)) {
105+
return [];
106+
}
107+
108+
const eventLoop = params.status?.eventLoop;
109+
if (eventLoop?.degraded !== true) {
110+
return [];
111+
}
112+
113+
const tuiProcesses = (params.listLocalTuiProcesses ?? listLocalTuiProcesses)();
114+
if (tuiProcesses.length === 0) {
115+
return [];
116+
}
117+
118+
const pids = formatPidList(tuiProcesses);
119+
return [
120+
{
121+
checkId: WHATSAPP_RESPONSIVENESS_CHECK_ID,
122+
severity: "warning",
123+
message:
124+
"Gateway event loop is degraded while local TUI clients are running; WhatsApp replies can queue behind TUI startup/session refresh work.",
125+
path: "channels.whatsapp",
126+
target: pids,
127+
requirement: "local-tui-event-loop-pressure",
128+
fixHint: `Close local TUI sessions (${pids}), or run ${formatCliCommand(
129+
"openclaw doctor --fix",
130+
)}.`,
131+
},
132+
];
133+
}
134+
96135
function isProcessAlive(controller: ProcessController, pid: number): boolean {
97136
try {
98137
controller.kill(pid, 0);

src/flows/doctor-health-contributions.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
2828
noteLegacyCodexProviderOverride: vi.fn(),
2929
noteMemorySearchHealth: vi.fn().mockResolvedValue(undefined),
3030
buildGatewayConnectionDetails: vi.fn(() => ({ message: "gateway details" })),
31+
callGateway: vi.fn(),
3132
resolveSecretInputRef: vi.fn((params: { value?: unknown }) => ({
3233
ref:
3334
params.value === "exec-token"
@@ -100,6 +101,8 @@ const mocks = vi.hoisted(() => ({
100101
collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []),
101102
collectHeartbeatTemplateHealthFindings: vi.fn(async () => [] as unknown[]),
102103
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
104+
collectWhatsappResponsivenessHealthFindings: vi.fn((): readonly HealthFinding[] => []),
105+
noteWhatsappResponsivenessHealth: vi.fn().mockResolvedValue(undefined),
103106
collectDevicePairingHealthFindings: vi.fn(async () => []),
104107
scanConfiguredChannelPluginBlockers: vi.fn(
105108
(): Array<{ channelId: string; pluginId: string; reason: string }> => [],
@@ -213,6 +216,7 @@ vi.mock("../commands/doctor-memory-search.js", () => ({
213216

214217
vi.mock("../gateway/call.js", () => ({
215218
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
219+
callGateway: mocks.callGateway,
216220
}));
217221

218222
vi.mock("../commands/doctor-platform-notes.js", () => ({
@@ -338,6 +342,11 @@ vi.mock("../commands/doctor-heartbeat-template-repair.js", () => ({
338342
maybeRepairHeartbeatTemplate: mocks.maybeRepairHeartbeatTemplate,
339343
}));
340344

345+
vi.mock("../commands/doctor-whatsapp-responsiveness.js", () => ({
346+
collectWhatsappResponsivenessHealthFindings: mocks.collectWhatsappResponsivenessHealthFindings,
347+
noteWhatsappResponsivenessHealth: mocks.noteWhatsappResponsivenessHealth,
348+
}));
349+
341350
vi.mock("../commands/doctor-device-pairing.js", () => ({
342351
collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings,
343352
noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined),
@@ -428,6 +437,8 @@ describe("doctor health contributions", () => {
428437
mocks.noteMemorySearchHealth.mockResolvedValue(undefined);
429438
mocks.buildGatewayConnectionDetails.mockClear();
430439
mocks.buildGatewayConnectionDetails.mockReturnValue({ message: "gateway details" });
440+
mocks.callGateway.mockReset();
441+
mocks.callGateway.mockResolvedValue({});
431442
mocks.resolveSecretInputRef.mockClear();
432443
mocks.resolveGatewayAuth.mockClear();
433444
mocks.resolveGatewayAuth.mockReturnValue({ mode: "token", token: undefined });
@@ -542,6 +553,10 @@ describe("doctor health contributions", () => {
542553
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValue([]);
543554
mocks.maybeRepairHeartbeatTemplate.mockReset();
544555
mocks.maybeRepairHeartbeatTemplate.mockResolvedValue(undefined);
556+
mocks.collectWhatsappResponsivenessHealthFindings.mockReset();
557+
mocks.collectWhatsappResponsivenessHealthFindings.mockReturnValue([]);
558+
mocks.noteWhatsappResponsivenessHealth.mockReset();
559+
mocks.noteWhatsappResponsivenessHealth.mockResolvedValue(undefined);
545560
mocks.collectDevicePairingHealthFindings.mockReset();
546561
mocks.collectDevicePairingHealthFindings.mockResolvedValue([]);
547562
mocks.scanConfiguredChannelPluginBlockers.mockReset();
@@ -1362,6 +1377,7 @@ describe("doctor health contributions", () => {
13621377
expect(contributionIds).toContain("core/doctor/stale-plugin-runtime-symlinks");
13631378
expect(contributionIds).toContain("core/doctor/disk-space");
13641379
expect(contributionIds).toContain("core/doctor/heartbeat-template");
1380+
expect(contributionIds).toContain("core/doctor/whatsapp-responsiveness");
13651381
expect(contributionIds).toContain("core/doctor/device-pairing");
13661382
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
13671383
expect(contributionIds).toContain("core/doctor/tool-result-cap");
@@ -1694,6 +1710,125 @@ describe("doctor health contributions", () => {
16941710
expect(mocks.collectDiskSpaceHealthFindings).toHaveBeenCalledWith(ctx.cfg);
16951711
});
16961712

1713+
it("keeps WhatsApp responsiveness opt-in for default lint selection", async () => {
1714+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1715+
const whatsappCheck = contributionChecks.find(
1716+
(check) => check.id === "core/doctor/whatsapp-responsiveness",
1717+
);
1718+
expect(whatsappCheck).toMatchObject({ defaultEnabled: false });
1719+
expect(whatsappCheck).toBeDefined();
1720+
1721+
const ctx = {
1722+
cfg: { channels: { whatsapp: { enabled: true } } },
1723+
mode: "lint",
1724+
allowExecSecretRefs: true,
1725+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1726+
} as const;
1727+
const checks = [whatsappCheck!];
1728+
1729+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
1730+
checksRun: 0,
1731+
checksSkipped: 1,
1732+
});
1733+
expect(mocks.checkGatewayHealth).not.toHaveBeenCalled();
1734+
expect(mocks.callGateway).not.toHaveBeenCalled();
1735+
expect(mocks.collectWhatsappResponsivenessHealthFindings).not.toHaveBeenCalled();
1736+
1737+
const status = {
1738+
eventLoop: {
1739+
degraded: true,
1740+
reasons: ["event_loop_delay"],
1741+
intervalMs: 30_000,
1742+
delayP99Ms: 42,
1743+
delayMaxMs: 12_000,
1744+
utilization: 0.3,
1745+
cpuCoreRatio: 0.4,
1746+
},
1747+
};
1748+
mocks.callGateway.mockResolvedValueOnce(status);
1749+
mocks.collectWhatsappResponsivenessHealthFindings.mockReturnValueOnce([
1750+
{
1751+
checkId: "core/doctor/whatsapp-responsiveness",
1752+
severity: "warning",
1753+
message: "Gateway event loop is degraded while local TUI clients are running.",
1754+
path: "channels.whatsapp",
1755+
requirement: "local-tui-event-loop-pressure",
1756+
},
1757+
]);
1758+
1759+
await expect(
1760+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/whatsapp-responsiveness"] }),
1761+
).resolves.toMatchObject({
1762+
checksRun: 1,
1763+
checksSkipped: 0,
1764+
findings: [expect.objectContaining({ checkId: "core/doctor/whatsapp-responsiveness" })],
1765+
});
1766+
expect(mocks.checkGatewayHealth).not.toHaveBeenCalled();
1767+
expect(mocks.callGateway).toHaveBeenCalledWith({
1768+
method: "status",
1769+
params: { includeChannelSummary: false },
1770+
timeoutMs: 3000,
1771+
config: ctx.cfg,
1772+
deviceIdentity: null,
1773+
});
1774+
expect(mocks.collectWhatsappResponsivenessHealthFindings).toHaveBeenCalledWith({
1775+
cfg: ctx.cfg,
1776+
status,
1777+
});
1778+
1779+
mocks.callGateway.mockRejectedValueOnce(new Error("gateway unavailable"));
1780+
mocks.collectWhatsappResponsivenessHealthFindings.mockReturnValueOnce([]);
1781+
const error = vi.fn();
1782+
await expect(
1783+
runDoctorLintChecks(
1784+
{
1785+
...ctx,
1786+
runtime: { log: vi.fn(), error, exit: vi.fn() },
1787+
},
1788+
{ checks, onlyIds: ["core/doctor/whatsapp-responsiveness"] },
1789+
),
1790+
).resolves.toMatchObject({
1791+
checksRun: 1,
1792+
checksSkipped: 0,
1793+
findings: [],
1794+
});
1795+
expect(error).not.toHaveBeenCalled();
1796+
expect(mocks.collectWhatsappResponsivenessHealthFindings).toHaveBeenLastCalledWith({
1797+
cfg: ctx.cfg,
1798+
status: undefined,
1799+
});
1800+
});
1801+
1802+
it("skips WhatsApp responsiveness Gateway status probes for exec SecretRefs without allow-exec", async () => {
1803+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1804+
const whatsappCheck = contributionChecks.find(
1805+
(check) => check.id === "core/doctor/whatsapp-responsiveness",
1806+
);
1807+
expect(whatsappCheck).toBeDefined();
1808+
mocks.gatewaySecretInputPathCanWin.mockReturnValue(true);
1809+
mocks.readGatewaySecretInputValue.mockReturnValue("exec-token");
1810+
1811+
const ctx = {
1812+
cfg: { channels: { whatsapp: { enabled: true } } },
1813+
mode: "lint",
1814+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1815+
} as const;
1816+
const checks = [whatsappCheck!];
1817+
1818+
await expect(
1819+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/whatsapp-responsiveness"] }),
1820+
).resolves.toMatchObject({
1821+
checksRun: 1,
1822+
checksSkipped: 0,
1823+
findings: [],
1824+
});
1825+
expect(mocks.callGateway).not.toHaveBeenCalled();
1826+
expect(mocks.collectWhatsappResponsivenessHealthFindings).toHaveBeenCalledWith({
1827+
cfg: ctx.cfg,
1828+
status: undefined,
1829+
});
1830+
});
1831+
16971832
it("keeps device pairing opt-in for default lint selection", async () => {
16981833
const contributionChecks = await resolveDoctorContributionHealthChecks();
16991834
const devicePairingCheck = contributionChecks.find(

0 commit comments

Comments
 (0)