Skip to content

Commit 7acbb22

Browse files
committed
fix(mac): surface stale updater launchd jobs
1 parent 0dc8552 commit 7acbb22

11 files changed

Lines changed: 266 additions & 3 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
3636
- Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors.
3737
- Control UI/WebChat: let sidebar markdown code-block Copy buttons use the same delegated clipboard handler as chat messages. (#58709) Thanks @tikitoki.
3838
- Discord/streaming: only mark partial draft previews delivered after final edit or fallback delivery succeeds, so failed finalization cleanup removes stale truncated drafts instead of leaving them as the visible reply. Fixes #82035. Thanks @compoodment.
39+
- macOS/Gateway: surface leftover `ai.openclaw.update.*` launchd updater jobs in `openclaw gateway status --deep` and doctor so post-update launchd loops point at the stale job cleanup. Fixes #81859. Thanks @BKF-Gitty.
3940
- Config/doctor: rotate capped `.clobbered.*` repair snapshots by artifact timestamp so repeated repairs keep the newest forensic copy instead of preserving only the first capped set. (#82012) Thanks @Kaspre.
4041
- Telegram: initialize the bot before isolated polling drains spooled updates so default isolated polling no longer retries every update with `Bot not initialized` and stalls replies. Fixes #81973. (#81975) Thanks @neeravmakwana.
4142
- Telegram: apply method-aware Bot API request timeouts to direct message/action clients so `openclaw message delete --channel telegram` no longer waits on grammY's 500-second default when the API request wedges. Fixes #81908. Thanks @DashLabsDev.

src/cli/daemon-cli/status.gather.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
2828
fingerprintSha256: "sha256:11:22:33:44",
2929
}));
3030
const findExtraGatewayServices = vi.fn(async (_env?: unknown, _opts?: unknown) => []);
31+
const findStaleOpenClawUpdateLaunchdJobs = vi.fn(async () => []);
3132
const inspectPortUsage = vi.fn(async (port: number) => ({
3233
port,
3334
status: "free" as const,
@@ -139,6 +140,10 @@ vi.mock("../../daemon/inspect.js", () => ({
139140
findExtraGatewayServices: (env: unknown, opts?: unknown) => findExtraGatewayServices(env, opts),
140141
}));
141142

143+
vi.mock("../../daemon/launchd.js", () => ({
144+
findStaleOpenClawUpdateLaunchdJobs: () => findStaleOpenClawUpdateLaunchdJobs(),
145+
}));
146+
142147
vi.mock("../../daemon/service-audit.js", () => ({
143148
auditGatewayServiceConfig: (opts: unknown) => auditGatewayServiceConfig(opts),
144149
}));
@@ -210,6 +215,8 @@ describe("gatherDaemonStatus", () => {
210215
delete process.env.DAEMON_GATEWAY_PASSWORD;
211216
callGatewayStatusProbe.mockClear();
212217
createConfigIOCalls.mockClear();
218+
findStaleOpenClawUpdateLaunchdJobs.mockReset();
219+
findStaleOpenClawUpdateLaunchdJobs.mockResolvedValue([]);
213220
loadGatewayTlsRuntime.mockClear();
214221
inspectGatewayRestart.mockClear();
215222
readGatewayRestartHandoffSync.mockClear();
@@ -440,6 +447,31 @@ describe("gatherDaemonStatus", () => {
440447
expect(status.service.restartHandoff?.supervisorMode).toBe("launchd");
441448
});
442449

450+
it.runIf(process.platform === "darwin")(
451+
"surfaces stale updater launchd jobs only during deep status",
452+
async () => {
453+
findStaleOpenClawUpdateLaunchdJobs.mockResolvedValueOnce([
454+
{
455+
label: "ai.openclaw.update.2026.5.12",
456+
lastExitStatus: 127,
457+
},
458+
]);
459+
460+
const status = await gatherDaemonStatus({
461+
rpc: {},
462+
probe: false,
463+
deep: true,
464+
});
465+
466+
expect(status.service.staleUpdateLaunchdJobs).toEqual([
467+
{
468+
label: "ai.openclaw.update.2026.5.12",
469+
lastExitStatus: 127,
470+
},
471+
]);
472+
},
473+
);
474+
443475
it("does not read restart handoffs during normal status", async () => {
444476
await gatherDaemonStatus({
445477
rpc: {},
@@ -448,6 +480,7 @@ describe("gatherDaemonStatus", () => {
448480
});
449481

450482
expect(readGatewayRestartHandoffSync).not.toHaveBeenCalled();
483+
expect(findStaleOpenClawUpdateLaunchdJobs).not.toHaveBeenCalled();
451484
});
452485

453486
it("uses the fast config path for plain same-file status reads", async () => {

src/cli/daemon-cli/status.gather.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from "../../config/types.js";
1515
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
1616
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
17+
import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
1718
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
1819
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
1920
import { resolveGatewayService } from "../../daemon/service.js";
@@ -97,6 +98,7 @@ const gatewayProbeAuthModuleLoader = createLazyImportLoader(
9798
() => import("../../gateway/probe-auth.js"),
9899
);
99100
const daemonInspectModuleLoader = createLazyImportLoader(() => import("../../daemon/inspect.js"));
101+
const launchdModuleLoader = createLazyImportLoader(() => import("../../daemon/launchd.js"));
100102
const serviceAuditModuleLoader = createLazyImportLoader(
101103
() => import("../../daemon/service-audit.js"),
102104
);
@@ -112,6 +114,10 @@ function loadDaemonInspectModule() {
112114
return daemonInspectModuleLoader.load();
113115
}
114116

117+
function loadLaunchdModule() {
118+
return launchdModuleLoader.load();
119+
}
120+
115121
function loadServiceAuditModule() {
116122
return serviceAuditModuleLoader.load();
117123
}
@@ -268,6 +274,7 @@ export type DaemonStatus = {
268274
runtime?: GatewayServiceRuntime;
269275
configAudit?: ServiceConfigAudit;
270276
restartHandoff?: GatewayRestartHandoff;
277+
staleUpdateLaunchdJobs?: StaleOpenClawUpdateLaunchdJob[];
271278
};
272279
config?: {
273280
cli: ConfigSummary;
@@ -511,6 +518,12 @@ export async function gatherDaemonStatus(
511518
)
512519
.catch(() => [])
513520
: [];
521+
const staleUpdateLaunchdJobs =
522+
opts.deep && process.platform === "darwin"
523+
? await loadLaunchdModule()
524+
.then(({ findStaleOpenClawUpdateLaunchdJobs }) => findStaleOpenClawUpdateLaunchdJobs())
525+
.catch(() => [])
526+
: [];
514527

515528
const timeoutMs =
516529
parseStrictPositiveInteger(opts.rpc.timeout ?? undefined) ??
@@ -595,6 +608,7 @@ export async function gatherDaemonStatus(
595608
runtime,
596609
configAudit,
597610
...(restartHandoff ? { restartHandoff } : {}),
611+
...(staleUpdateLaunchdJobs.length > 0 ? { staleUpdateLaunchdJobs } : {}),
598612
},
599613
config: {
600614
cli: cliConfigSummary,

src/cli/daemon-cli/status.print.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,40 @@ describe("printDaemonStatus", () => {
126126
expectMockLineContains(runtime.error, formatCliCommand("openclaw gateway restart"));
127127
});
128128

129+
it("prints stale updater launchd job guidance", () => {
130+
printDaemonStatus(
131+
{
132+
service: {
133+
label: "LaunchAgent",
134+
loaded: true,
135+
loadedText: "loaded",
136+
notLoadedText: "not loaded",
137+
runtime: { status: "running", pid: 8000 },
138+
staleUpdateLaunchdJobs: [
139+
{
140+
label: "ai.openclaw.update.2026.5.12",
141+
lastExitStatus: 127,
142+
},
143+
],
144+
},
145+
gateway: {
146+
bindMode: "loopback",
147+
bindHost: "127.0.0.1",
148+
port: 18789,
149+
portSource: "env/config",
150+
probeUrl: "ws://127.0.0.1:18789",
151+
},
152+
extraServices: [],
153+
},
154+
{ json: false },
155+
);
156+
157+
expectMockLineContains(runtime.error, "Stale OpenClaw updater launchd job(s) detected.");
158+
expectMockLineContains(runtime.error, "ai.openclaw.update.2026.5.12");
159+
expectMockLineContains(runtime.error, "launchctl remove <label>");
160+
expectMockLineContains(runtime.error, formatCliCommand("openclaw gateway restart"));
161+
});
162+
129163
it("prints probe kind and capability separately", () => {
130164
printDaemonStatus(
131165
{

src/cli/daemon-cli/status.print.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,22 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
341341
spacer();
342342
}
343343

344+
if (service.staleUpdateLaunchdJobs?.length) {
345+
defaultRuntime.error(errorText("Stale OpenClaw updater launchd job(s) detected."));
346+
for (const job of service.staleUpdateLaunchdJobs) {
347+
const exitStatus =
348+
job.lastExitStatus !== undefined ? `, last exit ${job.lastExitStatus}` : "";
349+
const pid = job.pid !== undefined ? `, pid ${job.pid}` : "";
350+
defaultRuntime.error(errorText(`- ${job.label}${pid}${exitStatus}`));
351+
}
352+
defaultRuntime.error(
353+
errorText(
354+
`Fix after confirming no update is running: launchctl remove <label>, then run ${formatCliCommand("openclaw gateway restart")}.`,
355+
),
356+
);
357+
spacer();
358+
}
359+
344360
for (const line of renderPortDiagnosticsForCli(status, rpc?.ok)) {
345361
defaultRuntime.error(errorText(line));
346362
}

src/commands/doctor-platform-notes.launchctl-env-overrides.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
3-
import { noteMacLaunchctlGatewayEnvOverrides } from "./doctor-platform-notes.js";
3+
import {
4+
noteMacLaunchctlGatewayEnvOverrides,
5+
noteMacStaleOpenClawUpdateLaunchdJobs,
6+
} from "./doctor-platform-notes.js";
47

58
function requireNoteCall(noteFn: { mock: { calls: unknown[][] } }, index = 0): unknown[] {
69
const call = noteFn.mock.calls[index];
@@ -91,3 +94,42 @@ describe("noteMacLaunchctlGatewayEnvOverrides", () => {
9194
expect(noteFn).not.toHaveBeenCalled();
9295
});
9396
});
97+
98+
describe("noteMacStaleOpenClawUpdateLaunchdJobs", () => {
99+
it("prints stale updater job cleanup guidance on macOS", async () => {
100+
const noteFn = vi.fn();
101+
const findJobs = vi.fn(async () => [
102+
{
103+
label: "ai.openclaw.update.2026.5.12",
104+
lastExitStatus: 127,
105+
},
106+
]);
107+
108+
await noteMacStaleOpenClawUpdateLaunchdJobs({
109+
platform: "darwin",
110+
findJobs,
111+
noteFn,
112+
});
113+
114+
expect(findJobs).toHaveBeenCalledTimes(1);
115+
const [message, title] = requireNoteCall(noteFn);
116+
expect(title).toBe("Gateway (macOS)");
117+
expect(message).toContain("Stale OpenClaw updater launchd job(s) detected");
118+
expect(message).toContain("ai.openclaw.update.2026.5.12");
119+
expect(message).toContain("launchctl remove <label>");
120+
expect(message).toContain("openclaw gateway restart");
121+
});
122+
123+
it("does nothing when no stale updater jobs exist", async () => {
124+
const noteFn = vi.fn();
125+
const findJobs = vi.fn(async () => []);
126+
127+
await noteMacStaleOpenClawUpdateLaunchdJobs({
128+
platform: "darwin",
129+
findJobs,
130+
noteFn,
131+
});
132+
133+
expect(noteFn).not.toHaveBeenCalled();
134+
});
135+
});

src/commands/doctor-platform-notes.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { promisify } from "node:util";
6+
import { formatCliCommand } from "../cli/command-format.js";
67
import type { OpenClawConfig } from "../config/types.openclaw.js";
78
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
9+
import { findStaleOpenClawUpdateLaunchdJobs } from "../daemon/launchd.js";
810
import { normalizeOptionalString } from "../shared/string-coerce.js";
911
import { note } from "../terminal/note.js";
1012
import { shortenHomePath } from "../utils.js";
@@ -35,6 +37,35 @@ export async function noteMacLaunchAgentOverrides() {
3537
note(lines.join("\n"), "Gateway (macOS)");
3638
}
3739

40+
export async function noteMacStaleOpenClawUpdateLaunchdJobs(deps?: {
41+
platform?: NodeJS.Platform;
42+
findJobs?: typeof findStaleOpenClawUpdateLaunchdJobs;
43+
noteFn?: typeof note;
44+
}) {
45+
const platform = deps?.platform ?? process.platform;
46+
if (platform !== "darwin") {
47+
return;
48+
}
49+
const jobs = await (deps?.findJobs ?? findStaleOpenClawUpdateLaunchdJobs)().catch(() => []);
50+
if (jobs.length === 0) {
51+
return;
52+
}
53+
54+
const lines = [
55+
"- Stale OpenClaw updater launchd job(s) detected.",
56+
...jobs.map((job) => {
57+
const exitStatus =
58+
job.lastExitStatus !== undefined ? `, last exit ${job.lastExitStatus}` : "";
59+
const pid = job.pid !== undefined ? `, pid ${job.pid}` : "";
60+
return `- ${job.label}${pid}${exitStatus}`;
61+
}),
62+
"- Fix after confirming no update is running:",
63+
" launchctl remove <label>",
64+
` ${formatCliCommand("openclaw gateway restart")}`,
65+
];
66+
(deps?.noteFn ?? note)(lines.join("\n"), "Gateway (macOS)");
67+
}
68+
3869
async function launchctlGetenv(name: string): Promise<string | undefined> {
3970
try {
4071
const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" });

src/commands/doctor.fast-path-mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ vi.mock("./doctor-memory-search.js", () => ({
3636
vi.mock("./doctor-platform-notes.js", () => ({
3737
noteStartupOptimizationHints: vi.fn(),
3838
noteMacLaunchAgentOverrides: vi.fn().mockResolvedValue(undefined),
39+
noteMacStaleOpenClawUpdateLaunchdJobs: vi.fn().mockResolvedValue(undefined),
3940
noteMacLaunchctlGatewayEnvOverrides: vi.fn().mockResolvedValue(undefined),
4041
}));
4142

src/daemon/launchd.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
} from "./launchd-plist.js";
99
import {
1010
installLaunchAgent,
11+
findStaleOpenClawUpdateLaunchdJobs,
1112
isLaunchAgentListed,
1213
parseLaunchctlPrint,
14+
parseLaunchctlListOpenClawUpdateJobs,
1315
readLaunchAgentProgramArguments,
1416
readLaunchAgentRuntime,
1517
repairLaunchAgentBootstrap,
@@ -418,6 +420,45 @@ describe("launchctl list detection", () => {
418420
});
419421
expect(listed).toBe(false);
420422
});
423+
424+
it("parses stale OpenClaw updater jobs from launchctl list", () => {
425+
const jobs = parseLaunchctlListOpenClawUpdateJobs(
426+
[
427+
"123 0 ai.openclaw.gateway",
428+
"- 127 ai.openclaw.update.2026.5.12",
429+
"8142 0 ai.openclaw.update.2026.5.13-beta.1",
430+
"- 0 com.example.other",
431+
].join("\n"),
432+
);
433+
434+
expect(jobs).toEqual([
435+
{
436+
label: "ai.openclaw.update.2026.5.12",
437+
lastExitStatus: 127,
438+
},
439+
{
440+
label: "ai.openclaw.update.2026.5.13-beta.1",
441+
pid: 8142,
442+
lastExitStatus: 0,
443+
},
444+
]);
445+
});
446+
447+
it.runIf(process.platform === "darwin")(
448+
"finds stale OpenClaw updater jobs via launchctl list",
449+
async () => {
450+
state.listOutput = "- 127 ai.openclaw.update.2026.5.12\n";
451+
452+
const jobs = await findStaleOpenClawUpdateLaunchdJobs();
453+
454+
expect(jobs).toEqual([
455+
{
456+
label: "ai.openclaw.update.2026.5.12",
457+
lastExitStatus: 127,
458+
},
459+
]);
460+
},
461+
);
421462
});
422463

423464
describe("launchd bootstrap repair", () => {

0 commit comments

Comments
 (0)