diff --git a/CHANGELOG.md b/CHANGELOG.md index 79dd190cd7c..17f561502dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - 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. - 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. - 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. diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 952eb3561e3..eb37d4d0929 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -370,17 +370,22 @@ describe("runDaemonRestart health checks", () => { expect(service.restart).not.toHaveBeenCalled(); }); - it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => { + it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + recoverInstalledLaunchAgent.mockResolvedValue({ + result: "restarted", + loaded: true, + message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", + }); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); mockUnmanagedRestart({ runPostRestartCheck: true }); await runDaemonRestart({ json: true }); - expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1"); - expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled(); - expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1); - expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled(); + expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" }); + expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled(); + expect(waitForGatewayHealthyListener).not.toHaveBeenCalled(); + expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1); }); it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index b4324107f44..e0a9ce80daf 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -201,12 +201,18 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi opts, checkTokenDrift: true, onNotLoaded: async () => { + if (process.platform === "darwin") { + const recovered = await recoverInstalledLaunchAgent({ result: "restarted" }); + if (recovered) { + return recovered; + } + } const handled = await restartGatewayWithoutServiceManager(restartPort); if (handled) { restartedWithoutServiceManager = true; return handled; } - return await recoverInstalledLaunchAgent({ result: "restarted" }); + return null; }, postRestartCheck: async ({ warnings, fail, stdout }) => { if (restartedWithoutServiceManager) { diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index dbdc3940fcb..cd996d3a8fc 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -144,7 +144,7 @@ export function normalizeListenerAddress(raw: string): string { } export function renderRuntimeHints( - runtime: { missingUnit?: boolean; status?: string } | undefined, + runtime: { missingUnit?: boolean; missingSupervision?: boolean; status?: string } | undefined, env: NodeJS.ProcessEnv = process.env, logFile?: string | null, ): string[] { @@ -160,6 +160,15 @@ export function renderRuntimeHints( } return hints; } + if (runtime.missingSupervision) { + hints.push( + `LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`, + ); + if (fileLog) { + hints.push(`File logs: ${fileLog}`); + } + return hints; + } if (runtime.status === "stopped") { if (fileLog) { hints.push(`File logs: ${fileLog}`); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index f29748b53b6..cb37808a363 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -251,6 +251,15 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) for (const hint of renderRuntimeHints(service.runtime, process.env, status.logFile)) { defaultRuntime.error(errorText(hint)); } + } else if (service.runtime?.missingSupervision) { + defaultRuntime.error(errorText("LaunchAgent plist exists but launchd has no loaded job.")); + for (const hint of renderRuntimeHints( + service.runtime, + service.command?.environment ?? process.env, + status.logFile, + )) { + defaultRuntime.error(errorText(hint)); + } } else if (service.loaded && service.runtime?.status === "stopped") { defaultRuntime.error( errorText("Service is loaded but not running (likely exited immediately)."), diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts index 6f7169e77ac..a3dadff07d6 100644 --- a/src/commands/doctor-format.ts +++ b/src/commands/doctor-format.ts @@ -72,6 +72,15 @@ export function buildGatewayRuntimeHints( } return hints; } + if (runtime.missingSupervision && platform === "darwin") { + hints.push( + `LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`, + ); + if (fileLog) { + hints.push(`File logs: ${fileLog}`); + } + return hints; + } if (runtime.status === "stopped") { hints.push("Service is loaded but not running (likely exited immediately)."); if (fileLog) { diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index d8d66f31527..9fae857742d 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -294,7 +294,6 @@ describe("maybeRepairGatewayDaemon", () => { it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => { setPlatform("darwin"); service.isLoaded.mockResolvedValue(false); - vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true); vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false); vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index ff03a068fa9..dade4659c74 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -7,7 +7,6 @@ import { } from "../daemon/constants.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { - isLaunchAgentListed, isLaunchAgentLoaded, launchAgentPlistExists, repairLaunchAgentBootstrap, @@ -49,8 +48,8 @@ async function maybeRepairLaunchAgentBootstrap(params: { return false; } - const listed = await isLaunchAgentListed({ env: params.env }); - if (!listed) { + const plistExists = await launchAgentPlistExists(params.env); + if (!plistExists) { return false; } @@ -59,12 +58,7 @@ async function maybeRepairLaunchAgentBootstrap(params: { return false; } - const plistExists = await launchAgentPlistExists(params.env); - if (!plistExists) { - return false; - } - - note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`); + note("LaunchAgent is installed but not loaded in launchd.", `${params.title} LaunchAgent`); if (params.serviceRepairExternal) { note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`); return false; diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index f834a6972e1..a3999cf12ff 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -187,6 +187,19 @@ describeLaunchdIntegration("launchd integration", () => { await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); }, 60_000); + it("keeps LaunchAgent supervision after a raw SIGTERM", async () => { + const launchEnv = launchEnvOrThrow(env); + try { + await initializeLaunchdRuntime(launchEnv, stdout); + } catch { + return; + } + + const before = await waitForRunningRuntime({ env: launchEnv }); + process.kill(before.pid, "SIGTERM"); + await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); + }, 60_000); + it("stops persistently without reinstall and starts later", async () => { const launchEnv = launchEnvOrThrow(env); try { diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index e0f36f86072..5c2f9d0bd7c 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -8,6 +8,7 @@ import { installLaunchAgent, isLaunchAgentListed, parseLaunchctlPrint, + readLaunchAgentRuntime, repairLaunchAgentBootstrap, restartLaunchAgent, resolveLaunchAgentPlistPath, @@ -349,6 +350,30 @@ describe("launchd runtime parsing", () => { }); }); +describe("launchd runtime state", () => { + it("marks installed plist split-brain when launchd no longer has the job", async () => { + const env = createDefaultLaunchdEnv(); + state.files.set(resolveLaunchAgentPlistPath(env), ""); + state.serviceLoaded = false; + + await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({ + status: "unknown", + missingSupervision: true, + detail: "Could not find service", + }); + }); + + it("marks a missing unit when launchd has no job and no plist exists", async () => { + const env = createDefaultLaunchdEnv(); + state.serviceLoaded = false; + + await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({ + status: "unknown", + missingUnit: true, + }); + }); +}); + describe("launchctl list detection", () => { it("detects the resolved label in launchctl list", async () => { state.listOutput = "123 0 ai.openclaw.gateway\n"; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index f1c12d098b5..a1eeb603bd7 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -305,10 +305,11 @@ export async function readLaunchAgentRuntime( const label = resolveLaunchAgentLabel({ env }); const res = await execLaunchctl(["print", `${domain}/${label}`]); if (res.code !== 0) { + const plistExists = await launchAgentPlistExists(env); return { status: "unknown", detail: (res.stderr || res.stdout).trim() || undefined, - missingUnit: true, + ...(plistExists ? { missingSupervision: true } : { missingUnit: true }), }; } const parsed = parseLaunchctlPrint(res.stdout || res.stderr || ""); diff --git a/src/daemon/service-runtime.ts b/src/daemon/service-runtime.ts index 08fe12cfc3d..bceab991190 100644 --- a/src/daemon/service-runtime.ts +++ b/src/daemon/service-runtime.ts @@ -10,4 +10,5 @@ export type GatewayServiceRuntime = { detail?: string; cachedLabel?: boolean; missingUnit?: boolean; + missingSupervision?: boolean; };