Skip to content

Commit 60d4d5e

Browse files
authored
fix(daemon): reconcile macOS LaunchAgent supervision state (#72616)
1 parent 8c2f894 commit 60d4d5e

12 files changed

Lines changed: 90 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
1516
- Plugins/install: stage bundled plugin runtime dependencies before Gateway startup and drain update restarts while preserving per-plugin isolation when pre-stage scan or install fails. Thanks @codex.
1617
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
1718
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,17 +370,22 @@ describe("runDaemonRestart health checks", () => {
370370
expect(service.restart).not.toHaveBeenCalled();
371371
});
372372

373-
it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => {
373+
it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => {
374374
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
375+
recoverInstalledLaunchAgent.mockResolvedValue({
376+
result: "restarted",
377+
loaded: true,
378+
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
379+
});
375380
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
376381
mockUnmanagedRestart({ runPostRestartCheck: true });
377382

378383
await runDaemonRestart({ json: true });
379384

380-
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
381-
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
382-
expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1);
383-
expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled();
385+
expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" });
386+
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
387+
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
388+
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
384389
});
385390

386391
it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => {

src/cli/daemon-cli/lifecycle.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,18 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
201201
opts,
202202
checkTokenDrift: true,
203203
onNotLoaded: async () => {
204+
if (process.platform === "darwin") {
205+
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
206+
if (recovered) {
207+
return recovered;
208+
}
209+
}
204210
const handled = await restartGatewayWithoutServiceManager(restartPort);
205211
if (handled) {
206212
restartedWithoutServiceManager = true;
207213
return handled;
208214
}
209-
return await recoverInstalledLaunchAgent({ result: "restarted" });
215+
return null;
210216
},
211217
postRestartCheck: async ({ warnings, fail, stdout }) => {
212218
if (restartedWithoutServiceManager) {

src/cli/daemon-cli/shared.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function normalizeListenerAddress(raw: string): string {
144144
}
145145

146146
export function renderRuntimeHints(
147-
runtime: { missingUnit?: boolean; status?: string } | undefined,
147+
runtime: { missingUnit?: boolean; missingSupervision?: boolean; status?: string } | undefined,
148148
env: NodeJS.ProcessEnv = process.env,
149149
logFile?: string | null,
150150
): string[] {
@@ -160,6 +160,15 @@ export function renderRuntimeHints(
160160
}
161161
return hints;
162162
}
163+
if (runtime.missingSupervision) {
164+
hints.push(
165+
`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`,
166+
);
167+
if (fileLog) {
168+
hints.push(`File logs: ${fileLog}`);
169+
}
170+
return hints;
171+
}
163172
if (runtime.status === "stopped") {
164173
if (fileLog) {
165174
hints.push(`File logs: ${fileLog}`);

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
251251
for (const hint of renderRuntimeHints(service.runtime, process.env, status.logFile)) {
252252
defaultRuntime.error(errorText(hint));
253253
}
254+
} else if (service.runtime?.missingSupervision) {
255+
defaultRuntime.error(errorText("LaunchAgent plist exists but launchd has no loaded job."));
256+
for (const hint of renderRuntimeHints(
257+
service.runtime,
258+
service.command?.environment ?? process.env,
259+
status.logFile,
260+
)) {
261+
defaultRuntime.error(errorText(hint));
262+
}
254263
} else if (service.loaded && service.runtime?.status === "stopped") {
255264
defaultRuntime.error(
256265
errorText("Service is loaded but not running (likely exited immediately)."),

src/commands/doctor-format.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ export function buildGatewayRuntimeHints(
7272
}
7373
return hints;
7474
}
75+
if (runtime.missingSupervision && platform === "darwin") {
76+
hints.push(
77+
`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`,
78+
);
79+
if (fileLog) {
80+
hints.push(`File logs: ${fileLog}`);
81+
}
82+
return hints;
83+
}
7584
if (runtime.status === "stopped") {
7685
hints.push("Service is loaded but not running (likely exited immediately).");
7786
if (fileLog) {

src/commands/doctor-gateway-daemon-flow.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,6 @@ describe("maybeRepairGatewayDaemon", () => {
294294
it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => {
295295
setPlatform("darwin");
296296
service.isLoaded.mockResolvedValue(false);
297-
vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true);
298297
vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false);
299298
vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true);
300299

src/commands/doctor-gateway-daemon-flow.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from "../daemon/constants.js";
88
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
99
import {
10-
isLaunchAgentListed,
1110
isLaunchAgentLoaded,
1211
launchAgentPlistExists,
1312
repairLaunchAgentBootstrap,
@@ -49,8 +48,8 @@ async function maybeRepairLaunchAgentBootstrap(params: {
4948
return false;
5049
}
5150

52-
const listed = await isLaunchAgentListed({ env: params.env });
53-
if (!listed) {
51+
const plistExists = await launchAgentPlistExists(params.env);
52+
if (!plistExists) {
5453
return false;
5554
}
5655

@@ -59,12 +58,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
5958
return false;
6059
}
6160

62-
const plistExists = await launchAgentPlistExists(params.env);
63-
if (!plistExists) {
64-
return false;
65-
}
66-
67-
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
61+
note("LaunchAgent is installed but not loaded in launchd.", `${params.title} LaunchAgent`);
6862
if (params.serviceRepairExternal) {
6963
note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`);
7064
return false;

src/daemon/launchd.integration.e2e.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,19 @@ describeLaunchdIntegration("launchd integration", () => {
187187
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
188188
}, 60_000);
189189

190+
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
191+
const launchEnv = launchEnvOrThrow(env);
192+
try {
193+
await initializeLaunchdRuntime(launchEnv, stdout);
194+
} catch {
195+
return;
196+
}
197+
198+
const before = await waitForRunningRuntime({ env: launchEnv });
199+
process.kill(before.pid, "SIGTERM");
200+
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
201+
}, 60_000);
202+
190203
it("stops persistently without reinstall and starts later", async () => {
191204
const launchEnv = launchEnvOrThrow(env);
192205
try {

src/daemon/launchd.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
installLaunchAgent,
99
isLaunchAgentListed,
1010
parseLaunchctlPrint,
11+
readLaunchAgentRuntime,
1112
repairLaunchAgentBootstrap,
1213
restartLaunchAgent,
1314
resolveLaunchAgentPlistPath,
@@ -349,6 +350,30 @@ describe("launchd runtime parsing", () => {
349350
});
350351
});
351352

353+
describe("launchd runtime state", () => {
354+
it("marks installed plist split-brain when launchd no longer has the job", async () => {
355+
const env = createDefaultLaunchdEnv();
356+
state.files.set(resolveLaunchAgentPlistPath(env), "<plist/>");
357+
state.serviceLoaded = false;
358+
359+
await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({
360+
status: "unknown",
361+
missingSupervision: true,
362+
detail: "Could not find service",
363+
});
364+
});
365+
366+
it("marks a missing unit when launchd has no job and no plist exists", async () => {
367+
const env = createDefaultLaunchdEnv();
368+
state.serviceLoaded = false;
369+
370+
await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({
371+
status: "unknown",
372+
missingUnit: true,
373+
});
374+
});
375+
});
376+
352377
describe("launchctl list detection", () => {
353378
it("detects the resolved label in launchctl list", async () => {
354379
state.listOutput = "123 0 ai.openclaw.gateway\n";

0 commit comments

Comments
 (0)