From 30990d73b2e66e751fcbbcdb4d3f5b2c94cbc21d Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Sat, 7 Mar 2026 14:32:59 -0800 Subject: [PATCH] fix(daemon): enable LaunchAgent before bootstrap on restart restartLaunchAgent was missing the launchctl enable call that installLaunchAgent already performs. launchd can persist a "disabled" state after bootout, causing bootstrap to silently fail and leaving the gateway unloaded until a manual reinstall. Fixes #39211 Co-Authored-By: Claude Opus 4.6 --- src/daemon/launchd.test.ts | 14 ++++++++++---- src/daemon/launchd.ts | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3030b6ffc4a..4cd1f09afeb 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -241,7 +241,7 @@ describe("launchd install", () => { expect(plist).toContain(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); - it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => { + it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => { const env = createDefaultLaunchdEnv(); await restartLaunchAgent({ env, @@ -251,20 +251,26 @@ describe("launchd install", () => { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); + const serviceId = `${domain}/${label}`; const bootoutIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "bootout" && c[1] === `${domain}/${label}`, + (c) => c[0] === "bootout" && c[1] === serviceId, + ); + const enableIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "enable" && c[1] === serviceId, ); const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); const kickstartIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === `${domain}/${label}`, + (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, ); expect(bootoutIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(kickstartIndex).toBeGreaterThanOrEqual(0); - expect(bootoutIndex).toBeLessThan(bootstrapIndex); + expect(bootoutIndex).toBeLessThan(enableIndex); + expect(enableIndex).toBeLessThan(bootstrapIndex); expect(bootstrapIndex).toBeLessThan(kickstartIndex); }); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 5b62fad9805..b017a14a495 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -466,6 +466,9 @@ export async function restartLaunchAgent({ await waitForPidExit(previousPid); } + // launchd can persist "disabled" state after bootout; clear it before bootstrap + // (matches the same guard in installLaunchAgent). + await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim();